mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
+24
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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}"`)
|
||||
}
|
||||
}
|
||||
@@ -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,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).
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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`.
|
||||
*/
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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: [] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface CodeExecutionInput {
|
||||
workflowId?: string
|
||||
executionId?: string
|
||||
largeValueExecutionIds?: string[]
|
||||
largeValueKeys?: string[]
|
||||
fileKeys?: string[]
|
||||
allowLargeValueWorkflowScope?: boolean
|
||||
userId?: string
|
||||
workspaceId?: string
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user