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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
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
}
}
}