improvement(execution): memory usage for aggregated results (#4650)

* improvement(execution): memory usage for aggregated results

* progress

* address comments

* loop/parallel results compaction

* address comment

* remove build files, harden edge cases

* remove hotpath serialiazation

* display change to make use of only preview

* materialize refs before sending in response block

* preserve exact large-value access through workflow materialization

* address comments

* progress

* fix notif error + sync manifest undefined exit

* fix streaming ref materialization

* fix tests
This commit is contained in:
Vikhyath Mondreti
2026-05-18 23:55:06 -07:00
committed by GitHub
parent 6827be7f43
commit 279010a2e7
78 changed files with 5586 additions and 530 deletions
@@ -198,7 +198,7 @@ const file = <readfile.file>;
const base64 = await sim.files.readBase64(file);
```
`sim.files.readBase64(file)`, `sim.files.readText(file)`, `sim.files.readBase64Chunk(file, { offset, length })`, and `sim.files.readTextChunk(file, { offset, length })` read from server-side execution storage under memory caps. `sim.values.read(ref)` can explicitly read a large execution value reference. These helpers are available only in JavaScript functions without imports. JavaScript with imports, Python, and shell do not support these lazy helpers yet.
`sim.files.readBase64(file)`, `sim.files.readText(file)`, `sim.files.readBase64Chunk(file, { offset, length })`, and `sim.files.readTextChunk(file, { offset, length })` read from server-side execution storage under memory caps. `sim.values.read(ref)` explicitly reads a large execution value reference, and `sim.values.readArray(ref)` reads a manifest-backed large array. These helpers are available only in JavaScript functions without imports. JavaScript with imports, Python, and shell do not support these lazy helpers yet.
Very large full reads can still fail by design; use chunk helpers or return a file when you need to handle more data.
@@ -228,7 +228,7 @@ return { name: file.name, chunk: firstMegabyteBase64 };
Chunk `offset` and `length` are byte-based. For Unicode text, a chunk can split a multi-byte character at the boundary; use text chunks for approximate text processing and prefer smaller structured references when exact parsing matters.
Avoid passing a full large object into a Function block when you only need one field. For example, prefer `<api.data.customerId>` over `<api.data>` when the API response is large. If a JavaScript Function without imports references a large execution value, Sim automatically reads it through `sim.values.read(...)` at runtime under memory caps.
Avoid passing a full large object into a Function block when you only need one field. For example, prefer `<api.data.customerId>` over `<api.data>` when the API response is large. If a JavaScript Function without imports references a whole large execution value, Sim automatically rewrites it to `sim.values.read(...)` at runtime under memory caps. If the value is a manifest-backed array, Sim rewrites it to `sim.values.readArray(...)` so array variables can stay compact between blocks.
For large generated data, write the result to a file or table with `outputPath`, `outputSandboxPath`, or `outputTable` instead of returning the entire payload inline.
@@ -232,7 +232,7 @@ Workflow execution responses are capped by platform request and response limits.
}
```
The `version` field is part of the external API contract. Treat the reference as an opaque placeholder for a value that could not be safely embedded in the response. `id`, `key`, and `executionId` are not fetch URLs; `key` points to execution-scoped server storage. Use `selectedOutputs` to request a smaller nested field, reduce the data passed between blocks, or return the data from a Response block when your workflow intentionally owns the HTTP response body. File outputs are metadata-first; request `.base64` only when you need inline file content. JavaScript Function blocks can explicitly read large files or value refs with the `sim.files` and `sim.values` helpers under memory caps.
The `version` field is part of the external API contract. Treat the reference as an opaque placeholder for a value that could not be safely embedded in the response. `id`, `key`, and `executionId` are not fetch URLs; `key` points to execution-scoped server storage. Use `selectedOutputs` to request a smaller nested field, reduce the data passed between blocks, or return the data from a Response block when your workflow intentionally owns the HTTP response body. File outputs are metadata-first; request `.base64` only when you need inline file content. JavaScript Function blocks can explicitly read large files, value refs, and manifest-backed arrays with the `sim.files` and `sim.values` helpers under memory caps.
### Asynchronous
+135 -1
View File
@@ -12,9 +12,10 @@ import {
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockExecuteInE2B, mockExecuteInIsolatedVM } = vi.hoisted(() => ({
const { mockExecuteInE2B, mockExecuteInIsolatedVM, mockUploadFile } = vi.hoisted(() => ({
mockExecuteInE2B: vi.fn(),
mockExecuteInIsolatedVM: vi.fn(),
mockUploadFile: vi.fn(),
}))
vi.mock('@/lib/execution/isolated-vm', () => ({
@@ -42,16 +43,26 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
uploadWorkspaceFile: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
uploadFile: mockUploadFile,
},
}))
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock)
import { validateProxyUrl } from '@/lib/core/security/input-validation'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import { POST } from '@/app/api/function/execute/route'
describe('Function Execute API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
featureFlagsMock.isE2bEnabled = false
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
success: true,
@@ -60,6 +71,8 @@ describe('Function Execute API Route', () => {
})
mockExecuteInIsolatedVM.mockResolvedValue({ result: 'test', stdout: '' })
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
clearLargeValueCacheForTests()
mockExecuteInE2B.mockResolvedValue({
result: 'e2b success',
@@ -201,6 +214,60 @@ describe('Function Execute API Route', () => {
expect(data.output).toHaveProperty('executionTime')
})
it('compacts large array result fields to manifests when execution context is durable', async () => {
mockExecuteInIsolatedVM.mockResolvedValueOnce({
result: {
rows: Array.from({ length: 120_000 }, (_, index) => ({
key: `SIM-${index}`,
payload: 'x'.repeat(100),
})),
},
stdout: '',
})
const req = createMockRequest('POST', {
code: 'return rows',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(isLargeArrayManifest(data.output.result.rows)).toBe(true)
expect(data.output.result.rows).toMatchObject({
__simLargeArrayManifest: true,
kind: 'array',
totalCount: 120_000,
})
})
it('keeps large string result fields as generic large value refs', async () => {
mockExecuteInIsolatedVM.mockResolvedValueOnce({
result: {
text: 'x'.repeat(9 * 1024 * 1024),
},
stdout: '',
})
const req = createMockRequest('POST', {
code: 'return text',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(isLargeValueRef(data.output.result.text)).toBe(true)
})
it('should return computed result for multi-line code', async () => {
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 10, stdout: '' })
@@ -240,6 +307,73 @@ describe('Function Execute API Route', () => {
expect(response.status).toBe(200)
expect(data.success).toBe(true)
})
it('rejects large refs in runtimes without ref-native helpers', async () => {
featureFlagsMock.isE2bEnabled = true
const req = createMockRequest('POST', {
code: 'echo "$__blockRef_0"',
language: 'shell',
contextVariables: {
__blockRef_0: {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 12 * 1024 * 1024,
executionId: 'execution-1',
},
},
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.success).toBe(false)
expect(data.error).toContain(
'Large execution values require the JavaScript isolated-vm runtime'
)
})
it('registers manifest array read broker for isolated-vm execution', async () => {
const req = createMockRequest('POST', {
code: 'return await sim.values.readArray(__blockRef_0)',
language: 'javascript',
contextVariables: {
__blockRef_0: {
__simLargeArrayManifest: true,
version: 2,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: 16,
chunks: [
{
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 16,
executionId: 'execution-1',
},
count: 1,
byteSize: 16,
},
],
preview: [{ id: 1 }],
},
},
})
const response = await POST(req)
const data = await response.json()
const [, options] = mockExecuteInIsolatedVM.mock.calls.at(-1) ?? []
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(options?.brokers).toHaveProperty('sim.values.readArray')
})
})
describe('Template Variable Resolution', () => {
+62 -2
View File
@@ -14,7 +14,12 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { executeInE2B, executeShellInE2B } from '@/lib/execution/e2b'
import { executeInIsolatedVM, type IsolatedVMBrokerHandler } from '@/lib/execution/isolated-vm'
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys'
import {
isLargeArrayManifest,
materializeLargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest'
import { containsLargeValueRef, isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import {
MAX_FUNCTION_INLINE_BYTES,
MAX_INLINE_MATERIALIZATION_BYTES,
@@ -699,6 +704,8 @@ interface FunctionRouteExecutionContext {
workspaceId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
requestId: string
@@ -741,17 +748,26 @@ function getBrokerFileArgs(args: unknown): {
function createFunctionRuntimeBrokers(
context: FunctionRouteExecutionContext
): Record<string, IsolatedVMBrokerHandler> {
context.largeValueKeys ??= []
context.fileKeys ??= []
const largeValueKeys = context.largeValueKeys
const fileKeys = context.fileKeys
const base = {
requestId: context.requestId,
workflowId: context.workflowId,
workspaceId: context.workspaceId,
executionId: context.executionId,
largeValueExecutionIds: context.largeValueExecutionIds,
largeValueKeys,
fileKeys,
allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope,
userId: context.userId,
logger,
}
const recordMaterializedKeys = (value: unknown) =>
recordMaterializedAccessKeys({ largeValueKeys, fileKeys }, value)
const readFile = async (args: unknown, encoding: 'base64' | 'text', chunked = false) => {
const fileArgs = getBrokerFileArgs(args)
return readUserFileContent(fileArgs.file, {
@@ -786,6 +802,24 @@ function createFunctionRuntimeBrokers(
if (value === undefined) {
throw unavailableLargeValueError(ref)
}
recordMaterializedKeys(value)
return value
},
'sim.values.readArray': async (args) => {
const record = asRecord(args)
const options = asRecord(record.options)
const manifest = record.ref
if (!isLargeArrayManifest(manifest)) {
throw new Error('Expected a large array manifest.')
}
if (!context.executionId) {
throw new Error('Large array manifests require an execution context.')
}
const value = await materializeLargeArrayManifest(manifest, {
...base,
maxBytes: clampInlineBytes(options.maxBytes, MAX_INLINE_MATERIALIZATION_BYTES),
})
recordMaterializedKeys(value)
return value
},
}
@@ -810,7 +844,17 @@ async function functionJsonResponse<T>(
context: FunctionRouteExecutionContext,
init?: ResponseInit
) {
return NextResponse.json(await compactFunctionRouteBody(body, context), init)
return NextResponse.json(
await compactFunctionRouteBody(
{
...body,
largeValueKeys: context.largeValueKeys,
fileKeys: context.fileKeys,
},
context
),
init
)
}
async function maybeExportSandboxFileToWorkspace(args: {
@@ -955,6 +999,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
workflowId,
executionId,
largeValueExecutionIds,
largeValueKeys,
fileKeys,
allowLargeValueWorkflowScope = false,
workspaceId,
isCustomTool = false,
@@ -979,6 +1025,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
workspaceId,
executionId,
largeValueExecutionIds,
largeValueKeys,
fileKeys,
allowLargeValueWorkflowScope,
userId: auth.userId,
requestId,
@@ -1013,6 +1061,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
contextVariables = { ...codeResolution.contextVariables, ...preResolvedContextVariables }
}
if (lang === CodeLanguage.Shell && containsLargeValueRef(contextVariables)) {
throw new Error(
'Large execution values require the JavaScript isolated-vm runtime. Select a nested field or read the value in a JavaScript function.'
)
}
let jsImports = ''
let jsRemainingCode = resolvedCode
let hasImports = false
@@ -1124,6 +1178,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
!isCustomTool &&
(lang === CodeLanguage.Python || (lang === CodeLanguage.JavaScript && hasImports))
if (useE2B && containsLargeValueRef(contextVariables)) {
throw new Error(
'Large execution values require the JavaScript isolated-vm runtime. Remove imports, select a nested field, or read the value in a JavaScript function without E2B.'
)
}
if (useE2B) {
logger.info(`[${requestId}] E2B status`, {
enabled: isE2bEnabled,
@@ -5,11 +5,36 @@
* @vitest-environment node
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthType } from '@/lib/auth/hybrid'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import { createLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest'
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import { storeLargeValue } from '@/lib/execution/payloads/store'
import { EXECUTION_RESOURCE_LIMIT_CODE } from '@/lib/execution/resource-errors'
import type { ExecutionResult } from '@/lib/workflows/types'
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
const { mockDownloadFile, mockUploadFile, uploadedFiles } = vi.hoisted(() => ({
mockDownloadFile: vi.fn(),
mockUploadFile: vi.fn(),
uploadedFiles: new Map<string, Buffer>(),
}))
const MATERIALIZATION_CONTEXT = {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
}
vi.mock('@/lib/uploads', () => ({
StorageService: {
downloadFile: mockDownloadFile,
uploadFile: mockUploadFile,
},
}))
function buildExecutionResult(overrides: Partial<ExecutionResult> = {}): ExecutionResult {
return {
success: true,
@@ -38,6 +63,16 @@ describe('Response block gating by auth type', () => {
let resultWithResponseBlock: ExecutionResult
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
uploadedFiles.clear()
mockUploadFile.mockImplementation(async ({ customKey, file }) => {
uploadedFiles.set(customKey, file)
return { key: customKey }
})
mockDownloadFile.mockImplementation(
async ({ key }) => uploadedFiles.get(key) ?? Buffer.from('{}')
)
resultWithResponseBlock = buildExecutionResult()
})
@@ -75,14 +110,14 @@ describe('Response block gating by auth type', () => {
expect(shouldFormatAsResponseBlock).toBe(false)
})
it('should apply Response block formatting for API key callers', () => {
it('should apply Response block formatting for API key callers', async () => {
const authType = AuthType.API_KEY
const hasResponseBlock = workflowHasResponseBlock(resultWithResponseBlock)
const shouldFormatAsResponseBlock = authType !== AuthType.INTERNAL_JWT && hasResponseBlock
expect(shouldFormatAsResponseBlock).toBe(true)
const response = createHttpResponseFromBlock(resultWithResponseBlock)
const response = await createHttpResponseFromBlock(resultWithResponseBlock)
expect(response.status).toBe(200)
})
@@ -95,7 +130,7 @@ describe('Response block gating by auth type', () => {
})
it('should return raw user data via createHttpResponseFromBlock', async () => {
const response = createHttpResponseFromBlock(resultWithResponseBlock)
const response = await createHttpResponseFromBlock(resultWithResponseBlock)
const body = await response.json()
// Response block returns the user-defined data directly (no success/executionId wrapper)
@@ -104,12 +139,293 @@ describe('Response block gating by auth type', () => {
expect(body.executionId).toBeUndefined()
})
it('should respect custom status codes from Response block', () => {
it('should respect custom status codes from Response block', async () => {
const result = buildExecutionResult({
output: { data: { error: 'Not found' }, status: 404, headers: {} },
})
const response = createHttpResponseFromBlock(result)
const response = await createHttpResponseFromBlock(result)
expect(response.status).toBe(404)
})
it('should materialize manifest data for Response block HTTP output', async () => {
const rows = Array.from({ length: 100 }, (_, index) => ({
key: `SIM-${index}`,
payload: 'x'.repeat(100),
}))
const output = await compactExecutionPayload(
{
data: { rows },
status: 200,
headers: {},
},
{
...MATERIALIZATION_CONTEXT,
requireDurable: true,
preserveRoot: true,
thresholdBytes: 1024,
}
)
const response = await createHttpResponseFromBlock(
buildExecutionResult({ output }),
MATERIALIZATION_CONTEXT
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.rows).toEqual(rows)
expect(body.success).toBeUndefined()
})
it('should materialize Response block manifests from an allowed source execution', async () => {
const rows = [{ key: 'SIM-1' }, { key: 'SIM-2' }]
const manifest = await createLargeArrayManifest(rows, {
...MATERIALIZATION_CONTEXT,
executionId: 'source-execution-1',
})
const response = await createHttpResponseFromBlock(
buildExecutionResult({
output: {
data: { rows: manifest },
status: 200,
headers: {},
},
}),
{
...MATERIALIZATION_CONTEXT,
largeValueExecutionIds: ['source-execution-1'],
}
)
const body = await response.json()
expect(body.rows).toEqual(rows)
})
it('should reject Response block manifests from non-source same-workflow executions', async () => {
const manifest = await createLargeArrayManifest([{ key: 'SIM-stale' }], {
...MATERIALIZATION_CONTEXT,
executionId: 'stale-execution-1',
})
await expect(
createHttpResponseFromBlock(
buildExecutionResult({
output: {
data: { rows: manifest },
status: 200,
headers: {},
},
}),
{
...MATERIALIZATION_CONTEXT,
largeValueExecutionIds: ['source-execution-1'],
}
)
).rejects.toThrow('Large execution value is not available in this execution')
})
it('should materialize Response block manifests inherited by the source snapshot', async () => {
const rows = [{ key: 'SIM-inherited' }]
const manifest = await createLargeArrayManifest(rows, {
...MATERIALIZATION_CONTEXT,
executionId: 'original-execution-1',
})
const response = await createHttpResponseFromBlock(
buildExecutionResult({
output: {
data: { rows: manifest },
status: 200,
headers: {},
},
}),
{
...MATERIALIZATION_CONTEXT,
largeValueExecutionIds: ['source-execution-1', 'original-execution-1'],
}
)
const body = await response.json()
expect(body.rows).toEqual(rows)
})
it('should recursively materialize refs inside Response block manifest rows', async () => {
const text = 'nested'.repeat(2 * 1024 * 1024)
const nestedOutput = await compactExecutionPayload(
{ text },
{
...MATERIALIZATION_CONTEXT,
executionId: 'original-execution-1',
requireDurable: true,
preserveRoot: true,
}
)
const nestedRef = (nestedOutput as unknown as { text: unknown }).text
const manifest = await createLargeArrayManifest([{ nested: nestedRef }], {
...MATERIALIZATION_CONTEXT,
executionId: 'source-execution-1',
})
const response = await createHttpResponseFromBlock(
buildExecutionResult({
output: {
data: { rows: manifest },
status: 200,
headers: {},
},
}),
{
...MATERIALIZATION_CONTEXT,
largeValueExecutionIds: ['source-execution-1'],
}
)
const body = await response.json()
expect(body.rows).toEqual([{ nested: text }])
})
it('should recursively materialize refs inside stored Response block objects', async () => {
const text = 'nested'.repeat(2 * 1024 * 1024)
const nestedOutput = await compactExecutionPayload(
{ text },
{
...MATERIALIZATION_CONTEXT,
executionId: 'original-execution-1',
requireDurable: true,
preserveRoot: true,
}
)
const nestedRef = (nestedOutput as unknown as { text: unknown }).text
const storedValue = {
wrapper: {
nested: nestedRef,
padding: 'x'.repeat(2048),
},
}
const storedJson = JSON.stringify(storedValue)
const storedOutput = await storeLargeValue(
storedValue,
storedJson,
Buffer.byteLength(storedJson),
{
...MATERIALIZATION_CONTEXT,
executionId: 'source-execution-1',
requireDurable: true,
}
)
const response = await createHttpResponseFromBlock(
buildExecutionResult({
output: {
data: storedOutput,
status: 200,
headers: {},
},
}),
{
...MATERIALIZATION_CONTEXT,
largeValueExecutionIds: ['source-execution-1'],
}
)
const body = await response.json()
expect(body.wrapper.nested).toEqual(text)
})
it('should memoize repeated materialized objects while resolving nested refs', async () => {
const text = 'nested'.repeat(2 * 1024 * 1024)
const nestedOutput = await compactExecutionPayload(
{ text },
{
...MATERIALIZATION_CONTEXT,
executionId: 'original-execution-1',
requireDurable: true,
preserveRoot: true,
}
)
const nestedRef = (nestedOutput as unknown as { text: unknown }).text
const sourceValue = { nested: nestedRef }
const sourceJson = JSON.stringify(sourceValue)
const sourceRef = await storeLargeValue(
sourceValue,
sourceJson,
Buffer.byteLength(sourceJson),
{
...MATERIALIZATION_CONTEXT,
executionId: 'source-execution-1',
requireDurable: true,
}
)
const response = await createHttpResponseFromBlock(
buildExecutionResult({
output: {
data: { first: sourceRef, second: sourceRef },
status: 200,
headers: {},
},
}),
{
...MATERIALIZATION_CONTEXT,
largeValueKeys: sourceRef.key ? [sourceRef.key] : [],
}
)
const body = await response.json()
expect(body).toEqual({
first: { nested: text },
second: { nested: text },
})
})
it('should materialize large string refs for Response block HTTP output', async () => {
const text = 'x'.repeat(9 * 1024 * 1024)
const output = await compactExecutionPayload(
{
data: { text },
status: 200,
headers: {},
},
{
...MATERIALIZATION_CONTEXT,
requireDurable: true,
preserveRoot: true,
}
)
const response = await createHttpResponseFromBlock(
buildExecutionResult({ output }),
MATERIALIZATION_CONTEXT
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.text).toBe(text)
})
it('should reject Response block HTTP output that is too large to inline', async () => {
const output = await compactExecutionPayload(
{
data: {
text: 'x'.repeat(17 * 1024 * 1024),
},
status: 200,
headers: {},
},
{
...MATERIALIZATION_CONTEXT,
requireDurable: true,
preserveRoot: true,
}
)
await expect(
createHttpResponseFromBlock(buildExecutionResult({ output }), MATERIALIZATION_CONTEXT)
).rejects.toMatchObject({
code: EXECUTION_RESOURCE_LIMIT_CODE,
})
})
})
@@ -399,7 +399,11 @@ async function handleExecutePost(
// Resolve runFromBlock snapshot from executionId if needed
let resolvedRunFromBlock:
| { startBlockId: string; sourceSnapshot: SerializableExecutionState }
| {
startBlockId: string
sourceSnapshot: SerializableExecutionState
sourceExecutionId?: string
}
| undefined
if (rawRunFromBlock) {
if (rawRunFromBlock.sourceSnapshot && auth.authType === 'api_key') {
@@ -424,13 +428,16 @@ async function handleExecutePost(
sourceSnapshot: rawRunFromBlock.sourceSnapshot as SerializableExecutionState,
}
} else if (rawRunFromBlock.executionId) {
const { getExecutionStateForWorkflow, getLatestExecutionState } = await import(
'@/lib/workflows/executor/execution-state'
)
const snapshot =
const { getExecutionStateForWorkflow, getLatestExecutionStateWithExecutionId } =
await import('@/lib/workflows/executor/execution-state')
const sourceExecution =
rawRunFromBlock.executionId === 'latest'
? await getLatestExecutionState(workflowId)
: await getExecutionStateForWorkflow(rawRunFromBlock.executionId, workflowId)
? await getLatestExecutionStateWithExecutionId(workflowId)
: {
executionId: rawRunFromBlock.executionId,
state: await getExecutionStateForWorkflow(rawRunFromBlock.executionId, workflowId),
}
const snapshot = sourceExecution?.state
if (!snapshot) {
return NextResponse.json(
{
@@ -442,6 +449,7 @@ async function handleExecutePost(
resolvedRunFromBlock = {
startBlockId: rawRunFromBlock.startBlockId,
sourceSnapshot: snapshot,
sourceExecutionId: sourceExecution.executionId,
}
} else {
return NextResponse.json(
@@ -687,6 +695,12 @@ async function handleExecutePost(
const effectiveWorkflowStateOverride =
sanitizedWorkflowStateOverride || cachedWorkflowData || undefined
const largeValueExecutionIds = [executionId]
const largeValueKeys: string[] = []
const fileKeys: string[] = []
const allowLargeValueWorkflowScope = Boolean(
resolvedRunFromBlock?.sourceSnapshot && !resolvedRunFromBlock.sourceExecutionId
)
if (!enableSSE) {
reqLogger.info('Using non-SSE execution (direct JSON response)')
@@ -705,6 +719,10 @@ async function handleExecutePost(
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
largeValueExecutionIds,
largeValueKeys,
fileKeys,
allowLargeValueWorkflowScope,
callChain,
executionMode: 'sync',
}
@@ -773,20 +791,47 @@ async function handleExecutePost(
)
}
const outputLargeValueKeys = result.metadata?.largeValueKeys ?? largeValueKeys
const outputFileKeys = result.metadata?.fileKeys ?? fileKeys
const outputWithBase64 = includeFileBase64
? ((await hydrateUserFilesWithBase64(result.output, {
requestId,
workspaceId,
workflowId,
executionId,
allowLargeValueWorkflowScope: Boolean(resolvedRunFromBlock?.sourceSnapshot),
largeValueExecutionIds,
largeValueKeys: outputLargeValueKeys,
fileKeys: outputFileKeys,
allowLargeValueWorkflowScope,
userId: actorUserId,
maxBytes: base64MaxBytes,
preserveLargeValueMetadata: true,
})) as NormalizedBlockOutput)
: result.output
if (auth.authType !== AuthType.INTERNAL_JWT && workflowHasResponseBlock(result)) {
return createHttpResponseFromBlock({ ...result, output: outputWithBase64 })
const compactResponseBlockOutput = await compactRoutePayload(outputWithBase64, {
workspaceId,
workflowId,
executionId,
userId: actorUserId,
preserveUserFileBase64: true,
preserveRoot: true,
})
return await createHttpResponseFromBlock(
{ ...result, output: compactResponseBlockOutput },
{
workspaceId,
workflowId,
executionId,
largeValueExecutionIds,
largeValueKeys: outputLargeValueKeys,
fileKeys: outputFileKeys,
userId: actorUserId,
allowLargeValueWorkflowScope,
}
)
}
const compactOutput = await compactRoutePayload(outputWithBase64, {
@@ -884,10 +929,13 @@ async function handleExecutePost(
timeoutMs: preprocessResult.executionTimeout?.sync,
},
executionId,
largeValueExecutionIds,
largeValueKeys,
fileKeys,
workspaceId,
workflowId,
userId: actorUserId,
allowLargeValueWorkflowScope: Boolean(resolvedRunFromBlock?.sourceSnapshot),
allowLargeValueWorkflowScope,
executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
executeWorkflow(
streamWorkflow,
@@ -906,6 +954,8 @@ async function handleExecutePost(
base64MaxBytes,
abortSignal,
executionMode: 'stream',
largeValueKeys,
fileKeys,
stopAfterBlockId,
runFromBlock: resolvedRunFromBlock,
},
@@ -1185,6 +1235,10 @@ async function handleExecutePost(
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
largeValueExecutionIds,
largeValueKeys,
fileKeys,
allowLargeValueWorkflowScope,
callChain,
executionMode: 'sync',
}
@@ -1309,15 +1363,22 @@ async function handleExecutePost(
return
}
const outputLargeValueKeys = result.metadata?.largeValueKeys ?? largeValueKeys
const outputFileKeys = result.metadata?.fileKeys ?? fileKeys
const sseOutput = includeFileBase64
? await hydrateUserFilesWithBase64(result.output, {
requestId,
workspaceId,
workflowId,
executionId,
allowLargeValueWorkflowScope: Boolean(resolvedRunFromBlock?.sourceSnapshot),
largeValueExecutionIds,
largeValueKeys: outputLargeValueKeys,
fileKeys: outputFileKeys,
allowLargeValueWorkflowScope,
userId: actorUserId,
maxBytes: base64MaxBytes,
preserveLargeValueMetadata: true,
})
: result.output
const compactSseOutput = await compactRoutePayload(sseOutput, {
@@ -15,6 +15,10 @@ import { List, type RowComponentProps, useListRef } from 'react-window'
import { Badge, ChevronDown } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { isUserFileDisplayMetadata } from '@/lib/core/utils/user-file'
import {
isLargeArrayManifest,
type LargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest-metadata'
import { isLargeValueRef, type LargeValueRef } from '@/lib/execution/payloads/large-value-ref'
type ValueType = 'null' | 'undefined' | 'array' | 'string' | 'number' | 'boolean' | 'object'
@@ -86,8 +90,27 @@ function getLargeValueDisplayValue(ref: LargeValueRef): unknown {
return ref.preview ?? `[Large value: ${formatLargeValueSize(ref.size)}]`
}
function getLargeArrayManifestDisplayValue(manifest: LargeArrayManifest): unknown[] {
const preview = manifest.preview
if (manifest.totalCount <= preview.length) {
return preview
}
const remainingCount = manifest.totalCount - preview.length
return [
...preview,
`[... ${remainingCount.toLocaleString()} more item${remainingCount === 1 ? '' : 's'}]`,
]
}
function getDisplayValue(value: unknown): unknown {
return isLargeValueRef(value) ? getLargeValueDisplayValue(value) : value
if (isLargeValueRef(value)) {
return getLargeValueDisplayValue(value)
}
if (isLargeArrayManifest(value)) {
return getLargeArrayManifestDisplayValue(value)
}
return value
}
function getTypeLabel(value: unknown): ValueType {
@@ -302,6 +302,8 @@ export function createBlockEventHandlers(
updateConsole(
data.blockId,
{
blockName: data.blockName,
blockType: data.blockType,
executionOrder: data.executionOrder,
input: data.input || {},
replaceOutput: data.output,
@@ -320,6 +322,8 @@ export function createBlockEventHandlers(
updateConsole(
data.blockId,
{
blockName: data.blockName,
blockType: data.blockType,
executionOrder: data.executionOrder,
input: data.input || {},
replaceOutput: {},
@@ -493,6 +497,8 @@ export function reconcileFinalBlockLogs(
log.blockId,
{
executionOrder: log.executionOrder,
blockName: log.blockName,
blockType: log.blockType,
replaceOutput: (log.output ?? {}) as Record<string, unknown>,
...(log.input ? { input: log.input } : {}),
success: log.success,
@@ -576,6 +582,8 @@ function spanConsoleIdentity(span: TraceSpan, childWorkflowInstanceId: string):
const iterationContainerId = span.loopId ?? span.parallelId
const iterationType = span.loopId ? 'loop' : span.parallelId ? 'parallel' : undefined
return {
blockName: span.name,
blockType: span.type,
...(span.executionOrder !== undefined && { executionOrder: span.executionOrder }),
...(span.iterationIndex !== undefined && { iterationCurrent: span.iterationIndex }),
...(iterationType !== undefined && { iterationType }),
@@ -0,0 +1,129 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { BlockType } from '@/executor/constants'
import type { DAGNode } from '@/executor/dag/builder'
import { BlockExecutor } from '@/executor/execution/block-executor'
import { ExecutionState } from '@/executor/execution/state'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
const { mockUploadFile } = vi.hoisted(() => ({
mockUploadFile: vi.fn(),
}))
vi.mock('@/ee/access-control/utils/permission-check', () => ({
validateBlockType: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
uploadFile: mockUploadFile,
},
}))
function createBlock(): SerializedBlock {
return {
id: 'function-block-1',
metadata: { id: BlockType.FUNCTION, name: 'Function' },
position: { x: 0, y: 0 },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
}
}
function createContext(state: ExecutionState): ExecutionContext {
return {
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
blockStates: state.getBlockStates(),
blockLogs: [],
metadata: { requestId: 'request-1', duration: 0 },
environmentVariables: {},
workflowVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
} as ExecutionContext
}
function createNode(block: SerializedBlock): DAGNode {
return {
id: block.id,
block,
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {},
}
}
describe('BlockExecutor', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
})
it('persists function output arrays as manifests in execution state', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = {
result: Array.from({ length: 120_000 }, (_, index) => ({
key: `SIM-${index}`,
payload: 'x'.repeat(100),
})),
}
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
await executor.execute(createContext(state), createNode(block), block)
const storedOutput = state.getBlockOutput(block.id)
expect(isLargeArrayManifest(storedOutput?.result)).toBe(true)
expect(storedOutput?.result).toMatchObject({
__simLargeArrayManifest: true,
kind: 'array',
totalCount: output.result.length,
})
})
})
@@ -202,9 +202,12 @@ export class BlockExecutor {
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
fileKeys: ctx.fileKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
maxBytes: ctx.base64MaxBytes,
preserveLargeValueMetadata: true,
})) as NormalizedBlockOutput
}
+23 -1
View File
@@ -1,6 +1,9 @@
import { createLogger, type Logger } from '@sim/logger'
import { normalizeStringArray } from '@/lib/core/utils/arrays'
import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records'
import { collectUserFileKeys } from '@/lib/core/utils/user-file'
import { mergeFileKeys, mergeLargeValueKeys } from '@/lib/execution/payloads/access-keys'
import { collectLargeValueKeys } from '@/lib/execution/payloads/large-execution-value'
import { StartBlockPath } from '@/lib/workflows/triggers/triggers'
import type { DAG } from '@/executor/dag/builder'
import { DAGBuilder } from '@/executor/dag/builder'
@@ -210,10 +213,27 @@ export class DAGExecutor {
snapshotState: filteredSnapshot,
runFromBlockContext,
})
const filteredLargeValueKeys = collectLargeValueKeys({
blockStates: filteredBlockStates,
loopExecutions: filteredLoopExecutions,
parallelExecutions: filteredParallelExecutions,
})
mergeLargeValueKeys(context, filteredLargeValueKeys)
const filteredFileKeys = collectUserFileKeys({
blockStates: filteredBlockStates,
loopExecutions: filteredLoopExecutions,
parallelExecutions: filteredParallelExecutions,
})
mergeFileKeys(context, filteredFileKeys)
context.subflowParentMap = this.buildSubflowParentMap(dag)
const engine = this.buildExecutionPipeline(context, dag, state)
return await engine.run()
const result = await engine.run()
if (result.metadata) {
result.metadata.largeValueKeys = context.largeValueKeys
result.metadata.fileKeys = context.fileKeys
}
return result
}
private restoreSavedIncomingEdges(dag: DAG, savedIncomingEdges?: Record<string, string[]>): void {
@@ -313,6 +333,8 @@ export class DAGExecutor {
workspaceId: this.contextExtensions.workspaceId,
executionId: this.contextExtensions.executionId,
largeValueExecutionIds: this.contextExtensions.largeValueExecutionIds,
largeValueKeys: this.contextExtensions.largeValueKeys,
fileKeys: this.contextExtensions.fileKeys,
allowLargeValueWorkflowScope: this.contextExtensions.allowLargeValueWorkflowScope,
userId: this.contextExtensions.userId,
isDeployedContext: this.contextExtensions.isDeployedContext,
@@ -1,7 +1,7 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { serializePauseSnapshot } from '@/executor/execution/snapshot-serializer'
import type { ExecutionContext } from '@/executor/types'
@@ -67,4 +67,26 @@ describe('serializePauseSnapshot', () => {
},
})
})
it('rejects oversized snapshot values without full JSON serialization', () => {
const stringifySpy = vi.spyOn(JSON, 'stringify').mockImplementation(() => {
throw new Error('full stringify should not be used for compactness checks')
})
const context = createContext({
workflowVariables: {
oversized: {
type: 'string',
value: 'x'.repeat(9 * 1024 * 1024),
},
},
})
try {
expect(() => serializePauseSnapshot(context, ['next-block'])).toThrow(
'Cannot serialize pause snapshot with oversized workflow variables'
)
} finally {
stringifySpy.mockRestore()
}
})
})
@@ -1,8 +1,136 @@
import { LARGE_VALUE_THRESHOLD_BYTES } from '@/lib/execution/payloads/large-value-ref'
import type { DAG } from '@/executor/dag/builder'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types'
import type { ExecutionContext, SerializedSnapshot } from '@/executor/types'
const JSON_SYNTAX_BYTES = {
QUOTE: 1,
COLON: 1,
COMMA: 1,
ARRAY_BRACKETS: 2,
OBJECT_BRACES: 2,
NULL: 4,
} as const
function getEscapedJsonStringByteLength(value: string): number {
let bytes = JSON_SYNTAX_BYTES.QUOTE * 2
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index)
if (code === 0x22 || code === 0x5c) {
bytes += 2
} else if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) {
bytes += 2
} else if (code < 0x20) {
bytes += 6
} else if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1)
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4
index++
} else {
bytes += 6
}
} else if (code >= 0xdc00 && code <= 0xdfff) {
bytes += 6
} else if (code < 0x80) {
bytes += 1
} else if (code < 0x800) {
bytes += 2
} else {
bytes += 3
}
}
return bytes
}
function getPrimitiveJsonByteLength(value: unknown): number | undefined {
if (value === null) {
return JSON_SYNTAX_BYTES.NULL
}
if (typeof value === 'string') {
return getEscapedJsonStringByteLength(value)
}
if (typeof value === 'number') {
return Number.isFinite(value)
? Buffer.byteLength(String(value), 'utf8')
: JSON_SYNTAX_BYTES.NULL
}
if (typeof value === 'boolean') {
return value ? 4 : 5
}
if (typeof value === 'bigint') {
throw new TypeError('Do not know how to serialize a BigInt')
}
return undefined
}
function getBoundedJsonByteLength(
value: unknown,
maxBytes: number,
seen = new WeakSet<object>()
): number | undefined {
const primitiveSize = getPrimitiveJsonByteLength(value)
if (primitiveSize !== undefined) {
return primitiveSize
}
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
return undefined
}
if (!value || typeof value !== 'object') {
return undefined
}
if (seen.has(value)) {
throw new TypeError('Converting circular structure to JSON')
}
seen.add(value)
let bytes = Array.isArray(value)
? JSON_SYNTAX_BYTES.ARRAY_BRACKETS
: JSON_SYNTAX_BYTES.OBJECT_BRACES
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index++) {
if (index > 0) bytes += JSON_SYNTAX_BYTES.COMMA
const itemSize = getBoundedJsonByteLength(value[index], maxBytes - bytes, seen)
bytes += itemSize ?? JSON_SYNTAX_BYTES.NULL
if (bytes > maxBytes) return bytes
}
seen.delete(value)
return bytes
}
let hasEntries = false
for (const key of Object.keys(value)) {
const entryValue = (value as Record<string, unknown>)[key]
if (
entryValue === undefined ||
typeof entryValue === 'function' ||
typeof entryValue === 'symbol'
) {
continue
}
if (hasEntries) bytes += JSON_SYNTAX_BYTES.COMMA
bytes += getEscapedJsonStringByteLength(key) + JSON_SYNTAX_BYTES.COLON
const entrySize = getBoundedJsonByteLength(entryValue, maxBytes - bytes, seen)
bytes += entrySize ?? JSON_SYNTAX_BYTES.NULL
hasEntries = true
if (bytes > maxBytes) return bytes
}
seen.delete(value)
return bytes
}
function assertSnapshotValueIsCompact(value: unknown, label: string): void {
const byteLength = getBoundedJsonByteLength(value, LARGE_VALUE_THRESHOLD_BYTES)
if (byteLength !== undefined && byteLength > LARGE_VALUE_THRESHOLD_BYTES) {
throw new Error(`Cannot serialize pause snapshot with oversized ${label}; compact it first.`)
}
}
function mapFromEntries<T>(map?: Map<string, T>): Record<string, T> | undefined {
if (!map) return undefined
return Object.fromEntries(map)
@@ -94,6 +222,9 @@ export function serializePauseSnapshot(
dagIncomingEdges,
}
assertSnapshotValueIsCompact(context.workflowVariables, 'workflow variables')
assertSnapshotValueIsCompact(state.loopExecutions, 'loop execution state')
const workspaceId = metadataFromContext?.workspaceId ?? context.workspaceId
if (!workspaceId) {
throw new Error(
+4
View File
@@ -36,6 +36,8 @@ export interface ExecutionMetadata {
deploymentVersionId?: string
}
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
callChain?: string[]
correlation?: AsyncExecutionCorrelation
@@ -146,6 +148,8 @@ export interface ContextExtensions {
workspaceId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
stream?: boolean
@@ -755,6 +755,8 @@ export class AgentBlockHandler implements BlockHandler {
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
fileKeys: ctx.fileKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
logger,
@@ -5,6 +5,7 @@ import {
} from '@/lib/core/utils/records'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages'
import { mergeFileKeys, mergeLargeValueKeys } from '@/lib/execution/payloads/access-keys'
import { BlockType } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
@@ -69,6 +70,8 @@ export class FunctionBlockHandler implements BlockHandler {
workspaceId: ctx.workspaceId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
fileKeys: ctx.fileKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
@@ -82,6 +85,9 @@ export class FunctionBlockHandler implements BlockHandler {
throw new Error(result.error || 'Function execution failed')
}
mergeLargeValueKeys(ctx, result.largeValueKeys ?? [])
mergeFileKeys(ctx, result.fileKeys ?? [])
return result.output
}
}
@@ -298,6 +298,8 @@ async function buildMothershipFileAttachments(
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
fileKeys: ctx.fileKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
requestId,
logger,
@@ -0,0 +1,296 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { BlockType } from '@/executor/constants'
import { VariablesBlockHandler } from '@/executor/handlers/variables/variables-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const { mockUploadFile } = vi.hoisted(() => ({
mockUploadFile: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
uploadFile: mockUploadFile,
},
}))
function createContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
workflowVariables: {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: [] },
},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
...overrides,
}
}
function createBlock(): SerializedBlock {
return {
id: 'variables-block-1',
metadata: { id: BlockType.VARIABLES, name: 'Variables' },
position: { x: 0, y: 0 },
config: { tool: BlockType.VARIABLES, params: {} },
inputs: {},
outputs: {},
enabled: true,
}
}
describe('VariablesBlockHandler', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
})
it('preserves small assignments inline', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext()
const value = [{ key: 'SIM-1', summary: 'Small issue' }]
const output = await handler.execute(ctx, createBlock(), {
variables: [
{
variableId: 'var-1',
variableName: 'issues',
type: 'array',
value,
},
],
})
expect(ctx.workflowVariables?.['var-1'].value).toEqual(value)
expect(output).toEqual({ issues: value })
expect(mockUploadFile).not.toHaveBeenCalled()
})
it('includes unmatched assignments in block output without mutating workflow variables', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext()
const value = [{ key: 'SIM-1', summary: 'Transient issue' }]
const output = await handler.execute(ctx, createBlock(), {
variables: [
{
variableName: 'transientIssues',
type: 'array',
value,
},
],
})
expect(ctx.workflowVariables).not.toHaveProperty('transientIssues')
expect(output).toEqual({ transientIssues: value })
})
it('keeps special unmatched assignment names as own output fields', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext()
const value = { polluted: true }
const output = await handler.execute(ctx, createBlock(), {
variables: [
{
variableName: '__proto__',
type: 'object',
value,
},
],
})
expect(Object.hasOwn(output, '__proto__')).toBe(true)
expect(output.__proto__).toEqual(value)
expect(Object.getPrototypeOf(output)).toBe(Object.prototype)
})
it('does not treat inherited prototype keys as existing workflow variable IDs', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext()
const value = { safe: true }
const originalPrototype = Object.getPrototypeOf(ctx.workflowVariables)
const output = await handler.execute(ctx, createBlock(), {
variables: [
{
variableId: '__proto__',
variableName: 'prototypeAssignment',
type: 'object',
value,
},
],
})
expect(Object.getPrototypeOf(ctx.workflowVariables)).toBe(originalPrototype)
expect(ctx.workflowVariables).not.toHaveProperty('__proto__')
expect(output).toEqual({ prototypeAssignment: value })
})
it('stores oversized array assignments as durable manifests in variables and block output', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext()
const value = Array.from({ length: 120_000 }, (_, index) => ({
key: `SIM-${index}`,
summary: 'Issue summary that keeps each item small',
}))
const output = await handler.execute(ctx, createBlock(), {
variables: [
{
variableId: 'var-1',
variableName: 'issues',
type: 'array',
value,
},
],
})
const storedValue = ctx.workflowVariables?.['var-1'].value
expect(isLargeArrayManifest(storedValue)).toBe(true)
expect(output.issues).toBe(storedValue)
expect(storedValue).toMatchObject({
__simLargeArrayManifest: true,
kind: 'array',
totalCount: value.length,
})
expect(storedValue.chunkCount).toBeGreaterThan(1)
expect(mockUploadFile).toHaveBeenCalledWith(
expect.objectContaining({
context: 'execution',
preserveKey: true,
customKey: expect.stringContaining('/execution-1/large-value-'),
})
)
})
it('fails clearly when durable context is missing for oversized assignments', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext({ workspaceId: undefined, executionId: undefined })
const value = Array.from({ length: 120_000 }, (_, index) => ({
key: `SIM-${index}`,
summary: 'Issue summary that keeps each item small',
}))
await expect(
handler.execute(ctx, createBlock(), {
variables: [
{
variableId: 'var-1',
variableName: 'issues',
type: 'array',
value,
},
],
})
).rejects.toThrow(
'Cannot persist large execution value without workspace, workflow, and execution IDs'
)
expect(mockUploadFile).not.toHaveBeenCalled()
})
it('preserves whole large refs before scalar type coercion', async () => {
const handler = new VariablesBlockHandler()
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 12 * 1024 * 1024,
executionId: 'execution-1',
}
const ctx = createContext({
workflowVariables: {
stringVar: { id: 'stringVar', name: 'stringRef', type: 'string', value: '' },
plainVar: { id: 'plainVar', name: 'plainRef', type: 'plain', value: '' },
numberVar: { id: 'numberVar', name: 'numberRef', type: 'number', value: 0 },
booleanVar: { id: 'booleanVar', name: 'booleanRef', type: 'boolean', value: false },
},
})
await handler.execute(ctx, createBlock(), {
variables: [
{
variableId: 'stringVar',
variableName: 'stringRef',
type: 'string',
value: JSON.stringify(ref),
},
{
variableId: 'plainVar',
variableName: 'plainRef',
type: 'plain',
value: JSON.stringify(ref),
},
{
variableId: 'numberVar',
variableName: 'numberRef',
type: 'number',
value: JSON.stringify(ref),
},
{
variableId: 'booleanVar',
variableName: 'booleanRef',
type: 'boolean',
value: JSON.stringify(ref),
},
],
})
expect(ctx.workflowVariables?.stringVar.value).toEqual(ref)
expect(ctx.workflowVariables?.plainVar.value).toEqual(ref)
expect(ctx.workflowVariables?.numberVar.value).toEqual(ref)
expect(ctx.workflowVariables?.booleanVar.value).toEqual(ref)
})
it('preserves existing variable metadata when compacting reassignment', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext({
workflowVariables: {
'var-1': {
id: 'var-1',
name: 'issues',
type: 'array',
value: [],
isExisting: true,
},
},
})
const value = [{ key: 'SIM-1', summary: 'Updated' }]
await handler.execute(ctx, createBlock(), {
variables: [
{
variableId: 'var-1',
variableName: 'issues',
type: 'array',
value,
},
],
})
expect(ctx.workflowVariables?.['var-1']).toEqual({
id: 'var-1',
name: 'issues',
type: 'array',
value,
isExisting: true,
})
})
})
@@ -1,4 +1,7 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { parseLargeExecutionValue } from '@/lib/execution/payloads/large-execution-value'
import { compactWorkflowVariableValue } from '@/lib/execution/payloads/serializer'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
@@ -6,6 +9,38 @@ import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('VariablesBlockHandler')
function setOutputValue(output: Record<string, any>, key: string, value: any): void {
Object.defineProperty(output, key, {
value,
enumerable: true,
configurable: true,
writable: true,
})
}
function getWorkflowVariableEntry(
workflowVariables: Record<string, any>,
variableId: string | undefined
): [string, any] | undefined {
if (!variableId || !Object.hasOwn(workflowVariables, variableId)) {
return undefined
}
return [variableId, workflowVariables[variableId]]
}
function setWorkflowVariableEntry(
workflowVariables: Record<string, any>,
id: string,
value: any
): void {
Object.defineProperty(workflowVariables, id, {
value,
enumerable: true,
configurable: true,
writable: true,
})
}
export class VariablesBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
const canHandle = block.metadata?.id === BlockType.VARIABLES
@@ -24,36 +59,46 @@ export class VariablesBlockHandler implements BlockHandler {
const assignments = this.parseAssignments(inputs.variables)
const output: Record<string, any> = {}
for (const assignment of assignments) {
const existingEntry = assignment.variableId
? [assignment.variableId, ctx.workflowVariables[assignment.variableId]]
: Object.entries(ctx.workflowVariables).find(
([_, v]) => v.name === assignment.variableName
)
const existingEntry =
getWorkflowVariableEntry(ctx.workflowVariables, assignment.variableId) ??
Object.entries(ctx.workflowVariables).find(([_, v]) => v.name === assignment.variableName)
const value = await this.compactAssignmentValue(ctx, assignment.value)
if (existingEntry?.[1]) {
const [id, variable] = existingEntry
ctx.workflowVariables[id] = {
setWorkflowVariableEntry(ctx.workflowVariables, id, {
...variable,
value: assignment.value,
}
value,
})
} else {
logger.warn(`Variable "${assignment.variableName}" not found in workflow variables`)
}
}
const output: Record<string, any> = {}
for (const assignment of assignments) {
output[assignment.variableName] = assignment.value
setOutputValue(output, assignment.variableName, value)
}
return output
} catch (error: any) {
logger.error('Variables block execution failed:', error)
throw new Error(`Variables block execution failed: ${error.message}`)
} catch (error) {
const normalizedError = toError(error)
logger.error('Variables block execution failed:', normalizedError)
throw new Error(`Variables block execution failed: ${normalizedError.message}`)
}
}
private async compactAssignmentValue(ctx: ExecutionContext, value: any): Promise<any> {
return compactWorkflowVariableValue(value, {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
userId: ctx.userId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
})
}
private parseAssignments(
assignmentsInput: any
): Array<{ variableId?: string; variableName: string; type: string; value: any }> {
@@ -83,6 +128,11 @@ export class VariablesBlockHandler implements BlockHandler {
}
private parseValueByType(value: any, type: string, variableName?: string): any {
const refValue = parseLargeExecutionValue(value)
if (refValue !== undefined) {
return refValue
}
if (value === null || value === undefined || value === '') {
if (type === 'number') return 0
if (type === 'boolean') return false
@@ -0,0 +1,150 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { EDGE } from '@/executor/constants'
import { LoopOrchestrator } from '@/executor/orchestrators/loop'
import type { ExecutionContext } from '@/executor/types'
const { mockUploadFile } = vi.hoisted(() => ({
mockUploadFile: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
uploadFile: mockUploadFile,
},
}))
function createContext(scope: Record<string, unknown>): ExecutionContext {
return {
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
blockStates: new Map(),
executedBlocks: new Set(),
blockLogs: [],
metadata: { requestId: 'request-1' },
environmentVariables: {},
workflowVariables: {},
decisions: { router: new Map(), condition: new Map() },
completedLoops: new Set(),
activeExecutionPath: new Set(),
loopExecutions: new Map([['loop-1', scope as any]]),
} as ExecutionContext
}
function createOrchestrator(loopConfigs = new Map<string, any>()) {
const setBlockOutput = vi.fn()
const orchestrator = new LoopOrchestrator(
{ loopConfigs, parallelConfigs: new Map(), nodes: new Map() } as any,
{ setBlockOutput, unmarkExecuted: vi.fn() } as any,
{ resolveSingleReference: vi.fn() } as any
)
return { orchestrator, setBlockOutput }
}
describe('LoopOrchestrator', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
})
it('exits doWhile loops when the configured iteration cap is reached', async () => {
const { orchestrator } = createOrchestrator()
const ctx = createContext({
iteration: 4,
maxIterations: 5,
loopType: 'doWhile',
condition: 'true',
currentIterationOutputs: new Map([['block-1', { result: 'done' }]]),
allIterationOutputs: [],
})
const result = await orchestrator.evaluateLoopContinuation(ctx, 'loop-1')
expect(result).toMatchObject({
shouldContinue: false,
shouldExit: true,
selectedRoute: EDGE.LOOP_EXIT,
totalIterations: 1,
})
})
it('does not treat doWhile iterations of zero as an immediate configured cap', async () => {
const { orchestrator } = createOrchestrator(
new Map([
[
'loop-1',
{
loopType: 'doWhile',
iterations: 0,
doWhileCondition: 'true',
nodes: ['block-1'],
},
],
])
)
const ctx = createContext({})
const scope = await orchestrator.initializeLoopScope(ctx, 'loop-1')
expect(scope.maxIterations).toBeUndefined()
expect(scope.condition).toBe('true')
})
it('keeps doWhile condition semantics when iterations are also configured', async () => {
const { orchestrator } = createOrchestrator(
new Map([
[
'loop-1',
{
loopType: 'doWhile',
iterations: 2,
doWhileCondition: 'true',
nodes: ['block-1'],
},
],
])
)
const ctx = createContext({})
const scope = await orchestrator.initializeLoopScope(ctx, 'loop-1')
expect(scope.maxIterations).toBeUndefined()
expect(scope.condition).toBe('true')
})
it('compacts current iteration outputs before retaining them', async () => {
const { orchestrator, setBlockOutput } = createOrchestrator()
const ctx = createContext({
iteration: 0,
maxIterations: 1,
loopType: 'doWhile',
condition: 'true',
currentIterationOutputs: new Map([
[
'block-1',
{
result: Array.from({ length: 200_000 }, (_, index) => ({
id: index,
summary: 'Issue summary that keeps each item small',
})),
},
],
]),
allIterationOutputs: [],
})
await orchestrator.evaluateLoopContinuation(ctx, 'loop-1')
const output = setBlockOutput.mock.calls[0][1]
expect(Array.isArray(output.results[0])).toBe(true)
expect(isLargeArrayManifest(output.results[0][0].result)).toBe(true)
expect(output.results[0][0].result.totalCount).toBe(200_000)
})
})
+28 -4
View File
@@ -22,8 +22,8 @@ import {
emitEmptySubflowEvents,
emitSubflowSuccessEvents,
extractBaseBlockId,
resolveArrayInputAsync,
} from '@/executor/utils/subflow-utils'
import { resolveArrayInputAsync } from '@/executor/utils/subflow-utils.server'
import type { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedLoop } from '@/serializer/types'
@@ -249,11 +249,25 @@ export class LoopOrchestrator {
}
if (iterationResults.length > 0) {
scope.allIterationOutputs.push(iterationResults)
const compactedIterationResults = await compactSubflowResults(iterationResults, {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
requireDurable: true,
})
scope.allIterationOutputs.push(compactedIterationResults)
}
scope.currentIterationOutputs.clear()
if (this.hasReachedConfiguredIterationLimit(scope, scope.iteration + 1)) {
return await this.createExitResult(ctx, loopId, scope)
}
if (!(await this.evaluateCondition(ctx, scope, scope.iteration + 1))) {
return await this.createExitResult(ctx, loopId, scope)
}
@@ -271,6 +285,13 @@ export class LoopOrchestrator {
}
}
private hasReachedConfiguredIterationLimit(scope: LoopScope, nextIteration: number): boolean {
if (scope.loopType !== 'doWhile' || scope.maxIterations === undefined) {
return false
}
return nextIteration >= scope.maxIterations
}
private async createExitResult(
ctx: ExecutionContext,
loopId: string,
@@ -282,6 +303,9 @@ export class LoopOrchestrator {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
requireDurable: true,
})
@@ -651,14 +675,14 @@ export class LoopOrchestrator {
logger.info('Evaluating loop condition', {
originalCondition: condition,
iteration: scope.iteration,
workflowVariables: ctx.workflowVariables,
workflowVariableCount: Object.keys(ctx.workflowVariables ?? {}).length,
})
const evaluatedCondition = await replaceLoopConditionReferences(condition, async (match) => {
const resolved = await this.resolver.resolveSingleReference(ctx, '', match, scope)
logger.debug('Resolved variable reference in loop condition', {
reference: match,
resolvedValue: resolved,
resolvedType: resolved === null ? 'null' : typeof resolved,
})
if (resolved !== undefined) {
if (typeof resolved === 'boolean' || typeof resolved === 'number') {
+7 -1
View File
@@ -13,8 +13,8 @@ import {
emitEmptySubflowEvents,
emitSubflowSuccessEvents,
extractBranchIndex,
resolveArrayInputAsync,
} from '@/executor/utils/subflow-utils'
import { resolveArrayInputAsync } from '@/executor/utils/subflow-utils.server'
import type { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedParallel } from '@/serializer/types'
@@ -350,6 +350,9 @@ export class ParallelOrchestrator {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
requireDurable: true,
})
@@ -377,6 +380,9 @@ export class ParallelOrchestrator {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
requireDurable: true,
})
+4
View File
@@ -265,6 +265,8 @@ interface ExecutionMetadata {
context?: ExecutionContext
workflowConnections?: Array<{ source: string; target: string }>
credentialAccountUserId?: string
largeValueKeys?: string[]
fileKeys?: string[]
status?: 'running' | 'paused' | 'completed'
pausePoints?: string[]
resumeChain?: {
@@ -291,6 +293,8 @@ export interface ExecutionContext {
workspaceId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
isDeployedContext?: boolean
@@ -0,0 +1,42 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
import { filterOutputForLog } from '@/executor/utils/output-filter'
vi.mock('@/blocks', () => ({
getBlock: () => undefined,
}))
describe('output filtering', () => {
it('preserves special top-level output keys as own fields', () => {
const rawOutput: Record<string, unknown> = {}
Object.defineProperty(rawOutput, 'constructor', {
value: { safe: true },
enumerable: true,
})
const output = filterOutputForLog('', rawOutput)
expect(Object.hasOwn(output, 'constructor')).toBe(true)
expect(output.constructor).toEqual({ safe: true })
expect(Object.getPrototypeOf(output)).toBe(Object.prototype)
})
it('preserves special nested output keys as own fields', () => {
const nested: Record<string, unknown> = {}
Object.defineProperty(nested, '__proto__', {
value: { safe: true },
enumerable: true,
})
const filtered = filterHiddenOutputKeys({
nested,
}) as { nested: Record<string, unknown> }
expect(Object.hasOwn(filtered.nested, '__proto__')).toBe(true)
expect(filtered.nested.__proto__).toEqual({ safe: true })
expect(Object.getPrototypeOf(filtered.nested)).toBe(Object.prototype)
})
})
+14 -1
View File
@@ -6,6 +6,19 @@ import { isTriggerBehavior, isTriggerInternalKey } from '@/executor/constants'
import type { NormalizedBlockOutput } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
function setFilteredOutputValue(
output: Record<string, unknown>,
key: string,
value: unknown
): void {
Object.defineProperty(output, key, {
value,
enumerable: true,
configurable: true,
writable: true,
})
}
/**
* Filters block output for logging/display purposes.
* Removes internal fields and fields marked with hiddenFromDisplay.
@@ -54,7 +67,7 @@ export function filterOutputForLog(
}
// Recursively filter globally hidden keys from nested objects
filtered[key] = filterHiddenOutputKeys(value)
setFilteredOutputValue(filtered, key, filterHiddenOutputKeys(value))
}
return filtered
@@ -0,0 +1,126 @@
import { toError } from '@sim/utils/errors'
import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys'
import {
isLargeArrayManifest,
LARGE_ARRAY_MANIFEST_MARKER,
materializeLargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest'
import { isLargeValueRef, LARGE_VALUE_REF_MARKER } from '@/lib/execution/payloads/large-value-ref'
import { MAX_DURABLE_LARGE_VALUE_BYTES } from '@/lib/execution/payloads/materialization.server'
import { materializeLargeValueRef } from '@/lib/execution/payloads/store'
import { REFERENCE } from '@/executor/constants'
import type { ExecutionContext } from '@/executor/types'
import type { VariableResolver } from '@/executor/variables/resolver'
async function normalizeCollectionValue(ctx: ExecutionContext, value: unknown): Promise<any[]> {
if (Array.isArray(value)) {
return value
}
if (isLargeArrayManifest(value)) {
const materialized = await materializeLargeArrayManifest(value, {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
fileKeys: ctx.fileKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
maxBytes: MAX_DURABLE_LARGE_VALUE_BYTES,
})
recordMaterializedAccessKeys(ctx, materialized)
return materialized
}
if (isLargeValueRef(value)) {
const materialized = await materializeLargeValueRef(value, {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
fileKeys: ctx.fileKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
maxBytes: MAX_DURABLE_LARGE_VALUE_BYTES,
})
if (materialized === undefined) {
throw new Error('Large execution value is unavailable.')
}
recordMaterializedAccessKeys(ctx, materialized)
return normalizeCollectionValue(ctx, materialized)
}
if (typeof value === 'object' && value !== null) {
if ((value as Record<string, unknown>)[LARGE_ARRAY_MANIFEST_MARKER] === true) {
throw new Error('Invalid large array manifest.')
}
if ((value as Record<string, unknown>)[LARGE_VALUE_REF_MARKER] === true) {
throw new Error('Invalid large value ref.')
}
return Object.entries(value)
}
if (value === null) {
return []
}
throw new Error('Value did not resolve to an array or object')
}
/**
* Resolves loop/parallel collection inputs on the server, including durable
* execution values that cannot be imported into client-reachable utilities.
*/
export async function resolveArrayInputAsync(
ctx: ExecutionContext,
items: any,
resolver: VariableResolver | null
): Promise<any[]> {
if (typeof items !== 'string') {
if (items === null) {
return []
}
if (!Array.isArray(items) && typeof items !== 'object') {
if (!resolver) {
return []
}
try {
const resolved = (await resolver.resolveInputs(ctx, 'subflow_items', { items })).items
return normalizeCollectionValue(ctx, resolved)
} catch (error) {
if (error instanceof Error && error.message.startsWith('Resolved items')) {
throw error
}
throw new Error(`Failed to resolve items: ${toError(error).message}`)
}
}
return normalizeCollectionValue(ctx, items)
}
if (items.startsWith(REFERENCE.START) && items.endsWith(REFERENCE.END) && resolver) {
try {
const resolved = await resolver.resolveSingleReference(ctx, '', items, undefined, {
allowLargeValueRefs: true,
})
return normalizeCollectionValue(ctx, resolved)
} catch (error) {
if (error instanceof Error && error.message.startsWith('Reference "')) {
throw error
}
throw new Error(`Failed to resolve reference "${items}": ${toError(error).message}`)
}
}
try {
const normalized = items.replace(/'/g, '"')
const parsed = JSON.parse(normalized)
return normalizeCollectionValue(ctx, parsed)
} catch (error) {
if (error instanceof Error && error.message.startsWith('Parsed value')) {
throw error
}
throw new Error(`Failed to parse items as JSON: "${items}"`)
}
}
+144 -3
View File
@@ -1,13 +1,60 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cacheLargeValue, clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import {
LARGE_ARRAY_MANIFEST_MARKER,
LARGE_ARRAY_MANIFEST_VERSION,
} from '@/lib/execution/payloads/large-array-manifest-metadata'
import { LARGE_VALUE_REF_MARKER } from '@/lib/execution/payloads/large-value-ref'
import type { ExecutionContext } from '@/executor/types'
import { findEffectiveContainerId } from '@/executor/utils/subflow-utils'
import { resolveArrayInputAsync } from '@/executor/utils/subflow-utils.server'
import type { VariableResolver } from '@/executor/variables/resolver'
import { findEffectiveContainerId, resolveArrayInputAsync } from './subflow-utils'
describe('resolveArrayInputAsync', () => {
const fakeCtx = {} as unknown as ExecutionContext
const fakeCtx = {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
} as unknown as ExecutionContext
beforeEach(() => {
clearLargeValueCacheForTests()
})
function createManifest(items: unknown[]) {
const json = JSON.stringify(items)
const size = Buffer.byteLength(json, 'utf8')
const id = 'lv_ABCDEFGHIJKL'
cacheLargeValue(id, items, size, fakeCtx)
return {
__simLargeArrayManifest: true,
version: LARGE_ARRAY_MANIFEST_VERSION,
kind: 'array',
totalCount: items.length,
chunkCount: 1,
byteSize: size,
chunks: [
{
ref: {
__simLargeValueRef: true,
version: 1,
id,
kind: 'array',
size,
executionId: fakeCtx.executionId,
},
count: items.length,
byteSize: size,
},
],
preview: items.slice(0, 3),
}
}
it('returns arrays as-is', async () => {
await expect(resolveArrayInputAsync(fakeCtx, [1, 2, 3], null)).resolves.toEqual([1, 2, 3])
@@ -20,6 +67,81 @@ describe('resolveArrayInputAsync', () => {
])
})
it('materializes large array manifests instead of iterating metadata entries', async () => {
const items = [{ id: 1 }, { id: 2 }]
const manifest = createManifest(items)
await expect(resolveArrayInputAsync(fakeCtx, manifest, null)).resolves.toEqual(items)
})
it('records exact nested keys discovered while materializing collection values', async () => {
const ctx = {
...fakeCtx,
largeValueKeys: [] as string[],
fileKeys: [] as string[],
} as ExecutionContext
const nestedRef = {
__simLargeValueRef: true,
version: 1,
id: 'lv_MNOPQRSTUVWX',
kind: 'object',
size: 12,
key: 'execution/workspace-1/workflow-1/source-execution/large-value-lv_MNOPQRSTUVWX.json',
executionId: 'source-execution',
}
const file = {
id: 'file-1',
name: 'nested.txt',
key: 'execution/workspace-1/workflow-1/source-execution/nested.txt',
url: '/api/files/serve/execution/workspace-1/workflow-1/source-execution/nested.txt?context=execution',
size: 5,
type: 'text/plain',
context: 'execution',
}
const items = [{ nestedRef, file }]
const manifest = createManifest(items)
await expect(resolveArrayInputAsync(ctx, manifest, null)).resolves.toEqual(items)
expect(ctx.largeValueKeys).toEqual([nestedRef.key])
expect(ctx.fileKeys).toEqual([file.key])
})
it('rejects invalid manifest-shaped collection inputs instead of iterating metadata', async () => {
await expect(
resolveArrayInputAsync(
fakeCtx,
{
[LARGE_ARRAY_MANIFEST_MARKER]: true,
version: LARGE_ARRAY_MANIFEST_VERSION,
kind: 'array',
totalCount: 1,
chunkCount: 0,
byteSize: 0,
chunks: [],
preview: [],
},
null
)
).rejects.toThrow('Invalid large array manifest')
})
it('rejects invalid large-ref-shaped collection inputs instead of iterating metadata', async () => {
await expect(
resolveArrayInputAsync(
fakeCtx,
{
[LARGE_VALUE_REF_MARKER]: true,
version: 1,
id: 'not-a-valid-large-value-id',
kind: 'array',
size: 1,
},
null
)
).rejects.toThrow('Invalid large value ref')
})
it('returns empty array when a pure reference resolves to null (skipped block)', async () => {
// `resolveSingleReference` returns `null` for a reference that points at a
// block that exists in the workflow but did not execute on this path.
@@ -45,6 +167,25 @@ describe('resolveArrayInputAsync', () => {
])
})
it('materializes a manifest returned by a pure reference', async () => {
const items = [{ id: 1 }, { id: 2 }]
const manifest = createManifest(items)
const resolver = {
resolveSingleReference: vi.fn().mockResolvedValue(manifest),
} as unknown as VariableResolver
await expect(resolveArrayInputAsync(fakeCtx, '<variable.issues>', resolver)).resolves.toEqual(
items
)
expect(resolver.resolveSingleReference).toHaveBeenCalledWith(
fakeCtx,
'',
'<variable.issues>',
undefined,
{ allowLargeValueRefs: true }
)
})
it('converts resolved objects to entries', async () => {
const resolver = {
resolveSingleReference: vi.fn().mockResolvedValue({ x: 1, y: 2 }),
+1 -77
View File
@@ -1,10 +1,9 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { DEFAULTS, LOOP, PARALLEL, REFERENCE } from '@/executor/constants'
import { DEFAULTS, LOOP, PARALLEL } from '@/executor/constants'
import type { ContextExtensions } from '@/executor/execution/types'
import { type BlockLog, type ExecutionContext, getNextExecutionOrder } from '@/executor/types'
import { buildContainerIterationContext } from '@/executor/utils/iteration-context'
import type { VariableResolver } from '@/executor/variables/resolver'
const logger = createLogger('SubflowUtils')
@@ -198,81 +197,6 @@ export function normalizeNodeId(nodeId: string): string {
return nodeId
}
/**
* Async variant used by execution paths that may need durable large-value or
* explicit UserFile.base64 materialization while resolving collection inputs.
*/
export async function resolveArrayInputAsync(
ctx: ExecutionContext,
items: any,
resolver: VariableResolver | null
): Promise<any[]> {
if (Array.isArray(items)) {
return items
}
if (typeof items === 'object' && items !== null) {
return Object.entries(items)
}
if (typeof items === 'string') {
if (items.startsWith(REFERENCE.START) && items.endsWith(REFERENCE.END) && resolver) {
try {
const resolved = await resolver.resolveSingleReference(ctx, '', items)
if (Array.isArray(resolved)) {
return resolved
}
if (typeof resolved === 'object' && resolved !== null) {
return Object.entries(resolved)
}
if (resolved === null) {
return []
}
throw new Error(`Reference "${items}" did not resolve to an array or object`)
} catch (error) {
if (error instanceof Error && error.message.startsWith('Reference "')) {
throw error
}
throw new Error(`Failed to resolve reference "${items}": ${toError(error).message}`)
}
}
try {
const normalized = items.replace(/'/g, '"')
const parsed = JSON.parse(normalized)
if (Array.isArray(parsed)) {
return parsed
}
if (typeof parsed === 'object' && parsed !== null) {
return Object.entries(parsed)
}
throw new Error(`Parsed value is not an array or object`)
} catch (error) {
if (error instanceof Error && error.message.startsWith('Parsed value')) {
throw error
}
throw new Error(`Failed to parse items as JSON: "${items}"`)
}
}
if (resolver) {
try {
const resolved = (await resolver.resolveInputs(ctx, 'subflow_items', { items })).items
if (Array.isArray(resolved)) {
return resolved
}
throw new Error(`Resolved items is not an array`)
} catch (error) {
if (error instanceof Error && error.message.startsWith('Resolved items')) {
throw error
}
throw new Error(`Failed to resolve items: ${toError(error).message}`)
}
}
return []
}
/**
* Creates and logs an error for a subflow (loop or parallel).
*/
+335 -3
View File
@@ -1,11 +1,16 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import {
LARGE_ARRAY_MANIFEST_VERSION,
type LargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest-metadata'
import { BlockType } from '@/executor/constants'
import { ExecutionState } from '@/executor/execution/state'
import type { ExecutionContext } from '@/executor/types'
import { VariableResolver } from '@/executor/variables/resolver'
import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
function createBlock(id: string, name: string, type: string, params = {}): SerializedBlock {
@@ -24,7 +29,37 @@ function createBlock(id: string, name: string, type: string, params = {}): Seria
}
}
function createResolver(language = 'javascript') {
function createTestManifest(totalCount = 100_000): LargeArrayManifest {
return {
__simLargeArrayManifest: true,
version: LARGE_ARRAY_MANIFEST_VERSION,
kind: 'array',
totalCount,
chunkCount: 1,
byteSize: 12 * 1024 * 1024,
chunks: [
{
count: totalCount,
byteSize: 12 * 1024 * 1024,
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 12 * 1024 * 1024,
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'execution-1',
},
},
],
preview: [{ key: 'SIM-0' }],
}
}
function createResolver(
language = 'javascript',
options: ConstructorParameters<typeof VariableResolver>[3] = {}
) {
const producer = createBlock('producer', 'Producer', BlockType.API)
const functionBlock = createBlock('function', 'Function', BlockType.FUNCTION, {
language,
@@ -67,7 +102,7 @@ function createResolver(language = 'javascript') {
return {
block: functionBlock,
ctx,
resolver: new VariableResolver(workflow, {}, state),
resolver: new VariableResolver(workflow, {}, state, options),
}
}
@@ -95,6 +130,274 @@ describe('VariableResolver function block inputs', () => {
expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
})
it('allows Variables block assignments to receive whole large refs', async () => {
const producer = createBlock('producer', 'Producer', BlockType.API)
const variablesBlock = createBlock('variables', 'Variables', BlockType.VARIABLES, {
variables: [
{
variableId: 'var-1',
variableName: 'issues',
type: 'array',
value: '<Producer.result>',
},
],
})
const workflow: SerializedWorkflow = {
version: '1',
blocks: [producer, variablesBlock],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 12 * 1024 * 1024,
executionId: 'execution-1',
}
state.setBlockOutput('producer', { result: ref })
const ctx = {
blockStates: state.getBlockStates(),
blockLogs: [],
environmentVariables: {},
workflowVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
metadata: {},
} as ExecutionContext
const resolver = new VariableResolver(workflow, {}, state)
const result = await resolver.resolveInputs(
ctx,
'variables',
variablesBlock.config.params,
variablesBlock
)
expect(JSON.parse(result.variables[0].value)).toEqual(ref)
})
it('allows Variables block assignments to receive whole large array manifests', async () => {
const producer = createBlock('producer', 'Producer', BlockType.API)
const variablesBlock = createBlock('variables', 'Variables', BlockType.VARIABLES, {
variables: [
{
variableId: 'var-1',
variableName: 'issues',
type: 'array',
value: '<Producer.result>',
},
],
})
const workflow: SerializedWorkflow = {
version: '1',
blocks: [producer, variablesBlock],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const manifest = createTestManifest()
state.setBlockOutput('producer', { result: manifest })
const ctx = {
blockStates: state.getBlockStates(),
blockLogs: [],
environmentVariables: {},
workflowVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
metadata: {},
} as ExecutionContext
const resolver = new VariableResolver(workflow, {}, state)
const result = await resolver.resolveInputs(
ctx,
'variables',
variablesBlock.config.params,
variablesBlock
)
expect(JSON.parse(result.variables[0].value)).toEqual(manifest)
})
it('allows Response block data to preserve whole large refs', async () => {
const producer = createBlock('producer', 'Producer', BlockType.API)
const responseBlock = createBlock('response', 'Response', BlockType.RESPONSE, {
dataMode: 'json',
data: '<Producer.result>',
})
const workflow: SerializedWorkflow = {
version: '1',
blocks: [producer, responseBlock],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ZYXWVUTSRQPO',
kind: 'array',
size: 12 * 1024 * 1024,
executionId: 'execution-1',
}
state.setBlockOutput('producer', { result: ref })
const ctx = {
blockStates: state.getBlockStates(),
blockLogs: [],
environmentVariables: {},
workflowVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
metadata: {},
} as ExecutionContext
const resolver = new VariableResolver(workflow, {}, state)
const result = await resolver.resolveInputs(
ctx,
'response',
responseBlock.config.params,
responseBlock
)
expect(JSON.parse(result.data)).toEqual(ref)
})
it('resolves workflow variable object references through context variables', async () => {
const { block, ctx, resolver } = createResolver('javascript')
const issues = [{ key: 'SIM-1', summary: 'Small issue' }]
ctx.workflowVariables = {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: issues },
}
const result = await resolver.resolveInputsForFunctionBlock(
ctx,
'function',
{ code: 'return <variable.issues>' },
block
)
expect(result.resolvedInputs.code).toBe('return globalThis["__blockRef_0"]')
expect(result.displayInputs.code).toBe('return [{"key":"SIM-1","summary":"Small issue"}]')
expect(result.contextVariables).toEqual({ __blockRef_0: issues })
})
it('resolves large workflow variable refs without embedding large literals', async () => {
const { block, ctx, resolver } = createResolver('javascript')
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 12 * 1024 * 1024,
executionId: 'execution-1',
}
ctx.workflowVariables = {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: ref },
}
const result = await resolver.resolveInputsForFunctionBlock(
ctx,
'function',
{ code: 'return <variable.issues>' },
block
)
expect(result.resolvedInputs.code).toBe(
'return (await sim.values.read(globalThis["__blockRef_0"]))'
)
expect(result.contextVariables).toEqual({ __blockRef_0: ref })
})
it('rewrites whole manifest workflow variables to lazy JavaScript array reads', async () => {
const { block, ctx, resolver } = createResolver('javascript')
const manifest = createTestManifest()
ctx.workflowVariables = {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: manifest },
}
const result = await resolver.resolveInputsForFunctionBlock(
ctx,
'function',
{ code: 'return <variable.issues>' },
block
)
expect(result.resolvedInputs.code).toBe(
'return (await sim.values.readArray(globalThis["__blockRef_0"]))'
)
expect(result.contextVariables).toEqual({ __blockRef_0: manifest })
})
it('resolves manifest workflow variable length without whole-array context variables', async () => {
const { block, ctx, resolver } = createResolver('javascript', { navigatePathAsync })
const manifest = createTestManifest()
ctx.workflowVariables = {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: manifest },
}
const result = await resolver.resolveInputsForFunctionBlock(
ctx,
'function',
{ code: 'return <variable.issues.length>' },
block
)
expect(result.resolvedInputs.code).toBe('return 100000')
expect(result.contextVariables).toEqual({})
})
it('keeps manifest internals hidden during async path navigation', async () => {
const { ctx } = createResolver()
const manifest = createTestManifest()
await expect(navigatePathAsync(manifest, ['totalCount'], ctx)).resolves.toBe(100_000)
await expect(navigatePathAsync(manifest, ['chunkCount'], ctx)).resolves.toBe(1)
await expect(navigatePathAsync(manifest, ['preview'], ctx)).resolves.toEqual([{ key: 'SIM-0' }])
await expect(
navigatePathAsync(manifest, ['chunks', '0', 'ref', 'id'], ctx)
).resolves.toBeUndefined()
})
it('resolves indexed manifest workflow variable paths without whole-array context variables', async () => {
const manifest = createTestManifest()
const navigateManifestPath = vi.fn(async () => 'SIM-0')
const { block, ctx, resolver } = createResolver('javascript', {
navigatePathAsync: navigateManifestPath,
})
ctx.workflowVariables = {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: manifest },
}
const result = await resolver.resolveInputsForFunctionBlock(
ctx,
'function',
{ code: 'return <variable.issues[0].key>' },
block
)
expect(navigateManifestPath).toHaveBeenCalledWith(
manifest,
['0', 'key'],
expect.objectContaining({ allowLargeValueRefs: true })
)
expect(result.resolvedInputs.code).toBe('return "SIM-0"')
expect(result.contextVariables).toEqual({})
})
it('resolves named loop result bracket paths in function code', async () => {
const loopBlock = createBlock('loop-1', 'Loop 1', 'loop')
const functionBlock = createBlock('function', 'Function', BlockType.FUNCTION, {
@@ -408,6 +711,35 @@ describe('VariableResolver function block inputs', () => {
).rejects.toThrow('This execution value is too large to inline')
})
it('fails whole large array manifests for Function runtimes without lazy helpers', async () => {
const { block, ctx } = createResolver('python')
const state = new ExecutionState()
state.setBlockOutput('producer', {
result: createTestManifest(),
})
const workflow: SerializedWorkflow = {
version: '1',
blocks: [createBlock('producer', 'Producer', BlockType.API), block],
connections: [],
loops: {},
parallels: {},
}
const largeResolver = new VariableResolver(workflow, {}, state)
const largeCtx = {
...ctx,
blockStates: state.getBlockStates(),
} as ExecutionContext
await expect(
largeResolver.resolveInputsForFunctionBlock(
largeCtx,
'function',
{ code: 'return <Producer.result>' },
block
)
).rejects.toThrow('This execution value contains nested large values')
})
it('fails whole large value refs for JavaScript with imports', async () => {
const { block, ctx } = createResolver('javascript')
const state = new ExecutionState()
+107 -9
View File
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import {
containsLargeValueRef,
getLargeValueMaterializationError,
@@ -100,7 +101,7 @@ export class VariableResolver {
this.resolvers = [
new LoopResolver(workflow, options.navigatePathAsync),
new ParallelResolver(workflow, options.navigatePathAsync),
new WorkflowResolver(workflowVariables),
new WorkflowResolver(workflowVariables, options.navigatePathAsync),
new EnvResolver(),
this.blockResolver,
]
@@ -244,16 +245,31 @@ export class VariableResolver {
if (isConditionBlock && key === 'conditions') {
continue
}
resolved[key] = await this.resolveValue(ctx, currentNodeId, value, undefined, block)
resolved[key] = await this.resolveValue(ctx, currentNodeId, value, undefined, block, {
allowLargeValueRefs: this.canResolveInputToLargeValueRef(block, key),
})
}
return resolved
}
private canResolveInputToLargeValueRef(block: SerializedBlock | undefined, key: string): boolean {
if (block?.metadata?.id === BlockType.VARIABLES) {
return key === 'variables'
}
if (block?.metadata?.id === BlockType.RESPONSE) {
return key === 'data' || key === 'builderData'
}
return false
}
async resolveSingleReference(
ctx: ExecutionContext,
currentNodeId: string,
reference: string,
loopScope?: LoopScope
loopScope?: LoopScope,
options: { allowLargeValueRefs?: boolean } = {}
): Promise<any> {
if (typeof reference === 'string') {
const trimmed = reference.trim()
@@ -263,6 +279,7 @@ export class VariableResolver {
executionState: this.state,
currentNodeId,
loopScope,
allowLargeValueRefs: options.allowLargeValueRefs,
}
const result = await this.resolveReference(trimmed, resolutionContext)
@@ -281,7 +298,8 @@ export class VariableResolver {
currentNodeId: string,
value: any,
loopScope?: LoopScope,
block?: SerializedBlock
block?: SerializedBlock,
options: { allowLargeValueRefs?: boolean } = {}
): Promise<any> {
if (value === null || value === undefined) {
return value
@@ -289,7 +307,7 @@ export class VariableResolver {
if (Array.isArray(value)) {
return Promise.all(
value.map((v) => this.resolveValue(ctx, currentNodeId, v, loopScope, block))
value.map((v) => this.resolveValue(ctx, currentNodeId, v, loopScope, block, options))
)
}
@@ -297,14 +315,14 @@ export class VariableResolver {
const entries = await Promise.all(
Object.entries(value).map(async ([key, val]) => [
key,
await this.resolveValue(ctx, currentNodeId, val, loopScope, block),
await this.resolveValue(ctx, currentNodeId, val, loopScope, block, options),
])
)
return Object.fromEntries(entries)
}
if (typeof value === 'string') {
return this.resolveTemplate(ctx, currentNodeId, value, loopScope, block)
return this.resolveTemplate(ctx, currentNodeId, value, loopScope, block, options)
}
return value
}
@@ -383,6 +401,17 @@ export class VariableResolver {
throw getLargeValueMaterializationError(effectiveValue)
}
replacement = lazyReplacement
} else if (isLargeArrayManifest(effectiveValue)) {
const lazyReplacement = this.formatLazyLargeArrayManifestReference(
varName,
language,
template,
index
)
if (!lazyReplacement) {
throw getNestedLargeValueMaterializationError()
}
replacement = lazyReplacement
} else if (containsLargeValueRef(effectiveValue)) {
throw getNestedLargeValueMaterializationError()
} else {
@@ -432,11 +461,53 @@ export class VariableResolver {
throw getLargeValueMaterializationError(effectiveValue)
}
if (isLargeArrayManifest(effectiveValue)) {
const varName = `__blockRef_${Object.keys(contextVarAccumulator).length}`
contextVarAccumulator[varName] = effectiveValue
const lazyReplacement = this.formatLazyLargeArrayManifestReference(
varName,
language,
template,
index
)
if (lazyReplacement) {
displayResult += this.formatDisplayValueForCodeContext(
effectiveValue,
language,
template,
index
)
return lazyReplacement
}
throw getNestedLargeValueMaterializationError()
}
if (containsLargeValueRef(effectiveValue)) {
throw getNestedLargeValueMaterializationError()
}
// Non-block reference (loop, parallel, workflow, env): embed as literal
if (
this.isWorkflowVariableReference(match) &&
this.shouldUseContextVariable(effectiveValue)
) {
const varName = `__blockRef_${Object.keys(contextVarAccumulator).length}`
contextVarAccumulator[varName] = effectiveValue
const replacement = this.formatContextVariableReference(
varName,
language,
template,
index,
effectiveValue
)
displayResult += this.formatDisplayValueForCodeContext(
effectiveValue,
language,
template,
index
)
return replacement
}
const replacement = this.blockResolver.formatValueForBlock(
effectiveValue,
BlockType.FUNCTION,
@@ -522,6 +593,31 @@ export class VariableResolver {
})
}
private formatLazyLargeArrayManifestReference(
varName: string,
language: string | undefined,
template: string,
matchIndex: number
): string | null {
if (!this.canUseJavaScriptRuntimeHelpers(language, template)) {
return null
}
const expression = `(await sim.values.readArray(globalThis[${JSON.stringify(varName)}]))`
return this.formatJavaScriptAsyncExpression(expression, template, matchIndex, {
stringifyInStringContext: true,
})
}
private isWorkflowVariableReference(reference: string): boolean {
const parts = parseReferencePath(reference)
return parts[0] === REFERENCE.PREFIX.VARIABLE
}
private shouldUseContextVariable(value: unknown): boolean {
return typeof value === 'object' && value !== null
}
private formatJavaScriptAsyncExpression(
expression: string,
template: string,
@@ -1011,13 +1107,15 @@ export class VariableResolver {
currentNodeId: string,
template: string,
loopScope?: LoopScope,
block?: SerializedBlock
block?: SerializedBlock,
options: { allowLargeValueRefs?: boolean } = {}
): Promise<string> {
const resolutionContext: ResolutionContext = {
executionContext: ctx,
executionState: this.state,
currentNodeId,
loopScope,
allowLargeValueRefs: options.allowLargeValueRefs,
}
let replacementError: Error | null = null
+22 -5
View File
@@ -180,7 +180,10 @@ export class LoopResolver implements Resolver {
if (pathParts.length > 0) {
return useAsyncPath && this.navigatePathAsync
? this.navigatePathAsync(value, pathParts, context)
: navigatePath(value, pathParts, { executionContext: context.executionContext })
: navigatePath(value, pathParts, {
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
})
}
return value
@@ -191,9 +194,15 @@ export class LoopResolver implements Resolver {
if (!output || typeof output !== 'object') {
return undefined
}
const value = (output as Record<string, unknown>).results
const value = navigatePath(output, ['results'], {
allowLargeValueRefs: true,
executionContext: context.executionContext,
})
if (pathParts.length > 0) {
return navigatePath(value, pathParts, { executionContext: context.executionContext })
return navigatePath(value, pathParts, {
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
})
}
if (!context.allowLargeValueRefs) {
assertNoLargeValueRefs(value)
@@ -210,11 +219,19 @@ export class LoopResolver implements Resolver {
if (!output || typeof output !== 'object') {
return undefined
}
const value = (output as Record<string, unknown>).results
const value = this.navigatePathAsync
? await this.navigatePathAsync(output, ['results'], { ...context, allowLargeValueRefs: true })
: navigatePath(output, ['results'], {
allowLargeValueRefs: true,
executionContext: context.executionContext,
})
if (pathParts.length > 0) {
return this.navigatePathAsync
? this.navigatePathAsync(value, pathParts, context)
: navigatePath(value, pathParts, { executionContext: context.executionContext })
: navigatePath(value, pathParts, {
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
})
}
if (!context.allowLargeValueRefs) {
assertNoLargeValueRefs(value)
@@ -177,7 +177,10 @@ export class ParallelResolver implements Resolver {
if (pathParts.length > 0) {
return useAsyncPath && this.navigatePathAsync
? this.navigatePathAsync(value, pathParts, context)
: navigatePath(value, pathParts, { executionContext: context.executionContext })
: navigatePath(value, pathParts, {
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
})
}
return value
@@ -281,9 +284,15 @@ export class ParallelResolver implements Resolver {
if (!output || typeof output !== 'object') {
return undefined
}
const value = (output as Record<string, unknown>).results
const value = navigatePath(output, ['results'], {
allowLargeValueRefs: true,
executionContext: context.executionContext,
})
if (pathParts.length > 0) {
return navigatePath(value, pathParts, { executionContext: context.executionContext })
return navigatePath(value, pathParts, {
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
})
}
if (!context.allowLargeValueRefs) {
assertNoLargeValueRefs(value)
@@ -300,11 +309,19 @@ export class ParallelResolver implements Resolver {
if (!output || typeof output !== 'object') {
return undefined
}
const value = (output as Record<string, unknown>).results
const value = this.navigatePathAsync
? await this.navigatePathAsync(output, ['results'], { ...context, allowLargeValueRefs: true })
: navigatePath(output, ['results'], {
allowLargeValueRefs: true,
executionContext: context.executionContext,
})
if (pathParts.length > 0) {
return this.navigatePathAsync
? this.navigatePathAsync(value, pathParts, context)
: navigatePath(value, pathParts, { executionContext: context.executionContext })
: navigatePath(value, pathParts, {
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
})
}
if (!context.allowLargeValueRefs) {
assertNoLargeValueRefs(value)
@@ -1,4 +1,10 @@
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys'
import {
isLargeArrayManifest,
type LargeArrayManifest,
readLargeArrayManifestSlice,
} from '@/lib/execution/payloads/large-array-manifest'
import {
assertNoLargeValueRefs,
getLargeValueMaterializationError,
@@ -6,42 +12,72 @@ import {
} from '@/lib/execution/payloads/large-value-ref'
import { materializeLargeValueRef } from '@/lib/execution/payloads/store'
import { hydrateUserFileWithBase64 } from '@/lib/uploads/utils/user-file-base64.server'
import type { ResolutionContext } from '@/executor/variables/resolvers/reference'
import type { PathNavigationContext } from '@/executor/variables/resolvers/reference'
interface MaterializedNavigationValue {
value: unknown
context: PathNavigationContext
}
function withLocalLargeValueExecutionIds(
context: PathNavigationContext,
materializedValue: unknown
): PathNavigationContext {
if (!context.executionContext) {
return context
}
recordMaterializedAccessKeys(context.executionContext, materializedValue)
return {
...context,
executionContext: {
...context.executionContext,
largeValueKeys: context.executionContext.largeValueKeys,
fileKeys: context.executionContext.fileKeys,
},
}
}
async function materializeLargeValueRefOrThrow(
value: unknown,
context: ResolutionContext
): Promise<unknown> {
context: PathNavigationContext
): Promise<MaterializedNavigationValue> {
if (!isLargeValueRef(value)) {
return value
return { value, context }
}
const materialized = await materializeLargeValueRef(value, {
workspaceId: context.executionContext.workspaceId,
workflowId: context.executionContext.workflowId,
executionId: context.executionContext.executionId,
largeValueExecutionIds: context.executionContext.largeValueExecutionIds,
largeValueKeys: context.executionContext.largeValueKeys,
fileKeys: context.executionContext.fileKeys,
allowLargeValueWorkflowScope: context.executionContext.allowLargeValueWorkflowScope,
userId: context.executionContext.userId,
})
if (materialized === undefined) {
throw getLargeValueMaterializationError(value)
}
return materialized
return {
value: materialized,
context: withLocalLargeValueExecutionIds(context, materialized),
}
}
async function hydrateExplicitBase64(
file: unknown,
context: ResolutionContext
context: PathNavigationContext
): Promise<string | undefined> {
if (!isUserFileWithMetadata(file)) {
return undefined
}
const hydrated = await hydrateUserFileWithBase64(file, {
requestId: context.executionContext.metadata.requestId,
requestId: context.executionContext.metadata?.requestId,
workspaceId: context.executionContext.workspaceId,
workflowId: context.executionContext.workflowId,
executionId: context.executionContext.executionId,
largeValueExecutionIds: context.executionContext.largeValueExecutionIds,
largeValueKeys: context.executionContext.largeValueKeys,
fileKeys: context.executionContext.fileKeys,
allowLargeValueWorkflowScope: context.executionContext.allowLargeValueWorkflowScope,
userId: context.executionContext.userId,
maxBytes: context.executionContext.base64MaxBytes,
@@ -54,6 +90,47 @@ async function hydrateExplicitBase64(
return hydrated.base64
}
async function readManifestIndexAsync(
value: LargeArrayManifest,
part: string,
context: PathNavigationContext
): Promise<unknown> {
const [item] = await readLargeArrayManifestSlice(value, Number.parseInt(part, 10), 1, {
workspaceId: context.executionContext.workspaceId,
workflowId: context.executionContext.workflowId,
executionId: context.executionContext.executionId,
largeValueExecutionIds: context.executionContext.largeValueExecutionIds,
largeValueKeys: context.executionContext.largeValueKeys,
allowLargeValueWorkflowScope: context.executionContext.allowLargeValueWorkflowScope,
userId: context.executionContext.userId,
})
return item
}
async function navigateManifestMetadataOrIndexAsync(
value: unknown,
part: string,
context: PathNavigationContext
): Promise<MaterializedNavigationValue> {
if (!isLargeArrayManifest(value)) {
return { value: undefined, context }
}
if (part === 'length' || part === 'totalCount') {
return { value: value.totalCount, context }
}
if (part === 'chunkCount' || part === 'byteSize' || part === 'preview') {
return { value: value[part], context: withLocalLargeValueExecutionIds(context, value[part]) }
}
if (/^\d+$/.test(part)) {
const item = await readManifestIndexAsync(value, part, context)
return {
value: item,
context: withLocalLargeValueExecutionIds(context, item),
}
}
return { value: undefined, context }
}
/**
* Server-side path navigation used during execution. It can hydrate persisted
* large values and UserFile.base64 only when the requested path explicitly asks
@@ -62,24 +139,37 @@ async function hydrateExplicitBase64(
export async function navigatePathAsync(
obj: any,
path: string[],
context: ResolutionContext
context: PathNavigationContext
): Promise<any> {
let current = obj
let currentContext = context
for (const part of path) {
current = await materializeLargeValueRefOrThrow(current, context)
;({ value: current, context: currentContext } = await materializeLargeValueRefOrThrow(
current,
currentContext
))
if (current === null || current === undefined) {
return undefined
}
if (part === 'base64') {
const base64 = await hydrateExplicitBase64(current, context)
const base64 = await hydrateExplicitBase64(current, currentContext)
if (base64 !== undefined) {
current = base64
continue
}
}
if (isLargeArrayManifest(current)) {
;({ value: current, context: currentContext } = await navigateManifestMetadataOrIndexAsync(
current,
part,
currentContext
))
continue
}
const arrayMatch = part.match(/^([^[]+)(\[.+)$/)
if (arrayMatch) {
const [, prop, bracketsPart] = arrayMatch
@@ -87,7 +177,10 @@ export async function navigatePathAsync(
typeof current === 'object' && current !== null
? (current as Record<string, unknown>)[prop]
: undefined
current = await materializeLargeValueRefOrThrow(current, context)
;({ value: current, context: currentContext } = await materializeLargeValueRefOrThrow(
current,
currentContext
))
if (current === undefined || current === null) {
return undefined
}
@@ -95,17 +188,33 @@ export async function navigatePathAsync(
const indices = bracketsPart.match(/\[(\d+)\]/g)
if (indices) {
for (const indexMatch of indices) {
current = await materializeLargeValueRefOrThrow(current, context)
;({ value: current, context: currentContext } = await materializeLargeValueRefOrThrow(
current,
currentContext
))
if (current === null || current === undefined) {
return undefined
}
const idx = Number.parseInt(indexMatch.slice(1, -1), 10)
current = Array.isArray(current) ? current[idx] : undefined
if (isLargeArrayManifest(current)) {
;({ value: current, context: currentContext } =
await navigateManifestMetadataOrIndexAsync(current, String(idx), currentContext))
} else {
current = Array.isArray(current) ? current[idx] : undefined
}
}
}
} else if (/^\d+$/.test(part)) {
const index = Number.parseInt(part, 10)
current = Array.isArray(current) ? current[index] : undefined
if (isLargeArrayManifest(current)) {
;({ value: current, context: currentContext } = await navigateManifestMetadataOrIndexAsync(
current,
part,
currentContext
))
} else {
current = Array.isArray(current) ? current[index] : undefined
}
} else {
current =
typeof current === 'object' && current !== null
@@ -176,6 +176,38 @@ describe('navigatePath', () => {
})
})
describe('large array manifests', () => {
it('returns undefined for sync index access when the chunk is not cached', () => {
const manifest = {
__simLargeArrayManifest: true,
version: 2,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: 16,
chunks: [
{
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 16,
executionId: 'execution-1',
},
count: 1,
byteSize: 16,
},
],
preview: [{ id: 1 }],
}
expect(navigatePath(manifest, ['0'])).toBeUndefined()
expect(navigatePath(manifest, ['length'])).toBe(1)
expect(navigatePath(manifest, ['preview'])).toEqual([{ id: 1 }])
})
})
describe('bracket notation edge cases', () => {
it.concurrent('should handle bracket notation with property access', () => {
const obj = { data: [{ value: 100 }, { value: 200 }] }
@@ -1,7 +1,33 @@
import { materializeLargeValueRefSyncOrThrow } from '@/lib/execution/payloads/cache'
import {
materializeLargeValueRefSync,
materializeLargeValueRefSyncOrThrow,
} from '@/lib/execution/payloads/cache'
import {
isLargeArrayManifest,
type LargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest-metadata'
import { assertNoLargeValueRefs, isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import type { ExecutionState, LoopScope } from '@/executor/execution/state'
import type { ExecutionContext } from '@/executor/types'
export interface PathNavigationExecutionContext {
workflowId: string
workspaceId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
metadata?: { requestId?: string }
base64MaxBytes?: number
}
export interface PathNavigationContext {
executionContext: PathNavigationExecutionContext
allowLargeValueRefs?: boolean
}
export interface ResolutionContext {
executionContext: ExecutionContext
executionState: ExecutionState
@@ -19,7 +45,7 @@ export interface Resolver {
export type AsyncPathNavigator = (
obj: any,
path: string[],
context: ResolutionContext
context: PathNavigationContext
) => Promise<any>
/**
@@ -43,6 +69,54 @@ export function splitLeadingBracketPath(part: string): { property: string; pathP
}
}
function readManifestIndexSync(
manifest: LargeArrayManifest,
index: number,
executionContext?: ExecutionContext
): unknown {
if (!Number.isInteger(index) || index < 0 || index >= manifest.totalCount) {
return undefined
}
let offset = 0
for (const chunk of manifest.chunks) {
const nextOffset = offset + chunk.count
if (index < nextOffset) {
const materialized = materializeLargeValueRefSync(chunk.ref, executionContext)
if (materialized === undefined) {
return undefined
}
if (!Array.isArray(materialized)) {
throw new Error('Large array manifest chunk must materialize to an array.')
}
if (materialized.length !== chunk.count) {
throw new Error('Large array manifest chunk count does not match materialized data.')
}
return materialized[index - offset]
}
offset = nextOffset
}
return undefined
}
function navigateManifestMetadataOrIndexSync(
manifest: LargeArrayManifest,
part: string,
executionContext?: ExecutionContext
): unknown {
if (part === 'length' || part === 'totalCount') {
return manifest.totalCount
}
if (part === 'chunkCount' || part === 'byteSize' || part === 'preview') {
return manifest[part]
}
if (/^\d+$/.test(part)) {
return readManifestIndexSync(manifest, Number.parseInt(part, 10), executionContext)
}
return undefined
}
/**
* Navigate through nested object properties using a path array.
* Supports dot notation and array indices.
@@ -66,6 +140,11 @@ export function navigatePath(
return undefined
}
if (isLargeArrayManifest(current)) {
current = navigateManifestMetadataOrIndexSync(current, part, options.executionContext)
continue
}
const arrayMatch = part.match(/^([^[]+)(\[.+)$/)
if (arrayMatch) {
const [, prop, bracketsPart] = arrayMatch
@@ -89,13 +168,25 @@ export function navigatePath(
if (isLargeValueRef(current)) {
current = materializeLargeValueRefSyncOrThrow(current, options.executionContext)
}
if (isLargeArrayManifest(current)) {
current = navigateManifestMetadataOrIndexSync(
current,
indexMatch.slice(1, -1),
options.executionContext
)
continue
}
const idx = Number.parseInt(indexMatch.slice(1, -1), 10)
current = Array.isArray(current) ? current[idx] : undefined
}
}
} else if (/^\d+$/.test(part)) {
const index = Number.parseInt(part, 10)
current = Array.isArray(current) ? current[index] : undefined
current = isLargeArrayManifest(current)
? readManifestIndexSync(current, index, options.executionContext)
: Array.isArray(current)
? current[index]
: undefined
} else {
current =
typeof current === 'object' && current !== null
@@ -1,4 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import {
createLargeArrayManifest,
isLargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest'
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server'
import type { ResolutionContext } from './reference'
import { WorkflowResolver } from './workflow'
@@ -15,7 +21,12 @@ vi.mock('@/lib/workflows/variables/variable-manager', () => ({
*/
function createTestContext(workflowVariables: Record<string, any>): ResolutionContext {
return {
executionContext: { workflowVariables },
executionContext: {
workflowVariables,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
},
executionState: {},
currentNodeId: 'test-node',
} as ResolutionContext
@@ -157,6 +168,111 @@ describe('WorkflowResolver', () => {
expect(result).toBe(value)
}
})
it('returns whole large workflow variable manifests only when refs are allowed', async () => {
const compacted = await compactExecutionPayload(
Array.from({ length: 100 }, (_, index) => ({
key: `SIM-${index}`,
summary: 'Issue summary that keeps each item small',
})),
{
thresholdBytes: 256,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
}
)
const variables = {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: compacted },
}
const resolver = new WorkflowResolver(variables, navigatePathAsync)
const context = createTestContext(variables)
expect(() => resolver.resolve('<variable.issues>', context)).toThrow('too large to inline')
await expect(resolver.resolveAsync('<variable.issues>', context)).rejects.toThrow(
'too large to inline'
)
const allowedContext = { ...context, allowLargeValueRefs: true }
expect(isLargeArrayManifest(resolver.resolve('<variable.issues>', allowedContext))).toBe(true)
await expect(resolver.resolveAsync('<variable.issues>', allowedContext)).resolves.toEqual(
compacted
)
})
it('resolves nested paths through async large workflow variable navigation', async () => {
const compacted = await compactExecutionPayload(
Array.from({ length: 100 }, (_, index) => ({
key: `SIM-${index + 1}`,
fields: { summary: index === 0 ? 'Large issue' : 'Other issue' },
})),
{
thresholdBytes: 256,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
}
)
const variables = {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: compacted },
}
const resolver = new WorkflowResolver(variables, navigatePathAsync)
await expect(
resolver.resolveAsync('<variable.issues.0.fields.summary>', createTestContext(variables))
).resolves.toBe('Large issue')
})
it('preserves large array manifest workflow variables without array coercion', async () => {
const manifest = await createLargeArrayManifest([{ key: 'SIM-1' }], {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
})
const variables = {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: manifest },
}
const resolver = new WorkflowResolver(variables, navigatePathAsync)
const allowedContext = { ...createTestContext(variables), allowLargeValueRefs: true }
expect(resolver.resolve('<variable.issues>', allowedContext)).toEqual(manifest)
await expect(resolver.resolveAsync('<variable.issues>', allowedContext)).resolves.toEqual(
manifest
)
})
it('resolves bracket-indexed paths through manifest workflow variables', async () => {
const manifest = await createLargeArrayManifest([{ key: 'SIM-1' }], {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
})
const variables = {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: manifest },
}
const resolver = new WorkflowResolver(variables, navigatePathAsync)
await expect(
resolver.resolveAsync('<variable.issues[0].key>', createTestContext(variables))
).resolves.toBe('SIM-1')
})
it('resolves manifest array length without materializing chunks', async () => {
const manifest = await createLargeArrayManifest([{ key: 'SIM-1' }, { key: 'SIM-2' }], {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
})
const variables = {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: manifest },
}
const resolver = new WorkflowResolver(variables, navigatePathAsync)
await expect(
resolver.resolveAsync('<variable.issues.length>', createTestContext(variables))
).resolves.toBe(2)
})
})
describe('edge cases', () => {
@@ -1,16 +1,24 @@
import { createLogger } from '@sim/logger'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { assertNoLargeValueRefs, isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import { VariableManager } from '@/lib/workflows/variables/variable-manager'
import { isReference, normalizeName, parseReferencePath, REFERENCE } from '@/executor/constants'
import {
type AsyncPathNavigator,
navigatePath,
type ResolutionContext,
type Resolver,
splitLeadingBracketPath,
} from '@/executor/variables/resolvers/reference'
import type { VariableType } from '@/stores/variables/types'
const logger = createLogger('WorkflowResolver')
export class WorkflowResolver implements Resolver {
constructor(private workflowVariables: Record<string, any>) {}
constructor(
private workflowVariables: Record<string, any>,
private navigatePathAsync?: AsyncPathNavigator
) {}
canResolve(reference: string): boolean {
if (!isReference(reference)) {
@@ -31,7 +39,10 @@ export class WorkflowResolver implements Resolver {
return undefined
}
const [_, variableName, ...pathParts] = parts
const [_, rawVariableName, ...rawPathParts] = parts
const { property: variableName, pathParts: bracketPathParts } =
splitLeadingBracketPath(rawVariableName)
const pathParts = [...bracketPathParts, ...rawPathParts]
const normalizedRefName = normalizeName(variableName)
const workflowVars = context.executionContext.workflowVariables || this.workflowVariables
@@ -45,25 +56,85 @@ export class WorkflowResolver implements Resolver {
if (normalizedVarName === normalizedRefName || v.id === variableName) {
const normalizedType = (v.type === 'string' ? 'plain' : v.type) || 'plain'
let value: any
try {
value = VariableManager.resolveForExecution(v.value, normalizedType)
} catch (error) {
logger.warn('Failed to resolve workflow variable, returning raw value', {
variableName,
error: (error as Error).message,
})
value = v.value
}
value = this.resolveVariableValue(v.value, normalizedType, variableName)
// If there are additional path parts, navigate deeper
if (pathParts.length > 0) {
return navigatePath(value, pathParts, { executionContext: context.executionContext })
return navigatePath(value, pathParts, {
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
})
}
if (!context.allowLargeValueRefs) {
assertNoLargeValueRefs(value)
}
return value
}
}
return undefined
}
async resolveAsync(reference: string, context: ResolutionContext): Promise<any> {
const parts = parseReferencePath(reference)
if (parts.length < 2) {
logger.warn('Invalid variable reference - missing variable name', { reference })
return undefined
}
const [_, rawVariableName, ...rawPathParts] = parts
const { property: variableName, pathParts: bracketPathParts } =
splitLeadingBracketPath(rawVariableName)
const pathParts = [...bracketPathParts, ...rawPathParts]
const normalizedRefName = normalizeName(variableName)
const workflowVars = context.executionContext.workflowVariables || this.workflowVariables
for (const varObj of Object.values(workflowVars)) {
const v = varObj as any
if (!v) continue
const normalizedVarName = v.name ? normalizeName(v.name) : ''
if (normalizedVarName === normalizedRefName || v.id === variableName) {
const normalizedType = (v.type === 'string' ? 'plain' : v.type) || 'plain'
let value: any
value = this.resolveVariableValue(v.value, normalizedType, variableName)
if (pathParts.length > 0) {
return this.navigatePathAsync
? this.navigatePathAsync(value, pathParts, context)
: navigatePath(value, pathParts, {
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
})
}
if (!context.allowLargeValueRefs) {
assertNoLargeValueRefs(value)
}
return value
}
}
return undefined
}
private resolveVariableValue(
value: any,
normalizedType: VariableType,
variableName: string
): any {
if (isLargeValueRef(value) || isLargeArrayManifest(value)) {
return value
}
try {
return VariableManager.resolveForExecution(value, normalizedType)
} catch (error) {
logger.warn('Failed to resolve workflow variable, returning raw value', {
variableName,
error: (error as Error).message,
})
return value
}
}
}
+2
View File
@@ -104,6 +104,8 @@ export const functionExecuteContract = defineRouteContract({
workflowId: z.string().optional(),
executionId: z.string().optional(),
largeValueExecutionIds: z.array(z.string()).optional(),
largeValueKeys: z.array(z.string()).optional(),
fileKeys: z.array(z.string()).optional(),
allowLargeValueWorkflowScope: z.boolean().optional(),
workspaceId: z.string().optional(),
userId: z.string().optional(),
@@ -3,10 +3,20 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { ensureWorkflowAccessMock, setWorkflowVariablesMock, recordAuditMock } = vi.hoisted(() => ({
const {
ensureWorkflowAccessMock,
setWorkflowVariablesMock,
recordAuditMock,
executeWorkflowMock,
getExecutionStateForWorkflowMock,
getLatestExecutionStateWithExecutionIdMock,
} = vi.hoisted(() => ({
ensureWorkflowAccessMock: vi.fn(),
setWorkflowVariablesMock: vi.fn(),
recordAuditMock: vi.fn(),
executeWorkflowMock: vi.fn(),
getExecutionStateForWorkflowMock: vi.fn(),
getLatestExecutionStateWithExecutionIdMock: vi.fn(),
}))
vi.mock('@sim/audit', () => ({
@@ -37,12 +47,12 @@ vi.mock('@/lib/core/utils/urls', () => ({
}))
vi.mock('@/lib/workflows/executor/execute-workflow', () => ({
executeWorkflow: vi.fn(),
executeWorkflow: executeWorkflowMock,
}))
vi.mock('@/lib/workflows/executor/execution-state', () => ({
getExecutionState: vi.fn(),
getLatestExecutionState: vi.fn(),
getExecutionStateForWorkflow: getExecutionStateForWorkflowMock,
getLatestExecutionStateWithExecutionId: getLatestExecutionStateWithExecutionIdMock,
}))
vi.mock('@/lib/workflows/orchestration', () => ({
@@ -79,7 +89,7 @@ vi.mock('../access', () => ({
getDefaultWorkspaceId: vi.fn(),
}))
import { executeSetGlobalWorkflowVariables } from './mutations'
import { executeRunFromBlock, executeSetGlobalWorkflowVariables } from './mutations'
describe('executeSetGlobalWorkflowVariables', () => {
beforeEach(() => {
@@ -124,3 +134,71 @@ describe('executeSetGlobalWorkflowVariables', () => {
expect(recordAuditMock).toHaveBeenCalled()
})
})
describe('executeRunFromBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
ensureWorkflowAccessMock.mockResolvedValue({
workflow: {
id: 'workflow-1',
userId: 'owner-1',
workspaceId: 'workspace-1',
variables: {},
},
})
executeWorkflowMock.mockResolvedValue({
success: true,
output: {},
logs: [],
metadata: { executionId: 'new-execution-1' },
})
})
it('passes source execution lineage for stored run-from-block snapshots', async () => {
const sourceSnapshot = {
blockStates: {
upstream: {
output: {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 10,
key: 'execution/workspace-1/workflow-1/source-execution-1/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'source-execution-1',
},
},
},
executedBlocks: [],
blockLogs: [],
decisions: {},
completedLoops: [],
activeExecutionPath: [],
}
getExecutionStateForWorkflowMock.mockResolvedValue(sourceSnapshot)
const result = await executeRunFromBlock(
{
workflowId: 'workflow-1',
startBlockId: 'agent-1',
executionId: 'source-execution-1',
},
{ userId: 'user-1' } as any
)
expect(result.success).toBe(true)
expect(executeWorkflowMock).toHaveBeenCalledWith(
expect.any(Object),
'request-1',
undefined,
'user-1',
expect.objectContaining({
runFromBlock: {
startBlockId: 'agent-1',
sourceSnapshot,
sourceExecutionId: 'source-execution-1',
},
})
)
})
})
@@ -11,8 +11,8 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { getSocketServerUrl } from '@/lib/core/utils/urls'
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
import {
getExecutionState,
getLatestExecutionState,
getExecutionStateForWorkflow,
getLatestExecutionStateWithExecutionId,
} from '@/lib/workflows/executor/execution-state'
import {
performCreateFolder,
@@ -28,6 +28,7 @@ import {
} from '@/lib/workflows/persistence/utils'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { listFolders, setWorkflowVariables, verifyFolderWorkspace } from '@/lib/workflows/utils'
import type { SerializableExecutionState } from '@/executor/execution/types'
import { hasExecutionResult } from '@/executor/utils/errors'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import { ensureWorkflowAccess, ensureWorkspaceAccess, getDefaultWorkspaceId } from '../access'
@@ -79,6 +80,33 @@ function buildExecutionError(error: unknown): ToolCallResult {
return { success: false, error: message }
}
async function resolveRunFromBlockSnapshot(
workflowId: string,
executionId?: string
): Promise<
| {
executionId: string
snapshot: SerializableExecutionState
}
| undefined
> {
const sourceExecution = executionId
? {
executionId,
state: await getExecutionStateForWorkflow(executionId, workflowId),
}
: await getLatestExecutionStateWithExecutionId(workflowId)
if (!sourceExecution?.state) {
return undefined
}
return {
executionId: sourceExecution.executionId,
snapshot: sourceExecution.state,
}
}
function resolveRunWorkflowInput(params: { workflow_input?: unknown; input?: unknown }): unknown {
if (Object.hasOwn(params, 'workflow_input')) {
return params.workflow_input
@@ -710,11 +738,9 @@ export async function executeRunFromBlock(
return { success: false, error: 'startBlockId is required' }
}
const snapshot = params.executionId
? await getExecutionState(params.executionId)
: await getLatestExecutionState(workflowId)
const sourceSnapshot = await resolveRunFromBlockSnapshot(workflowId, params.executionId)
if (!snapshot) {
if (!sourceSnapshot) {
return {
success: false,
error: params.executionId
@@ -744,7 +770,11 @@ export async function executeRunFromBlock(
enabled: true,
useDraftState,
workflowTriggerType: 'copilot',
runFromBlock: { startBlockId: params.startBlockId, sourceSnapshot: snapshot },
runFromBlock: {
startBlockId: params.startBlockId,
sourceSnapshot: sourceSnapshot.snapshot,
sourceExecutionId: sourceSnapshot.executionId,
},
}
)
@@ -1084,11 +1114,9 @@ export async function executeRunBlock(
return { success: false, error: 'blockId is required' }
}
const snapshot = params.executionId
? await getExecutionState(params.executionId)
: await getLatestExecutionState(workflowId)
const sourceSnapshot = await resolveRunFromBlockSnapshot(workflowId, params.executionId)
if (!snapshot) {
if (!sourceSnapshot) {
return {
success: false,
error: params.executionId
@@ -1118,7 +1146,11 @@ export async function executeRunBlock(
enabled: true,
useDraftState,
workflowTriggerType: 'copilot',
runFromBlock: { startBlockId: params.blockId, sourceSnapshot: snapshot },
runFromBlock: {
startBlockId: params.blockId,
sourceSnapshot: sourceSnapshot.snapshot,
sourceExecutionId: sourceSnapshot.executionId,
},
stopAfterBlockId: params.blockId,
}
)
@@ -0,0 +1,58 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { extractFieldValues, traverseObjectPath } from '@/lib/core/utils/response-format'
import {
LARGE_ARRAY_MANIFEST_VERSION,
type LargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest-metadata'
function createManifest(totalCount = 100_000): LargeArrayManifest {
return {
__simLargeArrayManifest: true,
version: LARGE_ARRAY_MANIFEST_VERSION,
kind: 'array',
totalCount,
chunkCount: 1,
byteSize: 12 * 1024 * 1024,
chunks: [
{
count: totalCount,
byteSize: 12 * 1024 * 1024,
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 12 * 1024 * 1024,
executionId: 'execution-1',
},
},
],
preview: [{ key: 'SIM-0' }],
}
}
describe('response format traversal', () => {
it('returns whole large array manifest metadata without materializing chunks', () => {
const manifest = createManifest()
expect(traverseObjectPath({ output: { rows: manifest } }, 'output.rows')).toEqual(manifest)
})
it('returns manifest totalCount for length selections', () => {
const manifest = createManifest()
expect(traverseObjectPath({ output: { rows: manifest } }, 'output.rows.length')).toBe(100_000)
expect(
extractFieldValues({ output: { rows: manifest } }, ['block-1_output.rows.length'], 'block-1')
).toEqual({ 'output.rows.length': 100_000 })
})
it('does not perform indexed manifest reads in sync traversal', () => {
const manifest = createManifest()
expect(traverseObjectPath({ output: { rows: manifest } }, 'output.rows.0.key')).toBeUndefined()
})
})
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { materializeLargeValueRefSyncOrThrow } from '@/lib/execution/payloads/cache'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
const logger = createLogger('ResponseFormatUtils')
@@ -202,6 +203,18 @@ function traverseObjectPathInternal(obj: any, path: string): any {
current = materializeLargeValueRefSyncOrThrow(current)
}
if (isLargeArrayManifest(current)) {
if (part === 'length' || part === 'totalCount') {
current = current.totalCount
continue
}
if (part === 'chunkCount' || part === 'byteSize' || part === 'preview') {
current = current[part]
continue
}
return undefined
}
if (current?.[part] !== undefined) {
current = current[part]
} else {
+36
View File
@@ -42,6 +42,42 @@ export function isUserFileWithMetadata(value: unknown): value is UserFile {
return typeof candidate.size === 'number' && typeof candidate.type === 'string'
}
/**
* Finds storage keys for UserFile objects embedded in a value.
*/
export function collectUserFileKeys(value: unknown): string[] {
const keys = new Set<string>()
collectUserFileKeysInto(value, keys, new WeakSet<object>())
return Array.from(keys)
}
function collectUserFileKeysInto(value: unknown, keys: Set<string>, seen: WeakSet<object>): void {
if (!value || typeof value !== 'object') {
return
}
if (seen.has(value)) {
return
}
seen.add(value)
if (isUserFileWithMetadata(value)) {
keys.add(value.key)
return
}
if (Array.isArray(value)) {
for (const item of value) {
collectUserFileKeysInto(item, keys, seen)
}
return
}
for (const item of Object.values(value)) {
collectUserFileKeysInto(item, keys, seen)
}
}
/**
* Checks if a value matches the display-safe UserFile metadata shape after internal fields are stripped.
*/
@@ -384,6 +384,7 @@ async function executeCode(request, executionId) {
}),
values: Object.freeze({
read: (ref, options) => callSimBroker('sim.values.read', { ref, options }),
readArray: (ref, options) => callSimBroker('sim.values.readArray', { ref, options }),
}),
});
})();
@@ -0,0 +1,41 @@
import { collectUserFileKeys } from '@/lib/core/utils/user-file'
import { collectLargeValueKeys } from '@/lib/execution/payloads/large-execution-value'
export interface ExactAccessKeyContext {
largeValueKeys?: string[]
fileKeys?: string[]
}
export function mergeUniqueKeys(target: string[], source: readonly string[]): void {
if (source.length === 0) {
return
}
const existingKeys = new Set(target)
for (const key of source) {
if (!existingKeys.has(key)) {
existingKeys.add(key)
target.push(key)
}
}
}
export function mergeLargeValueKeys(context: ExactAccessKeyContext, keys: readonly string[]): void {
if (keys.length === 0) {
return
}
context.largeValueKeys ??= []
mergeUniqueKeys(context.largeValueKeys, keys)
}
export function mergeFileKeys(context: ExactAccessKeyContext, keys: readonly string[]): void {
if (keys.length === 0) {
return
}
context.fileKeys ??= []
mergeUniqueKeys(context.fileKeys, keys)
}
export function recordMaterializedAccessKeys(context: ExactAccessKeyContext, value: unknown): void {
mergeLargeValueKeys(context, collectLargeValueKeys(value))
mergeFileKeys(context, collectUserFileKeys(value))
}
+4
View File
@@ -12,6 +12,7 @@ interface LargeValueCacheScope {
workflowId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
allowLargeValueWorkflowScope?: boolean
}
@@ -108,6 +109,9 @@ function scopeMatchesRef(
callerScope.executionId,
...(callerScope.largeValueExecutionIds ?? []),
])
if (ref.key && callerScope.largeValueKeys?.includes(ref.key)) {
return true
}
const workflowScopeAllowed =
callerScope.allowLargeValueWorkflowScope &&
callerScope.workspaceId === cachedScope.workspaceId &&
@@ -0,0 +1,144 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockMaterializeLargeValueRef } = vi.hoisted(() => ({
mockMaterializeLargeValueRef: vi.fn(),
}))
vi.mock('@/lib/execution/payloads/store', () => ({
materializeLargeValueRef: mockMaterializeLargeValueRef,
}))
import { warmLargeValueRefs } from '@/lib/execution/payloads/hydration'
describe('warmLargeValueRefs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('does not warm manifest chunks before explicit navigation', async () => {
const chunkRef = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 16,
executionId: 'execution-1',
}
const manifest = {
__simLargeArrayManifest: true,
version: 2,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: 16,
chunks: [
{
ref: chunkRef,
count: 1,
byteSize: 16,
},
],
preview: [],
}
await warmLargeValueRefs({ issues: manifest }, { executionId: 'execution-1' })
expect(mockMaterializeLargeValueRef).not.toHaveBeenCalled()
})
it('records exact keys discovered while warming manifest preview refs', async () => {
const previewRef = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 16,
key: 'execution/workspace-1/workflow-1/source-execution/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'source-execution',
}
const nestedRef = {
__simLargeValueRef: true,
version: 1,
id: 'lv_MNOPQRSTUVWX',
kind: 'object',
size: 16,
key: 'execution/workspace-1/workflow-1/source-execution/large-value-lv_MNOPQRSTUVWX.json',
executionId: 'source-execution',
}
const file = {
id: 'file-1',
name: 'nested.txt',
key: 'execution/workspace-1/workflow-1/source-execution/nested.txt',
url: '/api/files/serve/execution/workspace-1/workflow-1/source-execution/nested.txt?context=execution',
size: 5,
type: 'text/plain',
context: 'execution',
}
const manifest = {
__simLargeArrayManifest: true,
version: 2,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: 16,
chunks: [
{
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_CHUNKREF0001',
kind: 'array',
size: 16,
executionId: 'source-execution',
},
count: 1,
byteSize: 16,
},
],
preview: [previewRef],
}
const context = {
executionId: 'execution-1',
largeValueKeys: [] as string[],
fileKeys: [] as string[],
}
mockMaterializeLargeValueRef.mockResolvedValueOnce([{ nestedRef, file }])
await warmLargeValueRefs({ issues: manifest }, context)
expect(mockMaterializeLargeValueRef).toHaveBeenCalledWith(previewRef, context)
expect(context.largeValueKeys).toEqual([nestedRef.key])
expect(context.fileKeys).toEqual([file.key])
})
it('warms manifest preview refs without exposing chunk internals as navigable metadata', async () => {
const previewRef = {
__simLargeValueRef: true,
version: 1,
id: 'lv_PREVIEWREF01',
kind: 'object',
size: 16,
executionId: 'execution-1',
}
const manifest = {
__simLargeArrayManifest: true,
version: 2,
kind: 'array',
totalCount: 0,
chunkCount: 0,
byteSize: 0,
chunks: [],
preview: [previewRef],
}
mockMaterializeLargeValueRef.mockResolvedValueOnce({ key: 'SIM-1' })
await warmLargeValueRefs({ issues: manifest }, { executionId: 'execution-1' })
expect(mockMaterializeLargeValueRef).toHaveBeenCalledWith(previewRef, {
executionId: 'execution-1',
})
})
})
+27 -6
View File
@@ -1,9 +1,23 @@
import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import {
type LargeValueStoreContext,
materializeLargeValueRef,
} from '@/lib/execution/payloads/store'
function withLocalMaterializedKeys(
context: LargeValueStoreContext,
materializedValue: unknown
): LargeValueStoreContext {
recordMaterializedAccessKeys(context, materializedValue)
return {
...context,
largeValueKeys: context.largeValueKeys,
fileKeys: context.fileKeys,
}
}
export async function warmLargeValueRefs(
value: unknown,
context: LargeValueStoreContext = {},
@@ -15,7 +29,7 @@ export async function warmLargeValueRefs(
if (isLargeValueRef(value)) {
const materialized = await materializeLargeValueRef(value, context)
await warmLargeValueRefs(materialized, context, seen)
await warmLargeValueRefs(materialized, withLocalMaterializedKeys(context, materialized), seen)
return
}
@@ -24,12 +38,19 @@ export async function warmLargeValueRefs(
}
seen.add(value)
if (Array.isArray(value)) {
await Promise.all(value.map((item) => warmLargeValueRefs(item, context, seen)))
if (isLargeArrayManifest(value)) {
await warmLargeValueRefs(value.preview, context, seen)
return
}
await Promise.all(
Object.values(value).map((entryValue) => warmLargeValueRefs(entryValue, context, seen))
)
if (Array.isArray(value)) {
for (const item of value) {
await warmLargeValueRefs(item, context, seen)
}
return
}
for (const entryValue of Object.values(value)) {
await warmLargeValueRefs(entryValue, context, seen)
}
}
@@ -0,0 +1,167 @@
import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys'
import {
isLargeArrayManifest,
materializeLargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest'
import {
getLargeValueMaterializationError,
isLargeValueRef,
} from '@/lib/execution/payloads/large-value-ref'
import {
assertInlineMaterializationSize,
type ExecutionMaterializationContext,
MAX_INLINE_MATERIALIZATION_BYTES,
} from '@/lib/execution/payloads/materialization.server'
import { materializeLargeValueRef } from '@/lib/execution/payloads/store'
interface InlineMaterializationOptions {
maxBytes?: number
}
type InlineMaterializationMemo = WeakMap<object, Promise<unknown>>
interface MaterializedInlineValue {
value: unknown
byteLength: number | undefined
}
export function getInlineJsonByteLength(value: unknown): number | undefined {
const json = JSON.stringify(value)
return json === undefined ? undefined : Buffer.byteLength(json, 'utf8')
}
function getArrayItemByteLength(value: MaterializedInlineValue): number {
return value.byteLength ?? Buffer.byteLength('null', 'utf8')
}
function getObjectEntryByteLength(key: string, value: MaterializedInlineValue): number | undefined {
if (value.byteLength === undefined) {
return undefined
}
return Buffer.byteLength(JSON.stringify(key), 'utf8') + 1 + value.byteLength
}
function withMaterializedAccessKeys(
context: ExecutionMaterializationContext | undefined,
materializedValue: unknown
): ExecutionMaterializationContext | undefined {
if (!context) {
return context
}
recordMaterializedAccessKeys(context, materializedValue)
return {
...context,
largeValueKeys: context.largeValueKeys,
fileKeys: context.fileKeys,
}
}
export async function materializeInlineExecutionValue(
value: unknown,
context: ExecutionMaterializationContext | undefined,
options: InlineMaterializationOptions = {}
): Promise<unknown> {
const materialized = await materializeInlineExecutionValueWithinBudget(
value,
context,
options.maxBytes ?? MAX_INLINE_MATERIALIZATION_BYTES,
new WeakMap<object, Promise<unknown>>()
)
return materialized.value
}
async function materializeInlineExecutionValueWithinBudget(
value: unknown,
context: ExecutionMaterializationContext | undefined,
maxBytes: number,
memo: InlineMaterializationMemo
): Promise<MaterializedInlineValue> {
if (isLargeArrayManifest(value)) {
assertInlineMaterializationSize(value.byteSize, maxBytes)
const materialized = await materializeLargeArrayManifest(value, {
...context,
maxBytes,
})
return materializeInlineExecutionValueWithinBudget(
materialized,
withMaterializedAccessKeys(context, materialized),
maxBytes,
memo
)
}
if (isLargeValueRef(value)) {
assertInlineMaterializationSize(value.size, maxBytes)
const materialized = await materializeLargeValueRef(value, {
...context,
maxBytes,
})
if (materialized === undefined) {
throw getLargeValueMaterializationError(value)
}
return materializeInlineExecutionValueWithinBudget(
materialized,
withMaterializedAccessKeys(context, materialized),
maxBytes,
memo
)
}
if (!value || typeof value !== 'object') {
const valueBytes = getInlineJsonByteLength(value)
if (valueBytes !== undefined) {
assertInlineMaterializationSize(valueBytes, maxBytes)
}
return { value, byteLength: valueBytes }
}
const cached = memo.get(value)
if (cached) {
return { value: await cached, byteLength: 0 }
}
if (Array.isArray(value)) {
const result: unknown[] = []
memo.set(value, Promise.resolve(result))
let usedBytes = Buffer.byteLength('[]', 'utf8')
for (const item of value) {
const commaBytes = result.length > 0 ? 1 : 0
const remainingBytes = maxBytes - usedBytes - commaBytes
assertInlineMaterializationSize(0, remainingBytes)
const materializedItem = await materializeInlineExecutionValueWithinBudget(
item,
context,
remainingBytes,
memo
)
const itemBytes = getArrayItemByteLength(materializedItem)
usedBytes += commaBytes + itemBytes
assertInlineMaterializationSize(usedBytes, maxBytes)
result.push(materializedItem.value)
}
return { value: result, byteLength: usedBytes }
}
const result: Record<string, unknown> = {}
memo.set(value, Promise.resolve(result))
let usedBytes = Buffer.byteLength('{}', 'utf8')
for (const [key, entryValue] of Object.entries(value as Record<string, unknown>)) {
const keyBytes = Buffer.byteLength(JSON.stringify(key), 'utf8') + 1
const commaBytes = Object.keys(result).length > 0 ? 1 : 0
const remainingBytes = maxBytes - usedBytes - commaBytes - keyBytes
assertInlineMaterializationSize(0, remainingBytes)
const materializedEntryValue = await materializeInlineExecutionValueWithinBudget(
entryValue,
context,
remainingBytes,
memo
)
const entryBytes = getObjectEntryByteLength(key, materializedEntryValue)
if (entryBytes !== undefined) {
usedBytes += commaBytes + entryBytes
assertInlineMaterializationSize(usedBytes, maxBytes)
}
result[key] = materializedEntryValue.value
}
return { value: result, byteLength: usedBytes }
}
@@ -0,0 +1,80 @@
import { isLargeValueRef, type LargeValueRef } from '@/lib/execution/payloads/large-value-ref'
export const LARGE_ARRAY_MANIFEST_MARKER = '__simLargeArrayManifest'
export const LARGE_ARRAY_MANIFEST_VERSION = 2
export const LARGE_ARRAY_MANIFEST_PREVIEW_MAX_BYTES = 16 * 1024
export interface LargeArrayManifest {
[LARGE_ARRAY_MANIFEST_MARKER]: true
version: typeof LARGE_ARRAY_MANIFEST_VERSION
kind: 'array'
totalCount: number
chunkCount: number
byteSize: number
chunks: LargeArrayManifestChunk[]
preview: unknown[]
}
export interface LargeArrayManifestChunk {
ref: LargeValueRef
count: number
byteSize: number
}
function isValidCount(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0
}
function isValidByteSize(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
}
function isValidPreview(value: unknown): value is unknown[] {
return Array.isArray(value) && value.length <= 3
}
export function isLargeArrayManifest(value: unknown): value is LargeArrayManifest {
if (!value || typeof value !== 'object') {
return false
}
const candidate = value as Record<string, unknown>
if (
candidate[LARGE_ARRAY_MANIFEST_MARKER] !== true ||
candidate.version !== LARGE_ARRAY_MANIFEST_VERSION ||
candidate.kind !== 'array' ||
!isValidCount(candidate.totalCount) ||
!isValidCount(candidate.chunkCount) ||
!isValidByteSize(candidate.byteSize) ||
!Array.isArray(candidate.chunks) ||
!isValidPreview(candidate.preview) ||
candidate.chunkCount !== candidate.chunks.length
) {
return false
}
let totalCount = 0
let byteSize = 0
for (const chunk of candidate.chunks) {
if (!chunk || typeof chunk !== 'object') {
return false
}
const chunkRecord = chunk as Record<string, unknown>
if (
!isLargeValueRef(chunkRecord.ref) ||
!isValidCount(chunkRecord.count) ||
chunkRecord.count <= 0 ||
!isValidByteSize(chunkRecord.byteSize) ||
chunkRecord.byteSize <= 0 ||
chunkRecord.byteSize !== chunkRecord.ref.size
) {
return false
}
totalCount += chunkRecord.count
byteSize += chunkRecord.ref.size
}
return candidate.totalCount === totalCount && candidate.byteSize === byteSize
}
@@ -0,0 +1,183 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import {
appendLargeArrayManifest,
createLargeArrayManifest,
isLargeArrayManifest,
materializeLargeArrayManifest,
readLargeArrayManifestSlice,
} from '@/lib/execution/payloads/large-array-manifest'
import { EXECUTION_RESOURCE_LIMIT_CODE } from '@/lib/execution/resource-errors'
const { mockDownloadFile, mockUploadFile } = vi.hoisted(() => ({
mockDownloadFile: vi.fn(),
mockUploadFile: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
downloadFile: mockDownloadFile,
uploadFile: mockUploadFile,
},
}))
const TEST_CONTEXT = {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
}
describe('large array manifests', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
mockDownloadFile.mockReset()
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
})
it('creates a manifest with one chunk for the first page', async () => {
const manifest = await createLargeArrayManifest([{ id: 1 }, { id: 2 }], TEST_CONTEXT)
expect(isLargeArrayManifest(manifest)).toBe(true)
expect(manifest).toMatchObject({
__simLargeArrayManifest: true,
kind: 'array',
totalCount: 2,
chunkCount: 1,
preview: [{ id: 1 }, { id: 2 }],
})
expect(manifest.chunks).toEqual([
expect.objectContaining({ count: 2, byteSize: expect.any(Number) }),
])
expect(mockUploadFile).toHaveBeenCalledTimes(1)
})
it('appends pages without materializing previous chunks', async () => {
const firstPage = await createLargeArrayManifest([{ id: 1 }], TEST_CONTEXT)
clearLargeValueCacheForTests()
const manifest = await appendLargeArrayManifest(firstPage, [{ id: 2 }, { id: 3 }], TEST_CONTEXT)
expect(manifest.totalCount).toBe(3)
expect(manifest.chunkCount).toBe(2)
expect(manifest.chunks).toHaveLength(2)
expect(mockUploadFile).toHaveBeenCalledTimes(2)
})
it('reads a bounded slice from only requested positions', async () => {
let manifest = await createLargeArrayManifest([{ id: 1 }, { id: 2 }], TEST_CONTEXT)
manifest = await appendLargeArrayManifest(manifest, [{ id: 3 }, { id: 4 }], TEST_CONTEXT)
await expect(readLargeArrayManifestSlice(manifest, 1, 2, TEST_CONTEXT)).resolves.toEqual([
{ id: 2 },
{ id: 3 },
])
})
it('splits oversized pages into bounded chunks', async () => {
const manifest = await createLargeArrayManifest(
[
{ id: 1, payload: 'x'.repeat(80) },
{ id: 2, payload: 'y'.repeat(80) },
{ id: 3, payload: 'z'.repeat(80) },
],
{ ...TEST_CONTEXT, chunkTargetBytes: 128 }
)
expect(manifest.totalCount).toBe(3)
expect(manifest.chunkCount).toBe(3)
expect(manifest.chunks.map((chunk) => chunk.count)).toEqual([1, 1, 1])
expect(mockUploadFile).toHaveBeenCalledTimes(3)
})
it('chunks arrays with undefined entries using JSON array semantics', async () => {
const manifest = await createLargeArrayManifest([{ id: 1 }, undefined, { id: 3 }], {
...TEST_CONTEXT,
chunkTargetBytes: 16,
})
expect(manifest.totalCount).toBe(3)
await expect(readLargeArrayManifestSlice(manifest, 1, 1, TEST_CONTEXT)).resolves.toEqual([
undefined,
])
})
it('reports non-serializable chunk values with a manifest-specific error', async () => {
const circular: Record<string, unknown> = { id: 1 }
circular.self = circular
await expect(createLargeArrayManifest([circular], TEST_CONTEXT)).rejects.toThrow(
'Large array manifest chunks must be JSON-serializable.'
)
await expect(createLargeArrayManifest([{ id: 1n }], TEST_CONTEXT)).rejects.toThrow(
'Large array manifest chunks must be JSON-serializable.'
)
})
it('skips preceding chunks without materializing them for bounded reads', async () => {
let manifest = await createLargeArrayManifest([{ id: 1 }, { id: 2 }], TEST_CONTEXT)
manifest = await appendLargeArrayManifest(manifest, [{ id: 3 }, { id: 4 }], TEST_CONTEXT)
clearLargeValueCacheForTests()
mockDownloadFile.mockImplementation(async ({ key }) => {
expect(key).toBe(manifest.chunks[1].ref.key)
return Buffer.from(JSON.stringify([{ id: 3 }, { id: 4 }]))
})
await expect(readLargeArrayManifestSlice(manifest, 2, 1, TEST_CONTEXT)).resolves.toEqual([
{ id: 3 },
])
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
})
it('bounds full materialization by byte size', async () => {
const manifest = await createLargeArrayManifest([{ id: 1, payload: 'x'.repeat(2048) }], {
...TEST_CONTEXT,
})
await expect(
materializeLargeArrayManifest(manifest, { ...TEST_CONTEXT, maxBytes: 256 })
).rejects.toMatchObject({ code: EXECUTION_RESOURCE_LIMIT_CODE })
})
it('rejects manifests with understated aggregate byte size', async () => {
const manifest = await createLargeArrayManifest([{ id: 1, payload: 'x'.repeat(2048) }], {
...TEST_CONTEXT,
})
await expect(
materializeLargeArrayManifest({ ...manifest, byteSize: 1 }, { ...TEST_CONTEXT })
).rejects.toThrow('Invalid large array manifest')
})
it('rejects manifests whose chunk count does not match materialized data', async () => {
const manifest = await createLargeArrayManifest([{ id: 1 }], TEST_CONTEXT)
const forgedManifest = {
...manifest,
totalCount: 2,
chunks: [{ ...manifest.chunks[0], count: 2 }],
}
expect(isLargeArrayManifest(forgedManifest)).toBe(true)
await expect(readLargeArrayManifestSlice(forgedManifest, 1, 1, TEST_CONTEXT)).rejects.toThrow(
'Large array manifest chunk count does not match materialized data'
)
})
it('does not serialize preview metadata during hot type-guard checks', async () => {
const manifest = await createLargeArrayManifest([{ id: 1 }], TEST_CONTEXT)
const stringifySpy = vi.spyOn(JSON, 'stringify')
expect(
isLargeArrayManifest({
...manifest,
preview: [{ payload: 'x'.repeat(20 * 1024) }],
})
).toBe(true)
expect(stringifySpy).not.toHaveBeenCalled()
stringifySpy.mockRestore()
})
})
@@ -0,0 +1,250 @@
import {
isLargeArrayManifest,
LARGE_ARRAY_MANIFEST_PREVIEW_MAX_BYTES,
LARGE_ARRAY_MANIFEST_VERSION,
type LargeArrayManifest,
type LargeArrayManifestChunk,
} from '@/lib/execution/payloads/large-array-manifest-metadata'
import {
assertInlineMaterializationSize,
MAX_INLINE_MATERIALIZATION_BYTES,
} from '@/lib/execution/payloads/materialization.server'
import type { LargeValueStoreContext } from '@/lib/execution/payloads/store'
import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store'
export {
isLargeArrayManifest,
LARGE_ARRAY_MANIFEST_MARKER,
LARGE_ARRAY_MANIFEST_PREVIEW_MAX_BYTES,
LARGE_ARRAY_MANIFEST_VERSION,
type LargeArrayManifest,
type LargeArrayManifestChunk,
} from '@/lib/execution/payloads/large-array-manifest-metadata'
export const LARGE_ARRAY_MANIFEST_CHUNK_TARGET_BYTES = Math.floor(
MAX_INLINE_MATERIALIZATION_BYTES / 2
)
const LARGE_ARRAY_MANIFEST_JSON_SERIALIZATION_ERROR =
'Large array manifest chunks must be JSON-serializable.'
export interface LargeArrayManifestReadOptions extends LargeValueStoreContext {
maxBytes?: number
}
export interface LargeArrayManifestWriteOptions extends LargeValueStoreContext {
chunkTargetBytes?: number
}
function measureJson(value: unknown): { json: string; size: number } {
let json: string | undefined
try {
json = JSON.stringify(value)
} catch {
throw new Error(LARGE_ARRAY_MANIFEST_JSON_SERIALIZATION_ERROR)
}
if (json === undefined) {
throw new Error(LARGE_ARRAY_MANIFEST_JSON_SERIALIZATION_ERROR)
}
return { json, size: Buffer.byteLength(json, 'utf8') }
}
function assertArray(value: unknown): asserts value is unknown[] {
if (!Array.isArray(value)) {
throw new Error('Large array manifest chunks must materialize to arrays.')
}
}
function assertChunkCount(chunk: unknown[], expectedCount: number): void {
if (chunk.length !== expectedCount) {
throw new Error('Large array manifest chunk count does not match materialized data.')
}
}
function getPreview(items: unknown[]): unknown[] {
const preview: unknown[] = []
for (const item of items.slice(0, 3)) {
const candidate = [...preview, item]
try {
if (measureJson(candidate).size > LARGE_ARRAY_MANIFEST_PREVIEW_MAX_BYTES) {
break
}
} catch {
break
}
preview.push(item)
}
return preview
}
function measureArrayElementJsonSize(item: unknown): number {
const measured = measureJson([item])
return Math.max(0, measured.size - 2)
}
async function storeArrayChunk(
items: unknown[],
context: LargeArrayManifestWriteOptions
): Promise<LargeArrayManifestChunk> {
const measured = measureJson(items)
const ref = await storeLargeValue(items, measured.json, measured.size, {
...context,
requireDurable: true,
})
return { ref, count: items.length, byteSize: measured.size }
}
function chunkArrayItems(items: unknown[], targetBytes: number): unknown[][] {
const chunks: unknown[][] = []
let current: unknown[] = []
let currentBytes = 2
for (const item of items) {
const itemBytes = measureArrayElementJsonSize(item)
const separatorBytes = current.length > 0 ? 1 : 0
if (current.length > 0 && currentBytes + separatorBytes + itemBytes > targetBytes) {
chunks.push(current)
current = []
currentBytes = 2
}
current.push(item)
currentBytes += (current.length > 1 ? 1 : 0) + itemBytes
}
if (current.length > 0) {
chunks.push(current)
}
return chunks
}
async function storeArrayChunks(
items: unknown[],
context: LargeArrayManifestWriteOptions
): Promise<LargeArrayManifestChunk[]> {
const targetBytes = Math.max(
2,
Math.min(
context.chunkTargetBytes ?? LARGE_ARRAY_MANIFEST_CHUNK_TARGET_BYTES,
MAX_INLINE_MATERIALIZATION_BYTES
)
)
const chunks = chunkArrayItems(items, targetBytes)
const storedChunks: LargeArrayManifestChunk[] = []
for (const chunk of chunks) {
storedChunks.push(await storeArrayChunk(chunk, context))
}
return storedChunks
}
function assertLargeArrayManifest(value: LargeArrayManifest): void {
if (!isLargeArrayManifest(value)) {
throw new Error('Invalid large array manifest.')
}
}
export async function createLargeArrayManifest(
items: unknown[],
context: LargeArrayManifestWriteOptions
): Promise<LargeArrayManifest> {
if (items.length === 0) {
return {
__simLargeArrayManifest: true,
version: LARGE_ARRAY_MANIFEST_VERSION,
kind: 'array',
totalCount: 0,
chunkCount: 0,
byteSize: 0,
chunks: [],
preview: [],
}
}
const chunks = await storeArrayChunks(items, context)
const byteSize = chunks.reduce((sum, chunk) => sum + chunk.byteSize, 0)
return {
__simLargeArrayManifest: true,
version: LARGE_ARRAY_MANIFEST_VERSION,
kind: 'array',
totalCount: items.length,
chunkCount: chunks.length,
byteSize,
chunks,
preview: getPreview(items),
}
}
export async function appendLargeArrayManifest(
manifest: LargeArrayManifest,
items: unknown[],
context: LargeArrayManifestWriteOptions
): Promise<LargeArrayManifest> {
if (items.length === 0) {
return manifest
}
const chunks = await storeArrayChunks(items, context)
const byteSize = chunks.reduce((sum, chunk) => sum + chunk.byteSize, 0)
return {
...manifest,
totalCount: manifest.totalCount + items.length,
chunkCount: manifest.chunkCount + chunks.length,
byteSize: manifest.byteSize + byteSize,
chunks: [...manifest.chunks, ...chunks],
preview: manifest.preview.length > 0 ? manifest.preview : getPreview(items),
}
}
export async function readLargeArrayManifestSlice(
manifest: LargeArrayManifest,
start: number,
limit: number,
context: LargeArrayManifestReadOptions
): Promise<unknown[]> {
assertLargeArrayManifest(manifest)
const normalizedStart = Math.max(0, Math.floor(start))
const normalizedLimit = Math.max(0, Math.floor(limit))
if (normalizedLimit === 0 || normalizedStart >= manifest.totalCount) {
return []
}
const end = Math.min(manifest.totalCount, normalizedStart + normalizedLimit)
const results: unknown[] = []
let cursor = 0
for (const chunkEntry of manifest.chunks) {
const chunkStart = cursor
const chunkEnd = cursor + chunkEntry.count
if (chunkEnd <= normalizedStart || chunkStart >= end) {
cursor = chunkEnd
continue
}
const chunk = await materializeLargeValueRef(chunkEntry.ref, context)
if (chunk === undefined) {
throw new Error('Large array manifest chunk is unavailable.')
}
assertArray(chunk)
assertChunkCount(chunk, chunkEntry.count)
const from = Math.max(0, normalizedStart - chunkStart)
const to = Math.min(chunk.length, end - chunkStart)
results.push(...chunk.slice(from, to))
cursor = chunkEnd
if (cursor >= end) {
break
}
}
return results
}
export async function materializeLargeArrayManifest(
manifest: LargeArrayManifest,
context: LargeArrayManifestReadOptions
): Promise<unknown[]> {
assertLargeArrayManifest(manifest)
assertInlineMaterializationSize(manifest.byteSize, context.maxBytes)
return readLargeArrayManifestSlice(manifest, 0, manifest.totalCount, context)
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import {
LARGE_ARRAY_MANIFEST_MARKER,
LARGE_ARRAY_MANIFEST_VERSION,
type LargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest-metadata'
import {
collectLargeValueExecutionIds,
collectLargeValueKeys,
} from '@/lib/execution/payloads/large-execution-value'
import {
LARGE_VALUE_REF_MARKER,
LARGE_VALUE_REF_VERSION,
type LargeValueRef,
} from '@/lib/execution/payloads/large-value-ref'
function largeValueRef(id: string, executionId: string): LargeValueRef {
return {
[LARGE_VALUE_REF_MARKER]: true,
version: LARGE_VALUE_REF_VERSION,
id,
kind: 'object',
size: 10,
key: `execution/workspace-1/workflow-1/${executionId}/large-value-${id}.json`,
executionId,
}
}
function largeArrayManifest(executionId: string): LargeArrayManifest {
const ref = largeValueRef('lv_MNOPQRSTUVWX', executionId)
return {
[LARGE_ARRAY_MANIFEST_MARKER]: true,
version: LARGE_ARRAY_MANIFEST_VERSION,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: ref.size,
chunks: [{ ref, count: 1, byteSize: ref.size }],
preview: [],
}
}
describe('collectLargeValueExecutionIds', () => {
it('collects deduplicated execution IDs from nested refs and manifests', () => {
const executionIds = collectLargeValueExecutionIds({
blockStates: {
upstream: {
output: {
directRef: largeValueRef('lv_ABCDEFGHIJKL', 'execution-a'),
inheritedManifest: largeArrayManifest('execution-b'),
duplicateRef: largeValueRef('lv_NOPQRSTUVWXY', 'execution-a'),
},
},
},
})
expect(executionIds).toEqual(['execution-a', 'execution-b'])
})
it('collects deduplicated storage keys from nested refs and manifests', () => {
const keys = collectLargeValueKeys({
directRef: largeValueRef('lv_ABCDEFGHIJKL', 'execution-a'),
manifest: largeArrayManifest('execution-b'),
})
expect(keys).toEqual([
'execution/workspace-1/workflow-1/execution-a/large-value-lv_ABCDEFGHIJKL.json',
'execution/workspace-1/workflow-1/execution-b/large-value-lv_MNOPQRSTUVWX.json',
])
})
})
@@ -0,0 +1,130 @@
import {
isLargeArrayManifest,
type LargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest-metadata'
import { isLargeValueRef, type LargeValueRef } from '@/lib/execution/payloads/large-value-ref'
export type LargeExecutionValue = LargeValueRef | LargeArrayManifest
/**
* Parses execution values that must survive type coercion as refs.
*/
export function parseLargeExecutionValue(value: unknown): LargeExecutionValue | undefined {
if (isLargeValueRef(value) || isLargeArrayManifest(value)) {
return value
}
if (typeof value !== 'string' || !value.trim()) {
return undefined
}
try {
const parsed = JSON.parse(value)
return isLargeValueRef(parsed) || isLargeArrayManifest(parsed) ? parsed : undefined
} catch {
return undefined
}
}
/**
* Finds execution IDs referenced by large values embedded in persisted execution state.
*/
export function collectLargeValueExecutionIds(value: unknown): string[] {
const executionIds = new Set<string>()
collectLargeValueExecutionIdsInto(value, executionIds, new WeakSet<object>())
return Array.from(executionIds)
}
export function collectLargeValueKeys(value: unknown): string[] {
const keys = new Set<string>()
collectLargeValueKeysInto(value, keys, new WeakSet<object>())
return Array.from(keys)
}
function collectLargeValueExecutionIdsInto(
value: unknown,
executionIds: Set<string>,
seen: WeakSet<object>
): void {
if (!value || typeof value !== 'object') {
return
}
if (seen.has(value)) {
return
}
seen.add(value)
if (isLargeValueRef(value)) {
addExecutionId(value, executionIds)
collectLargeValueExecutionIdsInto(value.preview, executionIds, seen)
return
}
if (isLargeArrayManifest(value)) {
for (const chunk of value.chunks) {
addExecutionId(chunk.ref, executionIds)
}
collectLargeValueExecutionIdsInto(value.preview, executionIds, seen)
return
}
if (Array.isArray(value)) {
for (const item of value) {
collectLargeValueExecutionIdsInto(item, executionIds, seen)
}
return
}
for (const item of Object.values(value)) {
collectLargeValueExecutionIdsInto(item, executionIds, seen)
}
}
function addExecutionId(ref: LargeValueRef, executionIds: Set<string>): void {
if (ref.executionId) {
executionIds.add(ref.executionId)
}
}
function collectLargeValueKeysInto(value: unknown, keys: Set<string>, seen: WeakSet<object>): void {
if (!value || typeof value !== 'object') {
return
}
if (seen.has(value)) {
return
}
seen.add(value)
if (isLargeValueRef(value)) {
addKey(value, keys)
collectLargeValueKeysInto(value.preview, keys, seen)
return
}
if (isLargeArrayManifest(value)) {
for (const chunk of value.chunks) {
addKey(chunk.ref, keys)
}
collectLargeValueKeysInto(value.preview, keys, seen)
return
}
if (Array.isArray(value)) {
for (const item of value) {
collectLargeValueKeysInto(item, keys, seen)
}
return
}
for (const item of Object.values(value)) {
collectLargeValueKeysInto(item, keys, seen)
}
}
function addKey(ref: LargeValueRef, keys: Set<string>): void {
if (ref.key) {
keys.add(ref.key)
}
}
@@ -25,6 +25,8 @@ export interface ExecutionMaterializationContext {
workspaceId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
requestId?: string
@@ -84,6 +86,7 @@ export function assertLargeValueRefAccess(
context.executionId,
...(context.largeValueExecutionIds ?? []),
])
const allowedKeys = new Set(context.largeValueKeys ?? [])
const parts = ref.key?.split('/') ?? []
const [, workspaceId, workflowId, executionId] = parts
@@ -101,18 +104,21 @@ export function assertLargeValueRefAccess(
context.allowLargeValueWorkflowScope &&
context.workspaceId === workspaceId &&
context.workflowId === workflowId
if (ref.executionId && !allowedExecutionIds.has(ref.executionId) && !workflowScopeAllowed) {
throw new Error('Large execution value is not available in this execution.')
}
if (!allowedExecutionIds.has(executionId) && !workflowScopeAllowed) {
throw new Error('Large execution value is not available in this execution.')
}
if (context.workspaceId && workspaceId !== context.workspaceId) {
throw new Error('Large execution value is not available in this execution.')
}
if (context.workflowId && workflowId !== context.workflowId) {
throw new Error('Large execution value is not available in this execution.')
}
if (allowedKeys.has(ref.key)) {
return
}
if (ref.executionId && !allowedExecutionIds.has(ref.executionId) && !workflowScopeAllowed) {
throw new Error('Large execution value is not available in this execution.')
}
if (!allowedExecutionIds.has(executionId) && !workflowScopeAllowed) {
throw new Error('Large execution value is not available in this execution.')
}
}
export async function readLargeValueRefFromStorage(
@@ -191,16 +197,11 @@ function assertExecutionFileScope(key: string, options: ExecutionMaterialization
options.executionId,
...(options.largeValueExecutionIds ?? []),
])
const allowedFileKeys = new Set(options.fileKeys ?? [])
const workflowScopeAllowed =
options.allowLargeValueWorkflowScope &&
options.workspaceId === parts.workspaceId &&
options.workflowId === parts.workflowId
if (
!options.executionId ||
(!allowedExecutionIds.has(parts.executionId) && !workflowScopeAllowed)
) {
throw new Error('File is not available in this execution.')
}
if (options.workspaceId && parts.workspaceId !== options.workspaceId) {
throw new Error('File is not available in this execution.')
@@ -209,6 +210,17 @@ function assertExecutionFileScope(key: string, options: ExecutionMaterialization
if (options.workflowId && parts.workflowId !== options.workflowId) {
throw new Error('File is not available in this execution.')
}
if (allowedFileKeys.has(key)) {
return
}
if (
!options.executionId ||
(!allowedExecutionIds.has(parts.executionId) && !workflowScopeAllowed)
) {
throw new Error('File is not available in this execution.')
}
}
function getVerifiedStorageContext(file: UserFile): StorageContext {
@@ -2,18 +2,23 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
isLargeArrayManifest,
LARGE_ARRAY_MANIFEST_VERSION,
readLargeArrayManifestSlice,
} from '@/lib/execution/payloads/large-array-manifest'
import {
getLargeValueMaterializationError,
isLargeValueRef,
} from '@/lib/execution/payloads/large-value-ref'
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import { compactExecutionPayload, compactSubflowResults } from '@/lib/execution/payloads/serializer'
import type { UserFile } from '@/executor/types'
import { navigatePath } from '@/executor/variables/resolvers/reference'
const TEST_EXECUTION_CONTEXT = {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
}
describe('compactExecutionPayload', () => {
@@ -57,19 +62,33 @@ describe('compactExecutionPayload', () => {
})
})
it('stores oversized arrays as refs and allows nested path navigation in-process', async () => {
it('stores oversized arrays as manifests and allows bounded slice reads', async () => {
const results = Array.from({ length: 100 }, (_, index) => [{ event: { id: `event-${index}` } }])
const compacted = await compactExecutionPayload(
{ results },
{ thresholdBytes: 256, ...TEST_EXECUTION_CONTEXT }
{ thresholdBytes: 1024, ...TEST_EXECUTION_CONTEXT }
)
expect(isLargeValueRef(compacted.results)).toBe(true)
expect(
navigatePath(compacted, ['results', '1', '0', 'event', 'id'], {
executionContext: TEST_EXECUTION_CONTEXT,
})
).toBe('event-1')
expect(isLargeArrayManifest(compacted.results)).toBe(true)
expect(compacted.results.totalCount).toBe(100)
await expect(
readLargeArrayManifestSlice(compacted.results, 1, 1, TEST_EXECUTION_CONTEXT)
).resolves.toEqual([[{ event: { id: 'event-1' } }]])
})
it('keeps oversized strings and objects as large value refs', async () => {
const compacted = await compactExecutionPayload(
{
text: 'x'.repeat(2048),
metadata: Object.fromEntries(
Array.from({ length: 100 }, (_, index) => [`key-${index}`, `value-${index}`])
),
},
{ thresholdBytes: 1024, ...TEST_EXECUTION_CONTEXT }
)
expect(isLargeValueRef(compacted.text)).toBe(true)
expect(isLargeValueRef(compacted.metadata)).toBe(true)
})
it('does not double-spill existing refs', async () => {
@@ -83,6 +102,125 @@ describe('compactExecutionPayload', () => {
expect(compactedAgain).toEqual(compacted)
})
it('bounds user-supplied manifest-shaped metadata during compaction', async () => {
const forgedManifest = {
__simLargeArrayManifest: true,
version: LARGE_ARRAY_MANIFEST_VERSION,
kind: 'array',
totalCount: 2,
chunkCount: 2,
byteSize: 2,
chunks: [
{
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 1,
executionId: TEST_EXECUTION_CONTEXT.executionId,
},
count: 1,
byteSize: 1,
},
{
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_MNOPQRSTUVWX',
kind: 'array',
size: 1,
executionId: TEST_EXECUTION_CONTEXT.executionId,
},
count: 1,
byteSize: 1,
},
],
preview: [],
}
expect(isLargeArrayManifest(forgedManifest)).toBe(true)
const compacted = await compactExecutionPayload(forgedManifest, {
thresholdBytes: 128,
preserveRoot: true,
...TEST_EXECUTION_CONTEXT,
})
expect(isLargeValueRef(compacted)).toBe(true)
})
it('bounds oversized manifest preview metadata during compaction', async () => {
const forgedManifest = {
__simLargeArrayManifest: true,
version: LARGE_ARRAY_MANIFEST_VERSION,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: 1,
chunks: [
{
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 1,
executionId: TEST_EXECUTION_CONTEXT.executionId,
},
count: 1,
byteSize: 1,
},
],
preview: [{ payload: 'x'.repeat(20 * 1024) }],
}
expect(isLargeArrayManifest(forgedManifest)).toBe(true)
const compacted = await compactExecutionPayload(forgedManifest, {
thresholdBytes: 128,
preserveRoot: true,
...TEST_EXECUTION_CONTEXT,
})
expect(isLargeValueRef(compacted)).toBe(true)
})
it('does not re-wrap manifests when forcing oversized subflow result entries', async () => {
const manifest = {
__simLargeArrayManifest: true,
version: LARGE_ARRAY_MANIFEST_VERSION,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: 1,
chunks: [
{
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 1,
executionId: TEST_EXECUTION_CONTEXT.executionId,
},
count: 1,
byteSize: 1,
},
],
preview: [],
}
const thresholdBytes = Buffer.byteLength(JSON.stringify(manifest), 'utf8') + 8
const compacted = await compactSubflowResults([manifest, manifest], {
thresholdBytes,
...TEST_EXECUTION_CONTEXT,
})
expect(compacted).toEqual([manifest, manifest])
expect(compacted.every(isLargeArrayManifest)).toBe(true)
})
it('rejects durable compaction when storage context is incomplete', async () => {
await expect(
compactExecutionPayload(
+33 -4
View File
@@ -1,4 +1,8 @@
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import {
createLargeArrayManifest,
isLargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest'
import {
isLargeValueRef,
LARGE_VALUE_THRESHOLD_BYTES,
@@ -36,6 +40,10 @@ function stripUserFileBase64<T extends { base64?: unknown }>(value: T): Omit<T,
return rest
}
function canPersistDurably(options: CompactExecutionPayloadOptions): boolean {
return Boolean(options.workspaceId && options.workflowId && options.executionId)
}
async function compactValue(
value: unknown,
options: CompactExecutionPayloadOptions,
@@ -56,6 +64,14 @@ async function compactValue(
return value
}
if (isLargeArrayManifest(value)) {
const measured = getJsonAndSize(value)
if (measured && measured.size > (options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES)) {
return storeLargeValue(value, measured.json, measured.size, options)
}
return value
}
if (isUserFileWithMetadata(value) && !options.preserveUserFileBase64) {
return stripUserFileBase64(value)
}
@@ -80,9 +96,15 @@ async function compactValue(
const measured = getJsonAndSize(compacted)
if (measured && measured.size > (options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES)) {
return options.preserveRoot && depth === 0
? compacted
: storeLargeValue(compacted, measured.json, measured.size, options)
if (Array.isArray(compacted) && (canPersistDurably(options) || options.requireDurable)) {
return createLargeArrayManifest(compacted, { ...options, requireDurable: true })
}
if (options.preserveRoot && depth === 0) {
return compacted
}
return storeLargeValue(compacted, measured.json, measured.size, options)
}
return compacted
@@ -92,7 +114,7 @@ async function forceStoreValue(
value: unknown,
options: CompactExecutionPayloadOptions
): Promise<unknown> {
if (isLargeValueRef(value)) {
if (isLargeValueRef(value) || isLargeArrayManifest(value)) {
return value
}
const measured = getJsonAndSize(value)
@@ -109,6 +131,13 @@ export async function compactExecutionPayload<T>(
return (await compactValue(value, options, { seen: new WeakSet<object>() })) as T
}
export async function compactWorkflowVariableValue<T>(
value: T,
options: CompactExecutionPayloadOptions = {}
): Promise<T> {
return compactExecutionPayload(value, { ...options, requireDurable: true })
}
/**
* Compacts subflow result aggregates while preserving indexable `results`.
*/
+3
View File
@@ -24,6 +24,8 @@ export interface LargeValueStoreContext {
workflowId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
requireDurable?: boolean
@@ -152,6 +154,7 @@ export async function materializeLargeValueRef(
workflowId: context.workflowId,
executionId: context.executionId,
largeValueExecutionIds: context.largeValueExecutionIds,
largeValueKeys: context.largeValueKeys,
allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope,
userId: context.userId,
maxBytes: context.maxBytes ?? ref.size,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -10,6 +10,15 @@ import type { BlockLog, ExecutionResult } from '@/executor/types'
const HIDDEN_OUTPUT_KEYS = new Set(['childTraceSpans'])
const SUCCESSFUL_CHILD_ERROR_BOUNDARY_BLOCK_TYPES = new Set(['mothership'])
function setFilteredValue(output: Record<string, unknown>, key: string, value: unknown): void {
Object.defineProperty(output, key, {
value,
enumerable: true,
configurable: true,
writable: true,
})
}
/**
* Recursively filters hidden keys from nested objects for cleaner display.
* Used by both executor (for log output) and UI (for display).
@@ -29,7 +38,7 @@ export function filterHiddenOutputKeys(value: unknown): unknown {
if (HIDDEN_OUTPUT_KEYS.has(key)) {
continue
}
filtered[key] = filterHiddenOutputKeys(val)
setFilteredValue(filtered, key, filterHiddenOutputKeys(val))
}
return filtered
}
@@ -143,6 +143,149 @@ describe('hydrateUserFilesWithBase64', () => {
expect(hydrated.file.base64).toBe(Buffer.from('hello').toString('base64'))
})
it('materializes large refs before hydrating nested files', async () => {
const file: UserFile = {
id: 'file-1',
name: 'nested.txt',
key: 'execution/workspace/workflow/source-execution/nested.txt',
url: '/api/files/serve/execution/workspace/workflow/source-execution/nested.txt?context=execution',
size: 5,
type: 'text/plain',
context: 'execution',
}
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 256,
key: 'execution/workspace/workflow/source-execution/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'source-execution',
}
mockDownloadFile.mockImplementation(async ({ key }) => {
if (key.includes('large-value')) {
return Buffer.from(JSON.stringify({ file }), 'utf8')
}
return Buffer.from('hello', 'utf8')
})
const hydrated = await hydrateUserFilesWithBase64(
{ ref },
{
workspaceId: 'workspace',
workflowId: 'workflow',
executionId: 'resume-execution',
largeValueExecutionIds: ['source-execution'],
userId: 'user-1',
maxBytes: 1024,
}
)
expect((hydrated.ref as unknown as { file: UserFile }).file.base64).toBe(
Buffer.from('hello').toString('base64')
)
})
it('preserves large-value metadata while hydrating visible files when requested', async () => {
mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello', 'utf8'))
const file: UserFile = {
id: 'file-1',
name: 'visible.txt',
key: 'execution/workspace/workflow/execution-1/visible.txt',
url: '/api/files/serve/execution/workspace/workflow/execution-1/visible.txt?context=execution',
size: 5,
type: 'text/plain',
context: 'execution',
}
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_PRESERVEREF1',
kind: 'object',
size: 256,
key: 'execution/workspace/workflow/source-execution/large-value-lv_PRESERVEREF1.json',
executionId: 'source-execution',
}
const manifest = {
__simLargeArrayManifest: true,
version: 2,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: 256,
chunks: [
{
ref,
count: 1,
byteSize: 256,
},
],
preview: [{ id: 1 }],
}
const hydrated = await hydrateUserFilesWithBase64(
{ file, ref, manifest },
{
workspaceId: 'workspace',
workflowId: 'workflow',
executionId: 'execution-1',
userId: 'user-1',
maxBytes: 1024,
preserveLargeValueMetadata: true,
}
)
expect(hydrated.file.base64).toBe(Buffer.from('hello').toString('base64'))
expect(hydrated.ref).toBe(ref)
expect(hydrated.manifest).toBe(manifest)
expect(mockDownloadFile).toHaveBeenCalledOnce()
})
it('hydrates nested prior-execution files discovered from exact-key large refs', async () => {
const file: UserFile = {
id: 'file-1',
name: 'nested.txt',
key: 'execution/workspace/workflow/source-execution/nested.txt',
url: '/api/files/serve/execution/workspace/workflow/source-execution/nested.txt?context=execution',
size: 5,
type: 'text/plain',
context: 'execution',
}
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_MNOPQRSTUVWX',
kind: 'object',
size: 256,
key: 'execution/workspace/workflow/source-execution/large-value-lv_MNOPQRSTUVWX.json',
executionId: 'source-execution',
}
mockDownloadFile.mockImplementation(async ({ key }) => {
if (key.includes('large-value')) {
return Buffer.from(JSON.stringify({ file }), 'utf8')
}
return Buffer.from('hello', 'utf8')
})
const hydrated = await hydrateUserFilesWithBase64(
{ ref },
{
workspaceId: 'workspace',
workflowId: 'workflow',
executionId: 'resume-execution',
largeValueKeys: [ref.key],
userId: 'user-1',
maxBytes: 1024,
}
)
expect((hydrated.ref as unknown as { file: UserFile }).file.base64).toBe(
Buffer.from('hello').toString('base64')
)
})
it('releases reserved Redis budget when cleaning up execution cache entries', async () => {
mockGetRedisClient.mockReturnValue(mockRedis)
const rawEntry = JSON.stringify({ bytes: 12, userId: 'user-1' })
@@ -2,11 +2,21 @@ import type { Logger } from '@sim/logger'
import { createLogger } from '@sim/logger'
import { getRedisClient } from '@/lib/core/config/redis'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import { LARGE_VALUE_THRESHOLD_BYTES } from '@/lib/execution/payloads/large-value-ref'
import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys'
import {
isLargeArrayManifest,
materializeLargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest'
import {
getLargeValueMaterializationError,
isLargeValueRef,
LARGE_VALUE_THRESHOLD_BYTES,
} from '@/lib/execution/payloads/large-value-ref'
import {
assertUserFileContentAccess,
readUserFileContent,
} from '@/lib/execution/payloads/materialization.server'
import { materializeLargeValueRef } from '@/lib/execution/payloads/store'
import {
type ExecutionRedisBudgetReservation,
getExecutionRedisBudgetKeys,
@@ -149,6 +159,8 @@ export interface Base64HydrationOptions {
workflowId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
logger?: Logger
@@ -156,6 +168,7 @@ export interface Base64HydrationOptions {
allowUnknownSize?: boolean
timeoutMs?: number
cacheTtlSeconds?: number
preserveLargeValueMetadata?: boolean
}
class InMemoryBase64Cache implements Base64Cache {
@@ -401,6 +414,7 @@ async function resolveBase64(
workflowId: options.workflowId,
executionId: options.executionId,
largeValueExecutionIds: options.largeValueExecutionIds,
fileKeys: options.fileKeys,
allowLargeValueWorkflowScope: options.allowLargeValueWorkflowScope,
userId: options.userId,
encoding: 'base64',
@@ -427,6 +441,7 @@ async function hydrateUserFile(
workflowId: options.workflowId,
executionId: options.executionId,
largeValueExecutionIds: options.largeValueExecutionIds,
fileKeys: options.fileKeys,
allowLargeValueWorkflowScope: options.allowLargeValueWorkflowScope,
userId: options.userId,
logger,
@@ -465,6 +480,36 @@ async function hydrateValue(
return value
}
if (
options.preserveLargeValueMetadata &&
(isLargeArrayManifest(value) || isLargeValueRef(value))
) {
return value
}
if (isLargeArrayManifest(value)) {
const materialized = await materializeLargeArrayManifest(value, options)
return hydrateValue(
materialized,
withLocalLargeValueExecutionIds(options, materialized),
state,
logger
)
}
if (isLargeValueRef(value)) {
const materialized = await materializeLargeValueRef(value, options)
if (materialized === undefined) {
throw getLargeValueMaterializationError(value)
}
return hydrateValue(
materialized,
withLocalLargeValueExecutionIds(options, materialized),
state,
logger
)
}
if (isUserFileWithMetadata(value)) {
return hydrateUserFile(value, options, state, logger)
}
@@ -491,6 +536,18 @@ async function hydrateValue(
return Object.fromEntries(entries)
}
function withLocalLargeValueExecutionIds(
options: Base64HydrationOptions,
materializedValue: unknown
): Base64HydrationOptions {
recordMaterializedAccessKeys(options, materializedValue)
return {
...options,
largeValueKeys: options.largeValueKeys,
fileKeys: options.fileKeys,
}
}
/**
* Hydrates UserFile objects within a value to include base64 content.
* Returns the original structure with UserFile.base64 set where available.
@@ -34,6 +34,8 @@ export interface ExecuteWorkflowOptions {
skipLoggingComplete?: boolean
includeFileBase64?: boolean
base64MaxBytes?: number
largeValueKeys?: string[]
fileKeys?: string[]
abortSignal?: AbortSignal
/** Use the live/draft workflow state instead of the deployed state. Used by copilot. */
useDraftState?: boolean
@@ -43,6 +45,7 @@ export interface ExecuteWorkflowOptions {
runFromBlock?: {
startBlockId: string
sourceSnapshot: SerializableExecutionState
sourceExecutionId?: string
}
executionMode?: 'sync' | 'stream' | 'async'
}
@@ -86,6 +89,9 @@ export async function executeWorkflow(
useDraftState: streamConfig?.useDraftState ?? false,
startTime: new Date().toISOString(),
isClientSession: false,
largeValueExecutionIds: Array.from(new Set([executionId])),
largeValueKeys: streamConfig?.largeValueKeys,
fileKeys: streamConfig?.fileKeys,
executionMode: streamConfig?.executionMode,
}
@@ -239,6 +239,54 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
expect(findStartBlockMock).toHaveBeenCalledWith(expect.anything(), 'external', false)
})
it('preserves manifest-backed workflow variables during execution setup', async () => {
const manifest = {
__simLargeArrayManifest: true,
version: 2,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: 16,
chunks: [
{
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 16,
executionId: 'execution-1',
},
count: 1,
byteSize: 16,
},
],
preview: [{ id: 1 }],
}
executorExecuteMock.mockResolvedValue({
success: true,
status: 'completed',
output: { done: true },
logs: [],
metadata: { duration: 123, startTime: 'start', endTime: 'end' },
})
await executeWorkflowCore({
snapshot: {
...createSnapshot(),
workflowVariables: {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: manifest },
},
} as any,
callbacks: {},
loggingSession: loggingSession as any,
})
expect(executorConstructorMock.mock.calls[0]?.[0]?.workflowVariables['var-1'].value).toEqual(
manifest
)
})
it('does not await user block start callback after persistence completes', async () => {
let releaseCallback: (() => void) | undefined
const callbackPromise = new Promise<void>((resolve) => {
@@ -12,6 +12,7 @@ import { isPlainRecord } from '@/lib/core/utils/records'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { clearExecutionCancellation } from '@/lib/execution/cancellation'
import { warmLargeValueRefs } from '@/lib/execution/payloads/hydration'
import { parseLargeExecutionValue } from '@/lib/execution/payloads/large-execution-value'
import type { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import {
@@ -51,10 +52,16 @@ export interface ExecuteWorkflowCoreOptions {
runFromBlock?: {
startBlockId: string
sourceSnapshot: SerializableExecutionState
sourceExecutionId?: string
}
}
function parseVariableValueByType(value: unknown, type: string): unknown {
const refValue = parseLargeExecutionValue(value)
if (refValue !== undefined) {
return refValue
}
if (value === null || value === undefined) {
switch (type) {
case 'number':
@@ -555,18 +562,26 @@ export async function executeWorkflowCore(
}
const largeValueExecutionIds = Array.from(
new Set([executionId, ...(metadata.largeValueExecutionIds ?? [])].filter(Boolean))
new Set(
[executionId, ...(metadata.largeValueExecutionIds ?? [])].filter((id): id is string =>
Boolean(id)
)
)
)
const largeValueKeys = metadata.largeValueKeys
const fileKeys = metadata.fileKeys
const allowLargeValueWorkflowScope =
metadata.allowLargeValueWorkflowScope === true ||
metadata.resumeFromSnapshot === true ||
Boolean(runFromBlock?.sourceSnapshot)
Boolean(runFromBlock?.sourceSnapshot && !runFromBlock.sourceExecutionId)
const contextExtensions: ContextExtensions = {
stream: !!onStream,
selectedOutputs,
executionId,
largeValueExecutionIds,
largeValueKeys,
fileKeys,
allowLargeValueWorkflowScope,
workspaceId: providedWorkspaceId,
userId,
@@ -600,21 +615,12 @@ export async function executeWorkflowCore(
workflowId,
executionId,
largeValueExecutionIds,
largeValueKeys,
fileKeys,
allowLargeValueWorkflowScope,
userId,
})
}
if (runFromBlock?.sourceSnapshot) {
await warmLargeValueRefs(runFromBlock.sourceSnapshot, {
workspaceId: providedWorkspaceId,
workflowId,
executionId,
largeValueExecutionIds,
allowLargeValueWorkflowScope,
userId,
})
}
for (const variable of Object.values(workflowVariables)) {
if (
isPlainRecord(variable) &&
@@ -3,6 +3,11 @@ import { workflowExecutionLogs } from '@sim/db/schema'
import { and, desc, eq, sql } from 'drizzle-orm'
import type { SerializableExecutionState } from '@/executor/execution/types'
export interface ExecutionStateRecord {
executionId: string
state: SerializableExecutionState
}
function isSerializableExecutionState(value: unknown): value is SerializableExecutionState {
if (!value || typeof value !== 'object') return false
const state = value as Record<string, unknown>
@@ -55,8 +60,18 @@ export async function getExecutionStateForWorkflow(
export async function getLatestExecutionState(
workflowId: string
): Promise<SerializableExecutionState | null> {
const record = await getLatestExecutionStateWithExecutionId(workflowId)
return record?.state ?? null
}
export async function getLatestExecutionStateWithExecutionId(
workflowId: string
): Promise<ExecutionStateRecord | null> {
const [row] = await db
.select({ executionData: workflowExecutionLogs.executionData })
.select({
executionId: workflowExecutionLogs.executionId,
executionData: workflowExecutionLogs.executionData,
})
.from(workflowExecutionLogs)
.where(
and(
@@ -67,5 +82,6 @@ export async function getLatestExecutionState(
.orderBy(desc(workflowExecutionLogs.startedAt))
.limit(1)
return extractExecutionState(row?.executionData)
const state = extractExecutionState(row?.executionData)
return row && state ? { executionId: row.executionId, state } : null
}
@@ -0,0 +1,606 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readSSEStream } from '@/lib/core/utils/sse'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
const { mockDownloadFile } = vi.hoisted(() => ({
mockDownloadFile: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
downloadFile: mockDownloadFile,
},
}))
const manifestChunk = [{ id: 1 }]
const manifestChunkBytes = Buffer.byteLength(JSON.stringify(manifestChunk), 'utf8')
const manifest = {
__simLargeArrayManifest: true,
version: 2,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: manifestChunkBytes,
chunks: [
{
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: manifestChunkBytes,
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'execution-1',
},
count: 1,
byteSize: manifestChunkBytes,
},
],
preview: [{ id: 1 }],
}
async function collectSSEEvents(
stream: ReadableStream<Uint8Array>
): Promise<Record<string, unknown>[]> {
const reader = stream.getReader()
const decoder = new TextDecoder()
const events: Record<string, unknown>[] = []
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
buffer += decoder.decode()
break
}
buffer += decoder.decode(value, { stream: true })
}
} finally {
reader.releaseLock()
}
for (const chunk of buffer.split('\n\n')) {
if (!chunk.startsWith('data: ')) {
continue
}
const payload = chunk.substring(6)
if (payload === '[DONE]') {
continue
}
const event = JSON.parse(payload) as unknown
if (event === '[DONE]') {
continue
}
events.push(event as Record<string, unknown>)
}
return events
}
describe('createStreamingResponse', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
})
it('extracts block-level selected outputs from JSON content payloads', async () => {
const output = { content: JSON.stringify({ answer: 'ok' }) }
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
streamConfig: {
selectedOutputs: ['block'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
await expect(readSSEStream(stream)).resolves.toBe(JSON.stringify({ answer: 'ok' }, null, 2))
})
it('extracts selected outputs from JSON content payloads', async () => {
const output = { content: JSON.stringify({ answer: 'ok' }) }
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
streamConfig: {
selectedOutputs: ['block_answer'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
await expect(readSSEStream(stream)).resolves.toBe('ok')
})
it('auto-materializes whole manifest selected outputs under the inline cap', async () => {
mockDownloadFile.mockResolvedValue(Buffer.from(JSON.stringify(manifestChunk), 'utf8'))
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
streamConfig: {
selectedOutputs: ['block_issues'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
const output = { issues: manifest }
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
await expect(readSSEStream(stream)).resolves.toBe(JSON.stringify(manifestChunk, null, 2))
})
it('auto-materializes whole-block selected outputs containing manifests', async () => {
mockDownloadFile.mockResolvedValue(Buffer.from(JSON.stringify(manifestChunk), 'utf8'))
const output = { issues: manifest }
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
streamConfig: {
selectedOutputs: ['block'],
includeFileBase64: true,
},
executeFn: async ({ onBlockComplete }) => {
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
await expect(readSSEStream(stream)).resolves.toBe(
JSON.stringify({ issues: manifestChunk }, null, 2)
)
expect(mockDownloadFile).toHaveBeenCalled()
})
it('inlines materialized selected outputs without recompacting them into refs', async () => {
const largeString = 'x'.repeat(8 * 1024 * 1024 + 1)
const largeStringJson = JSON.stringify(largeString)
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_LARGESTRING1',
kind: 'string',
size: Buffer.byteLength(largeStringJson, 'utf8'),
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_LARGESTRING1.json',
executionId: 'execution-1',
}
mockDownloadFile.mockResolvedValue(Buffer.from(largeStringJson, 'utf8'))
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
streamConfig: {
selectedOutputs: ['block_text'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
const output = { text: ref }
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
const streamed = await readSSEStream(stream)
expect(streamed).toHaveLength(largeString.length)
expect(streamed).not.toContain('__simLargeValueRef')
})
it('deduplicates repeated equivalent selected outputs before streaming', async () => {
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
streamConfig: {
selectedOutputs: ['block_text', 'block.text', 'block_text'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
const output = { text: 'ok' }
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
const events = await collectSSEEvents(stream)
const chunkEvents = events.filter((event) => 'chunk' in event)
expect(chunkEvents).toHaveLength(1)
expect(chunkEvents[0]).toMatchObject({ blockId: 'block', chunk: 'ok' })
})
it('fails when distinct selected outputs aggregate over the inline cap', async () => {
const largeString = 'x'.repeat(9 * 1024 * 1024)
const largeStringJson = JSON.stringify(largeString)
const largeStringBytes = Buffer.byteLength(largeStringJson, 'utf8')
const firstRef = {
__simLargeValueRef: true,
version: 1,
id: 'lv_MULTIREF0001',
kind: 'string',
size: largeStringBytes,
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_MULTIREF0001.json',
executionId: 'execution-1',
}
const secondRef = {
__simLargeValueRef: true,
version: 1,
id: 'lv_MULTIREF0002',
kind: 'string',
size: largeStringBytes,
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_MULTIREF0002.json',
executionId: 'execution-1',
}
mockDownloadFile.mockImplementation(async ({ key }) => {
if (key === firstRef.key || key === secondRef.key) {
return Buffer.from(largeStringJson, 'utf8')
}
throw new Error(`Unexpected key: ${key}`)
})
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
streamConfig: {
selectedOutputs: ['block_first', 'block_second'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
const output = { first: firstRef, second: secondRef }
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
const events = await collectSSEEvents(stream)
expect(events).toContainEqual({
event: 'error',
blockId: 'block',
error:
'Selected output is too large to inline; select a nested field or use pagination/preview.',
})
expect(events.some((event) => event.event === 'final')).toBe(false)
})
it('accounts escaped string JSON bytes against the aggregate selected-output cap', async () => {
const first = '\\'.repeat(Math.floor((16 * 1024 * 1024 - 2) / 2))
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
streamConfig: {
selectedOutputs: ['block_first', 'block_second'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
const output = { first, second: 'ok' }
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
const events = await collectSSEEvents(stream)
expect(events).toContainEqual({
event: 'error',
blockId: 'block',
error:
'Selected output is too large to inline; select a nested field or use pagination/preview.',
})
expect(events.some((event) => event.event === 'final')).toBe(false)
})
it('fails when nested refs aggregate over the inline selected-output cap', async () => {
const largeString = 'x'.repeat(9 * 1024 * 1024)
const largeStringJson = JSON.stringify(largeString)
const largeStringBytes = Buffer.byteLength(largeStringJson, 'utf8')
const nestedRefA = {
__simLargeValueRef: true,
version: 1,
id: 'lv_NESTEDREF001',
kind: 'string',
size: largeStringBytes,
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_NESTEDREF001.json',
executionId: 'execution-1',
}
const nestedRefB = {
__simLargeValueRef: true,
version: 1,
id: 'lv_NESTEDREF002',
kind: 'string',
size: largeStringBytes,
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_NESTEDREF002.json',
executionId: 'execution-1',
}
const nestedChunk = [nestedRefA, nestedRefB]
const nestedChunkBytes = Buffer.byteLength(JSON.stringify(nestedChunk), 'utf8')
const nestedManifest = {
...manifest,
totalCount: 2,
byteSize: nestedChunkBytes,
chunks: [
{
ref: {
...manifest.chunks[0].ref,
size: nestedChunkBytes,
},
count: 2,
byteSize: nestedChunkBytes,
},
],
preview: [],
}
mockDownloadFile.mockImplementation(async ({ key }) => {
if (key === nestedManifest.chunks[0].ref.key) {
return Buffer.from(JSON.stringify(nestedChunk), 'utf8')
}
if (key === nestedRefA.key) {
return Buffer.from(largeStringJson, 'utf8')
}
if (key === nestedRefB.key) {
return Buffer.from(largeStringJson, 'utf8')
}
throw new Error(`Unexpected key: ${key}`)
})
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
streamConfig: {
selectedOutputs: ['block_issues'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
const output = { issues: nestedManifest }
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
const events = await collectSSEEvents(stream)
expect(events).toContainEqual({
event: 'error',
blockId: 'block',
error:
'Selected output is too large to inline; select a nested field or use pagination/preview.',
})
expect(events.some((event) => event.event === 'final')).toBe(false)
expect(JSON.stringify(events)).not.toContain('__simLargeValueRef')
})
it('fails clearly instead of streaming raw manifest internals when selected output is over cap', async () => {
const oversizedManifest = {
...manifest,
byteSize: 16 * 1024 * 1024 + 1,
chunks: [
{
...manifest.chunks[0],
ref: {
...manifest.chunks[0].ref,
size: 16 * 1024 * 1024 + 1,
},
byteSize: 16 * 1024 * 1024 + 1,
},
],
}
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
streamConfig: {
selectedOutputs: ['block_issues'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
const output = { issues: oversizedManifest }
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
const events = await collectSSEEvents(stream)
expect(events).toContainEqual({
event: 'error',
blockId: 'block',
error:
'Selected output is too large to inline; select a nested field or use pagination/preview.',
})
expect(events.some((event) => event.event === 'final')).toBe(false)
expect(JSON.stringify(events)).not.toContain('__simLargeArrayManifest')
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('uses live large-value keys for selected-output materialization', async () => {
const largeValueKeys: string[] = []
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_MNOPQRSTUVWX',
kind: 'object',
size: 15,
key: 'execution/workspace-1/workflow-1/source-execution/large-value-lv_MNOPQRSTUVWX.json',
executionId: 'source-execution',
}
mockDownloadFile.mockResolvedValue(Buffer.from(JSON.stringify({ nested: 'ok' }), 'utf8'))
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
largeValueKeys,
streamConfig: {
selectedOutputs: ['block.value.nested'],
},
executeFn: async ({ onBlockComplete }) => {
largeValueKeys.push(ref.key)
await onBlockComplete('block', { value: ref })
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output: { value: ref },
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
await expect(readSSEStream(stream)).resolves.toBe('ok')
})
})
+274 -70
View File
@@ -4,10 +4,20 @@ import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core
import {
extractBlockIdFromOutputId,
extractPathFromOutputId,
traverseObjectPath,
parseOutputContentSafely,
} from '@/lib/core/utils/response-format'
import { encodeSSE } from '@/lib/core/utils/sse'
import {
getInlineJsonByteLength,
materializeInlineExecutionValue,
} from '@/lib/execution/payloads/inline-materialization.server'
import {
assertInlineMaterializationSize,
type ExecutionMaterializationContext,
MAX_INLINE_MATERIALIZATION_BYTES,
} from '@/lib/execution/payloads/materialization.server'
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { processStreamingBlockLogs } from '@/lib/tokenization'
import {
@@ -15,6 +25,7 @@ import {
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types'
import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server'
/**
* Extended streaming execution type that includes blockId on the execution.
@@ -27,6 +38,8 @@ interface StreamingExecutionWithBlockId extends Omit<StreamingExecution, 'execut
const logger = createLogger('WorkflowStreaming')
const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype']
const SELECTED_OUTPUT_TOO_LARGE_MESSAGE =
'Selected output is too large to inline; select a nested field or use pagination/preview.'
interface StreamingConfig {
selectedOutputs?: string[]
@@ -48,6 +61,8 @@ export interface StreamingResponseOptions {
streamConfig: StreamingConfig
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
workspaceId?: string
workflowId?: string
@@ -60,6 +75,16 @@ interface StreamingState {
processedOutputs: Set<string>
streamCompletionTimes: Map<string, number>
completedBlockIds: Set<string>
selectedOutputBytes: number
streamedSelectedOutputKeys: Set<string>
selectedOutputError?: string
}
interface SelectedOutputDescriptor {
outputId: string
blockId: string
path: string
key: string
}
function resolveStreamedContent(state: StreamingState): Map<string, string> {
@@ -70,24 +95,115 @@ function resolveStreamedContent(state: StreamingState): Map<string, string> {
return result
}
function extractOutputValue(output: unknown, path: string): unknown {
return traverseObjectPath(output, path)
type OutputExtractionContext = Pick<
StreamingResponseOptions,
| 'requestId'
| 'workspaceId'
| 'workflowId'
| 'executionId'
| 'largeValueExecutionIds'
| 'largeValueKeys'
| 'fileKeys'
| 'allowLargeValueWorkflowScope'
| 'userId'
> & { base64MaxBytes?: number }
async function extractOutputValue(
output: unknown,
path: string,
context: OutputExtractionContext
): Promise<unknown> {
const parsedOutput = parseOutputContentSafely(output)
const outputValue = path
? await navigatePathAsync(parsedOutput, path.split('.'), {
executionContext: {
workflowId: context.workflowId ?? '',
workspaceId: context.workspaceId,
executionId: context.executionId,
largeValueExecutionIds: context.largeValueExecutionIds,
largeValueKeys: context.largeValueKeys,
fileKeys: context.fileKeys,
allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope,
userId: context.userId,
metadata: { requestId: context.requestId },
base64MaxBytes: context.base64MaxBytes,
},
allowLargeValueRefs: true,
})
: parsedOutput
return outputValue
}
function isDangerousKey(key: string): boolean {
return DANGEROUS_KEYS.includes(key)
}
function getSelectedOutputDescriptors(
selectedOutputs: string[] | undefined
): SelectedOutputDescriptor[] {
const descriptors: SelectedOutputDescriptor[] = []
const seen = new Set<string>()
for (const outputId of selectedOutputs ?? []) {
const blockId = extractBlockIdFromOutputId(outputId)
const path = extractPathFromOutputId(outputId, blockId)
const key = `${blockId}\u0000${path}`
if (seen.has(key)) {
continue
}
seen.add(key)
descriptors.push({ outputId, blockId, path, key })
}
return descriptors
}
function getSelectedOutputErrorMessage(error: unknown): string {
if (isExecutionResourceLimitError(error)) {
return SELECTED_OUTPUT_TOO_LARGE_MESSAGE
}
return getErrorMessage(error, 'Selected output could not be materialized')
}
function buildMaterializationContext(
context: Omit<OutputExtractionContext, 'requestId'>
): ExecutionMaterializationContext {
return {
workspaceId: context.workspaceId,
workflowId: context.workflowId,
executionId: context.executionId,
largeValueExecutionIds: context.largeValueExecutionIds,
largeValueKeys: context.largeValueKeys,
fileKeys: context.fileKeys,
allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope,
userId: context.userId,
}
}
function getRemainingSelectedOutputBytes(usedBytes: number): number {
return MAX_INLINE_MATERIALIZATION_BYTES - usedBytes
}
function getBase64DecodedByteBudget(remainingJsonBytes: number): number {
return Math.max(0, Math.floor(((remainingJsonBytes - 2) * 3) / 4))
}
function assertSelectedOutputBytes(value: unknown): number {
const bytes = getInlineJsonByteLength(value) ?? 0
assertInlineMaterializationSize(bytes, MAX_INLINE_MATERIALIZATION_BYTES)
return bytes
}
async function buildMinimalResult(
result: ExecutionResult,
selectedOutputs: string[] | undefined,
streamedContent: Map<string, string>,
completedBlockIds: Set<string>,
streamedSelectedOutputKeys: Set<string>,
requestId: string,
includeFileBase64: boolean,
base64MaxBytes: number | undefined,
executionId?: string,
context: Pick<StreamingResponseOptions, 'workspaceId' | 'workflowId' | 'userId'> = {}
context: Omit<OutputExtractionContext, 'executionId'> = { requestId }
): Promise<{ success: boolean; error?: string; output: Record<string, unknown> }> {
const durableContext = {
workspaceId: context.workspaceId,
@@ -125,13 +241,18 @@ async function buildMinimalResult(
return minimalResult
}
for (const outputId of selectedOutputs) {
const blockId = extractBlockIdFromOutputId(outputId)
let selectedOutputBytes = assertSelectedOutputBytes(minimalResult.output)
for (const descriptor of getSelectedOutputDescriptors(selectedOutputs)) {
const { blockId, path } = descriptor
if (streamedContent.has(blockId)) {
continue
}
if (streamedSelectedOutputKeys.has(descriptor.key)) {
continue
}
if (!completedBlockIds.has(blockId)) {
continue
}
@@ -141,7 +262,6 @@ async function buildMinimalResult(
continue
}
const path = extractPathFromOutputId(outputId, blockId)
if (isDangerousKey(path)) {
logger.warn(`[${requestId}] Blocked dangerous path: ${path}`)
continue
@@ -152,22 +272,33 @@ async function buildMinimalResult(
continue
}
const value = extractOutputValue(blockLog.output, path)
const remainingBytes = getRemainingSelectedOutputBytes(selectedOutputBytes)
const extractionContext = {
...context,
executionId,
base64MaxBytes: Math.min(
base64MaxBytes ?? MAX_INLINE_MATERIALIZATION_BYTES,
getBase64DecodedByteBudget(remainingBytes)
),
}
const value = await extractOutputValue(blockLog.output, path, extractionContext)
if (value === undefined) {
continue
}
const materializedValue = await materializeInlineExecutionValue(
value,
buildMaterializationContext(extractionContext),
{ maxBytes: remainingBytes }
)
if (!minimalResult.output[blockId]) {
minimalResult.output[blockId] = Object.create(null) as Record<string, unknown>
}
;(minimalResult.output[blockId] as Record<string, unknown>)[path] = value
;(minimalResult.output[blockId] as Record<string, unknown>)[path] = materializedValue
selectedOutputBytes = assertSelectedOutputBytes(minimalResult.output)
}
return compactExecutionPayload(minimalResult, {
...durableContext,
preserveUserFileBase64: includeFileBase64,
preserveRoot: true,
})
return minimalResult
}
function updateLogsWithStreamedContent(
@@ -236,11 +367,26 @@ export async function createStreamingResponse(
processedOutputs: new Set(),
streamCompletionTimes: new Map(),
completedBlockIds: new Set(),
selectedOutputBytes: 0,
streamedSelectedOutputKeys: new Set(),
}
const sendChunk = (blockId: string, content: string) => {
const sendChunk = (
blockId: string,
content: string,
options: { selectedOutputKey?: string; selectedOutputBytes?: number } = {}
) => {
const separator = state.processedOutputs.size > 0 ? '\n\n' : ''
controller.enqueue(encodeSSE({ blockId, chunk: separator + content }))
const chunk = separator + content
if (options.selectedOutputKey) {
const selectedOutputBytes =
options.selectedOutputBytes ?? Buffer.byteLength(chunk, 'utf8')
const nextSelectedOutputBytes = state.selectedOutputBytes + selectedOutputBytes
assertInlineMaterializationSize(nextSelectedOutputBytes, MAX_INLINE_MATERIALIZATION_BYTES)
state.selectedOutputBytes = nextSelectedOutputBytes
state.streamedSelectedOutputKeys.add(options.selectedOutputKey)
}
controller.enqueue(encodeSSE({ blockId, chunk }))
state.processedOutputs.add(blockId)
}
@@ -305,36 +451,84 @@ export async function createStreamingResponse(
return
}
const matchingOutputs = streamConfig.selectedOutputs.filter(
(outputId) => extractBlockIdFromOutputId(outputId) === blockId
const matchingOutputs = getSelectedOutputDescriptors(streamConfig.selectedOutputs).filter(
(descriptor) => descriptor.blockId === blockId
)
for (const outputId of matchingOutputs) {
const path = extractPathFromOutputId(outputId, blockId)
const outputValue = extractOutputValue(output, path)
for (const descriptor of matchingOutputs) {
if (state.selectedOutputError) {
break
}
try {
const remainingBytes = getRemainingSelectedOutputBytes(state.selectedOutputBytes)
const extractionContext = {
requestId,
workspaceId: options.workspaceId,
workflowId: options.workflowId,
executionId,
largeValueExecutionIds: options.largeValueExecutionIds,
largeValueKeys: options.largeValueKeys,
fileKeys: options.fileKeys,
allowLargeValueWorkflowScope: options.allowLargeValueWorkflowScope,
userId: options.userId,
base64MaxBytes: Math.min(
base64MaxBytes ?? MAX_INLINE_MATERIALIZATION_BYTES,
getBase64DecodedByteBudget(remainingBytes)
),
}
const materializationContext = buildMaterializationContext(extractionContext)
const outputValue = await extractOutputValue(output, descriptor.path, extractionContext)
if (outputValue !== undefined) {
const hydratedOutput = includeFileBase64
? await hydrateUserFilesWithBase64(outputValue, {
requestId,
workspaceId: options.workspaceId,
workflowId: options.workflowId,
executionId,
largeValueExecutionIds: options.largeValueExecutionIds,
allowLargeValueWorkflowScope: options.allowLargeValueWorkflowScope,
userId: options.userId,
maxBytes: base64MaxBytes,
})
: outputValue
const compactHydratedOutput = await compactExecutionPayload(hydratedOutput, {
...durableContext,
preserveUserFileBase64: includeFileBase64,
if (outputValue !== undefined) {
const materializedOutput = await materializeInlineExecutionValue(
outputValue,
materializationContext,
{ maxBytes: remainingBytes }
)
const shouldHydrateOutput = includeFileBase64
const hydratedOutput = shouldHydrateOutput
? await hydrateUserFilesWithBase64(materializedOutput, {
requestId,
...materializationContext,
maxBytes: Math.min(
base64MaxBytes ?? MAX_INLINE_MATERIALIZATION_BYTES,
getBase64DecodedByteBudget(remainingBytes)
),
preserveLargeValueMetadata: true,
})
: materializedOutput
await materializeInlineExecutionValue(hydratedOutput, materializationContext, {
maxBytes: getRemainingSelectedOutputBytes(state.selectedOutputBytes),
})
const formattedOutput =
typeof hydratedOutput === 'string'
? hydratedOutput
: JSON.stringify(hydratedOutput, null, 2)
const selectedOutputBytes = Math.max(
getInlineJsonByteLength(hydratedOutput) ?? 0,
Buffer.byteLength(formattedOutput, 'utf8')
)
sendChunk(blockId, formattedOutput, {
selectedOutputKey: descriptor.key,
selectedOutputBytes,
})
}
} catch (error) {
logger.warn(`[${requestId}] Failed to materialize selected output`, {
blockId,
outputId: descriptor.outputId,
error,
})
const formattedOutput =
typeof compactHydratedOutput === 'string'
? compactHydratedOutput
: JSON.stringify(compactHydratedOutput, null, 2)
sendChunk(blockId, formattedOutput)
const errorMessage = getSelectedOutputErrorMessage(error)
state.selectedOutputError ??= errorMessage
controller.enqueue(
encodeSSE({
event: 'error',
blockId,
error: errorMessage,
})
)
break
}
}
}
@@ -374,31 +568,39 @@ export async function createStreamingResponse(
} else {
await completeLoggingSession(result)
const minimalResult = await buildMinimalResult(
result,
streamConfig.selectedOutputs,
streamedContent,
state.completedBlockIds,
requestId,
streamConfig.includeFileBase64 ?? true,
streamConfig.base64MaxBytes,
executionId,
{
workspaceId: options.workspaceId,
workflowId: options.workflowId,
userId: options.userId,
}
)
if (!state.selectedOutputError) {
const minimalResult = await buildMinimalResult(
result,
streamConfig.selectedOutputs,
streamedContent,
state.completedBlockIds,
state.streamedSelectedOutputKeys,
requestId,
streamConfig.includeFileBase64 ?? true,
streamConfig.base64MaxBytes,
executionId,
{
requestId,
workspaceId: options.workspaceId,
workflowId: options.workflowId,
largeValueExecutionIds: options.largeValueExecutionIds,
largeValueKeys: result.metadata?.largeValueKeys ?? options.largeValueKeys,
fileKeys: result.metadata?.fileKeys ?? options.fileKeys,
allowLargeValueWorkflowScope: options.allowLargeValueWorkflowScope,
userId: options.userId,
}
)
controller.enqueue(
encodeSSE({
event: 'final',
data: {
...minimalResult,
...(result.status === 'paused' && { status: 'paused' }),
},
})
)
controller.enqueue(
encodeSSE({
event: 'final',
data: {
...minimalResult,
...(result.status === 'paused' && { status: 'paused' }),
},
})
)
}
}
controller.enqueue(encodeSSE('[DONE]'))
@@ -408,11 +610,13 @@ export async function createStreamingResponse(
}
controller.close()
} catch (error: any) {
} catch (error) {
logger.error(`[${requestId}] Stream error:`, error)
controller.enqueue(
encodeSSE({ event: 'error', error: error.message || 'Stream processing error' })
)
const errorMessage =
streamConfig.selectedOutputs?.length && isExecutionResourceLimitError(error)
? SELECTED_OUTPUT_TOO_LARGE_MESSAGE
: getErrorMessage(error, 'Stream processing error')
controller.enqueue(encodeSSE({ event: 'error', error: errorMessage }))
if (executionId) {
await cleanupExecutionBase64Cache(executionId)
+34 -1
View File
@@ -27,7 +27,7 @@ vi.mock('@sim/workflow-authz', () => ({
assertActiveWorkflowContext: vi.fn(),
}))
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createHttpResponseFromBlock, validateWorkflowPermissions } from '@/lib/workflows/utils'
const mockSession = createSession({ userId: 'user-1', email: 'user1@test.com' })
const mockWorkflow = createWorkflowRecord({
@@ -36,6 +36,15 @@ const mockWorkflow = createWorkflowRecord({
workspaceId: 'ws-1',
})
const largeValueRef = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 12 * 1024 * 1024,
executionId: 'execution-1',
}
const allowed = (workspacePermission: 'read' | 'write' | 'admin') => ({
allowed: true,
status: 200,
@@ -231,3 +240,27 @@ describe('validateWorkflowPermissions', () => {
})
})
})
describe('createHttpResponseFromBlock', () => {
it('rejects large refs that cannot be materialized for HTTP response output', async () => {
await expect(
createHttpResponseFromBlock({
output: {
data: { issues: largeValueRef },
status: 200,
},
} as any)
).rejects.toThrow('This execution value is too large to inline')
})
it('returns raw response data when no large execution values are present', async () => {
const response = await createHttpResponseFromBlock({
output: {
data: { issues: [] },
status: 200,
},
} as any)
await expect(response.json()).resolves.toEqual({ issues: [] })
})
})
+7 -5
View File
@@ -6,7 +6,8 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
import { and, asc, eq, inArray, isNull, max, min, sql } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { materializeLargeValueRefsSync } from '@/lib/execution/payloads/cache'
import { materializeInlineExecutionValue } from '@/lib/execution/payloads/inline-materialization.server'
import type { ExecutionMaterializationContext } from '@/lib/execution/payloads/materialization.server'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
@@ -316,11 +317,12 @@ export const workflowHasResponseBlock = (
return responseBlock !== undefined
}
export const createHttpResponseFromBlock = (
executionResult: Pick<ExecutionResult, 'output'>
): NextResponse => {
export const createHttpResponseFromBlock = async (
executionResult: Pick<ExecutionResult, 'output'>,
context?: ExecutionMaterializationContext
): Promise<NextResponse> => {
const { data = {}, status = 200, headers = {} } = executionResult.output
const responseData = materializeLargeValueRefsSync(data)
const responseData = await materializeInlineExecutionValue(data, context)
const responseHeaders = new Headers({
'Content-Type': 'application/json',
@@ -5,7 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.unmock('@/stores/terminal')
vi.unmock('@/stores/terminal/console/store')
vi.unmock('@/stores/notifications')
import { useNotificationStore } from '@/stores/notifications'
import { useTerminalConsoleStore } from '@/stores/terminal/console/store'
describe('terminal console store', () => {
@@ -17,6 +19,9 @@ describe('terminal console store', () => {
isOpen: false,
_hasHydrated: true,
})
useNotificationStore.setState({
notifications: [],
})
})
it('normalizes oversized payloads when adding console entries', () => {
@@ -117,6 +122,37 @@ describe('terminal console store', () => {
expect(after.getWorkflowEntries('wf-1')[0].output).toMatchObject({ status: 'updated' })
})
it('uses the block name from error updates in notifications', () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-1',
blockName: 'Unknown Block',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 1,
isRunning: true,
})
useTerminalConsoleStore.getState().updateConsole(
'block-1',
{
blockName: 'Transform Data',
blockType: 'function',
executionOrder: 1,
error: 'Boom',
success: false,
},
'exec-1'
)
const [entry] = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1')
const [notification] = useNotificationStore.getState().notifications
expect(entry.blockName).toBe('Transform Data')
expect(notification.message).toBe('Transform Data: Boom')
expect(notification.action?.message).toContain('Error in Transform Data.')
})
describe('cancelRunningEntries', () => {
it('flips a plain running entry to canceled', () => {
useTerminalConsoleStore.getState().addConsole({
+9 -1
View File
@@ -507,6 +507,14 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
: normalizeConsoleOutput(mergedOutput)
}
if (update.blockName !== undefined) {
updatedEntry.blockName = update.blockName
}
if (update.blockType !== undefined) {
updatedEntry.blockType = update.blockType
}
if (update.error !== undefined) {
updatedEntry.error = normalizeConsoleError(update.error)
}
@@ -605,7 +613,7 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
.find((entry) => matchesEntryForUpdate(entry, blockId, executionId, update))
notifyBlockError({
error: update.error,
blockName: matchingEntry?.blockName || 'Unknown Block',
blockName: update.blockName || matchingEntry?.blockName || 'Unknown Block',
workflowId: matchingEntry?.workflowId,
logContext: { blockId },
})
@@ -38,6 +38,8 @@ export interface ConsoleUpdate {
content?: string
output?: Partial<NormalizedBlockOutput>
replaceOutput?: NormalizedBlockOutput
blockName?: string
blockType?: string
executionOrder?: number
error?: string | Error | null
warning?: string
+6
View File
@@ -139,6 +139,8 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu
workflowId: params._context?.workflowId,
executionId: params._context?.executionId,
largeValueExecutionIds: params._context?.largeValueExecutionIds,
largeValueKeys: params._context?.largeValueKeys,
fileKeys: params._context?.fileKeys,
allowLargeValueWorkflowScope: params._context?.allowLargeValueWorkflowScope,
userId: params._context?.userId,
workspaceId: params._context?.workspaceId,
@@ -165,6 +167,8 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu
},
error: result.error,
resources: result.resources,
largeValueKeys: result.largeValueKeys,
fileKeys: result.fileKeys,
}
}
@@ -175,6 +179,8 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu
stdout: result.output.stdout,
},
resources: result.resources,
largeValueKeys: result.largeValueKeys,
fileKeys: result.fileKeys,
}
},
+2
View File
@@ -29,6 +29,8 @@ export interface CodeExecutionInput {
workflowId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
workspaceId?: string
+2
View File
@@ -66,6 +66,8 @@ export interface ToolResponse {
output: Record<string, any> // The structured output from the tool
error?: string // Error message if success is false
resources?: MothershipResource[] // Resources to auto-open/show in UI
largeValueKeys?: string[]
fileKeys?: string[]
timing?: {
startTime: string // ISO timestamp when the tool execution started
endTime: string // ISO timestamp when the tool execution ended