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
271 lines
8.5 KiB
TypeScript
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()
|
|
})
|
|
})
|