chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user