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,270 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import { DAGBuilder } from '@/executor/dag/builder'
|
||||
import {
|
||||
buildBranchNodeId,
|
||||
buildParallelSentinelEndId,
|
||||
buildParallelSentinelStartId,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
function createBlock(id: string, metadataId: string): SerializedBlock {
|
||||
return {
|
||||
id,
|
||||
position: { x: 0, y: 0 },
|
||||
config: {
|
||||
tool: 'noop',
|
||||
params: {},
|
||||
},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: {
|
||||
id: metadataId,
|
||||
name: id,
|
||||
},
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
describe('DAGBuilder disabled subflow validation', () => {
|
||||
it('skips validation for disabled loops with no blocks inside', () => {
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
{ ...createBlock('loop-block', BlockType.FUNCTION), enabled: false },
|
||||
],
|
||||
connections: [],
|
||||
loops: {
|
||||
'loop-1': {
|
||||
id: 'loop-1',
|
||||
nodes: [], // Empty loop - would normally throw
|
||||
iterations: 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const builder = new DAGBuilder()
|
||||
// Should not throw even though loop has no blocks inside
|
||||
expect(() => builder.build(workflow)).not.toThrow()
|
||||
})
|
||||
|
||||
it('skips validation for disabled parallels with no blocks inside', () => {
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [createBlock('start', BlockType.STARTER)],
|
||||
connections: [],
|
||||
loops: {},
|
||||
parallels: {
|
||||
'parallel-1': {
|
||||
id: 'parallel-1',
|
||||
nodes: [], // Empty parallel - would normally throw
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const builder = new DAGBuilder()
|
||||
// Should not throw even though parallel has no blocks inside
|
||||
expect(() => builder.build(workflow)).not.toThrow()
|
||||
})
|
||||
|
||||
it('skips validation for loops where all inner blocks are disabled', () => {
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
{ ...createBlock('inner-block', BlockType.FUNCTION), enabled: false },
|
||||
],
|
||||
connections: [],
|
||||
loops: {
|
||||
'loop-1': {
|
||||
id: 'loop-1',
|
||||
nodes: ['inner-block'], // Has node but it's disabled
|
||||
iterations: 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const builder = new DAGBuilder()
|
||||
// Should not throw - loop is effectively disabled since all inner blocks are disabled
|
||||
expect(() => builder.build(workflow)).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not mutate serialized loop config nodes during DAG build', () => {
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
createBlock('loop-1', BlockType.LOOP),
|
||||
{ ...createBlock('inner-block', BlockType.FUNCTION), enabled: false },
|
||||
],
|
||||
connections: [{ source: 'start', target: 'loop-1' }],
|
||||
loops: {
|
||||
'loop-1': {
|
||||
id: 'loop-1',
|
||||
nodes: ['inner-block'],
|
||||
iterations: 3,
|
||||
},
|
||||
},
|
||||
parallels: {},
|
||||
}
|
||||
|
||||
const builder = new DAGBuilder()
|
||||
builder.build(workflow)
|
||||
|
||||
expect(workflow.loops?.['loop-1']?.nodes).toEqual(['inner-block'])
|
||||
})
|
||||
|
||||
it('does not mutate serialized parallel config nodes during DAG build', () => {
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
createBlock('parallel-1', BlockType.PARALLEL),
|
||||
{ ...createBlock('inner-block', BlockType.FUNCTION), enabled: false },
|
||||
],
|
||||
connections: [{ source: 'start', target: 'parallel-1' }],
|
||||
loops: {},
|
||||
parallels: {
|
||||
'parallel-1': {
|
||||
id: 'parallel-1',
|
||||
nodes: ['inner-block'],
|
||||
count: 2,
|
||||
parallelType: 'count',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const builder = new DAGBuilder()
|
||||
builder.build(workflow)
|
||||
|
||||
expect(workflow.parallels?.['parallel-1']?.nodes).toEqual(['inner-block'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('DAGBuilder nested parallel support', () => {
|
||||
it('builds DAG for parallel-in-parallel with correct sentinel wiring', () => {
|
||||
const outerParallelId = 'outer-parallel'
|
||||
const innerParallelId = 'inner-parallel'
|
||||
const functionId = 'func-1'
|
||||
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
createBlock(outerParallelId, BlockType.PARALLEL),
|
||||
createBlock(innerParallelId, BlockType.PARALLEL),
|
||||
createBlock(functionId, BlockType.FUNCTION),
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: outerParallelId },
|
||||
{
|
||||
source: outerParallelId,
|
||||
target: innerParallelId,
|
||||
sourceHandle: 'parallel-start-source',
|
||||
},
|
||||
{
|
||||
source: innerParallelId,
|
||||
target: functionId,
|
||||
sourceHandle: 'parallel-start-source',
|
||||
},
|
||||
],
|
||||
loops: {},
|
||||
parallels: {
|
||||
[innerParallelId]: {
|
||||
id: innerParallelId,
|
||||
nodes: [functionId],
|
||||
count: 5,
|
||||
parallelType: 'count',
|
||||
},
|
||||
[outerParallelId]: {
|
||||
id: outerParallelId,
|
||||
nodes: [innerParallelId],
|
||||
count: 5,
|
||||
parallelType: 'count',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const builder = new DAGBuilder()
|
||||
const dag = builder.build(workflow)
|
||||
|
||||
// Outer parallel sentinel pair exists
|
||||
const outerStartId = buildParallelSentinelStartId(outerParallelId)
|
||||
const outerEndId = buildParallelSentinelEndId(outerParallelId)
|
||||
expect(dag.nodes.has(outerStartId)).toBe(true)
|
||||
expect(dag.nodes.has(outerEndId)).toBe(true)
|
||||
|
||||
// Inner parallel sentinel pair exists
|
||||
const innerStartId = buildParallelSentinelStartId(innerParallelId)
|
||||
const innerEndId = buildParallelSentinelEndId(innerParallelId)
|
||||
expect(dag.nodes.has(innerStartId)).toBe(true)
|
||||
expect(dag.nodes.has(innerEndId)).toBe(true)
|
||||
|
||||
// Function 1 branch template node exists
|
||||
const funcTemplateId = buildBranchNodeId(functionId, 0)
|
||||
expect(dag.nodes.has(funcTemplateId)).toBe(true)
|
||||
|
||||
// Start → outer-sentinel-start
|
||||
const startNode = dag.nodes.get('start')!
|
||||
const startTargets = Array.from(startNode.outgoingEdges.values()).map((e) => e.target)
|
||||
expect(startTargets).toContain(outerStartId)
|
||||
|
||||
// Outer-sentinel-start → inner-sentinel-start
|
||||
const outerStart = dag.nodes.get(outerStartId)!
|
||||
const outerStartTargets = Array.from(outerStart.outgoingEdges.values()).map((e) => e.target)
|
||||
expect(outerStartTargets).toContain(innerStartId)
|
||||
|
||||
// Inner-sentinel-start → function branch template
|
||||
const innerStart = dag.nodes.get(innerStartId)!
|
||||
const innerStartTargets = Array.from(innerStart.outgoingEdges.values()).map((e) => e.target)
|
||||
expect(innerStartTargets).toContain(funcTemplateId)
|
||||
|
||||
// Function branch template → inner-sentinel-end
|
||||
const funcTemplate = dag.nodes.get(funcTemplateId)!
|
||||
const funcTargets = Array.from(funcTemplate.outgoingEdges.values()).map((e) => e.target)
|
||||
expect(funcTargets).toContain(innerEndId)
|
||||
|
||||
// Inner-sentinel-end → outer-sentinel-end
|
||||
const innerEnd = dag.nodes.get(innerEndId)!
|
||||
const innerEndTargets = Array.from(innerEnd.outgoingEdges.values()).map((e) => e.target)
|
||||
expect(innerEndTargets).toContain(outerEndId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DAGBuilder human-in-the-loop transformation', () => {
|
||||
it('creates trigger nodes and rewires edges for pause blocks', () => {
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
createBlock('pause', BlockType.HUMAN_IN_THE_LOOP),
|
||||
createBlock('finish', BlockType.FUNCTION),
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: 'pause' },
|
||||
{ source: 'pause', target: 'finish' },
|
||||
],
|
||||
loops: {},
|
||||
}
|
||||
|
||||
const builder = new DAGBuilder()
|
||||
const dag = builder.build(workflow)
|
||||
|
||||
const pauseNode = dag.nodes.get('pause')
|
||||
expect(pauseNode).toBeDefined()
|
||||
expect(pauseNode?.metadata.isPauseResponse).toBe(true)
|
||||
|
||||
const startNode = dag.nodes.get('start')!
|
||||
const startOutgoing = Array.from(startNode.outgoingEdges.values())
|
||||
expect(startOutgoing).toHaveLength(1)
|
||||
expect(startOutgoing[0].target).toBe('pause')
|
||||
|
||||
const pauseOutgoing = Array.from(pauseNode!.outgoingEdges.values())
|
||||
expect(pauseOutgoing).toHaveLength(1)
|
||||
expect(pauseOutgoing[0].target).toBe('finish')
|
||||
|
||||
const triggerNode = dag.nodes.get('pause__trigger')
|
||||
expect(triggerNode).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { EdgeConstructor } from '@/executor/dag/construction/edges'
|
||||
import { LoopConstructor } from '@/executor/dag/construction/loops'
|
||||
import { NodeConstructor } from '@/executor/dag/construction/nodes'
|
||||
import { ParallelConstructor } from '@/executor/dag/construction/parallels'
|
||||
import { PathConstructor } from '@/executor/dag/construction/paths'
|
||||
import type { DAGEdge, NodeMetadata } from '@/executor/dag/types'
|
||||
import {
|
||||
buildParallelSentinelStartId,
|
||||
buildSentinelStartId,
|
||||
normalizeNodeId,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
import type {
|
||||
SerializedBlock,
|
||||
SerializedLoop,
|
||||
SerializedParallel,
|
||||
SerializedWorkflow,
|
||||
} from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('DAGBuilder')
|
||||
|
||||
export interface DAGNode {
|
||||
id: string
|
||||
block: SerializedBlock
|
||||
incomingEdges: Set<string>
|
||||
outgoingEdges: Map<string, DAGEdge>
|
||||
metadata: NodeMetadata
|
||||
}
|
||||
|
||||
export interface DAG {
|
||||
nodes: Map<string, DAGNode>
|
||||
loopConfigs: Map<string, SerializedLoop>
|
||||
parallelConfigs: Map<string, SerializedParallel>
|
||||
}
|
||||
|
||||
export interface DAGBuildOptions {
|
||||
/** Trigger block ID to start path construction from */
|
||||
triggerBlockId?: string
|
||||
/** Saved incoming edges from snapshot for resumption */
|
||||
savedIncomingEdges?: Record<string, string[]>
|
||||
/** Include all enabled blocks instead of only those reachable from trigger */
|
||||
includeAllBlocks?: boolean
|
||||
}
|
||||
|
||||
export class DAGBuilder {
|
||||
private pathConstructor = new PathConstructor()
|
||||
private loopConstructor = new LoopConstructor()
|
||||
private parallelConstructor = new ParallelConstructor()
|
||||
private nodeConstructor = new NodeConstructor()
|
||||
private edgeConstructor = new EdgeConstructor()
|
||||
|
||||
build(workflow: SerializedWorkflow, options: DAGBuildOptions = {}): DAG {
|
||||
const { triggerBlockId, savedIncomingEdges, includeAllBlocks } = options
|
||||
|
||||
const dag: DAG = {
|
||||
nodes: new Map(),
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
|
||||
this.initializeConfigs(workflow, dag)
|
||||
|
||||
const reachableBlocks = this.pathConstructor.execute(workflow, triggerBlockId, includeAllBlocks)
|
||||
|
||||
this.loopConstructor.execute(dag, reachableBlocks)
|
||||
this.parallelConstructor.execute(dag, reachableBlocks)
|
||||
|
||||
const { blocksInLoops, blocksInParallels, pauseTriggerMapping } = this.nodeConstructor.execute(
|
||||
workflow,
|
||||
dag,
|
||||
reachableBlocks
|
||||
)
|
||||
|
||||
this.edgeConstructor.execute(
|
||||
workflow,
|
||||
dag,
|
||||
blocksInParallels,
|
||||
blocksInLoops,
|
||||
reachableBlocks,
|
||||
pauseTriggerMapping
|
||||
)
|
||||
|
||||
if (savedIncomingEdges) {
|
||||
logger.info('Restoring DAG incoming edges from snapshot', {
|
||||
nodeCount: Object.keys(savedIncomingEdges).length,
|
||||
})
|
||||
|
||||
for (const [nodeId, incomingEdgeArray] of Object.entries(savedIncomingEdges)) {
|
||||
const node = dag.nodes.get(nodeId)
|
||||
|
||||
if (node) {
|
||||
node.incomingEdges = new Set(incomingEdgeArray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate loop and parallel structure
|
||||
this.validateSubflowStructure(dag)
|
||||
|
||||
logger.info('DAG built', {
|
||||
totalNodes: dag.nodes.size,
|
||||
loopCount: dag.loopConfigs.size,
|
||||
parallelCount: dag.parallelConfigs.size,
|
||||
allNodeIds: Array.from(dag.nodes.keys()),
|
||||
triggerNodes: Array.from(dag.nodes.values())
|
||||
.filter((n) => n.metadata?.isResumeTrigger)
|
||||
.map((n) => ({ id: n.id, originalBlockId: n.metadata?.originalBlockId })),
|
||||
})
|
||||
|
||||
return dag
|
||||
}
|
||||
|
||||
private initializeConfigs(workflow: SerializedWorkflow, dag: DAG): void {
|
||||
if (workflow.loops) {
|
||||
for (const [loopId, loopConfig] of Object.entries(workflow.loops)) {
|
||||
dag.loopConfigs.set(loopId, {
|
||||
...loopConfig,
|
||||
nodes: [...(loopConfig.nodes ?? [])],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (workflow.parallels) {
|
||||
for (const [parallelId, parallelConfig] of Object.entries(workflow.parallels)) {
|
||||
dag.parallelConfigs.set(parallelId, {
|
||||
...parallelConfig,
|
||||
nodes: [...(parallelConfig.nodes ?? [])],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that loops and parallels have proper internal structure.
|
||||
* Throws an error if a loop/parallel has no blocks inside or no connections from start.
|
||||
*/
|
||||
private validateSubflowStructure(dag: DAG): void {
|
||||
for (const [id, config] of dag.loopConfigs) {
|
||||
this.validateSubflow(dag, id, config.nodes, 'Loop')
|
||||
}
|
||||
for (const [id, config] of dag.parallelConfigs) {
|
||||
this.validateSubflow(dag, id, config.nodes, 'Parallel')
|
||||
}
|
||||
}
|
||||
|
||||
private validateSubflow(
|
||||
dag: DAG,
|
||||
id: string,
|
||||
nodes: string[] | undefined,
|
||||
type: 'Loop' | 'Parallel'
|
||||
): void {
|
||||
const sentinelStartId =
|
||||
type === 'Loop' ? buildSentinelStartId(id) : buildParallelSentinelStartId(id)
|
||||
const sentinelStartNode = dag.nodes.get(sentinelStartId)
|
||||
|
||||
if (!sentinelStartNode) return
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasConnections = Array.from(sentinelStartNode.outgoingEdges.values()).some((edge) =>
|
||||
nodes.includes(normalizeNodeId(edge.target))
|
||||
)
|
||||
|
||||
if (!hasConnections) {
|
||||
throw new Error(
|
||||
`${type} start is not connected to any blocks. Connect a block to the ${type.toLowerCase()} start.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,784 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import {
|
||||
CONTROL_BACK_EDGE_HANDLES,
|
||||
EDGE,
|
||||
isConditionBlockType,
|
||||
isRouterBlockType,
|
||||
isRouterV2BlockType,
|
||||
} from '@/executor/constants'
|
||||
import type { DAG, DAGNode } from '@/executor/dag/builder'
|
||||
import {
|
||||
buildBranchNodeId,
|
||||
buildParallelSentinelEndId,
|
||||
buildParallelSentinelStartId,
|
||||
buildSentinelEndId,
|
||||
buildSentinelStartId,
|
||||
normalizeNodeId,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
import type { SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('EdgeConstructor')
|
||||
|
||||
interface ConditionConfig {
|
||||
id: string
|
||||
label?: string
|
||||
condition: string
|
||||
}
|
||||
|
||||
interface RouterV2RouteConfig {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface EdgeMetadata {
|
||||
blockTypeMap: Map<string, string>
|
||||
conditionConfigMap: Map<string, ConditionConfig[]>
|
||||
routerBlockIds: Set<string>
|
||||
routerV2ConfigMap: Map<string, RouterV2RouteConfig[]>
|
||||
}
|
||||
|
||||
export class EdgeConstructor {
|
||||
execute(
|
||||
workflow: SerializedWorkflow,
|
||||
dag: DAG,
|
||||
blocksInParallels: Set<string>,
|
||||
blocksInLoops: Set<string>,
|
||||
reachableBlocks: Set<string>,
|
||||
pauseTriggerMapping: Map<string, string>
|
||||
): void {
|
||||
const loopBlockIds = new Set(dag.loopConfigs.keys())
|
||||
const parallelBlockIds = new Set(dag.parallelConfigs.keys())
|
||||
const metadata = this.buildMetadataMaps(workflow)
|
||||
|
||||
this.wireRegularEdges(
|
||||
workflow,
|
||||
dag,
|
||||
blocksInParallels,
|
||||
blocksInLoops,
|
||||
reachableBlocks,
|
||||
loopBlockIds,
|
||||
parallelBlockIds,
|
||||
metadata,
|
||||
pauseTriggerMapping
|
||||
)
|
||||
|
||||
this.wireLoopSentinels(dag)
|
||||
this.wireParallelSentinels(dag)
|
||||
}
|
||||
|
||||
private buildMetadataMaps(workflow: SerializedWorkflow): EdgeMetadata {
|
||||
const blockTypeMap = new Map<string, string>()
|
||||
const conditionConfigMap = new Map<string, ConditionConfig[]>()
|
||||
const routerBlockIds = new Set<string>()
|
||||
const routerV2ConfigMap = new Map<string, RouterV2RouteConfig[]>()
|
||||
|
||||
for (const block of workflow.blocks) {
|
||||
const blockType = block.metadata?.id ?? ''
|
||||
blockTypeMap.set(block.id, blockType)
|
||||
|
||||
if (isConditionBlockType(blockType)) {
|
||||
const conditions = this.parseConditionConfig(block)
|
||||
|
||||
if (conditions) {
|
||||
conditionConfigMap.set(block.id, conditions)
|
||||
}
|
||||
} else if (isRouterV2BlockType(blockType)) {
|
||||
// Router V2 uses port-based routing with route configs
|
||||
const routes = this.parseRouterV2Config(block)
|
||||
if (routes) {
|
||||
routerV2ConfigMap.set(block.id, routes)
|
||||
}
|
||||
} else if (isRouterBlockType(blockType)) {
|
||||
// Legacy router uses target block IDs
|
||||
routerBlockIds.add(block.id)
|
||||
}
|
||||
}
|
||||
|
||||
return { blockTypeMap, conditionConfigMap, routerBlockIds, routerV2ConfigMap }
|
||||
}
|
||||
|
||||
private parseConditionConfig(block: any): ConditionConfig[] | null {
|
||||
try {
|
||||
const conditionsJson = block.config.params?.conditions
|
||||
|
||||
if (typeof conditionsJson === 'string') {
|
||||
return JSON.parse(conditionsJson)
|
||||
}
|
||||
|
||||
if (Array.isArray(conditionsJson)) {
|
||||
return conditionsJson
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse condition config', {
|
||||
blockId: block.id,
|
||||
error: toError(error).message,
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private parseRouterV2Config(block: any): RouterV2RouteConfig[] | null {
|
||||
try {
|
||||
const routesJson = block.config.params?.routes
|
||||
|
||||
if (typeof routesJson === 'string') {
|
||||
return JSON.parse(routesJson)
|
||||
}
|
||||
|
||||
if (Array.isArray(routesJson)) {
|
||||
return routesJson
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse router v2 config', {
|
||||
blockId: block.id,
|
||||
error: toError(error).message,
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private generateSourceHandle(
|
||||
source: string,
|
||||
target: string,
|
||||
sourceHandle: string | undefined,
|
||||
metadata: EdgeMetadata,
|
||||
workflow: SerializedWorkflow
|
||||
): string | undefined {
|
||||
let handle = sourceHandle
|
||||
|
||||
if (!handle && isConditionBlockType(metadata.blockTypeMap.get(source) ?? '')) {
|
||||
const conditions = metadata.conditionConfigMap.get(source)
|
||||
|
||||
if (conditions && conditions.length > 0) {
|
||||
const edgesFromCondition = workflow.connections.filter((c) => c.source === source)
|
||||
const edgeIndex = edgesFromCondition.findIndex((e) => e.target === target)
|
||||
|
||||
if (edgeIndex >= 0 && edgeIndex < conditions.length) {
|
||||
const correspondingCondition = conditions[edgeIndex]
|
||||
handle = `${EDGE.CONDITION_PREFIX}${correspondingCondition.id}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Router V2 uses port-based routing - handle is already set from UI (router-{routeId})
|
||||
// We don't modify it here, just validate it exists
|
||||
if (metadata.routerV2ConfigMap.has(source)) {
|
||||
// For router_v2, the sourceHandle should already be set from the UI
|
||||
// If not set and not an error handle, generate based on route index
|
||||
if (!handle || (!handle.startsWith(EDGE.ROUTER_PREFIX) && handle !== EDGE.ERROR)) {
|
||||
const routes = metadata.routerV2ConfigMap.get(source)
|
||||
if (routes && routes.length > 0) {
|
||||
const edgesFromRouter = workflow.connections.filter((c) => c.source === source)
|
||||
const edgeIndex = edgesFromRouter.findIndex((e) => e.target === target)
|
||||
|
||||
if (edgeIndex >= 0 && edgeIndex < routes.length) {
|
||||
const correspondingRoute = routes[edgeIndex]
|
||||
handle = `${EDGE.ROUTER_PREFIX}${correspondingRoute.id}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy router uses target block ID
|
||||
if (metadata.routerBlockIds.has(source) && handle !== EDGE.ERROR) {
|
||||
handle = `${EDGE.ROUTER_PREFIX}${target}`
|
||||
}
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
private wireRegularEdges(
|
||||
workflow: SerializedWorkflow,
|
||||
dag: DAG,
|
||||
blocksInParallels: Set<string>,
|
||||
blocksInLoops: Set<string>,
|
||||
reachableBlocks: Set<string>,
|
||||
loopBlockIds: Set<string>,
|
||||
parallelBlockIds: Set<string>,
|
||||
metadata: EdgeMetadata,
|
||||
pauseTriggerMapping: Map<string, string>
|
||||
): void {
|
||||
for (const connection of workflow.connections) {
|
||||
let { source, target } = connection
|
||||
const originalSource = source
|
||||
const originalTarget = target
|
||||
let sourceHandle = this.generateSourceHandle(
|
||||
source,
|
||||
target,
|
||||
connection.sourceHandle,
|
||||
metadata,
|
||||
workflow
|
||||
)
|
||||
const targetHandle = connection.targetHandle
|
||||
const sourceIsLoopBlock = loopBlockIds.has(source)
|
||||
const targetIsLoopBlock = loopBlockIds.has(target)
|
||||
const sourceIsParallelBlock = parallelBlockIds.has(source)
|
||||
const targetIsParallelBlock = parallelBlockIds.has(target)
|
||||
|
||||
if (this.edgeStaysWithinSameParallel(originalSource, originalTarget, dag)) {
|
||||
const sourceId = this.resolveSubflowToSentinelEnd(originalSource, dag)
|
||||
const targetId = this.resolveSubflowToSentinelStart(originalTarget, dag)
|
||||
const resolvedSourceHandle = this.resolveParallelChildSourceHandle(
|
||||
originalSource,
|
||||
dag,
|
||||
sourceHandle
|
||||
)
|
||||
this.addEdge(dag, sourceId, targetId, resolvedSourceHandle, targetHandle)
|
||||
this.addSubflowStartExitBypass(dag, originalSource)
|
||||
continue
|
||||
}
|
||||
|
||||
if (sourceIsLoopBlock) {
|
||||
const sentinelEndId = buildSentinelEndId(originalSource)
|
||||
const loopSentinelStartId = buildSentinelStartId(originalSource)
|
||||
if (!dag.nodes.has(sentinelEndId) || !dag.nodes.has(loopSentinelStartId)) {
|
||||
continue
|
||||
}
|
||||
source = sentinelEndId
|
||||
sourceHandle = EDGE.LOOP_EXIT
|
||||
this.addSubflowStartExitBypass(dag, originalSource)
|
||||
}
|
||||
|
||||
if (targetIsLoopBlock) {
|
||||
const sentinelStartId = buildSentinelStartId(target)
|
||||
if (!dag.nodes.has(sentinelStartId)) {
|
||||
continue
|
||||
}
|
||||
target = sentinelStartId
|
||||
}
|
||||
|
||||
if (sourceIsParallelBlock) {
|
||||
// Skip intra-parallel edges (start → child); handled by wireParallelSentinels
|
||||
const sourceParallelNodes = dag.parallelConfigs.get(originalSource)?.nodes
|
||||
if (sourceParallelNodes?.includes(originalTarget)) {
|
||||
continue
|
||||
}
|
||||
const sentinelEndId = buildParallelSentinelEndId(originalSource)
|
||||
if (!dag.nodes.has(sentinelEndId)) {
|
||||
continue
|
||||
}
|
||||
source = sentinelEndId
|
||||
sourceHandle = EDGE.PARALLEL_EXIT
|
||||
}
|
||||
|
||||
if (targetIsParallelBlock) {
|
||||
const sentinelStartId = buildParallelSentinelStartId(target)
|
||||
if (!dag.nodes.has(sentinelStartId)) {
|
||||
continue
|
||||
}
|
||||
target = sentinelStartId
|
||||
}
|
||||
|
||||
if (sourceIsParallelBlock) {
|
||||
this.addSubflowStartExitBypass(dag, originalSource)
|
||||
}
|
||||
|
||||
if (this.edgeCrossesLoopBoundary(originalSource, originalTarget, blocksInLoops, dag)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!this.isEdgeReachable(source, target, reachableBlocks, dag)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (blocksInParallels.has(source) && blocksInParallels.has(target)) {
|
||||
const sourceParallelId = this.getParallelId(source, dag)
|
||||
const targetParallelId = this.getParallelId(target, dag)
|
||||
|
||||
if (sourceParallelId === targetParallelId) {
|
||||
this.wireParallelTemplateEdge(source, target, dag, sourceHandle, targetHandle)
|
||||
} else {
|
||||
logger.warn('Edge between different parallels - invalid workflow', { source, target })
|
||||
}
|
||||
} else if (blocksInParallels.has(source) || blocksInParallels.has(target)) {
|
||||
// Skip - will be handled by sentinel wiring
|
||||
} else {
|
||||
const resolvedSource = pauseTriggerMapping.get(originalSource) ?? source
|
||||
this.addEdge(dag, resolvedSource, target, sourceHandle, targetHandle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private wireLoopSentinels(dag: DAG): void {
|
||||
for (const [loopId, loopConfig] of dag.loopConfigs) {
|
||||
const nodes = loopConfig.nodes
|
||||
|
||||
if (nodes.length === 0) continue
|
||||
|
||||
const sentinelStartId = buildSentinelStartId(loopId)
|
||||
const sentinelEndId = buildSentinelEndId(loopId)
|
||||
|
||||
if (!dag.nodes.has(sentinelStartId) || !dag.nodes.has(sentinelEndId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
this.addSubflowStartExitBypass(dag, loopId)
|
||||
|
||||
const { startNodes, terminalNodes } = this.findLoopBoundaryNodes(nodes, dag)
|
||||
|
||||
for (const startNodeId of startNodes) {
|
||||
const resolvedId = this.resolveLoopBlockToSentinelStart(startNodeId, dag)
|
||||
this.addEdge(dag, sentinelStartId, resolvedId)
|
||||
}
|
||||
|
||||
for (const terminalNodeId of terminalNodes) {
|
||||
const resolvedId = this.resolveLoopBlockToSentinelEnd(terminalNodeId, dag)
|
||||
if (resolvedId !== terminalNodeId) {
|
||||
// Use the sourceHandle that matches the nested subflow's exit route.
|
||||
// Parallel sentinel-end outputs selectedRoute "parallel_exit",
|
||||
// loop sentinel-end outputs "loop_exit". The edge manager only activates
|
||||
// edges whose sourceHandle matches the source node's selectedRoute.
|
||||
const handle = dag.parallelConfigs.has(terminalNodeId)
|
||||
? EDGE.PARALLEL_EXIT
|
||||
: EDGE.LOOP_EXIT
|
||||
this.addEdge(dag, resolvedId, sentinelEndId, handle)
|
||||
this.addSubflowStartExitBypass(dag, terminalNodeId)
|
||||
} else {
|
||||
this.addEdge(dag, resolvedId, sentinelEndId)
|
||||
}
|
||||
}
|
||||
|
||||
this.addEdge(dag, sentinelEndId, sentinelStartId, EDGE.LOOP_CONTINUE, undefined, {
|
||||
registerIncoming: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private wireParallelSentinels(dag: DAG): void {
|
||||
for (const [parallelId, parallelConfig] of dag.parallelConfigs) {
|
||||
const nodes = parallelConfig.nodes
|
||||
|
||||
if (nodes.length === 0) continue
|
||||
|
||||
const sentinelStartId = buildParallelSentinelStartId(parallelId)
|
||||
const sentinelEndId = buildParallelSentinelEndId(parallelId)
|
||||
|
||||
if (!dag.nodes.has(sentinelStartId) || !dag.nodes.has(sentinelEndId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
this.addSubflowStartExitBypass(dag, parallelId)
|
||||
|
||||
const { entryNodes, terminalNodes } = this.findParallelBoundaryNodes(nodes, dag)
|
||||
|
||||
for (const entryNodeId of entryNodes) {
|
||||
const targetId = this.resolveSubflowToSentinelStart(entryNodeId, dag)
|
||||
if (dag.nodes.has(targetId)) {
|
||||
this.addEdge(dag, sentinelStartId, targetId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const terminalNodeId of terminalNodes) {
|
||||
const sourceId = this.resolveSubflowToSentinelEnd(terminalNodeId, dag)
|
||||
if (dag.nodes.has(sourceId)) {
|
||||
const handle = this.resolveSubflowExitHandle(terminalNodeId, dag)
|
||||
this.addEdge(dag, sourceId, sentinelEndId, handle)
|
||||
if (handle) {
|
||||
this.addSubflowStartExitBypass(dag, terminalNodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.addEdge(dag, sentinelEndId, sentinelStartId, EDGE.PARALLEL_CONTINUE, undefined, {
|
||||
registerIncoming: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a node ID to the appropriate entry point for sentinel wiring.
|
||||
* Nested parallels → their sentinel-start, nested loops → their sentinel-start,
|
||||
* regular blocks → their branch template node.
|
||||
*/
|
||||
private resolveSubflowToSentinelStart(nodeId: string, dag: DAG): string {
|
||||
if (dag.parallelConfigs.has(nodeId)) {
|
||||
return buildParallelSentinelStartId(nodeId)
|
||||
}
|
||||
if (dag.loopConfigs.has(nodeId)) {
|
||||
return buildSentinelStartId(nodeId)
|
||||
}
|
||||
return buildBranchNodeId(nodeId, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a node ID to the appropriate exit point for sentinel wiring.
|
||||
* Nested parallels → their sentinel-end, nested loops → their sentinel-end,
|
||||
* regular blocks → their branch template node.
|
||||
*/
|
||||
private resolveSubflowToSentinelEnd(nodeId: string, dag: DAG): string {
|
||||
if (dag.parallelConfigs.has(nodeId)) {
|
||||
return buildParallelSentinelEndId(nodeId)
|
||||
}
|
||||
if (dag.loopConfigs.has(nodeId)) {
|
||||
return buildSentinelEndId(nodeId)
|
||||
}
|
||||
return buildBranchNodeId(nodeId, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an edge crosses a loop boundary (source and target are in
|
||||
* different loops, or one is inside a loop and the other is not). Uses the
|
||||
* original block IDs (pre-sentinel-remapping) because `blocksInLoops` and
|
||||
* `loopConfigs.nodes` reference original block IDs from the serialized workflow.
|
||||
*/
|
||||
private edgeCrossesLoopBoundary(
|
||||
source: string,
|
||||
target: string,
|
||||
blocksInLoops: Set<string>,
|
||||
dag: DAG
|
||||
): boolean {
|
||||
const sourceInLoop = blocksInLoops.has(source)
|
||||
const targetInLoop = blocksInLoops.has(target)
|
||||
|
||||
if (sourceInLoop !== targetInLoop) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!sourceInLoop && !targetInLoop) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Find the innermost loop for each block. In nested loops a block appears
|
||||
// in multiple loop configs; we need the most deeply nested one.
|
||||
const sourceLoopId = this.findInnermostLoop(source, dag)
|
||||
const targetLoopId = this.findInnermostLoop(target, dag)
|
||||
|
||||
return sourceLoopId !== targetLoopId
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the innermost loop containing a block. When a block is in nested
|
||||
* loops (A contains B, both list the block), returns B (the one that
|
||||
* doesn't contain any other candidate loop).
|
||||
*/
|
||||
private findInnermostLoop(blockId: string, dag: DAG): string | undefined {
|
||||
const candidates: string[] = []
|
||||
for (const [loopId, loopConfig] of dag.loopConfigs) {
|
||||
if (loopConfig.nodes.includes(blockId)) {
|
||||
candidates.push(loopId)
|
||||
}
|
||||
}
|
||||
if (candidates.length <= 1) return candidates[0]
|
||||
|
||||
return candidates.find((candidateId) =>
|
||||
candidates.every((otherId) => {
|
||||
if (otherId === candidateId) return true
|
||||
const candidateConfig = dag.loopConfigs.get(candidateId)
|
||||
return !candidateConfig?.nodes.includes(otherId)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private isEdgeReachable(
|
||||
source: string,
|
||||
target: string,
|
||||
reachableBlocks: Set<string>,
|
||||
dag: DAG
|
||||
): boolean {
|
||||
if (!reachableBlocks.has(source) && !dag.nodes.has(source)) {
|
||||
return false
|
||||
}
|
||||
if (!reachableBlocks.has(target) && !dag.nodes.has(target)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private wireParallelTemplateEdge(
|
||||
source: string,
|
||||
target: string,
|
||||
dag: DAG,
|
||||
sourceHandle?: string,
|
||||
targetHandle?: string
|
||||
): void {
|
||||
const sourceNodeId = buildBranchNodeId(source, 0)
|
||||
const targetNodeId = buildBranchNodeId(target, 0)
|
||||
this.addEdge(dag, sourceNodeId, targetNodeId, sourceHandle, targetHandle)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the DAG node to inspect for a given loop child.
|
||||
* If the child is a nested subflow (loop or parallel), returns its sentinel node;
|
||||
* otherwise returns the regular DAG node.
|
||||
*/
|
||||
private resolveLoopChildNode(
|
||||
nodeId: string,
|
||||
dag: DAG,
|
||||
sentinel: 'start' | 'end'
|
||||
): { resolvedId: string; node: DAGNode | undefined } {
|
||||
if (dag.loopConfigs.has(nodeId)) {
|
||||
const resolvedId =
|
||||
sentinel === 'start' ? buildSentinelStartId(nodeId) : buildSentinelEndId(nodeId)
|
||||
return { resolvedId, node: dag.nodes.get(resolvedId) }
|
||||
}
|
||||
if (dag.parallelConfigs.has(nodeId)) {
|
||||
const resolvedId =
|
||||
sentinel === 'start'
|
||||
? buildParallelSentinelStartId(nodeId)
|
||||
: buildParallelSentinelEndId(nodeId)
|
||||
return { resolvedId, node: dag.nodes.get(resolvedId) }
|
||||
}
|
||||
return { resolvedId: nodeId, node: dag.nodes.get(nodeId) }
|
||||
}
|
||||
|
||||
private resolveLoopBlockToSentinelStart(nodeId: string, dag: DAG): string {
|
||||
return this.resolveLoopChildNode(nodeId, dag, 'start').resolvedId
|
||||
}
|
||||
|
||||
private resolveLoopBlockToSentinelEnd(nodeId: string, dag: DAG): string {
|
||||
return this.resolveLoopChildNode(nodeId, dag, 'end').resolvedId
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the set of effective DAG node IDs for a loop's children,
|
||||
* mapping nested subflow block IDs (loops and parallels) to their sentinel IDs.
|
||||
*/
|
||||
private buildEffectiveNodeSet(nodes: string[], dag: DAG): Set<string> {
|
||||
const effective = new Set<string>()
|
||||
for (const nodeId of nodes) {
|
||||
if (dag.loopConfigs.has(nodeId)) {
|
||||
effective.add(buildSentinelStartId(nodeId))
|
||||
effective.add(buildSentinelEndId(nodeId))
|
||||
} else if (dag.parallelConfigs.has(nodeId)) {
|
||||
effective.add(buildParallelSentinelStartId(nodeId))
|
||||
effective.add(buildParallelSentinelEndId(nodeId))
|
||||
} else {
|
||||
effective.add(nodeId)
|
||||
}
|
||||
}
|
||||
return effective
|
||||
}
|
||||
|
||||
private findLoopBoundaryNodes(
|
||||
nodes: string[],
|
||||
dag: DAG
|
||||
): { startNodes: string[]; terminalNodes: string[] } {
|
||||
const effectiveNodeSet = this.buildEffectiveNodeSet(nodes, dag)
|
||||
const startNodesSet = new Set<string>()
|
||||
const terminalNodesSet = new Set<string>()
|
||||
|
||||
for (const nodeId of nodes) {
|
||||
const { node } = this.resolveLoopChildNode(nodeId, dag, 'start')
|
||||
|
||||
if (!node) continue
|
||||
|
||||
let hasIncomingFromLoop = false
|
||||
for (const incomingNodeId of node.incomingEdges) {
|
||||
if (effectiveNodeSet.has(incomingNodeId)) {
|
||||
hasIncomingFromLoop = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasIncomingFromLoop) {
|
||||
startNodesSet.add(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const nodeId of nodes) {
|
||||
const { node } = this.resolveLoopChildNode(nodeId, dag, 'end')
|
||||
|
||||
if (!node) continue
|
||||
|
||||
let hasOutgoingToLoop = false
|
||||
for (const [, edge] of node.outgoingEdges) {
|
||||
if (this.isControlBackEdge(edge.sourceHandle)) continue
|
||||
|
||||
if (effectiveNodeSet.has(edge.target)) {
|
||||
hasOutgoingToLoop = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasOutgoingToLoop) {
|
||||
terminalNodesSet.add(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startNodes: Array.from(startNodesSet),
|
||||
terminalNodes: Array.from(terminalNodesSet),
|
||||
}
|
||||
}
|
||||
|
||||
private findParallelBoundaryNodes(
|
||||
nodes: string[],
|
||||
dag: DAG
|
||||
): { entryNodes: string[]; terminalNodes: string[] } {
|
||||
const nodesSet = new Set(nodes)
|
||||
const entryNodes: string[] = []
|
||||
const terminalNodes: string[] = []
|
||||
|
||||
for (const nodeId of nodes) {
|
||||
// For nested subflow containers, use their sentinel nodes for boundary detection
|
||||
const { startNode, endNode } = this.resolveParallelChildNodes(nodeId, dag)
|
||||
|
||||
if (!startNode && !endNode) continue
|
||||
|
||||
// Entry detection: check if the start-facing node has incoming edges from within the parallel
|
||||
if (startNode) {
|
||||
let hasIncomingFromParallel = false
|
||||
for (const incomingNodeId of startNode.incomingEdges) {
|
||||
const originalNodeId = normalizeNodeId(incomingNodeId)
|
||||
if (nodesSet.has(originalNodeId)) {
|
||||
hasIncomingFromParallel = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!hasIncomingFromParallel) {
|
||||
entryNodes.push(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal detection: check if the end-facing node has outgoing edges to within the parallel
|
||||
if (endNode) {
|
||||
let hasOutgoingToParallel = false
|
||||
for (const [, edge] of endNode.outgoingEdges) {
|
||||
if (this.isControlBackEdge(edge.sourceHandle)) continue
|
||||
|
||||
const originalTargetId = normalizeNodeId(edge.target)
|
||||
if (nodesSet.has(originalTargetId)) {
|
||||
hasOutgoingToParallel = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!hasOutgoingToParallel) {
|
||||
terminalNodes.push(nodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { entryNodes, terminalNodes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a child node inside a parallel to the correct DAG nodes for boundary detection.
|
||||
* For regular blocks, returns the branch template node for both start and end.
|
||||
* For nested parallels, returns the inner parallel's sentinel-start and sentinel-end.
|
||||
* For nested loops, returns the inner loop's sentinel-start and sentinel-end.
|
||||
*/
|
||||
private resolveParallelChildNodes(
|
||||
nodeId: string,
|
||||
dag: DAG
|
||||
): { startNode: DAGNode | undefined; endNode: DAGNode | undefined } {
|
||||
if (dag.parallelConfigs.has(nodeId)) {
|
||||
return {
|
||||
startNode: dag.nodes.get(buildParallelSentinelStartId(nodeId)),
|
||||
endNode: dag.nodes.get(buildParallelSentinelEndId(nodeId)),
|
||||
}
|
||||
}
|
||||
if (dag.loopConfigs.has(nodeId)) {
|
||||
return {
|
||||
startNode: dag.nodes.get(buildSentinelStartId(nodeId)),
|
||||
endNode: dag.nodes.get(buildSentinelEndId(nodeId)),
|
||||
}
|
||||
}
|
||||
// Regular block — use branch template node for both
|
||||
const templateNode = dag.nodes.get(buildBranchNodeId(nodeId, 0))
|
||||
return { startNode: templateNode, endNode: templateNode }
|
||||
}
|
||||
|
||||
private isControlBackEdge(sourceHandle?: string): boolean {
|
||||
return sourceHandle !== undefined && CONTROL_BACK_EDGE_HANDLES.has(sourceHandle)
|
||||
}
|
||||
|
||||
private getParallelId(blockId: string, dag: DAG): string | null {
|
||||
for (const [parallelId, parallelConfig] of dag.parallelConfigs) {
|
||||
if (parallelConfig.nodes.includes(blockId)) {
|
||||
return parallelId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private edgeStaysWithinSameParallel(source: string, target: string, dag: DAG): boolean {
|
||||
const sourceParallelId = this.getParallelId(source, dag)
|
||||
const targetParallelId = this.getParallelId(target, dag)
|
||||
return !!sourceParallelId && sourceParallelId === targetParallelId
|
||||
}
|
||||
|
||||
private resolveParallelChildSourceHandle(
|
||||
source: string,
|
||||
dag: DAG,
|
||||
sourceHandle?: string
|
||||
): string | undefined {
|
||||
if (dag.parallelConfigs.has(source)) {
|
||||
return EDGE.PARALLEL_EXIT
|
||||
}
|
||||
if (dag.loopConfigs.has(source)) {
|
||||
return EDGE.LOOP_EXIT
|
||||
}
|
||||
return sourceHandle
|
||||
}
|
||||
|
||||
private resolveSubflowExitHandle(nodeId: string, dag: DAG): string | undefined {
|
||||
if (dag.parallelConfigs.has(nodeId)) {
|
||||
return EDGE.PARALLEL_EXIT
|
||||
}
|
||||
if (dag.loopConfigs.has(nodeId)) {
|
||||
return EDGE.LOOP_EXIT
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private addSubflowStartExitBypass(dag: DAG, subflowId: string): void {
|
||||
if (dag.parallelConfigs.has(subflowId)) {
|
||||
const sourceId = buildParallelSentinelStartId(subflowId)
|
||||
const targetId = buildParallelSentinelEndId(subflowId)
|
||||
if (dag.nodes.has(sourceId) && dag.nodes.has(targetId)) {
|
||||
this.addEdge(dag, sourceId, targetId, EDGE.PARALLEL_EXIT, undefined, {
|
||||
registerIncoming: false,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (dag.loopConfigs.has(subflowId)) {
|
||||
const sourceId = buildSentinelStartId(subflowId)
|
||||
const targetId = buildSentinelEndId(subflowId)
|
||||
if (dag.nodes.has(sourceId) && dag.nodes.has(targetId)) {
|
||||
this.addEdge(dag, sourceId, targetId, EDGE.LOOP_EXIT, undefined, {
|
||||
registerIncoming: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addEdge(
|
||||
dag: DAG,
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
sourceHandle?: string,
|
||||
targetHandle?: string,
|
||||
options: { registerIncoming?: boolean } = {}
|
||||
): void {
|
||||
const sourceNode = dag.nodes.get(sourceId)
|
||||
const targetNode = dag.nodes.get(targetId)
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
logger.warn('Edge references non-existent node', { sourceId, targetId })
|
||||
return
|
||||
}
|
||||
|
||||
const edgeId = `${sourceId}→${targetId}${sourceHandle ? `-${sourceHandle}` : ''}`
|
||||
|
||||
sourceNode.outgoingEdges.set(edgeId, {
|
||||
target: targetId,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
})
|
||||
|
||||
const { registerIncoming = true } = options
|
||||
if (registerIncoming) {
|
||||
targetNode.incomingEdges.add(sourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { BlockType, LOOP } from '@/executor/constants'
|
||||
import type { DAG } from '@/executor/dag/builder'
|
||||
import { createSubflowSentinelNode } from '@/executor/dag/construction/sentinels'
|
||||
import { buildSentinelEndId, buildSentinelStartId } from '@/executor/utils/subflow-utils'
|
||||
|
||||
export class LoopConstructor {
|
||||
execute(dag: DAG, reachableBlocks: Set<string>): void {
|
||||
for (const [loopId, loopConfig] of dag.loopConfigs) {
|
||||
if (!reachableBlocks.has(loopId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const loopNodes = loopConfig.nodes
|
||||
const hasReachableChildren = loopNodes.some((nodeId) => reachableBlocks.has(nodeId))
|
||||
|
||||
if (!hasReachableChildren) {
|
||||
loopConfig.nodes = []
|
||||
}
|
||||
|
||||
this.createSentinelPair(dag, loopId)
|
||||
}
|
||||
}
|
||||
|
||||
private createSentinelPair(dag: DAG, loopId: string): void {
|
||||
const startId = buildSentinelStartId(loopId)
|
||||
const endId = buildSentinelEndId(loopId)
|
||||
|
||||
dag.nodes.set(
|
||||
startId,
|
||||
createSubflowSentinelNode({
|
||||
id: startId,
|
||||
subflowId: loopId,
|
||||
subflowType: 'loop',
|
||||
sentinelType: LOOP.SENTINEL.START_TYPE,
|
||||
blockType: BlockType.SENTINEL_START,
|
||||
name: `${LOOP.SENTINEL.START_NAME_PREFIX} (${loopId})`,
|
||||
})
|
||||
)
|
||||
|
||||
dag.nodes.set(
|
||||
endId,
|
||||
createSubflowSentinelNode({
|
||||
id: endId,
|
||||
subflowId: loopId,
|
||||
subflowType: 'loop',
|
||||
sentinelType: LOOP.SENTINEL.END_TYPE,
|
||||
blockType: BlockType.SENTINEL_END,
|
||||
name: `${LOOP.SENTINEL.END_NAME_PREFIX} (${loopId})`,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import type { DAG } from '@/executor/dag/builder'
|
||||
import { NodeConstructor } from '@/executor/dag/construction/nodes'
|
||||
import type { SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
describe('NodeConstructor', () => {
|
||||
it('assigns nested loop nodes to the innermost loop metadata', () => {
|
||||
const dag: DAG = {
|
||||
nodes: new Map(),
|
||||
loopConfigs: new Map([
|
||||
['outer-loop', { id: 'outer-loop', nodes: ['inner-loop', 'task'], iterations: 1 }],
|
||||
['inner-loop', { id: 'inner-loop', nodes: ['task'], iterations: 1 }],
|
||||
]),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
{
|
||||
id: 'task',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: '', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Task' },
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
connections: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
}
|
||||
|
||||
new NodeConstructor().execute(workflow, dag, new Set(['task']))
|
||||
|
||||
expect(dag.nodes.get('task')?.metadata).toMatchObject({
|
||||
isLoopNode: true,
|
||||
subflowId: 'inner-loop',
|
||||
subflowType: 'loop',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import { BlockType, isMetadataOnlyBlockType } from '@/executor/constants'
|
||||
import type { DAG } from '@/executor/dag/builder'
|
||||
import { buildBranchNodeId } from '@/executor/utils/subflow-utils'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
export class NodeConstructor {
|
||||
execute(
|
||||
workflow: SerializedWorkflow,
|
||||
dag: DAG,
|
||||
reachableBlocks: Set<string>
|
||||
): {
|
||||
blocksInLoops: Set<string>
|
||||
blocksInParallels: Set<string>
|
||||
pauseTriggerMapping: Map<string, string>
|
||||
} {
|
||||
const blocksInLoops = new Set<string>()
|
||||
const blocksInParallels = new Set<string>()
|
||||
const pauseTriggerMapping = new Map<string, string>()
|
||||
|
||||
this.categorizeBlocks(dag, reachableBlocks, blocksInLoops, blocksInParallels)
|
||||
|
||||
for (const block of workflow.blocks) {
|
||||
if (!this.shouldProcessBlock(block, reachableBlocks)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parallelId = this.findParallelForBlock(block.id, dag)
|
||||
|
||||
if (parallelId) {
|
||||
this.createParallelTemplateNode(block, parallelId, dag)
|
||||
} else {
|
||||
this.createRegularOrLoopNode(block, blocksInLoops, dag)
|
||||
}
|
||||
}
|
||||
|
||||
return { blocksInLoops, blocksInParallels, pauseTriggerMapping }
|
||||
}
|
||||
|
||||
private shouldProcessBlock(block: SerializedBlock, reachableBlocks: Set<string>): boolean {
|
||||
if (!block.enabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!reachableBlocks.has(block.id)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isMetadataOnlyBlockType(block.metadata?.id)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private categorizeBlocks(
|
||||
dag: DAG,
|
||||
reachableBlocks: Set<string>,
|
||||
blocksInLoops: Set<string>,
|
||||
blocksInParallels: Set<string>
|
||||
): void {
|
||||
this.categorizeLoopBlocks(dag, reachableBlocks, blocksInLoops)
|
||||
this.categorizeParallelBlocks(dag, reachableBlocks, blocksInParallels)
|
||||
}
|
||||
|
||||
private categorizeLoopBlocks(
|
||||
dag: DAG,
|
||||
reachableBlocks: Set<string>,
|
||||
blocksInLoops: Set<string>
|
||||
): void {
|
||||
for (const [, loopConfig] of dag.loopConfigs) {
|
||||
for (const nodeId of loopConfig.nodes) {
|
||||
if (reachableBlocks.has(nodeId)) {
|
||||
blocksInLoops.add(nodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private categorizeParallelBlocks(
|
||||
dag: DAG,
|
||||
reachableBlocks: Set<string>,
|
||||
blocksInParallels: Set<string>
|
||||
): void {
|
||||
for (const [, parallelConfig] of dag.parallelConfigs) {
|
||||
for (const nodeId of parallelConfig.nodes) {
|
||||
if (reachableBlocks.has(nodeId)) {
|
||||
blocksInParallels.add(nodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createParallelTemplateNode(block: SerializedBlock, parallelId: string, dag: DAG): void {
|
||||
const templateNodeId = buildBranchNodeId(block.id, 0)
|
||||
const blockClone: SerializedBlock = {
|
||||
...block,
|
||||
id: templateNodeId,
|
||||
}
|
||||
|
||||
dag.nodes.set(templateNodeId, {
|
||||
id: templateNodeId,
|
||||
block: blockClone,
|
||||
incomingEdges: new Set(),
|
||||
outgoingEdges: new Map(),
|
||||
metadata: {
|
||||
isParallelBranch: true,
|
||||
subflowId: parallelId,
|
||||
subflowType: 'parallel',
|
||||
branchIndex: 0,
|
||||
branchTotal: 1,
|
||||
isPauseResponse: block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP,
|
||||
originalBlockId: block.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private createRegularOrLoopNode(
|
||||
block: SerializedBlock,
|
||||
blocksInLoops: Set<string>,
|
||||
dag: DAG
|
||||
): void {
|
||||
const isLoopNode = blocksInLoops.has(block.id)
|
||||
const loopId = isLoopNode ? this.findLoopIdForBlock(block.id, dag) : undefined
|
||||
const isPauseBlock = block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP
|
||||
|
||||
dag.nodes.set(block.id, {
|
||||
id: block.id,
|
||||
block,
|
||||
incomingEdges: new Set(),
|
||||
outgoingEdges: new Map(),
|
||||
metadata: {
|
||||
isLoopNode,
|
||||
...(loopId && { subflowId: loopId, subflowType: 'loop' as const }),
|
||||
isPauseResponse: isPauseBlock,
|
||||
originalBlockId: block.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private findLoopIdForBlock(blockId: string, dag: DAG): string | undefined {
|
||||
const candidates: string[] = []
|
||||
for (const [loopId, loopConfig] of dag.loopConfigs) {
|
||||
if (loopConfig.nodes.includes(blockId)) {
|
||||
candidates.push(loopId)
|
||||
}
|
||||
}
|
||||
if (candidates.length <= 1) return candidates[0]
|
||||
|
||||
return candidates.find((candidateId) =>
|
||||
candidates.every((otherId) => {
|
||||
if (otherId === candidateId) return true
|
||||
const candidateConfig = dag.loopConfigs.get(candidateId)
|
||||
return !candidateConfig?.nodes.includes(otherId)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private findParallelForBlock(blockId: string, dag: DAG): string | null {
|
||||
for (const [parallelId, parallelConfig] of dag.parallelConfigs) {
|
||||
if (parallelConfig.nodes.includes(blockId)) {
|
||||
return parallelId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BlockType, PARALLEL } from '@/executor/constants'
|
||||
import type { DAG } from '@/executor/dag/builder'
|
||||
import { createSubflowSentinelNode } from '@/executor/dag/construction/sentinels'
|
||||
import {
|
||||
buildParallelSentinelEndId,
|
||||
buildParallelSentinelStartId,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
|
||||
export class ParallelConstructor {
|
||||
execute(dag: DAG, reachableBlocks: Set<string>): void {
|
||||
for (const [parallelId, parallelConfig] of dag.parallelConfigs) {
|
||||
if (!reachableBlocks.has(parallelId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parallelNodes = parallelConfig.nodes
|
||||
const hasReachableChildren = parallelNodes.some((nodeId) => reachableBlocks.has(nodeId))
|
||||
|
||||
if (!hasReachableChildren) {
|
||||
parallelConfig.nodes = []
|
||||
}
|
||||
|
||||
this.createSentinelPair(dag, parallelId)
|
||||
}
|
||||
}
|
||||
|
||||
private createSentinelPair(dag: DAG, parallelId: string): void {
|
||||
const startId = buildParallelSentinelStartId(parallelId)
|
||||
const endId = buildParallelSentinelEndId(parallelId)
|
||||
|
||||
dag.nodes.set(
|
||||
startId,
|
||||
createSubflowSentinelNode({
|
||||
id: startId,
|
||||
subflowId: parallelId,
|
||||
subflowType: 'parallel',
|
||||
sentinelType: PARALLEL.SENTINEL.START_TYPE,
|
||||
blockType: BlockType.SENTINEL_START,
|
||||
name: `${PARALLEL.SENTINEL.START_NAME_PREFIX} (${parallelId})`,
|
||||
})
|
||||
)
|
||||
|
||||
dag.nodes.set(
|
||||
endId,
|
||||
createSubflowSentinelNode({
|
||||
id: endId,
|
||||
subflowId: parallelId,
|
||||
subflowType: 'parallel',
|
||||
sentinelType: PARALLEL.SENTINEL.END_TYPE,
|
||||
blockType: BlockType.SENTINEL_END,
|
||||
name: `${PARALLEL.SENTINEL.END_NAME_PREFIX} (${parallelId})`,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isMetadataOnlyBlockType, isTriggerBlockType } from '@/executor/constants'
|
||||
import { extractBaseBlockId } from '@/executor/utils/subflow-utils'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('PathConstructor')
|
||||
|
||||
export class PathConstructor {
|
||||
execute(
|
||||
workflow: SerializedWorkflow,
|
||||
triggerBlockId?: string,
|
||||
includeAllBlocks?: boolean
|
||||
): Set<string> {
|
||||
// For run-from-block mode, include all enabled blocks regardless of trigger reachability
|
||||
if (includeAllBlocks) {
|
||||
return this.getAllEnabledBlocks(workflow)
|
||||
}
|
||||
|
||||
const resolvedTriggerId = this.findTriggerBlock(workflow, triggerBlockId)
|
||||
|
||||
if (!resolvedTriggerId) {
|
||||
logger.warn('No trigger block found, including all enabled blocks as fallback')
|
||||
return this.getAllEnabledBlocks(workflow)
|
||||
}
|
||||
|
||||
const adjacency = this.buildAdjacencyMap(workflow)
|
||||
const reachable = this.performBFS(resolvedTriggerId, adjacency)
|
||||
|
||||
return reachable
|
||||
}
|
||||
|
||||
private findTriggerBlock(
|
||||
workflow: SerializedWorkflow,
|
||||
triggerBlockId?: string
|
||||
): string | undefined {
|
||||
if (triggerBlockId) {
|
||||
const block = workflow.blocks.find((b) => b.id === triggerBlockId)
|
||||
|
||||
if (block) {
|
||||
if (!block.enabled) {
|
||||
logger.error('Provided triggerBlockId is disabled, finding alternative', {
|
||||
triggerBlockId,
|
||||
blockEnabled: block.enabled,
|
||||
})
|
||||
// Try to find an alternative enabled trigger instead of failing
|
||||
const alternativeTrigger = this.findExplicitTrigger(workflow)
|
||||
if (alternativeTrigger) {
|
||||
logger.info('Using alternative enabled trigger', {
|
||||
disabledTriggerId: triggerBlockId,
|
||||
alternativeTriggerId: alternativeTrigger,
|
||||
})
|
||||
return alternativeTrigger
|
||||
}
|
||||
throw new Error(
|
||||
`Trigger block ${triggerBlockId} is disabled and no alternative enabled trigger found`
|
||||
)
|
||||
}
|
||||
return triggerBlockId
|
||||
}
|
||||
|
||||
const fallbackTriggerId = this.resolveResumeTriggerFallback(triggerBlockId, workflow)
|
||||
|
||||
if (fallbackTriggerId) {
|
||||
return fallbackTriggerId
|
||||
}
|
||||
|
||||
logger.error('Provided triggerBlockId not found in workflow', {
|
||||
triggerBlockId,
|
||||
availableBlocks: workflow.blocks.map((b) => ({ id: b.id, type: b.metadata?.id })),
|
||||
})
|
||||
|
||||
throw new Error(`Trigger block not found: ${triggerBlockId}`)
|
||||
}
|
||||
|
||||
const explicitTrigger = this.findExplicitTrigger(workflow)
|
||||
|
||||
if (explicitTrigger) {
|
||||
return explicitTrigger
|
||||
}
|
||||
|
||||
const rootBlock = this.findRootBlock(workflow)
|
||||
|
||||
if (rootBlock) {
|
||||
return rootBlock
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private findExplicitTrigger(workflow: SerializedWorkflow): string | undefined {
|
||||
for (const block of workflow.blocks) {
|
||||
if (block.enabled && this.isTriggerBlock(block)) {
|
||||
return block.id
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private findRootBlock(workflow: SerializedWorkflow): string | undefined {
|
||||
const hasIncoming = new Set(workflow.connections.map((c) => c.target))
|
||||
|
||||
for (const block of workflow.blocks) {
|
||||
if (
|
||||
!hasIncoming.has(block.id) &&
|
||||
block.enabled &&
|
||||
!isMetadataOnlyBlockType(block.metadata?.id)
|
||||
) {
|
||||
return block.id
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private isTriggerBlock(block: SerializedBlock): boolean {
|
||||
return isTriggerBlockType(block.metadata?.id)
|
||||
}
|
||||
|
||||
private getAllEnabledBlocks(workflow: SerializedWorkflow): Set<string> {
|
||||
return new Set(workflow.blocks.filter((b) => b.enabled).map((b) => b.id))
|
||||
}
|
||||
|
||||
private buildAdjacencyMap(workflow: SerializedWorkflow): Map<string, string[]> {
|
||||
const adjacency = new Map<string, string[]>()
|
||||
const enabledBlocks = new Set(workflow.blocks.filter((b) => b.enabled).map((b) => b.id))
|
||||
|
||||
for (const connection of workflow.connections) {
|
||||
if (!enabledBlocks.has(connection.source) || !enabledBlocks.has(connection.target)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const neighbors = adjacency.get(connection.source) ?? []
|
||||
neighbors.push(connection.target)
|
||||
adjacency.set(connection.source, neighbors)
|
||||
}
|
||||
|
||||
return adjacency
|
||||
}
|
||||
|
||||
private performBFS(triggerBlockId: string, adjacency: Map<string, string[]>): Set<string> {
|
||||
const reachable = new Set<string>([triggerBlockId])
|
||||
const queue = [triggerBlockId]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentBlockId = queue.shift()
|
||||
|
||||
if (!currentBlockId) break
|
||||
|
||||
const neighbors = adjacency.get(currentBlockId) ?? []
|
||||
|
||||
for (const neighborId of neighbors) {
|
||||
if (!reachable.has(neighborId)) {
|
||||
reachable.add(neighborId)
|
||||
queue.push(neighborId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reachable
|
||||
}
|
||||
|
||||
private resolveResumeTriggerFallback(
|
||||
triggerBlockId: string,
|
||||
workflow: SerializedWorkflow
|
||||
): string | undefined {
|
||||
if (!triggerBlockId.endsWith('__trigger')) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const baseId = triggerBlockId.replace(/__trigger$/, '')
|
||||
const normalizedBaseId = extractBaseBlockId(baseId)
|
||||
const candidates = baseId === normalizedBaseId ? [baseId] : [baseId, normalizedBaseId]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const block = workflow.blocks.find((b) => b.id === candidate)
|
||||
|
||||
if (block) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { BlockType, SentinelType } from '@/executor/constants'
|
||||
import type { DAGNode } from '@/executor/dag/builder'
|
||||
import type { SentinelSubflowType } from '@/executor/dag/types'
|
||||
|
||||
interface SubflowSentinelNodeConfig {
|
||||
id: string
|
||||
subflowId: string
|
||||
subflowType: SentinelSubflowType
|
||||
sentinelType: SentinelType
|
||||
blockType: BlockType
|
||||
name: string
|
||||
}
|
||||
|
||||
export function createSubflowSentinelNode(config: SubflowSentinelNodeConfig): DAGNode {
|
||||
return {
|
||||
id: config.id,
|
||||
block: {
|
||||
id: config.id,
|
||||
enabled: true,
|
||||
position: { x: 0, y: 0 },
|
||||
metadata: {
|
||||
id: config.blockType,
|
||||
name: config.name,
|
||||
},
|
||||
config: { tool: config.blockType, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
},
|
||||
incomingEdges: new Set(),
|
||||
outgoingEdges: new Map(),
|
||||
metadata: {
|
||||
isSentinel: true,
|
||||
sentinelType: config.sentinelType,
|
||||
subflowId: config.subflowId,
|
||||
subflowType: config.subflowType,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface DAGEdge {
|
||||
target: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
}
|
||||
|
||||
export type SentinelSubflowType = 'loop' | 'parallel'
|
||||
|
||||
export interface NodeMetadata {
|
||||
isParallelBranch?: boolean
|
||||
branchIndex?: number
|
||||
branchTotal?: number
|
||||
distributionItem?: unknown
|
||||
isLoopNode?: boolean
|
||||
isSentinel?: boolean
|
||||
sentinelType?: 'start' | 'end'
|
||||
subflowType?: SentinelSubflowType
|
||||
subflowId?: string
|
||||
isPauseResponse?: boolean
|
||||
isResumeTrigger?: boolean
|
||||
originalBlockId?: string
|
||||
}
|
||||
Reference in New Issue
Block a user