d25d482dc2
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
1937 lines
58 KiB
TypeScript
1937 lines
58 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage } from '@sim/utils/errors'
|
|
import { type NextRequest, NextResponse } from 'next/server'
|
|
import { functionExecuteContract } from '@/lib/api/contracts'
|
|
import { parseRequest } from '@/lib/api/server'
|
|
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
|
import {
|
|
FORMAT_TO_CONTENT_TYPE,
|
|
getOutputFileDeclarations,
|
|
normalizeOutputWorkspaceFileName,
|
|
type OutputFileDeclaration,
|
|
resolveOutputFormat,
|
|
} from '@/lib/copilot/request/tools/files'
|
|
import {
|
|
validateWorkspaceFileWriteTarget,
|
|
writeWorkspaceFileByPath,
|
|
} from '@/lib/copilot/vfs/resource-writer'
|
|
import { isE2bEnabled } from '@/lib/core/config/env-flags'
|
|
import { generateRequestId } from '@/lib/core/utils/request'
|
|
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
|
import { executeInE2B, executeShellInE2B } from '@/lib/execution/e2b'
|
|
import { executeInIsolatedVM, type IsolatedVMBrokerHandler } from '@/lib/execution/isolated-vm'
|
|
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
|
|
import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys'
|
|
import {
|
|
isLargeArrayManifest,
|
|
materializeLargeArrayManifest,
|
|
} from '@/lib/execution/payloads/large-array-manifest'
|
|
import { containsLargeValueRef, isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
|
|
import {
|
|
MAX_FUNCTION_INLINE_BYTES,
|
|
MAX_INLINE_MATERIALIZATION_BYTES,
|
|
readUserFileContent,
|
|
unavailableLargeValueError,
|
|
} from '@/lib/execution/payloads/materialization.server'
|
|
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
|
|
import { materializeLargeValueRef } from '@/lib/execution/payloads/store'
|
|
import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors'
|
|
import { getWorkflowById } from '@/lib/workflows/utils'
|
|
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
|
|
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
|
|
import { formatLiteralForCode } from '@/executor/utils/code-formatting'
|
|
import {
|
|
createEnvVarPattern,
|
|
createReferencePattern,
|
|
createWorkflowVariablePattern,
|
|
} from '@/executor/utils/reference-validation'
|
|
export const dynamic = 'force-dynamic'
|
|
export const runtime = 'nodejs'
|
|
|
|
const logger = createLogger('FunctionExecuteAPI')
|
|
|
|
const TAG_PATTERN = createReferencePattern()
|
|
|
|
const E2B_JS_WRAPPER_LINES = 3
|
|
const E2B_PYTHON_WRAPPER_LINES = 1
|
|
const MAX_SANDBOX_OUTPUT_FILES = 20
|
|
const MAX_SANDBOX_OUTPUT_BYTES = 50 * 1024 * 1024
|
|
|
|
/** Matches valid JS identifier names (letters, digits, underscore; no leading digit). */
|
|
const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/
|
|
|
|
/** ES2023 reserved words — using these as `const` variable names produces a SyntaxError. */
|
|
const JS_RESERVED_WORDS = new Set([
|
|
'break',
|
|
'case',
|
|
'catch',
|
|
'class',
|
|
'const',
|
|
'continue',
|
|
'debugger',
|
|
'default',
|
|
'delete',
|
|
'do',
|
|
'else',
|
|
'export',
|
|
'extends',
|
|
'false',
|
|
'finally',
|
|
'for',
|
|
'function',
|
|
'if',
|
|
'import',
|
|
'in',
|
|
'instanceof',
|
|
'let',
|
|
'new',
|
|
'null',
|
|
'return',
|
|
'static',
|
|
'super',
|
|
'switch',
|
|
'this',
|
|
'throw',
|
|
'true',
|
|
'try',
|
|
'typeof',
|
|
'var',
|
|
'void',
|
|
'while',
|
|
'with',
|
|
'yield',
|
|
'enum',
|
|
'await',
|
|
'implements',
|
|
'interface',
|
|
'package',
|
|
'private',
|
|
'protected',
|
|
'public',
|
|
])
|
|
|
|
type TypeScriptModule = typeof import('@typescript/typescript6')
|
|
|
|
let typescriptModulePromise: Promise<TypeScriptModule> | null = null
|
|
|
|
async function loadTypeScriptModule(): Promise<TypeScriptModule> {
|
|
if (!typescriptModulePromise) {
|
|
typescriptModulePromise = import('@typescript/typescript6').then(
|
|
(mod) => (mod?.default ?? mod) as TypeScriptModule,
|
|
(error) => {
|
|
typescriptModulePromise = null
|
|
throw error
|
|
}
|
|
)
|
|
}
|
|
|
|
return typescriptModulePromise
|
|
}
|
|
|
|
async function extractJavaScriptImports(
|
|
code: string
|
|
): Promise<{ imports: string; remainingCode: string; importLineCount: number }> {
|
|
try {
|
|
const tsModule = await loadTypeScriptModule()
|
|
|
|
const sourceFile = tsModule.createSourceFile(
|
|
'user-code.js',
|
|
code,
|
|
tsModule.ScriptTarget.Latest,
|
|
true,
|
|
tsModule.ScriptKind.JS
|
|
)
|
|
|
|
const importSegments: Array<{ text: string; start: number; end: number }> = []
|
|
|
|
sourceFile.statements.forEach((statement) => {
|
|
if (
|
|
tsModule.isImportDeclaration(statement) ||
|
|
tsModule.isImportEqualsDeclaration(statement)
|
|
) {
|
|
importSegments.push({
|
|
text: statement.getFullText(sourceFile).trim(),
|
|
start: statement.getFullStart(),
|
|
end: statement.getEnd(),
|
|
})
|
|
}
|
|
})
|
|
|
|
if (importSegments.length === 0) {
|
|
return { imports: '', remainingCode: code, importLineCount: 0 }
|
|
}
|
|
|
|
importSegments.sort((a, b) => a.start - b.start)
|
|
|
|
const imports = importSegments.map((segment) => segment.text).join('\n')
|
|
|
|
let cursor = 0
|
|
const parts: string[] = []
|
|
let importLineCount = 0
|
|
|
|
for (const segment of importSegments) {
|
|
if (segment.start > cursor) {
|
|
parts.push(code.slice(cursor, segment.start))
|
|
}
|
|
|
|
const removedSegment = code.slice(segment.start, segment.end)
|
|
importLineCount += removedSegment.split('\n').length - 1
|
|
|
|
const newlinePlaceholder = removedSegment.replace(/[^\n]/g, '')
|
|
parts.push(newlinePlaceholder)
|
|
|
|
cursor = segment.end
|
|
}
|
|
|
|
if (cursor < code.length) {
|
|
parts.push(code.slice(cursor))
|
|
}
|
|
|
|
const remainingCode = parts.join('')
|
|
|
|
return { imports, remainingCode, importLineCount: Math.max(importLineCount, 0) }
|
|
} catch (error) {
|
|
logger.error('Failed to extract JavaScript imports', { error })
|
|
return { imports: '', remainingCode: code, importLineCount: 0 }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enhanced error information interface
|
|
*/
|
|
interface EnhancedError {
|
|
message: string
|
|
line?: number
|
|
column?: number
|
|
stack?: string
|
|
name: string
|
|
originalError: any
|
|
lineContent?: string
|
|
}
|
|
|
|
/**
|
|
* Extract enhanced error information from VM execution errors
|
|
*/
|
|
function extractEnhancedError(
|
|
error: any,
|
|
userCodeStartLine: number,
|
|
userCode?: string
|
|
): EnhancedError {
|
|
const enhanced: EnhancedError = {
|
|
message: error.message || 'Unknown error',
|
|
name: error.name || 'Error',
|
|
originalError: error,
|
|
}
|
|
|
|
if (error.stack) {
|
|
enhanced.stack = error.stack
|
|
|
|
const stackLines: string[] = error.stack.split('\n')
|
|
|
|
for (const line of stackLines) {
|
|
let match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
|
|
|
if (!match) {
|
|
match = line.match(/at\s+user-function\.js:(\d+):(\d+)/)
|
|
}
|
|
|
|
if (match) {
|
|
const stackLine = Number.parseInt(match[1], 10)
|
|
const stackColumn = match[2] ? Number.parseInt(match[2], 10) : undefined
|
|
|
|
const adjustedLine = stackLine - userCodeStartLine + 1
|
|
|
|
const isWrapperSyntaxError =
|
|
stackLine > userCodeStartLine &&
|
|
error.name === 'SyntaxError' &&
|
|
(error.message.includes('Unexpected token') ||
|
|
error.message.includes('Unexpected end of input'))
|
|
|
|
if (isWrapperSyntaxError && userCode) {
|
|
const codeLines = userCode.split('\n')
|
|
const lastUserLine = codeLines.length
|
|
enhanced.line = lastUserLine
|
|
enhanced.column = codeLines[lastUserLine - 1]?.length || 0
|
|
enhanced.lineContent = codeLines[lastUserLine - 1]?.trim()
|
|
break
|
|
}
|
|
|
|
if (adjustedLine > 0) {
|
|
enhanced.line = adjustedLine
|
|
enhanced.column = stackColumn
|
|
|
|
if (userCode) {
|
|
const codeLines = userCode.split('\n')
|
|
if (adjustedLine <= codeLines.length) {
|
|
enhanced.lineContent = codeLines[adjustedLine - 1]?.trim()
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
if (stackLine <= userCodeStartLine) {
|
|
enhanced.line = stackLine
|
|
enhanced.column = stackColumn
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
const cleanedStackLines: string[] = stackLines
|
|
.filter(
|
|
(line: string) =>
|
|
line.includes('user-function.js') ||
|
|
(!line.includes('vm.js') && !line.includes('internal/'))
|
|
)
|
|
.map((line: string) => line.replace(/\s+at\s+/, ' at '))
|
|
|
|
if (cleanedStackLines.length > 0) {
|
|
enhanced.stack = cleanedStackLines.join('\n')
|
|
}
|
|
}
|
|
|
|
return enhanced
|
|
}
|
|
|
|
/**
|
|
* Parse and format E2B error message
|
|
* Removes E2B-specific line references and adds correct user line numbers
|
|
*/
|
|
function formatE2BError(
|
|
errorMessage: string,
|
|
errorOutput: string,
|
|
language: CodeLanguage,
|
|
userCode: string,
|
|
prologueLineCount: number
|
|
): { formattedError: string; cleanedOutput: string } {
|
|
const wrapperLines =
|
|
language === CodeLanguage.Python ? E2B_PYTHON_WRAPPER_LINES : E2B_JS_WRAPPER_LINES
|
|
const totalOffset = prologueLineCount + wrapperLines
|
|
|
|
let userLine: number | undefined
|
|
let cleanErrorType = ''
|
|
let cleanErrorMsg = ''
|
|
|
|
if (language === CodeLanguage.Python) {
|
|
const cellMatch = errorOutput.match(/Cell In\[\d+\], line (\d+)/)
|
|
if (cellMatch) {
|
|
const originalLine = Number.parseInt(cellMatch[1], 10)
|
|
userLine = originalLine - totalOffset
|
|
}
|
|
|
|
cleanErrorMsg = errorMessage
|
|
.replace(/\s*\(detected at line \d+\)/g, '')
|
|
.replace(/\s*\([^)]+\.py, line \d+\)/g, '')
|
|
.trim()
|
|
} else if (language === CodeLanguage.JavaScript) {
|
|
const firstLineEnd = errorMessage.indexOf('\n')
|
|
const firstLine = firstLineEnd > 0 ? errorMessage.substring(0, firstLineEnd) : errorMessage
|
|
|
|
const jsErrorMatch = firstLine.match(/^(\w+Error):\s*[^:]+:\s*([^(]+)\.\s*\((\d+):(\d+)\)/)
|
|
if (jsErrorMatch) {
|
|
cleanErrorType = jsErrorMatch[1]
|
|
cleanErrorMsg = jsErrorMatch[2].trim()
|
|
const originalLine = Number.parseInt(jsErrorMatch[3], 10)
|
|
userLine = originalLine - totalOffset
|
|
} else {
|
|
const arrowMatch = errorMessage.match(/^>\s*(\d+)\s*\|/m)
|
|
if (arrowMatch) {
|
|
const originalLine = Number.parseInt(arrowMatch[1], 10)
|
|
userLine = originalLine - totalOffset
|
|
}
|
|
const errorMatch = firstLine.match(/^(\w+Error):\s*(.+)/)
|
|
if (errorMatch) {
|
|
cleanErrorType = errorMatch[1]
|
|
cleanErrorMsg = errorMatch[2]
|
|
.replace(/^[^:]+:\s*/, '') // Remove file path
|
|
.replace(/\s*\(\d+:\d+\)\s*$/, '') // Remove line:col at end
|
|
.trim()
|
|
} else {
|
|
cleanErrorMsg = firstLine
|
|
}
|
|
}
|
|
}
|
|
|
|
const finalErrorMsg =
|
|
cleanErrorType && cleanErrorMsg
|
|
? `${cleanErrorType}: ${cleanErrorMsg}`
|
|
: cleanErrorMsg || errorMessage
|
|
|
|
let formattedError = finalErrorMsg
|
|
if (userLine && userLine > 0) {
|
|
const codeLines = userCode.split('\n')
|
|
// Clamp userLine to the actual user code range
|
|
const actualUserLine = Math.min(userLine, codeLines.length)
|
|
if (actualUserLine > 0 && actualUserLine <= codeLines.length) {
|
|
const lineContent = codeLines[actualUserLine - 1]?.trim()
|
|
if (lineContent) {
|
|
formattedError = `Line ${actualUserLine}: \`${lineContent}\` - ${finalErrorMsg}`
|
|
} else {
|
|
formattedError = `Line ${actualUserLine} - ${finalErrorMsg}`
|
|
}
|
|
}
|
|
}
|
|
|
|
const cleanedOutput = finalErrorMsg
|
|
|
|
return { formattedError, cleanedOutput }
|
|
}
|
|
|
|
/**
|
|
* Create a detailed error message for users
|
|
*/
|
|
function createUserFriendlyErrorMessage(
|
|
enhanced: EnhancedError,
|
|
requestId: string,
|
|
userCode?: string
|
|
): string {
|
|
let errorMessage = enhanced.message
|
|
|
|
if (enhanced.line !== undefined) {
|
|
let lineInfo = `Line ${enhanced.line}`
|
|
|
|
// Add the actual line content if available
|
|
if (enhanced.lineContent) {
|
|
lineInfo += `: \`${enhanced.lineContent}\``
|
|
}
|
|
|
|
errorMessage = `${lineInfo} - ${errorMessage}`
|
|
} else {
|
|
if (enhanced.stack) {
|
|
const stackMatch = enhanced.stack.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
|
if (stackMatch) {
|
|
const line = Number.parseInt(stackMatch[1], 10)
|
|
let lineInfo = `Line ${line}`
|
|
|
|
if (userCode) {
|
|
const codeLines = userCode.split('\n')
|
|
if (line <= codeLines.length) {
|
|
const lineContent = codeLines[line - 1]?.trim()
|
|
if (lineContent) {
|
|
lineInfo += `: \`${lineContent}\``
|
|
}
|
|
}
|
|
}
|
|
|
|
errorMessage = `${lineInfo} - ${errorMessage}`
|
|
}
|
|
}
|
|
}
|
|
|
|
if (enhanced.name !== 'Error') {
|
|
const errorTypePrefix =
|
|
enhanced.name === 'SyntaxError'
|
|
? 'Syntax Error'
|
|
: enhanced.name === 'TypeError'
|
|
? 'Type Error'
|
|
: enhanced.name === 'ReferenceError'
|
|
? 'Reference Error'
|
|
: enhanced.name
|
|
|
|
if (!errorMessage.toLowerCase().includes(errorTypePrefix.toLowerCase())) {
|
|
errorMessage = `${errorTypePrefix}: ${errorMessage}`
|
|
}
|
|
}
|
|
|
|
return errorMessage
|
|
}
|
|
|
|
function getErrorDisplayCode(sourceCode: string | undefined, resolvedCode: string): string {
|
|
return sourceCode && sourceCode.length > 0 ? sourceCode : resolvedCode
|
|
}
|
|
|
|
function getLineContent(code: string, line: number | undefined): string | undefined {
|
|
if (line === undefined || line < 1) {
|
|
return undefined
|
|
}
|
|
|
|
return code.split('\n')[line - 1]?.trim()
|
|
}
|
|
|
|
function getErrorDisplayMessage(
|
|
message: string,
|
|
sourceCode: string | undefined,
|
|
resolvedCode: string
|
|
): string {
|
|
if (!sourceCode || sourceCode === resolvedCode || !resolvedCode.includes('__blockRef_')) {
|
|
return message
|
|
}
|
|
|
|
return message.replace(/\s+["']globalThis["']/g, '')
|
|
}
|
|
|
|
function resolveWorkflowVariables(
|
|
code: string,
|
|
workflowVariables: Record<string, any>,
|
|
contextVariables: Record<string, any>
|
|
): string {
|
|
let resolvedCode = code
|
|
|
|
const regex = createWorkflowVariablePattern()
|
|
let match: RegExpExecArray | null
|
|
const replacements: Array<{
|
|
match: string
|
|
index: number
|
|
variableName: string
|
|
variableValue: unknown
|
|
}> = []
|
|
|
|
while ((match = regex.exec(code)) !== null) {
|
|
const variableName = match[1].trim()
|
|
|
|
const foundVariable = Object.entries(workflowVariables).find(
|
|
([_, variable]) => normalizeName(variable.name || '') === variableName
|
|
)
|
|
|
|
if (!foundVariable) {
|
|
const availableVars = Object.values(workflowVariables)
|
|
.map((v) => v.name)
|
|
.filter(Boolean)
|
|
throw new Error(
|
|
`Variable "${variableName}" doesn't exist.` +
|
|
(availableVars.length > 0 ? ` Available: ${availableVars.join(', ')}` : '')
|
|
)
|
|
}
|
|
|
|
const variable = foundVariable[1]
|
|
let variableValue: unknown = variable.value
|
|
|
|
if (variable.value !== undefined && variable.value !== null) {
|
|
const type = variable.type === 'string' ? 'plain' : variable.type
|
|
|
|
if (type === 'number') {
|
|
variableValue = Number(variableValue)
|
|
} else if (type === 'boolean') {
|
|
if (typeof variableValue === 'boolean') {
|
|
// Already a boolean, keep as-is
|
|
} else {
|
|
const normalized = String(variableValue).toLowerCase().trim()
|
|
variableValue = normalized === 'true'
|
|
}
|
|
} else if (type === 'json' && typeof variableValue === 'string') {
|
|
try {
|
|
variableValue = JSON.parse(variableValue)
|
|
} catch {
|
|
// Keep as-is
|
|
}
|
|
}
|
|
}
|
|
|
|
replacements.push({
|
|
match: match[0],
|
|
index: match.index,
|
|
variableName,
|
|
variableValue,
|
|
})
|
|
}
|
|
|
|
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
const { match: matchStr, index, variableName, variableValue } = replacements[i]
|
|
|
|
const safeVarName = `__variable_${variableName.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
|
contextVariables[safeVarName] = variableValue
|
|
resolvedCode =
|
|
resolvedCode.slice(0, index) + safeVarName + resolvedCode.slice(index + matchStr.length)
|
|
}
|
|
|
|
return resolvedCode
|
|
}
|
|
|
|
function resolveEnvironmentVariables(
|
|
code: string,
|
|
params: Record<string, any>,
|
|
envVars: Record<string, string>,
|
|
contextVariables: Record<string, any>
|
|
): string {
|
|
let resolvedCode = code
|
|
|
|
const regex = createEnvVarPattern()
|
|
let match: RegExpExecArray | null
|
|
const replacements: Array<{ match: string; index: number; varName: string; varValue: string }> =
|
|
[]
|
|
|
|
const resolverVars: Record<string, string> = {}
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
resolverVars[key] = String(value)
|
|
}
|
|
})
|
|
Object.entries(envVars).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
resolverVars[key] = value
|
|
}
|
|
})
|
|
|
|
while ((match = regex.exec(code)) !== null) {
|
|
const varName = match[1].trim()
|
|
|
|
if (!(varName in resolverVars)) {
|
|
continue
|
|
}
|
|
|
|
replacements.push({
|
|
match: match[0],
|
|
index: match.index,
|
|
varName,
|
|
varValue: resolverVars[varName],
|
|
})
|
|
}
|
|
|
|
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
const { match: matchStr, index, varName, varValue } = replacements[i]
|
|
|
|
const safeVarName = `__var_${varName.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
|
contextVariables[safeVarName] = varValue
|
|
resolvedCode =
|
|
resolvedCode.slice(0, index) + safeVarName + resolvedCode.slice(index + matchStr.length)
|
|
}
|
|
|
|
return resolvedCode
|
|
}
|
|
|
|
function resolveTagVariables(
|
|
code: string,
|
|
blockData: Record<string, unknown>,
|
|
blockNameMapping: Record<string, string>,
|
|
blockOutputSchemas: Record<string, OutputSchema>,
|
|
contextVariables: Record<string, unknown>,
|
|
language = 'javascript'
|
|
): string {
|
|
let resolvedCode = code
|
|
const undefinedLiteral = language === 'python' ? 'None' : 'undefined'
|
|
|
|
const tagMatches = resolvedCode.match(TAG_PATTERN) || []
|
|
|
|
for (const match of tagMatches) {
|
|
const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim()
|
|
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
|
|
const blockName = pathParts[0]
|
|
const fieldPath = pathParts.slice(1)
|
|
|
|
const result = resolveBlockReference(blockName, fieldPath, {
|
|
blockNameMapping,
|
|
blockData,
|
|
blockOutputSchemas,
|
|
})
|
|
|
|
if (!result) {
|
|
continue
|
|
}
|
|
|
|
let tagValue = result.value
|
|
|
|
if (tagValue === undefined) {
|
|
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), undefinedLiteral)
|
|
continue
|
|
}
|
|
|
|
if (typeof tagValue === 'string') {
|
|
const trimmed = tagValue.trimStart()
|
|
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
try {
|
|
tagValue = JSON.parse(tagValue)
|
|
} catch {
|
|
// Keep as string if not valid JSON
|
|
}
|
|
}
|
|
}
|
|
|
|
const safeVarName = `__tag_${tagName.replace(/_/g, '_1').replace(/\./g, '_0')}`
|
|
contextVariables[safeVarName] = tagValue
|
|
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
|
|
}
|
|
|
|
return resolvedCode
|
|
}
|
|
|
|
/**
|
|
* Resolves environment variables and tags in code
|
|
* @param code - Code with variables
|
|
* @param params - Parameters that may contain variable values
|
|
* @param envVars - Environment variables from the workflow
|
|
* @returns Resolved code
|
|
*/
|
|
function resolveCodeVariables(
|
|
code: string,
|
|
params: Record<string, unknown>,
|
|
envVars: Record<string, string> = {},
|
|
blockData: Record<string, unknown> = {},
|
|
blockNameMapping: Record<string, string> = {},
|
|
blockOutputSchemas: Record<string, OutputSchema> = {},
|
|
workflowVariables: Record<string, unknown> = {},
|
|
language = 'javascript'
|
|
): { resolvedCode: string; contextVariables: Record<string, unknown> } {
|
|
let resolvedCode = code
|
|
const contextVariables: Record<string, unknown> = {}
|
|
|
|
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
|
|
resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
|
|
resolvedCode = resolveTagVariables(
|
|
resolvedCode,
|
|
blockData,
|
|
blockNameMapping,
|
|
blockOutputSchemas,
|
|
contextVariables,
|
|
language
|
|
)
|
|
|
|
return { resolvedCode, contextVariables }
|
|
}
|
|
|
|
/**
|
|
* Remove one trailing newline from stdout
|
|
* This handles the common case where print() or console.log() adds a trailing \n
|
|
* that users don't expect to see in the output
|
|
*/
|
|
/**
|
|
* Heuristic: did the sandbox die from an infrastructure failure (OOM kill,
|
|
* timeout, lost connection) rather than a normal code error? Python/JS code
|
|
* exceptions surface via execution.error; an OOM kill instead makes runCode
|
|
* throw, often with an empty or cryptic message.
|
|
*/
|
|
function isLikelySandboxKill(error: any): boolean {
|
|
const msg = `${error?.name ?? ''} ${error?.message ?? ''} ${error?.code ?? ''}`
|
|
.toLowerCase()
|
|
.trim()
|
|
if (!msg) return true
|
|
return [
|
|
'out of memory',
|
|
'oom',
|
|
'killed',
|
|
'sigkill',
|
|
'code 137',
|
|
'signal 9',
|
|
'terminated',
|
|
'econnreset',
|
|
'epipe',
|
|
'socket hang up',
|
|
'connection closed',
|
|
'connection reset',
|
|
'websocket',
|
|
'timed out',
|
|
'timeout',
|
|
'deadline',
|
|
].some((s) => msg.includes(s))
|
|
}
|
|
|
|
function cleanStdout(stdout: string): string {
|
|
if (stdout.endsWith('\n')) {
|
|
return stdout.slice(0, -1)
|
|
}
|
|
return stdout
|
|
}
|
|
|
|
/**
|
|
* Serializes a value for use as a shell environment variable. Strings pass through
|
|
* unchanged; primitives are coerced via `String`; objects, arrays, and other complex
|
|
* values are JSON-stringified so that referencing them via `$VAR` yields a useful
|
|
* representation instead of `[object Object]`. `null`/`undefined` become an empty
|
|
* string to match POSIX env semantics.
|
|
*/
|
|
function serializeForShellEnv(value: unknown, nullValue = ''): string {
|
|
if (value === null || value === undefined) return nullValue
|
|
if (typeof value === 'string') return value
|
|
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
|
return String(value)
|
|
}
|
|
try {
|
|
return JSON.stringify(value) ?? ''
|
|
} catch {
|
|
return String(value)
|
|
}
|
|
}
|
|
|
|
interface FunctionRouteExecutionContext {
|
|
workflowId?: string
|
|
workspaceId?: string
|
|
executionId?: string
|
|
largeValueExecutionIds?: string[]
|
|
largeValueKeys?: string[]
|
|
fileKeys?: string[]
|
|
allowLargeValueWorkflowScope?: boolean
|
|
userId?: string
|
|
requestId: string
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {}
|
|
}
|
|
|
|
function getPositiveNumber(value: unknown): number | undefined {
|
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
return undefined
|
|
}
|
|
return value
|
|
}
|
|
|
|
function clampInlineBytes(value: unknown, limit = MAX_FUNCTION_INLINE_BYTES): number {
|
|
const requested = getPositiveNumber(value)
|
|
return Math.min(requested ?? limit, limit)
|
|
}
|
|
|
|
function getBrokerFileArgs(args: unknown): {
|
|
file: unknown
|
|
maxBytes: number
|
|
offset?: number
|
|
length?: number
|
|
} {
|
|
const record = asRecord(args)
|
|
const options = asRecord(record.options)
|
|
return {
|
|
file: record.file,
|
|
maxBytes: clampInlineBytes(options.maxBytes),
|
|
offset: getPositiveNumber(options.offset),
|
|
length: getPositiveNumber(options.length),
|
|
}
|
|
}
|
|
|
|
function createFunctionRuntimeBrokers(
|
|
context: FunctionRouteExecutionContext
|
|
): Record<string, IsolatedVMBrokerHandler> {
|
|
context.largeValueKeys ??= []
|
|
context.fileKeys ??= []
|
|
const largeValueKeys = context.largeValueKeys
|
|
const fileKeys = context.fileKeys
|
|
const base = {
|
|
requestId: context.requestId,
|
|
workflowId: context.workflowId,
|
|
workspaceId: context.workspaceId,
|
|
executionId: context.executionId,
|
|
largeValueExecutionIds: context.largeValueExecutionIds,
|
|
largeValueKeys,
|
|
fileKeys,
|
|
allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope,
|
|
userId: context.userId,
|
|
logger,
|
|
}
|
|
|
|
const recordMaterializedKeys = (value: unknown) =>
|
|
recordMaterializedAccessKeys({ largeValueKeys, fileKeys }, value)
|
|
|
|
const readFile = async (args: unknown, encoding: 'base64' | 'text', chunked = false) => {
|
|
const fileArgs = getBrokerFileArgs(args)
|
|
return readUserFileContent(fileArgs.file, {
|
|
...base,
|
|
encoding,
|
|
maxBytes: fileArgs.maxBytes,
|
|
chunked,
|
|
offset: chunked ? fileArgs.offset : undefined,
|
|
length: chunked ? fileArgs.length : undefined,
|
|
})
|
|
}
|
|
|
|
return {
|
|
'sim.files.readBase64': (args) => readFile(args, 'base64'),
|
|
'sim.files.readText': (args) => readFile(args, 'text'),
|
|
'sim.files.readBase64Chunk': (args) => readFile(args, 'base64', true),
|
|
'sim.files.readTextChunk': (args) => readFile(args, 'text', true),
|
|
'sim.values.read': async (args) => {
|
|
const record = asRecord(args)
|
|
const options = asRecord(record.options)
|
|
const ref = record.ref
|
|
if (!isLargeValueRef(ref)) {
|
|
throw new Error('Expected a large execution value reference.')
|
|
}
|
|
if (!context.executionId) {
|
|
throw new Error('Large execution values require an execution context.')
|
|
}
|
|
const value = await materializeLargeValueRef(ref, {
|
|
...base,
|
|
maxBytes: clampInlineBytes(options.maxBytes, MAX_INLINE_MATERIALIZATION_BYTES),
|
|
})
|
|
if (value === undefined) {
|
|
throw unavailableLargeValueError(ref)
|
|
}
|
|
recordMaterializedKeys(value)
|
|
return value
|
|
},
|
|
'sim.values.readArray': async (args) => {
|
|
const record = asRecord(args)
|
|
const options = asRecord(record.options)
|
|
const manifest = record.ref
|
|
if (!isLargeArrayManifest(manifest)) {
|
|
throw new Error('Expected a large array manifest.')
|
|
}
|
|
if (!context.executionId) {
|
|
throw new Error('Large array manifests require an execution context.')
|
|
}
|
|
const value = await materializeLargeArrayManifest(manifest, {
|
|
...base,
|
|
maxBytes: clampInlineBytes(options.maxBytes, MAX_INLINE_MATERIALIZATION_BYTES),
|
|
})
|
|
recordMaterializedKeys(value)
|
|
return value
|
|
},
|
|
}
|
|
}
|
|
|
|
async function compactFunctionRouteBody<T>(
|
|
body: T,
|
|
context: FunctionRouteExecutionContext
|
|
): Promise<T> {
|
|
return compactExecutionPayload(body, {
|
|
workflowId: context.workflowId,
|
|
workspaceId: context.workspaceId,
|
|
executionId: context.executionId,
|
|
userId: context.userId,
|
|
preserveRoot: true,
|
|
requireDurable: Boolean(context.workspaceId && context.workflowId && context.executionId),
|
|
})
|
|
}
|
|
|
|
async function functionJsonResponse<T>(
|
|
body: T,
|
|
context: FunctionRouteExecutionContext,
|
|
init?: ResponseInit
|
|
) {
|
|
return NextResponse.json(
|
|
await compactFunctionRouteBody(
|
|
{
|
|
...body,
|
|
largeValueKeys: context.largeValueKeys,
|
|
fileKeys: context.fileKeys,
|
|
},
|
|
context
|
|
),
|
|
init
|
|
)
|
|
}
|
|
|
|
async function maybeExportSandboxFileToWorkspace(args: {
|
|
authUserId: string
|
|
workflowId?: string
|
|
workspaceId?: string
|
|
outputPath?: string
|
|
outputFormat?: string
|
|
outputMimeType?: string
|
|
outputSandboxPath?: string
|
|
overwriteFileId?: string
|
|
outputMode?: 'create' | 'overwrite'
|
|
exportedFileContent?: string
|
|
stdout: string
|
|
executionTime: number
|
|
}) {
|
|
const {
|
|
authUserId,
|
|
workflowId,
|
|
workspaceId,
|
|
outputPath,
|
|
outputFormat,
|
|
outputMimeType,
|
|
outputSandboxPath,
|
|
overwriteFileId,
|
|
outputMode,
|
|
exportedFileContent,
|
|
stdout,
|
|
executionTime,
|
|
} = args
|
|
|
|
if (!outputSandboxPath) return null
|
|
|
|
if (!outputPath) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error:
|
|
'outputSandboxPath requires outputPath. Set outputPath to the destination workspace file, e.g. "files/result.csv".',
|
|
output: { result: null, stdout: cleanStdout(stdout), executionTime },
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const resolvedWorkspaceId =
|
|
workspaceId || (workflowId ? (await getWorkflowById(workflowId))?.workspaceId : undefined)
|
|
|
|
if (!resolvedWorkspaceId) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: 'Workspace context required to save sandbox file to workspace',
|
|
output: { result: null, stdout: cleanStdout(stdout), executionTime },
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
if (exportedFileContent === undefined) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: `Sandbox file "${outputSandboxPath}" was not found or could not be read`,
|
|
output: { result: null, stdout: cleanStdout(stdout), executionTime },
|
|
},
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
const fileName = normalizeOutputWorkspaceFileName(outputPath)
|
|
|
|
const TEXT_MIMES = new Set(Object.values(FORMAT_TO_CONTENT_TYPE))
|
|
const resolvedMimeType =
|
|
outputMimeType ||
|
|
FORMAT_TO_CONTENT_TYPE[resolveOutputFormat(fileName, outputFormat)] ||
|
|
'application/octet-stream'
|
|
const isBinary = !TEXT_MIMES.has(resolvedMimeType)
|
|
const fileBuffer = isBinary
|
|
? Buffer.from(exportedFileContent, 'base64')
|
|
: Buffer.from(exportedFileContent, 'utf-8')
|
|
|
|
const targetPath = overwriteFileId || outputPath
|
|
const mode = outputMode ?? (overwriteFileId ? 'overwrite' : 'create')
|
|
|
|
try {
|
|
const written = await writeWorkspaceFileByPath({
|
|
workspaceId: resolvedWorkspaceId,
|
|
userId: authUserId,
|
|
target: {
|
|
path: targetPath,
|
|
mode,
|
|
mimeType: outputMimeType,
|
|
},
|
|
buffer: fileBuffer,
|
|
inferredMimeType: resolvedMimeType,
|
|
})
|
|
logger.info('Sandbox file exported to workspace', {
|
|
fileId: written.id,
|
|
vfsPath: written.vfsPath,
|
|
sandboxPath: outputSandboxPath,
|
|
mode,
|
|
mimeType: resolvedMimeType,
|
|
size: fileBuffer.length,
|
|
})
|
|
return NextResponse.json({
|
|
success: true,
|
|
output: {
|
|
result: {
|
|
message: `Sandbox file exported to ${written.vfsPath}`,
|
|
fileId: written.id,
|
|
fileName: written.name,
|
|
vfsPath: written.vfsPath,
|
|
downloadUrl: written.downloadUrl,
|
|
sandboxPath: outputSandboxPath,
|
|
},
|
|
stdout: cleanStdout(stdout),
|
|
executionTime,
|
|
},
|
|
resources: [{ type: 'file', id: written.id, title: written.name, path: written.vfsPath }],
|
|
})
|
|
} catch (error) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: getErrorMessage(error, 'Failed to export sandbox file'),
|
|
output: { result: null, stdout: cleanStdout(stdout), executionTime },
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
}
|
|
|
|
async function maybeExportSandboxFilesToWorkspace(args: {
|
|
authUserId: string
|
|
workflowId?: string
|
|
workspaceId?: string
|
|
outputFiles: OutputFileDeclaration[]
|
|
exportedFiles?: Record<string, string>
|
|
exportedFileContent?: string
|
|
stdout: string
|
|
executionTime: number
|
|
}) {
|
|
const sandboxFiles = args.outputFiles.filter((file) => file.sandboxPath)
|
|
if (sandboxFiles.length === 0) return null
|
|
if (sandboxFiles.length > MAX_SANDBOX_OUTPUT_FILES) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: `Too many sandbox output files requested (${sandboxFiles.length}). Maximum is ${MAX_SANDBOX_OUTPUT_FILES}.`,
|
|
output: {
|
|
result: null,
|
|
stdout: cleanStdout(args.stdout),
|
|
executionTime: args.executionTime,
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
if (sandboxFiles.length === 1) {
|
|
const file = sandboxFiles[0]
|
|
return maybeExportSandboxFileToWorkspace({
|
|
authUserId: args.authUserId,
|
|
workflowId: args.workflowId,
|
|
workspaceId: args.workspaceId,
|
|
outputPath: file.formatPath ?? file.path,
|
|
outputFormat: file.format,
|
|
outputMimeType: file.mimeType,
|
|
outputSandboxPath: file.sandboxPath,
|
|
outputMode: file.mode,
|
|
exportedFileContent:
|
|
(file.sandboxPath ? args.exportedFiles?.[file.sandboxPath] : undefined) ??
|
|
args.exportedFileContent,
|
|
stdout: args.stdout,
|
|
executionTime: args.executionTime,
|
|
})
|
|
}
|
|
|
|
const resolvedWorkspaceId =
|
|
args.workspaceId ||
|
|
(args.workflowId ? (await getWorkflowById(args.workflowId))?.workspaceId : undefined)
|
|
if (!resolvedWorkspaceId) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: 'Workspace context required to save sandbox files to workspace',
|
|
output: {
|
|
result: null,
|
|
stdout: cleanStdout(args.stdout),
|
|
executionTime: args.executionTime,
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const preparedFiles = []
|
|
let totalOutputBytes = 0
|
|
for (const file of sandboxFiles) {
|
|
const sandboxPath = file.sandboxPath!
|
|
const content = args.exportedFiles?.[sandboxPath]
|
|
if (content === undefined) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: `Sandbox file "${sandboxPath}" was not found or could not be read`,
|
|
output: {
|
|
result: null,
|
|
stdout: cleanStdout(args.stdout),
|
|
executionTime: args.executionTime,
|
|
},
|
|
},
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
const fileName = normalizeOutputWorkspaceFileName(file.formatPath ?? file.path)
|
|
const resolvedMimeType =
|
|
file.mimeType ||
|
|
FORMAT_TO_CONTENT_TYPE[resolveOutputFormat(fileName, file.format)] ||
|
|
'application/octet-stream'
|
|
const isBinary = !new Set(Object.values(FORMAT_TO_CONTENT_TYPE)).has(resolvedMimeType)
|
|
const size = Buffer.byteLength(content, isBinary ? 'base64' : 'utf-8')
|
|
totalOutputBytes += size
|
|
if (totalOutputBytes > MAX_SANDBOX_OUTPUT_BYTES) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: `Sandbox output files exceed ${MAX_SANDBOX_OUTPUT_BYTES} bytes total`,
|
|
output: {
|
|
result: null,
|
|
stdout: cleanStdout(args.stdout),
|
|
executionTime: args.executionTime,
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
preparedFiles.push({
|
|
file,
|
|
sandboxPath,
|
|
content,
|
|
resolvedMimeType,
|
|
isBinary,
|
|
size,
|
|
target: {
|
|
path: file.path,
|
|
mode: file.mode ?? 'create',
|
|
mimeType: file.mimeType,
|
|
},
|
|
})
|
|
}
|
|
|
|
let validationPaths: string[]
|
|
try {
|
|
const validations = await Promise.all(
|
|
preparedFiles.map((prepared) =>
|
|
validateWorkspaceFileWriteTarget({
|
|
workspaceId: resolvedWorkspaceId,
|
|
userId: args.authUserId,
|
|
target: prepared.target,
|
|
})
|
|
)
|
|
)
|
|
validationPaths = validations.map((validation) => validation.vfsPath)
|
|
} catch (error) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: getErrorMessage(error, 'Invalid sandbox output destination'),
|
|
output: {
|
|
result: null,
|
|
stdout: cleanStdout(args.stdout),
|
|
executionTime: args.executionTime,
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
const duplicateDestination = validationPaths.find(
|
|
(vfsPath, index) => validationPaths.indexOf(vfsPath) !== index
|
|
)
|
|
if (duplicateDestination) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: `Duplicate sandbox output destination: ${duplicateDestination}`,
|
|
output: {
|
|
result: null,
|
|
stdout: cleanStdout(args.stdout),
|
|
executionTime: args.executionTime,
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const writtenFiles = []
|
|
try {
|
|
for (const prepared of preparedFiles) {
|
|
const buffer = prepared.isBinary
|
|
? Buffer.from(prepared.content, 'base64')
|
|
: Buffer.from(prepared.content, 'utf-8')
|
|
const written = await writeWorkspaceFileByPath({
|
|
workspaceId: resolvedWorkspaceId,
|
|
userId: args.authUserId,
|
|
target: prepared.target,
|
|
buffer,
|
|
inferredMimeType: prepared.resolvedMimeType,
|
|
})
|
|
logger.info('Sandbox file exported to workspace', {
|
|
fileId: written.id,
|
|
vfsPath: written.vfsPath,
|
|
sandboxPath: prepared.sandboxPath,
|
|
mode: prepared.file.mode ?? 'create',
|
|
mimeType: prepared.resolvedMimeType,
|
|
size: prepared.size,
|
|
})
|
|
writtenFiles.push({ ...written, sandboxPath: prepared.sandboxPath })
|
|
}
|
|
} catch (error) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: getErrorMessage(error, 'Failed to export sandbox files'),
|
|
output: {
|
|
result: null,
|
|
stdout: cleanStdout(args.stdout),
|
|
executionTime: args.executionTime,
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
output: {
|
|
result: {
|
|
message: `Exported ${writtenFiles.length} sandbox files`,
|
|
files: writtenFiles.map((file) => ({
|
|
fileId: file.id,
|
|
fileName: file.name,
|
|
vfsPath: file.vfsPath,
|
|
backingVfsPath: file.backingVfsPath,
|
|
downloadUrl: file.downloadUrl,
|
|
sandboxPath: file.sandboxPath,
|
|
})),
|
|
},
|
|
stdout: cleanStdout(args.stdout),
|
|
executionTime: args.executionTime,
|
|
},
|
|
resources: writtenFiles.map((file) => ({
|
|
type: 'file',
|
|
id: file.id,
|
|
title: file.name,
|
|
path: file.vfsPath,
|
|
})),
|
|
})
|
|
}
|
|
|
|
export const POST = withRouteHandler(async (req: NextRequest) => {
|
|
const requestId = generateRequestId()
|
|
const startTime = Date.now()
|
|
let stdout = ''
|
|
let userCodeStartLine = 3 // Default value for error reporting
|
|
let resolvedCode = '' // Store resolved code for error reporting
|
|
let sourceCodeForErrors: string | undefined
|
|
let routeContext: FunctionRouteExecutionContext | undefined
|
|
|
|
try {
|
|
const auth = await checkInternalAuth(req)
|
|
if (!auth.success || !auth.userId) {
|
|
logger.warn(`[${requestId}] Unauthorized function execution attempt`)
|
|
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const parsed = await parseRequest(functionExecuteContract, req, {})
|
|
if (!parsed.success) return parsed.response
|
|
const { body } = parsed.data
|
|
|
|
const { DEFAULT_EXECUTION_TIMEOUT_MS } = await import('@/lib/execution/constants')
|
|
|
|
const {
|
|
code,
|
|
sourceCode,
|
|
params = {},
|
|
timeout = DEFAULT_EXECUTION_TIMEOUT_MS,
|
|
language = DEFAULT_CODE_LANGUAGE,
|
|
outputPath,
|
|
outputFormat,
|
|
outputMimeType,
|
|
outputSandboxPath,
|
|
overwriteFileId,
|
|
outputs,
|
|
envVars = {},
|
|
blockData = {},
|
|
blockNameMapping = {},
|
|
blockOutputSchemas = {},
|
|
workflowVariables = {},
|
|
contextVariables: preResolvedContextVariables = {},
|
|
workflowId,
|
|
executionId,
|
|
largeValueExecutionIds,
|
|
largeValueKeys,
|
|
fileKeys,
|
|
allowLargeValueWorkflowScope = false,
|
|
workspaceId,
|
|
isCustomTool = false,
|
|
_sandboxFiles,
|
|
} = body
|
|
sourceCodeForErrors = sourceCode
|
|
const outputFiles = getOutputFileDeclarations({
|
|
outputs,
|
|
outputPath,
|
|
outputFormat,
|
|
outputMimeType,
|
|
outputSandboxPath,
|
|
overwriteFileId,
|
|
})
|
|
const outputSandboxPaths = outputFiles
|
|
.map((file) => file.sandboxPath)
|
|
.filter((path): path is string => Boolean(path))
|
|
if (outputSandboxPaths.length > MAX_SANDBOX_OUTPUT_FILES) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: `Too many sandbox output files requested (${outputSandboxPaths.length}). Maximum is ${MAX_SANDBOX_OUTPUT_FILES}.`,
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const executionParams = { ...params }
|
|
executionParams._context = undefined
|
|
|
|
logger.info(`[${requestId}] Function execution request`, {
|
|
hasCode: !!code,
|
|
paramsCount: Object.keys(executionParams).length,
|
|
timeout,
|
|
workflowId,
|
|
executionId,
|
|
isCustomTool,
|
|
})
|
|
|
|
routeContext = {
|
|
workflowId,
|
|
workspaceId,
|
|
executionId,
|
|
largeValueExecutionIds,
|
|
largeValueKeys,
|
|
fileKeys,
|
|
allowLargeValueWorkflowScope,
|
|
userId: auth.userId,
|
|
requestId,
|
|
}
|
|
|
|
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
|
|
|
|
let contextVariables: Record<string, unknown> = {}
|
|
if (lang === CodeLanguage.Shell) {
|
|
// For shell, env vars are injected as OS env vars via shellEnvs.
|
|
// Replace {{VAR}} placeholders with $VAR so the shell can access them natively.
|
|
resolvedCode = code.replace(/\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g, '$$$1')
|
|
// Carry pre-resolved block output variables (e.g. __blockRef_N) so they can be
|
|
// injected as shell env vars below. The executor replaces block references in the
|
|
// code with these names, so the values must be present at runtime.
|
|
contextVariables = { ...preResolvedContextVariables }
|
|
} else {
|
|
const codeResolution = resolveCodeVariables(
|
|
code,
|
|
executionParams,
|
|
envVars,
|
|
blockData,
|
|
blockNameMapping,
|
|
blockOutputSchemas,
|
|
workflowVariables,
|
|
lang
|
|
)
|
|
resolvedCode = codeResolution.resolvedCode
|
|
// Merge pre-resolved block output variables from the executor. These take precedence
|
|
// because they were produced by the resolver using full execution-state context
|
|
// (including loop/parallel scope) and should not be overwritten.
|
|
contextVariables = { ...codeResolution.contextVariables, ...preResolvedContextVariables }
|
|
}
|
|
|
|
if (lang === CodeLanguage.Shell && containsLargeValueRef(contextVariables)) {
|
|
throw new Error(
|
|
'Large execution values require the JavaScript isolated-vm runtime. Select a nested field or read the value in a JavaScript function.'
|
|
)
|
|
}
|
|
|
|
let jsImports = ''
|
|
let jsRemainingCode = resolvedCode
|
|
let hasImports = false
|
|
|
|
if (lang === CodeLanguage.JavaScript) {
|
|
const extractionResult = await extractJavaScriptImports(resolvedCode)
|
|
jsImports = extractionResult.imports
|
|
jsRemainingCode = extractionResult.remainingCode
|
|
|
|
const hasRequireStatements = /require\s*\(\s*['"`]/.test(resolvedCode)
|
|
hasImports = jsImports.trim().length > 0 || hasRequireStatements
|
|
}
|
|
|
|
if (lang === CodeLanguage.Shell) {
|
|
if (!isE2bEnabled) {
|
|
throw new Error(
|
|
'Shell execution requires E2B to be enabled. Please contact your administrator to enable E2B.'
|
|
)
|
|
}
|
|
|
|
const shellEnvs: Record<string, string> = {}
|
|
for (const [k, v] of Object.entries(envVars)) {
|
|
shellEnvs[k] = serializeForShellEnv(v)
|
|
}
|
|
for (const [k, v] of Object.entries(contextVariables)) {
|
|
shellEnvs[k] = serializeForShellEnv(v, 'null')
|
|
}
|
|
|
|
logger.info(`[${requestId}] E2B shell execution`, {
|
|
enabled: isE2bEnabled,
|
|
hasApiKey: Boolean(process.env.E2B_API_KEY),
|
|
envVarCount: Object.keys(shellEnvs).length,
|
|
})
|
|
|
|
const execStart = Date.now()
|
|
const {
|
|
result: shellResult,
|
|
stdout: shellStdout,
|
|
sandboxId,
|
|
error: shellError,
|
|
exportedFileContent,
|
|
exportedFiles,
|
|
} = await executeShellInE2B({
|
|
code: resolvedCode,
|
|
envs: shellEnvs,
|
|
timeoutMs: timeout,
|
|
sandboxFiles: _sandboxFiles,
|
|
outputSandboxPath,
|
|
outputSandboxPaths,
|
|
})
|
|
const executionTime = Date.now() - execStart
|
|
|
|
logger.info(`[${requestId}] E2B shell sandbox`, {
|
|
sandboxId,
|
|
stdoutPreview: shellStdout?.slice(0, 200),
|
|
error: shellError,
|
|
executionTime,
|
|
})
|
|
|
|
if (shellError) {
|
|
return functionJsonResponse(
|
|
{
|
|
success: false,
|
|
error: shellError,
|
|
output: { result: null, stdout: cleanStdout(shellStdout), executionTime },
|
|
},
|
|
routeContext,
|
|
{ status: 422 }
|
|
)
|
|
}
|
|
|
|
if (outputSandboxPaths.length > 0 || outputSandboxPath) {
|
|
const fileExportResponse = await maybeExportSandboxFilesToWorkspace({
|
|
authUserId: auth.userId,
|
|
workflowId,
|
|
workspaceId,
|
|
outputFiles,
|
|
exportedFiles,
|
|
exportedFileContent,
|
|
stdout: shellStdout,
|
|
executionTime,
|
|
})
|
|
if (fileExportResponse) return fileExportResponse
|
|
}
|
|
|
|
return functionJsonResponse(
|
|
{
|
|
success: true,
|
|
output: { result: shellResult ?? null, stdout: cleanStdout(shellStdout), executionTime },
|
|
},
|
|
routeContext
|
|
)
|
|
}
|
|
|
|
if (lang === CodeLanguage.Python && !isE2bEnabled) {
|
|
throw new Error(
|
|
'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.'
|
|
)
|
|
}
|
|
|
|
if (lang === CodeLanguage.JavaScript && hasImports && !isE2bEnabled) {
|
|
throw new Error(
|
|
'JavaScript code with import statements requires E2B to be enabled. Please remove the import statements, or contact your administrator to enable E2B.'
|
|
)
|
|
}
|
|
|
|
const useE2B =
|
|
isE2bEnabled &&
|
|
!isCustomTool &&
|
|
(lang === CodeLanguage.Python || (lang === CodeLanguage.JavaScript && hasImports))
|
|
|
|
if (useE2B && containsLargeValueRef(contextVariables)) {
|
|
throw new Error(
|
|
'Large execution values require the JavaScript isolated-vm runtime. Remove imports, select a nested field, or read the value in a JavaScript function without E2B.'
|
|
)
|
|
}
|
|
|
|
if (useE2B) {
|
|
logger.info(`[${requestId}] E2B status`, {
|
|
enabled: isE2bEnabled,
|
|
hasApiKey: Boolean(process.env.E2B_API_KEY),
|
|
language: lang,
|
|
})
|
|
let prologue = ''
|
|
|
|
if (lang === CodeLanguage.JavaScript) {
|
|
let prologueLineCount = 0
|
|
|
|
const imports = jsImports
|
|
const remainingCode = jsRemainingCode
|
|
|
|
const importSection: string = imports ? `${imports}\n` : ''
|
|
const importLineCount = imports ? imports.split('\n').length : 0
|
|
|
|
const codeBody = remainingCode
|
|
resolvedCode = importSection ? `${imports}\n\n${codeBody}` : codeBody
|
|
|
|
prologue += `const params = JSON.parse(${JSON.stringify(JSON.stringify(executionParams))});\n`
|
|
prologueLineCount++
|
|
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
|
|
prologueLineCount++
|
|
for (const [k, v] of Object.entries(contextVariables)) {
|
|
prologue += `globalThis[${JSON.stringify(k)}] = ${formatLiteralForCode(v, 'javascript')};\n`
|
|
prologue += `const ${k} = globalThis[${JSON.stringify(k)}];\n`
|
|
prologueLineCount++
|
|
prologueLineCount++
|
|
}
|
|
|
|
const wrapped = [
|
|
';(async () => {',
|
|
' try {',
|
|
' const __sim_result = await (async () => {',
|
|
` ${codeBody.split('\n').join('\n ')}`,
|
|
' })();',
|
|
" console.log('__SIM_RESULT__=' + JSON.stringify(__sim_result));",
|
|
' } catch (error) {',
|
|
' console.log(String((error && (error.stack || error.message)) || error));',
|
|
' throw error;',
|
|
' }',
|
|
'})();',
|
|
].join('\n')
|
|
const codeForE2B = importSection + prologue + wrapped
|
|
|
|
const execStart = Date.now()
|
|
const {
|
|
result: e2bResult,
|
|
stdout: e2bStdout,
|
|
sandboxId,
|
|
error: e2bError,
|
|
exportedFileContent,
|
|
exportedFiles,
|
|
} = await executeInE2B({
|
|
code: codeForE2B,
|
|
language: CodeLanguage.JavaScript,
|
|
timeoutMs: timeout,
|
|
sandboxFiles: _sandboxFiles,
|
|
outputSandboxPath,
|
|
outputSandboxPaths,
|
|
})
|
|
const executionTime = Date.now() - execStart
|
|
stdout += e2bStdout
|
|
|
|
logger.info(`[${requestId}] E2B JS sandbox`, {
|
|
sandboxId,
|
|
stdoutPreview: e2bStdout?.slice(0, 200),
|
|
error: e2bError,
|
|
})
|
|
|
|
if (e2bError) {
|
|
const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode)
|
|
const { formattedError, cleanedOutput } = formatE2BError(
|
|
getErrorDisplayMessage(e2bError, sourceCodeForErrors, resolvedCode),
|
|
e2bStdout,
|
|
lang,
|
|
errorDisplayCode,
|
|
prologueLineCount + importLineCount
|
|
)
|
|
return functionJsonResponse(
|
|
{
|
|
success: false,
|
|
error: formattedError,
|
|
output: { result: null, stdout: cleanedOutput, executionTime },
|
|
},
|
|
routeContext,
|
|
{ status: 422 }
|
|
)
|
|
}
|
|
|
|
if (outputSandboxPaths.length > 0 || outputSandboxPath) {
|
|
const fileExportResponse = await maybeExportSandboxFilesToWorkspace({
|
|
authUserId: auth.userId,
|
|
workflowId,
|
|
workspaceId,
|
|
outputFiles,
|
|
exportedFiles,
|
|
exportedFileContent,
|
|
stdout,
|
|
executionTime,
|
|
})
|
|
if (fileExportResponse) return fileExportResponse
|
|
}
|
|
|
|
return functionJsonResponse(
|
|
{
|
|
success: true,
|
|
output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime },
|
|
},
|
|
routeContext
|
|
)
|
|
}
|
|
|
|
let prologueLineCount = 0
|
|
prologue += 'import json\n'
|
|
prologueLineCount++
|
|
prologue += `params = json.loads(${JSON.stringify(JSON.stringify(executionParams))})\n`
|
|
prologueLineCount++
|
|
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
|
|
prologueLineCount++
|
|
for (const [k, v] of Object.entries(contextVariables)) {
|
|
prologue += `${k} = ${formatLiteralForCode(v, 'python')}\n`
|
|
prologueLineCount++
|
|
}
|
|
const wrapped = [
|
|
'def __sim_main__():',
|
|
...resolvedCode.split('\n').map((l) => ` ${l}`),
|
|
'__sim_result__ = __sim_main__()',
|
|
"print('__SIM_RESULT__=' + json.dumps(__sim_result__))",
|
|
].join('\n')
|
|
const codeForE2B = prologue + wrapped
|
|
|
|
const execStart = Date.now()
|
|
const {
|
|
result: e2bResult,
|
|
stdout: e2bStdout,
|
|
sandboxId,
|
|
error: e2bError,
|
|
exportedFileContent,
|
|
exportedFiles,
|
|
} = await executeInE2B({
|
|
code: codeForE2B,
|
|
language: CodeLanguage.Python,
|
|
timeoutMs: timeout,
|
|
sandboxFiles: _sandboxFiles,
|
|
outputSandboxPath,
|
|
outputSandboxPaths,
|
|
})
|
|
const executionTime = Date.now() - execStart
|
|
stdout += e2bStdout
|
|
|
|
logger.info(`[${requestId}] E2B Py sandbox`, {
|
|
sandboxId,
|
|
stdoutPreview: e2bStdout?.slice(0, 200),
|
|
error: e2bError,
|
|
})
|
|
|
|
if (e2bError) {
|
|
const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode)
|
|
const { formattedError, cleanedOutput } = formatE2BError(
|
|
getErrorDisplayMessage(e2bError, sourceCodeForErrors, resolvedCode),
|
|
e2bStdout,
|
|
lang,
|
|
errorDisplayCode,
|
|
prologueLineCount
|
|
)
|
|
return functionJsonResponse(
|
|
{
|
|
success: false,
|
|
error: formattedError,
|
|
output: { result: null, stdout: cleanedOutput, executionTime },
|
|
},
|
|
routeContext,
|
|
{ status: 422 }
|
|
)
|
|
}
|
|
|
|
if (outputSandboxPaths.length > 0 || outputSandboxPath) {
|
|
const fileExportResponse = await maybeExportSandboxFilesToWorkspace({
|
|
authUserId: auth.userId,
|
|
workflowId,
|
|
workspaceId,
|
|
outputFiles,
|
|
exportedFiles,
|
|
exportedFileContent,
|
|
stdout,
|
|
executionTime,
|
|
})
|
|
if (fileExportResponse) return fileExportResponse
|
|
}
|
|
|
|
return functionJsonResponse(
|
|
{
|
|
success: true,
|
|
output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime },
|
|
},
|
|
routeContext
|
|
)
|
|
}
|
|
|
|
const executionMethod = 'isolated-vm'
|
|
|
|
const isSafeParamKey = (key: string) => SAFE_IDENTIFIER.test(key) && !JS_RESERVED_WORDS.has(key)
|
|
|
|
const wrapperLines = ['(async () => {', ' try {']
|
|
if (isCustomTool) {
|
|
Object.keys(executionParams).forEach((key) => {
|
|
if (isSafeParamKey(key)) {
|
|
wrapperLines.push(` const ${key} = params.${key};`)
|
|
} else {
|
|
logger.warn('Skipping param key — not a safe JS identifier', { key, requestId })
|
|
}
|
|
})
|
|
}
|
|
userCodeStartLine = wrapperLines.length + 1
|
|
|
|
let codeToExecute = resolvedCode
|
|
let prependedLineCount = 0
|
|
if (isCustomTool) {
|
|
const paramKeys = Object.keys(executionParams).filter(isSafeParamKey)
|
|
const paramDestructuring = paramKeys.map((key) => `const ${key} = params.${key};`).join('\n')
|
|
codeToExecute = `${paramDestructuring}\n${resolvedCode}`
|
|
prependedLineCount = paramKeys.length
|
|
}
|
|
|
|
const isolatedResult = await executeInIsolatedVM(
|
|
{
|
|
code: codeToExecute,
|
|
params: executionParams,
|
|
envVars,
|
|
contextVariables,
|
|
timeoutMs: timeout,
|
|
requestId,
|
|
ownerKey: `user:${auth.userId}`,
|
|
ownerWeight: 1,
|
|
},
|
|
{ brokers: createFunctionRuntimeBrokers(routeContext) }
|
|
)
|
|
|
|
const executionTime = Date.now() - startTime
|
|
|
|
if (isolatedResult.error) {
|
|
const isSystemError = isolatedResult.error.isSystemError === true
|
|
const logFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger)
|
|
logFn(`[${requestId}] Function execution failed in isolated-vm`, {
|
|
error: isolatedResult.error,
|
|
executionTime,
|
|
isSystemError,
|
|
})
|
|
|
|
const ivmError = isolatedResult.error
|
|
let adjustedLine = ivmError.line
|
|
let adjustedLineContent = ivmError.lineContent
|
|
if (prependedLineCount > 0 && ivmError.line !== undefined) {
|
|
adjustedLine = Math.max(1, ivmError.line - prependedLineCount)
|
|
}
|
|
const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode)
|
|
const displayMessage = getErrorDisplayMessage(
|
|
ivmError.message,
|
|
sourceCodeForErrors,
|
|
resolvedCode
|
|
)
|
|
adjustedLineContent = getLineContent(errorDisplayCode, adjustedLine) ?? adjustedLineContent
|
|
const enhancedError: EnhancedError = {
|
|
message: displayMessage,
|
|
name: ivmError.name,
|
|
stack: ivmError.stack,
|
|
originalError: ivmError,
|
|
line: adjustedLine,
|
|
column: ivmError.column,
|
|
lineContent: adjustedLineContent,
|
|
}
|
|
|
|
const userFriendlyErrorMessage = createUserFriendlyErrorMessage(
|
|
enhancedError,
|
|
requestId,
|
|
errorDisplayCode
|
|
)
|
|
|
|
const detailLogFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger)
|
|
detailLogFn(`[${requestId}] Enhanced error details`, {
|
|
originalMessage: ivmError.message,
|
|
enhancedMessage: userFriendlyErrorMessage,
|
|
line: enhancedError.line,
|
|
column: enhancedError.column,
|
|
lineContent: enhancedError.lineContent,
|
|
errorType: enhancedError.name,
|
|
})
|
|
|
|
return functionJsonResponse(
|
|
{
|
|
success: false,
|
|
error: userFriendlyErrorMessage,
|
|
output: {
|
|
result: null,
|
|
stdout: cleanStdout(isolatedResult.stdout),
|
|
executionTime,
|
|
},
|
|
debug: {
|
|
line: enhancedError.line,
|
|
column: enhancedError.column,
|
|
errorType: enhancedError.name,
|
|
lineContent: enhancedError.lineContent,
|
|
stack: enhancedError.stack,
|
|
},
|
|
},
|
|
routeContext,
|
|
{ status: isSystemError ? 500 : 422 }
|
|
)
|
|
}
|
|
|
|
stdout = isolatedResult.stdout
|
|
logger.info(`[${requestId}] Function executed successfully using ${executionMethod}`, {
|
|
executionTime,
|
|
})
|
|
|
|
return functionJsonResponse(
|
|
{
|
|
success: true,
|
|
output: { result: isolatedResult.result, stdout: cleanStdout(stdout), executionTime },
|
|
},
|
|
routeContext
|
|
)
|
|
} catch (error: any) {
|
|
const executionTime = Date.now() - startTime
|
|
if (isExecutionResourceLimitError(error)) {
|
|
logger.warn(`[${requestId}] Function execution exceeded resource limits`, {
|
|
resource: error.resource,
|
|
attemptedBytes: error.attemptedBytes,
|
|
limitBytes: error.limitBytes,
|
|
executionTime,
|
|
})
|
|
if (routeContext) {
|
|
return functionJsonResponse(
|
|
{
|
|
success: false,
|
|
error: error.message,
|
|
output: {
|
|
result: null,
|
|
stdout: cleanStdout(stdout),
|
|
executionTime,
|
|
},
|
|
},
|
|
routeContext,
|
|
{ status: error.statusCode }
|
|
)
|
|
}
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: error.message,
|
|
output: {
|
|
result: null,
|
|
stdout: cleanStdout(stdout),
|
|
executionTime,
|
|
},
|
|
},
|
|
{ status: error.statusCode }
|
|
)
|
|
}
|
|
|
|
if (isLikelySandboxKill(error)) {
|
|
const underlying = (error?.message || String(error)).slice(0, 300)
|
|
logger.warn(`[${requestId}] Sandbox terminated before completion (likely OOM or timeout)`, {
|
|
executionTime,
|
|
underlying,
|
|
})
|
|
const killResponse = {
|
|
success: false,
|
|
error:
|
|
'The sandbox was terminated before finishing — most likely it ran out of memory or hit the time limit while processing large or combined inputs. Mount and process fewer/smaller files at once (e.g. one file at a time), or stream and aggregate incrementally instead of loading everything into memory. ' +
|
|
`(underlying: ${underlying || 'no detail; sandbox died'})`,
|
|
output: { result: null, stdout: cleanStdout(stdout), executionTime },
|
|
}
|
|
return routeContext
|
|
? functionJsonResponse(killResponse, routeContext, { status: 500 })
|
|
: NextResponse.json(killResponse, { status: 500 })
|
|
}
|
|
|
|
logger.error(`[${requestId}] Function execution failed`, {
|
|
error: error.message || 'Unknown error',
|
|
stack: error.stack,
|
|
executionTime,
|
|
})
|
|
|
|
const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode)
|
|
const enhancedError = extractEnhancedError(error, userCodeStartLine, errorDisplayCode)
|
|
const userFriendlyErrorMessage = createUserFriendlyErrorMessage(
|
|
enhancedError,
|
|
requestId,
|
|
errorDisplayCode
|
|
)
|
|
|
|
logger.error(`[${requestId}] Enhanced error details`, {
|
|
originalMessage: error.message,
|
|
enhancedMessage: userFriendlyErrorMessage,
|
|
line: enhancedError.line,
|
|
column: enhancedError.column,
|
|
lineContent: enhancedError.lineContent,
|
|
errorType: enhancedError.name,
|
|
userCodeStartLine,
|
|
})
|
|
|
|
const errorResponse = {
|
|
success: false,
|
|
error: userFriendlyErrorMessage,
|
|
output: {
|
|
result: null,
|
|
stdout: cleanStdout(stdout),
|
|
executionTime,
|
|
},
|
|
debug: {
|
|
line: enhancedError.line,
|
|
column: enhancedError.column,
|
|
errorType: enhancedError.name,
|
|
lineContent: enhancedError.lineContent,
|
|
stack: enhancedError.stack,
|
|
},
|
|
}
|
|
|
|
if (routeContext) {
|
|
return functionJsonResponse(errorResponse, routeContext, { status: 500 })
|
|
}
|
|
|
|
return NextResponse.json(errorResponse, { status: 500 })
|
|
}
|
|
})
|