Files
wehub-resource-sync d25d482dc2
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

271 lines
8.5 KiB
TypeScript

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()
})
})