chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
|
||||
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
|
||||
import { EDGE } from '@/executor/constants'
|
||||
import type { DAG, DAGNode } from '@/executor/dag/builder'
|
||||
import type { EdgeManager } from '@/executor/execution/edge-manager'
|
||||
import type { BlockStateController } from '@/executor/execution/types'
|
||||
import { LoopOrchestrator } from '@/executor/orchestrators/loop'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
|
||||
const { mockExecuteInIsolatedVM, mockUploadFile } = vi.hoisted(() => ({
|
||||
mockExecuteInIsolatedVM: vi.fn(),
|
||||
mockUploadFile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/isolated-vm', () => ({
|
||||
executeInIsolatedVM: mockExecuteInIsolatedVM,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads', () => ({
|
||||
StorageService: {
|
||||
uploadFile: mockUploadFile,
|
||||
},
|
||||
}))
|
||||
|
||||
function createNode(id: string): DAGNode {
|
||||
return {
|
||||
id,
|
||||
block: {
|
||||
id,
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
metadata: { id: 'function', name: id },
|
||||
config: { params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
},
|
||||
incomingEdges: new Set(),
|
||||
outgoingEdges: new Map(),
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
|
||||
function createState(): BlockStateController {
|
||||
return {
|
||||
getBlockOutput: vi.fn(),
|
||||
hasExecuted: vi.fn(() => false),
|
||||
setBlockOutput: vi.fn(),
|
||||
setBlockState: vi.fn(),
|
||||
deleteBlockState: vi.fn(),
|
||||
unmarkExecuted: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(scope: Record<string, unknown> = {}, loopId = 'loop-1'): ExecutionContext {
|
||||
return {
|
||||
workflowId: 'workflow-1',
|
||||
workspaceId: 'workspace-1',
|
||||
executionId: 'execution-1',
|
||||
userId: 'user-1',
|
||||
blockStates: new Map(),
|
||||
executedBlocks: new Set(),
|
||||
blockLogs: [],
|
||||
metadata: { requestId: 'request-1' },
|
||||
environmentVariables: {},
|
||||
workflowVariables: {},
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
completedLoops: new Set(),
|
||||
activeExecutionPath: new Set(),
|
||||
loopExecutions: new Map([[loopId, scope as any]]),
|
||||
} as ExecutionContext
|
||||
}
|
||||
|
||||
function createOrchestrator(loopConfigs = new Map<string, any>()) {
|
||||
const state = createState()
|
||||
const orchestrator = new LoopOrchestrator(
|
||||
{ loopConfigs, parallelConfigs: new Map(), nodes: new Map() } as any,
|
||||
state,
|
||||
{ resolveSingleReference: vi.fn() } as any
|
||||
)
|
||||
return { orchestrator, setBlockOutput: vi.mocked(state.setBlockOutput) }
|
||||
}
|
||||
|
||||
describe('LoopOrchestrator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clearLargeValueCacheForTests()
|
||||
mockExecuteInIsolatedVM.mockResolvedValue({ result: true })
|
||||
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
|
||||
})
|
||||
|
||||
it('does not restore parallel_continue back edges for nested parallels', () => {
|
||||
const loopId = 'loop-1'
|
||||
const parallelId = 'parallel-1'
|
||||
const loopStartId = `loop-${loopId}-sentinel-start`
|
||||
const loopEndId = `loop-${loopId}-sentinel-end`
|
||||
const parallelStartId = `parallel-${parallelId}-sentinel-start`
|
||||
const parallelEndId = `parallel-${parallelId}-sentinel-end`
|
||||
const loopStart = createNode(loopStartId)
|
||||
const loopEnd = createNode(loopEndId)
|
||||
const parallelStart = createNode(parallelStartId)
|
||||
const parallelEnd = createNode(parallelEndId)
|
||||
loopStart.outgoingEdges.set(`${loopStartId}->${parallelStartId}`, { target: parallelStartId })
|
||||
loopStart.outgoingEdges.set(`${loopStartId}->${loopEndId}-exit`, {
|
||||
target: loopEndId,
|
||||
sourceHandle: EDGE.LOOP_EXIT,
|
||||
})
|
||||
parallelStart.outgoingEdges.set(`${parallelStartId}->${parallelEndId}-exit`, {
|
||||
target: parallelEndId,
|
||||
sourceHandle: EDGE.PARALLEL_EXIT,
|
||||
})
|
||||
parallelEnd.outgoingEdges.set(`${parallelEndId}->${parallelStartId}-continue`, {
|
||||
target: parallelStartId,
|
||||
sourceHandle: EDGE.PARALLEL_CONTINUE,
|
||||
})
|
||||
parallelEnd.outgoingEdges.set(`${parallelEndId}->${loopEndId}-exit`, {
|
||||
target: loopEndId,
|
||||
sourceHandle: EDGE.PARALLEL_EXIT,
|
||||
})
|
||||
|
||||
const dag: DAG = {
|
||||
nodes: new Map([
|
||||
[loopStartId, loopStart],
|
||||
[loopEndId, loopEnd],
|
||||
[parallelStartId, parallelStart],
|
||||
[parallelEndId, parallelEnd],
|
||||
]),
|
||||
loopConfigs: new Map([[loopId, { id: loopId, nodes: [parallelId], loopType: 'for' }]]),
|
||||
parallelConfigs: new Map([
|
||||
[parallelId, { id: parallelId, nodes: [], parallelType: 'count' }],
|
||||
]),
|
||||
}
|
||||
const edgeManager = {
|
||||
clearDeactivatedEdgesForNodes: vi.fn(),
|
||||
} as unknown as EdgeManager
|
||||
const orchestrator = new LoopOrchestrator(dag, createState(), null as any, {}, edgeManager)
|
||||
|
||||
orchestrator.restoreLoopEdges(loopId)
|
||||
|
||||
expect(parallelStart.incomingEdges.has(loopStartId)).toBe(true)
|
||||
expect(parallelStart.incomingEdges.has(parallelEndId)).toBe(false)
|
||||
expect(loopEnd.incomingEdges.has(loopStartId)).toBe(false)
|
||||
expect(parallelEnd.incomingEdges.has(parallelStartId)).toBe(false)
|
||||
expect(loopEnd.incomingEdges.has(parallelEndId)).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves forEach collections with the loop start sentinel scope', async () => {
|
||||
const loopId = 'loop-1'
|
||||
const dag: DAG = {
|
||||
nodes: new Map(),
|
||||
loopConfigs: new Map([
|
||||
[
|
||||
loopId,
|
||||
{
|
||||
id: loopId,
|
||||
nodes: ['task-1'],
|
||||
loopType: 'forEach',
|
||||
forEachItems: '<Producer.items>',
|
||||
},
|
||||
],
|
||||
]),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
const resolver = {
|
||||
resolveSingleReference: vi.fn().mockResolvedValue(['item-1']),
|
||||
}
|
||||
const orchestrator = new LoopOrchestrator(dag, createState(), resolver as any, {}, {
|
||||
clearDeactivatedEdgesForNodes: vi.fn(),
|
||||
} as unknown as EdgeManager)
|
||||
const ctx = createContext()
|
||||
|
||||
const scope = await orchestrator.initializeLoopScope(ctx, loopId)
|
||||
|
||||
expect(resolver.resolveSingleReference).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
'loop-loop-1-sentinel-start',
|
||||
'<Producer.items>',
|
||||
undefined,
|
||||
{ allowLargeValueRefs: true }
|
||||
)
|
||||
expect(scope.maxIterations).toBe(1)
|
||||
})
|
||||
|
||||
it('exits immediately when a loop was skipped at start', async () => {
|
||||
const loopId = 'loop-1'
|
||||
const state = createState()
|
||||
const dag: DAG = {
|
||||
nodes: new Map(),
|
||||
loopConfigs: new Map([[loopId, { id: loopId, nodes: ['task-1'], loopType: 'while' }]]),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
const resolver = {
|
||||
resolveSingleReference: vi.fn().mockResolvedValue(1),
|
||||
}
|
||||
const orchestrator = new LoopOrchestrator(dag, state, resolver as any, {}, {
|
||||
clearDeactivatedEdgesForNodes: vi.fn(),
|
||||
} as unknown as EdgeManager)
|
||||
const ctx = createContext(
|
||||
{
|
||||
iteration: 0,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
loopType: 'while',
|
||||
condition: '<loop.index> > 0',
|
||||
skippedAtStart: true,
|
||||
},
|
||||
loopId
|
||||
)
|
||||
|
||||
const result = await orchestrator.evaluateLoopContinuation(ctx, loopId)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
shouldContinue: false,
|
||||
shouldExit: true,
|
||||
selectedRoute: EDGE.LOOP_EXIT,
|
||||
aggregatedResults: [],
|
||||
})
|
||||
expect(resolver.resolveSingleReference).not.toHaveBeenCalled()
|
||||
expect(state.setBlockOutput).toHaveBeenCalledWith(loopId, { results: [] }, 0)
|
||||
})
|
||||
|
||||
it('marks empty forEach loops as skipped at the initial condition check', async () => {
|
||||
const { orchestrator, setBlockOutput } = createOrchestrator()
|
||||
const scope = {
|
||||
iteration: 0,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
loopType: 'forEach',
|
||||
items: [],
|
||||
maxIterations: 0,
|
||||
condition: '<loop.index> < 0',
|
||||
} as { skippedAtStart?: boolean } & Record<string, unknown>
|
||||
const ctx = createContext(scope)
|
||||
|
||||
const shouldExecute = await orchestrator.evaluateInitialCondition(ctx, 'loop-1')
|
||||
|
||||
expect(shouldExecute).toBe(false)
|
||||
expect(scope.skippedAtStart).toBe(true)
|
||||
expect(setBlockOutput).not.toHaveBeenCalled()
|
||||
|
||||
const result = await orchestrator.evaluateLoopContinuation(ctx, 'loop-1')
|
||||
|
||||
expect(result).toMatchObject({
|
||||
shouldContinue: false,
|
||||
shouldExit: true,
|
||||
selectedRoute: EDGE.LOOP_EXIT,
|
||||
aggregatedResults: [],
|
||||
})
|
||||
expect(scope.skippedAtStart).toBe(false)
|
||||
expect(setBlockOutput).toHaveBeenCalledWith('loop-1', { results: [] }, 0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['for loop with zero iterations', { loopType: 'for', maxIterations: 0 }],
|
||||
['while loop with no condition', { loopType: 'while' }],
|
||||
])('marks %s as skipped at the initial condition check', async (_name, overrides) => {
|
||||
const { orchestrator, setBlockOutput } = createOrchestrator()
|
||||
const scope = {
|
||||
iteration: 0,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
...overrides,
|
||||
} as { skippedAtStart?: boolean } & Record<string, unknown>
|
||||
const ctx = createContext(scope)
|
||||
|
||||
const shouldExecute = await orchestrator.evaluateInitialCondition(ctx, 'loop-1')
|
||||
|
||||
expect(shouldExecute).toBe(false)
|
||||
expect(scope.skippedAtStart).toBe(true)
|
||||
expect(setBlockOutput).not.toHaveBeenCalled()
|
||||
|
||||
await orchestrator.evaluateLoopContinuation(ctx, 'loop-1')
|
||||
|
||||
expect(scope.skippedAtStart).toBe(false)
|
||||
expect(setBlockOutput).toHaveBeenCalledWith('loop-1', { results: [] }, 0)
|
||||
})
|
||||
|
||||
it('marks while loops with false initial conditions as skipped at start', async () => {
|
||||
const state = createState()
|
||||
const resolver = { resolveSingleReference: vi.fn().mockResolvedValue(false) }
|
||||
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: false })
|
||||
const orchestrator = new LoopOrchestrator(
|
||||
{ loopConfigs: new Map(), parallelConfigs: new Map(), nodes: new Map() },
|
||||
state,
|
||||
resolver as any
|
||||
)
|
||||
const scope = {
|
||||
iteration: 0,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
loopType: 'while',
|
||||
condition: '<condition.output>',
|
||||
} as { skippedAtStart?: boolean } & Record<string, unknown>
|
||||
const ctx = createContext(scope)
|
||||
|
||||
const shouldExecute = await orchestrator.evaluateInitialCondition(ctx, 'loop-1')
|
||||
|
||||
expect(shouldExecute).toBe(false)
|
||||
expect(scope.skippedAtStart).toBe(true)
|
||||
expect(state.setBlockOutput).not.toHaveBeenCalled()
|
||||
expect(mockExecuteInIsolatedVM).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: 'return Boolean(false)',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('exits doWhile loops when the configured iteration cap is reached', async () => {
|
||||
const { orchestrator } = createOrchestrator()
|
||||
const ctx = createContext({
|
||||
iteration: 4,
|
||||
maxIterations: 5,
|
||||
loopType: 'doWhile',
|
||||
condition: 'true',
|
||||
currentIterationOutputs: new Map([['block-1', { result: 'done' }]]),
|
||||
allIterationOutputs: [],
|
||||
})
|
||||
|
||||
const result = await orchestrator.evaluateLoopContinuation(ctx, 'loop-1')
|
||||
|
||||
expect(result).toMatchObject({
|
||||
shouldContinue: false,
|
||||
shouldExit: true,
|
||||
selectedRoute: EDGE.LOOP_EXIT,
|
||||
totalIterations: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat doWhile iterations of zero as an immediate configured cap', async () => {
|
||||
const { orchestrator } = createOrchestrator(
|
||||
new Map([
|
||||
[
|
||||
'loop-1',
|
||||
{
|
||||
loopType: 'doWhile',
|
||||
iterations: 0,
|
||||
doWhileCondition: 'true',
|
||||
nodes: ['block-1'],
|
||||
},
|
||||
],
|
||||
])
|
||||
)
|
||||
const ctx = createContext()
|
||||
|
||||
const scope = await orchestrator.initializeLoopScope(ctx, 'loop-1')
|
||||
|
||||
expect(scope.maxIterations).toBeUndefined()
|
||||
expect(scope.condition).toBe('true')
|
||||
})
|
||||
|
||||
it('keeps doWhile condition semantics when iterations are also configured', async () => {
|
||||
const { orchestrator } = createOrchestrator(
|
||||
new Map([
|
||||
[
|
||||
'loop-1',
|
||||
{
|
||||
loopType: 'doWhile',
|
||||
iterations: 2,
|
||||
doWhileCondition: 'true',
|
||||
nodes: ['block-1'],
|
||||
},
|
||||
],
|
||||
])
|
||||
)
|
||||
const ctx = createContext()
|
||||
|
||||
const scope = await orchestrator.initializeLoopScope(ctx, 'loop-1')
|
||||
|
||||
expect(scope.maxIterations).toBeUndefined()
|
||||
expect(scope.condition).toBe('true')
|
||||
})
|
||||
|
||||
it('compacts current iteration outputs before retaining them', async () => {
|
||||
const { orchestrator, setBlockOutput } = createOrchestrator()
|
||||
const ctx = createContext({
|
||||
iteration: 0,
|
||||
maxIterations: 1,
|
||||
loopType: 'doWhile',
|
||||
condition: 'true',
|
||||
currentIterationOutputs: new Map([
|
||||
[
|
||||
'block-1',
|
||||
{
|
||||
result: Array.from({ length: 200_000 }, (_, index) => ({
|
||||
id: index,
|
||||
summary: 'Issue summary that keeps each item small',
|
||||
})),
|
||||
},
|
||||
],
|
||||
]),
|
||||
allIterationOutputs: [],
|
||||
})
|
||||
|
||||
await orchestrator.evaluateLoopContinuation(ctx, 'loop-1')
|
||||
|
||||
const output = setBlockOutput.mock.calls[0][1]
|
||||
expect(Array.isArray(output.results[0])).toBe(true)
|
||||
expect(isLargeArrayManifest(output.results[0][0].result)).toBe(true)
|
||||
expect(output.results[0][0].result.totalCount).toBe(200_000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,782 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
|
||||
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
|
||||
import { compactSubflowResults } from '@/lib/execution/payloads/serializer'
|
||||
import { isLikelyReferenceSegment } from '@/lib/workflows/sanitization/references'
|
||||
import {
|
||||
buildLoopIndexCondition,
|
||||
CONTROL_BACK_EDGE_HANDLES,
|
||||
DEFAULTS,
|
||||
EDGE,
|
||||
PARALLEL,
|
||||
} from '@/executor/constants'
|
||||
import type { DAG } from '@/executor/dag/builder'
|
||||
import type { EdgeManager } from '@/executor/execution/edge-manager'
|
||||
import type { LoopScope } from '@/executor/execution/state'
|
||||
import type { BlockStateController, ContextExtensions } from '@/executor/execution/types'
|
||||
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
|
||||
import type { LoopConfigWithNodes } from '@/executor/types/loop'
|
||||
import { createReferencePattern } from '@/executor/utils/reference-validation'
|
||||
import {
|
||||
addSubflowErrorLog,
|
||||
buildParallelSentinelEndId,
|
||||
buildParallelSentinelStartId,
|
||||
buildSentinelEndId,
|
||||
buildSentinelStartId,
|
||||
emitSubflowSuccessEvents,
|
||||
extractBaseBlockId,
|
||||
extractLoopIdFromSentinel,
|
||||
extractParallelIdFromSentinel,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
import { resolveArrayInputAsync } from '@/executor/utils/subflow-utils.server'
|
||||
import type { VariableResolver } from '@/executor/variables/resolver'
|
||||
import type { SerializedLoop } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('LoopOrchestrator')
|
||||
|
||||
const LOOP_CONDITION_TIMEOUT_MS = 5000
|
||||
|
||||
async function replaceLoopConditionReferences(
|
||||
condition: string,
|
||||
replacer: (match: string) => Promise<string>
|
||||
): Promise<string> {
|
||||
const pattern = createReferencePattern()
|
||||
let cursor = 0
|
||||
let result = ''
|
||||
for (const match of condition.matchAll(pattern)) {
|
||||
const fullMatch = match[0]
|
||||
const index = match.index ?? 0
|
||||
result += condition.slice(cursor, index)
|
||||
result += isLikelyReferenceSegment(fullMatch) ? await replacer(fullMatch) : fullMatch
|
||||
cursor = index + fullMatch.length
|
||||
}
|
||||
return result + condition.slice(cursor)
|
||||
}
|
||||
|
||||
export type LoopRoute = typeof EDGE.LOOP_CONTINUE | typeof EDGE.LOOP_EXIT
|
||||
|
||||
export interface LoopContinuationResult {
|
||||
shouldContinue: boolean
|
||||
shouldExit: boolean
|
||||
selectedRoute: LoopRoute
|
||||
aggregatedResults?: unknown
|
||||
totalIterations?: number
|
||||
}
|
||||
|
||||
export class LoopOrchestrator {
|
||||
constructor(
|
||||
private dag: DAG,
|
||||
private state: BlockStateController,
|
||||
private resolver: VariableResolver,
|
||||
private contextExtensions: ContextExtensions | null = null,
|
||||
private edgeManager: EdgeManager | null = null
|
||||
) {}
|
||||
|
||||
async initializeLoopScope(ctx: ExecutionContext, loopId: string): Promise<LoopScope> {
|
||||
const loopConfig = this.dag.loopConfigs.get(loopId) as SerializedLoop | undefined
|
||||
if (!loopConfig) {
|
||||
throw new Error(`Loop config not found: ${loopId}`)
|
||||
}
|
||||
|
||||
if (loopConfig.nodes.length === 0) {
|
||||
const errorMessage =
|
||||
'Loop has no executable blocks inside. Add or enable at least one block in the loop.'
|
||||
const loopType = loopConfig.loopType || 'for'
|
||||
logger.error(errorMessage, { loopId })
|
||||
await this.addLoopErrorLog(ctx, loopId, loopType, errorMessage, {})
|
||||
const errorScope: LoopScope = {
|
||||
iteration: 0,
|
||||
maxIterations: 0,
|
||||
loopType,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
condition: 'false',
|
||||
validationError: errorMessage,
|
||||
}
|
||||
if (!ctx.loopExecutions) {
|
||||
ctx.loopExecutions = new Map()
|
||||
}
|
||||
ctx.loopExecutions.set(loopId, errorScope)
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const scope: LoopScope = {
|
||||
iteration: 0,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
}
|
||||
|
||||
const loopType = loopConfig.loopType
|
||||
|
||||
switch (loopType) {
|
||||
case 'for': {
|
||||
scope.loopType = 'for'
|
||||
const requestedIterations = loopConfig.iterations || DEFAULTS.DEFAULT_LOOP_ITERATIONS
|
||||
scope.maxIterations = requestedIterations
|
||||
scope.condition = buildLoopIndexCondition(scope.maxIterations)
|
||||
break
|
||||
}
|
||||
|
||||
case 'forEach': {
|
||||
scope.loopType = 'forEach'
|
||||
if (
|
||||
loopConfig.forEachItems === undefined ||
|
||||
loopConfig.forEachItems === null ||
|
||||
loopConfig.forEachItems === ''
|
||||
) {
|
||||
const errorMessage =
|
||||
'ForEach loop collection is empty. Provide an array or a reference that resolves to a collection.'
|
||||
logger.error(errorMessage, { loopId })
|
||||
await this.addLoopErrorLog(ctx, loopId, loopType, errorMessage, {
|
||||
forEachItems: loopConfig.forEachItems,
|
||||
})
|
||||
scope.items = []
|
||||
scope.maxIterations = 0
|
||||
scope.validationError = errorMessage
|
||||
scope.condition = buildLoopIndexCondition(0)
|
||||
ctx.loopExecutions?.set(loopId, scope)
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
let items: any[]
|
||||
try {
|
||||
items = await resolveArrayInputAsync(
|
||||
ctx,
|
||||
loopConfig.forEachItems,
|
||||
this.resolver,
|
||||
buildSentinelStartId(loopId)
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage = `ForEach loop resolution failed: ${toError(error).message}`
|
||||
logger.error(errorMessage, { loopId, forEachItems: loopConfig.forEachItems })
|
||||
await this.addLoopErrorLog(ctx, loopId, loopType, errorMessage, {
|
||||
forEachItems: loopConfig.forEachItems,
|
||||
})
|
||||
scope.items = []
|
||||
scope.maxIterations = 0
|
||||
scope.validationError = errorMessage
|
||||
scope.condition = buildLoopIndexCondition(0)
|
||||
ctx.loopExecutions?.set(loopId, scope)
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
scope.items = items
|
||||
scope.maxIterations = items.length
|
||||
scope.item = items[0]
|
||||
scope.condition = buildLoopIndexCondition(scope.maxIterations)
|
||||
break
|
||||
}
|
||||
|
||||
case 'while':
|
||||
scope.loopType = 'while'
|
||||
scope.condition = loopConfig.whileCondition
|
||||
break
|
||||
|
||||
case 'doWhile': {
|
||||
scope.loopType = 'doWhile'
|
||||
if (loopConfig.doWhileCondition) {
|
||||
scope.condition = loopConfig.doWhileCondition
|
||||
} else {
|
||||
const requestedIterations = loopConfig.iterations || DEFAULTS.DEFAULT_LOOP_ITERATIONS
|
||||
scope.maxIterations = requestedIterations
|
||||
scope.condition = buildLoopIndexCondition(scope.maxIterations)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown loop type: ${loopType}`)
|
||||
}
|
||||
|
||||
if (!ctx.loopExecutions) {
|
||||
ctx.loopExecutions = new Map()
|
||||
}
|
||||
ctx.loopExecutions.set(loopId, scope)
|
||||
return scope
|
||||
}
|
||||
|
||||
private async addLoopErrorLog(
|
||||
ctx: ExecutionContext,
|
||||
loopId: string,
|
||||
loopType: string,
|
||||
errorMessage: string,
|
||||
inputData?: any
|
||||
): Promise<void> {
|
||||
await addSubflowErrorLog(
|
||||
ctx,
|
||||
loopId,
|
||||
'loop',
|
||||
errorMessage,
|
||||
{ loopType, ...inputData },
|
||||
this.contextExtensions
|
||||
)
|
||||
}
|
||||
|
||||
storeLoopNodeOutput(
|
||||
ctx: ExecutionContext,
|
||||
loopId: string,
|
||||
nodeId: string,
|
||||
output: NormalizedBlockOutput
|
||||
): void {
|
||||
const scope = ctx.loopExecutions?.get(loopId)
|
||||
if (!scope) {
|
||||
logger.warn('Loop scope not found for node output storage', { loopId, nodeId })
|
||||
return
|
||||
}
|
||||
|
||||
const baseId = extractBaseBlockId(nodeId)
|
||||
scope.currentIterationOutputs.set(baseId, output)
|
||||
}
|
||||
|
||||
async evaluateLoopContinuation(
|
||||
ctx: ExecutionContext,
|
||||
loopId: string
|
||||
): Promise<LoopContinuationResult> {
|
||||
const scope = ctx.loopExecutions?.get(loopId)
|
||||
if (!scope) {
|
||||
logger.error('Loop scope not found during continuation evaluation', { loopId })
|
||||
return {
|
||||
shouldContinue: false,
|
||||
shouldExit: true,
|
||||
selectedRoute: EDGE.LOOP_EXIT,
|
||||
}
|
||||
}
|
||||
|
||||
const useRedis = isRedisCancellationEnabled() && !!ctx.executionId
|
||||
let isCancelled = false
|
||||
if (useRedis) {
|
||||
isCancelled = await isExecutionCancelled(ctx.executionId!)
|
||||
} else {
|
||||
isCancelled = ctx.abortSignal?.aborted ?? false
|
||||
}
|
||||
if (isCancelled) {
|
||||
logger.info('Loop execution cancelled', { loopId, iteration: scope.iteration })
|
||||
return await this.createExitResult(ctx, loopId, scope)
|
||||
}
|
||||
|
||||
if (scope.skippedAtStart) {
|
||||
scope.skippedAtStart = false
|
||||
return await this.createExitResult(ctx, loopId, scope)
|
||||
}
|
||||
|
||||
const iterationResults: NormalizedBlockOutput[] = []
|
||||
for (const blockOutput of scope.currentIterationOutputs.values()) {
|
||||
iterationResults.push(blockOutput)
|
||||
}
|
||||
|
||||
if (iterationResults.length > 0) {
|
||||
const compactedIterationResults = await compactSubflowResults(iterationResults, {
|
||||
workspaceId: ctx.workspaceId,
|
||||
workflowId: ctx.workflowId,
|
||||
executionId: ctx.executionId,
|
||||
largeValueExecutionIds: ctx.largeValueExecutionIds,
|
||||
largeValueKeys: ctx.largeValueKeys,
|
||||
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
|
||||
userId: ctx.userId,
|
||||
requireDurable: true,
|
||||
})
|
||||
scope.allIterationOutputs.push(compactedIterationResults)
|
||||
}
|
||||
|
||||
scope.currentIterationOutputs.clear()
|
||||
|
||||
if (this.hasReachedConfiguredIterationLimit(scope, scope.iteration + 1)) {
|
||||
return await this.createExitResult(ctx, loopId, scope)
|
||||
}
|
||||
|
||||
if (!(await this.evaluateCondition(ctx, scope, scope.iteration + 1))) {
|
||||
return await this.createExitResult(ctx, loopId, scope)
|
||||
}
|
||||
|
||||
scope.iteration++
|
||||
|
||||
if (scope.items && scope.iteration < scope.items.length) {
|
||||
scope.item = scope.items[scope.iteration]
|
||||
}
|
||||
|
||||
return {
|
||||
shouldContinue: true,
|
||||
shouldExit: false,
|
||||
selectedRoute: EDGE.LOOP_CONTINUE,
|
||||
}
|
||||
}
|
||||
|
||||
private hasReachedConfiguredIterationLimit(scope: LoopScope, nextIteration: number): boolean {
|
||||
if (scope.loopType !== 'doWhile' || scope.maxIterations === undefined) {
|
||||
return false
|
||||
}
|
||||
return nextIteration >= scope.maxIterations
|
||||
}
|
||||
|
||||
private async createExitResult(
|
||||
ctx: ExecutionContext,
|
||||
loopId: string,
|
||||
scope: LoopScope
|
||||
): Promise<LoopContinuationResult> {
|
||||
const results = scope.allIterationOutputs
|
||||
const totalIterations = results.length
|
||||
const compactedResults = await compactSubflowResults(results, {
|
||||
workspaceId: ctx.workspaceId,
|
||||
workflowId: ctx.workflowId,
|
||||
executionId: ctx.executionId,
|
||||
largeValueExecutionIds: ctx.largeValueExecutionIds,
|
||||
largeValueKeys: ctx.largeValueKeys,
|
||||
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
|
||||
userId: ctx.userId,
|
||||
requireDurable: true,
|
||||
})
|
||||
const output = { results: compactedResults }
|
||||
this.state.setBlockOutput(loopId, output, DEFAULTS.EXECUTION_TIME)
|
||||
scope.allIterationOutputs = []
|
||||
|
||||
await emitSubflowSuccessEvents(ctx, loopId, 'loop', output, this.contextExtensions)
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
shouldExit: true,
|
||||
selectedRoute: EDGE.LOOP_EXIT,
|
||||
aggregatedResults: output.results,
|
||||
totalIterations,
|
||||
}
|
||||
}
|
||||
|
||||
private async evaluateCondition(
|
||||
ctx: ExecutionContext,
|
||||
scope: LoopScope,
|
||||
iteration?: number
|
||||
): Promise<boolean> {
|
||||
if (!scope.condition) {
|
||||
logger.warn('No condition defined for loop')
|
||||
return false
|
||||
}
|
||||
|
||||
const currentIteration = scope.iteration
|
||||
if (iteration !== undefined) {
|
||||
scope.iteration = iteration
|
||||
}
|
||||
|
||||
const result = await this.evaluateWhileCondition(ctx, scope.condition, scope)
|
||||
|
||||
if (iteration !== undefined) {
|
||||
scope.iteration = currentIteration
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
clearLoopExecutionState(loopId: string, ctx: ExecutionContext): void {
|
||||
const allNodeIds = this.collectAllLoopNodeIds(loopId)
|
||||
|
||||
for (const nodeId of allNodeIds) {
|
||||
this.state.unmarkExecuted(nodeId)
|
||||
}
|
||||
|
||||
this.resetNestedLoopScopes(loopId, ctx)
|
||||
this.resetNestedParallelScopes(loopId, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes loop scopes for any nested loops so they re-initialize
|
||||
* on the next outer iteration.
|
||||
*/
|
||||
private resetNestedLoopScopes(loopId: string, ctx: ExecutionContext): void {
|
||||
const loopConfig = this.dag.loopConfigs.get(loopId) as LoopConfigWithNodes | undefined
|
||||
if (!loopConfig) return
|
||||
|
||||
for (const nodeId of loopConfig.nodes) {
|
||||
if (this.dag.loopConfigs.has(nodeId)) {
|
||||
ctx.loopExecutions?.delete(nodeId)
|
||||
// Delete cloned loop variants (__obranch-N and __clone*) but not original
|
||||
// subflowParentMap entries which are needed for SSE iteration context.
|
||||
if (ctx.loopExecutions) {
|
||||
const obranchPrefix = `${nodeId}__obranch-`
|
||||
const cloneSeqPrefix = `${nodeId}__clone`
|
||||
for (const key of ctx.loopExecutions.keys()) {
|
||||
if (key.startsWith(obranchPrefix) || key.startsWith(cloneSeqPrefix)) {
|
||||
ctx.loopExecutions.delete(key)
|
||||
ctx.subflowParentMap?.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
this.resetNestedLoopScopes(nodeId, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes parallel scopes for any nested parallels (including cloned
|
||||
* subflows with `__obranch-N` suffixes) so they re-initialize on the
|
||||
* next outer loop iteration.
|
||||
*/
|
||||
private resetNestedParallelScopes(loopId: string, ctx: ExecutionContext): void {
|
||||
const loopConfig = this.dag.loopConfigs.get(loopId) as LoopConfigWithNodes | undefined
|
||||
if (!loopConfig) return
|
||||
|
||||
for (const nodeId of loopConfig.nodes) {
|
||||
if (this.dag.parallelConfigs.has(nodeId)) {
|
||||
this.deleteParallelScopeAndClones(nodeId, ctx)
|
||||
} else if (this.dag.loopConfigs.has(nodeId)) {
|
||||
this.resetNestedParallelScopes(nodeId, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a parallel scope and any cloned variants (`__obranch-N`),
|
||||
* recursively handling nested subflows within the parallel.
|
||||
*/
|
||||
private deleteParallelScopeAndClones(parallelId: string, ctx: ExecutionContext): void {
|
||||
ctx.parallelExecutions?.delete(parallelId)
|
||||
// Delete cloned scopes (__obranch-N and __clone*) but not original subflowParentMap entries
|
||||
if (ctx.parallelExecutions) {
|
||||
const obranchPrefix = `${parallelId}__obranch-`
|
||||
const clonePrefix = `${parallelId}__clone`
|
||||
for (const key of ctx.parallelExecutions.keys()) {
|
||||
if (key.startsWith(obranchPrefix) || key.startsWith(clonePrefix)) {
|
||||
ctx.parallelExecutions.delete(key)
|
||||
ctx.subflowParentMap?.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
|
||||
if (parallelConfig?.nodes) {
|
||||
for (const nodeId of parallelConfig.nodes) {
|
||||
if (this.dag.parallelConfigs.has(nodeId)) {
|
||||
this.deleteParallelScopeAndClones(nodeId, ctx)
|
||||
} else if (this.dag.loopConfigs.has(nodeId)) {
|
||||
ctx.loopExecutions?.delete(nodeId)
|
||||
// Also delete cloned loop scopes (__obranch-N and __clone*) created by expandParallel
|
||||
if (ctx.loopExecutions) {
|
||||
const obranchPrefix = `${nodeId}__obranch-`
|
||||
const cloneSeqPrefix = `${nodeId}__clone`
|
||||
for (const key of ctx.loopExecutions.keys()) {
|
||||
if (key.startsWith(obranchPrefix) || key.startsWith(cloneSeqPrefix)) {
|
||||
ctx.loopExecutions.delete(key)
|
||||
ctx.subflowParentMap?.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
this.resetNestedParallelScopes(nodeId, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all effective DAG node IDs for a loop, recursively including
|
||||
* sentinel IDs for any nested subflow blocks (loops and parallels).
|
||||
*/
|
||||
private collectAllLoopNodeIds(loopId: string, visited = new Set<string>()): Set<string> {
|
||||
if (visited.has(loopId)) return new Set()
|
||||
visited.add(loopId)
|
||||
|
||||
const loopConfig = this.dag.loopConfigs.get(loopId) as LoopConfigWithNodes | undefined
|
||||
if (!loopConfig) return new Set()
|
||||
|
||||
const sentinelStartId = buildSentinelStartId(loopId)
|
||||
const sentinelEndId = buildSentinelEndId(loopId)
|
||||
const result = new Set([sentinelStartId, sentinelEndId])
|
||||
|
||||
for (const nodeId of loopConfig.nodes) {
|
||||
if (this.dag.loopConfigs.has(nodeId)) {
|
||||
for (const id of this.collectAllLoopNodeIds(nodeId, visited)) {
|
||||
result.add(id)
|
||||
}
|
||||
this.collectClonedSubflowNodes(nodeId, result, visited)
|
||||
} else if (this.dag.parallelConfigs.has(nodeId)) {
|
||||
for (const id of this.collectAllParallelNodeIds(nodeId, visited)) {
|
||||
result.add(id)
|
||||
}
|
||||
this.collectClonedSubflowNodes(nodeId, result, visited)
|
||||
} else {
|
||||
result.add(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all effective DAG node IDs for a parallel, including
|
||||
* sentinel IDs and branch template nodes, recursively handling nested subflows.
|
||||
*/
|
||||
private collectAllParallelNodeIds(parallelId: string, visited = new Set<string>()): Set<string> {
|
||||
if (visited.has(parallelId)) return new Set()
|
||||
visited.add(parallelId)
|
||||
|
||||
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
|
||||
if (!parallelConfig) return new Set()
|
||||
|
||||
const sentinelStartId = buildParallelSentinelStartId(parallelId)
|
||||
const sentinelEndId = buildParallelSentinelEndId(parallelId)
|
||||
const result = new Set([sentinelStartId, sentinelEndId])
|
||||
|
||||
for (const nodeId of parallelConfig.nodes) {
|
||||
if (this.dag.loopConfigs.has(nodeId)) {
|
||||
for (const id of this.collectAllLoopNodeIds(nodeId, visited)) {
|
||||
result.add(id)
|
||||
}
|
||||
this.collectClonedSubflowNodes(nodeId, result, visited)
|
||||
} else if (this.dag.parallelConfigs.has(nodeId)) {
|
||||
for (const id of this.collectAllParallelNodeIds(nodeId, visited)) {
|
||||
result.add(id)
|
||||
}
|
||||
this.collectClonedSubflowNodes(nodeId, result, visited)
|
||||
} else {
|
||||
result.add(nodeId)
|
||||
this.collectAllBranchNodes(nodeId, result)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all branch nodes for a given base block ID by scanning the DAG.
|
||||
* This captures dynamically created branches (1, 2, ...) beyond the template (0).
|
||||
*/
|
||||
private collectAllBranchNodes(baseNodeId: string, result: Set<string>): void {
|
||||
const prefix = `${baseNodeId}${PARALLEL.BRANCH.PREFIX}`
|
||||
for (const dagNodeId of this.dag.nodes.keys()) {
|
||||
if (dagNodeId.startsWith(prefix)) {
|
||||
result.add(dagNodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all cloned subflow variants (e.g., loop-1__obranch-N) and their
|
||||
* descendant nodes by scanning the DAG configs.
|
||||
*/
|
||||
private collectClonedSubflowNodes(
|
||||
originalId: string,
|
||||
result: Set<string>,
|
||||
visited: Set<string>
|
||||
): void {
|
||||
const obranchPrefix = `${originalId}__obranch-`
|
||||
const clonePrefix = `${originalId}__clone`
|
||||
for (const loopId of this.dag.loopConfigs.keys()) {
|
||||
if (loopId.startsWith(obranchPrefix) || loopId.startsWith(clonePrefix)) {
|
||||
for (const id of this.collectAllLoopNodeIds(loopId, visited)) {
|
||||
result.add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const parallelId of this.dag.parallelConfigs.keys()) {
|
||||
if (parallelId.startsWith(obranchPrefix) || parallelId.startsWith(clonePrefix)) {
|
||||
for (const id of this.collectAllParallelNodeIds(parallelId, visited)) {
|
||||
result.add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
restoreLoopEdges(loopId: string): void {
|
||||
const loopConfig = this.dag.loopConfigs.get(loopId) as LoopConfigWithNodes | undefined
|
||||
if (!loopConfig) {
|
||||
logger.warn('Loop config not found for edge restoration', { loopId })
|
||||
return
|
||||
}
|
||||
|
||||
const allLoopNodeIds = this.collectAllLoopNodeIds(loopId)
|
||||
|
||||
if (this.edgeManager) {
|
||||
this.edgeManager.clearDeactivatedEdgesForNodes(allLoopNodeIds)
|
||||
}
|
||||
|
||||
for (const nodeId of allLoopNodeIds) {
|
||||
const nodeToRestore = this.dag.nodes.get(nodeId)
|
||||
if (!nodeToRestore) continue
|
||||
|
||||
for (const potentialSourceId of allLoopNodeIds) {
|
||||
const potentialSourceNode = this.dag.nodes.get(potentialSourceId)
|
||||
if (!potentialSourceNode) continue
|
||||
|
||||
for (const [, edge] of potentialSourceNode.outgoingEdges) {
|
||||
if (edge.target === nodeId) {
|
||||
if (
|
||||
!this.isSubflowStartExitBypassEdge(potentialSourceId, nodeId, edge.sourceHandle) &&
|
||||
(edge.sourceHandle === undefined || !CONTROL_BACK_EDGE_HANDLES.has(edge.sourceHandle))
|
||||
) {
|
||||
nodeToRestore.incomingEdges.add(potentialSourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isSubflowStartExitBypassEdge(
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
sourceHandle?: string
|
||||
): boolean {
|
||||
if (sourceHandle === EDGE.LOOP_EXIT) {
|
||||
const loopId = extractLoopIdFromSentinel(sourceId)
|
||||
return (
|
||||
!!loopId &&
|
||||
sourceId === buildSentinelStartId(loopId) &&
|
||||
targetId === buildSentinelEndId(loopId)
|
||||
)
|
||||
}
|
||||
|
||||
if (sourceHandle === EDGE.PARALLEL_EXIT) {
|
||||
const parallelId = extractParallelIdFromSentinel(sourceId)
|
||||
return (
|
||||
!!parallelId &&
|
||||
sourceId === buildParallelSentinelStartId(parallelId) &&
|
||||
targetId === buildParallelSentinelEndId(parallelId)
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
getLoopScope(ctx: ExecutionContext, loopId: string): LoopScope | undefined {
|
||||
return ctx.loopExecutions?.get(loopId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates the initial condition for loops at the sentinel start.
|
||||
* - For while loops, the condition must be checked BEFORE the first iteration.
|
||||
* - For forEach loops, skip if the items array is empty.
|
||||
* - For for loops, skip if maxIterations is 0.
|
||||
* - For doWhile loops, always execute at least once.
|
||||
*
|
||||
* @returns true if the loop should execute, false if it should be skipped
|
||||
*/
|
||||
async evaluateInitialCondition(ctx: ExecutionContext, loopId: string): Promise<boolean> {
|
||||
const scope = ctx.loopExecutions?.get(loopId)
|
||||
if (!scope) {
|
||||
logger.warn('Loop scope not found for initial condition evaluation', { loopId })
|
||||
return true
|
||||
}
|
||||
|
||||
if (scope.loopType === 'forEach') {
|
||||
if (!scope.items || scope.items.length === 0) {
|
||||
logger.info('ForEach loop has empty collection, skipping loop body', { loopId })
|
||||
scope.skippedAtStart = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (scope.loopType === 'for') {
|
||||
if (scope.maxIterations === 0) {
|
||||
logger.info('For loop has 0 iterations, skipping loop body', { loopId })
|
||||
scope.skippedAtStart = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (scope.loopType === 'doWhile') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (scope.loopType === 'while') {
|
||||
if (!scope.condition) {
|
||||
logger.warn('No condition defined for while loop', { loopId })
|
||||
scope.skippedAtStart = true
|
||||
return false
|
||||
}
|
||||
|
||||
const result = await this.evaluateWhileCondition(ctx, scope.condition, scope)
|
||||
logger.info('While loop initial condition evaluation', {
|
||||
loopId,
|
||||
condition: scope.condition,
|
||||
result,
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
logger.info('While loop initial condition is false, skipping loop body', { loopId })
|
||||
scope.skippedAtStart = true
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private async evaluateWhileCondition(
|
||||
ctx: ExecutionContext,
|
||||
condition: string,
|
||||
scope: LoopScope
|
||||
): Promise<boolean> {
|
||||
if (!condition) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('Evaluating loop condition', {
|
||||
originalCondition: condition,
|
||||
iteration: scope.iteration,
|
||||
workflowVariableCount: Object.keys(ctx.workflowVariables ?? {}).length,
|
||||
})
|
||||
|
||||
const evaluatedCondition = await replaceLoopConditionReferences(condition, async (match) => {
|
||||
const resolved = await this.resolver.resolveSingleReference(ctx, '', match, scope)
|
||||
logger.debug('Resolved variable reference in loop condition', {
|
||||
reference: match,
|
||||
resolvedType: resolved === null ? 'null' : typeof resolved,
|
||||
})
|
||||
if (resolved !== undefined) {
|
||||
if (typeof resolved === 'boolean' || typeof resolved === 'number') {
|
||||
return String(resolved)
|
||||
}
|
||||
if (typeof resolved === 'string') {
|
||||
const lower = resolved.toLowerCase().trim()
|
||||
if (lower === 'true' || lower === 'false') {
|
||||
return lower
|
||||
}
|
||||
return `"${resolved}"`
|
||||
}
|
||||
return JSON.stringify(resolved)
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
const requestId = generateRequestId()
|
||||
const code = `return Boolean(${evaluatedCondition})`
|
||||
|
||||
const vmResult = await executeInIsolatedVM({
|
||||
code,
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: LOOP_CONDITION_TIMEOUT_MS,
|
||||
requestId,
|
||||
ownerKey: `user:${ctx.userId}`,
|
||||
ownerWeight: 1,
|
||||
})
|
||||
|
||||
if (vmResult.error) {
|
||||
const isSystemError = vmResult.error.isSystemError === true
|
||||
const logFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger)
|
||||
logFn('Failed to evaluate loop condition', {
|
||||
condition,
|
||||
evaluatedCondition,
|
||||
error: vmResult.error,
|
||||
isSystemError,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const result = Boolean(vmResult.result)
|
||||
|
||||
logger.info('Loop condition evaluation result', {
|
||||
originalCondition: condition,
|
||||
evaluatedCondition,
|
||||
result,
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('Failed to evaluate loop condition', { condition, error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { EDGE } from '@/executor/constants'
|
||||
import type { DAG, DAGNode } from '@/executor/dag/builder'
|
||||
import type { BlockExecutor } from '@/executor/execution/block-executor'
|
||||
import type { BlockStateController } from '@/executor/execution/types'
|
||||
import type { LoopOrchestrator } from '@/executor/orchestrators/loop'
|
||||
import { NodeExecutionOrchestrator } from '@/executor/orchestrators/node'
|
||||
import type { ParallelOrchestrator } from '@/executor/orchestrators/parallel'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
|
||||
function createContext(): ExecutionContext {
|
||||
return {
|
||||
workflowId: 'workflow-1',
|
||||
workspaceId: 'workspace-1',
|
||||
executionId: 'execution-1',
|
||||
userId: 'user-1',
|
||||
blockStates: new Map(),
|
||||
executedBlocks: new Set(),
|
||||
blockLogs: [],
|
||||
metadata: { duration: 0 },
|
||||
environmentVariables: {},
|
||||
decisions: {
|
||||
router: new Map(),
|
||||
condition: new Map(),
|
||||
},
|
||||
completedLoops: new Set(),
|
||||
activeExecutionPath: new Set(),
|
||||
workflow: {
|
||||
version: '1',
|
||||
blocks: [],
|
||||
connections: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createState(): BlockStateController {
|
||||
return {
|
||||
getBlockOutput: vi.fn(),
|
||||
hasExecuted: vi.fn(() => false),
|
||||
setBlockOutput: vi.fn(),
|
||||
setBlockState: vi.fn(),
|
||||
deleteBlockState: vi.fn(),
|
||||
unmarkExecuted: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
function createSentinelNode(id: string, sentinelType: 'start' | 'end'): DAGNode {
|
||||
return {
|
||||
id,
|
||||
block: {
|
||||
id,
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
metadata: { id: 'parallel', name: id },
|
||||
config: { params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
},
|
||||
incomingEdges: new Set(),
|
||||
outgoingEdges: new Map(),
|
||||
metadata: {
|
||||
isSentinel: true,
|
||||
sentinelType,
|
||||
subflowId: 'parallel-1',
|
||||
subflowType: 'parallel',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createOrchestrator(
|
||||
dag: DAG,
|
||||
state: BlockStateController,
|
||||
parallelOrchestrator: Partial<ParallelOrchestrator>,
|
||||
loopOrchestratorOverrides: Partial<LoopOrchestrator> = {}
|
||||
): NodeExecutionOrchestrator {
|
||||
const blockExecutor = { execute: vi.fn() } as unknown as BlockExecutor
|
||||
const loopOrchestrator = {
|
||||
getLoopScope: vi.fn(),
|
||||
initializeLoopScope: vi.fn(),
|
||||
evaluateInitialCondition: vi.fn(),
|
||||
evaluateLoopContinuation: vi.fn(),
|
||||
clearLoopExecutionState: vi.fn(),
|
||||
restoreLoopEdges: vi.fn(),
|
||||
storeLoopNodeOutput: vi.fn(),
|
||||
...loopOrchestratorOverrides,
|
||||
} as unknown as LoopOrchestrator
|
||||
|
||||
return new NodeExecutionOrchestrator(
|
||||
dag,
|
||||
state,
|
||||
blockExecutor,
|
||||
loopOrchestrator,
|
||||
parallelOrchestrator as ParallelOrchestrator
|
||||
)
|
||||
}
|
||||
|
||||
describe('NodeExecutionOrchestrator parallel sentinel batching', () => {
|
||||
it('returns loop_exit from a loop start sentinel when the initial condition is false', async () => {
|
||||
const startNode = {
|
||||
...createSentinelNode('loop-loop-1-sentinel-start', 'start'),
|
||||
metadata: {
|
||||
isSentinel: true,
|
||||
sentinelType: 'start' as const,
|
||||
subflowId: 'loop-1',
|
||||
subflowType: 'loop' as const,
|
||||
},
|
||||
}
|
||||
const dag: DAG = {
|
||||
nodes: new Map([[startNode.id, startNode]]),
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
const state = createState()
|
||||
const loopOrchestrator = {
|
||||
getLoopScope: vi.fn(() => ({})),
|
||||
evaluateInitialCondition: vi.fn().mockResolvedValue(false),
|
||||
}
|
||||
const orchestrator = createOrchestrator(dag, state, {}, loopOrchestrator)
|
||||
|
||||
const result = await orchestrator.executeNode(createContext(), startNode.id)
|
||||
|
||||
expect(result.output).toMatchObject({
|
||||
shouldExit: true,
|
||||
selectedRoute: EDGE.LOOP_EXIT,
|
||||
})
|
||||
})
|
||||
|
||||
it('prepares the current batch when executing a parallel start sentinel', async () => {
|
||||
const startNode = createSentinelNode('parallel-parallel-1-sentinel-start', 'start')
|
||||
const dag: DAG = {
|
||||
nodes: new Map([[startNode.id, startNode]]),
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
const state = createState()
|
||||
const parallelOrchestrator = {
|
||||
getParallelScope: vi.fn(() => ({ parallelId: 'parallel-1', totalBranches: 2 })),
|
||||
initializeParallelScope: vi.fn(),
|
||||
prepareCurrentBatch: vi.fn(),
|
||||
}
|
||||
const orchestrator = createOrchestrator(dag, state, parallelOrchestrator)
|
||||
|
||||
const result = await orchestrator.executeNode(createContext(), startNode.id)
|
||||
|
||||
expect(result.output).toEqual({ sentinelStart: true })
|
||||
expect(parallelOrchestrator.prepareCurrentBatch).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
'parallel-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns parallel_exit from an empty parallel start sentinel without preparing a batch', async () => {
|
||||
const startNode = createSentinelNode('parallel-parallel-1-sentinel-start', 'start')
|
||||
const dag: DAG = {
|
||||
nodes: new Map([[startNode.id, startNode]]),
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
const state = createState()
|
||||
const parallelOrchestrator = {
|
||||
getParallelScope: vi.fn(() => ({
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 0,
|
||||
isEmpty: true,
|
||||
})),
|
||||
initializeParallelScope: vi.fn(),
|
||||
prepareCurrentBatch: vi.fn(),
|
||||
}
|
||||
const orchestrator = createOrchestrator(dag, state, parallelOrchestrator)
|
||||
|
||||
const result = await orchestrator.executeNode(createContext(), startNode.id)
|
||||
|
||||
expect(result.output).toMatchObject({
|
||||
shouldExit: true,
|
||||
selectedRoute: EDGE.PARALLEL_EXIT,
|
||||
})
|
||||
expect(parallelOrchestrator.prepareCurrentBatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prepares a batch continuation when parallel end selects parallel_continue', async () => {
|
||||
const endNode = createSentinelNode('parallel-parallel-1-sentinel-end', 'end')
|
||||
const dag: DAG = {
|
||||
nodes: new Map([[endNode.id, endNode]]),
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
const state = createState()
|
||||
const parallelOrchestrator = {
|
||||
getParallelScope: vi.fn(),
|
||||
prepareForBatchContinuation: vi.fn(),
|
||||
}
|
||||
const orchestrator = createOrchestrator(dag, state, parallelOrchestrator)
|
||||
|
||||
await orchestrator.handleNodeCompletion(createContext(), endNode.id, {
|
||||
selectedRoute: EDGE.PARALLEL_CONTINUE,
|
||||
})
|
||||
|
||||
expect(state.setBlockOutput).toHaveBeenCalledWith(endNode.id, {
|
||||
selectedRoute: EDGE.PARALLEL_CONTINUE,
|
||||
})
|
||||
expect(parallelOrchestrator.prepareForBatchContinuation).toHaveBeenCalledWith('parallel-1')
|
||||
})
|
||||
|
||||
it('marks terminal parallel exit output as final when only the continue back edge remains', async () => {
|
||||
const endNode = createSentinelNode('parallel-parallel-1-sentinel-end', 'end')
|
||||
endNode.outgoingEdges.set('continue', {
|
||||
target: 'parallel-parallel-1-sentinel-start',
|
||||
sourceHandle: EDGE.PARALLEL_CONTINUE,
|
||||
})
|
||||
const dag: DAG = {
|
||||
nodes: new Map([[endNode.id, endNode]]),
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
const parallelOrchestrator = {
|
||||
getParallelScope: vi.fn(() => ({ parallelId: 'parallel-1', totalBranches: 1 })),
|
||||
aggregateParallelResults: vi.fn().mockResolvedValue({
|
||||
allBranchesComplete: true,
|
||||
results: [['result']],
|
||||
totalBranches: 1,
|
||||
}),
|
||||
}
|
||||
const orchestrator = createOrchestrator(dag, createState(), parallelOrchestrator)
|
||||
|
||||
const result = await orchestrator.executeNode(createContext(), endNode.id)
|
||||
|
||||
expect(result.isFinalOutput).toBe(true)
|
||||
expect(result.output).toMatchObject({
|
||||
results: [['result']],
|
||||
selectedRoute: EDGE.PARALLEL_EXIT,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not mark a continuing parallel batch as final output', async () => {
|
||||
const endNode = createSentinelNode('parallel-parallel-1-sentinel-end', 'end')
|
||||
endNode.outgoingEdges.set('continue', {
|
||||
target: 'parallel-parallel-1-sentinel-start',
|
||||
sourceHandle: EDGE.PARALLEL_CONTINUE,
|
||||
})
|
||||
const dag: DAG = {
|
||||
nodes: new Map([[endNode.id, endNode]]),
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
const parallelOrchestrator = {
|
||||
getParallelScope: vi.fn(() => ({ parallelId: 'parallel-1', totalBranches: 3 })),
|
||||
aggregateParallelResults: vi.fn().mockResolvedValue({
|
||||
allBranchesComplete: false,
|
||||
totalBranches: 3,
|
||||
}),
|
||||
}
|
||||
const orchestrator = createOrchestrator(dag, createState(), parallelOrchestrator)
|
||||
|
||||
const result = await orchestrator.executeNode(createContext(), endNode.id)
|
||||
|
||||
expect(result.isFinalOutput).toBe(false)
|
||||
expect(result.output).toMatchObject({
|
||||
selectedRoute: EDGE.PARALLEL_CONTINUE,
|
||||
})
|
||||
})
|
||||
|
||||
it('records completed nested subflow sentinels as parent parallel branch output', async () => {
|
||||
const endNode = {
|
||||
...createSentinelNode('loop-nested-loop-sentinel-end', 'end'),
|
||||
metadata: {
|
||||
isSentinel: true,
|
||||
sentinelType: 'end' as const,
|
||||
subflowId: 'nested-loop',
|
||||
subflowType: 'loop' as const,
|
||||
},
|
||||
}
|
||||
const dag: DAG = {
|
||||
nodes: new Map([[endNode.id, endNode]]),
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
const state = createState()
|
||||
const parallelOrchestrator = {
|
||||
getParallelScope: vi.fn(),
|
||||
handleParallelBranchCompletion: vi.fn(),
|
||||
prepareForBatchContinuation: vi.fn(),
|
||||
}
|
||||
const orchestrator = createOrchestrator(dag, state, parallelOrchestrator)
|
||||
const ctx = createContext()
|
||||
ctx.subflowParentMap = new Map([
|
||||
['nested-loop', { parentId: 'parent-parallel', parentType: 'parallel', branchIndex: 3 }],
|
||||
])
|
||||
|
||||
await orchestrator.handleNodeCompletion(ctx, endNode.id, {
|
||||
results: ['loop-result'],
|
||||
shouldExit: true,
|
||||
selectedRoute: EDGE.LOOP_EXIT,
|
||||
})
|
||||
|
||||
expect(parallelOrchestrator.handleParallelBranchCompletion).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
'parent-parallel',
|
||||
endNode.id,
|
||||
{ results: ['loop-result'] },
|
||||
3
|
||||
)
|
||||
})
|
||||
|
||||
it('does not record continuing nested parallel batches as parent parallel branch output', async () => {
|
||||
const endNode = {
|
||||
...createSentinelNode('parallel-nested-parallel-sentinel-end', 'end'),
|
||||
metadata: {
|
||||
isSentinel: true,
|
||||
sentinelType: 'end' as const,
|
||||
subflowId: 'nested-parallel',
|
||||
subflowType: 'parallel' as const,
|
||||
},
|
||||
}
|
||||
const dag: DAG = {
|
||||
nodes: new Map([[endNode.id, endNode]]),
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
const state = createState()
|
||||
const parallelOrchestrator = {
|
||||
getParallelScope: vi.fn(),
|
||||
handleParallelBranchCompletion: vi.fn(),
|
||||
prepareForBatchContinuation: vi.fn(),
|
||||
}
|
||||
const orchestrator = createOrchestrator(dag, state, parallelOrchestrator)
|
||||
const ctx = createContext()
|
||||
ctx.subflowParentMap = new Map([
|
||||
['nested-parallel', { parentId: 'parent-parallel', parentType: 'parallel', branchIndex: 3 }],
|
||||
])
|
||||
|
||||
await orchestrator.handleNodeCompletion(ctx, endNode.id, {
|
||||
sentinelEnd: true,
|
||||
selectedRoute: EDGE.PARALLEL_CONTINUE,
|
||||
})
|
||||
|
||||
expect(parallelOrchestrator.handleParallelBranchCompletion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes stable outer-branch output aliases for completed parallel branch nodes', async () => {
|
||||
const branchNode: DAGNode = {
|
||||
id: 'worker₍0₎',
|
||||
block: {
|
||||
id: 'worker',
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
metadata: { id: 'function', name: 'Worker' },
|
||||
config: { params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
},
|
||||
incomingEdges: new Set(),
|
||||
outgoingEdges: new Map(),
|
||||
metadata: {
|
||||
isParallelBranch: true,
|
||||
subflowId: 'parallel-1',
|
||||
subflowType: 'parallel',
|
||||
originalBlockId: 'worker',
|
||||
branchIndex: 2,
|
||||
},
|
||||
}
|
||||
const dag: DAG = {
|
||||
nodes: new Map([[branchNode.id, branchNode]]),
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
const state = createState()
|
||||
const parallelOrchestrator = {
|
||||
getParallelScope: vi.fn(() => ({ parallelId: 'parallel-1', totalBranches: 3 })),
|
||||
initializeParallelScope: vi.fn(),
|
||||
handleParallelBranchCompletion: vi.fn(),
|
||||
}
|
||||
const orchestrator = createOrchestrator(dag, state, parallelOrchestrator)
|
||||
const output = { result: 'branch-2' }
|
||||
const ctx = createContext()
|
||||
|
||||
await orchestrator.handleNodeCompletion(ctx, branchNode.id, output)
|
||||
|
||||
expect(parallelOrchestrator.handleParallelBranchCompletion).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
'parallel-1',
|
||||
branchNode.id,
|
||||
output
|
||||
)
|
||||
expect(state.setBlockOutput).toHaveBeenCalledWith('worker__obranch-2', output)
|
||||
expect(state.setBlockOutput).toHaveBeenCalledWith(branchNode.id, output)
|
||||
})
|
||||
|
||||
it('records completed nested subflow sentinels as parent loop iteration output', async () => {
|
||||
const endNode = {
|
||||
...createSentinelNode('parallel-nested-parallel-sentinel-end', 'end'),
|
||||
metadata: {
|
||||
isSentinel: true,
|
||||
sentinelType: 'end' as const,
|
||||
subflowId: 'nested-parallel',
|
||||
subflowType: 'parallel' as const,
|
||||
},
|
||||
}
|
||||
const dag: DAG = {
|
||||
nodes: new Map([[endNode.id, endNode]]),
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
const state = createState()
|
||||
const loopOrchestrator = {
|
||||
storeLoopNodeOutput: vi.fn(),
|
||||
}
|
||||
const orchestrator = createOrchestrator(dag, state, {}, loopOrchestrator)
|
||||
const ctx = createContext()
|
||||
ctx.subflowParentMap = new Map([
|
||||
['nested-parallel', { parentId: 'parent-loop', parentType: 'loop' }],
|
||||
])
|
||||
|
||||
await orchestrator.handleNodeCompletion(ctx, endNode.id, {
|
||||
results: ['parallel-result'],
|
||||
sentinelEnd: true,
|
||||
selectedRoute: EDGE.PARALLEL_EXIT,
|
||||
})
|
||||
|
||||
expect(loopOrchestrator.storeLoopNodeOutput).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
'parent-loop',
|
||||
'nested-parallel',
|
||||
{ results: ['parallel-result'] }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not record continuing nested loop iterations as parent loop output', async () => {
|
||||
const endNode = {
|
||||
...createSentinelNode('loop-nested-loop-sentinel-end', 'end'),
|
||||
metadata: {
|
||||
isSentinel: true,
|
||||
sentinelType: 'end' as const,
|
||||
subflowId: 'nested-loop',
|
||||
subflowType: 'loop' as const,
|
||||
},
|
||||
}
|
||||
const dag: DAG = {
|
||||
nodes: new Map([[endNode.id, endNode]]),
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
const state = createState()
|
||||
const loopOrchestrator = {
|
||||
storeLoopNodeOutput: vi.fn(),
|
||||
}
|
||||
const orchestrator = createOrchestrator(dag, state, {}, loopOrchestrator)
|
||||
const ctx = createContext()
|
||||
ctx.subflowParentMap = new Map([
|
||||
['nested-loop', { parentId: 'parent-loop', parentType: 'loop' }],
|
||||
])
|
||||
|
||||
await orchestrator.handleNodeCompletion(ctx, endNode.id, {
|
||||
shouldContinue: true,
|
||||
selectedRoute: EDGE.LOOP_CONTINUE,
|
||||
})
|
||||
|
||||
expect(loopOrchestrator.storeLoopNodeOutput).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,377 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
|
||||
import { EDGE } from '@/executor/constants'
|
||||
import type { DAG, DAGNode } from '@/executor/dag/builder'
|
||||
import type { BlockExecutor } from '@/executor/execution/block-executor'
|
||||
import type { BlockStateController } from '@/executor/execution/types'
|
||||
import type { LoopOrchestrator } from '@/executor/orchestrators/loop'
|
||||
import type { ParallelOrchestrator } from '@/executor/orchestrators/parallel'
|
||||
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
|
||||
import {
|
||||
buildOuterBranchScopedId,
|
||||
extractBaseBlockId,
|
||||
extractOuterBranchIndex,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
|
||||
const logger = createLogger('NodeExecutionOrchestrator')
|
||||
|
||||
function getResultCount(value: unknown): number {
|
||||
if (isLargeValueRef(value)) {
|
||||
const preview = value.preview
|
||||
if (
|
||||
preview &&
|
||||
typeof preview === 'object' &&
|
||||
typeof (preview as Record<string, unknown>).length === 'number'
|
||||
) {
|
||||
return (preview as { length: number }).length
|
||||
}
|
||||
}
|
||||
return Array.isArray(value) ? value.length : 0
|
||||
}
|
||||
|
||||
function getSubflowResultOutput(output: NormalizedBlockOutput): NormalizedBlockOutput {
|
||||
return { results: output.results ?? [] }
|
||||
}
|
||||
|
||||
export interface NodeExecutionResult {
|
||||
nodeId: string
|
||||
output: NormalizedBlockOutput
|
||||
isFinalOutput: boolean
|
||||
}
|
||||
|
||||
export class NodeExecutionOrchestrator {
|
||||
constructor(
|
||||
private dag: DAG,
|
||||
private state: BlockStateController,
|
||||
private blockExecutor: BlockExecutor,
|
||||
private loopOrchestrator: LoopOrchestrator,
|
||||
private parallelOrchestrator: ParallelOrchestrator
|
||||
) {}
|
||||
|
||||
async executeNode(ctx: ExecutionContext, nodeId: string): Promise<NodeExecutionResult> {
|
||||
const node = this.dag.nodes.get(nodeId)
|
||||
if (!node) {
|
||||
throw new Error(`Node not found in DAG: ${nodeId}`)
|
||||
}
|
||||
|
||||
if (ctx.runFromBlockContext && !ctx.runFromBlockContext.dirtySet.has(nodeId)) {
|
||||
const cachedOutput = this.state.getBlockOutput(nodeId) || {}
|
||||
logger.debug('Skipping non-dirty block in run-from-block mode', { nodeId })
|
||||
return {
|
||||
nodeId,
|
||||
output: cachedOutput,
|
||||
isFinalOutput: false,
|
||||
}
|
||||
}
|
||||
|
||||
const isDirtyBlock = ctx.runFromBlockContext?.dirtySet.has(nodeId) ?? false
|
||||
if (!isDirtyBlock && this.state.hasExecuted(nodeId)) {
|
||||
const output = this.state.getBlockOutput(nodeId) || {}
|
||||
return {
|
||||
nodeId,
|
||||
output,
|
||||
isFinalOutput: false,
|
||||
}
|
||||
}
|
||||
|
||||
const loopId = node.metadata.subflowType === 'loop' ? node.metadata.subflowId : undefined
|
||||
if (loopId && !this.loopOrchestrator.getLoopScope(ctx, loopId)) {
|
||||
await this.loopOrchestrator.initializeLoopScope(ctx, loopId)
|
||||
}
|
||||
|
||||
const parallelId =
|
||||
node.metadata.subflowType === 'parallel' ? node.metadata.subflowId : undefined
|
||||
if (parallelId && !this.parallelOrchestrator.getParallelScope(ctx, parallelId)) {
|
||||
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId)
|
||||
}
|
||||
|
||||
if (node.metadata.isSentinel) {
|
||||
const output = await this.handleSentinel(ctx, node)
|
||||
const isFinalOutput = this.isFinalSentinelOutput(node, output)
|
||||
return {
|
||||
nodeId,
|
||||
output,
|
||||
isFinalOutput,
|
||||
}
|
||||
}
|
||||
|
||||
const output = await this.blockExecutor.execute(ctx, node, node.block)
|
||||
const isFinalOutput = node.outgoingEdges.size === 0
|
||||
return {
|
||||
nodeId,
|
||||
output,
|
||||
isFinalOutput,
|
||||
}
|
||||
}
|
||||
|
||||
private isFinalSentinelOutput(node: DAGNode, output: NormalizedBlockOutput): boolean {
|
||||
const selectedRoute = output.selectedRoute
|
||||
if (selectedRoute === EDGE.LOOP_CONTINUE || selectedRoute === EDGE.PARALLEL_CONTINUE) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (selectedRoute === EDGE.LOOP_EXIT || selectedRoute === EDGE.PARALLEL_EXIT) {
|
||||
return !Array.from(node.outgoingEdges.values()).some(
|
||||
(edge) => edge.sourceHandle === selectedRoute
|
||||
)
|
||||
}
|
||||
|
||||
return node.outgoingEdges.size === 0
|
||||
}
|
||||
|
||||
private async handleSentinel(
|
||||
ctx: ExecutionContext,
|
||||
node: DAGNode
|
||||
): Promise<NormalizedBlockOutput> {
|
||||
const sentinelType = node.metadata.sentinelType
|
||||
const subflowType = node.metadata.subflowType
|
||||
const subflowId = node.metadata.subflowId
|
||||
|
||||
if (!subflowType || !subflowId) {
|
||||
logger.warn('Sentinel missing subflow metadata', { nodeId: node.id, sentinelType })
|
||||
return {}
|
||||
}
|
||||
|
||||
if (subflowType === 'parallel') {
|
||||
return await this.handleParallelSentinel(ctx, node, sentinelType, subflowId)
|
||||
}
|
||||
|
||||
switch (sentinelType) {
|
||||
case 'start': {
|
||||
const shouldExecute = await this.loopOrchestrator.evaluateInitialCondition(ctx, subflowId)
|
||||
if (!shouldExecute) {
|
||||
logger.info('Loop initial condition false, skipping loop body', { loopId: subflowId })
|
||||
return {
|
||||
sentinelStart: true,
|
||||
shouldExit: true,
|
||||
selectedRoute: EDGE.LOOP_EXIT,
|
||||
}
|
||||
}
|
||||
return { sentinelStart: true }
|
||||
}
|
||||
|
||||
case 'end': {
|
||||
const continuationResult = await this.loopOrchestrator.evaluateLoopContinuation(
|
||||
ctx,
|
||||
subflowId
|
||||
)
|
||||
|
||||
if (continuationResult.shouldContinue) {
|
||||
return {
|
||||
shouldContinue: true,
|
||||
shouldExit: false,
|
||||
selectedRoute: continuationResult.selectedRoute,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
results: continuationResult.aggregatedResults || [],
|
||||
shouldContinue: false,
|
||||
shouldExit: true,
|
||||
selectedRoute: continuationResult.selectedRoute,
|
||||
totalIterations:
|
||||
continuationResult.totalIterations ??
|
||||
getResultCount(continuationResult.aggregatedResults),
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn('Unknown sentinel type', { sentinelType })
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleParallelSentinel(
|
||||
ctx: ExecutionContext,
|
||||
node: DAGNode,
|
||||
sentinelType: string | undefined,
|
||||
parallelId: string
|
||||
): Promise<NormalizedBlockOutput> {
|
||||
if (sentinelType === 'start') {
|
||||
if (!this.parallelOrchestrator.getParallelScope(ctx, parallelId)) {
|
||||
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
|
||||
if (parallelConfig) {
|
||||
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId)
|
||||
}
|
||||
}
|
||||
|
||||
const scope = this.parallelOrchestrator.getParallelScope(ctx, parallelId)
|
||||
if (scope?.isEmpty) {
|
||||
logger.info('Parallel has empty distribution, skipping parallel body', { parallelId })
|
||||
return {
|
||||
sentinelStart: true,
|
||||
shouldExit: true,
|
||||
selectedRoute: EDGE.PARALLEL_EXIT,
|
||||
}
|
||||
}
|
||||
|
||||
this.parallelOrchestrator.prepareCurrentBatch(ctx, parallelId)
|
||||
return { sentinelStart: true }
|
||||
}
|
||||
|
||||
if (sentinelType === 'end') {
|
||||
const result = await this.parallelOrchestrator.aggregateParallelResults(ctx, parallelId)
|
||||
if (!result.allBranchesComplete) {
|
||||
return {
|
||||
results: [],
|
||||
sentinelEnd: true,
|
||||
selectedRoute: EDGE.PARALLEL_CONTINUE,
|
||||
totalBranches: result.totalBranches,
|
||||
}
|
||||
}
|
||||
return {
|
||||
results: result.results || [],
|
||||
sentinelEnd: true,
|
||||
selectedRoute: EDGE.PARALLEL_EXIT,
|
||||
totalBranches: result.totalBranches,
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('Unknown parallel sentinel type', { sentinelType })
|
||||
return {}
|
||||
}
|
||||
|
||||
async handleNodeCompletion(
|
||||
ctx: ExecutionContext,
|
||||
nodeId: string,
|
||||
output: NormalizedBlockOutput
|
||||
): Promise<void> {
|
||||
const node = this.dag.nodes.get(nodeId)
|
||||
if (!node) {
|
||||
logger.error('Node not found during completion handling', { nodeId })
|
||||
return
|
||||
}
|
||||
|
||||
const loopId = node.metadata.subflowType === 'loop' ? node.metadata.subflowId : undefined
|
||||
const isParallelBranch = node.metadata.isParallelBranch
|
||||
const isSentinel = node.metadata.isSentinel
|
||||
if (isSentinel) {
|
||||
this.handleRegularNodeCompletion(ctx, node, output)
|
||||
this.handleParentSubflowCompletion(ctx, node, output)
|
||||
} else if (loopId) {
|
||||
this.handleLoopNodeCompletion(ctx, node, output, loopId)
|
||||
} else if (isParallelBranch) {
|
||||
const parallelId =
|
||||
node.metadata.subflowType === 'parallel' ? node.metadata.subflowId : undefined
|
||||
if (parallelId) {
|
||||
await this.handleParallelNodeCompletion(ctx, node, output, parallelId)
|
||||
} else {
|
||||
logger.warn('Parallel branch missing subflow metadata', { nodeId: node.id })
|
||||
this.handleRegularNodeCompletion(ctx, node, output)
|
||||
}
|
||||
} else {
|
||||
this.handleRegularNodeCompletion(ctx, node, output)
|
||||
}
|
||||
}
|
||||
|
||||
private handleLoopNodeCompletion(
|
||||
ctx: ExecutionContext,
|
||||
node: DAGNode,
|
||||
output: NormalizedBlockOutput,
|
||||
loopId: string
|
||||
): void {
|
||||
this.loopOrchestrator.storeLoopNodeOutput(ctx, loopId, node.id, output)
|
||||
this.state.setBlockOutput(node.id, output)
|
||||
}
|
||||
|
||||
private async handleParallelNodeCompletion(
|
||||
ctx: ExecutionContext,
|
||||
node: DAGNode,
|
||||
output: NormalizedBlockOutput,
|
||||
parallelId: string
|
||||
): Promise<void> {
|
||||
const scope = this.parallelOrchestrator.getParallelScope(ctx, parallelId)
|
||||
if (!scope) {
|
||||
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId)
|
||||
}
|
||||
this.parallelOrchestrator.handleParallelBranchCompletion(ctx, parallelId, node.id, output)
|
||||
const branchIndex = node.metadata.branchIndex
|
||||
if (branchIndex !== undefined && extractOuterBranchIndex(node.id) === undefined) {
|
||||
const originalBlockId = node.metadata.originalBlockId ?? extractBaseBlockId(node.id)
|
||||
this.state.setBlockOutput(buildOuterBranchScopedId(originalBlockId, branchIndex), output)
|
||||
}
|
||||
this.state.setBlockOutput(node.id, output)
|
||||
}
|
||||
|
||||
private handleParentSubflowCompletion(
|
||||
ctx: ExecutionContext,
|
||||
node: DAGNode,
|
||||
output: NormalizedBlockOutput
|
||||
): void {
|
||||
if (node.metadata.sentinelType !== 'end' || !node.metadata.subflowId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
output.selectedRoute === EDGE.LOOP_CONTINUE ||
|
||||
output.selectedRoute === EDGE.LOOP_CONTINUE_ALT ||
|
||||
output.selectedRoute === EDGE.PARALLEL_CONTINUE
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const subflowId = node.metadata.subflowId
|
||||
const parentEntry = ctx.subflowParentMap?.get(subflowId)
|
||||
if (!parentEntry) {
|
||||
return
|
||||
}
|
||||
|
||||
if (parentEntry.parentType === 'parallel') {
|
||||
if (parentEntry.branchIndex === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
this.parallelOrchestrator.handleParallelBranchCompletion(
|
||||
ctx,
|
||||
parentEntry.parentId,
|
||||
node.id,
|
||||
getSubflowResultOutput(output),
|
||||
parentEntry.branchIndex
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.loopOrchestrator.storeLoopNodeOutput(
|
||||
ctx,
|
||||
parentEntry.parentId,
|
||||
subflowId,
|
||||
getSubflowResultOutput(output)
|
||||
)
|
||||
}
|
||||
|
||||
private handleRegularNodeCompletion(
|
||||
ctx: ExecutionContext,
|
||||
node: DAGNode,
|
||||
output: NormalizedBlockOutput
|
||||
): void {
|
||||
this.state.setBlockOutput(node.id, output)
|
||||
|
||||
if (
|
||||
node.metadata.isSentinel &&
|
||||
node.metadata.subflowType === 'loop' &&
|
||||
node.metadata.sentinelType === 'end' &&
|
||||
output.selectedRoute === 'loop_continue'
|
||||
) {
|
||||
const loopId = node.metadata.subflowId
|
||||
if (!loopId) {
|
||||
logger.warn('Loop sentinel missing subflow metadata', { nodeId: node.id })
|
||||
return
|
||||
}
|
||||
this.loopOrchestrator.clearLoopExecutionState(loopId, ctx)
|
||||
this.loopOrchestrator.restoreLoopEdges(loopId)
|
||||
}
|
||||
|
||||
if (
|
||||
node.metadata.subflowType === 'parallel' &&
|
||||
node.metadata.sentinelType === 'end' &&
|
||||
output.selectedRoute === EDGE.PARALLEL_CONTINUE
|
||||
) {
|
||||
const parallelId = node.metadata.subflowId
|
||||
if (!parallelId) {
|
||||
logger.warn('Parallel sentinel missing subflow metadata', { nodeId: node.id })
|
||||
return
|
||||
}
|
||||
this.parallelOrchestrator.prepareForBatchContinuation(parallelId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DEFAULTS } from '@/executor/constants'
|
||||
import type { DAG, DAGNode } from '@/executor/dag/builder'
|
||||
import type { BlockStateWriter, ContextExtensions } from '@/executor/execution/types'
|
||||
import { ParallelOrchestrator } from '@/executor/orchestrators/parallel'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import {
|
||||
buildBranchNodeId,
|
||||
buildParallelSentinelEndId,
|
||||
buildParallelSentinelStartId,
|
||||
buildSentinelEndId,
|
||||
buildSentinelStartId,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
|
||||
const { mockCompactSubflowResults } = vi.hoisted(() => ({
|
||||
mockCompactSubflowResults: vi.fn(async (results: unknown) => results),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/payloads/serializer', () => ({
|
||||
compactSubflowResults: mockCompactSubflowResults,
|
||||
}))
|
||||
|
||||
function createDag(): DAG {
|
||||
return {
|
||||
nodes: new Map(),
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map([
|
||||
[
|
||||
'parallel-1',
|
||||
{
|
||||
id: 'parallel-1',
|
||||
nodes: ['task-1'],
|
||||
distribution: [],
|
||||
parallelType: 'collection',
|
||||
},
|
||||
],
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
function createDagNode(id: string, metadata: DAGNode['metadata'] = {}): DAGNode {
|
||||
return {
|
||||
id,
|
||||
block: {
|
||||
id,
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: '', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'function', name: id },
|
||||
enabled: true,
|
||||
},
|
||||
incomingEdges: new Set(),
|
||||
outgoingEdges: new Map(),
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
function createState(): BlockStateWriter {
|
||||
return {
|
||||
setBlockOutput: vi.fn(),
|
||||
setBlockState: vi.fn(),
|
||||
deleteBlockState: vi.fn(),
|
||||
unmarkExecuted: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
function createEdgeManager() {
|
||||
return {
|
||||
clearDeactivatedEdgesForNodes: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
|
||||
return {
|
||||
workflowId: 'workflow-1',
|
||||
workspaceId: 'workspace-1',
|
||||
executionId: 'execution-1',
|
||||
userId: 'user-1',
|
||||
blockStates: new Map(),
|
||||
executedBlocks: new Set(),
|
||||
blockLogs: [],
|
||||
metadata: { duration: 0 },
|
||||
environmentVariables: {},
|
||||
decisions: {
|
||||
router: new Map(),
|
||||
condition: new Map(),
|
||||
},
|
||||
completedLoops: new Set(),
|
||||
activeExecutionPath: new Set(),
|
||||
workflow: {
|
||||
version: '1',
|
||||
blocks: [
|
||||
{
|
||||
id: 'parallel-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: '', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'parallel', name: 'Parallel 1' },
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
connections: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ParallelOrchestrator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCompactSubflowResults.mockImplementation(async (results: unknown) => results)
|
||||
})
|
||||
|
||||
it('defers empty-subflow lifecycle callbacks to the sentinel end path', async () => {
|
||||
const onBlockStart = vi.fn()
|
||||
const onBlockComplete = vi.fn()
|
||||
const contextExtensions: ContextExtensions = {
|
||||
onBlockStart,
|
||||
onBlockComplete,
|
||||
}
|
||||
const orchestrator = new ParallelOrchestrator(
|
||||
createDag(),
|
||||
createState(),
|
||||
null,
|
||||
contextExtensions
|
||||
)
|
||||
const ctx = createContext()
|
||||
|
||||
const scope = await orchestrator.initializeParallelScope(ctx, 'parallel-1')
|
||||
|
||||
expect(onBlockStart).not.toHaveBeenCalled()
|
||||
expect(onBlockComplete).not.toHaveBeenCalled()
|
||||
expect(scope.isEmpty).toBe(true)
|
||||
})
|
||||
|
||||
it('returns an empty scope without emitting start-side lifecycle callbacks', async () => {
|
||||
const contextExtensions: ContextExtensions = {
|
||||
onBlockStart: vi.fn().mockRejectedValue(new Error('start failed')),
|
||||
onBlockComplete: vi.fn().mockRejectedValue(new Error('complete failed')),
|
||||
}
|
||||
const orchestrator = new ParallelOrchestrator(
|
||||
createDag(),
|
||||
createState(),
|
||||
null,
|
||||
contextExtensions
|
||||
)
|
||||
|
||||
await expect(
|
||||
orchestrator.initializeParallelScope(createContext(), 'parallel-1', 1)
|
||||
).resolves.toMatchObject({
|
||||
parallelId: 'parallel-1',
|
||||
isEmpty: true,
|
||||
})
|
||||
expect(contextExtensions.onBlockStart).not.toHaveBeenCalled()
|
||||
expect(contextExtensions.onBlockComplete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves collection distributions with the parallel start sentinel scope', async () => {
|
||||
const dag = createDag()
|
||||
const parallelConfig = dag.parallelConfigs.get('parallel-1')!
|
||||
parallelConfig.distribution = '<Producer.items>'
|
||||
const resolver = {
|
||||
resolveSingleReference: vi.fn().mockResolvedValue(['item-1', 'item-2']),
|
||||
}
|
||||
const orchestrator = new ParallelOrchestrator(
|
||||
dag,
|
||||
createState(),
|
||||
resolver as any,
|
||||
{},
|
||||
undefined,
|
||||
createEdgeManager() as any
|
||||
)
|
||||
|
||||
const scope = await orchestrator.initializeParallelScope(createContext(), 'parallel-1')
|
||||
|
||||
expect(resolver.resolveSingleReference).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
'parallel-parallel-1-sentinel-start',
|
||||
'<Producer.items>',
|
||||
undefined,
|
||||
{ allowLargeValueRefs: true }
|
||||
)
|
||||
expect(scope.totalBranches).toBe(2)
|
||||
})
|
||||
|
||||
it('records resumed later-batch outputs under restored global branch indexes', () => {
|
||||
const dag = createDag()
|
||||
dag.nodes.set('task-1', {
|
||||
id: 'task-1',
|
||||
block: {
|
||||
id: 'task-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: '', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'function', name: 'Task 1' },
|
||||
enabled: true,
|
||||
},
|
||||
incomingEdges: new Set(),
|
||||
outgoingEdges: new Set(),
|
||||
metadata: { branchIndex: 0 },
|
||||
})
|
||||
const orchestrator = new ParallelOrchestrator(dag, createState(), null, {})
|
||||
const ctx = createContext({
|
||||
parallelBlockMapping: new Map([
|
||||
['task-1', { originalBlockId: 'task', parallelId: 'parallel-1', iterationIndex: 20 }],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'parallel-1',
|
||||
{
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 25,
|
||||
currentBatchStart: 20,
|
||||
currentBatchSize: 5,
|
||||
accumulatedOutputs: new Map([[0, [{ output: 'previous' }]]]),
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
|
||||
orchestrator.handleParallelBranchCompletion(ctx, 'parallel-1', 'task-1', { output: 'resumed' })
|
||||
|
||||
const scope = ctx.parallelExecutions?.get('parallel-1')
|
||||
expect(scope?.branchOutputs.get(20)).toEqual([{ output: 'resumed' }])
|
||||
expect(scope?.branchOutputs.has(0)).toBe(false)
|
||||
})
|
||||
|
||||
it('clamps batch size and caps current batch to total branch count', async () => {
|
||||
const dag = createDag()
|
||||
const parallelConfig = dag.parallelConfigs.get('parallel-1')!
|
||||
parallelConfig.parallelType = 'count'
|
||||
parallelConfig.count = 9
|
||||
parallelConfig.batchSize = 0
|
||||
|
||||
const orchestrator = new ParallelOrchestrator(dag, createState(), null, {})
|
||||
const zeroBatchScope = await orchestrator.initializeParallelScope(createContext(), 'parallel-1')
|
||||
|
||||
expect(zeroBatchScope.batchSize).toBe(1)
|
||||
expect(zeroBatchScope.currentBatchSize).toBe(1)
|
||||
|
||||
parallelConfig.batchSize = 50
|
||||
const oversizedBatchScope = await orchestrator.initializeParallelScope(
|
||||
createContext(),
|
||||
'parallel-1'
|
||||
)
|
||||
|
||||
expect(oversizedBatchScope.currentBatchSize).toBe(9)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['oversized numeric batch size', 999, DEFAULTS.MAX_PARALLEL_BRANCHES],
|
||||
['negative batch size', -1, 1],
|
||||
['undefined batch size', undefined, DEFAULTS.MAX_PARALLEL_BRANCHES],
|
||||
['nonnumeric batch size', 'not-a-number', DEFAULTS.MAX_PARALLEL_BRANCHES],
|
||||
])('normalizes %s', async (_name, batchSize, expectedBatchSize) => {
|
||||
const dag = createDag()
|
||||
const parallelConfig = dag.parallelConfigs.get('parallel-1')!
|
||||
parallelConfig.parallelType = 'count'
|
||||
parallelConfig.count = DEFAULTS.MAX_PARALLEL_BRANCHES + 10
|
||||
parallelConfig.batchSize = batchSize as never
|
||||
|
||||
const orchestrator = new ParallelOrchestrator(dag, createState(), null, {})
|
||||
const scope = await orchestrator.initializeParallelScope(createContext(), 'parallel-1')
|
||||
|
||||
expect(scope.batchSize).toBe(expectedBatchSize)
|
||||
expect(scope.currentBatchSize).toBe(expectedBatchSize)
|
||||
})
|
||||
|
||||
it('advances batch state at sentinel end and prepares the next batch at sentinel start', async () => {
|
||||
const dag = createDag()
|
||||
const templateBranchId = buildBranchNodeId('task-1', 0)
|
||||
const secondBranchId = buildBranchNodeId('task-1', 1)
|
||||
dag.nodes.set(templateBranchId, {
|
||||
id: templateBranchId,
|
||||
block: {
|
||||
id: 'task-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: '', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'function', name: 'Task 1' },
|
||||
enabled: true,
|
||||
},
|
||||
incomingEdges: new Set(),
|
||||
outgoingEdges: new Map(),
|
||||
metadata: {
|
||||
subflowId: 'parallel-1',
|
||||
subflowType: 'parallel',
|
||||
isParallelBranch: true,
|
||||
branchIndex: 0,
|
||||
},
|
||||
})
|
||||
const state = createState()
|
||||
const edgeManager = createEdgeManager()
|
||||
const orchestrator = new ParallelOrchestrator(dag, state, null, {}, edgeManager)
|
||||
const scope = {
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 4,
|
||||
batchSize: 2,
|
||||
currentBatchStart: 0,
|
||||
currentBatchSize: 2,
|
||||
accumulatedOutputs: new Map<number, any[]>(),
|
||||
branchOutputs: new Map<number, any[]>([
|
||||
[0, [{ output: 'branch-0' }]],
|
||||
[1, [{ output: 'branch-1' }]],
|
||||
]),
|
||||
}
|
||||
const ctx = createContext({
|
||||
parallelExecutions: new Map([['parallel-1', scope]]),
|
||||
})
|
||||
|
||||
const result = await orchestrator.aggregateParallelResults(ctx, 'parallel-1')
|
||||
|
||||
expect(result.allBranchesComplete).toBe(false)
|
||||
expect(scope.currentBatchStart).toBe(2)
|
||||
expect(scope.currentBatchSize).toBe(2)
|
||||
expect(ctx.parallelBlockMapping?.size ?? 0).toBe(0)
|
||||
|
||||
orchestrator.prepareCurrentBatch(ctx, 'parallel-1')
|
||||
|
||||
expect(ctx.parallelBlockMapping?.get(templateBranchId)).toMatchObject({
|
||||
originalBlockId: 'task-1',
|
||||
parallelId: 'parallel-1',
|
||||
iterationIndex: 2,
|
||||
})
|
||||
expect(ctx.parallelBlockMapping?.get(secondBranchId)).toMatchObject({
|
||||
originalBlockId: 'task-1',
|
||||
parallelId: 'parallel-1',
|
||||
iterationIndex: 3,
|
||||
})
|
||||
expect(state.deleteBlockState).toHaveBeenCalledWith(templateBranchId)
|
||||
expect(state.deleteBlockState).toHaveBeenCalledWith(secondBranchId)
|
||||
expect(edgeManager.clearDeactivatedEdgesForNodes).toHaveBeenCalledWith(
|
||||
new Set([templateBranchId, secondBranchId])
|
||||
)
|
||||
})
|
||||
|
||||
it('resets only incoming batch branch state when scheduling later batches', async () => {
|
||||
const dag = createDag()
|
||||
const incomingBranchId = buildBranchNodeId('task-1', 0)
|
||||
const previousBranchId = buildBranchNodeId('task-1', 1)
|
||||
dag.nodes.set(incomingBranchId, {
|
||||
id: incomingBranchId,
|
||||
block: {
|
||||
id: 'task-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: '', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'function', name: 'Task 1' },
|
||||
enabled: true,
|
||||
},
|
||||
incomingEdges: new Set(),
|
||||
outgoingEdges: new Set(),
|
||||
metadata: {
|
||||
subflowId: 'parallel-1',
|
||||
subflowType: 'parallel',
|
||||
isParallelBranch: true,
|
||||
branchIndex: 0,
|
||||
},
|
||||
})
|
||||
dag.nodes.set(previousBranchId, {
|
||||
id: previousBranchId,
|
||||
block: {
|
||||
id: 'task-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: '', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'function', name: 'Task 1' },
|
||||
enabled: true,
|
||||
},
|
||||
incomingEdges: new Set(),
|
||||
outgoingEdges: new Set(),
|
||||
metadata: {
|
||||
subflowId: 'parallel-1',
|
||||
subflowType: 'parallel',
|
||||
isParallelBranch: true,
|
||||
branchIndex: 1,
|
||||
},
|
||||
})
|
||||
const state = createState()
|
||||
const orchestrator = new ParallelOrchestrator(dag, state, null, {})
|
||||
|
||||
orchestrator.prepareCurrentBatch(
|
||||
createContext({
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'parallel-1',
|
||||
{
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 3,
|
||||
batchSize: 1,
|
||||
currentBatchStart: 2,
|
||||
currentBatchSize: 1,
|
||||
accumulatedOutputs: new Map([[1, [{ output: 'previous' }]]]),
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
'parallel-1'
|
||||
)
|
||||
|
||||
expect(state.deleteBlockState).toHaveBeenCalledWith(incomingBranchId)
|
||||
expect(state.deleteBlockState).not.toHaveBeenCalledWith(previousBranchId)
|
||||
expect(state.unmarkExecuted).toHaveBeenCalledWith(incomingBranchId)
|
||||
expect(state.unmarkExecuted).not.toHaveBeenCalledWith(previousBranchId)
|
||||
})
|
||||
|
||||
it('marks expanded branch nodes dirty when running from a dirty parallel container', () => {
|
||||
const dag = createDag()
|
||||
const templateBranchId = buildBranchNodeId('task-1', 0)
|
||||
const secondBranchId = buildBranchNodeId('task-1', 1)
|
||||
dag.nodes.set(templateBranchId, {
|
||||
id: templateBranchId,
|
||||
block: {
|
||||
id: 'task-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: '', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'function', name: 'Task 1' },
|
||||
enabled: true,
|
||||
},
|
||||
incomingEdges: new Set(),
|
||||
outgoingEdges: new Map(),
|
||||
metadata: {
|
||||
subflowId: 'parallel-1',
|
||||
subflowType: 'parallel',
|
||||
isParallelBranch: true,
|
||||
branchIndex: 0,
|
||||
},
|
||||
})
|
||||
const dirtySet = new Set(['parallel-1'])
|
||||
const orchestrator = new ParallelOrchestrator(dag, createState(), null, {})
|
||||
|
||||
orchestrator.prepareCurrentBatch(
|
||||
createContext({
|
||||
runFromBlockContext: { startBlockId: 'parallel-1', dirtySet },
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'parallel-1',
|
||||
{
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 2,
|
||||
batchSize: 2,
|
||||
currentBatchStart: 0,
|
||||
currentBatchSize: 2,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
'parallel-1'
|
||||
)
|
||||
|
||||
expect(dirtySet.has(templateBranchId)).toBe(true)
|
||||
expect(dirtySet.has(secondBranchId)).toBe(true)
|
||||
})
|
||||
|
||||
it('marks cloned nested loop body nodes dirty for non-zero branches', () => {
|
||||
const dag = createDag()
|
||||
const parallelId = 'parallel-1'
|
||||
const loopId = 'loop-1'
|
||||
const taskId = 'task-1'
|
||||
const parallelStartId = buildParallelSentinelStartId(parallelId)
|
||||
const parallelEndId = buildParallelSentinelEndId(parallelId)
|
||||
const loopStartId = buildSentinelStartId(loopId)
|
||||
const loopEndId = buildSentinelEndId(loopId)
|
||||
|
||||
dag.parallelConfigs.set(parallelId, {
|
||||
id: parallelId,
|
||||
nodes: [loopId],
|
||||
count: 2,
|
||||
parallelType: 'count',
|
||||
})
|
||||
dag.loopConfigs.set(loopId, {
|
||||
id: loopId,
|
||||
nodes: [taskId],
|
||||
loopType: 'for',
|
||||
iterations: 1,
|
||||
})
|
||||
dag.nodes.set(parallelStartId, createDagNode(parallelStartId))
|
||||
dag.nodes.set(parallelEndId, createDagNode(parallelEndId))
|
||||
dag.nodes.set(
|
||||
loopStartId,
|
||||
createDagNode(loopStartId, {
|
||||
isSentinel: true,
|
||||
sentinelType: 'start',
|
||||
subflowId: loopId,
|
||||
subflowType: 'loop',
|
||||
})
|
||||
)
|
||||
dag.nodes.set(
|
||||
taskId,
|
||||
createDagNode(taskId, {
|
||||
isLoopNode: true,
|
||||
subflowId: loopId,
|
||||
subflowType: 'loop',
|
||||
originalBlockId: taskId,
|
||||
})
|
||||
)
|
||||
dag.nodes.set(
|
||||
loopEndId,
|
||||
createDagNode(loopEndId, {
|
||||
isSentinel: true,
|
||||
sentinelType: 'end',
|
||||
subflowId: loopId,
|
||||
subflowType: 'loop',
|
||||
})
|
||||
)
|
||||
dag.nodes.get(loopStartId)!.outgoingEdges.set(`${loopStartId}->${taskId}`, { target: taskId })
|
||||
dag.nodes.get(taskId)!.incomingEdges.add(loopStartId)
|
||||
dag.nodes.get(taskId)!.outgoingEdges.set(`${taskId}->${loopEndId}`, { target: loopEndId })
|
||||
dag.nodes.get(loopEndId)!.incomingEdges.add(taskId)
|
||||
|
||||
const dirtySet = new Set([parallelId])
|
||||
const orchestrator = new ParallelOrchestrator(dag, createState(), null, {})
|
||||
orchestrator.prepareCurrentBatch(
|
||||
createContext({
|
||||
runFromBlockContext: { startBlockId: parallelId, dirtySet },
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
parallelId,
|
||||
{
|
||||
parallelId,
|
||||
totalBranches: 2,
|
||||
batchSize: 2,
|
||||
currentBatchStart: 0,
|
||||
currentBatchSize: 2,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
parallelId
|
||||
)
|
||||
|
||||
expect([...dirtySet]).toContain(taskId)
|
||||
expect([...dirtySet].some((nodeId) => nodeId.startsWith(`${taskId}__clone`))).toBe(true)
|
||||
})
|
||||
|
||||
it('compacts accumulated outputs before scheduling later batches', async () => {
|
||||
const dag = createDag()
|
||||
const templateBranchId = buildBranchNodeId('task-1', 0)
|
||||
dag.nodes.set(templateBranchId, {
|
||||
id: templateBranchId,
|
||||
block: {
|
||||
id: 'task-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: '', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'function', name: 'Task 1' },
|
||||
enabled: true,
|
||||
},
|
||||
incomingEdges: new Set(),
|
||||
outgoingEdges: new Set(),
|
||||
metadata: {
|
||||
subflowId: 'parallel-1',
|
||||
subflowType: 'parallel',
|
||||
isParallelBranch: true,
|
||||
branchIndex: 0,
|
||||
},
|
||||
})
|
||||
const orchestrator = new ParallelOrchestrator(dag, createState(), null, {})
|
||||
const previousOutputs = [{ output: 'previous' }]
|
||||
const incomingOutputs = [{ output: 'incoming' }]
|
||||
const compactedPrevious = [{ output: 'compacted-previous' }]
|
||||
const compactedIncoming = [{ output: 'compacted-incoming' }]
|
||||
mockCompactSubflowResults.mockResolvedValueOnce([compactedPrevious, compactedIncoming])
|
||||
const scope = {
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 3,
|
||||
batchSize: 1,
|
||||
currentBatchStart: 0,
|
||||
currentBatchSize: 2,
|
||||
accumulatedOutputs: new Map([[0, previousOutputs]]),
|
||||
branchOutputs: new Map([[1, incomingOutputs]]),
|
||||
}
|
||||
const ctx = createContext({
|
||||
parallelExecutions: new Map([['parallel-1', scope]]),
|
||||
})
|
||||
|
||||
const result = await orchestrator.aggregateParallelResults(ctx, 'parallel-1')
|
||||
|
||||
expect(result).toMatchObject({ allBranchesComplete: false, completedBranches: 2 })
|
||||
expect(mockCompactSubflowResults).toHaveBeenCalledWith(
|
||||
[previousOutputs, incomingOutputs],
|
||||
expect.objectContaining({
|
||||
workspaceId: 'workspace-1',
|
||||
workflowId: 'workflow-1',
|
||||
executionId: 'execution-1',
|
||||
requireDurable: true,
|
||||
})
|
||||
)
|
||||
expect(scope.accumulatedOutputs.get(0)).toBe(compactedPrevious)
|
||||
expect(scope.accumulatedOutputs.get(1)).toBe(compactedIncoming)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,595 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { compactSubflowResults } from '@/lib/execution/payloads/serializer'
|
||||
import { DEFAULTS } from '@/executor/constants'
|
||||
import type { DAG } from '@/executor/dag/builder'
|
||||
import type { EdgeManager } from '@/executor/execution/edge-manager'
|
||||
import type { ParallelScope } from '@/executor/execution/state'
|
||||
import type { BlockStateWriter, ContextExtensions } from '@/executor/execution/types'
|
||||
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
|
||||
import { type ClonedSubflowInfo, ParallelExpander } from '@/executor/utils/parallel-expansion'
|
||||
import {
|
||||
addSubflowErrorLog,
|
||||
buildBranchNodeId,
|
||||
buildParallelSentinelEndId,
|
||||
buildParallelSentinelStartId,
|
||||
buildSentinelEndId,
|
||||
buildSentinelStartId,
|
||||
emitSubflowSuccessEvents,
|
||||
extractBaseBlockId,
|
||||
extractBranchIndex,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
import { resolveArrayInputAsync } from '@/executor/utils/subflow-utils.server'
|
||||
import type { VariableResolver } from '@/executor/variables/resolver'
|
||||
import type { SerializedParallel } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('ParallelOrchestrator')
|
||||
const DEFAULT_PARALLEL_BATCH_SIZE = 20
|
||||
|
||||
export interface ParallelBranchMetadata {
|
||||
branchIndex: number
|
||||
branchTotal: number
|
||||
distributionItem?: any
|
||||
parallelId: string
|
||||
}
|
||||
|
||||
export interface ParallelAggregationResult {
|
||||
allBranchesComplete: boolean
|
||||
results?: unknown
|
||||
completedBranches?: number
|
||||
totalBranches?: number
|
||||
}
|
||||
|
||||
export class ParallelOrchestrator {
|
||||
private expander = new ParallelExpander()
|
||||
|
||||
constructor(
|
||||
private dag: DAG,
|
||||
private state: BlockStateWriter,
|
||||
private resolver: VariableResolver | null = null,
|
||||
private contextExtensions: ContextExtensions | null = null,
|
||||
private edgeManager: Pick<EdgeManager, 'clearDeactivatedEdgesForNodes'> | null = null
|
||||
) {}
|
||||
|
||||
async initializeParallelScope(ctx: ExecutionContext, parallelId: string): Promise<ParallelScope> {
|
||||
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
|
||||
if (!parallelConfig) {
|
||||
throw new Error(`Parallel config not found: ${parallelId}`)
|
||||
}
|
||||
|
||||
if (parallelConfig.nodes.length === 0) {
|
||||
const errorMessage =
|
||||
'Parallel has no executable blocks inside. Add or enable at least one block in the parallel.'
|
||||
logger.error(errorMessage, { parallelId })
|
||||
await this.addParallelErrorLog(ctx, parallelId, errorMessage, {})
|
||||
this.setErrorScope(ctx, parallelId, errorMessage)
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
let items: any[] | undefined
|
||||
let branchCount: number
|
||||
let isEmpty = false
|
||||
|
||||
try {
|
||||
const resolved = await this.resolveBranchCount(ctx, parallelConfig, parallelId)
|
||||
branchCount = resolved.branchCount
|
||||
items = resolved.items
|
||||
isEmpty = resolved.isEmpty ?? false
|
||||
} catch (error) {
|
||||
const baseErrorMessage = toError(error).message
|
||||
const errorMessage = baseErrorMessage.startsWith('Parallel collection distribution is empty')
|
||||
? baseErrorMessage
|
||||
: `Parallel Items did not resolve: ${baseErrorMessage}`
|
||||
logger.error(errorMessage, { parallelId, distribution: parallelConfig.distribution })
|
||||
await this.addParallelErrorLog(ctx, parallelId, errorMessage, {
|
||||
distribution: parallelConfig.distribution,
|
||||
})
|
||||
this.setErrorScope(ctx, parallelId, errorMessage)
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
if (isEmpty || branchCount === 0) {
|
||||
const scope: ParallelScope = {
|
||||
parallelId,
|
||||
totalBranches: 0,
|
||||
branchOutputs: new Map(),
|
||||
items: [],
|
||||
isEmpty: true,
|
||||
}
|
||||
|
||||
if (!ctx.parallelExecutions) {
|
||||
ctx.parallelExecutions = new Map()
|
||||
}
|
||||
ctx.parallelExecutions.set(parallelId, scope)
|
||||
|
||||
logger.info('Parallel scope initialized with empty distribution, skipping body', {
|
||||
parallelId,
|
||||
branchCount: 0,
|
||||
})
|
||||
|
||||
return scope
|
||||
}
|
||||
|
||||
const batchSize = this.resolveBatchSize(parallelConfig.batchSize)
|
||||
const currentBatchSize = Math.min(batchSize, branchCount)
|
||||
const scope: ParallelScope = {
|
||||
parallelId,
|
||||
totalBranches: branchCount,
|
||||
batchSize,
|
||||
currentBatchStart: 0,
|
||||
currentBatchSize,
|
||||
accumulatedOutputs: new Map(),
|
||||
branchOutputs: new Map(),
|
||||
items,
|
||||
}
|
||||
|
||||
if (!ctx.parallelExecutions) {
|
||||
ctx.parallelExecutions = new Map()
|
||||
}
|
||||
ctx.parallelExecutions.set(parallelId, scope)
|
||||
|
||||
logger.info('Parallel scope initialized', {
|
||||
parallelId,
|
||||
branchCount,
|
||||
batchSize,
|
||||
currentBatchSize,
|
||||
})
|
||||
|
||||
return scope
|
||||
}
|
||||
|
||||
prepareCurrentBatch(ctx: ExecutionContext, parallelId: string): void {
|
||||
const scope = ctx.parallelExecutions?.get(parallelId)
|
||||
if (!scope || scope.isEmpty) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentBatchStart = scope.currentBatchStart ?? 0
|
||||
const currentBatchSize =
|
||||
scope.currentBatchSize ??
|
||||
Math.min(scope.batchSize ?? DEFAULT_PARALLEL_BATCH_SIZE, scope.totalBranches)
|
||||
|
||||
if (currentBatchSize <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const batchItems = scope.items?.slice(currentBatchStart, currentBatchStart + currentBatchSize)
|
||||
const { clonedSubflows, allBranchNodes, entryNodes, terminalNodes } =
|
||||
this.expander.expandParallel(this.dag, parallelId, currentBatchSize, batchItems, {
|
||||
branchIndexOffset: currentBatchStart,
|
||||
totalBranches: scope.totalBranches,
|
||||
})
|
||||
|
||||
this.markRunFromBlockBatchDirty(
|
||||
ctx,
|
||||
parallelId,
|
||||
allBranchNodes,
|
||||
entryNodes,
|
||||
terminalNodes,
|
||||
clonedSubflows
|
||||
)
|
||||
this.registerClonedSubflows(ctx, parallelId, clonedSubflows)
|
||||
this.registerBranchMappings(ctx, parallelId, allBranchNodes)
|
||||
this.resetBatchExecutionState(allBranchNodes)
|
||||
this.edgeManager?.clearDeactivatedEdgesForNodes(new Set(allBranchNodes))
|
||||
|
||||
logger.info('Prepared parallel batch', {
|
||||
parallelId,
|
||||
currentBatchStart,
|
||||
currentBatchSize,
|
||||
totalBranches: scope.totalBranches,
|
||||
branchNodeCount: allBranchNodes.length,
|
||||
})
|
||||
}
|
||||
|
||||
private async resolveBranchCount(
|
||||
ctx: ExecutionContext,
|
||||
config: SerializedParallel,
|
||||
parallelId: string
|
||||
): Promise<{ branchCount: number; items?: any[]; isEmpty?: boolean }> {
|
||||
if (config.parallelType === 'count') {
|
||||
return { branchCount: config.count ?? 1 }
|
||||
}
|
||||
|
||||
const items = await this.resolveDistributionItems(ctx, config)
|
||||
if (items.length === 0) {
|
||||
logger.info('Parallel has empty distribution, skipping parallel body', { parallelId })
|
||||
return { branchCount: 0, items: [], isEmpty: true }
|
||||
}
|
||||
|
||||
return { branchCount: items.length, items }
|
||||
}
|
||||
|
||||
private async addParallelErrorLog(
|
||||
ctx: ExecutionContext,
|
||||
parallelId: string,
|
||||
errorMessage: string,
|
||||
inputData?: any
|
||||
): Promise<void> {
|
||||
await addSubflowErrorLog(
|
||||
ctx,
|
||||
parallelId,
|
||||
'parallel',
|
||||
errorMessage,
|
||||
inputData || {},
|
||||
this.contextExtensions
|
||||
)
|
||||
}
|
||||
|
||||
private setErrorScope(ctx: ExecutionContext, parallelId: string, errorMessage: string): void {
|
||||
const scope: ParallelScope = {
|
||||
parallelId,
|
||||
totalBranches: 0,
|
||||
branchOutputs: new Map(),
|
||||
items: [],
|
||||
validationError: errorMessage,
|
||||
}
|
||||
if (!ctx.parallelExecutions) {
|
||||
ctx.parallelExecutions = new Map()
|
||||
}
|
||||
ctx.parallelExecutions.set(parallelId, scope)
|
||||
}
|
||||
|
||||
private async resolveDistributionItems(
|
||||
ctx: ExecutionContext,
|
||||
config: SerializedParallel
|
||||
): Promise<any[]> {
|
||||
if (
|
||||
config.distribution === undefined ||
|
||||
config.distribution === null ||
|
||||
config.distribution === ''
|
||||
) {
|
||||
throw new Error(
|
||||
'Parallel collection distribution is empty. Provide an array or a reference that resolves to a collection.'
|
||||
)
|
||||
}
|
||||
return resolveArrayInputAsync(
|
||||
ctx,
|
||||
config.distribution,
|
||||
this.resolver,
|
||||
buildParallelSentinelStartId(config.id)
|
||||
)
|
||||
}
|
||||
|
||||
private resolveBatchSize(batchSize: unknown): number {
|
||||
const parsed =
|
||||
typeof batchSize === 'number' ? batchSize : Number.parseInt(String(batchSize), 10)
|
||||
if (Number.isNaN(parsed)) {
|
||||
return DEFAULT_PARALLEL_BATCH_SIZE
|
||||
}
|
||||
return Math.max(1, Math.min(DEFAULTS.MAX_PARALLEL_BRANCHES, parsed))
|
||||
}
|
||||
|
||||
private registerClonedSubflows(
|
||||
ctx: ExecutionContext,
|
||||
parallelId: string,
|
||||
clonedSubflows: ClonedSubflowInfo[]
|
||||
): void {
|
||||
if (clonedSubflows.length === 0 || !ctx.subflowParentMap) {
|
||||
return
|
||||
}
|
||||
|
||||
const branchCloneMaps = new Map<number, Map<string, string>>()
|
||||
for (const clone of clonedSubflows) {
|
||||
let map = branchCloneMaps.get(clone.outerBranchIndex)
|
||||
if (!map) {
|
||||
map = new Map()
|
||||
branchCloneMaps.set(clone.outerBranchIndex, map)
|
||||
}
|
||||
map.set(clone.originalId, clone.clonedId)
|
||||
}
|
||||
|
||||
for (const clone of clonedSubflows) {
|
||||
const originalEntry = ctx.subflowParentMap.get(clone.originalId)
|
||||
if (originalEntry) {
|
||||
const cloneMap = branchCloneMaps.get(clone.outerBranchIndex)
|
||||
const clonedParentId = cloneMap?.get(originalEntry.parentId)
|
||||
if (clonedParentId) {
|
||||
ctx.subflowParentMap.set(clone.clonedId, {
|
||||
parentId: clonedParentId,
|
||||
parentType: originalEntry.parentType,
|
||||
branchIndex: 0,
|
||||
})
|
||||
} else {
|
||||
ctx.subflowParentMap.set(clone.clonedId, {
|
||||
parentId: parallelId,
|
||||
parentType: 'parallel',
|
||||
branchIndex: clone.outerBranchIndex,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
ctx.subflowParentMap.set(clone.clonedId, {
|
||||
parentId: parallelId,
|
||||
parentType: 'parallel',
|
||||
branchIndex: clone.outerBranchIndex,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private markRunFromBlockBatchDirty(
|
||||
ctx: ExecutionContext,
|
||||
parallelId: string,
|
||||
allBranchNodes: string[],
|
||||
entryNodes: string[],
|
||||
terminalNodes: string[],
|
||||
clonedSubflows: ClonedSubflowInfo[]
|
||||
): void {
|
||||
const dirtySet = ctx.runFromBlockContext?.dirtySet
|
||||
if (!dirtySet) return
|
||||
|
||||
const parallelStartId = buildParallelSentinelStartId(parallelId)
|
||||
const parallelEndId = buildParallelSentinelEndId(parallelId)
|
||||
if (
|
||||
!dirtySet.has(parallelId) &&
|
||||
!dirtySet.has(parallelStartId) &&
|
||||
!dirtySet.has(parallelEndId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const nodeId of allBranchNodes) dirtySet.add(nodeId)
|
||||
for (const nodeId of entryNodes) dirtySet.add(nodeId)
|
||||
for (const nodeId of terminalNodes) dirtySet.add(nodeId)
|
||||
|
||||
const config = this.dag.parallelConfigs.get(parallelId)
|
||||
for (const nodeId of config?.nodes ?? []) {
|
||||
if (this.dag.parallelConfigs.has(nodeId) || this.dag.loopConfigs.has(nodeId)) {
|
||||
this.collectSubflowNodeIds(nodeId, dirtySet)
|
||||
}
|
||||
}
|
||||
|
||||
for (const clone of clonedSubflows) {
|
||||
dirtySet.add(clone.clonedId)
|
||||
this.collectSubflowNodeIds(clone.clonedId, dirtySet)
|
||||
}
|
||||
}
|
||||
|
||||
private collectSubflowNodeIds(
|
||||
subflowId: string,
|
||||
nodeIds: Set<string>,
|
||||
visited = new Set<string>()
|
||||
): void {
|
||||
if (visited.has(subflowId)) return
|
||||
visited.add(subflowId)
|
||||
|
||||
if (this.dag.parallelConfigs.has(subflowId)) {
|
||||
nodeIds.add(buildParallelSentinelStartId(subflowId))
|
||||
nodeIds.add(buildParallelSentinelEndId(subflowId))
|
||||
for (const childId of this.dag.parallelConfigs.get(subflowId)?.nodes ?? []) {
|
||||
if (this.dag.parallelConfigs.has(childId) || this.dag.loopConfigs.has(childId)) {
|
||||
this.collectSubflowNodeIds(childId, nodeIds, visited)
|
||||
} else {
|
||||
nodeIds.add(buildBranchNodeId(childId, 0))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (this.dag.loopConfigs.has(subflowId)) {
|
||||
nodeIds.add(buildSentinelStartId(subflowId))
|
||||
nodeIds.add(buildSentinelEndId(subflowId))
|
||||
for (const childId of this.dag.loopConfigs.get(subflowId)?.nodes ?? []) {
|
||||
if (this.dag.parallelConfigs.has(childId) || this.dag.loopConfigs.has(childId)) {
|
||||
this.collectSubflowNodeIds(childId, nodeIds, visited)
|
||||
} else {
|
||||
nodeIds.add(childId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a node's output in the branch outputs for later aggregation.
|
||||
* Aggregation is triggered by the sentinel-end node via the edge mechanism,
|
||||
* not by counting individual node completions. This avoids incorrect completion
|
||||
* detection when branches have conditional paths (error edges, conditions).
|
||||
*/
|
||||
handleParallelBranchCompletion(
|
||||
ctx: ExecutionContext,
|
||||
parallelId: string,
|
||||
nodeId: string,
|
||||
output: NormalizedBlockOutput,
|
||||
branchIndexOverride?: number
|
||||
): void {
|
||||
const scope = ctx.parallelExecutions?.get(parallelId)
|
||||
if (!scope) {
|
||||
logger.warn('Parallel scope not found for branch completion', { parallelId, nodeId })
|
||||
return
|
||||
}
|
||||
|
||||
const mappedBranch = ctx.parallelBlockMapping?.get(nodeId)
|
||||
const branchIndex =
|
||||
branchIndexOverride ??
|
||||
(mappedBranch?.parallelId === parallelId
|
||||
? mappedBranch.iterationIndex
|
||||
: (this.dag.nodes.get(nodeId)?.metadata.branchIndex ?? extractBranchIndex(nodeId)))
|
||||
if (branchIndex === null) {
|
||||
logger.warn('Could not extract branch index from node ID', { nodeId })
|
||||
return
|
||||
}
|
||||
|
||||
if (!scope.branchOutputs.has(branchIndex)) {
|
||||
scope.branchOutputs.set(branchIndex, [])
|
||||
}
|
||||
scope.branchOutputs.get(branchIndex)!.push(output)
|
||||
}
|
||||
|
||||
async aggregateParallelResults(
|
||||
ctx: ExecutionContext,
|
||||
parallelId: string
|
||||
): Promise<ParallelAggregationResult> {
|
||||
const scope = ctx.parallelExecutions?.get(parallelId)
|
||||
if (!scope) {
|
||||
logger.error('Parallel scope not found for aggregation', { parallelId })
|
||||
return { allBranchesComplete: false }
|
||||
}
|
||||
|
||||
const accumulatedOutputs =
|
||||
scope.accumulatedOutputs ?? new Map<number, NormalizedBlockOutput[]>()
|
||||
for (const [branchIndex, outputs] of scope.branchOutputs.entries()) {
|
||||
accumulatedOutputs.set(branchIndex, outputs)
|
||||
}
|
||||
scope.accumulatedOutputs = accumulatedOutputs
|
||||
scope.branchOutputs = new Map()
|
||||
|
||||
const nextBatchStart =
|
||||
(scope.currentBatchStart ?? 0) + (scope.currentBatchSize ?? scope.totalBranches)
|
||||
if (nextBatchStart < scope.totalBranches) {
|
||||
/**
|
||||
* Compact accumulated outputs before scheduling the next batch. Each
|
||||
* block output is already individually compacted by `block-executor`, but
|
||||
* many below-threshold branch results can still exceed the aggregate
|
||||
* threshold over time. Re-running the existing subflow compactor over the
|
||||
* accumulated entries forces aggregate-size spills while existing
|
||||
* LargeValueRefs stay stable.
|
||||
*/
|
||||
if (accumulatedOutputs.size > 0) {
|
||||
const accumulatedBranchIndexes = Array.from(accumulatedOutputs.keys()).sort((a, b) => a - b)
|
||||
const accumulatedResults = accumulatedBranchIndexes.map(
|
||||
(idx) => accumulatedOutputs.get(idx) ?? []
|
||||
)
|
||||
const compactedAccumulated = await compactSubflowResults(accumulatedResults, {
|
||||
workspaceId: ctx.workspaceId,
|
||||
workflowId: ctx.workflowId,
|
||||
executionId: ctx.executionId,
|
||||
largeValueExecutionIds: ctx.largeValueExecutionIds,
|
||||
largeValueKeys: ctx.largeValueKeys,
|
||||
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
|
||||
userId: ctx.userId,
|
||||
requireDurable: true,
|
||||
})
|
||||
accumulatedBranchIndexes.forEach((branchIdx, position) => {
|
||||
accumulatedOutputs.set(branchIdx, compactedAccumulated[position])
|
||||
})
|
||||
}
|
||||
this.advanceToNextBatch(scope, nextBatchStart)
|
||||
return {
|
||||
allBranchesComplete: false,
|
||||
completedBranches: accumulatedOutputs.size,
|
||||
totalBranches: scope.totalBranches,
|
||||
}
|
||||
}
|
||||
|
||||
const results: NormalizedBlockOutput[][] = []
|
||||
for (let i = 0; i < scope.totalBranches; i++) {
|
||||
const branchOutputs = accumulatedOutputs.get(i)
|
||||
if (!branchOutputs) {
|
||||
logger.warn('Missing branch output during parallel aggregation', { parallelId, branch: i })
|
||||
}
|
||||
results.push(branchOutputs ?? [])
|
||||
}
|
||||
const compactedResults = await compactSubflowResults(results, {
|
||||
workspaceId: ctx.workspaceId,
|
||||
workflowId: ctx.workflowId,
|
||||
executionId: ctx.executionId,
|
||||
largeValueExecutionIds: ctx.largeValueExecutionIds,
|
||||
largeValueKeys: ctx.largeValueKeys,
|
||||
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
|
||||
userId: ctx.userId,
|
||||
requireDurable: true,
|
||||
})
|
||||
const output = { results: compactedResults }
|
||||
this.state.setBlockOutput(parallelId, output)
|
||||
scope.accumulatedOutputs = new Map()
|
||||
|
||||
await emitSubflowSuccessEvents(ctx, parallelId, 'parallel', output, this.contextExtensions)
|
||||
|
||||
return {
|
||||
allBranchesComplete: true,
|
||||
results: output.results,
|
||||
completedBranches: scope.totalBranches,
|
||||
totalBranches: scope.totalBranches,
|
||||
}
|
||||
}
|
||||
|
||||
private advanceToNextBatch(scope: ParallelScope, nextBatchStart: number): void {
|
||||
const batchSize = scope.batchSize ?? DEFAULT_PARALLEL_BATCH_SIZE
|
||||
const remaining = scope.totalBranches - nextBatchStart
|
||||
const currentBatchSize = Math.min(batchSize, remaining)
|
||||
|
||||
scope.currentBatchStart = nextBatchStart
|
||||
scope.currentBatchSize = currentBatchSize
|
||||
|
||||
logger.info('Advanced to next parallel batch', {
|
||||
parallelId: scope.parallelId,
|
||||
nextBatchStart,
|
||||
currentBatchSize,
|
||||
totalBranches: scope.totalBranches,
|
||||
})
|
||||
}
|
||||
|
||||
prepareForBatchContinuation(parallelId: string): void {
|
||||
this.state.unmarkExecuted(buildParallelSentinelStartId(parallelId))
|
||||
this.state.unmarkExecuted(buildParallelSentinelEndId(parallelId))
|
||||
this.state.deleteBlockState(buildParallelSentinelEndId(parallelId))
|
||||
}
|
||||
|
||||
private resetBatchExecutionState(branchNodeIds: string[]): void {
|
||||
for (const nodeId of branchNodeIds) {
|
||||
const node = this.dag.nodes.get(nodeId)
|
||||
if (!node?.metadata.isParallelBranch) {
|
||||
continue
|
||||
}
|
||||
this.state.unmarkExecuted(nodeId)
|
||||
this.state.deleteBlockState(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
private registerBranchMappings(
|
||||
ctx: ExecutionContext,
|
||||
parallelId: string,
|
||||
branchNodeIds: string[]
|
||||
): void {
|
||||
if (branchNodeIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!ctx.parallelBlockMapping) {
|
||||
ctx.parallelBlockMapping = new Map()
|
||||
}
|
||||
|
||||
for (const nodeId of branchNodeIds) {
|
||||
const node = this.dag.nodes.get(nodeId)
|
||||
const branchIndex = node?.metadata.branchIndex ?? extractBranchIndex(nodeId)
|
||||
if (branchIndex === null || branchIndex === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
ctx.parallelBlockMapping.set(nodeId, {
|
||||
originalBlockId: node?.metadata.originalBlockId ?? extractBaseBlockId(nodeId),
|
||||
parallelId,
|
||||
iterationIndex: branchIndex,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
extractBranchMetadata(nodeId: string): ParallelBranchMetadata | null {
|
||||
const node = this.dag.nodes.get(nodeId)
|
||||
if (!node?.metadata.isParallelBranch) {
|
||||
return null
|
||||
}
|
||||
|
||||
const branchIndex = node.metadata.branchIndex ?? extractBranchIndex(nodeId)
|
||||
if (branchIndex === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parallelId =
|
||||
node.metadata.subflowType === 'parallel' ? node.metadata.subflowId : undefined
|
||||
if (!parallelId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
branchIndex,
|
||||
branchTotal: node.metadata.branchTotal ?? 1,
|
||||
distributionItem: node.metadata.distributionItem,
|
||||
parallelId,
|
||||
}
|
||||
}
|
||||
|
||||
getParallelScope(ctx: ExecutionContext, parallelId: string): ParallelScope | undefined {
|
||||
return ctx.parallelExecutions?.get(parallelId)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user