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
1377 lines
43 KiB
TypeScript
1377 lines
43 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { toError } from '@sim/utils/errors'
|
|
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
|
|
import { mergeLargeValueKeys } from '@/lib/execution/payloads/access-keys'
|
|
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
|
|
import {
|
|
containsLargeValueRef,
|
|
formatLargeValueSize,
|
|
getLargeValueMaterializationError,
|
|
isLargeValueRef,
|
|
type LargeValueRef,
|
|
} from '@/lib/execution/payloads/large-value-ref'
|
|
import { isLikelyReferenceSegment } from '@/lib/workflows/sanitization/references'
|
|
import { BlockType, parseReferencePath, REFERENCE } from '@/executor/constants'
|
|
import type { ExecutionState, LoopScope } from '@/executor/execution/state'
|
|
import type { ExecutionContext } from '@/executor/types'
|
|
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
|
import { BlockResolver } from '@/executor/variables/resolvers/block'
|
|
import { EnvResolver } from '@/executor/variables/resolvers/env'
|
|
import { LoopResolver } from '@/executor/variables/resolvers/loop'
|
|
import { ParallelResolver } from '@/executor/variables/resolvers/parallel'
|
|
import {
|
|
type AsyncPathNavigator,
|
|
RESOLVED_EMPTY,
|
|
type ResolutionContext,
|
|
type Resolver,
|
|
} from '@/executor/variables/resolvers/reference'
|
|
import { WorkflowResolver } from '@/executor/variables/resolvers/workflow'
|
|
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
|
|
|
/** Key used to carry pre-resolved context variables through the inputs map. */
|
|
export const FUNCTION_BLOCK_CONTEXT_VARS_KEY = '_runtimeContextVars'
|
|
/** Key used to carry display-resolved code through the function execution path. */
|
|
export const FUNCTION_BLOCK_DISPLAY_CODE_KEY = '_runtimeDisplayCode'
|
|
|
|
const logger = createLogger('VariableResolver')
|
|
|
|
/**
|
|
* Combined inline budget (data + display source) for a function block's resolved
|
|
* block-output context values. Internal routes cap request bodies at ~10 MB, and a
|
|
* resolved block reference is serialized into the function request both as data
|
|
* (`contextVariables`) and as a literal in the display source, so it costs roughly
|
|
* twice its size. Keeping the inline footprint under this budget leaves headroom for
|
|
* the code, params, and environment variables in the same body. Values that would
|
|
* overflow the budget are offloaded to durable large-value refs and lazily re-read in
|
|
* the sandbox via the `sim.values.read` broker.
|
|
*/
|
|
const FUNCTION_CONTEXT_INLINE_BUDGET_BYTES = 6 * 1024 * 1024
|
|
|
|
interface FunctionContextOffloadState {
|
|
inlineFootprintRemaining: number
|
|
}
|
|
|
|
function createFunctionContextOffloadState(): FunctionContextOffloadState {
|
|
return { inlineFootprintRemaining: FUNCTION_CONTEXT_INLINE_BUDGET_BYTES }
|
|
}
|
|
|
|
function measureJson(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 getNestedLargeValueMaterializationError(): Error {
|
|
return new Error(
|
|
'This execution value contains nested large values. Reference the nested field directly so it can be lazy-loaded.'
|
|
)
|
|
}
|
|
|
|
async function replaceValidReferencesAsync(
|
|
template: string,
|
|
replacer: (match: string, index: number, template: string) => Promise<string>
|
|
): Promise<string> {
|
|
const pattern = createReferencePattern()
|
|
let cursor = 0
|
|
let result = ''
|
|
for (const match of template.matchAll(pattern)) {
|
|
const fullMatch = match[0]
|
|
const index = match.index ?? 0
|
|
result += template.slice(cursor, index)
|
|
result += isLikelyReferenceSegment(fullMatch)
|
|
? await replacer(fullMatch, index, template)
|
|
: fullMatch
|
|
cursor = index + fullMatch.length
|
|
}
|
|
return result + template.slice(cursor)
|
|
}
|
|
|
|
async function replaceEnvVarsAsync(
|
|
template: string,
|
|
replacer: (match: string) => Promise<string>
|
|
): Promise<string> {
|
|
const pattern = createEnvVarPattern()
|
|
let cursor = 0
|
|
let result = ''
|
|
for (const match of template.matchAll(pattern)) {
|
|
const fullMatch = match[0]
|
|
const index = match.index ?? 0
|
|
result += template.slice(cursor, index)
|
|
result += await replacer(fullMatch)
|
|
cursor = index + fullMatch.length
|
|
}
|
|
return result + template.slice(cursor)
|
|
}
|
|
|
|
type ShellQuoteContext = 'single' | 'double' | null
|
|
type CodeStringQuoteContext = ShellQuoteContext | 'triple-single' | 'triple-double' | 'template'
|
|
type CodeScanMode =
|
|
| { type: 'normal' }
|
|
| { type: 'single' }
|
|
| { type: 'double' }
|
|
| { type: 'triple-single' }
|
|
| { type: 'triple-double' }
|
|
| { type: 'template' }
|
|
| { type: 'template-expression'; depth: number }
|
|
| { type: 'line-comment' }
|
|
| { type: 'block-comment' }
|
|
|
|
export class VariableResolver {
|
|
private resolvers: Resolver[]
|
|
private blockResolver: BlockResolver
|
|
|
|
constructor(
|
|
workflow: SerializedWorkflow,
|
|
workflowVariables: Record<string, any>,
|
|
private state: ExecutionState,
|
|
options: { navigatePathAsync?: AsyncPathNavigator } = {}
|
|
) {
|
|
this.blockResolver = new BlockResolver(workflow, options.navigatePathAsync)
|
|
this.resolvers = [
|
|
new LoopResolver(workflow, options.navigatePathAsync),
|
|
new ParallelResolver(workflow, options.navigatePathAsync),
|
|
new WorkflowResolver(workflowVariables, options.navigatePathAsync),
|
|
new EnvResolver(),
|
|
this.blockResolver,
|
|
]
|
|
}
|
|
|
|
/**
|
|
* Resolves inputs for function blocks. Block output references in the `code` field
|
|
* are stored as named context variables instead of being embedded as JavaScript
|
|
* literals, preventing large values from bloating the code string.
|
|
*
|
|
* Returns runtime inputs, display inputs, and a `contextVariables` map. Callers
|
|
* should inject contextVariables into the function execution request body so the
|
|
* isolated VM can access them as global variables.
|
|
*/
|
|
async resolveInputsForFunctionBlock(
|
|
ctx: ExecutionContext,
|
|
currentNodeId: string,
|
|
params: Record<string, any> | null | undefined,
|
|
block: SerializedBlock
|
|
): Promise<{
|
|
resolvedInputs: Record<string, any>
|
|
displayInputs: Record<string, any>
|
|
contextVariables: Record<string, unknown>
|
|
}> {
|
|
const contextVariables: Record<string, unknown> = {}
|
|
const resolved: Record<string, any> = {}
|
|
const display: Record<string, any> = {}
|
|
const offloadState = createFunctionContextOffloadState()
|
|
|
|
if (!params) {
|
|
return { resolvedInputs: resolved, displayInputs: display, contextVariables }
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (key === 'code') {
|
|
if (typeof value === 'string') {
|
|
const code = await this.resolveCodeWithContextVars(
|
|
ctx,
|
|
currentNodeId,
|
|
value,
|
|
undefined,
|
|
block,
|
|
contextVariables,
|
|
offloadState
|
|
)
|
|
resolved[key] = code.resolvedCode
|
|
display[key] = code.displayCode
|
|
} else if (Array.isArray(value)) {
|
|
const resolvedItems: any[] = []
|
|
const displayItems: any[] = []
|
|
for (const item of value) {
|
|
if (item && typeof item === 'object' && typeof item.content === 'string') {
|
|
const code = await this.resolveCodeWithContextVars(
|
|
ctx,
|
|
currentNodeId,
|
|
item.content,
|
|
undefined,
|
|
block,
|
|
contextVariables,
|
|
offloadState
|
|
)
|
|
resolvedItems.push({
|
|
...item,
|
|
content: code.resolvedCode,
|
|
})
|
|
displayItems.push({
|
|
...item,
|
|
content: code.displayCode,
|
|
})
|
|
continue
|
|
}
|
|
resolvedItems.push(item)
|
|
displayItems.push(item)
|
|
}
|
|
resolved[key] = resolvedItems
|
|
display[key] = displayItems
|
|
} else {
|
|
resolved[key] = await this.resolveValue(ctx, currentNodeId, value, undefined, block)
|
|
display[key] = resolved[key]
|
|
}
|
|
} else {
|
|
resolved[key] = await this.resolveValue(ctx, currentNodeId, value, undefined, block)
|
|
display[key] = resolved[key]
|
|
}
|
|
}
|
|
|
|
return { resolvedInputs: resolved, displayInputs: display, contextVariables }
|
|
}
|
|
|
|
async resolveInputs(
|
|
ctx: ExecutionContext,
|
|
currentNodeId: string,
|
|
params: Record<string, any>,
|
|
block?: SerializedBlock
|
|
): Promise<Record<string, any>> {
|
|
if (!params) {
|
|
return {}
|
|
}
|
|
const resolved: Record<string, any> = {}
|
|
|
|
const isConditionBlock = block?.metadata?.id === BlockType.CONDITION
|
|
if (isConditionBlock && typeof params.conditions === 'string') {
|
|
try {
|
|
const parsed = JSON.parse(params.conditions)
|
|
if (Array.isArray(parsed)) {
|
|
resolved.conditions = await Promise.all(
|
|
parsed.map(async (cond: any) => ({
|
|
...cond,
|
|
value:
|
|
typeof cond.value === 'string'
|
|
? await this.resolveTemplateWithoutConditionFormatting(
|
|
ctx,
|
|
currentNodeId,
|
|
cond.value
|
|
)
|
|
: cond.value,
|
|
}))
|
|
)
|
|
} else {
|
|
resolved.conditions = await this.resolveValue(
|
|
ctx,
|
|
currentNodeId,
|
|
params.conditions,
|
|
undefined,
|
|
block
|
|
)
|
|
}
|
|
} catch (parseError) {
|
|
logger.warn('Failed to parse conditions JSON, falling back to normal resolution', {
|
|
error: parseError,
|
|
conditions: params.conditions,
|
|
})
|
|
resolved.conditions = await this.resolveValue(
|
|
ctx,
|
|
currentNodeId,
|
|
params.conditions,
|
|
undefined,
|
|
block
|
|
)
|
|
}
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (isConditionBlock && key === 'conditions') {
|
|
continue
|
|
}
|
|
resolved[key] = await this.resolveValue(ctx, currentNodeId, value, undefined, block, {
|
|
allowLargeValueRefs: this.canResolveInputToLargeValueRef(block, key),
|
|
})
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
private canResolveInputToLargeValueRef(block: SerializedBlock | undefined, key: string): boolean {
|
|
if (block?.metadata?.id === BlockType.VARIABLES) {
|
|
return key === 'variables'
|
|
}
|
|
|
|
if (block?.metadata?.id === BlockType.RESPONSE) {
|
|
return key === 'data' || key === 'builderData'
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
async resolveSingleReference(
|
|
ctx: ExecutionContext,
|
|
currentNodeId: string,
|
|
reference: string,
|
|
loopScope?: LoopScope,
|
|
options: { allowLargeValueRefs?: boolean } = {}
|
|
): Promise<any> {
|
|
if (typeof reference === 'string') {
|
|
const trimmed = reference.trim()
|
|
if (/^<[^<>]+>$/.test(trimmed)) {
|
|
const resolutionContext: ResolutionContext = {
|
|
executionContext: ctx,
|
|
executionState: this.state,
|
|
currentNodeId,
|
|
loopScope,
|
|
allowLargeValueRefs: options.allowLargeValueRefs,
|
|
}
|
|
|
|
const result = await this.resolveReference(trimmed, resolutionContext)
|
|
if (result === RESOLVED_EMPTY) {
|
|
return null
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
return this.resolveValue(ctx, currentNodeId, reference, loopScope)
|
|
}
|
|
|
|
private async resolveValue(
|
|
ctx: ExecutionContext,
|
|
currentNodeId: string,
|
|
value: any,
|
|
loopScope?: LoopScope,
|
|
block?: SerializedBlock,
|
|
options: { allowLargeValueRefs?: boolean } = {}
|
|
): Promise<any> {
|
|
if (value === null || value === undefined) {
|
|
return value
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return Promise.all(
|
|
value.map((v) => this.resolveValue(ctx, currentNodeId, v, loopScope, block, options))
|
|
)
|
|
}
|
|
|
|
if (typeof value === 'object') {
|
|
const entries = await Promise.all(
|
|
Object.entries(value).map(async ([key, val]) => [
|
|
key,
|
|
await this.resolveValue(ctx, currentNodeId, val, loopScope, block, options),
|
|
])
|
|
)
|
|
return Object.fromEntries(entries)
|
|
}
|
|
|
|
if (typeof value === 'string') {
|
|
return this.resolveTemplate(ctx, currentNodeId, value, loopScope, block, options)
|
|
}
|
|
return value
|
|
}
|
|
/**
|
|
* Resolves a code template for a function block. Block output references are stored
|
|
* in `contextVarAccumulator` as named variables (e.g. `__blockRef_0`) and replaced
|
|
* with those variable names in the returned code string. Non-block references (loop
|
|
* items, workflow variables, env vars) are still inlined as literals so they remain
|
|
* available without any extra passing mechanism.
|
|
*/
|
|
private async resolveCodeWithContextVars(
|
|
ctx: ExecutionContext,
|
|
currentNodeId: string,
|
|
template: string,
|
|
loopScope: LoopScope | undefined,
|
|
block: SerializedBlock,
|
|
contextVarAccumulator: Record<string, unknown>,
|
|
offloadState: FunctionContextOffloadState = createFunctionContextOffloadState()
|
|
): Promise<{ resolvedCode: string; displayCode: string }> {
|
|
const resolutionContext: ResolutionContext = {
|
|
executionContext: ctx,
|
|
executionState: this.state,
|
|
currentNodeId,
|
|
loopScope,
|
|
allowLargeValueRefs: true,
|
|
}
|
|
|
|
const language = (block.config?.params as Record<string, unknown> | undefined)?.language as
|
|
| string
|
|
| undefined
|
|
|
|
let replacementError: Error | null = null
|
|
let displayResult = ''
|
|
let displayCursor = 0
|
|
|
|
let result = await replaceValidReferencesAsync(template, async (match, index) => {
|
|
if (replacementError) return match
|
|
displayResult += template.slice(displayCursor, index)
|
|
displayCursor = index + match.length
|
|
|
|
try {
|
|
const lazyBase64 = await this.resolveLazyFileBase64Reference(
|
|
match,
|
|
resolutionContext,
|
|
language,
|
|
template,
|
|
index,
|
|
contextVarAccumulator
|
|
)
|
|
if (lazyBase64) {
|
|
displayResult += lazyBase64.display
|
|
return lazyBase64.replacement
|
|
}
|
|
|
|
if (this.blockResolver.canResolve(match)) {
|
|
const resolved = await this.resolveReference(match, resolutionContext)
|
|
if (resolved === undefined) {
|
|
displayResult += match
|
|
return match
|
|
}
|
|
|
|
const effectiveValue = resolved === RESOLVED_EMPTY ? null : resolved
|
|
|
|
// Block output: store in contextVarAccumulator and replace the reference
|
|
// with language-specific runtime access to that stored value.
|
|
const varName = `__blockRef_${Object.keys(contextVarAccumulator).length}`
|
|
let replacement: string
|
|
// `storedValue` is placed in contextVarAccumulator and `displayValue` is
|
|
// rendered into the display source. They diverge only when an oversized
|
|
// inline value is offloaded to a ref (both then carry the small ref).
|
|
let storedValue: unknown = effectiveValue
|
|
let displayValue: unknown = effectiveValue
|
|
if (isLargeValueRef(effectiveValue)) {
|
|
const lazyReplacement = this.formatLazyLargeValueReference(
|
|
varName,
|
|
language,
|
|
template,
|
|
index
|
|
)
|
|
if (!lazyReplacement) {
|
|
throw getLargeValueMaterializationError(effectiveValue)
|
|
}
|
|
replacement = lazyReplacement
|
|
} else if (isLargeArrayManifest(effectiveValue)) {
|
|
const lazyReplacement = this.formatLazyLargeArrayManifestReference(
|
|
varName,
|
|
language,
|
|
template,
|
|
index
|
|
)
|
|
if (!lazyReplacement) {
|
|
throw getNestedLargeValueMaterializationError()
|
|
}
|
|
replacement = lazyReplacement
|
|
} else if (containsLargeValueRef(effectiveValue)) {
|
|
throw getNestedLargeValueMaterializationError()
|
|
} else {
|
|
const offloadedRef = await this.maybeOffloadInlineFunctionContextValue(
|
|
ctx,
|
|
effectiveValue,
|
|
language,
|
|
template,
|
|
offloadState
|
|
)
|
|
if (offloadedRef) {
|
|
storedValue = offloadedRef
|
|
displayValue = offloadedRef
|
|
// maybeOffload only returns a ref when the JS runtime helpers are usable —
|
|
// the same guard formatLazyLargeValueReference needs — so it is non-null here.
|
|
replacement =
|
|
this.formatLazyLargeValueReference(varName, language, template, index) ??
|
|
this.formatContextVariableReference(
|
|
varName,
|
|
language,
|
|
template,
|
|
index,
|
|
effectiveValue
|
|
)
|
|
} else {
|
|
replacement = this.formatContextVariableReference(
|
|
varName,
|
|
language,
|
|
template,
|
|
index,
|
|
effectiveValue
|
|
)
|
|
}
|
|
}
|
|
contextVarAccumulator[varName] = storedValue
|
|
displayResult += this.formatDisplayValueForCodeContext(
|
|
displayValue,
|
|
language,
|
|
template,
|
|
index
|
|
)
|
|
return replacement
|
|
}
|
|
|
|
const resolved = await this.resolveReference(match, resolutionContext)
|
|
if (resolved === undefined) {
|
|
displayResult += match
|
|
return match
|
|
}
|
|
|
|
const effectiveValue = resolved === RESOLVED_EMPTY ? null : resolved
|
|
|
|
if (isLargeValueRef(effectiveValue)) {
|
|
const varName = `__blockRef_${Object.keys(contextVarAccumulator).length}`
|
|
contextVarAccumulator[varName] = effectiveValue
|
|
const lazyReplacement = this.formatLazyLargeValueReference(
|
|
varName,
|
|
language,
|
|
template,
|
|
index
|
|
)
|
|
if (lazyReplacement) {
|
|
displayResult += this.formatDisplayValueForCodeContext(
|
|
effectiveValue,
|
|
language,
|
|
template,
|
|
index
|
|
)
|
|
return lazyReplacement
|
|
}
|
|
throw getLargeValueMaterializationError(effectiveValue)
|
|
}
|
|
|
|
if (isLargeArrayManifest(effectiveValue)) {
|
|
const varName = `__blockRef_${Object.keys(contextVarAccumulator).length}`
|
|
contextVarAccumulator[varName] = effectiveValue
|
|
const lazyReplacement = this.formatLazyLargeArrayManifestReference(
|
|
varName,
|
|
language,
|
|
template,
|
|
index
|
|
)
|
|
if (lazyReplacement) {
|
|
displayResult += this.formatDisplayValueForCodeContext(
|
|
effectiveValue,
|
|
language,
|
|
template,
|
|
index
|
|
)
|
|
return lazyReplacement
|
|
}
|
|
throw getNestedLargeValueMaterializationError()
|
|
}
|
|
|
|
if (containsLargeValueRef(effectiveValue)) {
|
|
throw getNestedLargeValueMaterializationError()
|
|
}
|
|
|
|
if (
|
|
this.isWorkflowVariableReference(match) &&
|
|
this.shouldUseContextVariable(effectiveValue)
|
|
) {
|
|
const varName = `__blockRef_${Object.keys(contextVarAccumulator).length}`
|
|
contextVarAccumulator[varName] = effectiveValue
|
|
const replacement = this.formatContextVariableReference(
|
|
varName,
|
|
language,
|
|
template,
|
|
index,
|
|
effectiveValue
|
|
)
|
|
displayResult += this.formatDisplayValueForCodeContext(
|
|
effectiveValue,
|
|
language,
|
|
template,
|
|
index
|
|
)
|
|
return replacement
|
|
}
|
|
|
|
const replacement = this.blockResolver.formatValueForBlock(
|
|
effectiveValue,
|
|
BlockType.FUNCTION,
|
|
language
|
|
)
|
|
displayResult += replacement
|
|
return replacement
|
|
} catch (error) {
|
|
replacementError = error instanceof Error ? error : new Error(String(error))
|
|
displayResult += match
|
|
return match
|
|
}
|
|
})
|
|
displayResult += template.slice(displayCursor)
|
|
|
|
if (replacementError !== null) {
|
|
throw replacementError
|
|
}
|
|
|
|
result = await replaceEnvVarsAsync(result, async (match) => {
|
|
const resolved = await this.resolveReference(match, resolutionContext)
|
|
return typeof resolved === 'string' ? resolved : match
|
|
})
|
|
displayResult = await replaceEnvVarsAsync(displayResult, async (match) => {
|
|
const resolved = await this.resolveReference(match, resolutionContext)
|
|
return typeof resolved === 'string' ? resolved : match
|
|
})
|
|
|
|
return { resolvedCode: result, displayCode: displayResult }
|
|
}
|
|
|
|
private async resolveLazyFileBase64Reference(
|
|
reference: string,
|
|
context: ResolutionContext,
|
|
language: string | undefined,
|
|
template: string,
|
|
matchIndex: number,
|
|
contextVarAccumulator: Record<string, unknown>
|
|
): Promise<{ replacement: string; display: string } | null> {
|
|
if (!this.canUseJavaScriptRuntimeHelpers(language, template)) {
|
|
return null
|
|
}
|
|
|
|
const parts = parseReferencePath(reference)
|
|
if (parts.length < 3 || parts.at(-1) !== 'base64') {
|
|
return null
|
|
}
|
|
|
|
const fileReference = `${REFERENCE.START}${parts.slice(0, -1).join(REFERENCE.PATH_DELIMITER)}${REFERENCE.END}`
|
|
const file = await this.resolveReference(fileReference, context)
|
|
if (!isUserFileWithMetadata(file)) {
|
|
return null
|
|
}
|
|
if (!file.key) {
|
|
return null
|
|
}
|
|
|
|
const varName = `__blockRef_${Object.keys(contextVarAccumulator).length}`
|
|
const { base64: _base64, ...fileMetadata } = file
|
|
contextVarAccumulator[varName] = fileMetadata
|
|
const fileExpression = `globalThis[${JSON.stringify(varName)}]`
|
|
const lazyExpression = `(await sim.files.readBase64(${fileExpression}))`
|
|
|
|
return {
|
|
replacement: this.formatJavaScriptAsyncExpression(lazyExpression, template, matchIndex),
|
|
display: reference,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Offloads an inline function block-output value to a durable large-value ref when
|
|
* keeping it inline would push the function execution request body past its budget.
|
|
*
|
|
* A few medium values merged in one function block (for example two fetched images)
|
|
* can exceed the ~10 MB internal-route body cap even though no single value crosses
|
|
* the per-value large-value threshold. Offloading the overflowing values lets the
|
|
* function runtime lazily re-read them via the `sim.values.read` broker instead of
|
|
* inlining their bytes into the request.
|
|
*
|
|
* Returns the stored reference when offloaded, or `null` to keep the value inline.
|
|
*/
|
|
private async maybeOffloadInlineFunctionContextValue(
|
|
ctx: ExecutionContext,
|
|
value: unknown,
|
|
language: string | undefined,
|
|
template: string,
|
|
offloadState: FunctionContextOffloadState
|
|
): Promise<LargeValueRef | null> {
|
|
// Lazy re-reading is only available in the JavaScript isolated-vm runtime; other
|
|
// runtimes have no broker to materialize a ref, so the value must stay inline.
|
|
if (!this.canUseJavaScriptRuntimeHelpers(language, template)) {
|
|
return null
|
|
}
|
|
if (!ctx.workspaceId || !ctx.workflowId || !ctx.executionId) {
|
|
return null
|
|
}
|
|
|
|
const measured = measureJson(value)
|
|
if (measured === null) {
|
|
return null
|
|
}
|
|
|
|
// Inline values are serialized into both the request data and the display source.
|
|
const footprint = measured.size * 2
|
|
if (footprint <= offloadState.inlineFootprintRemaining) {
|
|
offloadState.inlineFootprintRemaining -= footprint
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const { storeLargeValue } = await import('@/lib/execution/payloads/store')
|
|
const ref = await storeLargeValue(value, measured.json, measured.size, {
|
|
workspaceId: ctx.workspaceId,
|
|
workflowId: ctx.workflowId,
|
|
executionId: ctx.executionId,
|
|
userId: ctx.userId,
|
|
requireDurable: true,
|
|
})
|
|
// Authorize the function route to materialize the ref it is about to receive.
|
|
if (ref.key) {
|
|
mergeLargeValueKeys(ctx, [ref.key])
|
|
}
|
|
return ref
|
|
} catch (error) {
|
|
logger.warn('Failed to offload oversized function context value; keeping inline', {
|
|
error: toError(error).message,
|
|
})
|
|
return null
|
|
}
|
|
}
|
|
|
|
private formatLazyLargeValueReference(
|
|
varName: string,
|
|
language: string | undefined,
|
|
template: string,
|
|
matchIndex: number
|
|
): string | null {
|
|
if (!this.canUseJavaScriptRuntimeHelpers(language, template)) {
|
|
return null
|
|
}
|
|
|
|
const expression = `(await sim.values.read(globalThis[${JSON.stringify(varName)}]))`
|
|
return this.formatJavaScriptAsyncExpression(expression, template, matchIndex, {
|
|
stringifyInStringContext: true,
|
|
})
|
|
}
|
|
|
|
private formatLazyLargeArrayManifestReference(
|
|
varName: string,
|
|
language: string | undefined,
|
|
template: string,
|
|
matchIndex: number
|
|
): string | null {
|
|
if (!this.canUseJavaScriptRuntimeHelpers(language, template)) {
|
|
return null
|
|
}
|
|
|
|
const expression = `(await sim.values.readArray(globalThis[${JSON.stringify(varName)}]))`
|
|
return this.formatJavaScriptAsyncExpression(expression, template, matchIndex, {
|
|
stringifyInStringContext: true,
|
|
})
|
|
}
|
|
|
|
private isWorkflowVariableReference(reference: string): boolean {
|
|
const parts = parseReferencePath(reference)
|
|
return parts[0] === REFERENCE.PREFIX.VARIABLE
|
|
}
|
|
|
|
private shouldUseContextVariable(value: unknown): boolean {
|
|
return typeof value === 'object' && value !== null
|
|
}
|
|
|
|
private formatJavaScriptAsyncExpression(
|
|
expression: string,
|
|
template: string,
|
|
matchIndex: number,
|
|
options: { stringifyInStringContext?: boolean } = {}
|
|
): string {
|
|
const quoteContext = this.getCodeStringQuoteContext(template, matchIndex, 'javascript')
|
|
const stringExpression = options.stringifyInStringContext
|
|
? `JSON.stringify(${expression})`
|
|
: expression
|
|
|
|
if (quoteContext === 'template') {
|
|
return `\${${stringExpression}}`
|
|
}
|
|
if (quoteContext === 'single' || quoteContext === 'double') {
|
|
const quote = this.getCodeStringQuoteToken(quoteContext)
|
|
return `${quote} + ${stringExpression} + ${quote}`
|
|
}
|
|
return expression
|
|
}
|
|
|
|
private canUseJavaScriptRuntimeHelpers(language: string | undefined, template: string): boolean {
|
|
if (language !== 'javascript') {
|
|
return false
|
|
}
|
|
return !this.hasJavaScriptModuleDependencySyntax(template)
|
|
}
|
|
|
|
private hasJavaScriptModuleDependencySyntax(template: string): boolean {
|
|
const modes: CodeScanMode[] = [{ type: 'normal' }]
|
|
|
|
for (let i = 0; i < template.length; i++) {
|
|
const char = template[i]
|
|
const next = template[i + 1]
|
|
const mode = modes[modes.length - 1]
|
|
|
|
if (mode.type === 'line-comment') {
|
|
if (char === '\n') modes.pop()
|
|
continue
|
|
}
|
|
|
|
if (mode.type === 'block-comment') {
|
|
if (char === '*' && next === '/') {
|
|
modes.pop()
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (mode.type === 'single' || mode.type === 'double') {
|
|
const quote = mode.type === 'single' ? "'" : '"'
|
|
if (char === '\\') {
|
|
i++
|
|
continue
|
|
}
|
|
if (char === quote || char === '\n') modes.pop()
|
|
continue
|
|
}
|
|
|
|
if (mode.type === 'template') {
|
|
if (char === '\\') {
|
|
i++
|
|
continue
|
|
}
|
|
if (char === '`') {
|
|
modes.pop()
|
|
continue
|
|
}
|
|
if (char === '$' && next === '{') {
|
|
modes.push({ type: 'template-expression', depth: 1 })
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
|
|
const isCodeMode = mode.type === 'normal' || mode.type === 'template-expression'
|
|
if (!isCodeMode) continue
|
|
|
|
if (char === '/' && next === '/') {
|
|
modes.push({ type: 'line-comment' })
|
|
i++
|
|
continue
|
|
}
|
|
if (char === '/' && next === '*') {
|
|
modes.push({ type: 'block-comment' })
|
|
i++
|
|
continue
|
|
}
|
|
if (char === "'") {
|
|
modes.push({ type: 'single' })
|
|
continue
|
|
}
|
|
if (char === '"') {
|
|
modes.push({ type: 'double' })
|
|
continue
|
|
}
|
|
if (char === '`') {
|
|
modes.push({ type: 'template' })
|
|
continue
|
|
}
|
|
|
|
if (mode.type === 'template-expression') {
|
|
if (char === '{') {
|
|
mode.depth += 1
|
|
continue
|
|
}
|
|
if (char === '}') {
|
|
mode.depth -= 1
|
|
if (mode.depth === 0) modes.pop()
|
|
continue
|
|
}
|
|
}
|
|
|
|
if (this.startsWithStaticImport(template, i) || this.startsWithRequireCall(template, i)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private startsWithStaticImport(template: string, index: number): boolean {
|
|
if (!this.matchesKeywordAt(template, index, 'import')) {
|
|
return false
|
|
}
|
|
const nextIndex = this.skipWhitespace(template, index + 'import'.length)
|
|
if (nextIndex === index + 'import'.length) {
|
|
return false
|
|
}
|
|
return template[nextIndex] !== '('
|
|
}
|
|
|
|
private startsWithRequireCall(template: string, index: number): boolean {
|
|
if (!this.matchesKeywordAt(template, index, 'require')) {
|
|
return false
|
|
}
|
|
const openParenIndex = this.skipWhitespace(template, index + 'require'.length)
|
|
if (template[openParenIndex] !== '(') {
|
|
return false
|
|
}
|
|
const argumentIndex = this.skipWhitespace(template, openParenIndex + 1)
|
|
return (
|
|
template[argumentIndex] === "'" ||
|
|
template[argumentIndex] === '"' ||
|
|
template[argumentIndex] === '`'
|
|
)
|
|
}
|
|
|
|
private matchesKeywordAt(template: string, index: number, keyword: string): boolean {
|
|
if (!template.startsWith(keyword, index)) {
|
|
return false
|
|
}
|
|
const before = index > 0 ? template[index - 1] : ''
|
|
const after = template[index + keyword.length] ?? ''
|
|
return !this.isJavaScriptIdentifierChar(before) && !this.isJavaScriptIdentifierChar(after)
|
|
}
|
|
|
|
private skipWhitespace(template: string, index: number): number {
|
|
let cursor = index
|
|
while (cursor < template.length && /\s/.test(template[cursor])) {
|
|
cursor++
|
|
}
|
|
return cursor
|
|
}
|
|
|
|
private isJavaScriptIdentifierChar(char: string): boolean {
|
|
return /[A-Za-z0-9_$]/.test(char)
|
|
}
|
|
|
|
private formatContextVariableReference(
|
|
varName: string,
|
|
language: string | undefined,
|
|
template: string,
|
|
matchIndex: number,
|
|
value: unknown
|
|
): string {
|
|
if (language === 'python') {
|
|
const expression = `globals()[${JSON.stringify(varName)}]`
|
|
const quoteContext = this.getCodeStringQuoteContext(template, matchIndex, language)
|
|
if (this.isPythonStringQuoteContext(quoteContext)) {
|
|
const quote = this.getCodeStringQuoteToken(quoteContext)
|
|
return `${quote} + json.dumps(${expression}) + ${quote}`
|
|
}
|
|
return expression
|
|
}
|
|
|
|
if (language === 'shell') {
|
|
return this.formatShellContextVariableReference(varName, template, matchIndex, value)
|
|
}
|
|
|
|
const expression = `globalThis[${JSON.stringify(varName)}]`
|
|
const quoteContext = this.getCodeStringQuoteContext(template, matchIndex, language)
|
|
if (quoteContext === 'template') {
|
|
return `\${JSON.stringify(${expression})}`
|
|
}
|
|
if (quoteContext === 'single' || quoteContext === 'double') {
|
|
const quote = this.getCodeStringQuoteToken(quoteContext)
|
|
return `${quote} + JSON.stringify(${expression}) + ${quote}`
|
|
}
|
|
return expression
|
|
}
|
|
|
|
private isPythonStringQuoteContext(
|
|
quoteContext: CodeStringQuoteContext
|
|
): quoteContext is 'single' | 'double' | 'triple-single' | 'triple-double' {
|
|
return (
|
|
quoteContext === 'single' ||
|
|
quoteContext === 'double' ||
|
|
quoteContext === 'triple-single' ||
|
|
quoteContext === 'triple-double'
|
|
)
|
|
}
|
|
|
|
private getCodeStringQuoteToken(
|
|
quoteContext: 'single' | 'double' | 'triple-single' | 'triple-double'
|
|
): string {
|
|
if (quoteContext === 'single') return "'"
|
|
if (quoteContext === 'double') return '"'
|
|
if (quoteContext === 'triple-single') return "'''"
|
|
return '"""'
|
|
}
|
|
|
|
private formatDisplayValueForCodeContext(
|
|
value: unknown,
|
|
language: string | undefined,
|
|
template: string,
|
|
matchIndex: number
|
|
): string {
|
|
// Offloaded large values carry only a storage ref (or array manifest), never the
|
|
// data itself. Render a concise placeholder for the Input view instead of leaking
|
|
// the internal ref object, which is unreadable and meaningless to a user.
|
|
if (isLargeValueRef(value)) {
|
|
return `/* large ${value.kind} · ${formatLargeValueSize(value.size)}, fetched at runtime */`
|
|
}
|
|
if (isLargeArrayManifest(value)) {
|
|
return `/* large array · ${value.totalCount} items · ${formatLargeValueSize(value.byteSize)}, fetched at runtime */`
|
|
}
|
|
|
|
if (language === 'shell') {
|
|
return this.formatShellDisplayValue(value, template, matchIndex)
|
|
}
|
|
|
|
return this.blockResolver.formatValueForBlock(value, BlockType.FUNCTION, language)
|
|
}
|
|
|
|
private formatShellDisplayValue(value: unknown, template: string, matchIndex: number): string {
|
|
const text = this.stringifyShellDisplayValue(value)
|
|
const quoteContext = this.getShellQuoteContext(template, matchIndex)
|
|
if (quoteContext === 'double') {
|
|
return text.replace(/["\\$`]/g, '\\$&')
|
|
}
|
|
|
|
return `"${text.replace(/["\\$`]/g, '\\$&')}"`
|
|
}
|
|
|
|
private stringifyShellDisplayValue(value: unknown): string {
|
|
if (value === null || value === undefined) {
|
|
return ''
|
|
}
|
|
if (typeof value === 'string') {
|
|
return value
|
|
}
|
|
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
|
return String(value)
|
|
}
|
|
return JSON.stringify(value)
|
|
}
|
|
|
|
private getCodeStringQuoteContext(
|
|
template: string,
|
|
index: number,
|
|
language: string | undefined
|
|
): CodeStringQuoteContext {
|
|
const isPython = language === 'python'
|
|
const modes: CodeScanMode[] = [{ type: 'normal' }]
|
|
|
|
for (let i = 0; i < index; i++) {
|
|
const char = template[i]
|
|
const next = template[i + 1]
|
|
const mode = modes[modes.length - 1]
|
|
|
|
if (mode.type === 'line-comment') {
|
|
if (char === '\n') {
|
|
modes.pop()
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (mode.type === 'block-comment') {
|
|
if (char === '*' && next === '/') {
|
|
modes.pop()
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (mode.type === 'single' || mode.type === 'double') {
|
|
const quote = mode.type === 'single' ? "'" : '"'
|
|
if (char === '\\') {
|
|
i++
|
|
continue
|
|
}
|
|
if (char === quote || char === '\n') {
|
|
modes.pop()
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (mode.type === 'triple-single' || mode.type === 'triple-double') {
|
|
const quote = mode.type === 'triple-single' ? "'" : '"'
|
|
if (char === '\\') {
|
|
i++
|
|
continue
|
|
}
|
|
if (char === quote && next === quote && template[i + 2] === quote) {
|
|
modes.pop()
|
|
i += 2
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (mode.type === 'template') {
|
|
if (char === '\\') {
|
|
i++
|
|
continue
|
|
}
|
|
if (char === '`') {
|
|
modes.pop()
|
|
continue
|
|
}
|
|
if (char === '$' && next === '{') {
|
|
modes.push({ type: 'template-expression', depth: 1 })
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (mode.type === 'template-expression') {
|
|
if (!isPython && char === '/' && next === '/') {
|
|
modes.push({ type: 'line-comment' })
|
|
i++
|
|
continue
|
|
}
|
|
if (!isPython && char === '/' && next === '*') {
|
|
modes.push({ type: 'block-comment' })
|
|
i++
|
|
continue
|
|
}
|
|
if (isPython && char === "'" && next === "'" && template[i + 2] === "'") {
|
|
modes.push({ type: 'triple-single' })
|
|
i += 2
|
|
continue
|
|
}
|
|
if (isPython && char === '"' && next === '"' && template[i + 2] === '"') {
|
|
modes.push({ type: 'triple-double' })
|
|
i += 2
|
|
continue
|
|
}
|
|
if (char === "'") {
|
|
modes.push({ type: 'single' })
|
|
continue
|
|
}
|
|
if (char === '"') {
|
|
modes.push({ type: 'double' })
|
|
continue
|
|
}
|
|
if (!isPython && char === '`') {
|
|
modes.push({ type: 'template' })
|
|
continue
|
|
}
|
|
if (char === '{') {
|
|
mode.depth += 1
|
|
continue
|
|
}
|
|
if (char === '}') {
|
|
mode.depth -= 1
|
|
if (mode.depth === 0) {
|
|
modes.pop()
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (isPython && char === '#') {
|
|
modes.push({ type: 'line-comment' })
|
|
continue
|
|
}
|
|
if (!isPython && char === '/' && next === '/') {
|
|
modes.push({ type: 'line-comment' })
|
|
i++
|
|
continue
|
|
}
|
|
if (!isPython && char === '/' && next === '*') {
|
|
modes.push({ type: 'block-comment' })
|
|
i++
|
|
continue
|
|
}
|
|
if (isPython && char === "'" && next === "'" && template[i + 2] === "'") {
|
|
modes.push({ type: 'triple-single' })
|
|
i += 2
|
|
} else if (isPython && char === '"' && next === '"' && template[i + 2] === '"') {
|
|
modes.push({ type: 'triple-double' })
|
|
i += 2
|
|
} else if (char === "'") {
|
|
modes.push({ type: 'single' })
|
|
} else if (char === '"') {
|
|
modes.push({ type: 'double' })
|
|
} else if (!isPython && char === '`') {
|
|
modes.push({ type: 'template' })
|
|
}
|
|
}
|
|
|
|
const mode = modes[modes.length - 1]
|
|
if (
|
|
mode.type === 'single' ||
|
|
mode.type === 'double' ||
|
|
mode.type === 'triple-single' ||
|
|
mode.type === 'triple-double' ||
|
|
mode.type === 'template'
|
|
) {
|
|
return mode.type
|
|
}
|
|
return null
|
|
}
|
|
|
|
private formatShellContextVariableReference(
|
|
varName: string,
|
|
template: string,
|
|
matchIndex: number,
|
|
value: unknown
|
|
): string {
|
|
const expansion = `\${${varName}}`
|
|
const quoteContext = this.getShellQuoteContext(template, matchIndex)
|
|
if (quoteContext === 'double') {
|
|
return expansion
|
|
}
|
|
|
|
const shouldQuote =
|
|
quoteContext === 'single' ||
|
|
typeof value === 'string' ||
|
|
(typeof value === 'object' && value !== null) ||
|
|
Array.isArray(value)
|
|
|
|
if (!shouldQuote) {
|
|
return expansion
|
|
}
|
|
|
|
const quotedExpansion = `"${expansion}"`
|
|
if (quoteContext === 'single') {
|
|
return `'${quotedExpansion}'`
|
|
}
|
|
|
|
return quotedExpansion
|
|
}
|
|
|
|
private getShellQuoteContext(template: string, index: number): ShellQuoteContext {
|
|
let quoteContext: ShellQuoteContext = null
|
|
|
|
for (let i = 0; i < index; i++) {
|
|
const char = template[i]
|
|
|
|
if (quoteContext === null && this.isShellCommentStart(template, i)) {
|
|
const nextNewline = template.indexOf('\n', i + 1)
|
|
if (nextNewline === -1 || nextNewline >= index) {
|
|
break
|
|
}
|
|
i = nextNewline
|
|
continue
|
|
}
|
|
|
|
if (char === '\\' && quoteContext !== 'single') {
|
|
i++
|
|
continue
|
|
}
|
|
|
|
if (char === "'" && quoteContext !== 'double') {
|
|
quoteContext = quoteContext === 'single' ? null : 'single'
|
|
} else if (char === '"' && quoteContext !== 'single') {
|
|
quoteContext = quoteContext === 'double' ? null : 'double'
|
|
}
|
|
}
|
|
|
|
return quoteContext
|
|
}
|
|
|
|
private isShellCommentStart(template: string, index: number): boolean {
|
|
if (template[index] !== '#') {
|
|
return false
|
|
}
|
|
|
|
const previous = template[index - 1]
|
|
return previous === undefined || /\s|[;&|()<>]/.test(previous)
|
|
}
|
|
|
|
private async resolveTemplate(
|
|
ctx: ExecutionContext,
|
|
currentNodeId: string,
|
|
template: string,
|
|
loopScope?: LoopScope,
|
|
block?: SerializedBlock,
|
|
options: { allowLargeValueRefs?: boolean } = {}
|
|
): Promise<string> {
|
|
const resolutionContext: ResolutionContext = {
|
|
executionContext: ctx,
|
|
executionState: this.state,
|
|
currentNodeId,
|
|
loopScope,
|
|
allowLargeValueRefs: options.allowLargeValueRefs,
|
|
}
|
|
|
|
let replacementError: Error | null = null
|
|
|
|
const blockType = block?.metadata?.id
|
|
const language =
|
|
blockType === BlockType.FUNCTION
|
|
? ((block?.config?.params as Record<string, unknown> | undefined)?.language as
|
|
| string
|
|
| undefined)
|
|
: undefined
|
|
|
|
let result = await replaceValidReferencesAsync(template, async (match) => {
|
|
if (replacementError) return match
|
|
|
|
try {
|
|
const resolved = await this.resolveReference(match, resolutionContext)
|
|
if (resolved === undefined) {
|
|
return match
|
|
}
|
|
|
|
if (resolved === RESOLVED_EMPTY) {
|
|
if (blockType === BlockType.FUNCTION) {
|
|
return this.blockResolver.formatValueForBlock(null, blockType, language)
|
|
}
|
|
return ''
|
|
}
|
|
|
|
return this.blockResolver.formatValueForBlock(resolved, blockType, language)
|
|
} catch (error) {
|
|
replacementError = toError(error)
|
|
return match
|
|
}
|
|
})
|
|
|
|
if (replacementError !== null) {
|
|
throw replacementError
|
|
}
|
|
|
|
result = await replaceEnvVarsAsync(result, async (match) => {
|
|
const resolved = await this.resolveReference(match, resolutionContext)
|
|
return typeof resolved === 'string' ? resolved : match
|
|
})
|
|
return result
|
|
}
|
|
|
|
private async resolveTemplateWithoutConditionFormatting(
|
|
ctx: ExecutionContext,
|
|
currentNodeId: string,
|
|
template: string,
|
|
loopScope?: LoopScope
|
|
): Promise<string> {
|
|
const resolutionContext: ResolutionContext = {
|
|
executionContext: ctx,
|
|
executionState: this.state,
|
|
currentNodeId,
|
|
loopScope,
|
|
}
|
|
|
|
let replacementError: Error | null = null
|
|
|
|
let result = await replaceValidReferencesAsync(template, async (match) => {
|
|
if (replacementError) return match
|
|
|
|
try {
|
|
const resolved = await this.resolveReference(match, resolutionContext)
|
|
if (resolved === undefined) {
|
|
return match
|
|
}
|
|
|
|
if (resolved === RESOLVED_EMPTY) {
|
|
return 'null'
|
|
}
|
|
|
|
if (typeof resolved === 'string') {
|
|
const escaped = resolved
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/'/g, "\\'")
|
|
.replace(/\n/g, '\\n')
|
|
.replace(/\r/g, '\\r')
|
|
.replace(/\u2028/g, '\\u2028')
|
|
.replace(/\u2029/g, '\\u2029')
|
|
return `'${escaped}'`
|
|
}
|
|
if (typeof resolved === 'object' && resolved !== null) {
|
|
return JSON.stringify(resolved)
|
|
}
|
|
return String(resolved)
|
|
} catch (error) {
|
|
replacementError = toError(error)
|
|
return match
|
|
}
|
|
})
|
|
|
|
if (replacementError !== null) {
|
|
throw replacementError
|
|
}
|
|
|
|
result = await replaceEnvVarsAsync(result, async (match) => {
|
|
const resolved = await this.resolveReference(match, resolutionContext)
|
|
return typeof resolved === 'string' ? resolved : match
|
|
})
|
|
return result
|
|
}
|
|
|
|
private async resolveReference(reference: string, context: ResolutionContext): Promise<any> {
|
|
for (const resolver of this.resolvers) {
|
|
if (resolver.canResolve(reference)) {
|
|
const result = resolver.resolveAsync
|
|
? await resolver.resolveAsync(reference, context)
|
|
: resolver.resolve(reference, context)
|
|
return result
|
|
}
|
|
}
|
|
|
|
logger.warn('No resolver found for reference', { reference })
|
|
return undefined
|
|
}
|
|
}
|