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

383 lines
11 KiB
TypeScript

/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { BlockType } from '@/executor/constants'
import type { DAGNode } from '@/executor/dag/builder'
import { BlockExecutor } from '@/executor/execution/block-executor'
import { ExecutionState } from '@/executor/execution/state'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
const { mockUploadFile } = vi.hoisted(() => ({
mockUploadFile: vi.fn(),
}))
vi.mock('@/ee/access-control/utils/permission-check', () => ({
validateBlockType: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
uploadFile: mockUploadFile,
},
}))
function createBlock(): SerializedBlock {
return {
id: 'function-block-1',
metadata: { id: BlockType.FUNCTION, name: 'Function' },
position: { x: 0, y: 0 },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
}
}
function createContext(state: ExecutionState): ExecutionContext {
return {
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
blockStates: state.getBlockStates(),
blockLogs: [],
metadata: { requestId: 'request-1', duration: 0 },
environmentVariables: {},
workflowVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
} as ExecutionContext
}
function createNode(block: SerializedBlock): DAGNode {
return {
id: block.id,
block,
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {},
}
}
describe('BlockExecutor', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
})
it('persists function output arrays as manifests in execution state', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = {
result: Array.from({ length: 120_000 }, (_, index) => ({
key: `SIM-${index}`,
payload: 'x'.repeat(100),
})),
}
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
await executor.execute(createContext(state), createNode(block), block)
const storedOutput = state.getBlockOutput(block.id)
expect(isLargeArrayManifest(storedOutput?.result)).toBe(true)
expect(storedOutput?.result).toMatchObject({
__simLargeArrayManifest: true,
kind: 'array',
totalCount: output.result.length,
})
})
it('persists stable outer-branch aliases for completed parallel branch outputs', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = { result: 'branch-2' }
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
const node = createNode(block)
node.id = 'function-block-1₍0₎'
node.metadata = {
isParallelBranch: true,
subflowId: 'parallel-1',
subflowType: 'parallel',
originalBlockId: block.id,
branchIndex: 2,
}
await executor.execute(createContext(state), node, block)
expect(state.getBlockOutput('function-block-1__obranch-2')).toEqual(output)
expect(state.getBlockOutput('function-block-1₍2₎')).toEqual(output)
expect(state.getBlockOutput('function-block-1₍0₎')).toEqual(output)
})
it('does not write global aliases for parallel branches inside cloned outer branches', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = { result: 'outer-2-inner-0' }
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
const node = createNode(block)
node.id = 'function-block-1__cloneabc__obranch-2₍0₎'
node.metadata = {
isParallelBranch: true,
subflowId: 'inner-parallel',
subflowType: 'parallel',
originalBlockId: block.id,
branchIndex: 0,
}
await executor.execute(createContext(state), node, block)
expect(state.getBlockOutput(node.id)).toEqual(output)
expect(state.getBlockOutput('function-block-1__obranch-0')).toBeUndefined()
expect(state.getBlockOutput('function-block-1₍0₎')).toBeUndefined()
})
it('does not let block completion callbacks overtake pending start callbacks', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = { result: 'done' }
const execute = vi.fn(async () => {
events.push('execute')
return output
})
const handler: BlockHandler = {
canHandle: () => true,
execute,
}
const events: string[] = []
let resolveStart!: () => void
const startGate = new Promise<void>((resolve) => {
resolveStart = resolve
})
const onBlockStart = vi.fn(async () => {
events.push('start-called')
await startGate
events.push('start-done')
})
const onBlockComplete = vi.fn(async () => {
events.push('complete')
})
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
onBlockStart,
onBlockComplete,
},
state
)
const execution = executor.execute(createContext(state), createNode(block), block)
expect(onBlockStart).toHaveBeenCalled()
expect(execute).not.toHaveBeenCalled()
expect(onBlockComplete).not.toHaveBeenCalled()
resolveStart()
await execution
await vi.waitFor(() => {
expect(onBlockComplete).toHaveBeenCalled()
})
expect(events).toEqual(['start-called', 'start-done', 'execute', 'complete'])
})
it('fires block completion callbacks for pausing blocks so clients receive pause output', async () => {
const block = {
...createBlock(),
id: 'hitl-block-1',
metadata: { id: BlockType.HUMAN_IN_THE_LOOP, name: 'Human in the Loop' },
config: { tool: BlockType.HUMAN_IN_THE_LOOP, params: {} },
}
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = {
response: { status: 'paused' },
_pauseMetadata: {
contextId: 'pause-context-1',
blockId: block.id,
response: { status: 'paused' },
timestamp: new Date().toISOString(),
pauseKind: 'human' as const,
},
}
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const onBlockStart = vi.fn(async () => {})
const onBlockComplete = vi.fn(async () => {})
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
onBlockStart,
onBlockComplete,
},
state
)
await executor.execute(createContext(state), createNode(block), block)
expect(onBlockStart).toHaveBeenCalled()
expect(onBlockComplete).toHaveBeenCalledWith(
block.id,
'Human in the Loop',
BlockType.HUMAN_IN_THE_LOOP,
expect.objectContaining({
output: expect.objectContaining({
response: { status: 'paused' },
}),
}),
undefined,
undefined
)
expect(state.getBlockOutput(block.id)).toEqual(output)
})
})