chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,406 @@
import { assertNoLargeValueRefs } from '@/lib/execution/payloads/large-value-ref'
import {
isReference,
normalizeName,
parseReferencePath,
SPECIAL_REFERENCE_PREFIXES,
} from '@/executor/constants'
import { getBlockSchema } from '@/executor/utils/block-data'
import {
InvalidFieldError,
type OutputSchema,
resolveBlockReference,
resolveBlockReferenceAsync,
} from '@/executor/utils/block-reference'
import { formatLiteralForCode } from '@/executor/utils/code-formatting'
import { buildClonedSubflowId, extractOuterBranchIndex } from '@/executor/utils/subflow-utils'
import {
type AsyncPathNavigator,
navigatePath,
RESOLVED_EMPTY,
type ResolutionContext,
type Resolver,
} from '@/executor/variables/resolvers/reference'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
export class BlockResolver implements Resolver {
private nameToBlockId: Map<string, string>
private blockById: Map<string, SerializedBlock>
private blockIdsInSubflows: Set<string>
private subflowContainerIds: Set<string>
constructor(
private workflow: SerializedWorkflow,
private navigatePathAsync?: AsyncPathNavigator
) {
this.nameToBlockId = new Map()
this.blockById = new Map()
this.blockIdsInSubflows = new Set()
this.subflowContainerIds = new Set([
...Object.keys(workflow.loops ?? {}),
...Object.keys(workflow.parallels ?? {}),
])
for (const block of workflow.blocks) {
this.blockById.set(block.id, block)
if (block.metadata?.name) {
// Name uniqueness is enforced at the normalized level on create/rename,
// but legacy workflows may contain names that collide only now that
// normalizeName strips dots. Dot-free names keep ownership of the key so
// previously working references never change targets.
const normalizedName = normalizeName(block.metadata.name)
const incumbentId = this.nameToBlockId.get(normalizedName)
const incumbentName = incumbentId
? this.blockById.get(incumbentId)?.metadata?.name
: undefined
if (!incumbentId || incumbentName?.includes('.')) {
this.nameToBlockId.set(normalizedName, block.id)
}
}
}
for (const loop of Object.values(workflow.loops ?? {})) {
for (const blockId of loop.nodes ?? []) {
this.blockIdsInSubflows.add(blockId)
}
}
for (const parallel of Object.values(workflow.parallels ?? {})) {
for (const blockId of parallel.nodes ?? []) {
this.blockIdsInSubflows.add(blockId)
}
}
}
canResolve(reference: string): boolean {
if (!isReference(reference)) {
return false
}
const parts = parseReferencePath(reference)
if (parts.length === 0) {
return false
}
const [type] = parts
return !(SPECIAL_REFERENCE_PREFIXES as readonly string[]).includes(type)
}
resolve(reference: string, context: ResolutionContext): any {
const parts = parseReferencePath(reference)
if (parts.length === 0) {
return undefined
}
const [blockName, ...pathParts] = parts
const blockId = this.findBlockIdByName(blockName)
if (!blockId) {
return undefined
}
const block = this.blockById.get(blockId)!
const output = this.getBlockOutput(blockId, context)
const blockData: Record<string, unknown> = {}
const blockOutputSchemas: Record<string, OutputSchema> = {}
if (output !== undefined) {
blockData[blockId] = output
}
const outputSchema = getBlockSchema(block)
if (outputSchema && Object.keys(outputSchema).length > 0) {
blockOutputSchemas[blockId] = outputSchema
}
try {
const result = resolveBlockReference(
blockName,
pathParts,
{
blockNameMapping: Object.fromEntries(this.nameToBlockId),
blockData,
blockOutputSchemas,
},
{
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
}
)!
if (result.value !== undefined) {
if (!context.allowLargeValueRefs) {
assertNoLargeValueRefs(result.value)
}
return result.value
}
const backwardsCompat = this.handleBackwardsCompatSync(block, output, pathParts)
if (backwardsCompat !== undefined) {
return backwardsCompat
}
return RESOLVED_EMPTY
} catch (error) {
if (error instanceof InvalidFieldError) {
const fallback = this.handleBackwardsCompatSync(block, output, pathParts)
if (fallback !== undefined) {
return fallback
}
}
throw error
}
}
async resolveAsync(reference: string, context: ResolutionContext): Promise<any> {
if (!this.navigatePathAsync) {
return this.resolve(reference, context)
}
const parts = parseReferencePath(reference)
if (parts.length === 0) {
return undefined
}
const [blockName, ...pathParts] = parts
const blockId = this.findBlockIdByName(blockName)
if (!blockId) {
return undefined
}
const block = this.blockById.get(blockId)!
const output = this.getBlockOutput(blockId, context)
const blockData: Record<string, unknown> = {}
const blockOutputSchemas: Record<string, OutputSchema> = {}
if (output !== undefined) {
blockData[blockId] = output
}
const outputSchema = getBlockSchema(block)
if (outputSchema && Object.keys(outputSchema).length > 0) {
blockOutputSchemas[blockId] = outputSchema
}
try {
const blockReferenceContext = {
blockNameMapping: Object.fromEntries(this.nameToBlockId),
blockData,
blockOutputSchemas,
}
const result = (await resolveBlockReferenceAsync(
blockName,
pathParts,
blockReferenceContext,
context,
this.navigatePathAsync
))!
if (result.value !== undefined) {
if (!context.allowLargeValueRefs) {
assertNoLargeValueRefs(result.value)
}
return result.value
}
const backwardsCompat = await this.handleBackwardsCompat(block, output, pathParts, context)
if (backwardsCompat !== undefined) {
return backwardsCompat
}
return RESOLVED_EMPTY
} catch (error) {
if (error instanceof InvalidFieldError) {
const fallback = await this.handleBackwardsCompat(block, output, pathParts, context)
if (fallback !== undefined) {
return fallback
}
}
throw error
}
}
private handleBackwardsCompatSync(
block: SerializedBlock,
output: unknown,
pathParts: string[]
): unknown {
if (output === undefined || pathParts.length === 0) {
return undefined
}
if (
block.metadata?.id === 'response' &&
pathParts[0] === 'response' &&
(output as Record<string, unknown>)?.response === undefined
) {
const adjustedPathParts = pathParts.slice(1)
if (adjustedPathParts.length === 0) {
return output
}
const fallbackResult = navigatePath(output, adjustedPathParts)
if (fallbackResult !== undefined) {
return fallbackResult
}
}
const outputRecord = output as Record<string, unknown> | undefined
if (
(block.metadata?.id === 'workflow' || block.metadata?.id === 'workflow_input') &&
pathParts[0] === 'result' &&
pathParts[1] === 'response' &&
outputRecord?.result !== undefined &&
typeof outputRecord.result === 'object' &&
outputRecord.result !== null &&
(outputRecord.result as Record<string, unknown>)?.response === undefined
) {
const adjustedPathParts = ['result', ...pathParts.slice(2)]
const fallbackResult = navigatePath(output, adjustedPathParts)
if (fallbackResult !== undefined) {
return fallbackResult
}
}
return undefined
}
private async handleBackwardsCompat(
block: SerializedBlock,
output: unknown,
pathParts: string[],
context: ResolutionContext
): Promise<unknown> {
const navigatePathAsync = this.navigatePathAsync
if (!navigatePathAsync) {
return this.handleBackwardsCompatSync(block, output, pathParts)
}
if (output === undefined || pathParts.length === 0) {
return undefined
}
if (
block.metadata?.id === 'response' &&
pathParts[0] === 'response' &&
(output as Record<string, unknown>)?.response === undefined
) {
const adjustedPathParts = pathParts.slice(1)
if (adjustedPathParts.length === 0) {
return output
}
const fallbackResult = await navigatePathAsync(output, adjustedPathParts, context)
if (fallbackResult !== undefined) {
return fallbackResult
}
}
const isWorkflowBlock =
block.metadata?.id === 'workflow' || block.metadata?.id === 'workflow_input'
const outputRecord = output as Record<string, Record<string, unknown> | undefined>
if (
isWorkflowBlock &&
pathParts[0] === 'result' &&
pathParts[1] === 'response' &&
outputRecord?.result?.response === undefined
) {
const adjustedPathParts = ['result', ...pathParts.slice(2)]
const fallbackResult = await navigatePathAsync(output, adjustedPathParts, context)
if (fallbackResult !== undefined) {
return fallbackResult
}
}
return undefined
}
private getBlockOutput(blockId: string, context: ResolutionContext): any {
const outerBranchIndex = extractOuterBranchIndex(context.currentNodeId)
const mappedBranchIndex =
outerBranchIndex ??
context.executionContext.parallelBlockMapping?.get(context.currentNodeId)?.iterationIndex
const shouldResolveClonedSubflowOutput =
mappedBranchIndex !== undefined &&
mappedBranchIndex > 0 &&
this.subflowContainerIds.has(blockId)
if (shouldResolveClonedSubflowOutput) {
const clonedStateOutput = context.executionState.getBlockOutput(
buildClonedSubflowId(blockId, mappedBranchIndex)
)
if (clonedStateOutput !== undefined) {
return clonedStateOutput
}
}
const stateOutput = context.executionState.getBlockOutput(blockId, context.currentNodeId)
if (stateOutput !== undefined) {
return stateOutput
}
if (
shouldResolveClonedSubflowOutput ||
(outerBranchIndex !== undefined && this.blockIdsInSubflows.has(blockId))
) {
return undefined
}
const contextState = context.executionContext.blockStates?.get(blockId)
if (contextState?.output) {
return contextState.output
}
return undefined
}
private findBlockIdByName(name: string): string | undefined {
return this.nameToBlockId.get(normalizeName(name))
}
public formatValueForBlock(value: any, blockType: string | undefined, language?: string): string {
if (blockType === 'condition') {
return this.stringifyForCondition(value)
}
if (blockType === 'function') {
return this.formatValueForCodeContext(value, language)
}
if (blockType === 'response') {
if (typeof value === 'string') {
return value
}
if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
return JSON.stringify(value)
}
return String(value)
}
if (typeof value === 'object' && value !== null) {
return JSON.stringify(value)
}
return String(value)
}
private stringifyForCondition(value: any): string {
if (typeof value === 'string') {
const sanitized = value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
return `"${sanitized}"`
}
if (value === null) {
return 'null'
}
if (value === undefined) {
return 'undefined'
}
if (typeof value === 'object') {
return JSON.stringify(value)
}
return String(value)
}
private formatValueForCodeContext(value: any, language?: string): string {
return formatLiteralForCode(value, language === 'python' ? 'python' : 'javascript')
}
}
@@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest'
import { EnvResolver } from './env'
import type { ResolutionContext } from './reference'
/**
* Creates a minimal ResolutionContext for testing.
* The EnvResolver only uses context.executionContext.environmentVariables.
*/
function createTestContext(environmentVariables: Record<string, string>): ResolutionContext {
return {
executionContext: { environmentVariables },
executionState: {},
currentNodeId: 'test-node',
} as ResolutionContext
}
describe('EnvResolver', () => {
describe('canResolve', () => {
it.concurrent('should return true for valid env var references', () => {
const resolver = new EnvResolver()
expect(resolver.canResolve('{{API_KEY}}')).toBe(true)
expect(resolver.canResolve('{{DATABASE_URL}}')).toBe(true)
expect(resolver.canResolve('{{MY_VAR}}')).toBe(true)
})
it.concurrent('should return true for env vars with underscores', () => {
const resolver = new EnvResolver()
expect(resolver.canResolve('{{MY_SECRET_KEY}}')).toBe(true)
expect(resolver.canResolve('{{SOME_LONG_VARIABLE_NAME}}')).toBe(true)
})
it.concurrent('should return true for env vars with numbers', () => {
const resolver = new EnvResolver()
expect(resolver.canResolve('{{API_KEY_2}}')).toBe(true)
expect(resolver.canResolve('{{V2_CONFIG}}')).toBe(true)
})
it.concurrent('should return false for non-env var references', () => {
const resolver = new EnvResolver()
expect(resolver.canResolve('<block.output>')).toBe(false)
expect(resolver.canResolve('<variable.myvar>')).toBe(false)
expect(resolver.canResolve('<loop.index>')).toBe(false)
expect(resolver.canResolve('plain text')).toBe(false)
expect(resolver.canResolve('{API_KEY}')).toBe(false)
expect(resolver.canResolve('{{API_KEY}')).toBe(false)
expect(resolver.canResolve('{API_KEY}}')).toBe(false)
})
})
describe('resolve', () => {
it.concurrent('should resolve existing environment variable', () => {
const resolver = new EnvResolver()
const ctx = createTestContext({ API_KEY: 'secret-api-key' })
const result = resolver.resolve('{{API_KEY}}', ctx)
expect(result).toBe('secret-api-key')
})
it.concurrent('should resolve multiple different environment variables', () => {
const resolver = new EnvResolver()
const ctx = createTestContext({
DATABASE_URL: 'postgres://localhost:5432/db',
REDIS_URL: 'redis://localhost:6379',
SECRET_KEY: 'super-secret',
})
expect(resolver.resolve('{{DATABASE_URL}}', ctx)).toBe('postgres://localhost:5432/db')
expect(resolver.resolve('{{REDIS_URL}}', ctx)).toBe('redis://localhost:6379')
expect(resolver.resolve('{{SECRET_KEY}}', ctx)).toBe('super-secret')
})
it.concurrent('should return original reference for non-existent variable', () => {
const resolver = new EnvResolver()
const ctx = createTestContext({ EXISTING: 'value' })
const result = resolver.resolve('{{NON_EXISTENT}}', ctx)
expect(result).toBe('{{NON_EXISTENT}}')
})
it.concurrent('should handle empty string value', () => {
const resolver = new EnvResolver()
const ctx = createTestContext({ EMPTY_VAR: '' })
const result = resolver.resolve('{{EMPTY_VAR}}', ctx)
expect(result).toBe('')
})
it.concurrent('should handle value with special characters', () => {
const resolver = new EnvResolver()
const ctx = createTestContext({
SPECIAL: 'value with spaces & special chars: !@#$%^&*()',
})
const result = resolver.resolve('{{SPECIAL}}', ctx)
expect(result).toBe('value with spaces & special chars: !@#$%^&*()')
})
it.concurrent('should handle JSON string values', () => {
const resolver = new EnvResolver()
const ctx = createTestContext({
JSON_CONFIG: '{"key": "value", "nested": {"a": 1}}',
})
const result = resolver.resolve('{{JSON_CONFIG}}', ctx)
expect(result).toBe('{"key": "value", "nested": {"a": 1}}')
})
it.concurrent('should handle empty environment variables object', () => {
const resolver = new EnvResolver()
const ctx = createTestContext({})
const result = resolver.resolve('{{ANY_VAR}}', ctx)
expect(result).toBe('{{ANY_VAR}}')
})
it.concurrent('should handle undefined environmentVariables gracefully', () => {
const resolver = new EnvResolver()
const ctx = {
executionContext: {},
executionState: {},
currentNodeId: 'test-node',
} as ResolutionContext
const result = resolver.resolve('{{API_KEY}}', ctx)
expect(result).toBe('{{API_KEY}}')
})
})
describe('edge cases', () => {
it.concurrent('should handle variable names with consecutive underscores', () => {
const resolver = new EnvResolver()
const ctx = createTestContext({ MY__VAR: 'double underscore' })
expect(resolver.canResolve('{{MY__VAR}}')).toBe(true)
expect(resolver.resolve('{{MY__VAR}}', ctx)).toBe('double underscore')
})
it.concurrent('should handle single character variable names', () => {
const resolver = new EnvResolver()
const ctx = createTestContext({ X: 'single' })
expect(resolver.canResolve('{{X}}')).toBe(true)
expect(resolver.resolve('{{X}}', ctx)).toBe('single')
})
it.concurrent('should handle very long variable names', () => {
const resolver = new EnvResolver()
const longName = 'A'.repeat(100)
const ctx = createTestContext({ [longName]: 'long name value' })
expect(resolver.canResolve(`{{${longName}}}`)).toBe(true)
expect(resolver.resolve(`{{${longName}}}`, ctx)).toBe('long name value')
})
it.concurrent('should handle value containing mustache-like syntax', () => {
const resolver = new EnvResolver()
const ctx = createTestContext({
TEMPLATE: 'Hello {{name}}!',
})
const result = resolver.resolve('{{TEMPLATE}}', ctx)
expect(result).toBe('Hello {{name}}!')
})
it.concurrent('should handle multiline values', () => {
const resolver = new EnvResolver()
const ctx = createTestContext({
MULTILINE: 'line1\nline2\nline3',
})
const result = resolver.resolve('{{MULTILINE}}', ctx)
expect(result).toBe('line1\nline2\nline3')
})
})
})
@@ -0,0 +1,21 @@
import { createLogger } from '@sim/logger'
import { extractEnvVarName, isEnvVarReference } from '@/executor/constants'
import type { ResolutionContext, Resolver } from '@/executor/variables/resolvers/reference'
const logger = createLogger('EnvResolver')
export class EnvResolver implements Resolver {
canResolve(reference: string): boolean {
return isEnvVarReference(reference)
}
resolve(reference: string, context: ResolutionContext): any {
const varName = extractEnvVarName(reference)
const value = context.executionContext.environmentVariables?.[varName]
if (value === undefined) {
return reference
}
return value
}
}
@@ -0,0 +1,576 @@
import { describe, expect, it } from 'vitest'
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import type { LoopScope } from '@/executor/execution/state'
import { InvalidFieldError } from '@/executor/utils/block-reference'
import { LoopResolver } from './loop'
import type { ResolutionContext } from './reference'
interface LoopDef {
nodes: string[]
id?: string
iterations?: number
loopType?: 'for' | 'forEach'
}
interface BlockDef {
id: string
name: string
}
function createTestWorkflow(
loops: Record<string, LoopDef> = {},
blockDefs: BlockDef[] = [],
parallels: Record<string, { id?: string; nodes: string[] }> = {}
) {
const normalizedLoops: Record<string, { id: string; nodes: string[]; iterations: number }> = {}
for (const [key, loop] of Object.entries(loops)) {
normalizedLoops[key] = {
id: loop.id ?? key,
nodes: loop.nodes,
iterations: loop.iterations ?? 1,
...(loop.loopType && { loopType: loop.loopType }),
}
}
const blocks = blockDefs.map((b) => ({
id: b.id,
position: { x: 0, y: 0 },
config: { tool: 'test', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'function', name: b.name },
enabled: true,
}))
return {
version: '1.0',
blocks,
connections: [],
loops: normalizedLoops,
parallels,
}
}
function createLoopScope(overrides: Partial<LoopScope> = {}): LoopScope {
return {
iteration: 0,
currentIterationOutputs: new Map(),
allIterationOutputs: [],
...overrides,
}
}
function createTestContext(
currentNodeId: string,
loopScope?: LoopScope,
loopExecutions?: Map<string, LoopScope>,
blockOutputs?: Record<string, any>,
parallelBlockMapping?: Map<string, any>
): ResolutionContext {
return {
executionContext: {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
loopExecutions: loopExecutions ?? new Map(),
parallelBlockMapping,
},
executionState: {
getBlockOutput: (id: string) => blockOutputs?.[id],
},
currentNodeId,
loopScope,
} as ResolutionContext
}
describe('LoopResolver', () => {
describe('canResolve', () => {
it.concurrent('should return true for bare loop reference', () => {
const resolver = new LoopResolver(createTestWorkflow())
expect(resolver.canResolve('<loop>')).toBe(true)
})
it.concurrent('should return true for known loop properties', () => {
const resolver = new LoopResolver(createTestWorkflow())
expect(resolver.canResolve('<loop.index>')).toBe(true)
expect(resolver.canResolve('<loop.iteration>')).toBe(true)
expect(resolver.canResolve('<loop.item>')).toBe(true)
expect(resolver.canResolve('<loop.currentItem>')).toBe(true)
expect(resolver.canResolve('<loop.items>')).toBe(true)
})
it.concurrent('should return true for loop references with nested paths', () => {
const resolver = new LoopResolver(createTestWorkflow())
expect(resolver.canResolve('<loop.item.name>')).toBe(true)
expect(resolver.canResolve('<loop.currentItem.data.value>')).toBe(true)
expect(resolver.canResolve('<loop.items.0>')).toBe(true)
})
it.concurrent('should return true for unknown loop properties (validates in resolve)', () => {
const resolver = new LoopResolver(createTestWorkflow())
expect(resolver.canResolve('<loop.results>')).toBe(true)
expect(resolver.canResolve('<loop.output>')).toBe(true)
expect(resolver.canResolve('<loop.unknownProperty>')).toBe(true)
})
it.concurrent('should return false for non-loop references', () => {
const resolver = new LoopResolver(createTestWorkflow())
expect(resolver.canResolve('<block.output>')).toBe(false)
expect(resolver.canResolve('<variable.myvar>')).toBe(false)
expect(resolver.canResolve('<parallel.index>')).toBe(false)
expect(resolver.canResolve('plain text')).toBe(false)
expect(resolver.canResolve('{{ENV_VAR}}')).toBe(false)
})
it.concurrent('should return false for malformed references', () => {
const resolver = new LoopResolver(createTestWorkflow())
expect(resolver.canResolve('loop.index')).toBe(false)
expect(resolver.canResolve('<loop.index')).toBe(false)
expect(resolver.canResolve('loop.index>')).toBe(false)
})
})
describe('resolve with explicit loopScope', () => {
it.concurrent('should resolve iteration/index property', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ iteration: 5 })
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop.iteration>', ctx)).toBe(5)
expect(resolver.resolve('<loop.index>', ctx)).toBe(5)
})
it.concurrent('should resolve item/currentItem property', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ item: { name: 'test', value: 42 } })
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop.item>', ctx)).toEqual({ name: 'test', value: 42 })
expect(resolver.resolve('<loop.currentItem>', ctx)).toEqual({ name: 'test', value: 42 })
})
it.concurrent('should resolve items property', () => {
const resolver = new LoopResolver(createTestWorkflow())
const items = ['a', 'b', 'c']
const loopScope = createLoopScope({ items })
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop.items>', ctx)).toEqual(items)
})
it.concurrent('should resolve nested path in item', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({
item: { user: { name: 'Alice', address: { city: 'NYC' } } },
})
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop.item.user.name>', ctx)).toBe('Alice')
expect(resolver.resolve('<loop.item.user.address.city>', ctx)).toBe('NYC')
})
it.concurrent('should resolve array index in items', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({
items: [{ id: 1 }, { id: 2 }, { id: 3 }],
})
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop.items.0>', ctx)).toEqual({ id: 1 })
expect(resolver.resolve('<loop.items.1.id>', ctx)).toBe(2)
})
})
describe('resolve without explicit loopScope (discovery)', () => {
it.concurrent('should find loop scope from workflow config', () => {
const workflow = createTestWorkflow({
'loop-1': { nodes: ['block-1', 'block-2'] },
})
const resolver = new LoopResolver(workflow)
const loopScope = createLoopScope({ iteration: 3 })
const loopExecutions = new Map([['loop-1', loopScope]])
const ctx = createTestContext('block-1', undefined, loopExecutions)
expect(resolver.resolve('<loop.iteration>', ctx)).toBe(3)
})
it.concurrent('should return undefined when block is not in any loop', () => {
const workflow = createTestWorkflow({
'loop-1': { nodes: ['other-block'] },
})
const resolver = new LoopResolver(workflow)
const ctx = createTestContext('block-1', undefined)
expect(resolver.resolve('<loop.iteration>', ctx)).toBeUndefined()
})
it.concurrent('should return undefined when loop scope not found in executions', () => {
const workflow = createTestWorkflow({
'loop-1': { nodes: ['block-1'] },
})
const resolver = new LoopResolver(workflow)
const ctx = createTestContext('block-1', undefined, new Map())
expect(resolver.resolve('<loop.iteration>', ctx)).toBeUndefined()
})
})
describe('edge cases', () => {
it.concurrent('should return context object for bare loop reference', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ iteration: 2, item: 'test', items: ['a', 'b', 'c'] })
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop>', ctx)).toEqual({
index: 2,
currentItem: 'test',
items: ['a', 'b', 'c'],
})
})
it.concurrent('should return minimal context object for for-loop (no items)', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ iteration: 5 })
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop>', ctx)).toEqual({
index: 5,
})
})
it.concurrent('should throw InvalidFieldError for unknown loop property', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ iteration: 0 })
const ctx = createTestContext('block-1', loopScope)
expect(() => resolver.resolve('<loop.unknownProperty>', ctx)).toThrow(InvalidFieldError)
expect(() => resolver.resolve('<loop.unknownProperty>', ctx)).toThrow(
'Available fields: index'
)
})
it.concurrent('should handle iteration index 0 correctly', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ iteration: 0 })
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop.index>', ctx)).toBe(0)
})
it('resolves generic loop context from inside a parallel nested in a loop', () => {
const workflow = createTestWorkflow({ 'loop-1': { nodes: ['parallel-1'] } }, [], {
'parallel-1': { id: 'parallel-1', nodes: ['block-1'] },
})
const resolver = new LoopResolver(workflow)
const loopScope = createLoopScope({ iteration: 3 })
const ctx = createTestContext('block-1₍0₎', undefined, new Map([['loop-1', loopScope]]))
expect(resolver.resolve('<loop.index>', ctx)).toBe(3)
})
it('resolves inner cloned loop context independently from outer clone indexes', () => {
const workflow = createTestWorkflow({ 'loop-1': { nodes: ['task'] } })
const resolver = new LoopResolver(workflow)
const loopExecutions = new Map<string, LoopScope>([
['loop-1__obranch-1', createLoopScope({ iteration: 4, item: 'inner-branch-1' })],
['loop-1__obranch-2', createLoopScope({ iteration: 9, item: 'outer-branch-2' })],
])
const ctx = createTestContext(
'task__cloneabc__obranch-2__clonedef__obranch-1',
undefined,
loopExecutions
)
expect(resolver.resolve('<loop.index>', ctx)).toBe(4)
expect(resolver.resolve('<loop.currentItem>', ctx)).toBe('inner-branch-1')
})
it.concurrent('should handle null item value', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ item: null })
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop.item>', ctx)).toBeNull()
})
it.concurrent('should handle undefined item value', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ item: undefined })
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop.item>', ctx)).toBeUndefined()
})
it.concurrent('should handle empty items array', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ items: [] })
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop.items>', ctx)).toEqual([])
})
it.concurrent('should handle primitive item value', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ item: 'simple string' })
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop.item>', ctx)).toBe('simple string')
})
it.concurrent('should handle numeric item value', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ item: 42 })
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop.item>', ctx)).toBe(42)
})
it.concurrent('should handle boolean item value', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ item: true })
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop.item>', ctx)).toBe(true)
})
it.concurrent('should handle item with array value', () => {
const resolver = new LoopResolver(createTestWorkflow())
const loopScope = createLoopScope({ item: [1, 2, 3] })
const ctx = createTestContext('block-1', loopScope)
expect(resolver.resolve('<loop.item>', ctx)).toEqual([1, 2, 3])
expect(resolver.resolve('<loop.item.0>', ctx)).toBe(1)
expect(resolver.resolve('<loop.item.2>', ctx)).toBe(3)
})
})
describe('block ID with branch suffix', () => {
it.concurrent('should handle block ID with branch suffix in loop lookup', () => {
const workflow = createTestWorkflow({
'loop-1': { nodes: ['block-1'] },
})
const resolver = new LoopResolver(workflow)
const loopScope = createLoopScope({ iteration: 2 })
const loopExecutions = new Map([['loop-1', loopScope]])
const ctx = createTestContext('block-1₍0₎', undefined, loopExecutions)
expect(resolver.resolve('<loop.iteration>', ctx)).toBe(2)
})
})
describe('named loop references', () => {
it.concurrent('should resolve named loop by block name', () => {
const workflow = createTestWorkflow({ 'loop-1': { nodes: ['block-1'] } }, [
{ id: 'loop-1', name: 'Loop 1' },
])
const resolver = new LoopResolver(workflow)
expect(resolver.canResolve('<loop1.index>')).toBe(true)
})
it.concurrent('should resolve index via named reference for block inside the loop', () => {
const workflow = createTestWorkflow({ 'loop-1': { nodes: ['block-1'] } }, [
{ id: 'loop-1', name: 'Loop 1' },
])
const resolver = new LoopResolver(workflow)
const loopScope = createLoopScope({ iteration: 3 })
const loopExecutions = new Map([['loop-1', loopScope]])
const ctx = createTestContext('block-1', undefined, loopExecutions)
expect(resolver.resolve('<loop1.index>', ctx)).toBe(3)
})
it.concurrent('should resolve index for block in a nested descendant loop', () => {
const workflow = createTestWorkflow(
{
'loop-outer': { nodes: ['loop-inner', 'block-a'] },
'loop-inner': { nodes: ['block-b'] },
},
[
{ id: 'loop-outer', name: 'Loop 1' },
{ id: 'loop-inner', name: 'Loop 2' },
]
)
const resolver = new LoopResolver(workflow)
const outerScope = createLoopScope({ iteration: 2 })
const innerScope = createLoopScope({ iteration: 4 })
const loopExecutions = new Map<string, LoopScope>([
['loop-outer', outerScope],
['loop-inner', innerScope],
])
const ctx = createTestContext('block-b', undefined, loopExecutions)
expect(resolver.resolve('<loop1.index>', ctx)).toBe(2)
expect(resolver.resolve('<loop2.index>', ctx)).toBe(4)
expect(resolver.resolve('<loop.index>', ctx)).toBe(4)
})
it.concurrent('should throw for contextual fields when block is outside the loop', () => {
const workflow = createTestWorkflow({ 'loop-1': { nodes: ['block-1'] } }, [
{ id: 'loop-1', name: 'Loop 1' },
])
const resolver = new LoopResolver(workflow)
const loopScope = createLoopScope({ iteration: 3 })
const loopExecutions = new Map([['loop-1', loopScope]])
const ctx = createTestContext('block-outside', undefined, loopExecutions)
expect(() => resolver.resolve('<loop1.index>', ctx)).toThrow(InvalidFieldError)
expect(() => resolver.resolve('<loop1.index>', ctx)).toThrow('Available fields: results')
})
it.concurrent('should resolve result from anywhere after loop completes', () => {
const workflow = createTestWorkflow({ 'loop-1': { nodes: ['block-1'] } }, [
{ id: 'loop-1', name: 'Loop 1' },
])
const resolver = new LoopResolver(workflow)
const results = [[{ response: 'a' }], [{ response: 'b' }]]
const ctx = createTestContext('block-outside', undefined, new Map(), {
'loop-1': { results },
})
expect(resolver.resolve('<loop1.result>', ctx)).toEqual(results)
expect(resolver.resolve('<loop1.results>', ctx)).toEqual(results)
})
it('uses parallel block mappings to resolve cloned loop outputs in later batches', () => {
const workflow = createTestWorkflow({ 'loop-1': { nodes: ['block-1'] } }, [
{ id: 'loop-1', name: 'Loop 1' },
])
const resolver = new LoopResolver(workflow)
const loopExecutions = new Map<string, LoopScope>([
['loop-1', createLoopScope()],
['loop-1__obranch-2', createLoopScope()],
])
const ctx = createTestContext(
'consumer₍0₎',
undefined,
loopExecutions,
{
'loop-1': { results: ['branch-0'] },
'loop-1__obranch-2': { results: ['branch-2'] },
},
new Map([
[
'consumer₍0₎',
{ originalBlockId: 'consumer', parallelId: 'parallel-1', iterationIndex: 2 },
],
])
)
expect(resolver.resolve('<loop1.results>', ctx)).toEqual(['branch-2'])
})
it('uses outer branch suffix over inner parallel mappings for cloned loop outputs', () => {
const workflow = createTestWorkflow({ 'loop-1': { nodes: ['block-1'] } }, [
{ id: 'loop-1', name: 'Loop 1' },
])
const resolver = new LoopResolver(workflow)
const loopExecutions = new Map<string, LoopScope>([
['loop-1__obranch-1', createLoopScope()],
['loop-1__obranch-2', createLoopScope()],
])
const ctx = createTestContext(
'consumer__cloneabc__obranch-2₍0₎',
undefined,
loopExecutions,
{
'loop-1__obranch-1': { results: ['outer-branch-1'] },
'loop-1__obranch-2': { results: ['outer-branch-2'] },
},
new Map([
[
'consumer__cloneabc__obranch-2₍0₎',
{ originalBlockId: 'consumer', parallelId: 'inner-parallel', iterationIndex: 1 },
],
])
)
expect(resolver.resolve('<loop1.results>', ctx)).toEqual(['outer-branch-2'])
})
it.concurrent('should resolve result with nested path', () => {
const workflow = createTestWorkflow({ 'loop-1': { nodes: ['block-1'] } }, [
{ id: 'loop-1', name: 'Loop 1' },
])
const resolver = new LoopResolver(workflow)
const results = [[{ response: 'a' }], [{ response: 'b' }]]
const ctx = createTestContext('block-outside', undefined, new Map(), {
'loop-1': { results },
})
expect(resolver.resolve('<loop1.result.0>', ctx)).toEqual([{ response: 'a' }])
expect(resolver.resolve('<loop1.result.1.0.response>', ctx)).toBe('b')
expect(resolver.resolve('<loop1.results[1][0].response>', ctx)).toBe('b')
})
it('should resolve nested paths inside compacted result references', async () => {
const workflow = createTestWorkflow({ 'loop-1': { nodes: ['block-1'] } }, [
{ id: 'loop-1', name: 'Loop 1' },
])
const resolver = new LoopResolver(workflow)
const compacted = await compactExecutionPayload(
{ results: [[{ response: 'a' }], [{ response: 'b', payload: 'x'.repeat(2048) }]] },
{
thresholdBytes: 256,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
}
)
const ctx = createTestContext('block-outside', undefined, new Map(), {
'loop-1': compacted,
})
expect(resolver.resolve('<loop1.result.1.0.response>', ctx)).toBe('b')
expect(resolver.resolve('<loop1.results[1][0].response>', ctx)).toBe('b')
expect(() => resolver.resolve('<loop1.results>', ctx)).toThrow('too large to inline')
})
it.concurrent('should resolve forEach properties via named reference', () => {
const workflow = createTestWorkflow(
{ 'loop-1': { nodes: ['block-1'], loopType: 'forEach' } },
[{ id: 'loop-1', name: 'Loop 1' }]
)
const resolver = new LoopResolver(workflow)
const items = ['x', 'y', 'z']
const loopScope = createLoopScope({ iteration: 1, item: 'y', items })
const loopExecutions = new Map([['loop-1', loopScope]])
const ctx = createTestContext('block-1', undefined, loopExecutions)
expect(resolver.resolve('<loop1.index>', ctx)).toBe(1)
expect(resolver.resolve('<loop1.currentItem>', ctx)).toBe('y')
expect(resolver.resolve('<loop1.items>', ctx)).toEqual(items)
})
it.concurrent('should throw InvalidFieldError for unknown property on named ref', () => {
const workflow = createTestWorkflow({ 'loop-1': { nodes: ['block-1'] } }, [
{ id: 'loop-1', name: 'Loop 1' },
])
const resolver = new LoopResolver(workflow)
const loopScope = createLoopScope({ iteration: 0 })
const loopExecutions = new Map([['loop-1', loopScope]])
const ctx = createTestContext('block-1', undefined, loopExecutions)
expect(() => resolver.resolve('<loop1.unknownProp>', ctx)).toThrow(InvalidFieldError)
expect(() => resolver.resolve('<loop1.unknownProp>', ctx)).toThrow('Available fields: index')
})
it.concurrent('should list only results for unknown fields outside a named loop', () => {
const workflow = createTestWorkflow({ 'loop-1': { nodes: ['block-1'] } }, [
{ id: 'loop-1', name: 'Loop 1' },
])
const resolver = new LoopResolver(workflow)
const loopScope = createLoopScope({ iteration: 0 })
const loopExecutions = new Map([['loop-1', loopScope]])
const ctx = createTestContext('block-outside', undefined, loopExecutions)
expect(() => resolver.resolve('<loop1.cooked>', ctx)).toThrow(InvalidFieldError)
expect(() => resolver.resolve('<loop1.cooked>', ctx)).toThrow('Available fields: results')
})
it.concurrent('should not resolve named ref when no matching block exists', () => {
const workflow = createTestWorkflow({ 'loop-1': { nodes: ['block-1'] } }, [
{ id: 'loop-1', name: 'Loop 1' },
])
const resolver = new LoopResolver(workflow)
expect(resolver.canResolve('<loop99.index>')).toBe(false)
})
})
})
@@ -0,0 +1,301 @@
import { createLogger } from '@sim/logger'
import { assertNoLargeValueRefs } from '@/lib/execution/payloads/large-value-ref'
import { isReference, normalizeName, parseReferencePath, REFERENCE } from '@/executor/constants'
import { InvalidFieldError } from '@/executor/utils/block-reference'
import {
extractInnermostOuterBranchIndex,
extractOuterBranchIndex,
findEffectiveContainerId,
isSubflowNestedInside,
stripCloneSuffixes,
stripOuterBranchSuffix,
subflowContainsBlock,
} from '@/executor/utils/subflow-utils'
import {
type AsyncPathNavigator,
navigatePath,
type ResolutionContext,
type Resolver,
splitLeadingBracketPath,
} from '@/executor/variables/resolvers/reference'
import type { SerializedWorkflow } from '@/serializer/types'
const logger = createLogger('LoopResolver')
const LOOP_OUTPUT_FIELDS = ['results'] as const
const LOOP_CONTEXT_FIELDS = ['index'] as const
const FOR_EACH_LOOP_CONTEXT_FIELDS = ['index', 'currentItem', 'items'] as const
export class LoopResolver implements Resolver {
private loopNameToId: Map<string, string>
constructor(
private workflow: SerializedWorkflow,
private navigatePathAsync?: AsyncPathNavigator
) {
this.loopNameToId = new Map()
for (const block of workflow.blocks) {
if (workflow.loops[block.id] && block.metadata?.name) {
this.loopNameToId.set(normalizeName(block.metadata.name), block.id)
}
}
}
private static OUTPUT_PROPERTIES = new Set(['result', 'results'])
private static KNOWN_PROPERTIES = new Set(['iteration', 'index', 'item', 'currentItem', 'items'])
canResolve(reference: string): boolean {
if (!isReference(reference)) {
return false
}
const parts = parseReferencePath(reference)
if (parts.length === 0) {
return false
}
const [type] = parts
return type === REFERENCE.PREFIX.LOOP || this.loopNameToId.has(type)
}
resolve(reference: string, context: ResolutionContext): any {
return this.resolveInternal(reference, context, false)
}
async resolveAsync(reference: string, context: ResolutionContext): Promise<any> {
if (!this.navigatePathAsync) {
return this.resolve(reference, context)
}
return this.resolveInternal(reference, context, true)
}
private async resolveInternal(
reference: string,
context: ResolutionContext,
useAsyncPath: true
): Promise<any>
private resolveInternal(reference: string, context: ResolutionContext, useAsyncPath: false): any
private resolveInternal(
reference: string,
context: ResolutionContext,
useAsyncPath: boolean
): any | Promise<any> {
const parts = parseReferencePath(reference)
if (parts.length === 0) {
logger.warn('Invalid loop reference', { reference })
return undefined
}
const [firstPart, ...rest] = parts
const isGenericRef = firstPart === REFERENCE.PREFIX.LOOP
let targetLoopId: string | undefined
if (isGenericRef) {
targetLoopId = this.findInnermostLoopForBlock(context.currentNodeId)
if (!targetLoopId && !context.loopScope) {
return undefined
}
} else {
targetLoopId = this.loopNameToId.get(firstPart)
if (!targetLoopId) {
return undefined
}
}
// Resolve the effective (possibly cloned) loop ID for scope/output lookups
if (targetLoopId && context.executionContext.loopExecutions) {
const mappedBranchIndex =
(isGenericRef
? extractInnermostOuterBranchIndex(context.currentNodeId)
: extractOuterBranchIndex(context.currentNodeId)) ??
context.executionContext.parallelBlockMapping?.get(context.currentNodeId)?.iterationIndex
targetLoopId = findEffectiveContainerId(
targetLoopId,
context.currentNodeId,
context.executionContext.loopExecutions,
mappedBranchIndex
)
}
if (rest.length > 0) {
const { property, pathParts: bracketPathParts } = splitLeadingBracketPath(rest[0])
if (LoopResolver.OUTPUT_PROPERTIES.has(property)) {
if (!targetLoopId) {
return undefined
}
return useAsyncPath
? this.resolveOutputAsync(targetLoopId, [...bracketPathParts, ...rest.slice(1)], context)
: this.resolveOutput(targetLoopId, [...bracketPathParts, ...rest.slice(1)], context)
}
const isContextual =
isGenericRef ||
(targetLoopId !== undefined &&
this.isBlockInLoopOrDescendant(context.currentNodeId, targetLoopId))
if (!LoopResolver.KNOWN_PROPERTIES.has(property)) {
throw new InvalidFieldError(
firstPart,
rest[0],
this.getAvailableFields(targetLoopId, context)
)
}
if (!isContextual) {
throw new InvalidFieldError(firstPart, rest[0], [...LOOP_OUTPUT_FIELDS])
}
}
let loopScope = isGenericRef ? context.loopScope : undefined
if (!loopScope && targetLoopId) {
loopScope = context.executionContext.loopExecutions?.get(targetLoopId)
}
if (!loopScope) {
logger.warn('Loop scope not found', { reference })
return undefined
}
if (rest.length === 0) {
const obj: Record<string, any> = {
index: loopScope.iteration,
}
if (loopScope.item !== undefined) {
obj.currentItem = loopScope.item
}
if (loopScope.items !== undefined) {
obj.items = loopScope.items
}
return obj
}
const [rawProperty, ...remainingPathParts] = rest
const { property, pathParts: bracketPathParts } = splitLeadingBracketPath(rawProperty)
const pathParts = [...bracketPathParts, ...remainingPathParts]
let value: any
switch (property) {
case 'iteration':
case 'index':
value = loopScope.iteration
break
case 'item':
case 'currentItem':
value = loopScope.item
break
case 'items':
value = loopScope.items
break
}
if (pathParts.length > 0) {
return useAsyncPath && this.navigatePathAsync
? this.navigatePathAsync(value, pathParts, context)
: navigatePath(value, pathParts, {
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
})
}
return value
}
private resolveOutput(loopId: string, pathParts: string[], context: ResolutionContext): unknown {
const output = context.executionState.getBlockOutput(loopId)
if (!output || typeof output !== 'object') {
return undefined
}
const value = navigatePath(output, ['results'], {
allowLargeValueRefs: true,
executionContext: context.executionContext,
})
if (pathParts.length > 0) {
return navigatePath(value, pathParts, {
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
})
}
if (!context.allowLargeValueRefs) {
assertNoLargeValueRefs(value)
}
return value
}
private async resolveOutputAsync(
loopId: string,
pathParts: string[],
context: ResolutionContext
): Promise<unknown> {
const output = context.executionState.getBlockOutput(loopId)
if (!output || typeof output !== 'object') {
return undefined
}
const value = this.navigatePathAsync
? await this.navigatePathAsync(output, ['results'], { ...context, allowLargeValueRefs: true })
: navigatePath(output, ['results'], {
allowLargeValueRefs: true,
executionContext: context.executionContext,
})
if (pathParts.length > 0) {
return this.navigatePathAsync
? this.navigatePathAsync(value, pathParts, context)
: navigatePath(value, pathParts, {
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
})
}
if (!context.allowLargeValueRefs) {
assertNoLargeValueRefs(value)
}
return value
}
private findInnermostLoopForBlock(blockId: string): string | undefined {
const baseId = stripCloneSuffixes(blockId)
const loops = this.workflow.loops || {}
const candidateLoopIds = Object.keys(loops).filter((loopId) =>
subflowContainsBlock(this.workflow, 'loop', loopId, baseId)
)
if (candidateLoopIds.length === 0) return undefined
if (candidateLoopIds.length === 1) return candidateLoopIds[0]
// Return the innermost: the loop that is not an ancestor of any other candidate.
// In a valid DAG, exactly one candidate will satisfy this (circular containment is impossible).
return candidateLoopIds.find((candidateId) =>
candidateLoopIds.every(
(otherId) =>
otherId === candidateId ||
!isSubflowNestedInside(this.workflow, 'loop', otherId, 'loop', candidateId)
)
)
}
private isBlockInLoopOrDescendant(blockId: string, targetLoopId: string): boolean {
const baseId = stripCloneSuffixes(blockId)
const originalLoopId = stripOuterBranchSuffix(targetLoopId)
return subflowContainsBlock(this.workflow, 'loop', originalLoopId, baseId)
}
private isForEachLoop(loopId: string): boolean {
const originalId = stripOuterBranchSuffix(loopId)
const loopConfig = this.workflow.loops?.[originalId]
return loopConfig?.loopType === 'forEach'
}
private getAvailableFields(
targetLoopId: string | undefined,
context: ResolutionContext
): string[] {
const isContextual =
targetLoopId === undefined ||
this.isBlockInLoopOrDescendant(context.currentNodeId, targetLoopId)
if (!isContextual) {
return [...LOOP_OUTPUT_FIELDS]
}
const isForEach = targetLoopId
? this.isForEachLoop(targetLoopId)
: context.loopScope?.items !== undefined
return isForEach ? [...FOR_EACH_LOOP_CONTEXT_FIELDS] : [...LOOP_CONTEXT_FIELDS]
}
}
@@ -0,0 +1,741 @@
import { describe, expect, it } from 'vitest'
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import { InvalidFieldError } from '@/executor/utils/block-reference'
import { ParallelResolver } from './parallel'
import type { ResolutionContext } from './reference'
interface BlockDef {
id: string
name: string
}
/**
* Creates a minimal workflow for testing.
*/
function createTestWorkflow(
parallels: Record<
string,
{
nodes: string[]
id?: string
distribution?: any
parallelType?: 'count' | 'collection'
}
> = {},
blockDefs: BlockDef[] = [],
loops: Record<string, { id?: string; nodes: string[] }> = {}
) {
const normalizedParallels: Record<
string,
{
id: string
nodes: string[]
distribution?: any
parallelType?: 'count' | 'collection'
}
> = {}
for (const [key, parallel] of Object.entries(parallels)) {
normalizedParallels[key] = {
id: parallel.id ?? key,
nodes: parallel.nodes,
distribution: parallel.distribution,
parallelType: parallel.parallelType,
}
}
const blocks = blockDefs.map((b) => ({
id: b.id,
position: { x: 0, y: 0 },
config: { tool: 'test', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'function', name: b.name },
enabled: true,
}))
return {
version: '1.0',
blocks,
connections: [],
loops,
parallels: normalizedParallels,
}
}
/**
* Creates a parallel scope for runtime context.
*/
function createParallelScope(items: any[]) {
return {
parallelId: 'parallel-1',
totalBranches: items.length,
branchOutputs: new Map(),
items,
}
}
/**
* Creates a minimal ResolutionContext for testing.
*/
function createTestContext(
currentNodeId: string,
parallelExecutions?: Map<string, any>,
blockOutputs?: Record<string, any>,
parallelBlockMapping?: Map<string, any>,
subflowParentMap?: Map<string, any>
): ResolutionContext {
return {
executionContext: {
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
parallelExecutions: parallelExecutions ?? new Map(),
parallelBlockMapping,
subflowParentMap,
},
executionState: {
getBlockOutput: (id: string) => blockOutputs?.[id],
},
currentNodeId,
} as ResolutionContext
}
describe('ParallelResolver', () => {
describe('canResolve', () => {
it.concurrent('should return true for bare parallel reference', () => {
const resolver = new ParallelResolver(createTestWorkflow())
expect(resolver.canResolve('<parallel>')).toBe(true)
})
it.concurrent('should return true for known parallel properties', () => {
const resolver = new ParallelResolver(createTestWorkflow())
expect(resolver.canResolve('<parallel.index>')).toBe(true)
expect(resolver.canResolve('<parallel.currentItem>')).toBe(true)
expect(resolver.canResolve('<parallel.items>')).toBe(true)
})
it.concurrent('should return true for parallel references with nested paths', () => {
const resolver = new ParallelResolver(createTestWorkflow())
expect(resolver.canResolve('<parallel.currentItem.name>')).toBe(true)
expect(resolver.canResolve('<parallel.items.0>')).toBe(true)
})
it.concurrent(
'should return true for unknown parallel properties (validates in resolve)',
() => {
const resolver = new ParallelResolver(createTestWorkflow())
expect(resolver.canResolve('<parallel.results>')).toBe(true)
expect(resolver.canResolve('<parallel.output>')).toBe(true)
expect(resolver.canResolve('<parallel.unknownProperty>')).toBe(true)
}
)
it.concurrent('should return false for non-parallel references', () => {
const resolver = new ParallelResolver(createTestWorkflow())
expect(resolver.canResolve('<block.output>')).toBe(false)
expect(resolver.canResolve('<variable.myvar>')).toBe(false)
expect(resolver.canResolve('<loop.index>')).toBe(false)
expect(resolver.canResolve('plain text')).toBe(false)
expect(resolver.canResolve('{{ENV_VAR}}')).toBe(false)
})
it.concurrent('should return false for malformed references', () => {
const resolver = new ParallelResolver(createTestWorkflow())
expect(resolver.canResolve('parallel.index')).toBe(false)
expect(resolver.canResolve('<parallel.index')).toBe(false)
expect(resolver.canResolve('parallel.index>')).toBe(false)
})
})
describe('resolve index property', () => {
it.concurrent('should resolve branch index from node ID', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b', 'c'] },
})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍0₎')
expect(resolver.resolve('<parallel.index>', ctx)).toBe(0)
})
it.concurrent('should resolve different branch indices', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b', 'c'] },
})
const resolver = new ParallelResolver(workflow)
expect(resolver.resolve('<parallel.index>', createTestContext('block-1₍0₎'))).toBe(0)
expect(resolver.resolve('<parallel.index>', createTestContext('block-1₍1₎'))).toBe(1)
expect(resolver.resolve('<parallel.index>', createTestContext('block-1₍2₎'))).toBe(2)
})
it.concurrent('uses runtime branch mapping for batched local branch node IDs', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b', 'c', 'd'] },
})
const resolver = new ParallelResolver(workflow)
const parallelScope = createParallelScope(['a', 'b', 'c', 'd'])
const parallelExecutions = new Map([['parallel-1', parallelScope]])
const parallelBlockMapping = new Map([
[
'block-1₍0₎',
{
originalBlockId: 'block-1',
parallelId: 'parallel-1',
iterationIndex: 2,
},
],
])
const ctx = createTestContext(
'block-1₍0₎',
parallelExecutions,
undefined,
parallelBlockMapping
)
expect(resolver.resolve('<parallel.index>', ctx)).toBe(2)
expect(resolver.resolve('<parallel.currentItem>', ctx)).toBe('c')
})
it.concurrent('should return undefined when branch index cannot be extracted', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b'] },
})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1')
expect(resolver.resolve('<parallel.index>', ctx)).toBeUndefined()
})
})
describe('resolve currentItem property', () => {
it.concurrent('should resolve current item from array distribution', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: ['apple', 'banana', 'cherry'] },
})
const resolver = new ParallelResolver(workflow)
expect(resolver.resolve('<parallel.currentItem>', createTestContext('block-1₍0₎'))).toBe(
'apple'
)
expect(resolver.resolve('<parallel.currentItem>', createTestContext('block-1₍1₎'))).toBe(
'banana'
)
expect(resolver.resolve('<parallel.currentItem>', createTestContext('block-1₍2₎'))).toBe(
'cherry'
)
})
it.concurrent('should resolve current item from object distribution as entries', () => {
// When an object is used as distribution, it gets converted to entries [key, value]
const workflow = createTestWorkflow({
'parallel-1': {
nodes: ['block-1'],
distribution: { key1: 'value1', key2: 'value2' },
},
})
const resolver = new ParallelResolver(workflow)
const ctx0 = createTestContext('block-1₍0₎')
const ctx1 = createTestContext('block-1₍1₎')
const item0 = resolver.resolve('<parallel.currentItem>', ctx0)
const item1 = resolver.resolve('<parallel.currentItem>', ctx1)
// Object entries are returned as [key, value] tuples
expect(item0).toEqual(['key1', 'value1'])
expect(item1).toEqual(['key2', 'value2'])
})
it.concurrent('should resolve current item with nested path', () => {
const workflow = createTestWorkflow({
'parallel-1': {
nodes: ['block-1'],
distribution: [
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 25 },
],
},
})
const resolver = new ParallelResolver(workflow)
expect(resolver.resolve('<parallel.currentItem.name>', createTestContext('block-1₍0₎'))).toBe(
'Alice'
)
expect(resolver.resolve('<parallel.currentItem.age>', createTestContext('block-1₍1₎'))).toBe(
25
)
})
it.concurrent('should use runtime parallelScope items when available', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: ['static1', 'static2'] },
})
const resolver = new ParallelResolver(workflow)
const parallelScope = createParallelScope(['runtime1', 'runtime2', 'runtime3'])
const parallelExecutions = new Map([['parallel-1', parallelScope]])
const ctx = createTestContext('block-1₍1₎', parallelExecutions)
expect(resolver.resolve('<parallel.currentItem>', ctx)).toBe('runtime2')
})
})
describe('resolve items property', () => {
it.concurrent('should resolve all items from array distribution', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: [1, 2, 3] },
})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍0₎')
expect(resolver.resolve('<parallel.items>', ctx)).toEqual([1, 2, 3])
})
it.concurrent('should resolve items with nested path', () => {
const workflow = createTestWorkflow({
'parallel-1': {
nodes: ['block-1'],
distribution: [{ id: 1 }, { id: 2 }, { id: 3 }],
},
})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍0₎')
expect(resolver.resolve('<parallel.items.1>', ctx)).toEqual({ id: 2 })
expect(resolver.resolve('<parallel.items.1.id>', ctx)).toBe(2)
})
it.concurrent('should use runtime parallelScope items when available', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: ['static'] },
})
const resolver = new ParallelResolver(workflow)
const parallelScope = createParallelScope(['runtime1', 'runtime2'])
const parallelExecutions = new Map([['parallel-1', parallelScope]])
const ctx = createTestContext('block-1₍0₎', parallelExecutions)
expect(resolver.resolve('<parallel.items>', ctx)).toEqual(['runtime1', 'runtime2'])
})
})
describe('edge cases', () => {
it.concurrent('should return context object for bare parallel reference', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b', 'c'] },
})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍1₎')
expect(resolver.resolve('<parallel>', ctx)).toEqual({
index: 1,
currentItem: 'b',
items: ['a', 'b', 'c'],
})
})
it.concurrent('should return minimal context object when no distribution', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'] },
})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍0₎')
const result = resolver.resolve('<parallel>', ctx)
expect(result).toHaveProperty('index', 0)
expect(result).toHaveProperty('items')
})
it.concurrent('should throw InvalidFieldError for unknown parallel property', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: ['a'] },
})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍0₎')
expect(() => resolver.resolve('<parallel.unknownProperty>', ctx)).toThrow(InvalidFieldError)
expect(() => resolver.resolve('<parallel.unknownProperty>', ctx)).toThrow(
'Available fields: index'
)
})
it.concurrent('should return undefined when block is not in any parallel', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['other-block'], distribution: ['a'] },
})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍0₎')
expect(resolver.resolve('<parallel.index>', ctx)).toBeUndefined()
})
it.concurrent('should return undefined when parallel config not found', () => {
const workflow = createTestWorkflow({})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍0₎')
expect(resolver.resolve('<parallel.index>', ctx)).toBeUndefined()
})
it.concurrent('should handle empty distribution array', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: [] },
})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍0₎')
expect(resolver.resolve('<parallel.items>', ctx)).toEqual([])
expect(resolver.resolve('<parallel.currentItem>', ctx)).toBeUndefined()
})
it.concurrent('should handle JSON string distribution', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: '["x", "y", "z"]' },
})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍1₎')
expect(resolver.resolve('<parallel.items>', ctx)).toEqual(['x', 'y', 'z'])
expect(resolver.resolve('<parallel.currentItem>', ctx)).toBe('y')
})
it.concurrent('should handle JSON string with single quotes', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: "['a', 'b']" },
})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍0₎')
expect(resolver.resolve('<parallel.items>', ctx)).toEqual(['a', 'b'])
})
it.concurrent('should return empty array for reference strings', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: '<block.output>' },
})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍0₎')
expect(resolver.resolve('<parallel.items>', ctx)).toEqual([])
})
it.concurrent('should resolve distribution items from distribution property', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: ['fallback1', 'fallback2'] },
})
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍0₎')
expect(resolver.resolve('<parallel.items>', ctx)).toEqual(['fallback1', 'fallback2'])
})
})
describe('nested parallel blocks', () => {
it.concurrent('should resolve for block with multiple parallel parents', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1', 'block-2'], distribution: ['p1', 'p2'] },
'parallel-2': { nodes: ['block-3'], distribution: ['p3', 'p4'] },
})
const resolver = new ParallelResolver(workflow)
expect(resolver.resolve('<parallel.currentItem>', createTestContext('block-1₍0₎'))).toBe('p1')
expect(resolver.resolve('<parallel.currentItem>', createTestContext('block-3₍1₎'))).toBe('p4')
})
})
describe('named parallel references', () => {
it.concurrent('should resolve result from anywhere after parallel completes', () => {
const workflow = createTestWorkflow(
{ 'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b'] } },
[{ id: 'parallel-1', name: 'Parallel 1' }]
)
const resolver = new ParallelResolver(workflow)
const results = [[{ response: 'a' }], [{ response: 'b' }]]
const ctx = createTestContext('block-outside', new Map(), {
'parallel-1': { results },
})
expect(resolver.resolve('<parallel1.result>', ctx)).toEqual(results)
expect(resolver.resolve('<parallel1.results>', ctx)).toEqual(results)
})
it('uses parallel block mappings to resolve cloned parallel outputs in later batches', () => {
const workflow = createTestWorkflow(
{ 'nested-parallel': { nodes: ['block-1'], distribution: ['a', 'b'] } },
[{ id: 'nested-parallel', name: 'Nested Parallel' }]
)
const resolver = new ParallelResolver(workflow)
const parallelExecutions = new Map<string, any>([
['nested-parallel', { parallelId: 'nested-parallel', branchOutputs: new Map() }],
[
'nested-parallel__obranch-2',
{ parallelId: 'nested-parallel__obranch-2', branchOutputs: new Map() },
],
])
const ctx = createTestContext(
'consumer₍0₎',
parallelExecutions,
{
'nested-parallel': { results: ['branch-0'] },
'nested-parallel__obranch-2': { results: ['branch-2'] },
},
new Map([
[
'consumer₍0₎',
{ originalBlockId: 'consumer', parallelId: 'parallel-1', iterationIndex: 2 },
],
])
)
expect(resolver.resolve('<nestedparallel.results>', ctx)).toEqual(['branch-2'])
})
it('uses outer branch suffix over inner parallel mappings for cloned parallel outputs', () => {
const workflow = createTestWorkflow(
{ 'nested-parallel': { nodes: ['block-1'], distribution: ['a', 'b'] } },
[{ id: 'nested-parallel', name: 'Nested Parallel' }]
)
const resolver = new ParallelResolver(workflow)
const parallelExecutions = new Map<string, any>([
[
'nested-parallel__obranch-1',
{ parallelId: 'nested-parallel__obranch-1', branchOutputs: new Map() },
],
[
'nested-parallel__obranch-2',
{ parallelId: 'nested-parallel__obranch-2', branchOutputs: new Map() },
],
])
const ctx = createTestContext(
'consumer__cloneabc__obranch-2₍0₎',
parallelExecutions,
{
'nested-parallel__obranch-1': { results: ['outer-branch-1'] },
'nested-parallel__obranch-2': { results: ['outer-branch-2'] },
},
new Map([
[
'consumer__cloneabc__obranch-2₍0₎',
{ originalBlockId: 'consumer', parallelId: 'inner-parallel', iterationIndex: 1 },
],
])
)
expect(resolver.resolve('<nestedparallel.results>', ctx)).toEqual(['outer-branch-2'])
})
it.concurrent('should resolve result with nested path', () => {
const workflow = createTestWorkflow(
{ 'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b'] } },
[{ id: 'parallel-1', name: 'Parallel 1' }]
)
const resolver = new ParallelResolver(workflow)
const results = [[{ response: 'a' }], [{ response: 'b' }]]
const ctx = createTestContext('block-outside', new Map(), {
'parallel-1': { results },
})
expect(resolver.resolve('<parallel1.result.0>', ctx)).toEqual([{ response: 'a' }])
expect(resolver.resolve('<parallel1.result.1.0.response>', ctx)).toBe('b')
expect(resolver.resolve('<parallel1.results[1][0].response>', ctx)).toBe('b')
})
it('should resolve nested paths inside compacted result references', async () => {
const workflow = createTestWorkflow(
{ 'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b'] } },
[{ id: 'parallel-1', name: 'Parallel 1' }]
)
const resolver = new ParallelResolver(workflow)
const compacted = await compactExecutionPayload(
{ results: [[{ response: 'a' }], [{ response: 'b', payload: 'x'.repeat(2048) }]] },
{
thresholdBytes: 256,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
}
)
const ctx = createTestContext('block-outside', new Map(), {
'parallel-1': compacted,
})
expect(resolver.resolve('<parallel1.result.1.0.response>', ctx)).toBe('b')
expect(resolver.resolve('<parallel1.results[1][0].response>', ctx)).toBe('b')
expect(() => resolver.resolve('<parallel1.results>', ctx)).toThrow('too large to inline')
})
it.concurrent('should resolve result with empty currentNodeId', () => {
const workflow = createTestWorkflow(
{ 'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b'] } },
[{ id: 'parallel-1', name: 'Parallel 1' }]
)
const resolver = new ParallelResolver(workflow)
const results = [[{ output: 'x' }], [{ output: 'y' }]]
const ctx = createTestContext('', new Map(), {
'parallel-1': { results },
})
expect(resolver.resolve('<parallel1.results>', ctx)).toEqual(results)
})
it.concurrent('should return undefined when no output stored yet', () => {
const workflow = createTestWorkflow(
{ 'parallel-1': { nodes: ['block-1'], distribution: ['a'] } },
[{ id: 'parallel-1', name: 'Parallel 1' }]
)
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-outside', new Map())
expect(resolver.resolve('<parallel1.results>', ctx)).toBeUndefined()
})
it.concurrent('should resolve iteration properties via named reference', () => {
const workflow = createTestWorkflow(
{
'parallel-1': {
nodes: ['block-1'],
distribution: ['x', 'y', 'z'],
parallelType: 'collection',
},
},
[{ id: 'parallel-1', name: 'Parallel 1' }]
)
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍1₎')
expect(resolver.resolve('<parallel1.index>', ctx)).toBe(1)
expect(resolver.resolve('<parallel1.currentItem>', ctx)).toBe('y')
expect(resolver.resolve('<parallel1.items>', ctx)).toEqual(['x', 'y', 'z'])
})
it.concurrent('should throw InvalidFieldError for unknown property on named ref', () => {
const workflow = createTestWorkflow(
{
'parallel-1': {
nodes: ['block-1'],
distribution: ['a'],
parallelType: 'collection',
},
},
[{ id: 'parallel-1', name: 'Parallel 1' }]
)
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍0₎')
expect(() => resolver.resolve('<parallel1.unknownProp>', ctx)).toThrow(InvalidFieldError)
expect(() => resolver.resolve('<parallel1.unknownProp>', ctx)).toThrow(
'Available fields: index, currentItem, items'
)
})
it.concurrent('should list only results for contextual fields outside a named parallel', () => {
const workflow = createTestWorkflow(
{
'parallel-1': {
nodes: ['block-1'],
distribution: ['a'],
parallelType: 'collection',
},
},
[{ id: 'parallel-1', name: 'Parallel 1' }]
)
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-outside', new Map())
expect(() => resolver.resolve('<parallel1.index>', ctx)).toThrow(InvalidFieldError)
expect(() => resolver.resolve('<parallel1.index>', ctx)).toThrow('Available fields: results')
expect(() => resolver.resolve('<parallel1.cooked>', ctx)).toThrow(InvalidFieldError)
expect(() => resolver.resolve('<parallel1.cooked>', ctx)).toThrow('Available fields: results')
})
it.concurrent('should not resolve named ref when no matching block exists', () => {
const workflow = createTestWorkflow({ 'parallel-1': { nodes: ['block-1'] } }, [
{ id: 'parallel-1', name: 'Parallel 1' },
])
const resolver = new ParallelResolver(workflow)
expect(resolver.canResolve('<parallel99.index>')).toBe(false)
})
it.concurrent('should resolve generic parallel results from inside a branch', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b'] },
})
const resolver = new ParallelResolver(workflow)
const results = [[{ response: 'a' }], [{ response: 'b' }]]
const ctx = createTestContext('block-1₍0₎', new Map(), {
'parallel-1': { results },
})
expect(resolver.resolve('<parallel.results>', ctx)).toEqual(results)
expect(resolver.resolve('<parallel.result>', ctx)).toEqual(results)
})
it('resolves generic parallel context from inside a loop nested in a parallel', () => {
const workflow = createTestWorkflow(
{
'parallel-1': {
nodes: ['loop-1'],
distribution: ['a', 'b', 'c'],
parallelType: 'collection',
},
},
[],
{ 'loop-1': { id: 'loop-1', nodes: ['block-1'] } }
)
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext(
'block-1__cloneaaa__obranch-2',
new Map([['parallel-1', createParallelScope(['a', 'b', 'c'])]])
)
expect(resolver.resolve('<parallel.index>', ctx)).toBe(2)
expect(resolver.resolve('<parallel.currentItem>', ctx)).toBe('c')
})
it('resolves inner parallel branch context independently from the outer clone index', () => {
const workflow = createTestWorkflow({
'outer-parallel': {
nodes: ['inner-parallel'],
distribution: ['outer0', 'outer1', 'outer2'],
},
'inner-parallel': {
nodes: ['block-1'],
distribution: ['inner0', 'inner1'],
},
})
const resolver = new ParallelResolver(workflow)
const parallelExecutions = new Map([
['inner-parallel__obranch-2', createParallelScope(['inner0', 'inner1'])],
])
const ctx = createTestContext('block-1__cloneabc__obranch-2₍1₎', parallelExecutions)
expect(resolver.resolve('<parallel.index>', ctx)).toBe(1)
expect(resolver.resolve('<parallel.currentItem>', ctx)).toBe('inner1')
})
it('resolves parent parallel context for branch-zero nested subflow descendants', () => {
const workflow = createTestWorkflow(
{
'outer-parallel': {
nodes: ['inner-loop'],
distribution: ['outer0', 'outer1'],
},
},
[],
{ 'inner-loop': { id: 'inner-loop', nodes: ['loop-task'] } }
)
const resolver = new ParallelResolver(workflow)
const parallelExecutions = new Map([
['outer-parallel', createParallelScope(['outer0', 'outer1'])],
])
const ctx = createTestContext(
'loop-task',
parallelExecutions,
undefined,
undefined,
new Map([
['inner-loop', { parentId: 'outer-parallel', parentType: 'parallel', branchIndex: 0 }],
])
)
expect(resolver.resolve('<parallel.index>', ctx)).toBe(0)
expect(resolver.resolve('<parallel.currentItem>', ctx)).toBe('outer0')
})
})
})
@@ -0,0 +1,396 @@
import { createLogger } from '@sim/logger'
import { assertNoLargeValueRefs } from '@/lib/execution/payloads/large-value-ref'
import { isReference, normalizeName, parseReferencePath, REFERENCE } from '@/executor/constants'
import { InvalidFieldError } from '@/executor/utils/block-reference'
import {
extractBranchIndex,
extractInnermostOuterBranchIndex,
extractOuterBranchIndex,
findEffectiveContainerId,
isSubflowNestedInside,
stripCloneSuffixes,
stripOuterBranchSuffix,
subflowContainsBlock,
} from '@/executor/utils/subflow-utils'
import {
type AsyncPathNavigator,
navigatePath,
type ResolutionContext,
type Resolver,
splitLeadingBracketPath,
} from '@/executor/variables/resolvers/reference'
import type { SerializedParallel, SerializedWorkflow } from '@/serializer/types'
const logger = createLogger('ParallelResolver')
const PARALLEL_OUTPUT_FIELDS = ['results'] as const
const PARALLEL_CONTEXT_FIELDS = ['index'] as const
const COLLECTION_PARALLEL_CONTEXT_FIELDS = ['index', 'currentItem', 'items'] as const
export class ParallelResolver implements Resolver {
private parallelNameToId: Map<string, string>
constructor(
private workflow: SerializedWorkflow,
private navigatePathAsync?: AsyncPathNavigator
) {
this.parallelNameToId = new Map()
for (const block of workflow.blocks) {
if (workflow.parallels?.[block.id] && block.metadata?.name) {
this.parallelNameToId.set(normalizeName(block.metadata.name), block.id)
}
}
}
private static OUTPUT_PROPERTIES = new Set(['result', 'results'])
private static KNOWN_PROPERTIES = new Set(['index', 'currentItem', 'items'])
canResolve(reference: string): boolean {
if (!isReference(reference)) {
return false
}
const parts = parseReferencePath(reference)
if (parts.length === 0) {
return false
}
const [type] = parts
return type === REFERENCE.PREFIX.PARALLEL || this.parallelNameToId.has(type)
}
resolve(reference: string, context: ResolutionContext): any {
return this.resolveInternal(reference, context, false)
}
async resolveAsync(reference: string, context: ResolutionContext): Promise<any> {
if (!this.navigatePathAsync) {
return this.resolve(reference, context)
}
return this.resolveInternal(reference, context, true)
}
private async resolveInternal(
reference: string,
context: ResolutionContext,
useAsyncPath: true
): Promise<any>
private resolveInternal(reference: string, context: ResolutionContext, useAsyncPath: false): any
private resolveInternal(
reference: string,
context: ResolutionContext,
useAsyncPath: boolean
): any | Promise<any> {
const parts = parseReferencePath(reference)
if (parts.length === 0) {
logger.warn('Invalid parallel reference', { reference })
return undefined
}
const [firstPart, ...rest] = parts
const isGenericRef = firstPart === REFERENCE.PREFIX.PARALLEL
// For named references, resolve to the specific parallel ID
let targetParallelId: string | undefined
if (isGenericRef) {
targetParallelId = this.findInnermostParallelForBlock(context.currentNodeId)
} else {
targetParallelId = this.parallelNameToId.get(firstPart)
}
if (!targetParallelId) {
return undefined
}
// Resolve the effective (possibly cloned) parallel ID for scope lookups
if (context.executionContext.parallelExecutions) {
const mappedBranchIndex =
(isGenericRef
? extractInnermostOuterBranchIndex(context.currentNodeId)
: extractOuterBranchIndex(context.currentNodeId)) ??
context.executionContext.parallelBlockMapping?.get(context.currentNodeId)?.iterationIndex
targetParallelId = findEffectiveContainerId(
targetParallelId,
context.currentNodeId,
context.executionContext.parallelExecutions,
mappedBranchIndex
)
}
if (rest.length > 0) {
const { property, pathParts: bracketPathParts } = splitLeadingBracketPath(rest[0])
if (ParallelResolver.OUTPUT_PROPERTIES.has(property)) {
return useAsyncPath
? this.resolveOutputAsync(
targetParallelId,
[...bracketPathParts, ...rest.slice(1)],
context
)
: this.resolveOutput(targetParallelId, [...bracketPathParts, ...rest.slice(1)], context)
}
}
// Look up config using the original (non-cloned) ID
const originalParallelId = stripOuterBranchSuffix(targetParallelId)
const parallelConfig = this.workflow.parallels?.[originalParallelId]
if (!parallelConfig) {
logger.warn('Parallel config not found', { parallelId: targetParallelId })
return undefined
}
const isContextual =
isGenericRef || this.isBlockInParallelOrDescendant(context.currentNodeId, originalParallelId)
if (rest.length > 0 && !isContextual) {
throw new InvalidFieldError(firstPart, rest[0], [...PARALLEL_OUTPUT_FIELDS])
}
const branchIndex = this.resolveBranchIndex(targetParallelId, context)
if (branchIndex === null) {
return undefined
}
const parallelScope = context.executionContext.parallelExecutions?.get(targetParallelId)
const distributionItems = parallelScope?.items ?? this.getDistributionItems(parallelConfig)
const currentItem = this.resolveCurrentItem(distributionItems, branchIndex)
if (rest.length === 0) {
const result: Record<string, any> = { index: branchIndex }
if (distributionItems !== undefined) {
result.items = distributionItems
result.currentItem = currentItem
}
return result
}
const [rawProperty, ...remainingPathParts] = rest
const { property, pathParts: bracketPathParts } = splitLeadingBracketPath(rawProperty)
const pathParts = [...bracketPathParts, ...remainingPathParts]
if (!ParallelResolver.KNOWN_PROPERTIES.has(property)) {
throw new InvalidFieldError(firstPart, rawProperty, this.getAvailableFields(parallelConfig))
}
let value: unknown
switch (property) {
case 'index':
value = branchIndex
break
case 'currentItem':
value = currentItem
if (value === undefined) return undefined
break
case 'items':
value = distributionItems
break
}
if (pathParts.length > 0) {
return useAsyncPath && this.navigatePathAsync
? this.navigatePathAsync(value, pathParts, context)
: navigatePath(value, pathParts, {
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
})
}
return value
}
private resolveBranchIndex(targetParallelId: string, context: ResolutionContext): number | null {
const mapping = context.executionContext.parallelBlockMapping?.get(context.currentNodeId)
const originalTargetParallelId = stripOuterBranchSuffix(targetParallelId)
if (
mapping?.parallelId === targetParallelId ||
mapping?.parallelId === originalTargetParallelId
) {
return mapping.iterationIndex
}
const branchIndex = extractBranchIndex(context.currentNodeId)
if (targetParallelId !== originalTargetParallelId && branchIndex !== null) {
return branchIndex
}
const outerBranchIndex = extractOuterBranchIndex(context.currentNodeId)
if (outerBranchIndex !== undefined) {
return outerBranchIndex
}
const parentBranchIndex = this.resolveParentParallelBranchIndex(
originalTargetParallelId,
context
)
if (parentBranchIndex !== undefined) {
return parentBranchIndex
}
return branchIndex
}
private resolveParentParallelBranchIndex(
targetParallelId: string,
context: ResolutionContext
): number | undefined {
const parentMap = context.executionContext.subflowParentMap
if (!parentMap) return undefined
const baseId = stripCloneSuffixes(context.currentNodeId)
for (const [subflowId, entry] of parentMap) {
if (entry.parentType !== 'parallel' || entry.parentId !== targetParallelId) continue
if (entry.branchIndex === undefined) continue
const originalSubflowId = stripOuterBranchSuffix(subflowId)
if (this.workflow.loops?.[originalSubflowId]) {
if (subflowContainsBlock(this.workflow, 'loop', originalSubflowId, baseId)) {
return entry.branchIndex
}
} else if (this.workflow.parallels?.[originalSubflowId]) {
if (subflowContainsBlock(this.workflow, 'parallel', originalSubflowId, baseId)) {
return entry.branchIndex
}
}
}
return undefined
}
private findInnermostParallelForBlock(blockId: string): string | undefined {
const baseId = stripCloneSuffixes(blockId)
const parallels = this.workflow.parallels
if (!parallels) return undefined
const candidateIds = Object.keys(parallels).filter((parallelId) =>
subflowContainsBlock(this.workflow, 'parallel', parallelId, baseId)
)
if (candidateIds.length === 0) return undefined
if (candidateIds.length === 1) return candidateIds[0]
// Return the innermost: the parallel that is not an ancestor of any other candidate.
// In a valid DAG, exactly one candidate will satisfy this (circular containment is impossible).
return candidateIds.find((candidateId) =>
candidateIds.every(
(otherId) =>
otherId === candidateId ||
!isSubflowNestedInside(this.workflow, 'parallel', otherId, 'parallel', candidateId)
)
)
}
private isBlockInParallelOrDescendant(blockId: string, targetParallelId: string): boolean {
const baseId = stripCloneSuffixes(blockId)
return subflowContainsBlock(this.workflow, 'parallel', targetParallelId, baseId)
}
private resolveCurrentItem(
distributionItems: unknown[] | undefined,
branchIndex: number
): unknown {
if (Array.isArray(distributionItems)) {
return distributionItems[branchIndex]
}
if (typeof distributionItems === 'object' && distributionItems !== null) {
const keys = Object.keys(distributionItems)
const key = keys[branchIndex]
return key !== undefined ? (distributionItems as Record<string, unknown>)[key] : undefined
}
return undefined
}
private resolveOutput(
parallelId: string,
pathParts: string[],
context: ResolutionContext
): unknown {
const output = context.executionState.getBlockOutput(parallelId)
if (!output || typeof output !== 'object') {
return undefined
}
const value = navigatePath(output, ['results'], {
allowLargeValueRefs: true,
executionContext: context.executionContext,
})
if (pathParts.length > 0) {
return navigatePath(value, pathParts, {
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
})
}
if (!context.allowLargeValueRefs) {
assertNoLargeValueRefs(value)
}
return value
}
private async resolveOutputAsync(
parallelId: string,
pathParts: string[],
context: ResolutionContext
): Promise<unknown> {
const output = context.executionState.getBlockOutput(parallelId)
if (!output || typeof output !== 'object') {
return undefined
}
const value = this.navigatePathAsync
? await this.navigatePathAsync(output, ['results'], { ...context, allowLargeValueRefs: true })
: navigatePath(output, ['results'], {
allowLargeValueRefs: true,
executionContext: context.executionContext,
})
if (pathParts.length > 0) {
return this.navigatePathAsync
? this.navigatePathAsync(value, pathParts, context)
: navigatePath(value, pathParts, {
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
})
}
if (!context.allowLargeValueRefs) {
assertNoLargeValueRefs(value)
}
return value
}
private getDistributionItems(parallelConfig: SerializedParallel): unknown[] {
const rawItems = parallelConfig.distribution ?? []
// Already an array - return as-is
if (Array.isArray(rawItems)) {
return rawItems
}
// Object - convert to entries array (consistent with loop forEach behavior)
if (typeof rawItems === 'object' && rawItems !== null) {
return Object.entries(rawItems)
}
// String handling
if (typeof rawItems === 'string') {
// Skip references - they should be resolved by the variable resolver
if (rawItems.startsWith(REFERENCE.START)) {
return []
}
// Try to parse as JSON
try {
const parsed = JSON.parse(rawItems.replace(/'/g, '"'))
if (Array.isArray(parsed)) {
return parsed
}
// Parsed to non-array (e.g. object) - convert to entries
if (typeof parsed === 'object' && parsed !== null) {
return Object.entries(parsed)
}
return []
} catch (e) {
logger.error('Failed to parse distribution items', { rawItems })
return []
}
}
return []
}
private getAvailableFields(parallelConfig: SerializedParallel): string[] {
return parallelConfig.parallelType === 'collection'
? [...COLLECTION_PARALLEL_CONTEXT_FIELDS]
: [...PARALLEL_CONTEXT_FIELDS]
}
}
@@ -0,0 +1,229 @@
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys'
import {
isLargeArrayManifest,
type LargeArrayManifest,
readLargeArrayManifestSlice,
} from '@/lib/execution/payloads/large-array-manifest'
import {
assertNoLargeValueRefs,
getLargeValueMaterializationError,
isLargeValueRef,
} from '@/lib/execution/payloads/large-value-ref'
import { materializeLargeValueRef } from '@/lib/execution/payloads/store'
import { hydrateUserFileWithBase64 } from '@/lib/uploads/utils/user-file-base64.server'
import type { PathNavigationContext } from '@/executor/variables/resolvers/reference'
interface MaterializedNavigationValue {
value: unknown
context: PathNavigationContext
}
function withLocalLargeValueExecutionIds(
context: PathNavigationContext,
materializedValue: unknown
): PathNavigationContext {
if (!context.executionContext) {
return context
}
recordMaterializedAccessKeys(context.executionContext, materializedValue)
return {
...context,
executionContext: {
...context.executionContext,
largeValueKeys: context.executionContext.largeValueKeys,
fileKeys: context.executionContext.fileKeys,
},
}
}
async function materializeLargeValueRefOrThrow(
value: unknown,
context: PathNavigationContext
): Promise<MaterializedNavigationValue> {
if (!isLargeValueRef(value)) {
return { value, context }
}
const materialized = await materializeLargeValueRef(value, {
workspaceId: context.executionContext.workspaceId,
workflowId: context.executionContext.workflowId,
executionId: context.executionContext.executionId,
largeValueExecutionIds: context.executionContext.largeValueExecutionIds,
largeValueKeys: context.executionContext.largeValueKeys,
fileKeys: context.executionContext.fileKeys,
allowLargeValueWorkflowScope: context.executionContext.allowLargeValueWorkflowScope,
userId: context.executionContext.userId,
})
if (materialized === undefined) {
throw getLargeValueMaterializationError(value)
}
return {
value: materialized,
context: withLocalLargeValueExecutionIds(context, materialized),
}
}
async function hydrateExplicitBase64(
file: unknown,
context: PathNavigationContext
): Promise<string | undefined> {
if (!isUserFileWithMetadata(file)) {
return undefined
}
const hydrated = await hydrateUserFileWithBase64(file, {
requestId: context.executionContext.metadata?.requestId,
workspaceId: context.executionContext.workspaceId,
workflowId: context.executionContext.workflowId,
executionId: context.executionContext.executionId,
largeValueExecutionIds: context.executionContext.largeValueExecutionIds,
largeValueKeys: context.executionContext.largeValueKeys,
fileKeys: context.executionContext.fileKeys,
allowLargeValueWorkflowScope: context.executionContext.allowLargeValueWorkflowScope,
userId: context.executionContext.userId,
maxBytes: context.executionContext.base64MaxBytes,
})
if (!hydrated.base64) {
throw new Error(
`Base64 content for ${file.name} is unavailable or exceeds the configured inline limit.`
)
}
return hydrated.base64
}
async function readManifestIndexAsync(
value: LargeArrayManifest,
part: string,
context: PathNavigationContext
): Promise<unknown> {
const [item] = await readLargeArrayManifestSlice(value, Number.parseInt(part, 10), 1, {
workspaceId: context.executionContext.workspaceId,
workflowId: context.executionContext.workflowId,
executionId: context.executionContext.executionId,
largeValueExecutionIds: context.executionContext.largeValueExecutionIds,
largeValueKeys: context.executionContext.largeValueKeys,
allowLargeValueWorkflowScope: context.executionContext.allowLargeValueWorkflowScope,
userId: context.executionContext.userId,
})
return item
}
async function navigateManifestMetadataOrIndexAsync(
value: unknown,
part: string,
context: PathNavigationContext
): Promise<MaterializedNavigationValue> {
if (!isLargeArrayManifest(value)) {
return { value: undefined, context }
}
if (part === 'length' || part === 'totalCount') {
return { value: value.totalCount, context }
}
if (part === 'chunkCount' || part === 'byteSize' || part === 'preview') {
return { value: value[part], context: withLocalLargeValueExecutionIds(context, value[part]) }
}
if (/^\d+$/.test(part)) {
const item = await readManifestIndexAsync(value, part, context)
return {
value: item,
context: withLocalLargeValueExecutionIds(context, item),
}
}
return { value: undefined, context }
}
/**
* Server-side path navigation used during execution. It can hydrate persisted
* large values and UserFile.base64 only when the requested path explicitly asks
* for base64.
*/
export async function navigatePathAsync(
obj: any,
path: string[],
context: PathNavigationContext
): Promise<any> {
let current = obj
let currentContext = context
for (const part of path) {
;({ value: current, context: currentContext } = await materializeLargeValueRefOrThrow(
current,
currentContext
))
if (current === null || current === undefined) {
return undefined
}
if (part === 'base64') {
const base64 = await hydrateExplicitBase64(current, currentContext)
if (base64 !== undefined) {
current = base64
continue
}
}
if (isLargeArrayManifest(current)) {
;({ value: current, context: currentContext } = await navigateManifestMetadataOrIndexAsync(
current,
part,
currentContext
))
continue
}
const arrayMatch = part.match(/^([^[]+)(\[.+)$/)
if (arrayMatch) {
const [, prop, bracketsPart] = arrayMatch
current =
typeof current === 'object' && current !== null
? (current as Record<string, unknown>)[prop]
: undefined
;({ value: current, context: currentContext } = await materializeLargeValueRefOrThrow(
current,
currentContext
))
if (current === undefined || current === null) {
return undefined
}
const indices = bracketsPart.match(/\[(\d+)\]/g)
if (indices) {
for (const indexMatch of indices) {
;({ value: current, context: currentContext } = await materializeLargeValueRefOrThrow(
current,
currentContext
))
if (current === null || current === undefined) {
return undefined
}
const idx = Number.parseInt(indexMatch.slice(1, -1), 10)
if (isLargeArrayManifest(current)) {
;({ value: current, context: currentContext } =
await navigateManifestMetadataOrIndexAsync(current, String(idx), currentContext))
} else {
current = Array.isArray(current) ? current[idx] : undefined
}
}
}
} else if (/^\d+$/.test(part)) {
const index = Number.parseInt(part, 10)
if (isLargeArrayManifest(current)) {
;({ value: current, context: currentContext } = await navigateManifestMetadataOrIndexAsync(
current,
part,
currentContext
))
} else {
current = Array.isArray(current) ? current[index] : undefined
}
} else {
current =
typeof current === 'object' && current !== null
? (current as Record<string, unknown>)[part]
: undefined
}
}
if (!context.allowLargeValueRefs) {
assertNoLargeValueRefs(current)
}
return current
}
@@ -0,0 +1,232 @@
import { describe, expect, it } from 'vitest'
import { navigatePath } from './reference'
describe('navigatePath', () => {
describe('basic property access', () => {
it.concurrent('should access top-level property', () => {
const obj = { name: 'test', value: 42 }
expect(navigatePath(obj, ['name'])).toBe('test')
expect(navigatePath(obj, ['value'])).toBe(42)
})
it.concurrent('should access nested properties', () => {
const obj = { a: { b: { c: 'deep' } } }
expect(navigatePath(obj, ['a', 'b', 'c'])).toBe('deep')
})
it.concurrent('should return entire object for empty path', () => {
const obj = { name: 'test' }
expect(navigatePath(obj, [])).toEqual(obj)
})
it.concurrent('should handle deeply nested objects', () => {
const obj = { level1: { level2: { level3: { level4: { value: 'found' } } } } }
expect(navigatePath(obj, ['level1', 'level2', 'level3', 'level4', 'value'])).toBe('found')
})
})
describe('array indexing', () => {
it.concurrent('should access array elements with numeric string index', () => {
const obj = { items: ['a', 'b', 'c'] }
expect(navigatePath(obj, ['items', '0'])).toBe('a')
expect(navigatePath(obj, ['items', '1'])).toBe('b')
expect(navigatePath(obj, ['items', '2'])).toBe('c')
})
it.concurrent('should access array elements with bracket notation', () => {
const obj = { items: [{ name: 'first' }, { name: 'second' }] }
expect(navigatePath(obj, ['items[0]', 'name'])).toBe('first')
expect(navigatePath(obj, ['items[1]', 'name'])).toBe('second')
})
it.concurrent('should access nested arrays', () => {
const obj = {
matrix: [
[1, 2],
[3, 4],
[5, 6],
],
}
expect(navigatePath(obj, ['matrix', '0', '0'])).toBe(1)
expect(navigatePath(obj, ['matrix', '1', '1'])).toBe(4)
expect(navigatePath(obj, ['matrix', '2', '0'])).toBe(5)
})
it.concurrent('should access array element properties', () => {
const obj = {
users: [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
],
}
expect(navigatePath(obj, ['users', '0', 'name'])).toBe('Alice')
expect(navigatePath(obj, ['users', '1', 'id'])).toBe(2)
})
})
describe('edge cases', () => {
it.concurrent('should return undefined for non-existent property', () => {
const obj = { name: 'test' }
expect(navigatePath(obj, ['nonexistent'])).toBeUndefined()
})
it.concurrent('should return undefined for path through null', () => {
const obj = { data: null }
expect(navigatePath(obj, ['data', 'value'])).toBeUndefined()
})
it.concurrent('should return undefined for path through undefined', () => {
const obj: Record<string, any> = { data: undefined }
expect(navigatePath(obj, ['data', 'value'])).toBeUndefined()
})
it.concurrent('should return null when accessing null property', () => {
const obj = { value: null }
expect(navigatePath(obj, ['value'])).toBeNull()
})
it.concurrent('should return undefined for out of bounds array access', () => {
const obj = { items: ['a', 'b'] }
expect(navigatePath(obj, ['items', '10'])).toBeUndefined()
})
it.concurrent('should return undefined when accessing array property on non-array', () => {
const obj = { data: 'string' }
expect(navigatePath(obj, ['data', '0'])).toBeUndefined()
})
it.concurrent('should handle empty object', () => {
const obj = {}
expect(navigatePath(obj, ['any'])).toBeUndefined()
})
it.concurrent('should handle object with empty string key', () => {
const obj = { '': 'empty key value' }
expect(navigatePath(obj, [''])).toBe('empty key value')
})
})
describe('mixed access patterns', () => {
it.concurrent('should handle complex nested structures', () => {
const obj = {
users: [
{
name: 'Alice',
addresses: [
{ city: 'NYC', zip: '10001' },
{ city: 'LA', zip: '90001' },
],
},
{
name: 'Bob',
addresses: [{ city: 'Chicago', zip: '60601' }],
},
],
}
expect(navigatePath(obj, ['users', '0', 'name'])).toBe('Alice')
expect(navigatePath(obj, ['users', '0', 'addresses', '1', 'city'])).toBe('LA')
expect(navigatePath(obj, ['users', '1', 'addresses', '0', 'zip'])).toBe('60601')
})
it.concurrent('should return undefined for numeric keys on non-array objects', () => {
// navigatePath treats numeric strings as array indices only for arrays
// For objects with numeric string keys, the numeric check takes precedence
// and returns undefined since the object is not an array
const obj = { data: { '0': 'zero', '1': 'one' } }
expect(navigatePath(obj, ['data', '0'])).toBeUndefined()
expect(navigatePath(obj, ['data', '1'])).toBeUndefined()
})
it.concurrent('should access non-numeric string keys', () => {
const obj = { data: { first: 'value1', second: 'value2' } }
expect(navigatePath(obj, ['data', 'first'])).toBe('value1')
expect(navigatePath(obj, ['data', 'second'])).toBe('value2')
})
})
describe('special value types', () => {
it.concurrent('should return boolean values', () => {
const obj = { active: true, disabled: false }
expect(navigatePath(obj, ['active'])).toBe(true)
expect(navigatePath(obj, ['disabled'])).toBe(false)
})
it.concurrent('should return numeric values including zero', () => {
const obj = { count: 0, value: -5, decimal: 3.14 }
expect(navigatePath(obj, ['count'])).toBe(0)
expect(navigatePath(obj, ['value'])).toBe(-5)
expect(navigatePath(obj, ['decimal'])).toBe(3.14)
})
it.concurrent('should return empty string', () => {
const obj = { text: '' }
expect(navigatePath(obj, ['text'])).toBe('')
})
it.concurrent('should return empty array', () => {
const obj = { items: [] }
expect(navigatePath(obj, ['items'])).toEqual([])
})
it.concurrent('should return function values', () => {
const fn = () => 'test'
const obj = { callback: fn }
expect(navigatePath(obj, ['callback'])).toBe(fn)
})
})
describe('large array manifests', () => {
it('returns undefined for sync index access when the chunk is not cached', () => {
const manifest = {
__simLargeArrayManifest: true,
version: 2,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: 16,
chunks: [
{
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 16,
executionId: 'execution-1',
},
count: 1,
byteSize: 16,
},
],
preview: [{ id: 1 }],
}
expect(navigatePath(manifest, ['0'])).toBeUndefined()
expect(navigatePath(manifest, ['length'])).toBe(1)
expect(navigatePath(manifest, ['preview'])).toEqual([{ id: 1 }])
})
})
describe('bracket notation edge cases', () => {
it.concurrent('should handle bracket notation with property access', () => {
const obj = { data: [{ value: 100 }, { value: 200 }] }
expect(navigatePath(obj, ['data[0]'])).toEqual({ value: 100 })
})
it.concurrent('should return undefined for bracket notation on non-existent property', () => {
const obj = { data: [1, 2, 3] }
expect(navigatePath(obj, ['nonexistent[0]'])).toBeUndefined()
})
it.concurrent('should return undefined for bracket notation with null property', () => {
const obj = { data: null }
expect(navigatePath(obj, ['data[0]'])).toBeUndefined()
})
it.concurrent('should return undefined for bracket notation on non-array', () => {
const obj = { data: 'string' }
expect(navigatePath(obj, ['data[0]'])).toBeUndefined()
})
})
})
@@ -0,0 +1,201 @@
import {
materializeLargeValueRefSync,
materializeLargeValueRefSyncOrThrow,
} from '@/lib/execution/payloads/cache'
import {
isLargeArrayManifest,
type LargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest-metadata'
import { assertNoLargeValueRefs, isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import type { ExecutionState, LoopScope } from '@/executor/execution/state'
import type { ExecutionContext } from '@/executor/types'
export interface PathNavigationExecutionContext {
workflowId: string
workspaceId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
metadata?: { requestId?: string }
base64MaxBytes?: number
}
export interface PathNavigationContext {
executionContext: PathNavigationExecutionContext
allowLargeValueRefs?: boolean
}
export interface ResolutionContext {
executionContext: ExecutionContext
executionState: ExecutionState
currentNodeId: string
loopScope?: LoopScope
allowLargeValueRefs?: boolean
}
export interface Resolver {
canResolve(reference: string): boolean
resolve(reference: string, context: ResolutionContext): any
resolveAsync?(reference: string, context: ResolutionContext): Promise<any>
}
export type AsyncPathNavigator = (
obj: any,
path: string[],
context: PathNavigationContext
) => Promise<any>
/**
* Sentinel value indicating a reference was resolved to a known block
* that produced no output (e.g., the block exists in the workflow but
* didn't execute on this path). Distinct from `undefined`, which means
* the reference couldn't be matched to any block at all.
*/
export const RESOLVED_EMPTY = Symbol('RESOLVED_EMPTY')
export function splitLeadingBracketPath(part: string): { property: string; pathParts: string[] } {
const bracketMatch = part.match(/^([^[]+)((?:\[\d+\])+)$/)
if (!bracketMatch) {
return { property: part, pathParts: [] }
}
const indices = bracketMatch[2].match(/\[(\d+)\]/g) ?? []
return {
property: bracketMatch[1],
pathParts: indices.map((indexMatch) => indexMatch.slice(1, -1)),
}
}
function readManifestIndexSync(
manifest: LargeArrayManifest,
index: number,
executionContext?: ExecutionContext
): unknown {
if (!Number.isInteger(index) || index < 0 || index >= manifest.totalCount) {
return undefined
}
let offset = 0
for (const chunk of manifest.chunks) {
const nextOffset = offset + chunk.count
if (index < nextOffset) {
const materialized = materializeLargeValueRefSync(chunk.ref, executionContext)
if (materialized === undefined) {
return undefined
}
if (!Array.isArray(materialized)) {
throw new Error('Large array manifest chunk must materialize to an array.')
}
if (materialized.length !== chunk.count) {
throw new Error('Large array manifest chunk count does not match materialized data.')
}
return materialized[index - offset]
}
offset = nextOffset
}
return undefined
}
function navigateManifestMetadataOrIndexSync(
manifest: LargeArrayManifest,
part: string,
executionContext?: ExecutionContext
): unknown {
if (part === 'length' || part === 'totalCount') {
return manifest.totalCount
}
if (part === 'chunkCount' || part === 'byteSize' || part === 'preview') {
return manifest[part]
}
if (/^\d+$/.test(part)) {
return readManifestIndexSync(manifest, Number.parseInt(part, 10), executionContext)
}
return undefined
}
/**
* Navigate through nested object properties using a path array.
* Supports dot notation and array indices.
*
* @example
* navigatePath({a: {b: {c: 1}}}, ['a', 'b', 'c']) => 1
* navigatePath({items: [{name: 'test'}]}, ['items', '0', 'name']) => 'test'
*/
export function navigatePath(
obj: any,
path: string[],
options: { allowLargeValueRefs?: boolean; executionContext?: ExecutionContext } = {}
): any {
let current = obj
for (const part of path) {
if (isLargeValueRef(current)) {
current = materializeLargeValueRefSyncOrThrow(current, options.executionContext)
}
if (current === null || current === undefined) {
return undefined
}
if (isLargeArrayManifest(current)) {
current = navigateManifestMetadataOrIndexSync(current, part, options.executionContext)
continue
}
const arrayMatch = part.match(/^([^[]+)(\[.+)$/)
if (arrayMatch) {
const [, prop, bracketsPart] = arrayMatch
current =
typeof current === 'object' && current !== null
? (current as Record<string, unknown>)[prop]
: undefined
if (isLargeValueRef(current)) {
current = materializeLargeValueRefSyncOrThrow(current, options.executionContext)
}
if (current === undefined || current === null) {
return undefined
}
const indices = bracketsPart.match(/\[(\d+)\]/g)
if (indices) {
for (const indexMatch of indices) {
if (current === null || current === undefined) {
return undefined
}
if (isLargeValueRef(current)) {
current = materializeLargeValueRefSyncOrThrow(current, options.executionContext)
}
if (isLargeArrayManifest(current)) {
current = navigateManifestMetadataOrIndexSync(
current,
indexMatch.slice(1, -1),
options.executionContext
)
continue
}
const idx = Number.parseInt(indexMatch.slice(1, -1), 10)
current = Array.isArray(current) ? current[idx] : undefined
}
}
} else if (/^\d+$/.test(part)) {
const index = Number.parseInt(part, 10)
current = isLargeArrayManifest(current)
? readManifestIndexSync(current, index, options.executionContext)
: Array.isArray(current)
? current[index]
: undefined
} else {
current =
typeof current === 'object' && current !== null
? (current as Record<string, unknown>)[part]
: undefined
}
}
if (!options.allowLargeValueRefs) {
assertNoLargeValueRefs(current)
}
return current
}
@@ -0,0 +1,326 @@
import { describe, expect, it, vi } from 'vitest'
import {
createLargeArrayManifest,
isLargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest'
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server'
import type { ResolutionContext } from './reference'
import { WorkflowResolver } from './workflow'
vi.mock('@/lib/workflows/variables/variable-manager', () => ({
VariableManager: {
resolveForExecution: vi.fn((value) => value),
},
}))
/**
* Creates a minimal ResolutionContext for testing.
* The WorkflowResolver only uses context.executionContext.workflowVariables,
* so we only need to provide that field.
*/
function createTestContext(workflowVariables: Record<string, any>): ResolutionContext {
return {
executionContext: {
workflowVariables,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
},
executionState: {},
currentNodeId: 'test-node',
} as ResolutionContext
}
describe('WorkflowResolver', () => {
describe('canResolve', () => {
it.concurrent('should return true for variable references', () => {
const resolver = new WorkflowResolver({})
expect(resolver.canResolve('<variable.myvar>')).toBe(true)
expect(resolver.canResolve('<variable.test>')).toBe(true)
})
it.concurrent('should return false for non-variable references', () => {
const resolver = new WorkflowResolver({})
expect(resolver.canResolve('<block.output>')).toBe(false)
expect(resolver.canResolve('<loop.index>')).toBe(false)
expect(resolver.canResolve('plain text')).toBe(false)
})
})
describe('resolve with normalized matching', () => {
it.concurrent('should resolve variable with exact name match', () => {
const variables = {
'var-1': { id: 'var-1', name: 'myvar', type: 'plain', value: 'test-value' },
}
const resolver = new WorkflowResolver(variables)
const result = resolver.resolve('<variable.myvar>', createTestContext(variables))
expect(result).toBe('test-value')
})
it.concurrent('should resolve variable with normalized name (lowercase)', () => {
const variables = {
'var-1': { id: 'var-1', name: 'MyVar', type: 'plain', value: 'test-value' },
}
const resolver = new WorkflowResolver(variables)
const result = resolver.resolve('<variable.myvar>', createTestContext(variables))
expect(result).toBe('test-value')
})
it.concurrent('should resolve variable with normalized name (spaces removed)', () => {
const variables = {
'var-1': { id: 'var-1', name: 'My Variable', type: 'plain', value: 'test-value' },
}
const resolver = new WorkflowResolver(variables)
const result = resolver.resolve('<variable.myvariable>', createTestContext(variables))
expect(result).toBe('test-value')
})
it.concurrent(
'should resolve variable with fully normalized name (JIRA TEAM UUID case)',
() => {
const variables = {
'var-1': { id: 'var-1', name: 'JIRA TEAM UUID', type: 'plain', value: 'uuid-123' },
}
const resolver = new WorkflowResolver(variables)
const result = resolver.resolve('<variable.jirateamuuid>', createTestContext(variables))
expect(result).toBe('uuid-123')
}
)
it.concurrent('should resolve variable regardless of reference case', () => {
const variables = {
'var-1': { id: 'var-1', name: 'jirateamuuid', type: 'plain', value: 'uuid-123' },
}
const resolver = new WorkflowResolver(variables)
const result = resolver.resolve('<variable.JIRATEAMUUID>', createTestContext(variables))
expect(result).toBe('uuid-123')
})
it.concurrent('should resolve by variable ID (exact match)', () => {
const variables = {
'my-uuid-id': { id: 'my-uuid-id', name: 'Some Name', type: 'plain', value: 'id-value' },
}
const resolver = new WorkflowResolver(variables)
const result = resolver.resolve('<variable.my-uuid-id>', createTestContext(variables))
expect(result).toBe('id-value')
})
it.concurrent('should return undefined for non-existent variable', () => {
const variables = {
'var-1': { id: 'var-1', name: 'existing', type: 'plain', value: 'test' },
}
const resolver = new WorkflowResolver(variables)
const result = resolver.resolve('<variable.nonexistent>', createTestContext(variables))
expect(result).toBeUndefined()
})
it.concurrent('should handle nested path access', () => {
const variables = {
'var-1': {
id: 'var-1',
name: 'config',
type: 'object',
value: { nested: { value: 'deep' } },
},
}
const resolver = new WorkflowResolver(variables)
const result = resolver.resolve(
'<variable.config.nested.value>',
createTestContext(variables)
)
expect(result).toBe('deep')
})
it.concurrent('should resolve with mixed case and spaces in reference', () => {
const variables = {
'var-1': { id: 'var-1', name: 'api key', type: 'plain', value: 'secret-key' },
}
const resolver = new WorkflowResolver(variables)
const result = resolver.resolve('<variable.APIKEY>', createTestContext(variables))
expect(result).toBe('secret-key')
})
it.concurrent('should handle real-world variable naming patterns', () => {
const testCases = [
{ varName: 'User ID', refName: 'userid', value: 'user-123' },
{ varName: 'API Key', refName: 'apikey', value: 'key-456' },
{ varName: 'STRIPE SECRET KEY', refName: 'stripesecretkey', value: 'sk_test' },
{ varName: 'Database URL', refName: 'databaseurl', value: 'postgres://...' },
]
for (const { varName, refName, value } of testCases) {
const variables = {
'var-1': { id: 'var-1', name: varName, type: 'plain', value },
}
const resolver = new WorkflowResolver(variables)
const result = resolver.resolve(`<variable.${refName}>`, createTestContext(variables))
expect(result).toBe(value)
}
})
it('returns whole large workflow variable manifests only when refs are allowed', async () => {
const compacted = await compactExecutionPayload(
Array.from({ length: 100 }, (_, index) => ({
key: `SIM-${index}`,
summary: 'Issue summary that keeps each item small',
})),
{
thresholdBytes: 256,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
}
)
const variables = {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: compacted },
}
const resolver = new WorkflowResolver(variables, navigatePathAsync)
const context = createTestContext(variables)
expect(() => resolver.resolve('<variable.issues>', context)).toThrow('too large to inline')
await expect(resolver.resolveAsync('<variable.issues>', context)).rejects.toThrow(
'too large to inline'
)
const allowedContext = { ...context, allowLargeValueRefs: true }
expect(isLargeArrayManifest(resolver.resolve('<variable.issues>', allowedContext))).toBe(true)
await expect(resolver.resolveAsync('<variable.issues>', allowedContext)).resolves.toEqual(
compacted
)
})
it('resolves nested paths through async large workflow variable navigation', async () => {
const compacted = await compactExecutionPayload(
Array.from({ length: 100 }, (_, index) => ({
key: `SIM-${index + 1}`,
fields: { summary: index === 0 ? 'Large issue' : 'Other issue' },
})),
{
thresholdBytes: 256,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
}
)
const variables = {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: compacted },
}
const resolver = new WorkflowResolver(variables, navigatePathAsync)
await expect(
resolver.resolveAsync('<variable.issues.0.fields.summary>', createTestContext(variables))
).resolves.toBe('Large issue')
})
it('preserves large array manifest workflow variables without array coercion', async () => {
const manifest = await createLargeArrayManifest([{ key: 'SIM-1' }], {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
})
const variables = {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: manifest },
}
const resolver = new WorkflowResolver(variables, navigatePathAsync)
const allowedContext = { ...createTestContext(variables), allowLargeValueRefs: true }
expect(resolver.resolve('<variable.issues>', allowedContext)).toEqual(manifest)
await expect(resolver.resolveAsync('<variable.issues>', allowedContext)).resolves.toEqual(
manifest
)
})
it('resolves bracket-indexed paths through manifest workflow variables', async () => {
const manifest = await createLargeArrayManifest([{ key: 'SIM-1' }], {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
})
const variables = {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: manifest },
}
const resolver = new WorkflowResolver(variables, navigatePathAsync)
await expect(
resolver.resolveAsync('<variable.issues[0].key>', createTestContext(variables))
).resolves.toBe('SIM-1')
})
it('resolves manifest array length without materializing chunks', async () => {
const manifest = await createLargeArrayManifest([{ key: 'SIM-1' }, { key: 'SIM-2' }], {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
})
const variables = {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: manifest },
}
const resolver = new WorkflowResolver(variables, navigatePathAsync)
await expect(
resolver.resolveAsync('<variable.issues.length>', createTestContext(variables))
).resolves.toBe(2)
})
})
describe('edge cases', () => {
it.concurrent('should handle empty workflow variables', () => {
const resolver = new WorkflowResolver({})
const result = resolver.resolve('<variable.anyvar>', createTestContext({}))
expect(result).toBeUndefined()
})
it.concurrent('should handle invalid reference format', () => {
const resolver = new WorkflowResolver({})
const result = resolver.resolve('<variable>', createTestContext({}))
expect(result).toBeUndefined()
})
it.concurrent('should handle null variable values in the map', () => {
const variables: Record<string, any> = {
'var-1': null,
'var-2': { id: 'var-2', name: 'valid', type: 'plain', value: 'exists' },
}
const resolver = new WorkflowResolver(variables)
const result = resolver.resolve('<variable.valid>', createTestContext(variables))
expect(result).toBe('exists')
})
it.concurrent('should handle variable with empty name', () => {
const variables = {
'var-1': { id: 'var-1', name: '', type: 'plain', value: 'empty-name' },
}
const resolver = new WorkflowResolver(variables)
// Empty name normalizes to empty string, which matches "<variable.>" reference
const result = resolver.resolve('<variable.>', createTestContext(variables))
expect(result).toBe('empty-name')
})
it.concurrent('should prefer name match over ID match when both could apply', () => {
const variables = {
apikey: { id: 'apikey', name: 'different', type: 'plain', value: 'by-id' },
'var-2': { id: 'var-2', name: 'apikey', type: 'plain', value: 'by-name' },
}
const resolver = new WorkflowResolver(variables)
const result = resolver.resolve('<variable.apikey>', createTestContext(variables))
expect(['by-id', 'by-name']).toContain(result)
})
})
})
@@ -0,0 +1,140 @@
import { createLogger } from '@sim/logger'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { assertNoLargeValueRefs, isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import { VariableManager } from '@/lib/workflows/variables/variable-manager'
import { isReference, normalizeName, parseReferencePath, REFERENCE } from '@/executor/constants'
import {
type AsyncPathNavigator,
navigatePath,
type ResolutionContext,
type Resolver,
splitLeadingBracketPath,
} from '@/executor/variables/resolvers/reference'
import type { VariableType } from '@/stores/variables/types'
const logger = createLogger('WorkflowResolver')
export class WorkflowResolver implements Resolver {
constructor(
private workflowVariables: Record<string, any>,
private navigatePathAsync?: AsyncPathNavigator
) {}
canResolve(reference: string): boolean {
if (!isReference(reference)) {
return false
}
const parts = parseReferencePath(reference)
if (parts.length === 0) {
return false
}
const [type] = parts
return type === REFERENCE.PREFIX.VARIABLE
}
resolve(reference: string, context: ResolutionContext): any {
const parts = parseReferencePath(reference)
if (parts.length < 2) {
logger.warn('Invalid variable reference - missing variable name', { reference })
return undefined
}
const [_, rawVariableName, ...rawPathParts] = parts
const { property: variableName, pathParts: bracketPathParts } =
splitLeadingBracketPath(rawVariableName)
const pathParts = [...bracketPathParts, ...rawPathParts]
const normalizedRefName = normalizeName(variableName)
const workflowVars = context.executionContext.workflowVariables || this.workflowVariables
for (const varObj of Object.values(workflowVars)) {
const v = varObj as any
if (!v) continue
// Match by normalized name or exact ID
const normalizedVarName = v.name ? normalizeName(v.name) : ''
if (normalizedVarName === normalizedRefName || v.id === variableName) {
const normalizedType = (v.type === 'string' ? 'plain' : v.type) || 'plain'
let value: any
value = this.resolveVariableValue(v.value, normalizedType, variableName)
if (pathParts.length > 0) {
return navigatePath(value, pathParts, {
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
})
}
if (!context.allowLargeValueRefs) {
assertNoLargeValueRefs(value)
}
return value
}
}
return undefined
}
async resolveAsync(reference: string, context: ResolutionContext): Promise<any> {
const parts = parseReferencePath(reference)
if (parts.length < 2) {
logger.warn('Invalid variable reference - missing variable name', { reference })
return undefined
}
const [_, rawVariableName, ...rawPathParts] = parts
const { property: variableName, pathParts: bracketPathParts } =
splitLeadingBracketPath(rawVariableName)
const pathParts = [...bracketPathParts, ...rawPathParts]
const normalizedRefName = normalizeName(variableName)
const workflowVars = context.executionContext.workflowVariables || this.workflowVariables
for (const varObj of Object.values(workflowVars)) {
const v = varObj as any
if (!v) continue
const normalizedVarName = v.name ? normalizeName(v.name) : ''
if (normalizedVarName === normalizedRefName || v.id === variableName) {
const normalizedType = (v.type === 'string' ? 'plain' : v.type) || 'plain'
let value: any
value = this.resolveVariableValue(v.value, normalizedType, variableName)
if (pathParts.length > 0) {
return this.navigatePathAsync
? this.navigatePathAsync(value, pathParts, context)
: navigatePath(value, pathParts, {
allowLargeValueRefs: context.allowLargeValueRefs,
executionContext: context.executionContext,
})
}
if (!context.allowLargeValueRefs) {
assertNoLargeValueRefs(value)
}
return value
}
}
return undefined
}
private resolveVariableValue(
value: any,
normalizedType: VariableType,
variableName: string
): any {
if (isLargeValueRef(value) || isLargeArrayManifest(value)) {
return value
}
try {
return VariableManager.resolveForExecution(value, normalizedType)
} catch (error) {
logger.warn('Failed to resolve workflow variable, returning raw value', {
variableName,
error: (error as Error).message,
})
return value
}
}
}