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,431 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import { DAGBuilder } from '@/executor/dag/builder'
|
||||
import { DAGExecutor } from '@/executor/execution/executor'
|
||||
import type { SerializableExecutionState } from '@/executor/execution/types'
|
||||
import type { ExecutionContext, ExecutionResult } from '@/executor/types'
|
||||
import { buildSentinelStartId } from '@/executor/utils/subflow-utils'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
function createExecutor(): DAGExecutor {
|
||||
return new DAGExecutor({
|
||||
workflow: {
|
||||
version: '1',
|
||||
blocks: [],
|
||||
connections: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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('DAGExecutor restored cloned subflow registration', () => {
|
||||
it('registers restored cloned subflows under their parent parallel branch', () => {
|
||||
const executor = createExecutor() as unknown as {
|
||||
registerRestoredClonedSubflows: (
|
||||
parentMap: Map<
|
||||
string,
|
||||
{ parentId: string; parentType: 'loop' | 'parallel'; branchIndex?: number }
|
||||
>,
|
||||
clonedSubflows: Array<{
|
||||
originalId: string
|
||||
clonedId: string
|
||||
outerBranchIndex: number
|
||||
parentParallelId: string
|
||||
}>
|
||||
) => void
|
||||
}
|
||||
const parentMap = new Map<
|
||||
string,
|
||||
{ parentId: string; parentType: 'loop' | 'parallel'; branchIndex?: number }
|
||||
>([['nested-loop', { parentId: 'parent-parallel', parentType: 'parallel' }]])
|
||||
|
||||
executor.registerRestoredClonedSubflows(parentMap, [
|
||||
{
|
||||
originalId: 'nested-loop',
|
||||
clonedId: 'nested-loop__obranch-2',
|
||||
outerBranchIndex: 2,
|
||||
parentParallelId: 'parent-parallel',
|
||||
},
|
||||
])
|
||||
|
||||
expect(parentMap.get('nested-loop__obranch-2')).toEqual({
|
||||
parentId: 'parent-parallel',
|
||||
parentType: 'parallel',
|
||||
branchIndex: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves cloned nested parent relationships within the same restored branch', () => {
|
||||
const executor = createExecutor() as unknown as {
|
||||
registerRestoredClonedSubflows: (
|
||||
parentMap: Map<
|
||||
string,
|
||||
{ parentId: string; parentType: 'loop' | 'parallel'; branchIndex?: number }
|
||||
>,
|
||||
clonedSubflows: Array<{
|
||||
originalId: string
|
||||
clonedId: string
|
||||
outerBranchIndex: number
|
||||
parentParallelId: string
|
||||
}>
|
||||
) => void
|
||||
}
|
||||
const parentMap = new Map<
|
||||
string,
|
||||
{ parentId: string; parentType: 'loop' | 'parallel'; branchIndex?: number }
|
||||
>([
|
||||
['middle-loop', { parentId: 'parent-parallel', parentType: 'parallel' }],
|
||||
['inner-parallel', { parentId: 'middle-loop', parentType: 'loop' }],
|
||||
])
|
||||
|
||||
executor.registerRestoredClonedSubflows(parentMap, [
|
||||
{
|
||||
originalId: 'middle-loop',
|
||||
clonedId: 'middle-loop__obranch-2',
|
||||
outerBranchIndex: 2,
|
||||
parentParallelId: 'parent-parallel',
|
||||
},
|
||||
{
|
||||
originalId: 'inner-parallel',
|
||||
clonedId: 'inner-parallel__obranch-2',
|
||||
outerBranchIndex: 2,
|
||||
parentParallelId: 'parent-parallel',
|
||||
},
|
||||
])
|
||||
|
||||
expect(parentMap.get('inner-parallel__obranch-2')).toEqual({
|
||||
parentId: 'middle-loop__obranch-2',
|
||||
parentType: 'loop',
|
||||
branchIndex: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('restores snapshot parallel batches with later global branch indexes', () => {
|
||||
const parallelId = 'parallel-1'
|
||||
const loopId = 'loop-1'
|
||||
const taskId = 'task-1'
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
createBlock(parallelId, BlockType.PARALLEL),
|
||||
createBlock(loopId, BlockType.LOOP),
|
||||
createBlock(taskId, BlockType.FUNCTION),
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: parallelId },
|
||||
{ source: parallelId, target: loopId, sourceHandle: 'parallel-start-source' },
|
||||
{ source: loopId, target: taskId, sourceHandle: 'loop-start-source' },
|
||||
],
|
||||
loops: {
|
||||
[loopId]: {
|
||||
id: loopId,
|
||||
nodes: [taskId],
|
||||
iterations: 1,
|
||||
loopType: 'for',
|
||||
},
|
||||
},
|
||||
parallels: {
|
||||
[parallelId]: {
|
||||
id: parallelId,
|
||||
nodes: [loopId],
|
||||
count: 4,
|
||||
parallelType: 'count',
|
||||
},
|
||||
},
|
||||
}
|
||||
const dag = new DAGBuilder().build(workflow)
|
||||
const executor = new DAGExecutor({ workflow }) as unknown as {
|
||||
restoreSnapshotParallelBatches: (
|
||||
dag: ReturnType<DAGBuilder['build']>,
|
||||
snapshotState?: SerializableExecutionState
|
||||
) => Array<{
|
||||
originalId: string
|
||||
clonedId: string
|
||||
outerBranchIndex: number
|
||||
parentParallelId: string
|
||||
}>
|
||||
}
|
||||
|
||||
const restoredClones = executor.restoreSnapshotParallelBatches(dag, {
|
||||
blockStates: {},
|
||||
executedBlocks: [],
|
||||
blockLogs: [],
|
||||
decisions: { router: {}, condition: {} },
|
||||
completedLoops: [],
|
||||
activeExecutionPath: [],
|
||||
parallelExecutions: {
|
||||
[parallelId]: {
|
||||
currentBatchStart: 2,
|
||||
currentBatchSize: 1,
|
||||
totalBranches: 4,
|
||||
items: ['zero', 'one', 'two', 'three'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(dag.nodes.has(buildSentinelStartId(`${loopId}__obranch-2`))).toBe(true)
|
||||
expect(restoredClones).toContainEqual(
|
||||
expect.objectContaining({
|
||||
originalId: loopId,
|
||||
clonedId: `${loopId}__obranch-2`,
|
||||
outerBranchIndex: 2,
|
||||
parentParallelId: parallelId,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DAGExecutor run-from-block snapshot metadata', () => {
|
||||
it('preserves reachable large value and file keys in run-from-block metadata', async () => {
|
||||
const reachableLargeValue = {
|
||||
__simLargeValueRef: true,
|
||||
version: 1,
|
||||
id: 'lv_ABCDEF123456',
|
||||
kind: 'object',
|
||||
size: 1024,
|
||||
key: 'execution/ws/wf/exec/large-value-lv_ABCDEF123456.json',
|
||||
}
|
||||
const unreachableLargeValue = {
|
||||
__simLargeValueRef: true,
|
||||
version: 1,
|
||||
id: 'lv_ZYXWVU654321',
|
||||
kind: 'object',
|
||||
size: 1024,
|
||||
key: 'execution/ws/wf/exec/large-value-lv_ZYXWVU654321.json',
|
||||
}
|
||||
const reachableFile = {
|
||||
id: 'file-1',
|
||||
name: 'reachable.txt',
|
||||
url: '/api/files/serve/reachable',
|
||||
size: 10,
|
||||
type: 'text/plain',
|
||||
key: 'execution/ws/wf/exec/reachable.txt',
|
||||
}
|
||||
const unreachableFile = {
|
||||
id: 'file-2',
|
||||
name: 'unreachable.txt',
|
||||
url: '/api/files/serve/unreachable',
|
||||
size: 10,
|
||||
type: 'text/plain',
|
||||
key: 'execution/ws/wf/exec/unreachable.txt',
|
||||
}
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
createBlock('producer', BlockType.FUNCTION),
|
||||
createBlock('consumer', BlockType.FUNCTION),
|
||||
createBlock('unreachable', BlockType.FUNCTION),
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: 'producer' },
|
||||
{ source: 'producer', target: 'consumer' },
|
||||
],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
}
|
||||
const executor = new DAGExecutor({
|
||||
workflow,
|
||||
contextExtensions: {
|
||||
workspaceId: 'ws',
|
||||
executionId: 'exec',
|
||||
largeValueKeys: ['existing-large-key'],
|
||||
fileKeys: ['existing-file-key'],
|
||||
},
|
||||
}) as unknown as DAGExecutor & {
|
||||
buildExecutionPipeline: (context: ExecutionContext) => { run: () => Promise<ExecutionResult> }
|
||||
}
|
||||
const run = vi.fn(async (): Promise<ExecutionResult> => {
|
||||
return {
|
||||
success: true,
|
||||
output: { ok: true },
|
||||
metadata: {} as ExecutionResult['metadata'],
|
||||
}
|
||||
})
|
||||
executor.buildExecutionPipeline = vi.fn(() => ({ run }))
|
||||
const sourceSnapshot: SerializableExecutionState = {
|
||||
blockStates: {
|
||||
producer: { output: { reachableLargeValue, reachableFile } },
|
||||
consumer: { output: { previous: true } },
|
||||
unreachable: { output: { unreachableLargeValue, unreachableFile } },
|
||||
},
|
||||
executedBlocks: ['producer', 'consumer', 'unreachable'],
|
||||
blockLogs: [],
|
||||
decisions: { router: {}, condition: {} },
|
||||
completedLoops: [],
|
||||
activeExecutionPath: [],
|
||||
}
|
||||
|
||||
const result = await executor.executeFromBlock('wf', 'consumer', sourceSnapshot)
|
||||
|
||||
expect(result.metadata?.largeValueKeys).toEqual(['existing-large-key', reachableLargeValue.key])
|
||||
expect(result.metadata?.fileKeys).toEqual(['existing-file-key', reachableFile.key])
|
||||
expect(result.metadata?.largeValueKeys).not.toContain(unreachableLargeValue.key)
|
||||
expect(result.metadata?.fileKeys).not.toContain(unreachableFile.key)
|
||||
})
|
||||
|
||||
it('preserves reachable stable branch aliases in run-from-block snapshots', async () => {
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
createBlock('producer', BlockType.FUNCTION),
|
||||
createBlock('consumer', BlockType.FUNCTION),
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: 'producer' },
|
||||
{ source: 'producer', target: 'consumer' },
|
||||
],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
}
|
||||
let capturedContext: ExecutionContext | undefined
|
||||
const executor = new DAGExecutor({ workflow }) as unknown as DAGExecutor & {
|
||||
buildExecutionPipeline: (context: ExecutionContext) => { run: () => Promise<ExecutionResult> }
|
||||
}
|
||||
executor.buildExecutionPipeline = vi.fn((context: ExecutionContext) => {
|
||||
capturedContext = context
|
||||
return {
|
||||
run: async (): Promise<ExecutionResult> => ({
|
||||
success: true,
|
||||
output: { ok: true },
|
||||
metadata: {},
|
||||
}),
|
||||
}
|
||||
})
|
||||
const sourceSnapshot: SerializableExecutionState = {
|
||||
blockStates: {
|
||||
producer: { output: { result: 'latest-local-batch' } },
|
||||
'producer__obranch-0': { output: { result: 'global-branch-0' } },
|
||||
'unreachable__obranch-0': { output: { result: 'unreachable' } },
|
||||
consumer: { output: { previous: true } },
|
||||
},
|
||||
executedBlocks: ['producer', 'producer__obranch-0', 'unreachable__obranch-0', 'consumer'],
|
||||
blockLogs: [],
|
||||
decisions: { router: {}, condition: {} },
|
||||
completedLoops: [],
|
||||
activeExecutionPath: [],
|
||||
}
|
||||
|
||||
await executor.executeFromBlock('wf', 'consumer', sourceSnapshot)
|
||||
|
||||
expect(capturedContext?.blockStates.get('producer__obranch-0')?.output).toEqual({
|
||||
result: 'global-branch-0',
|
||||
})
|
||||
expect(capturedContext?.blockStates.has('unreachable__obranch-0')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DAGExecutor resume DAG construction', () => {
|
||||
it('includes non-starter resume targets when a workflow has a disconnected starter', async () => {
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start-t2-v2', BlockType.STARTER),
|
||||
createBlock('webhook-start', 'generic_webhook'),
|
||||
createBlock('hitl', BlockType.HUMAN_IN_THE_LOOP),
|
||||
createBlock('generate-report', BlockType.FUNCTION),
|
||||
],
|
||||
connections: [
|
||||
{ source: 'webhook-start', target: 'hitl', sourceHandle: 'source' },
|
||||
{ source: 'hitl', target: 'generate-report', sourceHandle: 'source' },
|
||||
],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
}
|
||||
let capturedDag: ReturnType<DAGBuilder['build']> | undefined
|
||||
const executor = new DAGExecutor({
|
||||
workflow,
|
||||
contextExtensions: {
|
||||
resumeFromSnapshot: true,
|
||||
remainingEdges: [{ source: 'hitl', target: 'generate-report', sourceHandle: 'source' }],
|
||||
dagIncomingEdges: { 'start-t2-v2': [] },
|
||||
snapshotState: {
|
||||
blockStates: {},
|
||||
executedBlocks: ['webhook-start', 'hitl'],
|
||||
blockLogs: [],
|
||||
decisions: { router: {}, condition: {} },
|
||||
completedLoops: [],
|
||||
activeExecutionPath: [],
|
||||
},
|
||||
},
|
||||
}) as unknown as DAGExecutor & {
|
||||
buildExecutionPipeline: (
|
||||
context: ExecutionContext,
|
||||
dag: ReturnType<DAGBuilder['build']>
|
||||
) => { run: () => Promise<ExecutionResult> }
|
||||
}
|
||||
executor.buildExecutionPipeline = vi.fn((_context, dag) => {
|
||||
capturedDag = dag
|
||||
return {
|
||||
run: async (): Promise<ExecutionResult> => ({
|
||||
success: true,
|
||||
output: { ok: true },
|
||||
metadata: {},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
await executor.execute('wf')
|
||||
|
||||
expect(capturedDag?.nodes.has('generate-report')).toBe(true)
|
||||
expect(capturedDag?.nodes.get('generate-report')?.incomingEdges.has('hitl')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DAGExecutor createExecutionContext useDraftState', () => {
|
||||
function buildMetadataUseDraftState(opts: {
|
||||
metadataUseDraftState?: boolean
|
||||
isDeployedContext?: boolean
|
||||
}): boolean | undefined {
|
||||
const executor = new DAGExecutor({
|
||||
workflow: { version: '1', blocks: [], connections: [] },
|
||||
contextExtensions: {
|
||||
workspaceId: 'ws-1',
|
||||
isDeployedContext: opts.isDeployedContext,
|
||||
metadata:
|
||||
opts.metadataUseDraftState === undefined
|
||||
? undefined
|
||||
: ({ useDraftState: opts.metadataUseDraftState } as ExecutionContext['metadata']),
|
||||
},
|
||||
})
|
||||
const { context } = (
|
||||
executor as unknown as {
|
||||
createExecutionContext: (workflowId: string) => { context: ExecutionContext }
|
||||
}
|
||||
).createExecutionContext('wf-1')
|
||||
return context.metadata.useDraftState
|
||||
}
|
||||
|
||||
it('honors explicit useDraftState=true even when isDeployedContext is true (table dispatcher)', () => {
|
||||
expect(
|
||||
buildMetadataUseDraftState({ metadataUseDraftState: true, isDeployedContext: true })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('honors explicit useDraftState=false even when isDeployedContext is false', () => {
|
||||
expect(
|
||||
buildMetadataUseDraftState({ metadataUseDraftState: false, isDeployedContext: false })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the isDeployedContext heuristic when useDraftState is not provided', () => {
|
||||
expect(buildMetadataUseDraftState({ isDeployedContext: true })).toBe(false)
|
||||
expect(buildMetadataUseDraftState({ isDeployedContext: false })).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user