chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,130 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
buildNextCallChain,
MAX_CALL_CHAIN_DEPTH,
parseCallChain,
SIM_VIA_HEADER,
serializeCallChain,
validateCallChain,
} from '@/lib/execution/call-chain'
describe('call-chain', () => {
describe('SIM_VIA_HEADER', () => {
it('has the expected header name', () => {
expect(SIM_VIA_HEADER).toBe('X-Sim-Via')
})
})
describe('MAX_CALL_CHAIN_DEPTH', () => {
it('equals 25', () => {
expect(MAX_CALL_CHAIN_DEPTH).toBe(25)
})
})
describe('parseCallChain', () => {
it('returns empty array for null', () => {
expect(parseCallChain(null)).toEqual([])
})
it('returns empty array for undefined', () => {
expect(parseCallChain(undefined)).toEqual([])
})
it('returns empty array for empty string', () => {
expect(parseCallChain('')).toEqual([])
})
it('returns empty array for whitespace-only string', () => {
expect(parseCallChain(' ')).toEqual([])
})
it('parses a single workflow ID', () => {
expect(parseCallChain('wf-abc')).toEqual(['wf-abc'])
})
it('parses multiple comma-separated workflow IDs', () => {
expect(parseCallChain('wf-a,wf-b,wf-c')).toEqual(['wf-a', 'wf-b', 'wf-c'])
})
it('trims whitespace around workflow IDs', () => {
expect(parseCallChain(' wf-a , wf-b , wf-c ')).toEqual(['wf-a', 'wf-b', 'wf-c'])
})
it('filters out empty segments', () => {
expect(parseCallChain('wf-a,,wf-b')).toEqual(['wf-a', 'wf-b'])
})
})
describe('serializeCallChain', () => {
it('serializes an empty array', () => {
expect(serializeCallChain([])).toBe('')
})
it('serializes a single ID', () => {
expect(serializeCallChain(['wf-a'])).toBe('wf-a')
})
it('serializes multiple IDs with commas', () => {
expect(serializeCallChain(['wf-a', 'wf-b', 'wf-c'])).toBe('wf-a,wf-b,wf-c')
})
})
describe('validateCallChain', () => {
it('returns null for an empty chain', () => {
expect(validateCallChain([])).toBeNull()
})
it('returns null when chain is under max depth', () => {
expect(validateCallChain(['wf-a', 'wf-b'])).toBeNull()
})
it('allows legitimate self-recursion', () => {
expect(validateCallChain(['wf-a', 'wf-a', 'wf-a'])).toBeNull()
})
it('returns depth error when chain is at max depth', () => {
const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH }, (_, i) => `wf-${i}`)
const error = validateCallChain(chain)
expect(error).toContain(
`Maximum workflow call chain depth (${MAX_CALL_CHAIN_DEPTH}) exceeded`
)
})
it('allows chain just under max depth', () => {
const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH - 1 }, (_, i) => `wf-${i}`)
expect(validateCallChain(chain)).toBeNull()
})
})
describe('buildNextCallChain', () => {
it('appends workflow ID to empty chain', () => {
expect(buildNextCallChain([], 'wf-a')).toEqual(['wf-a'])
})
it('appends workflow ID to existing chain', () => {
expect(buildNextCallChain(['wf-a', 'wf-b'], 'wf-c')).toEqual(['wf-a', 'wf-b', 'wf-c'])
})
it('does not mutate the original chain', () => {
const original = ['wf-a']
const result = buildNextCallChain(original, 'wf-b')
expect(original).toEqual(['wf-a'])
expect(result).toEqual(['wf-a', 'wf-b'])
})
})
describe('round-trip', () => {
it('parse → serialize is identity', () => {
const header = 'wf-a,wf-b,wf-c'
expect(serializeCallChain(parseCallChain(header))).toBe(header)
})
it('serialize → parse is identity', () => {
const chain = ['wf-a', 'wf-b', 'wf-c']
expect(parseCallChain(serializeCallChain(chain))).toEqual(chain)
})
})
})
+51
View File
@@ -0,0 +1,51 @@
/**
* Workflow call chain detection using the Via-style pattern.
*
* Prevents infinite execution loops when workflows call each other via API or
* MCP endpoints. Each hop appends the current workflow ID to the `X-Sim-Via`
* header; on ingress the chain is checked for depth.
*/
export const SIM_VIA_HEADER = 'X-Sim-Via'
export const MAX_CALL_CHAIN_DEPTH = 25
/**
* Parses the `X-Sim-Via` header value into an ordered list of workflow IDs.
* Returns an empty array when the header is absent or empty.
*/
export function parseCallChain(headerValue: string | null | undefined): string[] {
if (!headerValue || !headerValue.trim()) {
return []
}
return headerValue
.split(',')
.map((id) => id.trim())
.filter(Boolean)
}
/**
* Serializes a call chain array back into the header value format.
*/
export function serializeCallChain(chain: string[]): string {
return chain.join(',')
}
/**
* Validates that the call chain has not exceeded the maximum depth.
* Returns an error message string if invalid, or `null` if the chain is
* safe to extend.
*/
export function validateCallChain(chain: string[]): string | null {
if (chain.length >= MAX_CALL_CHAIN_DEPTH) {
return `Maximum workflow call chain depth (${MAX_CALL_CHAIN_DEPTH}) exceeded.`
}
return null
}
/**
* Builds the next call chain by appending the current workflow ID.
*/
export function buildNextCallChain(chain: string[], workflowId: string): string[] {
return [...chain, workflowId]
}
+125
View File
@@ -0,0 +1,125 @@
import { redisConfigMock, redisConfigMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockRedisSet, mockPublish, mockSubscribe } = vi.hoisted(() => ({
mockRedisSet: vi.fn(),
mockPublish: vi.fn(),
mockSubscribe: vi.fn(),
}))
const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
vi.mock('@/lib/events/pubsub', () => ({
createPubSubChannel: () => ({
publish: mockPublish,
subscribe: mockSubscribe,
dispose: vi.fn(),
}),
}))
import { getCancellationChannel, markExecutionCancelled } from './cancellation'
import {
abortManualExecution,
registerManualExecutionAborter,
unregisterManualExecutionAborter,
} from './manual-cancellation'
describe('markExecutionCancelled', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns redis_unavailable when no Redis client exists', async () => {
mockGetRedisClient.mockReturnValue(null)
await expect(markExecutionCancelled('execution-1')).resolves.toEqual({
durablyRecorded: false,
reason: 'redis_unavailable',
})
})
it('returns recorded when Redis write succeeds', async () => {
mockRedisSet.mockResolvedValue('OK')
mockGetRedisClient.mockReturnValue({ set: mockRedisSet })
await expect(markExecutionCancelled('execution-1')).resolves.toEqual({
durablyRecorded: true,
reason: 'recorded',
})
})
it('returns redis_write_failed when Redis write throws', async () => {
mockRedisSet.mockRejectedValue(new Error('set failed'))
mockGetRedisClient.mockReturnValue({ set: mockRedisSet })
await expect(markExecutionCancelled('execution-1')).resolves.toEqual({
durablyRecorded: false,
reason: 'redis_write_failed',
})
})
it('publishes even when the Redis write fails so local subscribers wake up', async () => {
mockRedisSet.mockRejectedValue(new Error('set failed'))
mockGetRedisClient.mockReturnValue({ set: mockRedisSet })
await markExecutionCancelled('execution-write-failed')
expect(mockPublish).toHaveBeenCalledWith({ executionId: 'execution-write-failed' })
})
it('publishes a cancellation event after a successful Redis write', async () => {
mockRedisSet.mockResolvedValue('OK')
mockGetRedisClient.mockReturnValue({ set: mockRedisSet })
await markExecutionCancelled('execution-2')
expect(mockPublish).toHaveBeenCalledWith({ executionId: 'execution-2' })
expect(mockRedisSet.mock.invocationCallOrder[0]).toBeLessThan(
mockPublish.mock.invocationCallOrder[0]
)
})
it('publishes even when Redis is unavailable so local subscribers wake up', async () => {
mockGetRedisClient.mockReturnValue(null)
await markExecutionCancelled('execution-3')
expect(mockPublish).toHaveBeenCalledWith({ executionId: 'execution-3' })
})
})
describe('getCancellationChannel', () => {
it('returns the same channel instance across calls', () => {
expect(getCancellationChannel()).toBe(getCancellationChannel())
})
})
describe('manual execution cancellation registry', () => {
beforeEach(() => {
unregisterManualExecutionAborter('execution-1')
})
it('aborts registered executions', () => {
const abort = vi.fn()
registerManualExecutionAborter('execution-1', abort)
expect(abortManualExecution('execution-1')).toBe(true)
expect(abort).toHaveBeenCalledTimes(1)
})
it('returns false when no execution is registered', () => {
expect(abortManualExecution('execution-missing')).toBe(false)
})
it('unregisters executions', () => {
const abort = vi.fn()
registerManualExecutionAborter('execution-1', abort)
unregisterManualExecutionAborter('execution-1')
expect(abortManualExecution('execution-1')).toBe(false)
expect(abort).not.toHaveBeenCalled()
})
})
+90
View File
@@ -0,0 +1,90 @@
import { createLogger } from '@sim/logger'
import { getRedisClient } from '@/lib/core/config/redis'
import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub'
const logger = createLogger('ExecutionCancellation')
const EXECUTION_CANCEL_PREFIX = 'execution:cancel:'
const EXECUTION_CANCEL_EXPIRY = 60 * 60
const EXECUTION_CANCEL_CHANNEL = 'execution:cancel'
export interface ExecutionCancelEvent {
executionId: string
}
export type ExecutionCancellationRecordResult =
| { durablyRecorded: true; reason: 'recorded' }
| {
durablyRecorded: false
reason: 'redis_unavailable' | 'redis_write_failed'
}
type CancellationGlobal = typeof globalThis & {
_executionCancelChannel?: PubSubChannel<ExecutionCancelEvent>
}
const _g = globalThis as CancellationGlobal
export function getCancellationChannel(): PubSubChannel<ExecutionCancelEvent> {
if (!_g._executionCancelChannel) {
_g._executionCancelChannel = createPubSubChannel<ExecutionCancelEvent>({
channel: EXECUTION_CANCEL_CHANNEL,
label: 'execution-cancel',
})
}
return _g._executionCancelChannel
}
export function isRedisCancellationEnabled(): boolean {
return getRedisClient() !== null
}
/** Writes the durable key first, then publishes — so a late subscriber still sees the flag on backstop check. */
export async function markExecutionCancelled(
executionId: string
): Promise<ExecutionCancellationRecordResult> {
const redis = getRedisClient()
if (!redis) {
getCancellationChannel().publish({ executionId })
return { durablyRecorded: false, reason: 'redis_unavailable' }
}
try {
await redis.set(`${EXECUTION_CANCEL_PREFIX}${executionId}`, '1', 'EX', EXECUTION_CANCEL_EXPIRY)
logger.info('Marked execution as cancelled', { executionId })
getCancellationChannel().publish({ executionId })
return { durablyRecorded: true, reason: 'recorded' }
} catch (error) {
logger.error('Failed to mark execution as cancelled', { executionId, error })
getCancellationChannel().publish({ executionId })
return { durablyRecorded: false, reason: 'redis_write_failed' }
}
}
export async function isExecutionCancelled(executionId: string): Promise<boolean> {
const redis = getRedisClient()
if (!redis) {
return false
}
try {
const result = await redis.exists(`${EXECUTION_CANCEL_PREFIX}${executionId}`)
return result === 1
} catch (error) {
logger.error('Failed to check execution cancellation', { executionId, error })
return false
}
}
export async function clearExecutionCancellation(executionId: string): Promise<void> {
const redis = getRedisClient()
if (!redis) {
return
}
try {
await redis.del(`${EXECUTION_CANCEL_PREFIX}${executionId}`)
} catch (error) {
logger.error('Failed to clear execution cancellation', { executionId, error })
}
}
+21
View File
@@ -0,0 +1,21 @@
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
import type { SandboxTaskId } from '@/sandbox-tasks/registry'
export { DEFAULT_EXECUTION_TIMEOUT_MS }
/**
* Maximum inline source size accepted by document preview endpoints.
*
* This is intentionally much lower than Next.js's default 10MB proxy body cap:
* preview requests send user-authored source code, not binary uploads. Keeping
* the limit at 1MB gives generous headroom for real PPTX/PDF generator scripts
* while reducing memory pressure and abuse potential from oversized payloads.
*/
export const MAX_DOCUMENT_PREVIEW_CODE_BYTES = 1 * 1024 * 1024
/** Maps file extension to the sandbox task that compiles/generates that document type. */
export const BINARY_DOC_TASKS: Record<string, SandboxTaskId> = {
docx: 'docx-generate',
pptx: 'pptx-generate',
pdf: 'pdf-generate',
}
+108
View File
@@ -0,0 +1,108 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CodeLanguage } from '@/lib/execution/languages'
const { mockCreate, mockRunCode, mockCommandsRun, mockFilesWrite, mockKill } = vi.hoisted(() => ({
mockCreate: vi.fn(),
mockRunCode: vi.fn(),
mockCommandsRun: vi.fn(),
mockFilesWrite: vi.fn(),
mockKill: vi.fn(),
}))
vi.mock('@e2b/code-interpreter', () => ({ Sandbox: { create: mockCreate } }))
vi.mock('@/lib/core/config/env', () => ({ env: { E2B_API_KEY: 'test-key' } }))
import { executeInE2B, executeShellInE2B } from '@/lib/execution/e2b'
describe('e2b sandbox inputs', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreate.mockResolvedValue({
sandboxId: 'sb_1',
files: { write: mockFilesWrite },
commands: { run: mockCommandsRun },
runCode: mockRunCode,
kill: mockKill,
})
mockRunCode.mockResolvedValue({
error: null,
text: '',
logs: { stdout: [], stderr: [] },
results: [],
})
// Default: shell code run + any fetch succeed.
mockCommandsRun.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 })
})
it('fetches a url entry via curl with URL/DST/DIR passed as envs (no inline write)', async () => {
await executeInE2B({
code: 'x',
language: CodeLanguage.JavaScript,
timeoutMs: 1000,
sandboxFiles: [
{ type: 'url', path: '/home/user/tables/t.csv', url: 'https://s3.example/p?a=1&b=2' },
],
})
expect(mockCommandsRun).toHaveBeenCalledTimes(1)
const [cmd, opts] = mockCommandsRun.mock.calls[0]
expect(cmd).toContain('curl')
expect(cmd).toContain('mkdir -p')
// URL/path go through envs, never interpolated into the command string.
expect(cmd).not.toContain('https://s3.example')
expect(opts.envs).toEqual({
URL: 'https://s3.example/p?a=1&b=2',
DST: '/home/user/tables/t.csv',
DIR: '/home/user/tables',
})
expect(opts.user).toBeUndefined() // code sandbox runs as default user
expect(mockFilesWrite).not.toHaveBeenCalled()
})
it('writes a content entry inline (no fetch)', async () => {
await executeInE2B({
code: 'x',
language: CodeLanguage.JavaScript,
timeoutMs: 1000,
sandboxFiles: [{ path: '/home/user/f.txt', content: 'hi' }],
})
expect(mockFilesWrite).toHaveBeenCalledWith('/home/user/f.txt', 'hi')
expect(mockCommandsRun).not.toHaveBeenCalled()
})
it('fetches as root in the shell sandbox', async () => {
await executeShellInE2B({
code: 'echo hi',
envs: {},
timeoutMs: 1000,
sandboxFiles: [{ type: 'url', path: '/home/user/tables/t.csv', url: 'https://s3.example/p' }],
})
const fetchCall = mockCommandsRun.mock.calls.find((c) => c[1]?.envs?.URL)
expect(fetchCall).toBeDefined()
expect(fetchCall?.[0]).toContain('curl')
expect(fetchCall?.[1].user).toBe('root')
})
it('throws a clear error and kills the sandbox when the fetch fails', async () => {
mockCommandsRun.mockRejectedValueOnce(new Error('curl: (22) 403'))
await expect(
executeInE2B({
code: 'x',
language: CodeLanguage.JavaScript,
timeoutMs: 1000,
sandboxFiles: [
{ type: 'url', path: '/home/user/tables/t.csv', url: 'https://s3.example/p' },
],
})
).rejects.toThrow(/Failed to fetch mounted file into sandbox/)
expect(mockKill).toHaveBeenCalled()
expect(mockRunCode).not.toHaveBeenCalled()
})
})
+476
View File
@@ -0,0 +1,476 @@
import type { Sandbox as E2BSandbox } from '@e2b/code-interpreter'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { env } from '@/lib/core/config/env'
import { CodeLanguage } from '@/lib/execution/languages'
/**
* A sandbox input file. `content` entries are written inline; `url` entries are fetched from inside
* the sandbox (so large mounts never pass their bytes through the web process).
*/
export type SandboxFile =
| { type?: 'content'; path: string; content: string; encoding?: 'base64' }
| { type: 'url'; path: string; url: string }
export interface E2BExecutionRequest {
code: string
language: CodeLanguage
timeoutMs: number
sandboxFiles?: SandboxFile[]
outputSandboxPath?: string
outputSandboxPaths?: string[]
// Which sandbox template to run in. Defaults to 'code' (mothership-shell).
// Document generation passes 'doc' so it runs in the doc template
// (mothership-docs) that has python-pptx/docx/openpyxl/reportlab installed.
sandboxKind?: 'code' | 'doc'
}
export interface E2BShellExecutionRequest {
code: string
envs: Record<string, string>
timeoutMs: number
sandboxFiles?: SandboxFile[]
outputSandboxPath?: string
outputSandboxPaths?: string[]
// Which sandbox template to run in. Defaults to 'shell' (mothership-shell).
// The Node document engines (pptxgenjs/docx + react-icons/sharp) pass 'doc' so
// they run in the doc template (mothership-docs).
sandboxKind?: 'shell' | 'doc'
}
export interface E2BExecutionResult {
result: unknown
stdout: string
sandboxId?: string
error?: string
exportedFileContent?: string
exportedFiles?: Record<string, string>
/** Base64-encoded PNG images captured from rich outputs (e.g. matplotlib figures). */
images?: string[]
}
const logger = createLogger('E2BExecution')
/**
* Materializes sandbox input files before user code runs. `content` entries are written inline;
* `url` entries are fetched from inside the sandbox via `curl` — their bytes never pass through the
* web process, so the mount size is bounded by sandbox disk, not web heap. The URL and paths are
* passed as env vars (never interpolated into the shell) so a presigned query string can't break or
* inject. A failed fetch throws so user code never runs against a missing mount. `rootUser` matches
* the shell sandbox's root execution context.
*/
async function writeSandboxInputs(
sandbox: E2BSandbox,
files: SandboxFile[] | undefined,
opts: { sandboxId?: string; rootUser?: boolean }
): Promise<void> {
if (!files?.length) return
const fetchedByUrl: string[] = []
const writtenInline: string[] = []
for (const file of files) {
if (file.type === 'url') {
const dir = file.path.slice(0, file.path.lastIndexOf('/'))
try {
await sandbox.commands.run(
'set -e; [ -n "$DIR" ] && mkdir -p "$DIR"; curl -fsS --retry 3 --retry-connrefused --max-time 300 "$URL" -o "$DST"',
{
envs: { URL: file.url, DST: file.path, DIR: dir },
...(opts.rootUser ? { user: 'root' } : {}),
}
)
fetchedByUrl.push(file.path)
} catch (error) {
throw new Error(
`Failed to fetch mounted file into sandbox at ${file.path}: ${getErrorMessage(error)}`
)
}
} else if (file.encoding === 'base64') {
const buf = Buffer.from(file.content, 'base64')
await sandbox.files.write(
file.path,
buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
)
writtenInline.push(file.path)
} else {
await sandbox.files.write(file.path, file.content)
writtenInline.push(file.path)
}
}
// Split counts so it's visible whether a mount was fetched in-sandbox (by presigned URL, no bytes
// through the web process) or written inline.
logger.info('Materialized sandbox inputs', {
sandboxId: opts.sandboxId,
fetchedByUrlCount: fetchedByUrl.length,
writtenInlineCount: writtenInline.length,
fetchedByUrl,
writtenInline,
})
}
async function createE2BSandbox(kind: 'code' | 'shell' | 'doc' | 'pi'): Promise<E2BSandbox> {
const apiKey = env.E2B_API_KEY
if (!apiKey) {
throw new Error('E2B_API_KEY is required when E2B is enabled')
}
// Document generation uses a dedicated template (python-pptx/docx/openpyxl/
// reportlab + fonts); shell/code execution use the general shell template.
// Doc fails closed: never run LLM-authored Python in E2B's default template
// (which is not vetted for this) just because the doc template id is unset.
if (kind === 'doc' && !env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID) {
throw new Error('Document compiler not configured (MOTHERSHIP_E2B_DOC_TEMPLATE_ID is unset)')
}
// Pi fails closed for the same reason: the coding agent needs the Pi CLI + git
// baked into a vetted template, never E2B's default image.
if (kind === 'pi' && !env.E2B_PI_TEMPLATE_ID) {
throw new Error('Pi cloud agent not configured (E2B_PI_TEMPLATE_ID is unset)')
}
const templateName =
kind === 'doc'
? env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID
: kind === 'pi'
? env.E2B_PI_TEMPLATE_ID
: env.MOTHERSHIP_E2B_TEMPLATE_ID
logger.info('Creating E2B sandbox', {
kind,
template: templateName || '(default)',
})
const { Sandbox } = await import('@e2b/code-interpreter')
return templateName ? Sandbox.create(templateName, { apiKey }) : Sandbox.create({ apiKey })
}
function shouldReadSandboxPathAsBase64(outputSandboxPath: string): boolean {
const ext = outputSandboxPath.slice(outputSandboxPath.lastIndexOf('.')).toLowerCase()
const binaryExts = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.pdf',
'.zip',
'.mp3',
'.mp4',
'.docx',
'.pptx',
'.xlsx',
])
return binaryExts.has(ext)
}
async function readSandboxOutputFile(
sandbox: E2BSandbox,
outputSandboxPath: string,
options?: { user?: string }
): Promise<string | undefined> {
try {
if (shouldReadSandboxPathAsBase64(outputSandboxPath)) {
const b64Result = await sandbox.commands.run(`base64 -w0 "${outputSandboxPath}"`, options)
return b64Result.stdout
}
return await sandbox.files.read(outputSandboxPath)
} catch (error) {
logger.warn('Failed to read requested sandbox output file', {
outputSandboxPath,
error: getErrorMessage(error),
})
return undefined
}
}
function requestedOutputSandboxPaths(req: {
outputSandboxPath?: string
outputSandboxPaths?: string[]
}): string[] {
const paths = [...(req.outputSandboxPaths ?? [])]
if (req.outputSandboxPath && !paths.includes(req.outputSandboxPath)) {
paths.push(req.outputSandboxPath)
}
return paths
}
export async function executeInE2B(req: E2BExecutionRequest): Promise<E2BExecutionResult> {
const { code, language, timeoutMs } = req
const sandbox = await createE2BSandbox(req.sandboxKind ?? 'code')
const sandboxId = sandbox.sandboxId
const stdoutChunks = []
try {
// Inside the try so a failed mount still kills the sandbox via the finally below.
await writeSandboxInputs(sandbox, req.sandboxFiles, { sandboxId })
const execution = await sandbox.runCode(code, {
language: language === CodeLanguage.Python ? 'python' : 'javascript',
timeoutMs,
})
if (execution.error) {
const errorMessage = `${execution.error.name}: ${execution.error.value}`
logger.error(`E2B execution error`, {
sandboxId,
error: execution.error,
errorMessage,
})
const errorOutput = execution.error.traceback || errorMessage
return {
result: null,
stdout: errorOutput,
error: errorMessage,
sandboxId,
}
}
if (execution.text) {
stdoutChunks.push(execution.text)
}
if (execution.logs?.stdout) {
stdoutChunks.push(...execution.logs.stdout)
}
if (execution.logs?.stderr) {
stdoutChunks.push(...execution.logs.stderr)
}
const stdout = stdoutChunks.join('\n')
let result: unknown = null
const prefix = '__SIM_RESULT__='
const lines = stdout.split('\n')
const marker = lines.find((l) => l.startsWith(prefix))
let cleanedStdout = stdout
if (marker) {
const jsonPart = marker.slice(prefix.length)
try {
result = JSON.parse(jsonPart)
} catch {
result = jsonPart
}
const filteredLines = lines.filter((l) => !l.startsWith(prefix))
if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') {
filteredLines.pop()
}
cleanedStdout = filteredLines.join('\n')
}
const images: string[] = []
if (execution.results?.length) {
for (const r of execution.results) {
if (r.png) {
images.push(r.png)
} else if (r.jpeg) {
images.push(r.jpeg)
}
}
}
const exportedFiles: Record<string, string> = {}
for (const outputSandboxPath of requestedOutputSandboxPaths(req)) {
const content = await readSandboxOutputFile(sandbox, outputSandboxPath)
if (content !== undefined) {
exportedFiles[outputSandboxPath] = content
}
}
const exportedFileContent = req.outputSandboxPath
? exportedFiles[req.outputSandboxPath]
: undefined
return {
result,
stdout: cleanedStdout,
sandboxId,
exportedFileContent,
exportedFiles: Object.keys(exportedFiles).length ? exportedFiles : undefined,
images: images.length ? images : undefined,
}
} finally {
try {
await sandbox.kill()
} catch {}
}
}
export async function executeShellInE2B(
req: E2BShellExecutionRequest
): Promise<E2BExecutionResult> {
const { code, envs, timeoutMs } = req
const sandbox = await createE2BSandbox(req.sandboxKind ?? 'shell')
const sandboxId = sandbox.sandboxId
try {
// Inside the try so a failed mount still kills the sandbox via the finally below.
await writeSandboxInputs(sandbox, req.sandboxFiles, { sandboxId, rootUser: true })
let result: { stdout: string; stderr: string; exitCode: number }
try {
result = await sandbox.commands.run(code, {
envs: {
...envs,
PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/bin',
},
timeoutMs,
user: 'root',
})
} catch (cmdError: any) {
const stderr = cmdError?.stderr || cmdError?.message || String(cmdError)
const stdout = cmdError?.stdout || ''
const exitCode = cmdError?.exitCode ?? 1
logger.error('E2B shell command error', {
sandboxId,
exitCode,
error: stderr.slice(0, 500),
})
return {
result: null,
stdout: [stdout, stderr].filter(Boolean).join('\n'),
error: stderr || `Command failed with exit code ${exitCode}`,
sandboxId,
}
}
const stdout = [result.stdout, result.stderr].filter(Boolean).join('\n')
if (result.exitCode !== 0) {
const errorMessage = result.stderr || `Process exited with code ${result.exitCode}`
logger.error('E2B shell execution error', {
sandboxId,
exitCode: result.exitCode,
stderr: result.stderr?.slice(0, 500),
})
return {
result: null,
stdout,
error: errorMessage,
sandboxId,
}
}
let parsed: unknown = null
const prefix = '__SIM_RESULT__='
const lines = stdout.split('\n')
const marker = lines.find((l) => l.startsWith(prefix))
let cleanedStdout = stdout
if (marker) {
const jsonPart = marker.slice(prefix.length)
try {
parsed = JSON.parse(jsonPart)
} catch {
parsed = jsonPart
}
const filteredLines = lines.filter((l) => !l.startsWith(prefix))
if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') {
filteredLines.pop()
}
cleanedStdout = filteredLines.join('\n')
}
const exportedFiles: Record<string, string> = {}
for (const outputSandboxPath of requestedOutputSandboxPaths(req)) {
const content = await readSandboxOutputFile(sandbox, outputSandboxPath, {
user: 'root',
})
if (content !== undefined) {
exportedFiles[outputSandboxPath] = content
}
}
const exportedFileContent = req.outputSandboxPath
? exportedFiles[req.outputSandboxPath]
: undefined
return {
result: parsed,
stdout: cleanedStdout,
sandboxId,
exportedFileContent,
exportedFiles: Object.keys(exportedFiles).length ? exportedFiles : undefined,
}
} finally {
try {
await sandbox.kill()
} catch {}
}
}
const PI_SANDBOX_PATH =
'/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/bin'
/** Result of one command run inside a Pi sandbox. */
export interface PiSandboxCommandResult {
stdout: string
stderr: string
exitCode: number
}
/** Runs commands and moves files inside a live Pi sandbox. */
export interface PiSandboxRunner {
run(
command: string,
options: {
envs?: Record<string, string>
timeoutMs: number
onStdout?: (chunk: string) => void
onStderr?: (chunk: string) => void
}
): Promise<PiSandboxCommandResult>
readFile(path: string): Promise<string>
/**
* Writes a file via the sandbox filesystem API. Bytes go through the E2B SDK,
* never a shell, so untrusted content (the assembled prompt, a commit message)
* is delivered without any shell parsing — callers reference it by a fixed path.
*/
writeFile(path: string, content: string): Promise<void>
}
/**
* Creates a Pi sandbox, keeps it alive for the duration of `fn` (so the cloned
* repo persists across the clone -> agent -> push commands), streams command
* output, and always kills the sandbox afterward. Per-command envs are isolated,
* so secrets handed to one command never leak into the next.
*/
export async function withPiSandbox<T>(fn: (runner: PiSandboxRunner) => Promise<T>): Promise<T> {
const sandbox = await createE2BSandbox('pi')
const sandboxId = sandbox.sandboxId
logger.info('Started Pi sandbox', { sandboxId })
const runner: PiSandboxRunner = {
run: async (command, options) => {
try {
const result = await sandbox.commands.run(command, {
envs: { ...(options.envs ?? {}), PATH: PI_SANDBOX_PATH },
timeoutMs: options.timeoutMs,
user: 'root',
onStdout: options.onStdout,
onStderr: options.onStderr,
})
return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }
} catch (error) {
const failure = error as {
stdout?: string
stderr?: string
message?: string
exitCode?: number
}
return {
stdout: failure.stdout ?? '',
stderr: failure.stderr ?? failure.message ?? getErrorMessage(error),
exitCode: failure.exitCode ?? 1,
}
}
},
readFile: (path) => sandbox.files.read(path),
writeFile: async (path, content) => {
await sandbox.files.write(path, content)
},
}
try {
return await fn(runner)
} finally {
try {
await sandbox.kill()
} catch {}
}
}
+429
View File
@@ -0,0 +1,429 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionEventEntry } from '@/lib/execution/event-buffer'
import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events'
const { mockGetRedisClient, mockRedis, persistedEntries } = vi.hoisted(() => {
const persistedEntries: ExecutionEventEntry[] = []
const mockRedis = {
get: vi.fn(),
incrby: vi.fn(),
hset: vi.fn(),
expire: vi.fn(),
hgetall: vi.fn(),
zrangebyscore: vi.fn(),
zremrangebyrank: vi.fn(),
pipeline: vi.fn(),
eval: vi.fn(),
}
const mockGetRedisClient = vi.fn(() => mockRedis)
return { mockGetRedisClient, mockRedis, persistedEntries }
})
vi.mock('@/lib/core/config/redis', () => ({
getRedisClient: mockGetRedisClient,
}))
import {
createExecutionEventWriter,
flushExecutionStreamReplayBuffer,
initializeExecutionStreamMeta,
readExecutionEventsState,
resetExecutionStreamBuffer,
} from '@/lib/execution/event-buffer'
function makeEvent(blockId: string): ExecutionEvent {
return {
type: 'block:started',
timestamp: new Date().toISOString(),
executionId: 'exec-1',
workflowId: 'wf-1',
data: {
blockId,
blockName: blockId,
blockType: 'function',
executionOrder: 1,
},
}
}
function parseFlushEvalArgs(args: unknown[]): {
terminalStatus: string
zaddArgs: (string | number)[]
} {
const keyCount = Number(args[0])
return {
terminalStatus: String(args[keyCount + 4] ?? ''),
zaddArgs: args.slice(keyCount + 9) as (string | number)[],
}
}
function isFlushScript(script: string): boolean {
return script.includes("redis.call('ZADD'") && script.includes('new_count')
}
function isResetScript(script: string): boolean {
return script.includes('retained_bytes') && script.includes('replayStartEventId')
}
describe('execution event buffer', () => {
beforeEach(() => {
vi.clearAllMocks()
persistedEntries.length = 0
mockGetRedisClient.mockReturnValue(mockRedis)
mockRedis.get.mockResolvedValue(null)
mockRedis.hgetall.mockResolvedValue({})
mockRedis.zrangebyscore.mockResolvedValue([])
mockRedis.zremrangebyrank.mockResolvedValue(0)
mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => {
if (isFlushScript(script)) {
const { terminalStatus, zaddArgs } = parseFlushEvalArgs(args)
for (let i = 0; i < zaddArgs.length; i += 2) {
persistedEntries.push(JSON.parse(zaddArgs[i + 1] as string) as ExecutionEventEntry)
}
if (terminalStatus) {
await mockRedis.hset('meta', { status: terminalStatus })
}
return [1, persistedEntries[0]?.eventId ?? false, 0]
}
if (isResetScript(script)) {
return 0
}
if (script.includes('DECRBY')) {
return 1
}
return [1, 'ok', 0, 0]
})
mockRedis.pipeline.mockImplementation(() => ({
zadd: vi.fn((_key: string, ...args: (string | number)[]) => {
for (let i = 0; i < args.length; i += 2) {
persistedEntries.push(JSON.parse(args[i + 1] as string) as ExecutionEventEntry)
}
}),
expire: vi.fn(),
zremrangebyrank: vi.fn(),
exec: vi.fn().mockResolvedValue(undefined),
}))
})
it('serializes event id reservation so reconnect replay preserves write order', async () => {
let releaseReservation: ((value: number) => void) | undefined
mockRedis.incrby.mockReturnValueOnce(
new Promise<number>((resolve) => {
releaseReservation = resolve
})
)
const writer = createExecutionEventWriter('exec-1')
const firstWrite = writer.write(makeEvent('first'))
const secondWrite = writer.write(makeEvent('second'))
await Promise.resolve()
expect(mockRedis.incrby).toHaveBeenCalledTimes(1)
releaseReservation?.(100)
await expect(Promise.all([firstWrite, secondWrite])).resolves.toMatchObject([
{ eventId: 1 },
{ eventId: 2 },
])
await writer.close()
expect(persistedEntries.map((entry) => entry.eventId)).toEqual([1, 2])
expect(
persistedEntries.map((entry) => (entry.event.data as { blockId: string }).blockId)
).toEqual(['first', 'second'])
})
it('flush waits for queued writes before returning', async () => {
let releaseReservation: ((value: number) => void) | undefined
mockRedis.incrby.mockReturnValueOnce(
new Promise<number>((resolve) => {
releaseReservation = resolve
})
)
const writer = createExecutionEventWriter('exec-1')
const write = writer.write(makeEvent('terminal'))
const flush = writer.flush()
await Promise.resolve()
expect(persistedEntries).toEqual([])
releaseReservation?.(100)
await write
await flush
expect(persistedEntries.map((entry) => entry.eventId)).toEqual([1])
expect((persistedEntries[0].event.data as { blockId: string }).blockId).toBe('terminal')
})
it('flush drains events appended while another flush is in flight', async () => {
mockRedis.incrby.mockResolvedValue(100)
let releaseFirstFlush: (() => void) | undefined
const execCalls: Array<() => Promise<void>> = [
() =>
new Promise<void>((resolve) => {
releaseFirstFlush = resolve
}),
() => Promise.resolve(),
]
mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => {
const batchEntries: ExecutionEventEntry[] = []
const { zaddArgs } = parseFlushEvalArgs(args)
for (let i = 0; i < zaddArgs.length; i += 2) {
batchEntries.push(JSON.parse(zaddArgs[i + 1] as string) as ExecutionEventEntry)
}
await (execCalls.shift() ?? (() => Promise.resolve()))()
persistedEntries.push(...batchEntries)
return [1, persistedEntries[0]?.eventId ?? false, 0]
})
mockRedis.pipeline.mockImplementation(() => {
const batchEntries: ExecutionEventEntry[] = []
return {
zadd: vi.fn((_key: string, ...args: (string | number)[]) => {
for (let i = 0; i < args.length; i += 2) {
batchEntries.push(JSON.parse(args[i + 1] as string) as ExecutionEventEntry)
}
}),
expire: vi.fn(),
zremrangebyrank: vi.fn(),
exec: vi.fn(async () => {
await (execCalls.shift() ?? (() => Promise.resolve()))()
persistedEntries.push(...batchEntries)
}),
}
})
const writer = createExecutionEventWriter('exec-1')
await writer.write(makeEvent('first'))
const firstFlush = writer.flush()
await Promise.resolve()
expect(persistedEntries).toEqual([])
await writer.write(makeEvent('terminal'))
const terminalFlush = writer.flush()
releaseFirstFlush?.()
await firstFlush
await terminalFlush
expect(
persistedEntries.map((entry) => (entry.event.data as { blockId: string }).blockId)
).toEqual(['first', 'terminal'])
})
it('flush surfaces queued write failures', async () => {
mockRedis.incrby.mockRejectedValueOnce(new Error('redis reservation failed'))
const writer = createExecutionEventWriter('exec-1')
await expect(writer.write(makeEvent('lost'))).rejects.toThrow('redis reservation failed')
await expect(writer.flush()).rejects.toThrow('redis reservation failed')
})
it('allows terminal finalization after a recovered queued write failure', async () => {
mockRedis.incrby
.mockRejectedValueOnce(new Error('redis reservation failed'))
.mockResolvedValueOnce(200)
const writer = createExecutionEventWriter('exec-1')
await expect(writer.write(makeEvent('lost'))).rejects.toThrow('redis reservation failed')
await writer.write(makeEvent('terminal'))
await expect(flushExecutionStreamReplayBuffer('exec-1', writer)).resolves.toBe(true)
expect(persistedEntries.map((entry) => entry.eventId)).toEqual([101])
expect(mockRedis.hset).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ status: 'complete' })
)
})
it('does not write terminal meta when the final replay flush fails', async () => {
mockRedis.incrby.mockResolvedValue(100)
mockRedis.eval.mockRejectedValue(new Error('redis flush failed'))
const writer = createExecutionEventWriter('exec-1')
await writer.write(makeEvent('terminal'))
await expect(flushExecutionStreamReplayBuffer('exec-1', writer)).resolves.toBe(false)
expect(mockRedis.hset).not.toHaveBeenCalled()
})
it('flushes replay events after a recovered final replay flush without terminal meta', async () => {
mockRedis.incrby.mockResolvedValue(100)
let flushAttempt = 0
mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => {
const { zaddArgs } = parseFlushEvalArgs(args)
if (flushAttempt > 0) {
for (let i = 0; i < zaddArgs.length; i += 2) {
persistedEntries.push(JSON.parse(zaddArgs[i + 1] as string) as ExecutionEventEntry)
}
}
if (flushAttempt++ === 0) {
throw new Error('first flush failed')
}
return [1, persistedEntries[0]?.eventId ?? false, 0]
})
mockRedis.pipeline.mockImplementation(() => ({
zadd: vi.fn((_key: string, ...args: (string | number)[]) => {
if (flushAttempt > 0) {
for (let i = 0; i < args.length; i += 2) {
persistedEntries.push(JSON.parse(args[i + 1] as string) as ExecutionEventEntry)
}
}
}),
expire: vi.fn(),
zremrangebyrank: vi.fn(),
exec: vi.fn(async () => {
if (flushAttempt++ === 0) {
throw new Error('first flush failed')
}
}),
}))
const writer = createExecutionEventWriter('exec-1')
await writer.write(makeEvent('terminal'))
await expect(flushExecutionStreamReplayBuffer('exec-1', writer)).resolves.toBe(true)
expect(persistedEntries.map((entry) => entry.eventId)).toEqual([1])
expect(mockRedis.hset).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ status: 'complete' })
)
})
it('writes terminal event and terminal meta atomically through writeTerminal', async () => {
mockRedis.incrby.mockResolvedValue(100)
const writer = createExecutionEventWriter('exec-1')
await writer.writeTerminal(makeEvent('terminal'), 'complete')
expect(persistedEntries.map((entry) => entry.eventId)).toEqual([1])
expect(mockRedis.hset).toHaveBeenCalledWith('meta', { status: 'complete' })
})
it('budgets only net event bytes after pruning during flush', async () => {
mockRedis.incrby.mockResolvedValue(100)
let netBudgetBytes = 0
mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => {
const keyCount = Number(args[0])
netBudgetBytes = Number(args[keyCount + 5])
const { zaddArgs } = parseFlushEvalArgs(args)
for (let i = 0; i < zaddArgs.length; i += 2) {
persistedEntries.push(JSON.parse(zaddArgs[i + 1] as string) as ExecutionEventEntry)
}
return [1, persistedEntries[0]?.eventId ?? false, 123]
})
const writer = createExecutionEventWriter('exec-1')
await writer.writeTerminal(makeEvent('terminal'), 'complete')
expect(netBudgetBytes).toBeGreaterThan(0)
})
it('releases retained event budget when resetting the stream buffer', async () => {
mockRedis.get.mockResolvedValueOnce(41)
mockRedis.hgetall.mockResolvedValueOnce({ userId: 'user-1' })
let releasedBytes = 0
mockRedis.eval.mockImplementationOnce(async (script: string, ...args: unknown[]) => {
expect(script).toContain('retained_bytes')
expect(args.slice(0, 5)).toEqual([
4,
'execution:stream:exec-1:events',
'execution:stream:exec-1:meta',
'execution:redis-budget:execution:exec-1',
'execution:redis-budget:user:user-1',
])
releasedBytes = 256
return releasedBytes
})
await expect(resetExecutionStreamBuffer('exec-1')).resolves.toBe(true)
expect(releasedBytes).toBe(256)
})
it('surfaces execution memory limit errors when the Redis budget is exceeded', async () => {
mockRedis.incrby.mockResolvedValue(100)
mockRedis.eval.mockImplementation(async (script: string) => {
if (isFlushScript(script)) {
return [0, 'execution_redis_bytes', 64 * 1024 * 1024]
}
return [1, 'ok', 0, 0]
})
const writer = createExecutionEventWriter('exec-1')
await expect(writer.writeTerminal(makeEvent('terminal'), 'complete')).rejects.toThrow(
'Execution memory limit exceeded'
)
expect(persistedEntries).toEqual([])
})
it('preserves requested UserFile base64 when buffering terminal events', async () => {
mockRedis.incrby.mockResolvedValue(100)
const base64 = Buffer.from('hello').toString('base64')
const writer = createExecutionEventWriter('exec-1', { preserveUserFileBase64: true })
await writer.writeTerminal(
{
type: 'execution:completed',
timestamp: new Date().toISOString(),
executionId: 'exec-1',
workflowId: 'wf-1',
data: {
success: true,
duration: 1,
output: {
file: {
id: 'file-1',
name: 'small.txt',
size: 5,
type: 'text/plain',
context: 'execution',
base64,
},
},
},
},
'complete'
)
const eventData = persistedEntries[0].event.data as {
output: { file: { base64?: string } }
}
expect(eventData.output.file.base64).toBe(base64)
})
it('retries active meta initialization before giving up', async () => {
mockRedis.hset.mockRejectedValueOnce(new Error('meta write failed')).mockResolvedValueOnce(1)
await expect(
initializeExecutionStreamMeta('exec-1', { userId: 'user-1', workflowId: 'wf-1' })
).resolves.toBe(true)
expect(mockRedis.hset).toHaveBeenCalledTimes(2)
expect(mockRedis.hset).toHaveBeenLastCalledWith(
'execution:stream:exec-1:meta',
expect.objectContaining({
status: 'active',
userId: 'user-1',
workflowId: 'wf-1',
})
)
})
it('reports pruned replay buffers before reading incomplete events', async () => {
mockRedis.hgetall.mockResolvedValue({ status: 'active', earliestEventId: '10' })
await expect(readExecutionEventsState('exec-1', 0)).resolves.toEqual({
status: 'pruned',
earliestEventId: 10,
})
expect(mockRedis.zrangebyscore).not.toHaveBeenCalled()
})
})
File diff suppressed because it is too large Load Diff
+202
View File
@@ -0,0 +1,202 @@
import { createLogger } from '@sim/logger'
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import type { UserFile } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('ExecutionFiles')
const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
/**
* Process a single file for workflow execution - handles base64 ('file' type) and URL downloads ('url' type)
*/
export async function processExecutionFile(
file: { type: string; data: string; name: string; mime?: string },
executionContext: { workspaceId: string; workflowId: string; executionId: string },
requestId: string,
userId?: string
): Promise<UserFile | null> {
if (file.type === 'file' && file.data && file.name) {
const dataUrlPrefix = 'data:'
const base64Prefix = ';base64,'
if (!file.data.startsWith(dataUrlPrefix)) {
logger.warn(`[${requestId}] Invalid data format for file: ${file.name}`)
return null
}
const base64Index = file.data.indexOf(base64Prefix)
if (base64Index === -1) {
logger.warn(`[${requestId}] Invalid data format (no base64 marker) for file: ${file.name}`)
return null
}
const mimeType = file.data.substring(dataUrlPrefix.length, base64Index)
const base64Data = file.data.substring(base64Index + base64Prefix.length)
const buffer = Buffer.from(base64Data, 'base64')
if (buffer.length > MAX_FILE_SIZE) {
const fileSizeMB = (buffer.length / (1024 * 1024)).toFixed(2)
throw new Error(
`File "${file.name}" exceeds the maximum size limit of 20MB (actual size: ${fileSizeMB}MB)`
)
}
const userFile = await uploadExecutionFile(
executionContext,
buffer,
file.name,
mimeType || file.mime || 'application/octet-stream',
userId
)
return userFile
}
if (file.type === 'url' && file.data) {
const { downloadFileFromUrl } = await import('@/lib/uploads/utils/file-utils.server')
const buffer = await downloadFileFromUrl(file.data, { userId })
if (buffer.length > MAX_FILE_SIZE) {
const fileSizeMB = (buffer.length / (1024 * 1024)).toFixed(2)
throw new Error(
`File "${file.name}" exceeds the maximum size limit of 20MB (actual size: ${fileSizeMB}MB)`
)
}
const userFile = await uploadExecutionFile(
executionContext,
buffer,
file.name,
file.mime || 'application/octet-stream',
userId
)
return userFile
}
return null
}
/**
* Process all files for a given field in workflow execution input
*/
export async function processExecutionFiles(
fieldValue: any,
executionContext: { workspaceId: string; workflowId: string; executionId: string },
requestId: string,
userId?: string
): Promise<UserFile[]> {
if (!fieldValue || typeof fieldValue !== 'object') {
return []
}
const files = Array.isArray(fieldValue) ? fieldValue : [fieldValue]
const uploadedFiles: UserFile[] = []
const fullContext = { ...executionContext }
for (const file of files) {
try {
const userFile = await processExecutionFile(file, fullContext, requestId, userId)
if (userFile) {
uploadedFiles.push(userFile)
}
} catch (error) {
logger.error(`[${requestId}] Failed to process file ${file.name}:`, error)
throw new Error(`Failed to upload file: ${file.name}`)
}
}
return uploadedFiles
}
/**
* Extract inputFormat fields from a start block or trigger block
*/
type ValidatedInputFormatField = Required<Pick<InputFormatField, 'name' | 'type'>>
function extractInputFormatFromBlock(block: SerializedBlock): ValidatedInputFormatField[] {
const metadata = block.metadata as { subBlocks?: Record<string, { value?: unknown }> } | undefined
const subBlocksValue = metadata?.subBlocks?.inputFormat?.value
const legacyValue = block.config?.params?.inputFormat
const inputFormatValue = subBlocksValue ?? legacyValue
if (!Array.isArray(inputFormatValue) || inputFormatValue.length === 0) {
return []
}
return inputFormatValue.filter(
(field): field is ValidatedInputFormatField =>
field &&
typeof field === 'object' &&
'name' in field &&
'type' in field &&
typeof field.name === 'string' &&
typeof field.type === 'string'
)
}
/**
* Process file fields in workflow input based on the start block's inputFormat
* This handles base64 and URL file inputs from API calls
*/
export async function processInputFileFields(
input: unknown,
blocks: SerializedBlock[],
executionContext: { workspaceId: string; workflowId: string; executionId: string },
requestId: string,
userId?: string
): Promise<unknown> {
if (!input || typeof input !== 'object' || blocks.length === 0) {
return input
}
const startBlock = blocks.find((block) => {
const blockType = block.metadata?.id
return (
blockType === TRIGGER_TYPES.START ||
blockType === TRIGGER_TYPES.API ||
blockType === TRIGGER_TYPES.INPUT ||
blockType === TRIGGER_TYPES.GENERIC_WEBHOOK ||
blockType === TRIGGER_TYPES.STARTER
)
})
if (!startBlock) {
return input
}
const inputFormat = extractInputFormatFromBlock(startBlock)
const fileFields = inputFormat.filter((field) => field.type === 'file[]')
if (fileFields.length === 0) {
return input
}
const processedInput = { ...input } as Record<string, unknown>
for (const fileField of fileFields) {
const fieldValue = processedInput[fileField.name]
if (fieldValue && typeof fieldValue === 'object') {
const uploadedFiles = await processExecutionFiles(
fieldValue,
executionContext,
requestId,
userId
)
if (uploadedFiles.length > 0) {
processedInput[fileField.name] = uploadedFiles
logger.info(
`[${requestId}] Successfully processed ${uploadedFiles.length} file(s) for field: ${fileField.name}`
)
}
}
}
return processedInput
}
File diff suppressed because it is too large Load Diff
+624
View File
@@ -0,0 +1,624 @@
/**
* @vitest-environment node
*/
import { EventEmitter } from 'node:events'
import {
inputValidationMock,
inputValidationMockFns,
redisConfigMock,
redisConfigMockFns,
} from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type MockProc = EventEmitter & {
connected: boolean
stderr: EventEmitter
send: (message: unknown) => boolean
kill: () => boolean
}
type SpawnFactory = () => MockProc
type RedisEval = (...args: unknown[]) => unknown | Promise<unknown>
type SecureFetchImpl = (...args: unknown[]) => unknown | Promise<unknown>
function createBaseProc(): MockProc {
const proc = new EventEmitter() as MockProc
proc.connected = true
proc.stderr = new EventEmitter()
proc.send = () => true
proc.kill = () => {
if (!proc.connected) return true
proc.connected = false
setImmediate(() => proc.emit('exit', 0))
return true
}
return proc
}
function createStartupFailureProc(): MockProc {
const proc = createBaseProc()
setImmediate(() => {
proc.connected = false
proc.emit('exit', 1)
})
return proc
}
function createReadyProc(result: unknown): MockProc {
const proc = createBaseProc()
proc.send = (message: unknown) => {
const msg = message as { type?: string; executionId?: number }
if (msg.type === 'execute') {
setImmediate(() => {
proc.emit('message', {
type: 'result',
executionId: msg.executionId,
result: { result, stdout: '' },
})
})
}
return true
}
setImmediate(() => proc.emit('message', { type: 'ready' }))
return proc
}
function createReadyProcWithDelay(delayMs: number): MockProc {
const proc = createBaseProc()
proc.send = (message: unknown) => {
const msg = message as { type?: string; executionId?: number; request?: { requestId?: string } }
if (msg.type === 'execute') {
setTimeout(() => {
proc.emit('message', {
type: 'result',
executionId: msg.executionId,
result: { result: msg.request?.requestId ?? 'unknown', stdout: '' },
})
}, delayMs)
}
return true
}
setImmediate(() => proc.emit('message', { type: 'ready' }))
return proc
}
type ControllableReadyProc = {
spawn: SpawnFactory
dispatched: Promise<void>
release: (result?: unknown) => void
}
/**
* A ready worker whose execution is held open until {@link ControllableReadyProc.release}
* is called. {@link ControllableReadyProc.dispatched} resolves the moment the worker
* receives its `execute` message — i.e. once the scheduler has counted the execution as
* active — giving tests a deterministic, event-driven signal that the concurrency slot is
* occupied without relying on wall-clock delays. The proc is built inside {@link spawn} so
* its `ready` event is emitted only after the module attaches its listeners.
*/
function createControllableReadyProc(): ControllableReadyProc {
let markDispatched: () => void = () => {}
const dispatched = new Promise<void>((resolve) => {
markDispatched = resolve
})
let proc: MockProc | undefined
let lastExecutionId = 0
const spawn: SpawnFactory = () => {
const current = createBaseProc()
current.send = (message: unknown) => {
const msg = message as { type?: string; executionId?: number }
if (msg.type === 'execute') {
lastExecutionId = msg.executionId ?? 0
markDispatched()
}
return true
}
setImmediate(() => current.emit('message', { type: 'ready' }))
proc = current
return current
}
const release = (result: unknown = 'released') => {
proc?.emit('message', {
type: 'result',
executionId: lastExecutionId,
result: { result, stdout: '' },
})
}
return { spawn, dispatched, release }
}
function createReadyFetchProxyProc(fetchMessage: { url: string; optionsJson?: string }): MockProc {
const proc = createBaseProc()
let currentExecutionId = 0
proc.send = (message: unknown) => {
const msg = message as { type?: string; executionId?: number; request?: { requestId?: string } }
if (msg.type === 'execute') {
currentExecutionId = msg.executionId ?? 0
setImmediate(() => {
proc.emit('message', {
type: 'fetch',
fetchId: 1,
requestId: msg.request?.requestId ?? 'fetch-test',
url: fetchMessage.url,
optionsJson: fetchMessage.optionsJson,
})
})
return true
}
if (msg.type === 'fetchResponse') {
const fetchResponse = message as { response?: string }
setImmediate(() => {
proc.emit('message', {
type: 'result',
executionId: currentExecutionId,
result: { result: fetchResponse.response ?? '', stdout: '' },
})
})
return true
}
return true
}
setImmediate(() => proc.emit('message', { type: 'ready' }))
return proc
}
const { mockSpawn, mockExecSync, mockSanitizeUrl, mockEnv } = vi.hoisted(() => ({
mockSpawn: vi.fn(),
mockExecSync: vi.fn(() => Buffer.from('v23.11.0')),
mockSanitizeUrl: vi.fn((url: string) => url),
mockEnv: {
IVM_POOL_SIZE: '1',
IVM_MAX_CONCURRENT: '100',
IVM_MAX_PER_WORKER: '100',
IVM_WORKER_IDLE_TIMEOUT_MS: '60000',
IVM_MAX_QUEUE_SIZE: '10',
IVM_MAX_ACTIVE_PER_OWNER: '100',
IVM_MAX_QUEUED_PER_OWNER: '10',
IVM_MAX_OWNER_WEIGHT: '5',
IVM_DISTRIBUTED_MAX_INFLIGHT_PER_OWNER: '100',
IVM_DISTRIBUTED_LEASE_MIN_TTL_MS: '1000',
IVM_QUEUE_TIMEOUT_MS: '1000',
IVM_MAX_FETCH_RESPONSE_BYTES: '',
IVM_MAX_FETCH_RESPONSE_CHARS: '',
IVM_MAX_FETCH_URL_LENGTH: '',
IVM_MAX_FETCH_OPTIONS_JSON_CHARS: '',
REDIS_URL: '',
} as Record<string, string>,
}))
const mockSecureFetch = inputValidationMockFns.mockSecureFetchWithValidation
const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
vi.mock('@/lib/core/utils/logging', () => ({
sanitizeUrlForLog: mockSanitizeUrl,
}))
vi.mock('@/lib/core/config/env', () => ({
env: mockEnv,
}))
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
vi.mock('node:child_process', () => ({
execSync: mockExecSync,
spawn: mockSpawn,
}))
async function loadExecutionModule(options: {
envOverrides?: Record<string, string>
spawns: SpawnFactory[]
redisEvalImpl?: RedisEval
secureFetchImpl?: SecureFetchImpl
}) {
const spawnQueue = [...options.spawns]
mockSpawn.mockImplementation(() => {
const next = spawnQueue.shift()
if (!next) {
throw new Error('No mock spawn factory configured')
}
return next() as ReturnType<typeof mockSpawn>
})
if (options.secureFetchImpl) {
mockSecureFetch.mockImplementation(options.secureFetchImpl)
} else {
mockSecureFetch.mockImplementation(async () => ({
ok: true,
status: 200,
statusText: 'OK',
headers: new Map<string, string>(),
text: async () => '',
json: async () => ({}),
arrayBuffer: async () => new ArrayBuffer(0),
}))
}
Object.assign(mockEnv, {
IVM_POOL_SIZE: '1',
IVM_MAX_CONCURRENT: '100',
IVM_MAX_PER_WORKER: '100',
IVM_WORKER_IDLE_TIMEOUT_MS: '60000',
IVM_MAX_QUEUE_SIZE: '10',
IVM_MAX_ACTIVE_PER_OWNER: '100',
IVM_MAX_QUEUED_PER_OWNER: '10',
IVM_MAX_OWNER_WEIGHT: '5',
IVM_DISTRIBUTED_MAX_INFLIGHT_PER_OWNER: '100',
IVM_DISTRIBUTED_LEASE_MIN_TTL_MS: '1000',
IVM_QUEUE_TIMEOUT_MS: '1000',
IVM_MAX_FETCH_RESPONSE_BYTES: '',
IVM_MAX_FETCH_RESPONSE_CHARS: '',
IVM_MAX_FETCH_URL_LENGTH: '',
IVM_MAX_FETCH_OPTIONS_JSON_CHARS: '',
REDIS_URL: '',
...(options.envOverrides ?? {}),
})
const redisEval = options.redisEvalImpl ? vi.fn(options.redisEvalImpl) : undefined
mockGetRedisClient.mockImplementation(() =>
redisEval
? ({
eval: redisEval,
} as unknown)
: null
)
vi.resetModules()
const mod = await import('@/lib/execution/isolated-vm')
return { ...mod, spawnMock: mockSpawn, secureFetchMock: mockSecureFetch }
}
describe('isolated-vm scheduler', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('recovers from an initial spawn failure and drains queued work', async () => {
const { executeInIsolatedVM, spawnMock } = await loadExecutionModule({
spawns: [createStartupFailureProc, () => createReadyProc('ok')],
})
const result = await executeInIsolatedVM({
code: 'return "ok"',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 100,
requestId: 'req-1',
})
expect(result.error).toBeUndefined()
expect(result.result).toBe('ok')
expect(spawnMock).toHaveBeenCalledTimes(2)
})
it('rejects new requests when the queue is full', async () => {
const holder = createControllableReadyProc()
const { executeInIsolatedVM } = await loadExecutionModule({
envOverrides: {
IVM_MAX_CONCURRENT: '1',
IVM_MAX_QUEUE_SIZE: '1',
IVM_QUEUE_TIMEOUT_MS: '50',
},
spawns: [holder.spawn],
})
// Occupy the single global concurrency slot with an active execution. Awaiting
// `dispatched` is event-driven, so the queue state below is fully deterministic.
const holderPromise = executeInIsolatedVM({
code: 'return "holder"',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 1000,
requestId: 'req-holder',
ownerKey: 'user:holder',
})
await holder.dispatched
// With the slot held, this request lands in the queue (size 1, now full)...
const queuedPromise = executeInIsolatedVM({
code: 'return 1',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 100,
requestId: 'req-2',
ownerKey: 'user:a',
})
// ...and this one overflows it and is rejected immediately.
const rejected = await executeInIsolatedVM({
code: 'return 2',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 100,
requestId: 'req-3',
ownerKey: 'user:b',
})
expect(rejected.error?.message).toContain('at capacity')
const queued = await queuedPromise
expect(queued.error?.message).toContain('timed out waiting')
holder.release()
await holderPromise
})
it('enforces per-owner queued limit', async () => {
const holder = createControllableReadyProc()
const { executeInIsolatedVM } = await loadExecutionModule({
envOverrides: {
IVM_MAX_CONCURRENT: '1',
IVM_MAX_QUEUED_PER_OWNER: '1',
IVM_QUEUE_TIMEOUT_MS: '50',
},
spawns: [holder.spawn],
})
// Hold the single global slot with one of the owner's executions so the next
// requests deterministically queue instead of dispatching.
const holderPromise = executeInIsolatedVM({
code: 'return "holder"',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 1000,
requestId: 'req-holder',
ownerKey: 'user:hog',
})
await holder.dispatched
// First queued request for the owner fills their per-owner queue allowance...
const queuedPromise = executeInIsolatedVM({
code: 'return 1',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 100,
requestId: 'req-4',
ownerKey: 'user:hog',
})
// ...so the second is rejected for exceeding the per-owner queued limit.
const rejected = await executeInIsolatedVM({
code: 'return 2',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 100,
requestId: 'req-5',
ownerKey: 'user:hog',
})
expect(rejected.error?.message).toContain('Too many concurrent')
const queued = await queuedPromise
expect(queued.error?.message).toContain('timed out waiting')
holder.release()
await holderPromise
})
it('enforces distributed owner in-flight lease limit when Redis is configured', async () => {
const { executeInIsolatedVM } = await loadExecutionModule({
envOverrides: {
IVM_DISTRIBUTED_MAX_INFLIGHT_PER_OWNER: '1',
REDIS_URL: 'redis://localhost:6379',
},
spawns: [() => createReadyProc('ok')],
redisEvalImpl: (...args: unknown[]) => {
const script = String(args[0] ?? '')
if (script.includes('ZREMRANGEBYSCORE')) {
return 0
}
return 1
},
})
const result = await executeInIsolatedVM({
code: 'return "blocked"',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 100,
requestId: 'req-6',
ownerKey: 'user:distributed',
})
expect(result.error?.message).toContain('Too many concurrent')
})
it('falls back to local execution when Redis is configured but unavailable', async () => {
const { executeInIsolatedVM } = await loadExecutionModule({
envOverrides: {
REDIS_URL: 'redis://localhost:6379',
},
spawns: [() => createReadyProc('ok')],
})
const result = await executeInIsolatedVM({
code: 'return "ok"',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 100,
requestId: 'req-7',
ownerKey: 'user:redis-down',
})
expect(result.error).toBeUndefined()
expect(result.result).toBe('ok')
})
it('falls back to local execution when Redis lease evaluation errors', async () => {
const { executeInIsolatedVM } = await loadExecutionModule({
envOverrides: {
REDIS_URL: 'redis://localhost:6379',
},
spawns: [() => createReadyProc('ok')],
redisEvalImpl: (...args: unknown[]) => {
const script = String(args[0] ?? '')
if (script.includes('ZREMRANGEBYSCORE')) {
throw new Error('redis timeout')
}
return 1
},
})
const result = await executeInIsolatedVM({
code: 'return "ok"',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 100,
requestId: 'req-8',
ownerKey: 'user:redis-error',
})
expect(result.error).toBeUndefined()
expect(result.result).toBe('ok')
})
it('applies weighted owner scheduling when draining queued executions', async () => {
const { executeInIsolatedVM } = await loadExecutionModule({
envOverrides: {
IVM_MAX_PER_WORKER: '1',
},
spawns: [() => createReadyProcWithDelay(1)],
})
const completionOrder: string[] = []
const pushCompletion = (label: string) => (res: { result: unknown }) => {
completionOrder.push(String(res.result ?? label))
return res
}
const p1 = executeInIsolatedVM({
code: 'return 1',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 500,
requestId: 'a-1',
ownerKey: 'user:a',
ownerWeight: 2,
}).then(pushCompletion('a-1'))
const p2 = executeInIsolatedVM({
code: 'return 2',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 500,
requestId: 'a-2',
ownerKey: 'user:a',
ownerWeight: 2,
}).then(pushCompletion('a-2'))
const p3 = executeInIsolatedVM({
code: 'return 3',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 500,
requestId: 'b-1',
ownerKey: 'user:b',
ownerWeight: 1,
}).then(pushCompletion('b-1'))
const p4 = executeInIsolatedVM({
code: 'return 4',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 500,
requestId: 'b-2',
ownerKey: 'user:b',
ownerWeight: 1,
}).then(pushCompletion('b-2'))
const p5 = executeInIsolatedVM({
code: 'return 5',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 500,
requestId: 'a-3',
ownerKey: 'user:a',
ownerWeight: 2,
}).then(pushCompletion('a-3'))
await Promise.all([p1, p2, p3, p4, p5])
expect(completionOrder.slice(0, 3)).toEqual(['a-1', 'a-2', 'a-3'])
expect(completionOrder).toEqual(['a-1', 'a-2', 'a-3', 'b-1', 'b-2'])
})
it('rejects oversized fetch options payloads before outbound call', async () => {
const { executeInIsolatedVM, secureFetchMock } = await loadExecutionModule({
envOverrides: {
IVM_MAX_FETCH_OPTIONS_JSON_CHARS: '50',
},
spawns: [
() =>
createReadyFetchProxyProc({
url: 'https://example.com',
optionsJson: 'x'.repeat(100),
}),
],
})
const result = await executeInIsolatedVM({
code: 'return "fetch-options"',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 100,
requestId: 'req-fetch-options',
})
const payload = JSON.parse(String(result.result))
expect(payload.error).toContain('Fetch options exceed maximum payload size')
expect(secureFetchMock).not.toHaveBeenCalled()
})
it('rejects overly long fetch URLs before outbound call', async () => {
const { executeInIsolatedVM, secureFetchMock } = await loadExecutionModule({
envOverrides: {
IVM_MAX_FETCH_URL_LENGTH: '30',
},
spawns: [
() =>
createReadyFetchProxyProc({
url: 'https://example.com/path/to/a/very/long/resource',
}),
],
})
const result = await executeInIsolatedVM({
code: 'return "fetch-url"',
params: {},
envVars: {},
contextVariables: {},
timeoutMs: 100,
requestId: 'req-fetch-url',
})
const payload = JSON.parse(String(result.result))
expect(payload.error).toContain('fetch URL exceeds maximum length')
expect(secureFetchMock).not.toHaveBeenCalled()
})
})
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
/**
* Supported code execution languages
*/
export enum CodeLanguage {
JavaScript = 'javascript',
Python = 'python',
Shell = 'shell',
}
/**
* Type guard to check if a string is a valid CodeLanguage
*/
export function isValidCodeLanguage(value: string): value is CodeLanguage {
return Object.values(CodeLanguage).includes(value as CodeLanguage)
}
/**
* Get language display name
*/
export function getLanguageDisplayName(language: CodeLanguage): string {
switch (language) {
case CodeLanguage.JavaScript:
return 'JavaScript'
case CodeLanguage.Python:
return 'Python'
case CodeLanguage.Shell:
return 'Shell'
default:
return language
}
}
/**
* Default language for code execution
*/
export const DEFAULT_CODE_LANGUAGE = CodeLanguage.JavaScript
@@ -0,0 +1,19 @@
const activeExecutionAborters = new Map<string, () => void>()
export function registerManualExecutionAborter(executionId: string, abort: () => void): void {
activeExecutionAborters.set(executionId, abort)
}
export function unregisterManualExecutionAborter(executionId: string): void {
activeExecutionAborters.delete(executionId)
}
export function abortManualExecution(executionId: string): boolean {
const abort = activeExecutionAborters.get(executionId)
if (!abort) {
return false
}
abort()
return true
}
@@ -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))
}
+173
View File
@@ -0,0 +1,173 @@
import {
getLargeValueMaterializationError,
isLargeValueRef,
type LargeValueRef,
} from '@/lib/execution/payloads/large-value-ref'
const FALLBACK_TTL_MS = 15 * 60 * 1000
const MAX_IN_MEMORY_BYTES = 256 * 1024 * 1024
interface LargeValueCacheScope {
workspaceId?: string
workflowId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
allowLargeValueWorkflowScope?: boolean
}
const inMemoryValues = new Map<
string,
{
value: unknown
size: number
expiresAt: number
scope?: LargeValueCacheScope
recoverable: boolean
}
>()
let inMemoryBytes = 0
export function clearLargeValueCacheForTests(): void {
inMemoryValues.clear()
inMemoryBytes = 0
}
function cleanupExpiredValues(now = Date.now()): void {
for (const [id, entry] of inMemoryValues.entries()) {
if (entry.expiresAt <= now) {
inMemoryValues.delete(id)
inMemoryBytes -= entry.size
}
}
}
export function cacheLargeValue(
id: string,
value: unknown,
size: number,
scope?: LargeValueCacheScope,
options: { recoverable?: boolean } = {}
): boolean {
if (size > MAX_IN_MEMORY_BYTES) {
return false
}
cleanupExpiredValues()
const existing = inMemoryValues.get(id)
if (existing) {
inMemoryValues.delete(id)
inMemoryBytes -= existing.size
}
while (inMemoryBytes + size > MAX_IN_MEMORY_BYTES && inMemoryValues.size > 0) {
const oldestRecoverableId = Array.from(inMemoryValues.entries()).find(
([, entry]) => entry.recoverable
)?.[0]
if (!oldestRecoverableId) break
const oldest = inMemoryValues.get(oldestRecoverableId)
inMemoryValues.delete(oldestRecoverableId)
inMemoryBytes -= oldest?.size ?? 0
}
if (inMemoryBytes + size > MAX_IN_MEMORY_BYTES) {
if (existing) {
inMemoryValues.set(id, existing)
inMemoryBytes += existing.size
}
return false
}
inMemoryValues.set(id, {
value,
size,
scope,
recoverable: options.recoverable ?? false,
expiresAt: Date.now() + FALLBACK_TTL_MS,
})
inMemoryBytes += size
return true
}
function scopeMatchesRef(
ref: LargeValueRef,
cachedScope: LargeValueCacheScope | undefined,
callerScope?: LargeValueCacheScope
): boolean {
if (!cachedScope?.executionId) {
return false
}
if (ref.executionId && ref.executionId !== cachedScope.executionId) {
return false
}
if (!callerScope) {
return Boolean(ref.key) && (!ref.executionId || ref.executionId === cachedScope.executionId)
}
const allowedExecutionIds = new Set([
callerScope.executionId,
...(callerScope.largeValueExecutionIds ?? []),
])
if (ref.key && callerScope.largeValueKeys?.includes(ref.key)) {
return true
}
const workflowScopeAllowed =
callerScope.allowLargeValueWorkflowScope &&
callerScope.workspaceId === cachedScope.workspaceId &&
callerScope.workflowId === cachedScope.workflowId
return allowedExecutionIds.has(cachedScope.executionId) || Boolean(workflowScopeAllowed)
}
export function materializeLargeValueRefSync(
ref: LargeValueRef,
callerScope?: LargeValueCacheScope
): unknown {
cleanupExpiredValues()
const cached = inMemoryValues.get(ref.id)
if (!cached || !scopeMatchesRef(ref, cached.scope, callerScope)) {
return undefined
}
return cached.value
}
export function materializeLargeValueRefSyncOrThrow(
ref: LargeValueRef,
callerScope?: LargeValueCacheScope
): unknown {
const materialized = materializeLargeValueRefSync(ref, callerScope)
if (materialized === undefined) {
throw getLargeValueMaterializationError(ref)
}
return materialized
}
export function materializeLargeValueRefsSync(
value: unknown,
seen = new WeakSet<object>()
): unknown {
if (isLargeValueRef(value)) {
return materializeLargeValueRefsSync(materializeLargeValueRefSyncOrThrow(value), seen)
}
if (!value || typeof value !== 'object') {
return value
}
if (seen.has(value)) {
return value
}
seen.add(value)
if (Array.isArray(value)) {
return value.map((item) => materializeLargeValueRefsSync(item, seen))
}
return Object.fromEntries(
Object.entries(value).map(([key, entryValue]) => [
key,
materializeLargeValueRefsSync(entryValue, seen),
])
)
}
@@ -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',
})
})
})
@@ -0,0 +1,56 @@
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 = {},
seen = new WeakSet<object>()
): Promise<void> {
if (!value || typeof value !== 'object') {
return
}
if (isLargeValueRef(value)) {
const materialized = await materializeLargeValueRef(value, context)
await warmLargeValueRefs(materialized, withLocalMaterializedKeys(context, materialized), seen)
return
}
if (seen.has(value)) {
return
}
seen.add(value)
if (isLargeArrayManifest(value)) {
await warmLargeValueRefs(value.preview, context, seen)
return
}
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)
}
}
@@ -0,0 +1,457 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockAnd,
mockDelete,
mockEq,
mockExecute,
mockInsert,
mockOnConflictDoNothing,
mockSelect,
mockSelectFrom,
mockSelectLimit,
mockSelectWhere,
mockTransaction,
mockTxDelete,
mockTxInsert,
mockTxSelect,
mockTxSelectDistinct,
mockTxSelectFrom,
mockTxSelectLimit,
mockTxSelectWhere,
mockTxValues,
mockValues,
mockWhere,
mockTxWhere,
mockNotInArray,
} = vi.hoisted(() => {
const mockOnConflictDoNothing = vi.fn(async () => undefined)
const mockValues = vi.fn(() => ({ onConflictDoNothing: mockOnConflictDoNothing }))
const mockInsert = vi.fn(() => ({ values: mockValues }))
const mockWhere = vi.fn(async () => undefined)
const mockDelete = vi.fn(() => ({ where: mockWhere }))
const mockSelectLimit = vi.fn(async () => [])
const mockSelectWhere = vi.fn(() => ({ limit: mockSelectLimit }))
const mockSelectFrom = vi.fn(() => ({ where: mockSelectWhere }))
const mockSelect = vi.fn(() => ({ from: mockSelectFrom }))
const mockTxValues = vi.fn(() => ({ onConflictDoNothing: mockOnConflictDoNothing }))
const mockTxInsert = vi.fn(() => ({ values: mockTxValues }))
const mockTxWhere = vi.fn(async () => undefined)
const mockTxDelete = vi.fn(() => ({ where: mockTxWhere }))
const mockTxSelectLimit = vi.fn(async () => [])
const mockTxSelectWhere = vi.fn(() => ({ limit: mockTxSelectLimit }))
const mockTxSelectFrom = vi.fn(() => ({ where: mockTxSelectWhere }))
const mockTxSelect = vi.fn(() => ({ from: mockTxSelectFrom }))
const mockTxSelectDistinct = vi.fn(() => ({ from: mockTxSelectFrom }))
return {
mockAnd: vi.fn((...args: unknown[]) => ({ op: 'and', args })),
mockDelete,
mockEq: vi.fn((...args: unknown[]) => ({ op: 'eq', args })),
mockExecute: vi.fn(async () => [{ count: 0 }]),
mockInsert,
mockNotInArray: vi.fn((...args: unknown[]) => ({ op: 'notInArray', args })),
mockOnConflictDoNothing,
mockSelect,
mockSelectFrom,
mockSelectLimit,
mockSelectWhere,
mockTransaction: vi.fn(async (callback) =>
callback({
delete: mockTxDelete,
insert: mockTxInsert,
select: mockTxSelect,
selectDistinct: mockTxSelectDistinct,
})
),
mockTxDelete,
mockTxInsert,
mockTxSelect,
mockTxSelectDistinct,
mockTxSelectFrom,
mockTxSelectLimit,
mockTxSelectWhere,
mockTxValues,
mockValues,
mockWhere,
mockTxWhere,
}
})
vi.mock('@sim/db', () => ({
db: {
delete: mockDelete,
execute: mockExecute,
insert: mockInsert,
select: mockSelect,
transaction: mockTransaction,
},
}))
vi.mock('@sim/db/schema', () => ({
executionLargeValueDependencies: {
childKey: 'executionLargeValueDependencies.childKey',
parentKey: 'executionLargeValueDependencies.parentKey',
workspaceId: 'executionLargeValueDependencies.workspaceId',
},
executionLargeValueReferences: {
executionId: 'executionLargeValueReferences.executionId',
key: 'executionLargeValueReferences.key',
source: 'executionLargeValueReferences.source',
workspaceId: 'executionLargeValueReferences.workspaceId',
},
executionLargeValues: {
key: 'executionLargeValues.key',
ownerExecutionId: 'executionLargeValues.ownerExecutionId',
workspaceId: 'executionLargeValues.workspaceId',
},
pausedExecutions: {
executionId: 'pausedExecutions.executionId',
status: 'pausedExecutions.status',
},
workflowExecutionLogs: {
executionId: 'workflowExecutionLogs.executionId',
},
}))
vi.mock('@sim/logger', () => ({
createLogger: vi.fn(() => ({
warn: vi.fn(),
})),
}))
vi.mock('drizzle-orm', () => ({
and: mockAnd,
eq: mockEq,
inArray: vi.fn((...args: unknown[]) => ({ op: 'inArray', args })),
notInArray: mockNotInArray,
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })),
}))
import {
addLargeValueReference,
MAX_LARGE_VALUE_REFERENCES_PER_SCOPE,
pruneLargeValueMetadata,
registerLargeValueOwner,
replaceLargeValueReferences,
} from '@/lib/execution/payloads/large-value-metadata'
function largeValueKey(id: string, executionId = 'source-execution'): string {
return `execution/workspace-1/workflow-1/${executionId}/large-value-lv_${id}.json`
}
describe('large value metadata', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('registers valid large value owner metadata', async () => {
const registered = await registerLargeValueOwner({
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_abcdefghijkl.json',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
size: 123.4,
})
expect(registered).toBe(true)
expect(mockTxInsert).toHaveBeenCalledOnce()
expect(mockTxValues).toHaveBeenCalledWith({
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_abcdefghijkl.json',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
ownerExecutionId: 'execution-1',
size: 124,
})
expect(mockOnConflictDoNothing).toHaveBeenCalledOnce()
})
it('skips malformed owner keys', async () => {
const registered = await registerLargeValueOwner({
key: 'execution/workspace-1/workflow-1/other-execution/large-value-lv_abcdefghijkl.json',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
size: 123,
})
expect(registered).toBe(false)
expect(mockTxInsert).not.toHaveBeenCalled()
})
it('records dependency closure for nested large value refs', async () => {
const directKey = largeValueKey('abcdefghijkl')
const transitiveKey = largeValueKey('mnopqrstuvwx', 'root-execution')
const deepTransitiveKey = largeValueKey('deepqrstuvwx', 'deep-execution')
mockTxSelectLimit
.mockResolvedValueOnce([{ childKey: transitiveKey }])
.mockResolvedValueOnce([{ childKey: deepTransitiveKey }])
.mockResolvedValueOnce([])
const registered = await registerLargeValueOwner(
{
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_zyxwvutsrqpo.json',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
size: 123,
},
[directKey]
)
expect(registered).toBe(true)
expect(mockTxSelectDistinct).toHaveBeenCalledTimes(3)
expect(mockTxValues).toHaveBeenLastCalledWith([
{
parentKey: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_zyxwvutsrqpo.json',
childKey: directKey,
workspaceId: 'workspace-1',
},
{
parentKey: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_zyxwvutsrqpo.json',
childKey: transitiveKey,
workspaceId: 'workspace-1',
},
{
parentKey: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_zyxwvutsrqpo.json',
childKey: deepTransitiveKey,
workspaceId: 'workspace-1',
},
])
})
it('chunks dependency writes instead of emitting one oversized VALUES statement', async () => {
const keys = Array.from({ length: 501 }, (_, index) =>
largeValueKey(`a${index.toString(36).padStart(11, '0')}`)
)
await registerLargeValueOwner(
{
key: largeValueKey('zyxwvutsrqpo', 'execution-1'),
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
size: 123,
},
keys
)
expect(mockTxValues).toHaveBeenCalledTimes(3)
expect(mockTxValues.mock.calls[1]?.[0]).toHaveLength(500)
expect(mockTxValues.mock.calls[2]?.[0]).toHaveLength(1)
})
it('rejects reference sets over the metadata cardinality limit', async () => {
const keys = Array.from({ length: MAX_LARGE_VALUE_REFERENCES_PER_SCOPE + 1 }, (_, index) =>
largeValueKey(`b${index.toString(36).padStart(11, '0')}`)
)
await expect(
registerLargeValueOwner(
{
key: largeValueKey('zyxwvutsrqpo', 'execution-1'),
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
size: 123,
},
keys
)
).rejects.toThrow('exceeding the limit')
})
it('limits dependency closure reads to the remaining reference budget', async () => {
const directKey = largeValueKey('a00000000000')
mockTxSelectLimit.mockResolvedValueOnce(
Array.from({ length: MAX_LARGE_VALUE_REFERENCES_PER_SCOPE }, (_, index) => ({
childKey: largeValueKey(`c${index.toString(36).padStart(11, '0')}`),
}))
)
await expect(
registerLargeValueOwner(
{
key: largeValueKey('zyxwvutsrqpo', 'execution-1'),
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
size: 123,
},
[directKey]
)
).rejects.toThrow('Large value dependency closure exceeds the limit')
expect(mockTxSelectLimit).toHaveBeenCalledWith(MAX_LARGE_VALUE_REFERENCES_PER_SCOPE)
})
it('filters known dependency children before applying the remaining reference budget', async () => {
const directKeys = Array.from({ length: MAX_LARGE_VALUE_REFERENCES_PER_SCOPE }, (_, index) =>
largeValueKey(`e${index.toString(36).padStart(11, '0')}`)
)
const knownChildKey = directKeys[1]
const unseenChildKey = largeValueKey('unseenchild1', 'source-execution')
mockTxSelectLimit.mockImplementationOnce(async () => {
const filtersKnownChildren = mockNotInArray.mock.calls.some(
([field, values]) =>
field === 'executionLargeValueDependencies.childKey' &&
Array.isArray(values) &&
values.includes(knownChildKey)
)
return [{ childKey: filtersKnownChildren ? unseenChildKey : knownChildKey }]
})
await expect(
registerLargeValueOwner(
{
key: largeValueKey('zyxwvutsrqpo', 'execution-1'),
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
size: 123,
},
directKeys
)
).rejects.toThrow('Large value dependency closure exceeds the limit')
expect(mockTxSelectLimit).toHaveBeenCalledWith(1)
})
it('replaces an execution reference set with same-workspace unique keys', async () => {
const matchingKey = largeValueKey('abcdefghijkl')
const otherWorkspaceKey =
'execution/workspace-2/workflow-1/source-execution/large-value-lv_abcdefghijkl.json'
await replaceLargeValueReferences(
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-2',
source: 'execution_log',
},
{
a: {
__simLargeValueRef: true,
version: 1,
id: 'lv_abcdefghijkl',
kind: 'json',
size: 123,
key: matchingKey,
},
duplicate: {
__simLargeValueRef: true,
version: 1,
id: 'lv_abcdefghijkl',
kind: 'json',
size: 123,
key: matchingKey,
},
ignored: {
__simLargeValueRef: true,
version: 1,
id: 'lv_abcdefghijkl',
kind: 'json',
size: 123,
key: otherWorkspaceKey,
},
}
)
expect(mockTransaction).toHaveBeenCalledOnce()
expect(mockTxDelete).toHaveBeenCalledOnce()
expect(mockEq).toHaveBeenCalledWith('executionLargeValueReferences.source', 'execution_log')
expect(mockTxValues).toHaveBeenCalledWith([
{
key: matchingKey,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-2',
source: 'execution_log',
},
])
})
it('adds a materialized reference only when the scope is below the reference cap', async () => {
const key = largeValueKey('abcdefghijkl')
await addLargeValueReference(
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-2',
source: 'execution_log',
},
key
)
expect(mockSelectLimit).toHaveBeenCalledWith(1)
expect(mockSelectLimit).toHaveBeenCalledWith(MAX_LARGE_VALUE_REFERENCES_PER_SCOPE + 1)
expect(mockValues).toHaveBeenCalledWith({
key,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-2',
source: 'execution_log',
})
})
it('rejects materialized references once the scope reaches the reference cap', async () => {
mockSelectLimit.mockResolvedValueOnce([]).mockResolvedValueOnce(
Array.from({ length: MAX_LARGE_VALUE_REFERENCES_PER_SCOPE }, (_, index) => ({
key: largeValueKey(`d${index.toString(36).padStart(11, '0')}`),
}))
)
await expect(
addLargeValueReference(
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-2',
source: 'execution_log',
},
largeValueKey('zyxwvutsrqpo')
)
).rejects.toThrow('exceeding the limit')
expect(mockInsert).not.toHaveBeenCalled()
})
it('prunes large value metadata in bounded batches', async () => {
mockExecute
.mockResolvedValueOnce([{ count: 2 }])
.mockResolvedValueOnce([{ count: 3 }])
.mockResolvedValueOnce([{ count: 4 }])
await expect(
pruneLargeValueMetadata({
workspaceIds: ['workspace-1'],
tombstonesDeletedBefore: new Date('2026-01-01T00:00:00Z'),
batchSize: 10,
maxRowsPerTable: 100,
})
).resolves.toEqual({
referencesDeleted: 2,
dependenciesDeleted: 3,
tombstonesDeleted: 4,
})
})
it('uses source-specific liveness when pruning stale references', async () => {
await pruneLargeValueMetadata({
workspaceIds: ['workspace-1'],
tombstonesDeletedBefore: new Date('2026-01-01T00:00:00Z'),
batchSize: 10,
maxRowsPerTable: 100,
})
const [query] = mockExecute.mock.calls[0] ?? []
const sqlText = Array.isArray(query?.strings) ? query.strings.join(' ') : ''
expect(sqlText).toContain("ref.source = 'execution_log'")
expect(sqlText).toContain("ref.source = 'paused_snapshot'")
})
})
@@ -0,0 +1,618 @@
import { db } from '@sim/db'
import {
executionLargeValueDependencies,
executionLargeValueReferences,
executionLargeValues,
pausedExecutions,
workflowExecutionLogs,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, notInArray, sql } from 'drizzle-orm'
import { chunkArray } from '@/lib/cleanup/batch-delete'
import { collectLargeValueKeys } from '@/lib/execution/payloads/large-execution-value'
const logger = createLogger('LargeValueMetadata')
type LargeValueMetadataClient = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0]
export const MAX_LARGE_VALUE_REFERENCES_PER_SCOPE = 5_000
const LARGE_VALUE_METADATA_WRITE_CHUNK_SIZE = 500
const LARGE_VALUE_METADATA_WORKSPACE_CHUNK_SIZE = 50
const LARGE_VALUE_METADATA_PRUNE_BATCH_SIZE = 1_000
const LARGE_VALUE_METADATA_PRUNE_MAX_ROWS_PER_TABLE = 5_000
export const LIVE_PAUSED_REFERENCE_STATUSES = ['paused', 'partially_resumed', 'cancelling'] as const
export interface LargeValueOwner {
key: string
workspaceId: string
workflowId: string
executionId: string
size: number
}
export interface LargeValueReferenceScope {
workspaceId?: string
workflowId?: string | null
executionId?: string
source: 'execution_log' | 'paused_snapshot'
}
interface LargeValueStorageKeyParts {
workspaceId: string
workflowId: string
executionId: string
}
export interface LargeValueMetadataPruneResult {
referencesDeleted: number
dependenciesDeleted: number
tombstonesDeleted: number
}
interface PruneLargeValueMetadataOptions {
workspaceIds: string[]
tombstonesDeletedBefore: Date
batchSize?: number
maxRowsPerTable?: number
}
function parseLargeValueStorageKey(key: string): LargeValueStorageKeyParts | null {
const parts = key.split('/')
if (
parts.length !== 5 ||
parts[0] !== 'execution' ||
!parts[1] ||
!parts[2] ||
!parts[3] ||
!/^large-value-lv_[A-Za-z0-9_-]{12}\.json$/.test(parts[4])
) {
return null
}
return {
workspaceId: parts[1],
workflowId: parts[2],
executionId: parts[3],
}
}
function getBoundedUniqueKeys(keys: string[], label: string): string[] {
const uniqueKeys = Array.from(new Set(keys))
if (uniqueKeys.length > MAX_LARGE_VALUE_REFERENCES_PER_SCOPE) {
throw new Error(
`${label} contains ${uniqueKeys.length} large value references, exceeding the limit of ${MAX_LARGE_VALUE_REFERENCES_PER_SCOPE}`
)
}
return uniqueKeys
}
function getCount(rows: unknown): number {
const [row] = Array.isArray(rows) ? rows : []
if (!row || typeof row !== 'object' || !('count' in row)) {
return 0
}
return Number((row as { count: unknown }).count) || 0
}
export function collectLargeValueReferenceKeys(value: unknown, workspaceId?: string): string[] {
return getBoundedUniqueKeys(
collectLargeValueKeys(value).filter((key) => {
const parsed = parseLargeValueStorageKey(key)
return workspaceId ? parsed?.workspaceId === workspaceId : Boolean(parsed)
}),
'Large value reference set'
)
}
async function getDependencyClosure(
client: LargeValueMetadataClient,
ownerKey: string,
workspaceId: string,
referencedKeys: string[]
): Promise<string[]> {
const directKeys = getBoundedUniqueKeys(
referencedKeys.filter((key) => {
const parsed = parseLargeValueStorageKey(key)
return parsed?.workspaceId === workspaceId && key !== ownerKey
}),
'Large value dependency set'
)
if (directKeys.length === 0) {
return []
}
const closureKeys = new Set(directKeys)
let frontier = directKeys
while (frontier.length > 0) {
const nextFrontier: string[] = []
for (const keyChunk of chunkArray(frontier, LARGE_VALUE_METADATA_WRITE_CHUNK_SIZE)) {
const remainingBudget = MAX_LARGE_VALUE_REFERENCES_PER_SCOPE - closureKeys.size
const rows = await client
.selectDistinct({ childKey: executionLargeValueDependencies.childKey })
.from(executionLargeValueDependencies)
.where(
and(
eq(executionLargeValueDependencies.workspaceId, workspaceId),
inArray(executionLargeValueDependencies.parentKey, keyChunk),
notInArray(executionLargeValueDependencies.childKey, Array.from(closureKeys))
)
)
.limit(remainingBudget + 1)
for (const row of rows) {
if (closureKeys.has(row.childKey)) {
continue
}
closureKeys.add(row.childKey)
nextFrontier.push(row.childKey)
if (closureKeys.size > MAX_LARGE_VALUE_REFERENCES_PER_SCOPE) {
throw new Error(
`Large value dependency closure exceeds the limit of ${MAX_LARGE_VALUE_REFERENCES_PER_SCOPE}`
)
}
}
}
frontier = nextFrontier
}
return Array.from(closureKeys)
}
export async function registerLargeValueOwner(
owner: LargeValueOwner,
referencedKeys: string[] = []
): Promise<boolean> {
if (!Number.isFinite(owner.size) || owner.size <= 0) {
return false
}
const parsed = parseLargeValueStorageKey(owner.key)
if (
!parsed ||
parsed.workspaceId !== owner.workspaceId ||
parsed.workflowId !== owner.workflowId ||
parsed.executionId !== owner.executionId
) {
logger.warn('Skipping large value owner registration for malformed storage key', {
key: owner.key,
workspaceId: owner.workspaceId,
workflowId: owner.workflowId,
executionId: owner.executionId,
})
return false
}
await db.transaction(async (tx) => {
await tx
.insert(executionLargeValues)
.values({
key: owner.key,
workspaceId: owner.workspaceId,
workflowId: owner.workflowId,
ownerExecutionId: owner.executionId,
size: Math.ceil(owner.size),
})
.onConflictDoNothing()
const dependencyKeys = await getDependencyClosure(
tx,
owner.key,
owner.workspaceId,
referencedKeys
)
if (dependencyKeys.length === 0) {
return
}
for (const keyChunk of chunkArray(dependencyKeys, LARGE_VALUE_METADATA_WRITE_CHUNK_SIZE)) {
await tx
.insert(executionLargeValueDependencies)
.values(
keyChunk.map((childKey) => ({
parentKey: owner.key,
childKey,
workspaceId: owner.workspaceId,
}))
)
.onConflictDoNothing()
}
})
return true
}
export async function replaceLargeValueReferencesWithClient(
client: LargeValueMetadataClient,
scope: LargeValueReferenceScope,
value: unknown
): Promise<void> {
if (!scope.workspaceId || !scope.executionId) {
return
}
await replaceLargeValueReferenceKeysWithClient(
client,
scope,
collectLargeValueReferenceKeys(value, scope.workspaceId)
)
}
export async function replaceLargeValueReferenceKeysWithClient(
client: LargeValueMetadataClient,
scope: LargeValueReferenceScope,
referenceKeys: string[]
): Promise<void> {
const { workspaceId, workflowId, executionId, source } = scope
if (!workspaceId || !executionId) {
return
}
const keys = getBoundedUniqueKeys(
referenceKeys.filter((key) => {
const parsed = parseLargeValueStorageKey(key)
return parsed?.workspaceId === workspaceId
}),
'Large value reference set'
)
await client
.delete(executionLargeValueReferences)
.where(
and(
eq(executionLargeValueReferences.workspaceId, workspaceId),
eq(executionLargeValueReferences.executionId, executionId),
eq(executionLargeValueReferences.source, source)
)
)
if (keys.length === 0) {
return
}
for (const keyChunk of chunkArray(keys, LARGE_VALUE_METADATA_WRITE_CHUNK_SIZE)) {
await client
.insert(executionLargeValueReferences)
.values(
keyChunk.map((key) => ({
key,
workspaceId,
workflowId: workflowId ?? null,
executionId,
source,
}))
)
.onConflictDoNothing()
}
}
export async function addLargeValueReference(
scope: LargeValueReferenceScope,
key: string
): Promise<void> {
const { workspaceId, workflowId, executionId, source } = scope
if (!workspaceId || !executionId) {
return
}
const [boundedKey] = getBoundedUniqueKeys(
[key].filter((candidate) => {
const parsed = parseLargeValueStorageKey(candidate)
return parsed?.workspaceId === workspaceId
}),
'Large value reference set'
)
if (!boundedKey) {
return
}
const [existingRef] = await db
.select({ key: executionLargeValueReferences.key })
.from(executionLargeValueReferences)
.where(
and(
eq(executionLargeValueReferences.workspaceId, workspaceId),
eq(executionLargeValueReferences.executionId, executionId),
eq(executionLargeValueReferences.source, source),
eq(executionLargeValueReferences.key, boundedKey)
)
)
.limit(1)
if (existingRef) {
return
}
const existingRefs = await db
.select({ key: executionLargeValueReferences.key })
.from(executionLargeValueReferences)
.where(
and(
eq(executionLargeValueReferences.workspaceId, workspaceId),
eq(executionLargeValueReferences.executionId, executionId),
eq(executionLargeValueReferences.source, source)
)
)
.limit(MAX_LARGE_VALUE_REFERENCES_PER_SCOPE + 1)
if (existingRefs.length >= MAX_LARGE_VALUE_REFERENCES_PER_SCOPE) {
throw new Error(
`Large value reference set contains at least ${existingRefs.length} references, exceeding the limit of ${MAX_LARGE_VALUE_REFERENCES_PER_SCOPE}`
)
}
await db
.insert(executionLargeValueReferences)
.values({
key: boundedKey,
workspaceId,
workflowId: workflowId ?? null,
executionId,
source,
})
.onConflictDoNothing()
}
export async function replaceLargeValueReferences(
scope: LargeValueReferenceScope,
value: unknown
): Promise<void> {
const referenceKeys = scope.workspaceId
? collectLargeValueReferenceKeys(value, scope.workspaceId)
: []
await db.transaction(async (tx) => {
await replaceLargeValueReferenceKeysWithClient(tx, scope, referenceKeys)
})
}
export async function markLargeValuesDeleted(keys: string[]): Promise<void> {
if (keys.length === 0) {
return
}
await db
.update(executionLargeValues)
.set({ deletedAt: new Date() })
.where(inArray(executionLargeValues.key, keys))
}
async function pruneStaleReferences(workspaceIds: string[], batchSize: number): Promise<number> {
const rows = await db.execute<{ count: number }>(sql`
WITH deleted AS (
DELETE FROM ${executionLargeValueReferences} AS ref
WHERE ref.ctid IN (
SELECT ref.ctid
FROM ${executionLargeValueReferences} AS ref
WHERE ref.workspace_id = ANY(${workspaceIds}::text[])
AND (
(
ref.source = 'execution_log'
AND NOT EXISTS (
SELECT 1
FROM ${workflowExecutionLogs} AS wel
WHERE wel.execution_id = ref.execution_id
)
)
OR (
ref.source = 'paused_snapshot'
AND NOT EXISTS (
SELECT 1
FROM ${pausedExecutions} AS pe
WHERE pe.execution_id = ref.execution_id
AND pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
)
)
OR ref.source NOT IN ('execution_log', 'paused_snapshot')
)
LIMIT ${batchSize}
)
RETURNING ref.key
)
SELECT count(*)::int AS count FROM deleted
`)
return getCount(rows)
}
async function pruneDeletedParentDependencies(
workspaceIds: string[],
batchSize: number
): Promise<number> {
const rows = await db.execute<{ count: number }>(sql`
WITH deleted AS (
DELETE FROM ${executionLargeValueDependencies} AS dependency
WHERE dependency.ctid IN (
SELECT dependency.ctid
FROM ${executionLargeValueDependencies} AS dependency
WHERE dependency.workspace_id = ANY(${workspaceIds}::text[])
AND (
EXISTS (
SELECT 1
FROM ${executionLargeValues} AS parent_value
WHERE parent_value.key = dependency.parent_key
AND parent_value.deleted_at IS NOT NULL
)
OR NOT EXISTS (
SELECT 1
FROM ${executionLargeValues} AS parent_value
WHERE parent_value.key = dependency.parent_key
)
)
LIMIT ${batchSize}
)
RETURNING dependency.parent_key
)
SELECT count(*)::int AS count FROM deleted
`)
return getCount(rows)
}
async function pruneDeletedLargeValueTombstones(
workspaceIds: string[],
deletedBefore: Date,
batchSize: number
): Promise<number> {
const rows = await db.execute<{ count: number }>(sql`
WITH deleted AS (
DELETE FROM ${executionLargeValues} AS value
WHERE value.ctid IN (
SELECT value.ctid
FROM ${executionLargeValues} AS value
WHERE value.workspace_id = ANY(${workspaceIds}::text[])
AND value.deleted_at IS NOT NULL
AND value.deleted_at < ${deletedBefore}
AND NOT EXISTS (
SELECT 1
FROM ${executionLargeValueDependencies} AS dependency
WHERE dependency.parent_key = value.key
)
LIMIT ${batchSize}
)
RETURNING value.key
)
SELECT count(*)::int AS count FROM deleted
`)
return getCount(rows)
}
export async function pruneLargeValueMetadata({
workspaceIds,
tombstonesDeletedBefore,
batchSize = LARGE_VALUE_METADATA_PRUNE_BATCH_SIZE,
maxRowsPerTable = LARGE_VALUE_METADATA_PRUNE_MAX_ROWS_PER_TABLE,
}: PruneLargeValueMetadataOptions): Promise<LargeValueMetadataPruneResult> {
const result: LargeValueMetadataPruneResult = {
referencesDeleted: 0,
dependenciesDeleted: 0,
tombstonesDeleted: 0,
}
if (workspaceIds.length === 0) return result
for (const workspaceChunk of chunkArray(
workspaceIds,
LARGE_VALUE_METADATA_WORKSPACE_CHUNK_SIZE
)) {
const referencesRemaining = maxRowsPerTable - result.referencesDeleted
if (referencesRemaining > 0) {
result.referencesDeleted += await pruneStaleReferences(
workspaceChunk,
Math.min(batchSize, referencesRemaining)
)
}
const dependenciesRemaining = maxRowsPerTable - result.dependenciesDeleted
if (dependenciesRemaining > 0) {
result.dependenciesDeleted += await pruneDeletedParentDependencies(
workspaceChunk,
Math.min(batchSize, dependenciesRemaining)
)
}
const tombstonesRemaining = maxRowsPerTable - result.tombstonesDeleted
if (tombstonesRemaining > 0) {
result.tombstonesDeleted += await pruneDeletedLargeValueTombstones(
workspaceChunk,
tombstonesDeletedBefore,
Math.min(batchSize, tombstonesRemaining)
)
}
if (
result.referencesDeleted >= maxRowsPerTable &&
result.dependenciesDeleted >= maxRowsPerTable &&
result.tombstonesDeleted >= maxRowsPerTable
) {
break
}
}
return result
}
export function unreferencedLargeValuePredicate() {
return sql`
NOT EXISTS (
SELECT 1
FROM ${executionLargeValueReferences} AS elvr
WHERE elvr.key = ${executionLargeValues.key}
AND (
(
elvr.source = 'execution_log'
AND EXISTS (
SELECT 1
FROM ${workflowExecutionLogs} AS wel
WHERE wel.execution_id = elvr.execution_id
)
)
OR (
elvr.source = 'paused_snapshot'
AND EXISTS (
SELECT 1
FROM ${pausedExecutions} AS pe
WHERE pe.execution_id = elvr.execution_id
AND pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
)
)
)
)
AND NOT EXISTS (
SELECT 1
FROM ${workflowExecutionLogs} AS owner_wel
WHERE owner_wel.execution_id = ${executionLargeValues.ownerExecutionId}
)
AND NOT EXISTS (
SELECT 1
FROM ${pausedExecutions} AS owner_pe
WHERE owner_pe.execution_id = ${executionLargeValues.ownerExecutionId}
AND owner_pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
)
AND NOT EXISTS (
SELECT 1
FROM ${executionLargeValueDependencies} AS dependency
INNER JOIN ${executionLargeValues} AS parent_value
ON parent_value.key = dependency.parent_key
AND parent_value.deleted_at IS NULL
WHERE dependency.workspace_id = ${executionLargeValues.workspaceId}
AND dependency.child_key = ${executionLargeValues.key}
AND (
EXISTS (
SELECT 1
FROM ${workflowExecutionLogs} AS parent_owner_wel
WHERE parent_owner_wel.execution_id = parent_value.owner_execution_id
)
OR EXISTS (
SELECT 1
FROM ${pausedExecutions} AS parent_owner_pe
WHERE parent_owner_pe.execution_id = parent_value.owner_execution_id
AND parent_owner_pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
)
OR EXISTS (
SELECT 1
FROM ${executionLargeValueReferences} AS parent_ref
WHERE parent_ref.key = parent_value.key
AND (
(
parent_ref.source = 'execution_log'
AND EXISTS (
SELECT 1
FROM ${workflowExecutionLogs} AS parent_ref_wel
WHERE parent_ref_wel.execution_id = parent_ref.execution_id
)
)
OR (
parent_ref.source = 'paused_snapshot'
AND EXISTS (
SELECT 1
FROM ${pausedExecutions} AS parent_ref_pe
WHERE parent_ref_pe.execution_id = parent_ref.execution_id
AND parent_ref_pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
)
)
)
)
)
)
`
}
@@ -0,0 +1,97 @@
export const LARGE_VALUE_REF_MARKER = '__simLargeValueRef'
export const LARGE_VALUE_THRESHOLD_BYTES = 8 * 1024 * 1024
export const LARGE_VALUE_REF_VERSION = 1
export const LARGE_VALUE_KINDS = ['array', 'object', 'string', 'json'] as const
export type LargeValueKind = (typeof LARGE_VALUE_KINDS)[number]
export interface LargeValueRef {
[LARGE_VALUE_REF_MARKER]: true
version: typeof LARGE_VALUE_REF_VERSION
id: string
kind: LargeValueKind
size: number
key?: string
executionId?: string
preview?: unknown
}
const LARGE_VALUE_ID_PATTERN = /^lv_[A-Za-z0-9_-]{12}$/
export function isLargeValueStorageKey(key: string, id: string, executionId?: string): boolean {
if (!key.startsWith('execution/')) return false
if (!key.endsWith(`/large-value-${id}.json`)) return false
if (executionId && !key.includes(`/${executionId}/`)) return false
return true
}
export function isLargeValueRef(value: unknown): value is LargeValueRef {
if (!value || typeof value !== 'object') return false
const candidate = value as Record<string, unknown>
const id = candidate.id
const key = candidate.key
const executionId = candidate.executionId
return (
candidate[LARGE_VALUE_REF_MARKER] === true &&
candidate.version === LARGE_VALUE_REF_VERSION &&
typeof id === 'string' &&
LARGE_VALUE_ID_PATTERN.test(id) &&
typeof candidate.kind === 'string' &&
(LARGE_VALUE_KINDS as readonly string[]).includes(candidate.kind) &&
typeof candidate.size === 'number' &&
Number.isFinite(candidate.size) &&
candidate.size > 0 &&
(executionId === undefined || typeof executionId === 'string') &&
(key === undefined ||
(typeof key === 'string' &&
isLargeValueStorageKey(key, id, executionId as string | undefined)))
)
}
export function containsLargeValueRef(
value: unknown,
seen = new WeakSet<object>()
): LargeValueRef | null {
if (!value || typeof value !== 'object') return null
if (isLargeValueRef(value)) return value
if (seen.has(value)) return null
seen.add(value)
if (Array.isArray(value)) {
for (const item of value) {
const ref = containsLargeValueRef(item, seen)
if (ref) return ref
}
return null
}
for (const entryValue of Object.values(value)) {
const ref = containsLargeValueRef(entryValue, seen)
if (ref) return ref
}
return null
}
export function getLargeValueMaterializationError(ref: LargeValueRef): Error {
return new Error(
`This execution value is too large to inline (${formatLargeValueSize(ref.size)}). Select a nested field or reduce the amount of data passed between blocks.`
)
}
export function formatLargeValueSize(bytes: number): string {
const megabytes = bytes / (1024 * 1024)
return `${megabytes.toFixed(1)} MB`
}
export function assertNoLargeValueRefs(value: unknown): void {
const ref = containsLargeValueRef(value)
if (ref) {
throw getLargeValueMaterializationError(ref)
}
}
@@ -0,0 +1,327 @@
import { createLogger, type Logger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import {
getLargeValueMaterializationError,
isLargeValueRef,
isLargeValueStorageKey,
type LargeValueRef,
} from '@/lib/execution/payloads/large-value-ref'
import { ExecutionResourceLimitError } from '@/lib/execution/resource-errors'
import type { StorageContext } from '@/lib/uploads'
import { bufferToBase64, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import type { UserFile } from '@/executor/types'
const logger = createLogger('ExecutionPayloadMaterialization')
export const MAX_DURABLE_LARGE_VALUE_BYTES = 64 * 1024 * 1024
export const MAX_INLINE_MATERIALIZATION_BYTES = 16 * 1024 * 1024
export const MAX_FUNCTION_FILE_BYTES = 64 * 1024 * 1024
export const MAX_FUNCTION_INLINE_BYTES = 10 * 1024 * 1024
export interface ExecutionMaterializationContext {
workflowId?: string
workspaceId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
requestId?: string
logger?: Logger
}
export interface MaterializeLargeValueOptions extends ExecutionMaterializationContext {
maxBytes?: number
}
export interface ReadUserFileContentOptions extends ExecutionMaterializationContext {
maxBytes?: number
maxSourceBytes?: number
offset?: number
length?: number
chunked?: boolean
encoding: 'base64' | 'text'
}
function getLogger(options: ExecutionMaterializationContext): Logger {
return options.logger ?? logger
}
export function assertDurableLargeValueSize(size: number): void {
if (size > MAX_DURABLE_LARGE_VALUE_BYTES) {
throw new ExecutionResourceLimitError({
resource: 'execution_payload_bytes',
attemptedBytes: size,
limitBytes: MAX_DURABLE_LARGE_VALUE_BYTES,
})
}
}
export function assertInlineMaterializationSize(size: number, maxBytes?: number): void {
const limit = maxBytes ?? MAX_INLINE_MATERIALIZATION_BYTES
if (size > limit) {
throw new ExecutionResourceLimitError({
resource: 'execution_payload_bytes',
attemptedBytes: size,
limitBytes: limit,
})
}
}
export function isValidLargeValueKey(ref: LargeValueRef): boolean {
return Boolean(ref.key && isLargeValueStorageKey(ref.key, ref.id, ref.executionId))
}
export function assertLargeValueRefAccess(
ref: LargeValueRef,
context: ExecutionMaterializationContext
): void {
if (!context.executionId) {
throw new Error('Large execution value requires an execution context.')
}
const allowedExecutionIds = new Set([
context.executionId,
...(context.largeValueExecutionIds ?? []),
])
const allowedKeys = new Set(context.largeValueKeys ?? [])
const parts = ref.key?.split('/') ?? []
const [, workspaceId, workflowId, executionId] = parts
if (!ref.key) {
if (ref.executionId && !allowedExecutionIds.has(ref.executionId)) {
throw new Error('Large execution value is not available in this execution.')
}
return
}
if (!context.workspaceId || !context.workflowId) {
throw new Error('Large execution value requires workspace and workflow context.')
}
const workflowScopeAllowed =
context.allowLargeValueWorkflowScope &&
context.workspaceId === workspaceId &&
context.workflowId === workflowId
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(
ref: LargeValueRef,
options: MaterializeLargeValueOptions = {}
): Promise<unknown | undefined> {
const log = getLogger(options)
if (!isLargeValueRef(ref) || !ref.key || !isValidLargeValueKey(ref)) {
return undefined
}
assertLargeValueRefAccess(ref, options)
assertInlineMaterializationSize(ref.size, options.maxBytes)
const maxBytes = options.maxBytes ?? MAX_INLINE_MATERIALIZATION_BYTES
try {
const { StorageService } = await import('@/lib/uploads')
const buffer = await StorageService.downloadFile({
key: ref.key,
context: 'execution',
maxBytes,
})
if (buffer.length > maxBytes) {
throw new ExecutionResourceLimitError({
resource: 'execution_payload_bytes',
attemptedBytes: buffer.length,
limitBytes: maxBytes,
})
}
return JSON.parse(buffer.toString('utf8'))
} catch (error) {
if (isPayloadSizeLimitError(error)) {
throw new ExecutionResourceLimitError({
resource: 'execution_payload_bytes',
attemptedBytes: error.observedBytes ?? maxBytes + 1,
limitBytes: maxBytes,
})
}
if (error instanceof ExecutionResourceLimitError) {
throw error
}
log.warn('Failed to materialize persisted large execution value', {
id: ref.id,
key: ref.key,
error: toError(error).message,
})
return undefined
}
}
function normalizeRange(buffer: Buffer, options: ReadUserFileContentOptions): Buffer {
const offset = Math.max(0, Math.floor(options.offset ?? 0))
const maxLength = options.maxBytes ?? MAX_FUNCTION_INLINE_BYTES
const requestedLength = options.length === undefined ? maxLength : Math.floor(options.length)
const length = Math.max(0, Math.min(requestedLength, maxLength))
return buffer.subarray(offset, offset + length)
}
function getExecutionKeyParts(key: string):
| {
workspaceId: string
workflowId: string
executionId: string
}
| undefined {
const parts = key.split('/')
if (parts[0] !== 'execution' || parts.length < 5) {
return undefined
}
return {
workspaceId: parts[1],
workflowId: parts[2],
executionId: parts[3],
}
}
function assertExecutionFileScope(key: string, options: ExecutionMaterializationContext): void {
const parts = getExecutionKeyParts(key)
if (!parts) {
throw new Error('File is not available in this execution.')
}
const allowedExecutionIds = new Set([
options.executionId,
...(options.largeValueExecutionIds ?? []),
])
const allowedFileKeys = new Set(options.fileKeys ?? [])
const workflowScopeAllowed =
options.allowLargeValueWorkflowScope &&
options.workspaceId === parts.workspaceId &&
options.workflowId === parts.workflowId
if (options.workspaceId && parts.workspaceId !== options.workspaceId) {
throw new Error('File is not available in this execution.')
}
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 {
if (!file.key) {
throw new Error('File content requires a storage key.')
}
const inferredContext = inferContextFromKey(file.key)
if (file.context && file.context !== inferredContext) {
throw new Error('File context does not match its storage key.')
}
return inferredContext
}
export async function assertUserFileContentAccess(
file: UserFile,
options: ExecutionMaterializationContext
): Promise<void> {
const context = getVerifiedStorageContext(file)
if (context === 'execution') {
assertExecutionFileScope(file.key, options)
}
if (!options.userId) {
throw new Error('File access requires an authenticated user.')
}
const { verifyFileAccess } = await import('@/app/api/files/authorization')
const hasAccess = await verifyFileAccess(file.key, options.userId, undefined, context, false)
if (!hasAccess) {
throw new Error('File is not available in this execution.')
}
}
export async function readUserFileContent(
file: unknown,
options: ReadUserFileContentOptions
): Promise<string> {
if (!isUserFileWithMetadata(file)) {
throw new Error('Expected a file object with metadata.')
}
await assertUserFileContentAccess(file, options)
const maxSourceBytes = options.maxSourceBytes ?? MAX_FUNCTION_FILE_BYTES
if (Number.isFinite(file.size) && file.size > maxSourceBytes) {
throw new ExecutionResourceLimitError({
resource: 'execution_payload_bytes',
attemptedBytes: file.size,
limitBytes: maxSourceBytes,
})
}
let buffer: Buffer | null = null
const log = getLogger(options)
const requestId = options.requestId ?? 'unknown'
try {
buffer = await downloadFileFromStorage(file, requestId, log, { maxBytes: maxSourceBytes })
} catch (error) {
if (isPayloadSizeLimitError(error)) {
throw new ExecutionResourceLimitError({
resource: 'execution_payload_bytes',
attemptedBytes: error.observedBytes ?? maxSourceBytes + 1,
limitBytes: maxSourceBytes,
})
}
throw error
}
if (!buffer) {
throw new Error(`File content for ${file.name} is unavailable.`)
}
if (buffer.length > maxSourceBytes) {
throw new ExecutionResourceLimitError({
resource: 'execution_payload_bytes',
attemptedBytes: buffer.length,
limitBytes: maxSourceBytes,
})
}
const shouldSlice =
options.chunked || options.offset !== undefined || options.length !== undefined
const selected = shouldSlice ? normalizeRange(buffer, options) : buffer
assertInlineMaterializationSize(selected.length, options.maxBytes ?? MAX_FUNCTION_INLINE_BYTES)
return options.encoding === 'base64' ? bufferToBase64(selected) : selected.toString('utf8')
}
export function unavailableLargeValueError(ref: LargeValueRef): Error {
return getLargeValueMaterializationError(ref)
}
@@ -0,0 +1,285 @@
/**
* @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, compactSubflowResults } from '@/lib/execution/payloads/serializer'
import type { UserFile } from '@/executor/types'
const TEST_EXECUTION_CONTEXT = {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
}
describe('compactExecutionPayload', () => {
it('keeps small JSON payloads inline', async () => {
const value = { result: { id: 'event-1', text: 'hello' } }
await expect(compactExecutionPayload(value, { thresholdBytes: 1024 })).resolves.toEqual(value)
})
it('strips UserFile base64 by default while preserving metadata', async () => {
const file: UserFile = {
id: 'file-1',
name: 'large.txt',
url: 'https://example.com/file',
size: 11 * 1024 * 1024,
type: 'text/plain',
key: 'execution/workflow/execution/large.txt',
context: 'execution',
base64: 'Zm9v',
}
const compacted = await compactExecutionPayload(
{ event: { files: [file] } },
{ thresholdBytes: 1024 }
)
expect(compacted).toEqual({
event: {
files: [
{
id: 'file-1',
name: 'large.txt',
url: 'https://example.com/file',
size: 11 * 1024 * 1024,
type: 'text/plain',
key: 'execution/workflow/execution/large.txt',
context: 'execution',
},
],
},
})
})
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: 1024, ...TEST_EXECUTION_CONTEXT }
)
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('rejects oversized values before preserving or spilling them when requested', async () => {
await expect(
compactExecutionPayload(
{ root: Object.fromEntries(Array.from({ length: 100 }, (_, index) => [`k${index}`, 'x'])) },
{
thresholdBytes: 256,
preserveRoot: true,
rejectLargeValues: true,
rejectLargeValueLabel: 'Workflow execution response',
...TEST_EXECUTION_CONTEXT,
}
)
).rejects.toMatchObject({
name: 'PayloadSizeLimitError',
label: 'Workflow execution response',
})
})
it('does not double-spill existing refs', async () => {
const compacted = await compactExecutionPayload(
{ results: [[{ payload: 'x'.repeat(2048) }]] },
{ thresholdBytes: 256 }
)
const compactedAgain = await compactExecutionPayload(compacted, { thresholdBytes: 256 })
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(
{ payload: 'x'.repeat(2048) },
{ thresholdBytes: 256, requireDurable: true }
)
).rejects.toThrow('Cannot persist large execution value')
})
it('does not treat loosely marker-shaped user data as a large-value ref', () => {
expect(
isLargeValueRef({
__simLargeValueRef: true,
id: 'user-supplied',
})
).toBe(false)
})
it('rejects ref-shaped user data with non-execution storage keys', () => {
expect(
isLargeValueRef({
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 1024,
key: 'https://example.com/large-value-lv_ABCDEFGHIJKL.json',
})
).toBe(false)
})
it('omits opaque ref IDs from user-facing materialization errors', () => {
const error = getLargeValueMaterializationError({
__simLargeValueRef: true,
version: 1,
id: 'lv_CQcekP8gSJI5',
kind: 'string',
size: 23_259_101,
})
expect(error.message).toContain('This execution value is too large to inline (22.2 MB)')
expect(error.message).not.toContain('lv_CQcekP8gSJI5')
})
})
@@ -0,0 +1,281 @@
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import {
createLargeArrayManifest,
isLargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest'
import {
isLargeValueRef,
LARGE_VALUE_THRESHOLD_BYTES,
} from '@/lib/execution/payloads/large-value-ref'
import { type LargeValueStoreContext, storeLargeValue } from '@/lib/execution/payloads/store'
import type { BlockLog } from '@/executor/types'
export interface CompactExecutionPayloadOptions extends LargeValueStoreContext {
thresholdBytes?: number
preserveUserFileBase64?: boolean
preserveRoot?: boolean
rejectLargeValues?: boolean
rejectLargeValueLabel?: string
}
interface CompactState {
seen: WeakSet<object>
}
function getJsonAndSize(value: unknown): { json: string; size: number } | null {
try {
const json = JSON.stringify(value)
if (json === undefined) {
return null
}
return {
json,
size: Buffer.byteLength(json, 'utf8'),
}
} catch {
return null
}
}
function stripUserFileBase64<T extends { base64?: unknown }>(value: T): Omit<T, 'base64'> {
const { base64: _base64, ...rest } = value
return rest
}
function canPersistDurably(options: CompactExecutionPayloadOptions): boolean {
return Boolean(options.workspaceId && options.workflowId && options.executionId)
}
function largeValueLimitError(
options: CompactExecutionPayloadOptions,
observedBytes: number
): PayloadSizeLimitError {
return new PayloadSizeLimitError({
label: options.rejectLargeValueLabel ?? 'Large execution value',
maxBytes: options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES,
observedBytes,
})
}
function assertRejectSize(observedBytes: number, options: CompactExecutionPayloadOptions): void {
if (!options.rejectLargeValues) return
if (observedBytes > (options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES)) {
throw largeValueLimitError(options, observedBytes)
}
}
async function compactValue(
value: unknown,
options: CompactExecutionPayloadOptions,
state: CompactState,
depth = 0
): Promise<unknown> {
if (!value || typeof value !== 'object') {
const measured = getJsonAndSize(value)
if (measured && measured.size > (options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES)) {
if (options.rejectLargeValues) {
throw largeValueLimitError(options, measured.size)
}
return options.preserveRoot && depth === 0
? value
: storeLargeValue(value, measured.json, measured.size, options)
}
return value
}
if (isLargeValueRef(value)) {
return value
}
if (isLargeArrayManifest(value)) {
const measured = getJsonAndSize(value)
if (measured && measured.size > (options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES)) {
if (options.rejectLargeValues) {
throw largeValueLimitError(options, measured.size)
}
return storeLargeValue(value, measured.json, measured.size, options)
}
return value
}
if (isUserFileWithMetadata(value) && !options.preserveUserFileBase64) {
return stripUserFileBase64(value)
}
if (state.seen.has(value)) {
return value
}
state.seen.add(value)
const compacted = await compactEntries(value, options, state, depth)
const measured = getJsonAndSize(compacted)
if (measured && measured.size > (options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES)) {
if (options.rejectLargeValues) {
throw largeValueLimitError(options, measured.size)
}
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
}
async function compactEntries(
value: object,
options: CompactExecutionPayloadOptions,
state: CompactState,
depth: number
): Promise<unknown> {
if (options.rejectLargeValues) {
return compactEntriesWithEarlyReject(value, options, state, depth)
}
if (Array.isArray(value)) {
return Promise.all(value.map((item) => compactValue(item, options, state, depth + 1)))
}
return Object.fromEntries(
await Promise.all(
Object.entries(value).map(async ([key, entryValue]) => [
key,
key === 'finalBlockLogs' && Array.isArray(entryValue)
? await compactBlockLogs(entryValue as BlockLog[], options)
: await compactValue(entryValue, options, state, depth + 1),
])
)
)
}
async function compactEntriesWithEarlyReject(
value: object,
options: CompactExecutionPayloadOptions,
state: CompactState,
depth: number
): Promise<unknown> {
if (Array.isArray(value)) {
const compacted: unknown[] = []
let estimatedBytes = 2
for (const item of value) {
const compactedItem = await compactValue(item, options, state, depth + 1)
compacted.push(compactedItem)
const measured = getJsonAndSize(compactedItem)
estimatedBytes += (compacted.length > 1 ? 1 : 0) + (measured?.size ?? 4)
assertRejectSize(estimatedBytes, options)
}
return compacted
}
const compacted: Record<string, unknown> = {}
let estimatedBytes = 2
let serializedPropertyCount = 0
for (const [key, entryValue] of Object.entries(value)) {
const compactedEntry =
key === 'finalBlockLogs' && Array.isArray(entryValue)
? await compactBlockLogs(entryValue as BlockLog[], options)
: await compactValue(entryValue, options, state, depth + 1)
compacted[key] = compactedEntry
const measured = getJsonAndSize(compactedEntry)
if (measured) {
const keyJson = JSON.stringify(key)
estimatedBytes +=
(serializedPropertyCount > 0 ? 1 : 0) +
Buffer.byteLength(keyJson, 'utf8') +
1 +
measured.size
serializedPropertyCount += 1
assertRejectSize(estimatedBytes, options)
}
}
return compacted
}
async function forceStoreValue(
value: unknown,
options: CompactExecutionPayloadOptions
): Promise<unknown> {
if (isLargeValueRef(value) || isLargeArrayManifest(value)) {
return value
}
const measured = getJsonAndSize(value)
if (!measured) {
return value
}
return storeLargeValue(value, measured.json, measured.size, options)
}
export async function compactExecutionPayload<T>(
value: T,
options: CompactExecutionPayloadOptions = {}
): Promise<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`.
*/
export async function compactSubflowResults<T>(
results: T[],
options: CompactExecutionPayloadOptions = {}
): Promise<T[]> {
const entryOptions = { ...options, preserveRoot: false }
let compactedResults = (await Promise.all(
results.map((result) => compactExecutionPayload(result, entryOptions))
)) as T[]
const aggregate = getJsonAndSize({ results: compactedResults })
if (aggregate && aggregate.size <= (options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES)) {
return compactedResults
}
compactedResults = (await Promise.all(
compactedResults.map((result) => forceStoreValue(result, options))
)) as T[]
return compactedResults
}
export async function compactBlockLogs(
logs: BlockLog[] | undefined,
options: CompactExecutionPayloadOptions = {}
): Promise<BlockLog[] | undefined> {
if (!logs) {
return logs
}
return Promise.all(
logs.map(async (log) => {
const compactedLog = { ...log }
if ('input' in compactedLog) {
compactedLog.input = await compactExecutionPayload(compactedLog.input, options)
}
if ('output' in compactedLog) {
compactedLog.output = await compactExecutionPayload(compactedLog.output, options)
}
if ('childTraceSpans' in compactedLog) {
compactedLog.childTraceSpans = await compactExecutionPayload(
compactedLog.childTraceSpans,
options
)
}
return compactedLog
})
)
}
@@ -0,0 +1,765 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import {
cacheLargeValue,
clearLargeValueCacheForTests,
materializeLargeValueRefSync,
} from '@/lib/execution/payloads/cache'
import {
MAX_DURABLE_LARGE_VALUE_BYTES,
readLargeValueRefFromStorage,
readUserFileContent,
} from '@/lib/execution/payloads/materialization.server'
import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store'
import { EXECUTION_RESOURCE_LIMIT_CODE } from '@/lib/execution/resource-errors'
const {
mockAddLargeValueReference,
mockDeleteFileMetadata,
mockDeleteFiles,
mockDownloadFile,
mockRegisterLargeValueOwner,
mockUploadFile,
mockVerifyFileAccess,
} = vi.hoisted(() => ({
mockAddLargeValueReference: vi.fn(),
mockDeleteFileMetadata: vi.fn(),
mockDeleteFiles: vi.fn(),
mockDownloadFile: vi.fn(),
mockRegisterLargeValueOwner: vi.fn(),
mockUploadFile: vi.fn(),
mockVerifyFileAccess: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
deleteFiles: mockDeleteFiles,
downloadFile: mockDownloadFile,
uploadFile: mockUploadFile,
},
}))
vi.mock('@/lib/uploads/core/storage-service', () => ({
uploadFile: mockUploadFile,
downloadFile: mockDownloadFile,
}))
vi.mock('@/app/api/files/authorization', () => ({
verifyFileAccess: mockVerifyFileAccess,
}))
vi.mock('@/lib/execution/payloads/large-value-metadata', () => ({
addLargeValueReference: mockAddLargeValueReference,
registerLargeValueOwner: mockRegisterLargeValueOwner,
}))
vi.mock('@/lib/uploads/server/metadata', () => ({
deleteFileMetadata: mockDeleteFileMetadata,
}))
describe('large execution payload store', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
mockAddLargeValueReference.mockResolvedValue(undefined)
mockRegisterLargeValueOwner.mockResolvedValue(true)
mockDeleteFiles.mockResolvedValue({ deleted: 1, failed: [] })
mockDeleteFileMetadata.mockResolvedValue(true)
mockVerifyFileAccess.mockResolvedValue(true)
})
it('stores oversized JSON in execution object storage and returns a small ref', async () => {
const value = { payload: 'x'.repeat(2048) }
const json = JSON.stringify(value)
const ref = await storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
requireDurable: true,
})
expect(ref).toMatchObject({
__simLargeValueRef: true,
version: 1,
kind: 'object',
size: Buffer.byteLength(json, 'utf8'),
executionId: 'execution-1',
})
expect(ref.key).toBe(`execution/workspace-1/workflow-1/execution-1/large-value-${ref.id}.json`)
expect(mockUploadFile).toHaveBeenCalledWith(
expect.objectContaining({
contentType: 'application/json',
context: 'execution',
preserveKey: true,
customKey: ref.key,
})
)
expect(mockRegisterLargeValueOwner).toHaveBeenCalledWith(
{
key: ref.key,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
size: Buffer.byteLength(json, 'utf8'),
},
[]
)
})
it('cleans up uploaded storage and fails durable writes when owner metadata is not recorded', async () => {
const value = { payload: 'x'.repeat(2048) }
const json = JSON.stringify(value)
mockRegisterLargeValueOwner.mockResolvedValueOnce(false)
await expect(
storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
requireDurable: true,
})
).rejects.toThrow('Failed to persist large execution value metadata')
const key = 'execution/workspace-1/workflow-1/execution-1/large-value-lv_'
expect(mockDeleteFiles.mock.calls[0]?.[0][0]).toContain(key)
expect(mockDeleteFileMetadata).toHaveBeenCalledOnce()
})
it('keeps file metadata when untracked storage deletion reports failure', async () => {
const value = { payload: 'x'.repeat(2048) }
const json = JSON.stringify(value)
mockRegisterLargeValueOwner.mockResolvedValueOnce(false)
mockDeleteFiles.mockImplementationOnce(async (keys: string[]) => ({
deleted: 0,
failed: [{ key: keys[0] }],
}))
await expect(
storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
requireDurable: true,
})
).rejects.toThrow('Failed to persist large execution value metadata')
expect(mockDeleteFiles).toHaveBeenCalledOnce()
expect(mockDeleteFileMetadata).not.toHaveBeenCalled()
})
it('does not delete uploaded storage when owner metadata registration throws', async () => {
const value = { payload: 'x'.repeat(2048) }
const json = JSON.stringify(value)
mockRegisterLargeValueOwner.mockRejectedValueOnce(new Error('metadata db down'))
await expect(
storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
requireDurable: true,
})
).rejects.toThrow('metadata db down')
expect(mockDeleteFiles).not.toHaveBeenCalled()
expect(mockDeleteFileMetadata).not.toHaveBeenCalled()
})
it('falls back to memory-only refs for non-durable writes when orphan cleanup fails', async () => {
const value = { payload: 'x'.repeat(2048) }
const json = JSON.stringify(value)
mockRegisterLargeValueOwner.mockResolvedValueOnce(false)
mockDeleteFiles.mockImplementationOnce(async ([key]) => ({
deleted: 0,
failed: [{ key }],
}))
const ref = await storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
})
expect(ref.key).toBeUndefined()
expect(materializeLargeValueRefSync(ref, { executionId: 'execution-1' })).toEqual(value)
expect(mockDeleteFileMetadata).not.toHaveBeenCalled()
})
it('passes nested large value refs to owner metadata registration', async () => {
const nestedKey =
'execution/workspace-1/workflow-1/source-execution/large-value-lv_abcdefghijkl.json'
const value = {
nested: {
__simLargeValueRef: true,
version: 1,
id: 'lv_abcdefghijkl',
kind: 'object',
size: 123,
key: nestedKey,
},
payload: 'x'.repeat(2048),
}
const json = JSON.stringify(value)
await storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
requireDurable: true,
})
expect(mockRegisterLargeValueOwner).toHaveBeenCalledWith(expect.any(Object), [nestedKey])
})
it('fails durable writes before producing refs when execution context is missing', async () => {
const value = { payload: 'x'.repeat(2048) }
const json = JSON.stringify(value)
await expect(
storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), { requireDurable: true })
).rejects.toThrow('Cannot persist large execution value')
expect(mockUploadFile).not.toHaveBeenCalled()
})
it('fails durable writes when storage upload fails', async () => {
const value = { payload: 'x'.repeat(2048) }
const json = JSON.stringify(value)
mockUploadFile.mockRejectedValueOnce(new Error('storage down'))
await expect(
storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
requireDurable: true,
})
).rejects.toThrow('Failed to persist large execution value: storage down')
})
it('materializes object-storage refs through the server helper', async () => {
mockDownloadFile.mockResolvedValueOnce(Buffer.from(JSON.stringify({ ok: true }), 'utf8'))
await expect(
materializeLargeValueRef(
{
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 11,
key: 'execution/workflow-1/workflow-2/execution-1/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'execution-1',
},
{
workspaceId: 'workflow-1',
workflowId: 'workflow-2',
executionId: 'execution-1',
}
)
).resolves.toEqual({ ok: true })
expect(mockAddLargeValueReference).toHaveBeenCalledWith(
{
workspaceId: 'workflow-1',
workflowId: 'workflow-2',
executionId: 'execution-1',
source: 'execution_log',
},
'execution/workflow-1/workflow-2/execution-1/large-value-lv_ABCDEFGHIJKL.json'
)
})
it('records a reference before returning a cached prior-execution value', async () => {
cacheLargeValue(
'lv_CACHEDREF123',
{ cached: true },
16,
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'source-execution',
},
{ recoverable: true }
)
await expect(
materializeLargeValueRef(
{
__simLargeValueRef: true,
version: 1,
id: 'lv_CACHEDREF123',
kind: 'object',
size: 16,
key: 'execution/workspace-1/workflow-1/source-execution/large-value-lv_CACHEDREF123.json',
executionId: 'source-execution',
},
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'consumer-execution',
allowLargeValueWorkflowScope: true,
}
)
).resolves.toEqual({ cached: true })
expect(mockAddLargeValueReference).toHaveBeenCalledWith(
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'consumer-execution',
source: 'execution_log',
},
'execution/workspace-1/workflow-1/source-execution/large-value-lv_CACHEDREF123.json'
)
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('bounds durable large-value writes', async () => {
const size = MAX_DURABLE_LARGE_VALUE_BYTES + 1
await expect(
storeLargeValue('x', JSON.stringify('x'), size, {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
requireDurable: true,
})
).rejects.toMatchObject({ code: EXECUTION_RESOURCE_LIMIT_CODE })
})
it('bounds explicit server-side materialization', async () => {
await expect(
readLargeValueRefFromStorage(
{
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 2048,
key: 'execution/workflow-1/workflow-2/execution-1/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'execution-1',
},
{
workspaceId: 'workflow-1',
workflowId: 'workflow-2',
executionId: 'execution-1',
maxBytes: 1024,
}
)
).rejects.toMatchObject({ code: EXECUTION_RESOURCE_LIMIT_CODE })
})
it('does not materialize durable refs without caller execution context', async () => {
await expect(
materializeLargeValueRef({
__simLargeValueRef: true,
version: 1,
id: 'lv_NOCTXVALUE12',
kind: 'object',
size: 11,
key: 'execution/workflow-1/workflow-2/execution-1/large-value-lv_NOCTXVALUE12.json',
executionId: 'execution-1',
})
).resolves.toBeUndefined()
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('checks caller execution context before returning cached large values', async () => {
const value = { payload: 'cached' }
const json = JSON.stringify(value)
const ref = await storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
requireDurable: true,
})
await expect(
materializeLargeValueRef(ref, {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'other-execution',
userId: 'user-1',
})
).rejects.toThrow('Large execution value is not available in this execution.')
})
it('rejects durable refs whose key does not match caller execution context', async () => {
await expect(
readLargeValueRefFromStorage(
{
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 11,
key: 'execution/workflow-1/workflow-2/execution-1/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'execution-1',
},
{ workspaceId: 'workflow-1', workflowId: 'workflow-2', executionId: 'other-execution' }
)
).rejects.toThrow('Large execution value is not available in this execution.')
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('allows prior-execution durable refs only when workflow-scoped reads are explicitly enabled', async () => {
mockDownloadFile.mockResolvedValueOnce(Buffer.from(JSON.stringify({ ok: true }), 'utf8'))
await expect(
readLargeValueRefFromStorage(
{
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 11,
key: 'execution/workspace-1/workflow-1/source-execution/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'source-execution',
},
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'resume-execution',
allowLargeValueWorkflowScope: true,
}
)
).resolves.toEqual({ ok: true })
})
it('does not materialize forged keyless refs from another cached execution', () => {
cacheLargeValue('lv_FORGEDCACHE1', { secret: true }, 16, {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'source-execution',
})
const forged = {
__simLargeValueRef: true,
version: 1,
id: 'lv_FORGEDCACHE1',
kind: 'object',
size: 16,
executionId: 'other-execution',
} as const
expect(
materializeLargeValueRefSync(forged, {
workspaceId: 'workspace-2',
workflowId: 'workflow-2',
executionId: 'other-execution',
})
).toBeUndefined()
})
it('does not evict unrecoverable in-memory refs for recoverable cache entries', () => {
const scope = {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
}
const unrecoverableId = 'lv_UNRECOVER001'
const unrecoverableRef = {
__simLargeValueRef: true,
version: 1,
id: unrecoverableId,
kind: 'object',
size: 200 * 1024 * 1024,
executionId: scope.executionId,
} as const
expect(cacheLargeValue(unrecoverableId, { retained: true }, unrecoverableRef.size, scope)).toBe(
true
)
expect(
cacheLargeValue('lv_RECOVER00001', { recoverable: true }, 70 * 1024 * 1024, scope, {
recoverable: true,
})
).toBe(false)
expect(materializeLargeValueRefSync(unrecoverableRef, scope)).toEqual({ retained: true })
})
it('materializes keyless cached refs through the async helper', async () => {
const scope = {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
}
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_KEYLESSCACHE',
kind: 'object',
size: 32,
executionId: scope.executionId,
} as const
cacheLargeValue(ref.id, { retained: true }, ref.size, scope)
await expect(materializeLargeValueRef(ref, scope)).resolves.toEqual({ retained: true })
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('fails loudly when reference tracking fails before returning cached durable refs', async () => {
const scope = {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
}
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_TRACKFAIL12',
kind: 'object',
size: 32,
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_TRACKFAIL12.json',
executionId: 'execution-1',
} as const
cacheLargeValue(ref.id, { retained: true }, ref.size, scope, { recoverable: true })
mockAddLargeValueReference.mockRejectedValueOnce(new Error('reference cap exceeded'))
await expect(materializeLargeValueRef(ref, scope)).rejects.toThrow('reference cap exceeded')
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('enforces maxBytes before returning cached refs', async () => {
const scope = {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
}
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_CACHEDMAXBYTE',
kind: 'object',
size: 2048,
executionId: scope.executionId,
} as const
cacheLargeValue(ref.id, { retained: true }, ref.size, scope)
await expect(materializeLargeValueRef(ref, { ...scope, maxBytes: 1024 })).rejects.toMatchObject(
{
code: EXECUTION_RESOURCE_LIMIT_CODE,
}
)
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('rejects durable refs when caller omits workspace and workflow context', async () => {
await expect(
readLargeValueRefFromStorage(
{
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 11,
key: 'execution/workflow-1/workflow-2/execution-1/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'execution-1',
},
{ executionId: 'execution-1' }
)
).rejects.toThrow('Large execution value requires workspace and workflow context.')
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('rejects execution files with forged public contexts before storage download', async () => {
await expect(
readUserFileContent(
{
id: 'file_1',
name: 'secret.txt',
url: '/api/files/serve/execution/workspace-1/workflow-1/execution-1/secret.txt',
key: 'execution/workspace-1/workflow-1/execution-1/secret.txt',
context: 'profile-pictures',
size: 32,
type: 'text/plain',
},
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
encoding: 'text',
}
)
).rejects.toThrow('File context does not match its storage key.')
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('rejects URL-only file objects instead of reading internal URLs directly', async () => {
await expect(
readUserFileContent(
{
id: 'file_1',
name: 'secret.txt',
url: '/api/files/serve/execution/workspace-1/workflow-1/execution-1/secret.txt?context=execution',
key: '',
size: 32,
type: 'text/plain',
},
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
encoding: 'text',
}
)
).rejects.toThrow('File content requires a storage key.')
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('throws instead of truncating non-chunked file reads over the inline cap', async () => {
const workspaceId = '11111111-1111-4111-8111-111111111111'
const workflowId = '22222222-2222-4222-8222-222222222222'
const executionId = '33333333-3333-4333-8333-333333333333'
mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello world', 'utf8'))
await expect(
readUserFileContent(
{
id: 'file_1',
name: 'hello.txt',
url: `/api/files/serve/execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
key: `execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
context: 'execution',
size: 11,
type: 'text/plain',
},
{
workspaceId,
workflowId,
executionId,
userId: 'user-1',
encoding: 'text',
maxBytes: 5,
}
)
).rejects.toMatchObject({ code: EXECUTION_RESOURCE_LIMIT_CODE })
})
it('passes source byte limits into execution file storage downloads', async () => {
const workspaceId = '11111111-1111-4111-8111-111111111111'
const workflowId = '22222222-2222-4222-8222-222222222222'
const executionId = '33333333-3333-4333-8333-333333333333'
mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello', 'utf8'))
await expect(
readUserFileContent(
{
id: 'file_1',
name: 'hello.txt',
url: `/api/files/serve/execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
key: `execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
context: 'execution',
size: 5,
type: 'text/plain',
},
{
workspaceId,
workflowId,
executionId,
userId: 'user-1',
encoding: 'text',
maxSourceBytes: 6,
}
)
).resolves.toBe('hello')
expect(mockDownloadFile).toHaveBeenCalledWith(
expect.objectContaining({
key: `execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
context: 'execution',
maxBytes: 6,
})
)
})
it('converts storage byte-limit failures into execution resource-limit errors', async () => {
const workspaceId = '11111111-1111-4111-8111-111111111111'
const workflowId = '22222222-2222-4222-8222-222222222222'
const executionId = '33333333-3333-4333-8333-333333333333'
mockDownloadFile.mockRejectedValueOnce(
new PayloadSizeLimitError({
label: 'storage file download',
maxBytes: 6,
observedBytes: 7,
})
)
await expect(
readUserFileContent(
{
id: 'file_1',
name: 'hello.txt',
url: `/api/files/serve/execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
key: `execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
context: 'execution',
size: 5,
type: 'text/plain',
},
{
workspaceId,
workflowId,
executionId,
userId: 'user-1',
encoding: 'text',
maxSourceBytes: 6,
}
)
).rejects.toMatchObject({
code: EXECUTION_RESOURCE_LIMIT_CODE,
attemptedBytes: 7,
limitBytes: 6,
})
})
it('allows explicit chunked file reads to slice within the inline cap', async () => {
const workspaceId = '11111111-1111-4111-8111-111111111111'
const workflowId = '22222222-2222-4222-8222-222222222222'
const executionId = '33333333-3333-4333-8333-333333333333'
mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello world', 'utf8'))
await expect(
readUserFileContent(
{
id: 'file_1',
name: 'hello.txt',
url: `/api/files/serve/execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
key: `execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
context: 'execution',
size: 11,
type: 'text/plain',
},
{
workspaceId,
workflowId,
executionId,
userId: 'user-1',
encoding: 'text',
maxBytes: 5,
chunked: true,
}
)
).resolves.toBe('hello')
})
})
+274
View File
@@ -0,0 +1,274 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
import { cacheLargeValue, materializeLargeValueRefSync } from '@/lib/execution/payloads/cache'
import { collectLargeValueKeys } from '@/lib/execution/payloads/large-execution-value'
import {
LARGE_VALUE_REF_VERSION,
type LargeValueKind,
type LargeValueRef,
} from '@/lib/execution/payloads/large-value-ref'
import {
assertDurableLargeValueSize,
assertInlineMaterializationSize,
assertLargeValueRefAccess,
isValidLargeValueKey,
readLargeValueRefFromStorage,
} from '@/lib/execution/payloads/materialization.server'
import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils'
const logger = createLogger('LargeExecutionPayloadStore')
export interface LargeValueStoreContext {
workspaceId?: string
workflowId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
requireDurable?: boolean
maxBytes?: number
/**
* When false, materialization does not register an execution_log reference for
* the key. Read-only consumers (e.g. viewing/exporting a completed log) set
* this: the value is already owned + referenced by its own execution, so
* re-registering on every read is wasteful and a needless failure point.
*/
trackReference?: boolean
}
function getKind(value: unknown): LargeValueKind {
if (typeof value === 'string') return 'string'
if (Array.isArray(value)) return 'array'
if (value && typeof value === 'object') return 'object'
return 'json'
}
function getPreview(value: unknown): unknown {
if (typeof value === 'string') {
return truncate(value, 256)
}
if (Array.isArray(value)) {
return { length: value.length }
}
if (value && typeof value === 'object') {
return { keys: Object.keys(value).slice(0, 20) }
}
return value
}
async function persistValue(
id: string,
json: string,
context: LargeValueStoreContext
): Promise<string | undefined> {
const { workspaceId, workflowId, executionId, userId } = context
if (!workspaceId || !workflowId || !executionId) {
if (context.requireDurable) {
throw new Error(
'Cannot persist large execution value without workspace, workflow, and execution IDs'
)
}
return undefined
}
const key = generateExecutionFileKey(
{ workspaceId, workflowId, executionId },
`large-value-${id}.json`
)
try {
const { StorageService } = await import('@/lib/uploads')
const fileInfo = await StorageService.uploadFile({
file: Buffer.from(json, 'utf8'),
fileName: key,
contentType: 'application/json',
context: 'execution',
preserveKey: true,
customKey: key,
metadata: {
originalName: `large-value-${id}.json`,
uploadedAt: new Date().toISOString(),
purpose: 'execution-large-value',
workspaceId,
...(userId ? { userId } : {}),
},
})
return fileInfo.key
} catch (error) {
if (context.requireDurable) {
throw new Error(`Failed to persist large execution value: ${toError(error).message}`)
}
logger.warn('Failed to persist large execution value, keeping in memory only', {
id,
error: toError(error).message,
})
return undefined
}
}
async function registerPersistedValueOwner(
key: string | undefined,
size: number,
referencedKeys: string[],
context: LargeValueStoreContext
): Promise<boolean> {
const { workspaceId, workflowId, executionId } = context
if (!key || !workspaceId || !workflowId || !executionId) {
return false
}
const { registerLargeValueOwner } = await import('@/lib/execution/payloads/large-value-metadata')
return await registerLargeValueOwner(
{
key,
workspaceId,
workflowId,
executionId,
size,
},
referencedKeys
)
}
async function deleteUntrackedPersistedValue(key: string): Promise<boolean> {
try {
const [{ StorageService }, { deleteFileMetadata }] = await Promise.all([
import('@/lib/uploads'),
import('@/lib/uploads/server/metadata'),
])
const result = await StorageService.deleteFiles([key], 'execution')
const deleteFailed = result.failed.some((failed) => failed.key === key)
if (deleteFailed) {
logger.warn('Failed to delete untracked large execution value from storage', {
key,
})
return false
}
await deleteFileMetadata(key)
return true
} catch (error) {
logger.warn('Failed to clean up untracked large execution value', {
key,
error: toError(error).message,
})
return false
}
}
export async function storeLargeValue(
value: unknown,
json: string,
size: number,
context: LargeValueStoreContext
): Promise<LargeValueRef> {
assertDurableLargeValueSize(size)
const referencedKeys = collectLargeValueKeys(value)
const id = `lv_${generateShortId(12)}`
let key = await persistValue(id, json, context)
if (key) {
// Only clean up the uploaded object when registration definitively did NOT
// record ownership (returns false). If registration THROWS, the metadata
// state is uncertain (a row may have partially committed), so we propagate
// without deleting — deleting could orphan a metadata row pointing at a
// now-missing object.
const registered = await registerPersistedValueOwner(key, size, referencedKeys, context)
if (!registered) {
await deleteUntrackedPersistedValue(key)
if (context.requireDurable) {
throw new Error('Failed to persist large execution value metadata')
}
key = undefined
}
}
const cached = cacheLargeValue(id, value, size, context, { recoverable: Boolean(key) })
if (!key && !cached) {
throw new Error('Cannot retain large execution value without durable storage')
}
return {
__simLargeValueRef: true,
version: LARGE_VALUE_REF_VERSION,
id,
kind: getKind(value),
size,
key,
executionId: context.executionId,
preview: getPreview(value),
}
}
export async function materializeLargeValueRef(
ref: LargeValueRef,
context?: LargeValueStoreContext
): Promise<unknown> {
if (!context?.executionId) {
return undefined
}
assertLargeValueRefAccess(ref, context)
assertInlineMaterializationSize(ref.size, context.maxBytes)
if (!ref.key || !isValidLargeValueKey(ref)) {
return materializeLargeValueRefSync(ref, context)
}
if (context.trackReference !== false) {
const { addLargeValueReference } = await import('@/lib/execution/payloads/large-value-metadata')
// Reference tracking is GC-critical: if it fails, fail the read rather than
// return a value whose reference was never recorded (it could later be
// garbage-collected out from under a live consumer). Read-only consumers
// that don't need a reference set trackReference: false to skip this.
await addLargeValueReference(
{
workspaceId: context.workspaceId,
workflowId: context.workflowId,
executionId: context.executionId,
source: 'execution_log',
},
ref.key
)
}
try {
const cached = materializeLargeValueRefSync(ref, context)
if (cached !== undefined) {
return cached
}
const value = await readLargeValueRefFromStorage(ref, {
workspaceId: context.workspaceId,
workflowId: context.workflowId,
executionId: context.executionId,
largeValueExecutionIds: context.largeValueExecutionIds,
largeValueKeys: context.largeValueKeys,
allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope,
userId: context.userId,
maxBytes: context.maxBytes ?? ref.size,
})
if (value === undefined) {
return undefined
}
cacheLargeValue(
ref.id,
value,
ref.size,
{
...context,
executionId: ref.executionId ?? context.executionId,
},
{ recoverable: true }
)
return value
} catch (error) {
logger.warn('Failed to materialize persisted large execution value', {
id: ref.id,
key: ref.key,
error,
})
return undefined
}
}
@@ -0,0 +1,373 @@
/**
* @vitest-environment node
*/
import { loggingSessionMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetWorkspaceBilledAccountUserId, mockCheckRateLimit, mockGetActivelyBannedUserIds } =
vi.hoisted(() => ({
mockGetWorkspaceBilledAccountUserId: vi.fn(),
mockCheckRateLimit: vi.fn(),
mockGetActivelyBannedUserIds: vi.fn().mockResolvedValue([]),
}))
vi.mock('@sim/db', () => ({ db: {} }))
vi.mock('drizzle-orm', () => ({ eq: vi.fn() }))
vi.mock('@/lib/auth/ban', () => ({
getActivelyBannedUserIds: mockGetActivelyBannedUserIds,
}))
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
checkServerSideUsageLimits: vi.fn(),
checkOrgMemberUsageLimit: vi.fn().mockResolvedValue({ isExceeded: false }),
}))
vi.mock('@/lib/billing/core/subscription', () => ({
getHighestPrioritySubscription: vi.fn(),
}))
vi.mock('@/lib/core/execution-limits', () => ({
getExecutionTimeout: vi.fn(() => 0),
}))
vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({
// Regular function (not an arrow) so `new RateLimiter()` is constructable under
// vitest 4.x, which rejects `new` on an arrow-implemented mock.
RateLimiter: vi.fn(function (this: unknown) {
return { checkRateLimitWithSubscription: mockCheckRateLimit }
}),
}))
vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
vi.mock('@/lib/workspaces/utils', () => ({
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
}))
vi.mock('@sim/platform-authz/workflow', () => ({
getActiveWorkflowRecord: vi.fn().mockResolvedValue({
id: 'workflow-1',
userId: 'creator-1',
workspaceId: 'workspace-1',
isDeployed: true,
}),
}))
import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { preprocessExecution } from './preprocessing'
describe('preprocessExecution correlation logging', () => {
it('preserves trigger correlation when logging preprocessing failures', async () => {
mockGetWorkspaceBilledAccountUserId.mockResolvedValueOnce(null)
const loggingSession = {
safeStart: vi.fn().mockResolvedValue(true),
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
}
const correlation = {
executionId: 'execution-1',
requestId: 'request-1',
source: 'schedule' as const,
workflowId: 'workflow-1',
scheduleId: 'schedule-1',
triggerType: 'schedule',
scheduledFor: '2025-01-01T00:00:00.000Z',
}
const result = await preprocessExecution({
workflowId: 'workflow-1',
userId: 'unknown',
triggerType: 'schedule',
executionId: 'execution-1',
requestId: 'request-1',
loggingSession: loggingSession as any,
triggerData: { correlation },
workflowRecord: {
id: 'workflow-1',
workspaceId: 'workspace-1',
isDeployed: true,
} as any,
})
expect(result).toMatchObject({
success: false,
error: {
statusCode: 500,
logCreated: true,
},
})
expect(loggingSession.safeStart).toHaveBeenCalledWith({
userId: 'unknown',
workspaceId: 'workspace-1',
variables: {},
triggerData: { correlation },
})
})
})
describe('preprocessExecution logPreprocessingErrors option', () => {
const baseOptions = {
workflowId: 'workflow-1',
userId: 'owner-1',
triggerType: 'workflow' as const,
executionId: 'execution-1',
requestId: 'request-1',
checkDeployment: false,
checkRateLimit: true,
workflowRecord: { id: 'workflow-1', workspaceId: 'workspace-1', isDeployed: false } as any,
}
beforeEach(() => {
vi.clearAllMocks()
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('billed-account-1')
vi.mocked(getHighestPrioritySubscription).mockResolvedValue({ plan: 'free' } as any)
vi.mocked(checkServerSideUsageLimits).mockResolvedValue({
isExceeded: false,
currentUsage: 1,
limit: 10,
} as any)
mockCheckRateLimit.mockResolvedValue({
allowed: true,
remaining: 100,
resetAt: new Date(),
})
})
it('suppresses preprocessing-error logging when logPreprocessingErrors is false', async () => {
vi.mocked(checkServerSideUsageLimits).mockResolvedValueOnce({
isExceeded: true,
currentUsage: 20,
limit: 10,
message: 'Usage limit exceeded. Please upgrade your plan to continue.',
} as any)
const loggingSession = {
safeStart: vi.fn().mockResolvedValue(true),
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
}
const result = await preprocessExecution({
...baseOptions,
logPreprocessingErrors: false,
loggingSession: loggingSession as any,
})
expect(result).toMatchObject({ success: false, error: { statusCode: 402 } })
// No execution-log row written — the caller (table cell) surfaces it instead.
expect(loggingSession.safeStart).not.toHaveBeenCalled()
})
})
describe('preprocessExecution ban gate', () => {
const baseOptions = {
workflowId: 'workflow-1',
userId: 'owner-1',
triggerType: 'workflow' as const,
executionId: 'execution-1',
requestId: 'request-1',
checkDeployment: false,
checkRateLimit: false,
workflowRecord: { id: 'workflow-1', workspaceId: 'workspace-1', isDeployed: true } as any,
}
beforeEach(() => {
vi.clearAllMocks()
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('billed-account-1')
mockGetActivelyBannedUserIds.mockResolvedValue([])
vi.mocked(getHighestPrioritySubscription).mockResolvedValue({ plan: 'free' } as any)
vi.mocked(checkServerSideUsageLimits).mockResolvedValue({
isExceeded: false,
currentUsage: 1,
limit: 10,
} as any)
})
it('blocks execution with 403 when the actor is banned (ban wins over the parallel gates)', async () => {
mockGetActivelyBannedUserIds.mockResolvedValue(['billed-account-1'])
const loggingSession = {
safeStart: vi.fn().mockResolvedValue(true),
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
}
const result = await preprocessExecution({
...baseOptions,
loggingSession: loggingSession as any,
})
expect(result).toMatchObject({
success: false,
error: { statusCode: 403, logCreated: true, message: 'Account suspended' },
})
expect(loggingSession.safeStart).toHaveBeenCalled()
})
it('returns 403 (ban precedence) when ban, usage, and rate limit all fail simultaneously', async () => {
mockGetActivelyBannedUserIds.mockResolvedValue(['billed-account-1'])
vi.mocked(checkServerSideUsageLimits).mockResolvedValue({
isExceeded: true,
currentUsage: 20,
limit: 10,
message: 'Usage limit exceeded. Please upgrade your plan to continue.',
} as any)
mockCheckRateLimit.mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: new Date(),
})
const loggingSession = {
safeStart: vi.fn().mockResolvedValue(true),
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
}
const result = await preprocessExecution({
...baseOptions,
checkRateLimit: true,
loggingSession: loggingSession as any,
})
// Ban (403) takes precedence over usage (402) and rate limit (429),
// independent of which parallel gate's promise settled first.
expect(result).toMatchObject({
success: false,
error: { statusCode: 403, logCreated: true, message: 'Account suspended' },
})
})
it('does not debit rate-limit quota when the ban gate rejects', async () => {
// The rate-limit gate consumes a token, so it must not run for a request
// an earlier gate (ban) already rejects.
mockGetActivelyBannedUserIds.mockResolvedValue(['billed-account-1'])
const result = await preprocessExecution({ ...baseOptions, checkRateLimit: true })
expect(result).toMatchObject({ success: false, error: { statusCode: 403 } })
expect(mockCheckRateLimit).not.toHaveBeenCalled()
})
it('does not debit rate-limit quota when the usage gate rejects', async () => {
vi.mocked(checkServerSideUsageLimits).mockResolvedValue({
isExceeded: true,
currentUsage: 20,
limit: 10,
message: 'Usage limit exceeded. Please upgrade your plan to continue.',
} as any)
const result = await preprocessExecution({ ...baseOptions, checkRateLimit: true })
expect(result).toMatchObject({ success: false, error: { statusCode: 402 } })
expect(mockCheckRateLimit).not.toHaveBeenCalled()
})
it('consumes the rate-limit gate exactly once when the ban and usage gates pass', async () => {
mockCheckRateLimit.mockResolvedValue({ allowed: true, remaining: 5, resetAt: new Date() })
// skipConcurrencyReservation bypasses the STEP 7 admission reservation so the
// assertion isolates the rate gate and does not depend on Redis availability.
const result = await preprocessExecution({
...baseOptions,
checkRateLimit: true,
skipConcurrencyReservation: true,
})
expect(result.success).toBe(true)
expect(mockCheckRateLimit).toHaveBeenCalledTimes(1)
})
it('checks the billing actor, caller-provided userId, and workflow owner in one call', async () => {
const result = await preprocessExecution(baseOptions)
expect(result.success).toBe(true)
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledTimes(1)
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith([
'billed-account-1',
'owner-1',
'creator-1',
])
})
it('excludes the "unknown" sentinel userId but still checks the workflow owner', async () => {
const result = await preprocessExecution({ ...baseOptions, userId: 'unknown' })
expect(result.success).toBe(true)
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['billed-account-1', 'creator-1'])
})
it('fails closed with 500 when the ban check errors', async () => {
mockGetActivelyBannedUserIds.mockRejectedValue(new Error('db down'))
const loggingSession = {
safeStart: vi.fn().mockResolvedValue(true),
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
}
const result = await preprocessExecution({
...baseOptions,
loggingSession: loggingSession as any,
})
expect(result).toMatchObject({
success: false,
error: { statusCode: 500, logCreated: true },
})
})
})
describe('preprocessExecution resolvedActorUserId reuse', () => {
const baseOptions = {
workflowId: 'workflow-1',
userId: 'owner-1',
triggerType: 'webhook' as const,
executionId: 'execution-1',
requestId: 'request-1',
checkDeployment: false,
checkRateLimit: false,
skipConcurrencyReservation: true,
workspaceId: 'workspace-1',
workflowRecord: { id: 'workflow-1', workspaceId: 'workspace-1', isDeployed: true } as any,
}
beforeEach(() => {
vi.clearAllMocks()
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('billed-account-1')
mockGetActivelyBannedUserIds.mockResolvedValue([])
vi.mocked(getHighestPrioritySubscription).mockResolvedValue({ plan: 'free' } as any)
vi.mocked(checkServerSideUsageLimits).mockResolvedValue({
isExceeded: false,
currentUsage: 1,
limit: 10,
} as any)
})
it('skips the workspace billed-account lookup when an actor is pre-resolved', async () => {
const result = await preprocessExecution({
...baseOptions,
resolvedActorUserId: 'pre-resolved-actor',
})
expect(result.success).toBe(true)
expect(result.actorUserId).toBe('pre-resolved-actor')
expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled()
})
it('still runs the ban gate against the pre-resolved actor', async () => {
mockGetActivelyBannedUserIds.mockResolvedValue(['pre-resolved-actor'])
const result = await preprocessExecution({
...baseOptions,
resolvedActorUserId: 'pre-resolved-actor',
})
expect(result).toMatchObject({ success: false, error: { statusCode: 403 } })
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(
expect.arrayContaining(['pre-resolved-actor'])
)
})
it('falls back to the billed-account lookup when no actor is pre-resolved', async () => {
const result = await preprocessExecution(baseOptions)
expect(result.success).toBe(true)
expect(result.actorUserId).toBe('billed-account-1')
expect(mockGetWorkspaceBilledAccountUserId).toHaveBeenCalledTimes(1)
})
})
+821
View File
@@ -0,0 +1,821 @@
import type { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
import { getActivelyBannedUserIds } from '@/lib/auth/ban'
import {
checkOrgMemberUsageLimit,
checkServerSideUsageLimits,
} from '@/lib/billing/calculations/usage-monitor'
import { reserveExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
import type { HighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import {
describeRetryableInfrastructureError,
isRetryableInfrastructureError,
} from '@/lib/core/errors/retryable-infrastructure'
import { getExecutionTimeout } from '@/lib/core/execution-limits'
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
import { LoggingSession, type SessionStartParams } from '@/lib/logs/execution/logging-session'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import type { CoreTriggerType } from '@/stores/logs/filters/types'
const logger = createLogger('ExecutionPreprocessing')
const BILLING_ERROR_MESSAGES = {
BILLING_REQUIRED:
'Unable to resolve billing account. This workflow cannot execute without a valid billing account.',
BILLING_ERROR_GENERIC: 'Error resolving billing account',
} as const
export interface PreprocessExecutionOptions {
// Required fields
workflowId: string
userId: string // The authenticated user ID
triggerType: CoreTriggerType
executionId: string
requestId: string
// Optional checks configuration
checkRateLimit?: boolean // Default: false for manual/chat, true for others
checkDeployment?: boolean // Default: true for non-manual triggers
skipUsageLimits?: boolean // Default: false (only use for test mode)
/**
* Skip the atomic in-flight concurrency reservation while still enforcing the
* usage-cost cap. Default: false. Set by surfaces that already bound and pace
* their own fan-out (e.g. table-cell dispatch, which is row-bounded, async
* rate-limited, and surfaces a graceful "wait/upgrade" state) so the
* reservation's 429 can't surface as a hard error there.
*/
skipConcurrencyReservation?: boolean
logPreprocessingErrors?: boolean // Default: true. When false, skip writing workflow_execution_logs error rows (caller surfaces failures itself, e.g. table cells)
// Context information
workspaceId?: string // If known, used for billing resolution
loggingSession?: LoggingSession // If provided, will be used for error logging
triggerData?: SessionStartParams['triggerData']
isResumeContext?: boolean // Deprecated: no billing fallback is allowed
useAuthenticatedUserAsActor?: boolean // If true, use the authenticated userId as actorUserId (for client-side executions and personal API keys)
/** @deprecated No longer used - background/async executions always use deployed state */
useDraftState?: boolean
/** Pre-fetched workflow row for caller context; preprocessing still re-checks active state. */
workflowRecord?: WorkflowRecord
/**
* Billing actor already resolved by an upstream gate earlier in the same
* request (e.g. the webhook route's preprocessing pass, whose result is carried
* as the job's userId). When provided, the redundant workspace billed-account
* lookup is skipped. The ban, deployment, usage, and rate-limit gates still run
* against this actor — only the resolution is reused, never a gate.
*/
resolvedActorUserId?: string
}
/**
* Result of preprocessing checks
*/
export interface PreprocessExecutionResult {
success: boolean
error?: {
message: string
statusCode: number
logCreated: boolean
retryable?: boolean
cause?: Record<string, unknown>
}
actorUserId?: string
workflowRecord?: WorkflowRecord
userSubscription?: SubscriptionInfo | null
rateLimitInfo?: {
allowed: boolean
remaining: number
resetAt: Date
}
executionTimeout?: {
sync: number
async: number
}
}
type WorkflowRecord = typeof workflow.$inferSelect
type SubscriptionInfo = HighestPrioritySubscription
export async function preprocessExecution(
options: PreprocessExecutionOptions
): Promise<PreprocessExecutionResult> {
const {
workflowId,
userId,
triggerType,
executionId,
requestId,
checkRateLimit = triggerType !== 'manual' && triggerType !== 'chat',
checkDeployment = triggerType !== 'manual',
skipUsageLimits = false,
skipConcurrencyReservation = false,
logPreprocessingErrors = true,
workspaceId: providedWorkspaceId,
loggingSession: providedLoggingSession,
triggerData,
isResumeContext: _isResumeContext = false,
useAuthenticatedUserAsActor = false,
workflowRecord: prefetchedWorkflowRecord,
resolvedActorUserId,
} = options
// When `logPreprocessingErrors` is false the caller surfaces failures itself
// (e.g. table cells use cell state / SSE), so skip the execution-log writes.
const recordPreprocessingError: typeof logPreprocessingError = (args) =>
logPreprocessingErrors ? logPreprocessingError(args) : Promise.resolve()
logger.info(`[${requestId}] Starting execution preprocessing`, {
workflowId,
userId,
triggerType,
executionId,
})
// ========== STEP 1: Validate Workflow Exists ==========
if (prefetchedWorkflowRecord && prefetchedWorkflowRecord.id !== workflowId) {
logger.error(`[${requestId}] Prefetched workflow record ID mismatch`, {
expected: workflowId,
received: prefetchedWorkflowRecord.id,
})
throw new Error(
`Prefetched workflow record ID mismatch: expected ${workflowId}, got ${prefetchedWorkflowRecord.id}`
)
}
let workflowRecord: WorkflowRecord | null = prefetchedWorkflowRecord ?? null
if (!workflowRecord) {
try {
workflowRecord = await getActiveWorkflowRecord(workflowId)
if (!workflowRecord) {
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
await recordPreprocessingError({
workflowId,
executionId,
triggerType,
requestId,
userId: 'unknown',
workspaceId: '',
errorMessage:
'Workflow not found. The workflow may have been deleted or is no longer accessible.',
loggingSession: providedLoggingSession,
triggerData,
})
return {
success: false,
error: {
message: 'Workflow not found',
statusCode: 404,
logCreated: true,
},
}
}
} catch (error) {
logger.error(`[${requestId}] Error fetching workflow`, { error, workflowId })
await recordPreprocessingError({
workflowId,
executionId,
triggerType,
requestId,
userId: userId || 'unknown',
workspaceId: providedWorkspaceId || '',
errorMessage: 'Internal error while fetching workflow',
loggingSession: providedLoggingSession,
triggerData,
})
return {
success: false,
error: {
message: 'Internal error while fetching workflow',
statusCode: 500,
logCreated: true,
retryable: isRetryableInfrastructureError(error),
cause: describeRetryableInfrastructureError(error),
},
}
}
} else if (workflowRecord.archivedAt) {
logger.warn(`[${requestId}] Prefetched workflow is archived: ${workflowId}`)
return {
success: false,
error: {
message: 'Workflow not found',
statusCode: 404,
logCreated: false,
},
}
} else {
const activeWorkflow = await getActiveWorkflowRecord(workflowId)
if (!activeWorkflow) {
logger.warn(`[${requestId}] Workflow archived before execution started: ${workflowId}`)
return {
success: false,
error: {
message: 'Workflow not found',
statusCode: 404,
logCreated: false,
},
}
}
workflowRecord = activeWorkflow
}
const workspaceId = workflowRecord.workspaceId || providedWorkspaceId || ''
if (!workspaceId) {
logger.warn(`[${requestId}] Workflow ${workflowId} has no workspaceId; execution blocked`)
return {
success: false,
error: {
message:
'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot execute.',
statusCode: 403,
logCreated: false,
},
}
}
// ========== STEP 2: Check Deployment Status ==========
// If workflow is not deployed and deployment is required, reject without logging.
// No log entry or cost should be created for calls to undeployed workflows
// since the workflow was never intended to run.
if (checkDeployment && !workflowRecord.isDeployed) {
logger.warn(`[${requestId}] Workflow not deployed: ${workflowId}`)
return {
success: false,
error: {
message: 'Workflow is not deployed',
statusCode: 403,
logCreated: false,
},
}
}
// ========== STEP 3: Resolve Billing Actor ==========
let actorUserId: string | null = null
try {
// For client-side executions and personal API keys, the authenticated
// user is the billing and permission actor — not the workspace owner.
if (useAuthenticatedUserAsActor && userId) {
actorUserId = userId
logger.info(`[${requestId}] Using authenticated user as actor: ${actorUserId}`)
}
/**
* Reuse an actor already resolved upstream this request (e.g. the webhook
* route's preprocessing) to skip the redundant workspace billed-account
* lookup. Gates below still run against this actor.
*/
if (!actorUserId && resolvedActorUserId) {
actorUserId = resolvedActorUserId
logger.info(`[${requestId}] Using pre-resolved billing actor: ${actorUserId}`)
}
if (!actorUserId && workspaceId) {
actorUserId = await getWorkspaceBilledAccountUserId(workspaceId)
if (actorUserId) {
logger.info(`[${requestId}] Using workspace billed account: ${actorUserId}`)
}
}
if (!actorUserId) {
const fallbackUserId = userId || 'unknown'
logger.warn(`[${requestId}] ${BILLING_ERROR_MESSAGES.BILLING_REQUIRED}`, {
workflowId,
workspaceId,
})
await recordPreprocessingError({
workflowId,
executionId,
triggerType,
requestId,
userId: fallbackUserId,
workspaceId,
errorMessage: BILLING_ERROR_MESSAGES.BILLING_REQUIRED,
loggingSession: providedLoggingSession,
triggerData,
})
return {
success: false,
error: {
message: 'Unable to resolve billing account',
statusCode: 500,
logCreated: true,
},
}
}
} catch (error) {
logger.error(`[${requestId}] Error resolving billing actor`, { error, workflowId })
const fallbackUserId = userId || 'unknown'
await recordPreprocessingError({
workflowId,
executionId,
triggerType,
requestId,
userId: fallbackUserId,
workspaceId,
errorMessage: BILLING_ERROR_MESSAGES.BILLING_ERROR_GENERIC,
loggingSession: providedLoggingSession,
triggerData,
})
return {
success: false,
error: {
message: 'Error resolving billing account',
statusCode: 500,
logCreated: true,
retryable: isRetryableInfrastructureError(error),
cause: describeRetryableInfrastructureError(error),
},
}
}
// ========== STEPS 3.56: Preflight Gates ==========
// Read-only gates (ban, subscription, usage) run concurrently; the stateful
// rate-limit gate runs after they pass. Precedence: ban 403 → usage 402 → rate 429.
/**
* A failing gate's deferred outcome: the response to return, plus an optional
* error-log write to flush before returning. Evaluated in precedence order.
*/
interface GateFailure {
response: PreprocessExecutionResult
recordError?: Parameters<typeof recordPreprocessingError>[0]
}
/** Usage figures captured by STEP 5 and reused by the STEP 7 reservation. */
interface UsageSnapshot {
currentUsage: number
limit: number
}
const banCheck = (async (): Promise<GateFailure | null> => {
// Blocks executions when the billing actor, the workflow owner, or the
// caller-provided userId (chat deployer, authenticated caller) has an
// active ban or a blocked email domain. The owner comes from the workflow
// record so schedules — which pass the 'unknown' sentinel — are covered.
const banCandidateIds = [actorUserId]
if (userId && userId !== 'unknown' && userId !== actorUserId) {
banCandidateIds.push(userId)
}
if (workflowRecord.userId && !banCandidateIds.includes(workflowRecord.userId)) {
banCandidateIds.push(workflowRecord.userId)
}
try {
const bannedUserIds = await getActivelyBannedUserIds(banCandidateIds)
if (bannedUserIds.length > 0) {
logger.warn(`[${requestId}] Execution blocked: banned account`, {
workflowId,
bannedUserIds,
triggerType,
})
return {
response: {
success: false,
error: {
message: 'Account suspended',
statusCode: 403,
logCreated: true,
},
},
recordError: {
workflowId,
executionId,
triggerType,
requestId,
userId: actorUserId,
workspaceId,
errorMessage: 'This account has been suspended. Workflow executions are blocked.',
loggingSession: providedLoggingSession,
triggerData,
},
}
}
return null
} catch (error) {
logger.error(`[${requestId}] Error checking account ban status`, { error, actorUserId })
return {
response: {
success: false,
error: {
message: 'Unable to verify account status. Execution blocked for security.',
statusCode: 500,
logCreated: true,
retryable: isRetryableInfrastructureError(error),
cause: describeRetryableInfrastructureError(error),
},
},
recordError: {
workflowId,
executionId,
triggerType,
requestId,
userId: actorUserId,
workspaceId,
errorMessage: 'Unable to verify account status. Execution blocked for security.',
loggingSession: providedLoggingSession,
triggerData,
},
}
}
})()
// ========== STEP 4: Get Subscription ==========
const subscriptionFetch = getHighestPrioritySubscription(actorUserId)
const [banFailure, userSubscription] = await Promise.all([banCheck, subscriptionFetch])
/**
* STEP 5: usage + per-member org usage gate. Returns the failure outcome (or
* `null` on pass/skip) plus the usage snapshot reused by the STEP 7 admission
* reservation. The snapshot is returned rather than written to an outer
* variable so concurrent gate tasks share no mutable state.
*/
const usageCheckTask = (async (): Promise<{
failure: GateFailure | null
snapshot: UsageSnapshot | null
}> => {
if (skipUsageLimits) return { failure: null, snapshot: null }
let snapshot: UsageSnapshot | null = null
try {
const usageCheck = await checkServerSideUsageLimits(actorUserId, userSubscription)
snapshot = { currentUsage: usageCheck.currentUsage, limit: usageCheck.limit }
if (usageCheck.isExceeded) {
logger.warn(
`[${requestId}] User ${actorUserId} has exceeded usage limits. Blocking execution.`,
{
currentUsage: usageCheck.currentUsage,
limit: usageCheck.limit,
workflowId,
triggerType,
}
)
return {
failure: {
response: {
success: false,
error: {
message:
usageCheck.message ||
'Usage limit exceeded. Please upgrade your plan to continue.',
statusCode: 402,
logCreated: true,
},
},
recordError: {
workflowId,
executionId,
triggerType,
requestId,
userId: actorUserId,
workspaceId,
errorMessage:
usageCheck.message ||
`Usage limit exceeded: $${usageCheck.currentUsage?.toFixed(2)} used of $${usageCheck.limit?.toFixed(2)} limit. Please upgrade your plan to continue.`,
loggingSession: providedLoggingSession,
triggerData,
},
},
snapshot,
}
}
// Per-member org-workspace cap (hosted-only). Independent, additive gate:
// blocks an individual member's executions in org-owned workspaces once
// their personal credit limit for the org is reached, even if the pooled
// org limit still has room.
const memberUsageCheck = await checkOrgMemberUsageLimit(actorUserId, workspaceId)
if (memberUsageCheck.isExceeded) {
const memberLimitMessage =
memberUsageCheck.message ||
'Member usage limit exceeded for this organization. Ask an organization admin to raise your credit limit to continue.'
logger.warn(
`[${requestId}] User ${actorUserId} exceeded their per-member org usage limit. Blocking execution.`,
{
currentUsage: memberUsageCheck.currentUsage,
limit: memberUsageCheck.limit,
workflowId,
triggerType,
}
)
return {
failure: {
response: {
success: false,
error: {
message: memberLimitMessage,
statusCode: 402,
logCreated: true,
},
},
recordError: {
workflowId,
executionId,
triggerType,
requestId,
userId: actorUserId,
workspaceId,
errorMessage: memberLimitMessage,
loggingSession: providedLoggingSession,
triggerData,
},
},
snapshot,
}
}
return { failure: null, snapshot }
} catch (error) {
logger.error(`[${requestId}] Error checking usage limits`, {
error,
actorUserId,
})
return {
failure: {
response: {
success: false,
error: {
message: 'Unable to determine usage limits. Execution blocked for security.',
statusCode: 500,
logCreated: true,
retryable: isRetryableInfrastructureError(error),
cause: describeRetryableInfrastructureError(error),
},
},
recordError: {
workflowId,
executionId,
triggerType,
requestId,
userId: actorUserId,
workspaceId,
errorMessage:
'Unable to determine usage limits. Execution blocked for security. Please contact support.',
loggingSession: providedLoggingSession,
triggerData,
},
},
snapshot,
}
}
})()
// ========== STEP 6: Check Rate Limits ==========
let rateLimitInfo: { allowed: boolean; remaining: number; resetAt: Date } | undefined
/**
* STEP 6: rate-limit gate. Unlike the other gates this one is NOT read-only —
* `checkRateLimitWithSubscription` consumes a token — so it is invoked
* sequentially only after the ban and usage gates pass, matching the original
* order. Running it eagerly or in parallel would debit rate-limit quota for
* requests that ban or usage rejects. Returns the failure outcome, or `null`
* on pass/skip; on a non-error outcome it populates `rateLimitInfo`.
*/
const runRateLimitGate = async (): Promise<GateFailure | null> => {
if (!checkRateLimit) return null
try {
const rateLimiter = new RateLimiter()
const info = await rateLimiter.checkRateLimitWithSubscription(
actorUserId,
userSubscription,
triggerType,
false // not async
)
rateLimitInfo = info
if (!info.allowed) {
logger.warn(`[${requestId}] Rate limit exceeded for user ${actorUserId}`, {
triggerType,
remaining: info.remaining,
resetAt: info.resetAt,
})
return {
response: {
success: false,
error: {
message: `Rate limit exceeded. Please try again later.`,
statusCode: 429,
logCreated: true,
},
},
recordError: {
workflowId,
executionId,
triggerType,
requestId,
userId: actorUserId,
workspaceId,
errorMessage: `Rate limit exceeded. ${info.remaining} requests remaining. Resets at ${info.resetAt.toISOString()}.`,
loggingSession: providedLoggingSession,
triggerData,
},
}
}
return null
} catch (error) {
logger.error(`[${requestId}] Error checking rate limits`, { error, actorUserId })
return {
response: {
success: false,
error: {
message: 'Error checking rate limits',
statusCode: 500,
logCreated: true,
retryable: isRetryableInfrastructureError(error),
cause: describeRetryableInfrastructureError(error),
},
},
recordError: {
workflowId,
executionId,
triggerType,
requestId,
userId: actorUserId,
workspaceId,
errorMessage: 'Error checking rate limits. Execution blocked for safety.',
loggingSession: providedLoggingSession,
triggerData,
},
}
}
}
const usageResult = await usageCheckTask
const usageSnapshot = usageResult.snapshot
const readGateFailure = banFailure ?? usageResult.failure
if (readGateFailure) {
if (readGateFailure.recordError) {
await recordPreprocessingError(readGateFailure.recordError)
}
return readGateFailure.response
}
const rateLimitFailure = await runRateLimitGate()
if (rateLimitFailure) {
if (rateLimitFailure.recordError) {
await recordPreprocessingError(rateLimitFailure.recordError)
}
return rateLimitFailure.response
}
/**
* STEP 7: Atomic admission reservation. Cost is only recorded once an
* execution finishes, so without this a burst of concurrent executions all
* observe the same pre-burst usage and all pass the gate above. Reserving
* bounds in-flight (un-costed) executions per billing entity. Done last so an
* earlier rejection never leaves a slot held; the slot is released at
* execution completion (see {@link LoggingSession}).
*/
if (!skipUsageLimits && !skipConcurrencyReservation && usageSnapshot) {
try {
const { reserved } = await reserveExecutionSlot({
userId: actorUserId,
executionId,
subscription: userSubscription,
currentUsage: usageSnapshot.currentUsage,
limit: usageSnapshot.limit,
})
if (!reserved) {
logger.warn(`[${requestId}] Admission reservation full for user ${actorUserId}`, {
workflowId,
triggerType,
})
await recordPreprocessingError({
workflowId,
executionId,
triggerType,
requestId,
userId: actorUserId,
workspaceId,
errorMessage:
'Too many concurrent executions in flight for this account. Please wait for in-progress runs to finish and try again.',
loggingSession: providedLoggingSession,
triggerData,
})
return {
success: false,
error: {
message:
'Too many concurrent executions in flight. Please wait for in-progress runs to finish and try again.',
statusCode: 429,
logCreated: true,
retryable: true,
},
}
}
} catch (error) {
logger.error(`[${requestId}] Unexpected error reserving admission slot`, {
error,
actorUserId,
})
}
}
// ========== SUCCESS: All Checks Passed ==========
logger.info(`[${requestId}] All preprocessing checks passed`, {
workflowId,
actorUserId,
triggerType,
})
const plan = userSubscription?.plan as SubscriptionPlan | undefined
return {
success: true,
actorUserId,
workflowRecord,
userSubscription,
rateLimitInfo,
executionTimeout: {
sync: getExecutionTimeout(plan, 'sync'),
async: getExecutionTimeout(plan, 'async'),
},
}
}
/**
* Helper function to log preprocessing errors to the database
*
* This ensures users can see why their workflow execution was blocked.
*/
async function logPreprocessingError(params: {
workflowId: string
executionId: string
triggerType: string
requestId: string
userId: string
workspaceId: string
errorMessage: string
loggingSession?: LoggingSession
triggerData?: SessionStartParams['triggerData']
}): Promise<void> {
const {
workflowId,
executionId,
triggerType,
requestId,
userId,
workspaceId,
errorMessage,
loggingSession,
triggerData,
} = params
if (!workspaceId) {
logger.warn(`[${requestId}] Cannot log preprocessing error: no workspaceId available`, {
workflowId,
executionId,
errorMessage,
})
return
}
try {
const session =
loggingSession || new LoggingSession(workflowId, executionId, triggerType, requestId)
await session.safeStart({
userId,
workspaceId,
variables: {},
triggerData,
})
await session.safeCompleteWithError({
error: {
message: errorMessage,
stackTrace: undefined,
},
traceSpans: [],
skipCost: true, // Preprocessing errors should not charge - no execution occurred
})
} catch (error) {
logger.error(`[${requestId}] Failed to log preprocessing error`, {
error,
workflowId,
executionId,
})
// Don't throw - error logging should not block the error response
}
}
@@ -0,0 +1,91 @@
/**
* @vitest-environment node
*/
import { loggingSessionMock } from '@sim/testing'
import { describe, expect, it, vi } from 'vitest'
const { mockGetWorkspaceBilledAccountUserId } = vi.hoisted(() => ({
mockGetWorkspaceBilledAccountUserId: vi.fn(),
}))
vi.mock('@sim/db', () => ({ db: {} }))
vi.mock('drizzle-orm', () => ({ eq: vi.fn() }))
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
checkServerSideUsageLimits: vi.fn(),
}))
vi.mock('@/lib/billing/core/subscription', () => ({
getHighestPrioritySubscription: vi.fn(),
}))
vi.mock('@/lib/core/execution-limits', () => ({
getExecutionTimeout: vi.fn(() => 0),
}))
vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({
RateLimiter: vi.fn(),
}))
vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
vi.mock('@/lib/workspaces/utils', () => ({
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
}))
vi.mock('@sim/platform-authz/workflow', () => ({
getActiveWorkflowRecord: vi.fn().mockResolvedValue({
id: 'workflow-1',
workspaceId: 'workspace-1',
isDeployed: true,
}),
}))
import { preprocessExecution } from './preprocessing'
describe('preprocessExecution webhook correlation logging', () => {
it('preserves webhook correlation when logging preprocessing failures', async () => {
mockGetWorkspaceBilledAccountUserId.mockResolvedValueOnce(null)
const loggingSession = {
safeStart: vi.fn().mockResolvedValue(true),
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
}
const correlation = {
executionId: 'execution-webhook-1',
requestId: 'request-webhook-1',
source: 'webhook' as const,
workflowId: 'workflow-1',
webhookId: 'webhook-1',
path: 'incoming/slack',
provider: 'slack',
triggerType: 'webhook',
}
const result = await preprocessExecution({
workflowId: 'workflow-1',
userId: 'unknown',
triggerType: 'webhook',
executionId: 'execution-webhook-1',
requestId: 'request-webhook-1',
loggingSession: loggingSession as any,
triggerData: { correlation },
workflowRecord: {
id: 'workflow-1',
workspaceId: 'workspace-1',
isDeployed: true,
} as any,
})
expect(result).toMatchObject({
success: false,
error: {
statusCode: 500,
logCreated: true,
},
})
expect(loggingSession.safeStart).toHaveBeenCalledWith({
userId: 'unknown',
workspaceId: 'workspace-1',
variables: {},
triggerData: { correlation },
})
})
})
@@ -0,0 +1,138 @@
import { createLogger, type Logger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import type { getRedisClient } from '@/lib/core/config/redis'
import { ExecutionResourceLimitError } from '@/lib/execution/resource-errors'
type RedisClient = NonNullable<ReturnType<typeof getRedisClient>>
const logger = createLogger('ExecutionRedisBudget')
const REDIS_BUDGET_PREFIX = 'execution:redis-budget:'
const MAX_SINGLE_REDIS_WRITE_BYTES = 8 * 1024 * 1024
const MAX_EXECUTION_REDIS_BYTES = 64 * 1024 * 1024
const MAX_USER_REDIS_BYTES = 256 * 1024 * 1024
const REDIS_BUDGET_TTL_SECONDS = 60 * 60
const RESERVE_REDIS_BYTES_SCRIPT = `
local bytes = tonumber(ARGV[1])
local execution_limit = tonumber(ARGV[2])
local user_limit = tonumber(ARGV[3])
local ttl_seconds = tonumber(ARGV[4])
local execution_current = tonumber(redis.call('GET', KEYS[1]) or '0')
if execution_limit > 0 and execution_current + bytes > execution_limit then
return {0, 'execution_redis_bytes', execution_current}
end
local user_current = 0
if #KEYS >= 2 then
user_current = tonumber(redis.call('GET', KEYS[2]) or '0')
if user_limit > 0 and user_current + bytes > user_limit then
return {0, 'user_redis_bytes', user_current}
end
end
redis.call('INCRBY', KEYS[1], bytes)
redis.call('EXPIRE', KEYS[1], ttl_seconds)
if #KEYS >= 2 then
redis.call('INCRBY', KEYS[2], bytes)
redis.call('EXPIRE', KEYS[2], ttl_seconds)
end
return {1, 'ok', execution_current + bytes, user_current + bytes}
`
const RELEASE_REDIS_BYTES_SCRIPT = `
local bytes = tonumber(ARGV[1])
for i = 1, #KEYS do
local next_value = redis.call('DECRBY', KEYS[i], bytes)
if next_value <= 0 then
redis.call('DEL', KEYS[i])
end
end
return 1
`
export type ExecutionRedisBudgetCategory = 'event_buffer' | 'base64_cache'
export interface ExecutionRedisBudgetReservation {
executionId: string
userId?: string
category: ExecutionRedisBudgetCategory
bytes: number
operation: string
logger?: Logger
}
export function getExecutionRedisBudgetLimits() {
return {
maxSingleWriteBytes: MAX_SINGLE_REDIS_WRITE_BYTES,
maxExecutionBytes: MAX_EXECUTION_REDIS_BYTES,
maxUserBytes: MAX_USER_REDIS_BYTES,
ttlSeconds: REDIS_BUDGET_TTL_SECONDS,
}
}
export function getExecutionRedisBudgetKeys(
reservation: ExecutionRedisBudgetReservation
): string[] {
const keys = [`${REDIS_BUDGET_PREFIX}execution:${reservation.executionId}`]
if (reservation.userId) {
keys.push(`${REDIS_BUDGET_PREFIX}user:${reservation.userId}`)
}
return keys
}
export async function reserveExecutionRedisBytes(
redis: RedisClient,
reservation: ExecutionRedisBudgetReservation
): Promise<void> {
if (reservation.bytes <= 0) return
const limits = getExecutionRedisBudgetLimits()
if (reservation.bytes > limits.maxSingleWriteBytes) {
throw new ExecutionResourceLimitError({
resource: 'redis_key_bytes',
attemptedBytes: reservation.bytes,
limitBytes: limits.maxSingleWriteBytes,
})
}
const keys = getExecutionRedisBudgetKeys(reservation)
const result = (await redis.eval(
RESERVE_REDIS_BYTES_SCRIPT,
keys.length,
...keys,
reservation.bytes,
limits.maxExecutionBytes,
limits.maxUserBytes,
limits.ttlSeconds
)) as [number, string, number | string | null]
const [allowed, resource, current] = result
if (allowed === 1) return
throw new ExecutionResourceLimitError({
resource: resource === 'user_redis_bytes' ? 'user_redis_bytes' : 'execution_redis_bytes',
attemptedBytes: reservation.bytes,
currentBytes: Number(current ?? 0),
limitBytes: resource === 'user_redis_bytes' ? limits.maxUserBytes : limits.maxExecutionBytes,
})
}
export async function releaseExecutionRedisBytes(
redis: RedisClient,
reservation: ExecutionRedisBudgetReservation
): Promise<void> {
if (reservation.bytes <= 0) return
try {
const keys = getExecutionRedisBudgetKeys(reservation)
await redis.eval(RELEASE_REDIS_BYTES_SCRIPT, keys.length, ...keys, reservation.bytes)
} catch (error) {
const log = reservation.logger ?? logger
log.warn('Failed to release execution Redis budget reservation', {
executionId: reservation.executionId,
userId: reservation.userId,
category: reservation.category,
operation: reservation.operation,
bytes: reservation.bytes,
error: toError(error).message,
})
}
}
+45
View File
@@ -0,0 +1,45 @@
export const EXECUTION_RESOURCE_LIMIT_CODE = 'execution_resource_limit_exceeded' as const
export type ExecutionResourceLimitResource =
| 'redis_key_bytes'
| 'execution_redis_bytes'
| 'user_redis_bytes'
| 'execution_payload_bytes'
export interface ExecutionResourceLimitDetails {
resource: ExecutionResourceLimitResource
attemptedBytes: number
limitBytes: number
currentBytes?: number
statusCode?: number
}
export class ExecutionResourceLimitError extends Error {
readonly code = EXECUTION_RESOURCE_LIMIT_CODE
readonly statusCode: number
readonly resource: ExecutionResourceLimitResource
readonly attemptedBytes: number
readonly limitBytes: number
readonly currentBytes?: number
constructor(details: ExecutionResourceLimitDetails) {
super('Execution memory limit exceeded. Reduce payload size and try again.')
this.name = 'ExecutionResourceLimitError'
this.resource = details.resource
this.attemptedBytes = details.attemptedBytes
this.limitBytes = details.limitBytes
this.currentBytes = details.currentBytes
this.statusCode = details.statusCode ?? (details.resource === 'user_redis_bytes' ? 429 : 413)
}
}
export function isExecutionResourceLimitError(
error: unknown
): error is ExecutionResourceLimitError {
return (
error instanceof ExecutionResourceLimitError ||
(typeof error === 'object' &&
error !== null &&
(error as { code?: unknown }).code === EXECUTION_RESOURCE_LIMIT_CODE)
)
}
@@ -0,0 +1,47 @@
import { createLogger } from '@sim/logger'
import type { SandboxBroker } from '@/lib/execution/sandbox/types'
import {
fetchWorkspaceFileBuffer,
getWorkspaceFile,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
const logger = createLogger('SandboxWorkspaceFileBroker')
interface WorkspaceFileArgs {
fileId: string
}
interface WorkspaceFileResult {
dataUri: string
}
/**
* Host-side broker that resolves a workspace file id into a base64 data URI.
*
* Exposed to isolate code through `__brokers.workspaceFile(fileId)` and wrapped
* by the task bootstrap as `getFileBase64(fileId)`.
*/
export const workspaceFileBroker: SandboxBroker<WorkspaceFileArgs, WorkspaceFileResult> = {
name: 'workspaceFile',
async handle(ctx, args) {
if (!args || typeof args.fileId !== 'string' || args.fileId.length === 0) {
throw new Error('workspaceFile broker requires a non-empty fileId')
}
if (!ctx.workspaceId) {
throw new Error('workspaceFile broker requires a workspaceId')
}
const record = await getWorkspaceFile(ctx.workspaceId, args.fileId)
if (!record) {
logger.warn('Workspace file not found for sandbox broker', {
workspaceId: ctx.workspaceId,
fileId: args.fileId,
})
throw new Error(`File not found: ${args.fileId}`)
}
const buffer = await fetchWorkspaceFileBuffer(record)
const mime = record.type || 'image/png'
return { dataUri: `data:${mime};base64,${buffer.toString('base64')}` }
},
}
@@ -0,0 +1,21 @@
/**
* Minimal isolate-side shim run at the top of every bundle entry.
*
* Must execute BEFORE `process/browser` because that shim captures
* `setTimeout` at module-init time. Timers themselves are installed by
* `isolated-vm-worker.cjs` (delegated to Node's real timers via
* `ivm.Reference` per laverdet/isolated-vm#136) BEFORE the bundle runs, so
* `process/browser` picks up the real delegated `setTimeout`.
*
* The only thing this file still does is alias `global -> globalThis` for
* UMD-style fallbacks inside the bundles. All other runtime surface
* (`console`, `TextEncoder`, `TextDecoder`, timers) is installed by the
* worker via `ivm.Callback` / `ivm.Reference` bridges to Node's native
* implementations — no hand-rolled polyfill logic lives in the isolate.
*/
const g: typeof globalThis & { global?: typeof globalThis } = globalThis
if (typeof g.global === 'undefined') g.global = globalThis
export {}
@@ -0,0 +1,134 @@
#!/usr/bin/env bun
/**
* Builds isolate-compatible bundles for the document-generation libraries.
*
* Each library is bundled with `target=browser, format=iife` so it can be
* evaluated inside a V8 isolate that has no Node APIs (`require`, `process`,
* `fs`). The emitted files attach their exports to `globalThis.__bundles[name]`
* and are checked in so production images don't need the bundler at runtime.
*
* Run via: `bun run build:sandbox-bundles`.
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createLogger } from '@sim/logger'
const logger = createLogger('SandboxBundleBuild')
interface BunBuildResult {
success: boolean
logs: unknown[]
outputs: Array<{ text: () => Promise<string> }>
}
interface BunBuildOptions {
entrypoints: string[]
target: string
format: string
minify: boolean
sourcemap: string
root: string
}
declare const Bun: { build: (opts: BunBuildOptions) => Promise<BunBuildResult> }
const HERE = dirname(fileURLToPath(import.meta.url))
const BUNDLES_DIR = HERE
const ENTRIES_DIR = join(HERE, '.entries')
const APP_SIM_ROOT = join(HERE, '..', '..', '..', '..')
interface BundleSpec {
/** Key on `globalThis.__bundles`. */
name: string
/** Short filename written under `bundles/<file>.cjs`. */
outFile: string
/** Source of the entry file bun will bundle. */
entry: string
}
const POLYFILLS_PATH = join(HERE, '_polyfills.ts')
const POLYFILL_PRELUDE = `
// Isolate-side polyfills must execute BEFORE any other import (process/browser
// captures setTimeout at module-init time). Keep this as the first import.
import '${POLYFILLS_PATH}'
import { Buffer as __BufferPolyfill } from 'buffer'
import * as __processPolyfill from 'process/browser'
if (typeof globalThis.Buffer === 'undefined') globalThis.Buffer = __BufferPolyfill
if (typeof globalThis.process === 'undefined') globalThis.process = __processPolyfill
`
const BUNDLES: ReadonlyArray<BundleSpec> = [
{
name: 'pdf-lib',
outFile: 'pdf-lib.cjs',
entry: `
${POLYFILL_PRELUDE}
import * as mod from 'pdf-lib'
globalThis.__bundles = globalThis.__bundles || {}
globalThis.__bundles['pdf-lib'] = mod
`,
},
{
name: 'docx',
outFile: 'docx.cjs',
entry: `
${POLYFILL_PRELUDE}
import * as mod from 'docx'
globalThis.__bundles = globalThis.__bundles || {}
globalThis.__bundles['docx'] = mod
`,
},
{
name: 'pptxgenjs',
outFile: 'pptxgenjs.cjs',
entry: `
${POLYFILL_PRELUDE}
import PptxGenJS from 'pptxgenjs'
globalThis.__bundles = globalThis.__bundles || {}
globalThis.__bundles['pptxgenjs'] = PptxGenJS
`,
},
]
async function main(): Promise<void> {
rmSync(ENTRIES_DIR, { recursive: true, force: true })
mkdirSync(ENTRIES_DIR, { recursive: true })
mkdirSync(BUNDLES_DIR, { recursive: true })
for (const spec of BUNDLES) {
const entryPath = join(ENTRIES_DIR, `${spec.name}.entry.ts`)
writeFileSync(entryPath, spec.entry, 'utf-8')
const result = await Bun.build({
entrypoints: [entryPath],
target: 'browser',
format: 'iife',
minify: true,
sourcemap: 'none',
root: APP_SIM_ROOT,
})
if (!result.success) {
for (const log of result.logs) {
logger.error(String(log))
}
throw new Error(`Failed to build sandbox bundle: ${spec.name}`)
}
if (result.outputs.length === 0) {
throw new Error(`No output produced for sandbox bundle: ${spec.name}`)
}
const code = await result.outputs[0].text()
const banner = `// sandbox bundle: ${spec.name}\n// generated by apps/sim/lib/execution/sandbox/bundles/build.ts\n// do not edit by hand. run \`bun run build:sandbox-bundles\` to regenerate.\n`
writeFileSync(join(BUNDLES_DIR, spec.outFile), banner + code, 'utf-8')
logger.info(`built ${spec.outFile} (${code.length.toLocaleString()} chars)`)
}
rmSync(ENTRIES_DIR, { recursive: true, force: true })
}
main().catch((err) => {
logger.error('sandbox bundle build failed', err)
process.exit(1)
})
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
@@ -0,0 +1,27 @@
import type { SandboxTask, SandboxTaskInput } from '@/lib/execution/sandbox/types'
/**
* Helper that preserves the task's input type through declaration.
* Mirrors the `task(...)` / `defineConfig(...)` pattern used elsewhere in the
* codebase so sandbox tasks look familiar next to trigger.dev tasks.
*/
export function defineSandboxTask<TInput extends SandboxTaskInput = SandboxTaskInput>(
task: SandboxTask<TInput>
): SandboxTask<TInput> {
if (!task.id || !/^[a-z][a-z0-9-]*$/.test(task.id)) {
throw new Error(`Sandbox task id must be kebab-case: got "${task.id}"`)
}
const brokerNames = new Set<string>()
for (const broker of task.brokers) {
if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(broker.name)) {
throw new Error(
`Sandbox broker name must be a valid JS identifier: got "${broker.name}" on task "${task.id}"`
)
}
if (brokerNames.has(broker.name)) {
throw new Error(`Duplicate broker name "${broker.name}" on task "${task.id}"`)
}
brokerNames.add(broker.name)
}
return task
}
+138
View File
@@ -0,0 +1,138 @@
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import {
executeInIsolatedVM,
type IsolatedVMBrokerHandler,
type IsolatedVMExecutionRequest,
} from '@/lib/execution/isolated-vm'
import type { SandboxBrokerContext, SandboxTaskInput } from '@/lib/execution/sandbox/types'
import { getSandboxTask, type SandboxTaskId } from '@/sandbox-tasks/registry'
const logger = createLogger('SandboxRunTask')
export interface RunSandboxTaskOptions {
/**
* Owner key used by the isolated-vm pool for fairness + distributed leases.
* Typically `user:<userId>` or `workspace:<workspaceId>`.
*/
ownerKey?: string
/** Optional AbortSignal to cancel the execution early. */
signal?: AbortSignal
}
/**
* Thrown when the sandbox failure is attributable to the caller — user code
* errors (SyntaxError, ReferenceError, user-thrown exceptions), timeouts from
* user code, client aborts, or per-owner rate limits. Callers should translate
* this into a 4xx response so genuine 5xx remains a signal of server health.
*
* System-origin failures (worker crash, IPC failure, pool saturation, task
* misconfig) are tagged with `isSystemError` at the isolated-vm layer and
* surface as a plain `Error` → 500.
*/
export class SandboxUserCodeError extends Error {
constructor(message: string, name: string, stack?: string) {
super(message)
this.name = name || 'SandboxUserCodeError'
if (stack) this.stack = stack
}
}
/**
* Executes a sandbox task inside the shared isolated-vm pool and returns the
* binary result buffer. Throws with a human-readable message if the task fails
* so callers can propagate the error verbatim to UI.
*/
export async function runSandboxTask<TInput extends SandboxTaskInput>(
taskId: SandboxTaskId,
input: TInput,
options: RunSandboxTaskOptions = {}
): Promise<Buffer> {
const task = getSandboxTask(taskId)
const requestId = generateShortId(12)
const brokerContext: SandboxBrokerContext = {
workspaceId: input.workspaceId,
requestId,
}
const brokers: Record<string, IsolatedVMBrokerHandler> = {}
for (const broker of task.brokers) {
brokers[broker.name] = (args) => broker.handle(brokerContext, args)
}
const request: IsolatedVMExecutionRequest = {
code: input.code,
params: {},
envVars: {},
contextVariables: {},
timeoutMs: task.timeoutMs,
requestId,
ownerKey: options.ownerKey,
ownerWeight: 1,
task: {
id: task.id,
bundles: [...task.bundles],
bootstrap: task.bootstrap,
brokers: task.brokers.map((b) => b.name),
finalize: task.finalize,
},
}
const start = Date.now()
const result = await executeInIsolatedVM(request, { brokers, signal: options.signal })
const elapsedMs = Date.now() - start
// Phase timings come from the worker (see executeTask). `queue` is the
// gap between client call and worker-side start — useful for diagnosing
// pool saturation vs. isolate-internal slowness.
const queueMs = result.timings ? Math.max(0, elapsedMs - result.timings.total) : undefined
if (result.error) {
const isSystemError = result.error.isSystemError === true
const logFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger)
logFn('Sandbox task failed', {
taskId,
requestId,
workspaceId: input.workspaceId,
elapsedMs,
queueMs,
timings: result.timings,
error: result.error.message,
errorName: result.error.name,
isSystemError,
})
if (isSystemError) {
const err = new Error(result.error.message)
err.name = result.error.name || 'SandboxSystemError'
if (result.error.stack) err.stack = result.error.stack
throw err
}
throw new SandboxUserCodeError(
result.error.message,
result.error.name || 'SandboxTaskError',
result.error.stack
)
}
if (typeof result.bytesBase64 !== 'string' || result.bytesBase64.length === 0) {
logger.error('Sandbox task returned no bytes', {
taskId,
requestId,
workspaceId: input.workspaceId,
timings: result.timings,
})
throw new Error(`Sandbox task "${taskId}" finalize did not return any bytes`)
}
const bytes = Buffer.from(result.bytesBase64, 'base64')
logger.info('Sandbox task completed', {
taskId,
requestId,
workspaceId: input.workspaceId,
elapsedMs,
queueMs,
timings: result.timings,
bytes: bytes.length,
})
return task.toResult(new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength), input)
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Types for the sandbox task system.
*
* A `SandboxTask` is a recipe that tells the isolated-vm pool how to run a
* particular kind of user code: which pre-built library bundles to install,
* which host-side brokers to expose, and how to serialize the final result.
*/
export type SandboxBundleName = 'pptxgenjs' | 'docx' | 'pdf-lib'
export interface SandboxBroker<TArgs = unknown, TResult = unknown> {
/**
* Name the isolate-side bootstrap references (e.g. `__brokers.workspaceFile`).
* Must be a plain JS identifier segment.
*/
name: string
/**
* Host-side handler invoked when the isolate calls the broker.
* `ctx` carries per-execution metadata (workspaceId, requestId, etc.).
*/
handle(ctx: SandboxBrokerContext, args: TArgs): Promise<TResult>
}
export interface SandboxBrokerContext {
workspaceId: string
requestId: string
}
export interface SandboxTaskInput {
workspaceId: string
code: string
}
export interface SandboxTask<TInput extends SandboxTaskInput = SandboxTaskInput> {
/** Kebab-case stable identifier, used for logging + lookups. */
id: string
/** Script execution timeout inside the isolate. */
timeoutMs: number
/** Library bundles to load as isolate globals before the bootstrap runs. */
bundles: ReadonlyArray<SandboxBundleName>
/** Host-side brokers this task is allowed to call from inside the isolate. */
brokers: ReadonlyArray<SandboxBroker>
/**
* JS code run inside the isolate after bundles are installed and before
* user code. Should hoist bundle globals to friendly names and install any
* helper functions users expect (e.g. `getFileBase64`).
*/
bootstrap: string
/**
* JS source that, when evaluated inside an async IIFE after user code, must
* return a `Uint8Array`. The bytes are transferred out via `ExternalCopy`.
*/
finalize: string
/** Host-side transform from raw isolate bytes to the caller's return type. */
toResult(bytes: Uint8Array, input: TInput): Buffer
}