chore: import upstream snapshot with attribution
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) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { isTriggerBehavior, normalizeName } from '@/executor/constants'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { OutputSchema } from '@/executor/utils/block-reference'
|
||||
import {
|
||||
extractBaseBlockId,
|
||||
extractBranchIndex,
|
||||
isBranchNodeId,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
export interface BlockDataCollection {
|
||||
blockData: Record<string, unknown>
|
||||
blockNameMapping: Record<string, string>
|
||||
blockOutputSchemas: Record<string, OutputSchema>
|
||||
}
|
||||
|
||||
interface SubBlockWithValue {
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
function paramsToSubBlocks(
|
||||
params: Record<string, unknown> | undefined
|
||||
): Record<string, SubBlockWithValue> {
|
||||
if (!params) return {}
|
||||
|
||||
const subBlocks: Record<string, SubBlockWithValue> = {}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
subBlocks[key] = { value }
|
||||
}
|
||||
return subBlocks
|
||||
}
|
||||
|
||||
function getRegistrySchema(block: SerializedBlock): OutputSchema | undefined {
|
||||
const blockType = block.metadata?.id
|
||||
if (!blockType) return undefined
|
||||
|
||||
const subBlocks = paramsToSubBlocks(block.config?.params)
|
||||
const blockConfig = getBlock(blockType)
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const triggerMode = Boolean(isTriggerBehavior(block) && isTriggerCapable)
|
||||
const outputs = getEffectiveBlockOutputs(blockType, subBlocks, {
|
||||
triggerMode,
|
||||
preferToolOutputs: !triggerMode,
|
||||
includeHidden: true,
|
||||
}) as OutputSchema
|
||||
|
||||
if (!outputs || Object.keys(outputs).length === 0) {
|
||||
return undefined
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
export function getBlockSchema(block: SerializedBlock): OutputSchema | undefined {
|
||||
return getRegistrySchema(block)
|
||||
}
|
||||
|
||||
export function collectBlockData(
|
||||
ctx: ExecutionContext,
|
||||
currentNodeId?: string
|
||||
): BlockDataCollection {
|
||||
const blockData: Record<string, unknown> = {}
|
||||
const blockNameMapping: Record<string, string> = {}
|
||||
const blockOutputSchemas: Record<string, OutputSchema> = {}
|
||||
|
||||
const branchIndex =
|
||||
currentNodeId && isBranchNodeId(currentNodeId) ? extractBranchIndex(currentNodeId) : null
|
||||
|
||||
for (const [id, state] of ctx.blockStates.entries()) {
|
||||
if (state.output !== undefined) {
|
||||
blockData[id] = state.output
|
||||
|
||||
if (branchIndex !== null && isBranchNodeId(id)) {
|
||||
const stateBranchIndex = extractBranchIndex(id)
|
||||
if (stateBranchIndex === branchIndex) {
|
||||
const baseId = extractBaseBlockId(id)
|
||||
if (blockData[baseId] === undefined) {
|
||||
blockData[baseId] = state.output
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workflowBlocks = ctx.workflow?.blocks ?? []
|
||||
for (const block of workflowBlocks) {
|
||||
const id = block.id
|
||||
|
||||
if (block.metadata?.name) {
|
||||
blockNameMapping[normalizeName(block.metadata.name)] = id
|
||||
}
|
||||
|
||||
const schema = getBlockSchema(block)
|
||||
if (schema && Object.keys(schema).length > 0) {
|
||||
blockOutputSchemas[id] = schema
|
||||
}
|
||||
}
|
||||
|
||||
return { blockData, blockNameMapping, blockOutputSchemas }
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
type BlockReferenceContext,
|
||||
InvalidFieldError,
|
||||
resolveBlockReference,
|
||||
} from './block-reference'
|
||||
|
||||
describe('resolveBlockReference', () => {
|
||||
const createContext = (
|
||||
overrides: Partial<BlockReferenceContext> = {}
|
||||
): BlockReferenceContext => ({
|
||||
blockNameMapping: { start: 'block-1', agent: 'block-2' },
|
||||
blockData: {},
|
||||
blockOutputSchemas: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('block name resolution', () => {
|
||||
it('should return undefined when block name does not exist', () => {
|
||||
const ctx = createContext()
|
||||
const result = resolveBlockReference('unknown', ['field'], ctx)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should normalize block name before lookup', () => {
|
||||
const ctx = createContext({
|
||||
blockNameMapping: { myblock: 'block-1' },
|
||||
blockData: { 'block-1': { value: 'test' } },
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('MyBlock', ['value'], ctx)
|
||||
expect(result).toEqual({ value: 'test', blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should handle block names with spaces', () => {
|
||||
const ctx = createContext({
|
||||
blockNameMapping: { myblock: 'block-1' },
|
||||
blockData: { 'block-1': { value: 'test' } },
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('My Block', ['value'], ctx)
|
||||
expect(result).toEqual({ value: 'test', blockId: 'block-1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('field resolution', () => {
|
||||
it('should return entire block output when no path specified', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { input: 'hello', other: 'data' } },
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', [], ctx)
|
||||
expect(result).toEqual({
|
||||
value: { input: 'hello', other: 'data' },
|
||||
blockId: 'block-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('should resolve simple field path', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { input: 'hello' } },
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['input'], ctx)
|
||||
expect(result).toEqual({ value: 'hello', blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should resolve nested field path', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { response: { data: { name: 'test' } } } },
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['response', 'data', 'name'], ctx)
|
||||
expect(result).toEqual({ value: 'test', blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should resolve array index path', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { items: ['a', 'b', 'c'] } },
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['items', '1'], ctx)
|
||||
expect(result).toEqual({ value: 'b', blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should return undefined value when field exists but has no value', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { input: undefined } },
|
||||
blockOutputSchemas: {
|
||||
'block-1': { input: { type: 'string' } },
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['input'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should return null value when field has null', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { input: null } },
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['input'], ctx)
|
||||
expect(result).toEqual({ value: null, blockId: 'block-1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('schema validation', () => {
|
||||
it('should throw InvalidFieldError when field not in schema', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { existing: 'value' } },
|
||||
blockOutputSchemas: {
|
||||
'block-1': {
|
||||
input: { type: 'string' },
|
||||
conversationId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(() => resolveBlockReference('start', ['invalid'], ctx)).toThrow(InvalidFieldError)
|
||||
expect(() => resolveBlockReference('start', ['invalid'], ctx)).toThrow(
|
||||
/"invalid" doesn't exist on block "start"/
|
||||
)
|
||||
})
|
||||
|
||||
it('should include available fields in error message', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': {} },
|
||||
blockOutputSchemas: {
|
||||
'block-1': {
|
||||
input: { type: 'string' },
|
||||
conversationId: { type: 'string' },
|
||||
files: { type: 'file[]' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
resolveBlockReference('start', ['typo'], ctx)
|
||||
expect.fail('Should have thrown')
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(InvalidFieldError)
|
||||
const fieldError = error as InvalidFieldError
|
||||
expect(fieldError.availableFields).toContain('input')
|
||||
expect(fieldError.availableFields).toContain('conversationId')
|
||||
expect(fieldError.availableFields).toContain('files')
|
||||
}
|
||||
})
|
||||
|
||||
it('should allow valid field even when value is undefined', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': {} },
|
||||
blockOutputSchemas: {
|
||||
'block-1': { input: { type: 'string' } },
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['input'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should not validate path when block has no output yet', () => {
|
||||
// Blocks with no output typically live on a branched path that wasn't
|
||||
// taken this run. We resolve such references to undefined (which the
|
||||
// caller maps to RESOLVED_EMPTY) rather than throwing on every nested
|
||||
// path the schema doesn't pre-declare.
|
||||
const ctx = createContext({
|
||||
blockData: {},
|
||||
blockOutputSchemas: {
|
||||
'block-1': { input: { type: 'string' } },
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['invalid'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should return undefined for valid field when block has no output', () => {
|
||||
const ctx = createContext({
|
||||
blockData: {},
|
||||
blockOutputSchemas: {
|
||||
'block-1': { input: { type: 'string' } },
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['input'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should return undefined for nested path under json field when block has no output', () => {
|
||||
// Repro for the branched-path bug: a function block with a dynamic
|
||||
// `json` result that never ran should resolve to undefined regardless
|
||||
// of the nested path, not throw.
|
||||
const ctx = createContext({
|
||||
blockData: {},
|
||||
blockOutputSchemas: {
|
||||
'block-1': {
|
||||
result: { type: 'json' },
|
||||
stdout: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['result', 'summary'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should not throw for nested path under json field on executed block', () => {
|
||||
// A `json` field declares dynamic shape, so drilling into it must be
|
||||
// permitted even when the runtime data doesn't happen to include that
|
||||
// key on this run.
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { result: { foo: 1 } } },
|
||||
blockOutputSchemas: {
|
||||
'block-1': {
|
||||
result: { type: 'json' },
|
||||
stdout: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['result', 'summary'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should resolve values nested under json field on executed block', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { result: { summary: 'hello' } } },
|
||||
blockOutputSchemas: {
|
||||
'block-1': {
|
||||
result: { type: 'json' },
|
||||
stdout: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['result', 'summary'], ctx)
|
||||
expect(result).toEqual({ value: 'hello', blockId: 'block-1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('without schema (pass-through mode)', () => {
|
||||
it('should return undefined value without throwing when no schema', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { existing: 'value' } },
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['missing'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('file type handling', () => {
|
||||
it('should allow file property access', () => {
|
||||
const ctx = createContext({
|
||||
blockData: {
|
||||
'block-1': {
|
||||
files: [{ name: 'test.txt', url: 'http://example.com/file' }],
|
||||
},
|
||||
},
|
||||
blockOutputSchemas: {
|
||||
'block-1': { files: { type: 'file[]' } },
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['files', '0', 'name'], ctx)
|
||||
expect(result).toEqual({ value: 'test.txt', blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should validate file property names', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { files: [] } },
|
||||
blockOutputSchemas: {
|
||||
'block-1': { files: { type: 'file[]' } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(() => resolveBlockReference('start', ['files', '0', 'invalid'], ctx)).toThrow(
|
||||
InvalidFieldError
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('InvalidFieldError', () => {
|
||||
it('should have correct properties', () => {
|
||||
const error = new InvalidFieldError('myBlock', 'invalid.path', ['field1', 'field2'])
|
||||
|
||||
expect(error.blockName).toBe('myBlock')
|
||||
expect(error.fieldPath).toBe('invalid.path')
|
||||
expect(error.availableFields).toEqual(['field1', 'field2'])
|
||||
expect(error.name).toBe('InvalidFieldError')
|
||||
expect(error.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('should format message correctly', () => {
|
||||
const error = new InvalidFieldError('start', 'typo', ['input', 'files'])
|
||||
|
||||
expect(error.message).toBe(
|
||||
'"typo" doesn\'t exist on block "start". Available fields: input, files'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle empty available fields', () => {
|
||||
const error = new InvalidFieldError('start', 'field', [])
|
||||
|
||||
expect(error.message).toBe('"field" doesn\'t exist on block "start". Available fields: none')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,284 @@
|
||||
import { HttpError } from '@/lib/core/utils/http-error'
|
||||
import { USER_FILE_ACCESSIBLE_PROPERTIES } from '@/lib/workflows/types'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import {
|
||||
type AsyncPathNavigator,
|
||||
navigatePath,
|
||||
type ResolutionContext,
|
||||
} from '@/executor/variables/resolvers/reference'
|
||||
|
||||
/**
|
||||
* A single schema node encountered while walking an `OutputSchema`. Captures
|
||||
* only the fields this module inspects — not a full schema type.
|
||||
*/
|
||||
interface SchemaNode {
|
||||
type?: string
|
||||
description?: string
|
||||
properties?: unknown
|
||||
items?: unknown
|
||||
}
|
||||
|
||||
export type OutputSchema = Record<string, SchemaNode | unknown>
|
||||
|
||||
export interface BlockReferenceContext {
|
||||
blockNameMapping: Record<string, string>
|
||||
blockData: Record<string, unknown>
|
||||
blockOutputSchemas?: Record<string, OutputSchema>
|
||||
}
|
||||
|
||||
export interface BlockReferenceResult {
|
||||
value: unknown
|
||||
blockId: string
|
||||
}
|
||||
|
||||
export class InvalidFieldError extends HttpError {
|
||||
readonly statusCode = 400
|
||||
|
||||
constructor(
|
||||
public readonly blockName: string,
|
||||
public readonly fieldPath: string,
|
||||
public readonly availableFields: string[]
|
||||
) {
|
||||
super(
|
||||
`"${fieldPath}" doesn't exist on block "${blockName}". ` +
|
||||
`Available fields: ${availableFields.length > 0 ? availableFields.join(', ') : 'none'}`
|
||||
)
|
||||
this.name = 'InvalidFieldError'
|
||||
}
|
||||
}
|
||||
|
||||
function asSchemaNode(value: unknown): SchemaNode | undefined {
|
||||
if (typeof value !== 'object' || value === null) return undefined
|
||||
return value as SchemaNode
|
||||
}
|
||||
|
||||
function isFileType(value: unknown): boolean {
|
||||
const node = asSchemaNode(value)
|
||||
return node?.type === 'file' || node?.type === 'file[]'
|
||||
}
|
||||
|
||||
function isArrayType(value: unknown): value is { type: 'array'; items?: unknown } {
|
||||
return asSchemaNode(value)?.type === 'array'
|
||||
}
|
||||
|
||||
function getArrayItems(schema: unknown): unknown {
|
||||
return asSchemaNode(schema)?.items
|
||||
}
|
||||
|
||||
function getProperties(schema: unknown): Record<string, unknown> | undefined {
|
||||
const props = asSchemaNode(schema)?.properties
|
||||
return typeof props === 'object' && props !== null
|
||||
? (props as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function lookupField(schema: unknown, fieldName: string): unknown | undefined {
|
||||
if (typeof schema !== 'object' || schema === null) return undefined
|
||||
const typed = schema as Record<string, unknown>
|
||||
|
||||
if (fieldName in typed) {
|
||||
return typed[fieldName]
|
||||
}
|
||||
|
||||
const props = getProperties(schema)
|
||||
if (props && fieldName in props) {
|
||||
return props[fieldName]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isOpaqueSchemaNode(value: unknown): boolean {
|
||||
const node = asSchemaNode(value)
|
||||
if (!node) return false
|
||||
// A schema node whose nested shape isn't enumerated. Any path beneath it
|
||||
// is accepted because there's no declared structure to validate against.
|
||||
// `object` / `json` with declared `properties` are walked via lookupField.
|
||||
if (node.type === 'any') return true
|
||||
if ((node.type === 'json' || node.type === 'object') && node.properties === undefined) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isPathInSchema(schema: OutputSchema | undefined, pathParts: string[]): boolean {
|
||||
if (!schema || pathParts.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
let current: unknown = schema
|
||||
|
||||
for (let i = 0; i < pathParts.length; i++) {
|
||||
const part = pathParts[i]
|
||||
|
||||
if (current === null || current === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isOpaqueSchemaNode(current)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(part)) {
|
||||
if (isFileType(current)) {
|
||||
const nextPart = pathParts[i + 1]
|
||||
return (
|
||||
!nextPart ||
|
||||
USER_FILE_ACCESSIBLE_PROPERTIES.includes(
|
||||
nextPart as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number]
|
||||
)
|
||||
)
|
||||
}
|
||||
if (isArrayType(current)) {
|
||||
current = getArrayItems(current)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const arrayMatch = part.match(/^([^[]+)\[(\d+)\]$/)
|
||||
if (arrayMatch) {
|
||||
const [, prop] = arrayMatch
|
||||
const fieldDef = lookupField(current, prop)
|
||||
if (!fieldDef) return false
|
||||
|
||||
if (isFileType(fieldDef)) {
|
||||
const nextPart = pathParts[i + 1]
|
||||
return (
|
||||
!nextPart ||
|
||||
USER_FILE_ACCESSIBLE_PROPERTIES.includes(
|
||||
nextPart as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
current = isArrayType(fieldDef) ? getArrayItems(fieldDef) : fieldDef
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
isFileType(current) &&
|
||||
USER_FILE_ACCESSIBLE_PROPERTIES.includes(
|
||||
part as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number]
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fieldDef = lookupField(current, part)
|
||||
if (fieldDef !== undefined) {
|
||||
if (isFileType(fieldDef)) {
|
||||
const nextPart = pathParts[i + 1]
|
||||
if (!nextPart) return true
|
||||
if (/^\d+$/.test(nextPart)) {
|
||||
const afterIndex = pathParts[i + 2]
|
||||
return (
|
||||
!afterIndex ||
|
||||
USER_FILE_ACCESSIBLE_PROPERTIES.includes(
|
||||
afterIndex as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number]
|
||||
)
|
||||
)
|
||||
}
|
||||
return USER_FILE_ACCESSIBLE_PROPERTIES.includes(
|
||||
nextPart as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number]
|
||||
)
|
||||
}
|
||||
current = fieldDef
|
||||
continue
|
||||
}
|
||||
|
||||
if (isArrayType(current)) {
|
||||
const items = getArrayItems(current)
|
||||
const itemField = lookupField(items, part)
|
||||
if (itemField !== undefined) {
|
||||
current = itemField
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function getSchemaFieldNames(schema: OutputSchema | undefined): string[] {
|
||||
if (!schema) return []
|
||||
return Object.keys(schema)
|
||||
}
|
||||
|
||||
export function resolveBlockReference(
|
||||
blockName: string,
|
||||
pathParts: string[],
|
||||
context: BlockReferenceContext,
|
||||
options: {
|
||||
allowLargeValueRefs?: boolean
|
||||
executionContext?: ResolutionContext['executionContext']
|
||||
} = {}
|
||||
): BlockReferenceResult | undefined {
|
||||
const normalizedName = normalizeName(blockName)
|
||||
const blockId = context.blockNameMapping[normalizedName]
|
||||
|
||||
if (!blockId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const blockOutput = context.blockData[blockId]
|
||||
|
||||
// When the block has not produced any output (e.g. it lives on a branched
|
||||
// path that wasn't taken), resolve the reference to undefined without
|
||||
// validating against the declared schema. Callers map this to an empty
|
||||
// value so that references to skipped blocks don't fail the workflow.
|
||||
if (blockOutput === undefined) {
|
||||
return { value: undefined, blockId }
|
||||
}
|
||||
|
||||
if (pathParts.length === 0) {
|
||||
return { value: blockOutput, blockId }
|
||||
}
|
||||
|
||||
const value = navigatePath(blockOutput, pathParts, options)
|
||||
|
||||
const schema = context.blockOutputSchemas?.[blockId]
|
||||
if (value === undefined && schema) {
|
||||
if (!isPathInSchema(schema, pathParts)) {
|
||||
throw new InvalidFieldError(blockName, pathParts.join('.'), getSchemaFieldNames(schema))
|
||||
}
|
||||
}
|
||||
|
||||
return { value, blockId }
|
||||
}
|
||||
|
||||
export async function resolveBlockReferenceAsync(
|
||||
blockName: string,
|
||||
pathParts: string[],
|
||||
context: BlockReferenceContext,
|
||||
resolutionContext: ResolutionContext,
|
||||
navigatePathAsync: AsyncPathNavigator
|
||||
): Promise<BlockReferenceResult | undefined> {
|
||||
const normalizedName = normalizeName(blockName)
|
||||
const blockId = context.blockNameMapping[normalizedName]
|
||||
|
||||
if (!blockId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const blockOutput = context.blockData[blockId]
|
||||
if (blockOutput === undefined) {
|
||||
return { value: undefined, blockId }
|
||||
}
|
||||
|
||||
if (pathParts.length === 0) {
|
||||
return { value: blockOutput, blockId }
|
||||
}
|
||||
|
||||
const value = await navigatePathAsync(blockOutput, pathParts, resolutionContext)
|
||||
|
||||
const schema = context.blockOutputSchemas?.[blockId]
|
||||
if (value === undefined && schema) {
|
||||
if (!isPathInSchema(schema, pathParts)) {
|
||||
throw new InvalidFieldError(blockName, pathParts.join('.'), getSchemaFieldNames(schema))
|
||||
}
|
||||
}
|
||||
|
||||
return { value, blockId }
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { REFERENCE } from '@/executor/constants'
|
||||
|
||||
export interface JSONProperty {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
value: unknown
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts builder data (structured JSON properties) into a plain JSON object.
|
||||
*/
|
||||
export function convertBuilderDataToJson(builderData: JSONProperty[]): Record<string, unknown> {
|
||||
if (!Array.isArray(builderData)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
for (const prop of builderData) {
|
||||
if (!prop.name || !prop.name.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const value = convertPropertyValue(prop)
|
||||
result[prop.name] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts builder data into a JSON string with variable references unquoted.
|
||||
*/
|
||||
export function convertBuilderDataToJsonString(builderData: JSONProperty[]): string {
|
||||
if (!Array.isArray(builderData) || builderData.length === 0) {
|
||||
return '{\n \n}'
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
for (const prop of builderData) {
|
||||
if (!prop.name || !prop.name.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
result[prop.name] = prop.value
|
||||
}
|
||||
|
||||
let jsonString = JSON.stringify(result, null, 2)
|
||||
|
||||
jsonString = jsonString.replace(/"(<[^>]+>)"/g, '$1')
|
||||
|
||||
return jsonString
|
||||
}
|
||||
|
||||
export function convertPropertyValue(prop: JSONProperty): unknown {
|
||||
switch (prop.type) {
|
||||
case 'object':
|
||||
return convertObjectValue(prop.value)
|
||||
case 'array':
|
||||
return convertArrayValue(prop.value)
|
||||
case 'number':
|
||||
return convertNumberValue(prop.value)
|
||||
case 'boolean':
|
||||
return convertBooleanValue(prop.value)
|
||||
case 'files':
|
||||
return prop.value
|
||||
default:
|
||||
return prop.value
|
||||
}
|
||||
}
|
||||
|
||||
function convertObjectValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return convertBuilderDataToJson(value as JSONProperty[])
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && !isVariableReference(value)) {
|
||||
return tryParseJson(value, value)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function convertArrayValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item: unknown) => convertArrayItem(item))
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && !isVariableReference(value)) {
|
||||
const parsed = tryParseJson(value, value)
|
||||
return Array.isArray(parsed) ? parsed : value
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function convertArrayItem(item: unknown): unknown {
|
||||
if (typeof item !== 'object' || item === null || !('type' in item)) {
|
||||
return item
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>
|
||||
if (typeof record.type !== 'string') {
|
||||
return item
|
||||
}
|
||||
|
||||
const typed = record as { type: string; value: unknown }
|
||||
|
||||
if (typed.type === 'object' && Array.isArray(typed.value)) {
|
||||
return convertBuilderDataToJson(typed.value as JSONProperty[])
|
||||
}
|
||||
|
||||
if (typed.type === 'array' && Array.isArray(typed.value)) {
|
||||
return (typed.value as unknown[]).map((subItem: unknown) =>
|
||||
typeof subItem === 'object' && subItem !== null && 'value' in subItem
|
||||
? (subItem as { value: unknown }).value
|
||||
: subItem
|
||||
)
|
||||
}
|
||||
|
||||
return typed.value
|
||||
}
|
||||
|
||||
function convertNumberValue(value: unknown): unknown {
|
||||
if (isVariableReference(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
const numValue = Number(value)
|
||||
return Number.isNaN(numValue) ? value : numValue
|
||||
}
|
||||
|
||||
function convertBooleanValue(value: unknown): unknown {
|
||||
if (isVariableReference(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
return value === 'true' || value === true
|
||||
}
|
||||
|
||||
function tryParseJson(jsonString: string, fallback: unknown): unknown {
|
||||
try {
|
||||
return JSON.parse(jsonString)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function isVariableReference(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.trim().startsWith(REFERENCE.START) &&
|
||||
value.trim().includes(REFERENCE.END)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Formats a JavaScript/TypeScript value as a code literal for the target language.
|
||||
* Handles special cases like null, undefined, booleans, and Python-specific number representations.
|
||||
*
|
||||
* @param value - The value to format
|
||||
* @param language - Target language ('javascript' or 'python')
|
||||
* @returns A string literal representation valid in the target language
|
||||
*
|
||||
* @example
|
||||
* formatLiteralForCode(null, 'python') // => 'None'
|
||||
* formatLiteralForCode(true, 'python') // => 'True'
|
||||
* formatLiteralForCode(NaN, 'python') // => "float('nan')"
|
||||
* formatLiteralForCode("hello", 'javascript') // => '"hello"'
|
||||
* formatLiteralForCode({a: 1}, 'python') // => "json.loads('{\"a\":1}')"
|
||||
*/
|
||||
export function formatLiteralForCode(value: unknown, language: 'javascript' | 'python'): string {
|
||||
const isPython = language === 'python'
|
||||
|
||||
if (value === undefined) {
|
||||
return isPython ? 'None' : 'undefined'
|
||||
}
|
||||
if (value === null) {
|
||||
return isPython ? 'None' : 'null'
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return isPython ? (value ? 'True' : 'False') : String(value)
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isNaN(value)) {
|
||||
return isPython ? "float('nan')" : 'NaN'
|
||||
}
|
||||
if (value === Number.POSITIVE_INFINITY) {
|
||||
return isPython ? "float('inf')" : 'Infinity'
|
||||
}
|
||||
if (value === Number.NEGATIVE_INFINITY) {
|
||||
return isPython ? "float('-inf')" : '-Infinity'
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
// Objects and arrays - Python needs json.loads() because JSON true/false/null aren't valid Python
|
||||
if (isPython) {
|
||||
return `json.loads(${JSON.stringify(JSON.stringify(value))})`
|
||||
}
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { ExecutionContext, ExecutionResult } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
/**
|
||||
* Interface for errors that carry an ExecutionResult.
|
||||
* Used when workflow execution fails and we want to preserve partial results.
|
||||
*/
|
||||
export interface ErrorWithExecutionResult extends Error {
|
||||
executionResult: ExecutionResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error carries an ExecutionResult.
|
||||
* Validates that executionResult has required fields (success, output).
|
||||
*/
|
||||
export function hasExecutionResult(error: unknown): error is ErrorWithExecutionResult {
|
||||
if (
|
||||
!(error instanceof Error) ||
|
||||
!('executionResult' in error) ||
|
||||
error.executionResult == null ||
|
||||
typeof error.executionResult !== 'object'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const result = error.executionResult as Record<string, unknown>
|
||||
return typeof result.success === 'boolean' && result.output != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches an ExecutionResult to an error for propagation to parent workflows.
|
||||
*/
|
||||
export function attachExecutionResult(error: Error, executionResult: ExecutionResult): void {
|
||||
Object.assign(error, { executionResult })
|
||||
}
|
||||
|
||||
export interface BlockExecutionErrorDetails {
|
||||
block: SerializedBlock
|
||||
error: Error | string
|
||||
context?: ExecutionContext
|
||||
additionalInfo?: Record<string, any>
|
||||
}
|
||||
|
||||
export function buildBlockExecutionError(details: BlockExecutionErrorDetails): Error {
|
||||
const errorMessage =
|
||||
details.error instanceof Error ? details.error.message : String(details.error)
|
||||
const blockName = details.block.metadata?.name || details.block.id
|
||||
const blockType = details.block.metadata?.id || 'unknown'
|
||||
|
||||
const error = new Error(`${blockName}: ${errorMessage}`)
|
||||
|
||||
const innerStatusCode = readStatusCode(details.error)
|
||||
|
||||
Object.assign(error, {
|
||||
blockId: details.block.id,
|
||||
blockName,
|
||||
blockType,
|
||||
workflowId: details.context?.workflowId,
|
||||
timestamp: new Date().toISOString(),
|
||||
...details.additionalInfo,
|
||||
...(innerStatusCode !== undefined ? { statusCode: innerStatusCode } : {}),
|
||||
})
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
export function buildHTTPError(config: {
|
||||
status: number
|
||||
url?: string
|
||||
method?: string
|
||||
message?: string
|
||||
}): Error {
|
||||
let errorMessage = config.message || `HTTP ${config.method || 'request'} failed`
|
||||
|
||||
if (config.url) {
|
||||
errorMessage += ` - ${config.url}`
|
||||
}
|
||||
|
||||
if (config.status) {
|
||||
errorMessage += ` (Status: ${config.status})`
|
||||
}
|
||||
|
||||
const error = new Error(errorMessage)
|
||||
|
||||
Object.assign(error, {
|
||||
status: config.status,
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
function readStatusCode(value: unknown): number | undefined {
|
||||
if (!(value instanceof Error)) return undefined
|
||||
const status = (value as unknown as { statusCode?: unknown }).statusCode
|
||||
return typeof status === 'number' ? status : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an execution error to an HTTP status code. Errors thrown from the
|
||||
* executor that represent workflow-author mistakes (invalid field references,
|
||||
* etc.) carry a 4xx `statusCode`; everything else is a 500.
|
||||
*/
|
||||
export function getExecutionErrorStatus(error: unknown): number {
|
||||
const status = readStatusCode(error)
|
||||
if (status !== undefined && status >= 400 && status < 500) {
|
||||
return status
|
||||
}
|
||||
return 500
|
||||
}
|
||||
|
||||
export function normalizeError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { isUserFile } from '@/lib/core/utils/user-file'
|
||||
import { uploadExecutionFile, uploadFileFromRawData } from '@/lib/uploads/contexts/execution'
|
||||
import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server'
|
||||
import type { ExecutionContext, UserFile } from '@/executor/types'
|
||||
import type { ToolConfig, ToolFileData } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('FileToolProcessor')
|
||||
|
||||
/**
|
||||
* Processes tool outputs and converts file-typed outputs to UserFile objects.
|
||||
* This enables tools to return file data that gets automatically stored in the
|
||||
* execution filesystem and made available as UserFile objects for workflow use.
|
||||
*/
|
||||
export class FileToolProcessor {
|
||||
/**
|
||||
* Process tool outputs and convert file-typed outputs to UserFile objects
|
||||
*/
|
||||
static async processToolOutputs(
|
||||
toolOutput: any,
|
||||
toolConfig: ToolConfig,
|
||||
executionContext: ExecutionContext
|
||||
): Promise<any> {
|
||||
if (!toolConfig.outputs) {
|
||||
return toolOutput
|
||||
}
|
||||
|
||||
const processedOutput = { ...toolOutput }
|
||||
|
||||
for (const [outputKey, outputDef] of Object.entries(toolConfig.outputs)) {
|
||||
if (!FileToolProcessor.isFileOutput(outputDef.type)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fileData = processedOutput[outputKey]
|
||||
if (!fileData) {
|
||||
logger.warn(`File-typed output '${outputKey}' is missing from tool result`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
processedOutput[outputKey] = await FileToolProcessor.processFileOutput(
|
||||
fileData,
|
||||
outputDef.type,
|
||||
outputKey,
|
||||
executionContext
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`Error processing file output '${outputKey}':`, error)
|
||||
const errorMessage = toError(error).message
|
||||
throw new Error(`Failed to process file output '${outputKey}': ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
return processedOutput
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an output type is file-related
|
||||
*/
|
||||
private static isFileOutput(type: string): boolean {
|
||||
return type === 'file' || type === 'file[]'
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single file output (either single file or array of files)
|
||||
*/
|
||||
private static async processFileOutput(
|
||||
fileData: any,
|
||||
outputType: string,
|
||||
outputKey: string,
|
||||
executionContext: ExecutionContext
|
||||
): Promise<UserFile | UserFile[]> {
|
||||
if (outputType === 'file[]') {
|
||||
return FileToolProcessor.processFileArray(fileData, outputKey, executionContext)
|
||||
}
|
||||
return FileToolProcessor.processFileData(fileData, executionContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an array of files
|
||||
*/
|
||||
private static async processFileArray(
|
||||
fileData: any,
|
||||
outputKey: string,
|
||||
executionContext: ExecutionContext
|
||||
): Promise<UserFile[]> {
|
||||
if (!Array.isArray(fileData)) {
|
||||
throw new Error(`Output '${outputKey}' is marked as file[] but is not an array`)
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
fileData.map((file, index) => FileToolProcessor.processFileData(file, executionContext))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert various file data formats to UserFile by storing in execution filesystem.
|
||||
* If the input is already a UserFile, returns it unchanged.
|
||||
*/
|
||||
private static async processFileData(
|
||||
fileData: ToolFileData | UserFile,
|
||||
context: ExecutionContext
|
||||
): Promise<UserFile> {
|
||||
// If already a UserFile (e.g., from tools that handle their own file storage),
|
||||
// return it directly without re-processing
|
||||
if (isUserFile(fileData)) {
|
||||
return fileData as UserFile
|
||||
}
|
||||
|
||||
const data = fileData as ToolFileData
|
||||
try {
|
||||
let buffer: Buffer | null = null
|
||||
|
||||
if (Buffer.isBuffer(data.data)) {
|
||||
buffer = data.data
|
||||
} else if (
|
||||
data.data &&
|
||||
typeof data.data === 'object' &&
|
||||
'type' in data.data &&
|
||||
'data' in data.data
|
||||
) {
|
||||
const serializedBuffer = data.data as { type: string; data: number[] }
|
||||
if (serializedBuffer.type === 'Buffer' && Array.isArray(serializedBuffer.data)) {
|
||||
buffer = Buffer.from(serializedBuffer.data)
|
||||
} else {
|
||||
throw new Error(`Invalid serialized buffer format for ${data.name}`)
|
||||
}
|
||||
} else if (typeof data.data === 'string' && data.data) {
|
||||
let base64Data = data.data
|
||||
|
||||
if (base64Data.includes('-') || base64Data.includes('_')) {
|
||||
base64Data = base64Data.replace(/-/g, '+').replace(/_/g, '/')
|
||||
}
|
||||
|
||||
buffer = Buffer.from(base64Data, 'base64')
|
||||
}
|
||||
|
||||
if (!buffer && data.url) {
|
||||
buffer = await downloadFileFromUrl(data.url, { userId: context.userId })
|
||||
}
|
||||
|
||||
if (buffer) {
|
||||
if (buffer.length === 0) {
|
||||
throw new Error(`File '${data.name}' has zero bytes`)
|
||||
}
|
||||
|
||||
return await uploadExecutionFile(
|
||||
{
|
||||
workspaceId: context.workspaceId || '',
|
||||
workflowId: context.workflowId,
|
||||
executionId: context.executionId || '',
|
||||
},
|
||||
buffer,
|
||||
data.name,
|
||||
data.mimeType,
|
||||
context.userId
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.data) {
|
||||
throw new Error(
|
||||
`File data for '${data.name}' must have either 'data' (Buffer/base64) or 'url' property`
|
||||
)
|
||||
}
|
||||
|
||||
return uploadFileFromRawData(
|
||||
{
|
||||
name: data.name,
|
||||
data: data.data,
|
||||
mimeType: data.mimeType,
|
||||
},
|
||||
{
|
||||
workspaceId: context.workspaceId || '',
|
||||
workflowId: context.workflowId,
|
||||
executionId: context.executionId || '',
|
||||
},
|
||||
context.userId
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`Error processing file data for '${data.name}':`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool has any file-typed outputs
|
||||
*/
|
||||
static hasFileOutputs(toolConfig: ToolConfig): boolean {
|
||||
if (!toolConfig.outputs) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Object.values(toolConfig.outputs).some(
|
||||
(output) => output.type === 'file' || output.type === 'file[]'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { generateInternalToken } from '@/lib/auth/internal'
|
||||
import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { HTTP } from '@/executor/constants'
|
||||
|
||||
export async function buildAuthHeaders(userId?: string): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': HTTP.CONTENT_TYPE.JSON,
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
const token = await generateInternalToken(userId)
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
export function buildAPIUrl(path: string, params?: Record<string, string>): URL {
|
||||
const baseUrl = path.startsWith('/api/') ? getInternalApiBaseUrl() : getBaseUrl()
|
||||
const url = new URL(path, baseUrl)
|
||||
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
export async function extractAPIErrorMessage(response: Response): Promise<string> {
|
||||
const defaultMessage = `API request failed with status ${response.status}`
|
||||
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return errorData.error || defaultMessage
|
||||
} catch {
|
||||
return defaultMessage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import {
|
||||
buildContainerIterationContext,
|
||||
buildUnifiedParentIterations,
|
||||
getIterationContext,
|
||||
type IterationNodeMetadata,
|
||||
} from './iteration-context'
|
||||
|
||||
function makeCtx(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
|
||||
return {
|
||||
workflowId: 'wf-1',
|
||||
executionId: 'exec-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
blockStates: {},
|
||||
blockLogs: [],
|
||||
executedBlocks: [],
|
||||
environmentVariables: {},
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
completedLoops: new Set(),
|
||||
activeExecutionPath: [],
|
||||
executionOrder: 0,
|
||||
...overrides,
|
||||
} as unknown as ExecutionContext
|
||||
}
|
||||
|
||||
describe('getIterationContext', () => {
|
||||
it('returns undefined for undefined metadata', () => {
|
||||
const ctx = makeCtx()
|
||||
expect(getIterationContext(ctx, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves parallel branch metadata', () => {
|
||||
const ctx = makeCtx({
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'p1',
|
||||
{
|
||||
parallelId: 'p1',
|
||||
totalBranches: 3,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const metadata: IterationNodeMetadata = {
|
||||
branchIndex: 1,
|
||||
branchTotal: 3,
|
||||
subflowId: 'p1',
|
||||
subflowType: 'parallel',
|
||||
}
|
||||
const result = getIterationContext(ctx, metadata)
|
||||
expect(result).toEqual({
|
||||
iterationCurrent: 1,
|
||||
iterationTotal: 3,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'p1',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves loop node metadata', () => {
|
||||
const ctx = makeCtx({
|
||||
loopExecutions: new Map([
|
||||
[
|
||||
'l1',
|
||||
{
|
||||
iteration: 2,
|
||||
maxIterations: 5,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const metadata: IterationNodeMetadata = {
|
||||
isLoopNode: true,
|
||||
subflowId: 'l1',
|
||||
subflowType: 'loop',
|
||||
}
|
||||
const result = getIterationContext(ctx, metadata)
|
||||
expect(result).toEqual({
|
||||
iterationCurrent: 2,
|
||||
iterationTotal: 5,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: 'l1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildUnifiedParentIterations', () => {
|
||||
it('returns empty array when no parent maps exist', () => {
|
||||
const ctx = makeCtx()
|
||||
expect(buildUnifiedParentIterations(ctx, 'some-id')).toEqual([])
|
||||
})
|
||||
|
||||
it('resolves loop-in-loop parent chain', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([['inner-loop', { parentId: 'outer-loop', parentType: 'loop' }]]),
|
||||
loopExecutions: new Map([
|
||||
[
|
||||
'outer-loop',
|
||||
{
|
||||
iteration: 1,
|
||||
maxIterations: 3,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildUnifiedParentIterations(ctx, 'inner-loop')
|
||||
expect(result).toEqual([
|
||||
{
|
||||
iterationCurrent: 1,
|
||||
iterationTotal: 3,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: 'outer-loop',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves parallel-in-parallel parent chain', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
['inner-p__obranch-2', { parentId: 'outer-p', parentType: 'parallel', branchIndex: 2 }],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'outer-p',
|
||||
{
|
||||
parallelId: 'outer-p',
|
||||
totalBranches: 4,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildUnifiedParentIterations(ctx, 'inner-p__obranch-2')
|
||||
expect(result).toEqual([
|
||||
{
|
||||
iterationCurrent: 2,
|
||||
iterationTotal: 4,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'outer-p',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves loop-in-parallel (cross-type nesting)', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
['loop-1__obranch-1', { parentId: 'parallel-1', parentType: 'parallel', branchIndex: 1 }],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'parallel-1',
|
||||
{
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 5,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildUnifiedParentIterations(ctx, 'loop-1__obranch-1')
|
||||
expect(result).toEqual([
|
||||
{
|
||||
iterationCurrent: 1,
|
||||
iterationTotal: 5,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'parallel-1',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves parallel-in-loop (cross-type nesting)', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([['parallel-1', { parentId: 'loop-1', parentType: 'loop' }]]),
|
||||
loopExecutions: new Map([
|
||||
[
|
||||
'loop-1',
|
||||
{
|
||||
iteration: 3,
|
||||
maxIterations: 5,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildUnifiedParentIterations(ctx, 'parallel-1')
|
||||
expect(result).toEqual([
|
||||
{
|
||||
iterationCurrent: 3,
|
||||
iterationTotal: 5,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: 'loop-1',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves deep cross-type nesting: parallel → loop → parallel', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
['inner-p', { parentId: 'mid-loop', parentType: 'loop' }],
|
||||
['mid-loop', { parentId: 'outer-p', parentType: 'parallel', branchIndex: 0 }],
|
||||
['mid-loop__obranch-2', { parentId: 'outer-p', parentType: 'parallel', branchIndex: 2 }],
|
||||
]),
|
||||
loopExecutions: new Map([
|
||||
[
|
||||
'mid-loop',
|
||||
{
|
||||
iteration: 1,
|
||||
maxIterations: 4,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'outer-p',
|
||||
{
|
||||
parallelId: 'outer-p',
|
||||
totalBranches: 3,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildUnifiedParentIterations(ctx, 'inner-p')
|
||||
expect(result).toEqual([
|
||||
{
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 3,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'outer-p',
|
||||
},
|
||||
{
|
||||
iterationCurrent: 1,
|
||||
iterationTotal: 4,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: 'mid-loop',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves 3-level parallel nesting with branchIndex entries', () => {
|
||||
// P1 → P2 → P3, with P2__obranch-1 and P3__clone0__obranch-1
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
['P2', { parentId: 'P1', parentType: 'parallel', branchIndex: 0 }],
|
||||
['P3', { parentId: 'P2', parentType: 'parallel', branchIndex: 0 }],
|
||||
['P2__obranch-1', { parentId: 'P1', parentType: 'parallel', branchIndex: 1 }],
|
||||
[
|
||||
'P3__clone0__obranch-1',
|
||||
{ parentId: 'P2__obranch-1', parentType: 'parallel', branchIndex: 0 },
|
||||
],
|
||||
['P3__obranch-1', { parentId: 'P2', parentType: 'parallel', branchIndex: 1 }],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'P1',
|
||||
{
|
||||
parallelId: 'P1',
|
||||
totalBranches: 2,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
[
|
||||
'P2',
|
||||
{
|
||||
parallelId: 'P2',
|
||||
totalBranches: 2,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
[
|
||||
'P2__obranch-1',
|
||||
{
|
||||
parallelId: 'P2__obranch-1',
|
||||
totalBranches: 2,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
|
||||
// P3 (original): inside P2 branch 0, inside P1 branch 0
|
||||
expect(buildUnifiedParentIterations(ctx, 'P3')).toEqual([
|
||||
{
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 2,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'P1',
|
||||
},
|
||||
{
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 2,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'P2',
|
||||
},
|
||||
])
|
||||
|
||||
// P3__obranch-1 (runtime clone): inside P2 branch 1, inside P1 branch 0
|
||||
expect(buildUnifiedParentIterations(ctx, 'P3__obranch-1')).toEqual([
|
||||
{
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 2,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'P1',
|
||||
},
|
||||
{
|
||||
iterationCurrent: 1,
|
||||
iterationTotal: 2,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'P2',
|
||||
},
|
||||
])
|
||||
|
||||
// P3__clone0__obranch-1 (pre-expansion clone): inside P2__obranch-1 branch 0, inside P1 branch 1
|
||||
expect(buildUnifiedParentIterations(ctx, 'P3__clone0__obranch-1')).toEqual([
|
||||
{
|
||||
iterationCurrent: 1,
|
||||
iterationTotal: 2,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'P1',
|
||||
},
|
||||
{
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 2,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'P2__obranch-1',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('includes parent iterations in getIterationContext for loop-in-parallel', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
['loop-1__obranch-2', { parentId: 'parallel-1', parentType: 'parallel', branchIndex: 2 }],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'parallel-1',
|
||||
{
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 5,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
loopExecutions: new Map([
|
||||
[
|
||||
'loop-1__obranch-2',
|
||||
{
|
||||
iteration: 3,
|
||||
maxIterations: 5,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
|
||||
const metadata: IterationNodeMetadata = {
|
||||
isLoopNode: true,
|
||||
subflowId: 'loop-1__obranch-2',
|
||||
subflowType: 'loop',
|
||||
}
|
||||
const result = getIterationContext(ctx, metadata)
|
||||
expect(result).toEqual({
|
||||
iterationCurrent: 3,
|
||||
iterationTotal: 5,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: 'loop-1__obranch-2',
|
||||
parentIterations: [
|
||||
{
|
||||
iterationCurrent: 2,
|
||||
iterationTotal: 5,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'parallel-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('includes parent iterations in getIterationContext for parallel-in-loop', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([['parallel-1', { parentId: 'loop-1', parentType: 'loop' }]]),
|
||||
loopExecutions: new Map([
|
||||
[
|
||||
'loop-1',
|
||||
{
|
||||
iteration: 2,
|
||||
maxIterations: 5,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'parallel-1',
|
||||
{
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 3,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
|
||||
const metadata: IterationNodeMetadata = {
|
||||
branchIndex: 1,
|
||||
branchTotal: 3,
|
||||
subflowId: 'parallel-1',
|
||||
subflowType: 'parallel',
|
||||
}
|
||||
const result = getIterationContext(ctx, metadata)
|
||||
expect(result).toEqual({
|
||||
iterationCurrent: 1,
|
||||
iterationTotal: 3,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'parallel-1',
|
||||
parentIterations: [
|
||||
{
|
||||
iterationCurrent: 2,
|
||||
iterationTotal: 5,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: 'loop-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildContainerIterationContext', () => {
|
||||
it('returns undefined when no parent map exists', () => {
|
||||
const ctx = makeCtx()
|
||||
expect(buildContainerIterationContext(ctx, 'loop-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when container is not in parent map', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map(),
|
||||
})
|
||||
expect(buildContainerIterationContext(ctx, 'loop-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves loop nested inside parallel', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
['loop-1__obranch-2', { parentId: 'parallel-1', parentType: 'parallel', branchIndex: 2 }],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'parallel-1',
|
||||
{
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 5,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildContainerIterationContext(ctx, 'loop-1__obranch-2')
|
||||
expect(result).toEqual({
|
||||
iterationCurrent: 2,
|
||||
iterationTotal: 5,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'parallel-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves parallel nested inside loop', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([['parallel-1', { parentId: 'loop-1', parentType: 'loop' }]]),
|
||||
loopExecutions: new Map([
|
||||
[
|
||||
'loop-1',
|
||||
{
|
||||
iteration: 3,
|
||||
maxIterations: 10,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildContainerIterationContext(ctx, 'parallel-1')
|
||||
expect(result).toEqual({
|
||||
iterationCurrent: 3,
|
||||
iterationTotal: 10,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: 'loop-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined when parent scope is missing', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
['loop-1', { parentId: 'parallel-1', parentType: 'parallel', branchIndex: 0 }],
|
||||
]),
|
||||
parallelExecutions: new Map(),
|
||||
})
|
||||
expect(buildContainerIterationContext(ctx, 'loop-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves pre-expansion clone with explicit branchIndex', () => {
|
||||
// P1 → P2 → P3: P3__clone0__obranch-1 is pre-cloned inside P2__obranch-1
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
[
|
||||
'P3__clone0__obranch-1',
|
||||
{ parentId: 'P2__obranch-1', parentType: 'parallel', branchIndex: 0 },
|
||||
],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'P2__obranch-1',
|
||||
{
|
||||
parallelId: 'P2__obranch-1',
|
||||
totalBranches: 5,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildContainerIterationContext(ctx, 'P3__clone0__obranch-1')
|
||||
expect(result).toEqual({
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 5,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'P2__obranch-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses branch index 0 for non-cloned container in parallel parent', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
['inner-loop', { parentId: 'outer-parallel', parentType: 'parallel', branchIndex: 0 }],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'outer-parallel',
|
||||
{
|
||||
parallelId: 'outer-parallel',
|
||||
totalBranches: 3,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildContainerIterationContext(ctx, 'inner-loop')
|
||||
expect(result).toEqual({
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 3,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'outer-parallel',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { DEFAULTS } from '@/executor/constants'
|
||||
import type { NodeMetadata } from '@/executor/dag/types'
|
||||
import type { IterationContext, ParentIteration } from '@/executor/execution/types'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import { findEffectiveContainerId } from '@/executor/utils/subflow-utils'
|
||||
|
||||
/** Maximum ancestor depth to prevent runaway traversal in deeply nested subflows. */
|
||||
const MAX_PARENT_DEPTH = DEFAULTS.MAX_NESTING_DEPTH
|
||||
|
||||
/**
|
||||
* Subset of {@link NodeMetadata} needed for iteration context resolution.
|
||||
* Compatible with both DAGNode.metadata and inline metadata objects.
|
||||
*/
|
||||
export type IterationNodeMetadata = Pick<
|
||||
NodeMetadata,
|
||||
'subflowType' | 'subflowId' | 'branchIndex' | 'branchTotal' | 'isLoopNode'
|
||||
>
|
||||
|
||||
/**
|
||||
* Resolves the iteration context for a node based on its metadata and execution state.
|
||||
* Handles both parallel (branch) and loop iteration contexts, including cross-type
|
||||
* nesting (loop-in-parallel, parallel-in-loop) via the unified subflow parent map.
|
||||
*/
|
||||
export function getIterationContext(
|
||||
ctx: ExecutionContext,
|
||||
metadata: IterationNodeMetadata | undefined
|
||||
): IterationContext | undefined {
|
||||
if (!metadata) return undefined
|
||||
|
||||
if (metadata.branchIndex !== undefined && metadata.branchTotal !== undefined) {
|
||||
const parallelId = metadata.subflowType === 'parallel' ? metadata.subflowId : undefined
|
||||
const parentIterations = parallelId ? buildUnifiedParentIterations(ctx, parallelId) : []
|
||||
return {
|
||||
iterationCurrent: metadata.branchIndex,
|
||||
iterationTotal: metadata.branchTotal,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: parallelId,
|
||||
...(parentIterations.length > 0 && { parentIterations }),
|
||||
}
|
||||
}
|
||||
|
||||
const loopId = metadata.subflowType === 'loop' ? metadata.subflowId : undefined
|
||||
if (metadata.isLoopNode && loopId) {
|
||||
const loopScope = ctx.loopExecutions?.get(loopId)
|
||||
if (loopScope && loopScope.iteration !== undefined) {
|
||||
const parentIterations = buildUnifiedParentIterations(ctx, loopId)
|
||||
return {
|
||||
iterationCurrent: loopScope.iteration,
|
||||
iterationTotal: loopScope.maxIterations,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: loopId,
|
||||
...(parentIterations.length > 0 && { parentIterations }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a single-level iteration context for a container (loop/parallel) that is
|
||||
* nested inside a parent subflow. Used by orchestrators when emitting onBlockComplete
|
||||
* for container sentinel nodes.
|
||||
*/
|
||||
export function buildContainerIterationContext(
|
||||
ctx: ExecutionContext,
|
||||
containerId: string
|
||||
): IterationContext | undefined {
|
||||
const parentEntry = ctx.subflowParentMap?.get(containerId)
|
||||
if (!parentEntry) return undefined
|
||||
|
||||
if (parentEntry.parentType === 'parallel') {
|
||||
if (parentEntry.branchIndex !== undefined) {
|
||||
const parentScope = ctx.parallelExecutions?.get(parentEntry.parentId)
|
||||
if (!parentScope) return undefined
|
||||
|
||||
return {
|
||||
iterationCurrent: parentEntry.branchIndex,
|
||||
iterationTotal: parentScope.totalBranches,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: parentEntry.parentId,
|
||||
}
|
||||
}
|
||||
} else if (parentEntry.parentType === 'loop') {
|
||||
const effectiveParentId = ctx.loopExecutions
|
||||
? findEffectiveContainerId(parentEntry.parentId, containerId, ctx.loopExecutions)
|
||||
: parentEntry.parentId
|
||||
const parentScope = ctx.loopExecutions?.get(effectiveParentId)
|
||||
if (parentScope && parentScope.iteration !== undefined) {
|
||||
return {
|
||||
iterationCurrent: parentScope.iteration,
|
||||
iterationTotal: parentScope.maxIterations,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: effectiveParentId,
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the unified subflow parent map to build the full ancestor iteration chain,
|
||||
* handling all nesting combinations (loop-in-loop, parallel-in-parallel,
|
||||
* loop-in-parallel, parallel-in-loop).
|
||||
*
|
||||
* Returns an array of parent iteration contexts, ordered from outermost to innermost.
|
||||
*/
|
||||
export function buildUnifiedParentIterations(
|
||||
ctx: ExecutionContext,
|
||||
subflowId: string
|
||||
): ParentIteration[] {
|
||||
if (!ctx.subflowParentMap) {
|
||||
return []
|
||||
}
|
||||
|
||||
const parents: ParentIteration[] = []
|
||||
const visited = new Set<string>()
|
||||
let currentId = subflowId
|
||||
|
||||
while (
|
||||
ctx.subflowParentMap.has(currentId) &&
|
||||
!visited.has(currentId) &&
|
||||
visited.size < MAX_PARENT_DEPTH
|
||||
) {
|
||||
visited.add(currentId)
|
||||
const entry = ctx.subflowParentMap.get(currentId)!
|
||||
const { parentId, parentType } = entry
|
||||
|
||||
if (parentType === 'loop') {
|
||||
// Resolve the effective (possibly cloned) loop ID — at runtime the scope
|
||||
// may live under a cloned ID like `mid-loop__obranch-2` rather than `mid-loop`
|
||||
const effectiveParentId = ctx.loopExecutions
|
||||
? findEffectiveContainerId(parentId, currentId, ctx.loopExecutions)
|
||||
: parentId
|
||||
const parentScope = ctx.loopExecutions?.get(effectiveParentId)
|
||||
if (parentScope && parentScope.iteration !== undefined) {
|
||||
parents.unshift({
|
||||
iterationCurrent: parentScope.iteration,
|
||||
iterationTotal: parentScope.maxIterations,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: effectiveParentId,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (entry.branchIndex === undefined) {
|
||||
currentId = parentId
|
||||
continue
|
||||
}
|
||||
const effectiveParentId = parentId
|
||||
const parentScope = ctx.parallelExecutions?.get(effectiveParentId)
|
||||
if (parentScope) {
|
||||
parents.unshift({
|
||||
iterationCurrent: entry.branchIndex,
|
||||
iterationTotal: parentScope.totalBranches,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: effectiveParentId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
currentId = parentId
|
||||
}
|
||||
|
||||
return parents
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { EVALUATOR } from '@/executor/constants'
|
||||
|
||||
const logger = createLogger('JSONUtils')
|
||||
|
||||
export function parseJSON<T>(value: unknown, fallback: T): T {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value.trim())
|
||||
} catch (error) {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function parseJSONOrThrow(value: string): any {
|
||||
try {
|
||||
return JSON.parse(value.trim())
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON: ${getErrorMessage(error, 'Parse error')}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeJSONString(value: string): string {
|
||||
return value.replace(/'/g, '"')
|
||||
}
|
||||
|
||||
export function stringifyJSON(value: any, indent?: number): string {
|
||||
try {
|
||||
return JSON.stringify(value, null, indent ?? EVALUATOR.JSON_INDENT)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to stringify value, returning string representation', { error })
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
export function isJSONString(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
return trimmed.startsWith('{') || trimmed.startsWith('[')
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively parses JSON strings within an object or array.
|
||||
* Useful for normalizing data that may contain stringified JSON at various levels.
|
||||
*/
|
||||
export function parseObjectStrings(data: unknown): unknown {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return parseObjectStrings(parsed)
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
return data
|
||||
}
|
||||
} else if (Array.isArray(data)) {
|
||||
return data.map((item) => parseObjectStrings(item))
|
||||
} else if (typeof data === 'object' && data !== null) {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
result[key] = parseObjectStrings(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowBlocks } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||
|
||||
const logger = createLogger('LazyCleanup')
|
||||
|
||||
/**
|
||||
* Extract valid field names from a child workflow's start block inputFormat
|
||||
*
|
||||
* @param childWorkflowBlocks - The blocks from the child workflow state
|
||||
* @returns Set of valid field names defined in the child's inputFormat
|
||||
*/
|
||||
function extractValidInputFieldNames(childWorkflowBlocks: Record<string, any>): Set<string> | null {
|
||||
const fields = extractInputFieldsFromBlocks(childWorkflowBlocks)
|
||||
|
||||
if (fields.length === 0) {
|
||||
logger.debug('No inputFormat fields found in child workflow')
|
||||
return null
|
||||
}
|
||||
|
||||
return new Set(fields.map((field) => field.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up orphaned inputMapping fields that don't exist in child workflow's inputFormat.
|
||||
* This is a lazy cleanup that only runs at execution time and only persists if changes are needed.
|
||||
*
|
||||
* @param parentWorkflowId - The parent workflow ID
|
||||
* @param parentBlockId - The workflow block ID in the parent
|
||||
* @param currentInputMapping - The current inputMapping value from the parent block
|
||||
* @param childWorkflowBlocks - The blocks from the child workflow
|
||||
* @returns The cleaned inputMapping (only different if cleanup was needed)
|
||||
*/
|
||||
export async function lazyCleanupInputMapping(
|
||||
parentWorkflowId: string,
|
||||
parentBlockId: string,
|
||||
currentInputMapping: any,
|
||||
childWorkflowBlocks: Record<string, any>
|
||||
): Promise<any> {
|
||||
try {
|
||||
if (
|
||||
!currentInputMapping ||
|
||||
typeof currentInputMapping !== 'object' ||
|
||||
Array.isArray(currentInputMapping)
|
||||
) {
|
||||
return currentInputMapping
|
||||
}
|
||||
|
||||
const validFieldNames = extractValidInputFieldNames(childWorkflowBlocks)
|
||||
|
||||
if (!validFieldNames || validFieldNames.size === 0) {
|
||||
logger.debug('Child workflow has no inputFormat fields, skipping cleanup')
|
||||
return currentInputMapping
|
||||
}
|
||||
|
||||
const orphanedFields: string[] = []
|
||||
for (const fieldName of Object.keys(currentInputMapping)) {
|
||||
if (!validFieldNames.has(fieldName)) {
|
||||
orphanedFields.push(fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
if (orphanedFields.length === 0) {
|
||||
return currentInputMapping
|
||||
}
|
||||
|
||||
const cleanedMapping: Record<string, any> = {}
|
||||
for (const [fieldName, fieldValue] of Object.entries(currentInputMapping)) {
|
||||
if (validFieldNames.has(fieldName)) {
|
||||
cleanedMapping[fieldName] = fieldValue
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Lazy cleanup: Removing ${orphanedFields.length} orphaned field(s) from inputMapping in workflow ${parentWorkflowId}, block ${parentBlockId}: ${orphanedFields.join(', ')}`
|
||||
)
|
||||
|
||||
persistCleanedMapping(parentWorkflowId, parentBlockId, cleanedMapping).catch((error) => {
|
||||
logger.error('Failed to persist cleaned inputMapping:', error)
|
||||
})
|
||||
|
||||
return cleanedMapping
|
||||
} catch (error) {
|
||||
logger.error('Error in lazy cleanup:', error)
|
||||
return currentInputMapping
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the cleaned inputMapping to the database
|
||||
*
|
||||
* @param workflowId - The workflow ID
|
||||
* @param blockId - The block ID
|
||||
* @param cleanedMapping - The cleaned inputMapping value
|
||||
*/
|
||||
async function persistCleanedMapping(
|
||||
workflowId: string,
|
||||
blockId: string,
|
||||
cleanedMapping: Record<string, any>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const [block] = await tx
|
||||
.select({ subBlocks: workflowBlocks.subBlocks })
|
||||
.from(workflowBlocks)
|
||||
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
||||
.limit(1)
|
||||
|
||||
if (!block) {
|
||||
logger.warn(`Block ${blockId} not found in workflow ${workflowId}, skipping persistence`)
|
||||
return
|
||||
}
|
||||
|
||||
const subBlocks = (block.subBlocks as Record<string, any>) || {}
|
||||
|
||||
if (subBlocks.inputMapping) {
|
||||
subBlocks.inputMapping = {
|
||||
...subBlocks.inputMapping,
|
||||
value: cleanedMapping,
|
||||
}
|
||||
|
||||
// Persist updated subBlocks
|
||||
await tx
|
||||
.update(workflowBlocks)
|
||||
.set({
|
||||
subBlocks: subBlocks,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
||||
|
||||
logger.info(`Successfully persisted cleaned inputMapping for block ${blockId}`)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error persisting cleaned mapping:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { filterOutputForLog } from '@/executor/utils/output-filter'
|
||||
|
||||
vi.mock('@/blocks', () => ({
|
||||
getBlock: () => undefined,
|
||||
}))
|
||||
|
||||
describe('output filtering', () => {
|
||||
it('preserves special top-level output keys as own fields', () => {
|
||||
const rawOutput: Record<string, unknown> = {}
|
||||
Object.defineProperty(rawOutput, 'constructor', {
|
||||
value: { safe: true },
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
const output = filterOutputForLog('', rawOutput)
|
||||
|
||||
expect(Object.hasOwn(output, 'constructor')).toBe(true)
|
||||
expect(output.constructor).toEqual({ safe: true })
|
||||
expect(Object.getPrototypeOf(output)).toBe(Object.prototype)
|
||||
})
|
||||
|
||||
it('preserves special nested output keys as own fields', () => {
|
||||
const nested: Record<string, unknown> = {}
|
||||
Object.defineProperty(nested, '__proto__', {
|
||||
value: { safe: true },
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
const filtered = filterHiddenOutputKeys({
|
||||
nested,
|
||||
}) as { nested: Record<string, unknown> }
|
||||
|
||||
expect(Object.hasOwn(filtered.nested, '__proto__')).toBe(true)
|
||||
expect(filtered.nested.__proto__).toEqual({ safe: true })
|
||||
expect(Object.getPrototypeOf(filtered.nested)).toBe(Object.prototype)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
|
||||
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { isHiddenFromDisplay } from '@/blocks/types'
|
||||
import { isTriggerBehavior, isTriggerInternalKey } from '@/executor/constants'
|
||||
import type { NormalizedBlockOutput } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
function setFilteredOutputValue(
|
||||
output: Record<string, unknown>,
|
||||
key: string,
|
||||
value: unknown
|
||||
): void {
|
||||
Object.defineProperty(output, key, {
|
||||
value,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters block output for logging/display purposes.
|
||||
* Removes internal fields and fields marked with hiddenFromDisplay.
|
||||
* Also recursively filters globally hidden keys from nested objects.
|
||||
*
|
||||
* @param blockType - The block type string (e.g., 'human_in_the_loop', 'workflow')
|
||||
* @param output - The raw block output to filter
|
||||
* @param options - Optional configuration
|
||||
* @param options.block - Full SerializedBlock for trigger behavior detection
|
||||
* @param options.additionalHiddenKeys - Extra keys to filter out (e.g., 'resume')
|
||||
*/
|
||||
export function filterOutputForLog(
|
||||
blockType: string,
|
||||
output: NormalizedBlockOutput,
|
||||
options?: {
|
||||
block?: SerializedBlock
|
||||
additionalHiddenKeys?: string[]
|
||||
}
|
||||
): NormalizedBlockOutput {
|
||||
if (typeof output !== 'object' || output === null || Array.isArray(output)) {
|
||||
return output as NormalizedBlockOutput
|
||||
}
|
||||
if (isLargeValueRef(output)) {
|
||||
return output as NormalizedBlockOutput
|
||||
}
|
||||
const blockConfig = blockType ? getBlock(blockType) : undefined
|
||||
const filtered: NormalizedBlockOutput = {}
|
||||
const additionalHiddenKeys = options?.additionalHiddenKeys ?? []
|
||||
|
||||
for (const [key, value] of Object.entries(output)) {
|
||||
// Skip internal keys (underscore prefix)
|
||||
if (key.startsWith('_')) continue
|
||||
|
||||
if (blockConfig?.outputs && isHiddenFromDisplay(blockConfig.outputs[key])) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip runtime-injected trigger keys not in block config
|
||||
if (options?.block && isTriggerBehavior(options.block) && isTriggerInternalKey(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip additional hidden keys specified by caller
|
||||
if (additionalHiddenKeys.includes(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Recursively filter globally hidden keys from nested objects
|
||||
setFilteredOutputValue(filtered, key, filterHiddenOutputKeys(value))
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import { DAGBuilder } from '@/executor/dag/builder'
|
||||
import { EdgeManager } from '@/executor/execution/edge-manager'
|
||||
import { ParallelExpander } from '@/executor/utils/parallel-expansion'
|
||||
import {
|
||||
buildBranchNodeId,
|
||||
buildParallelSentinelEndId,
|
||||
buildParallelSentinelStartId,
|
||||
buildSentinelStartId,
|
||||
stripCloneSuffixes,
|
||||
} 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('Nested parallel expansion + edge resolution', () => {
|
||||
it('waits for every branch terminal before queuing the parallel end sentinel', () => {
|
||||
const parallelId = 'parallel-1'
|
||||
const taskId = 'task-1'
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
createBlock(parallelId, BlockType.PARALLEL),
|
||||
createBlock(taskId, BlockType.FUNCTION),
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: parallelId },
|
||||
{
|
||||
source: parallelId,
|
||||
target: taskId,
|
||||
sourceHandle: 'parallel-start-source',
|
||||
},
|
||||
],
|
||||
loops: {},
|
||||
parallels: {
|
||||
[parallelId]: {
|
||||
id: parallelId,
|
||||
nodes: [taskId],
|
||||
count: 2,
|
||||
parallelType: 'count',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const dag = new DAGBuilder().build(workflow)
|
||||
const expander = new ParallelExpander()
|
||||
const { terminalNodes } = expander.expandParallel(dag, parallelId, 2)
|
||||
const edgeManager = new EdgeManager(dag)
|
||||
const parallelEndId = buildParallelSentinelEndId(parallelId)
|
||||
|
||||
expect(terminalNodes).toEqual([buildBranchNodeId(taskId, 0), buildBranchNodeId(taskId, 1)])
|
||||
expect(dag.nodes.get(parallelEndId)?.incomingEdges).toEqual(new Set(terminalNodes))
|
||||
|
||||
const firstBranchReady = edgeManager.processOutgoingEdges(dag.nodes.get(terminalNodes[0])!, {
|
||||
value: 'first',
|
||||
})
|
||||
expect(firstBranchReady).not.toContain(parallelEndId)
|
||||
|
||||
const secondBranchReady = edgeManager.processOutgoingEdges(dag.nodes.get(terminalNodes[1])!, {
|
||||
value: 'second',
|
||||
})
|
||||
expect(secondBranchReady).toContain(parallelEndId)
|
||||
})
|
||||
|
||||
it('outer parallel expansion clones inner subflow per branch and edge manager resolves correctly', () => {
|
||||
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: 3,
|
||||
parallelType: 'count',
|
||||
},
|
||||
[outerParallelId]: {
|
||||
id: outerParallelId,
|
||||
nodes: [innerParallelId],
|
||||
count: 2,
|
||||
parallelType: 'count',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Step 1: Build the DAG
|
||||
const builder = new DAGBuilder()
|
||||
const dag = builder.build(workflow)
|
||||
|
||||
const outerStartId = buildParallelSentinelStartId(outerParallelId)
|
||||
const outerEndId = buildParallelSentinelEndId(outerParallelId)
|
||||
const innerStartId = buildParallelSentinelStartId(innerParallelId)
|
||||
const innerEndId = buildParallelSentinelEndId(innerParallelId)
|
||||
|
||||
// Verify DAG construction: 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)
|
||||
|
||||
// Step 2: Simulate runtime expansion of outer parallel (count=2)
|
||||
const expander = new ParallelExpander()
|
||||
const outerResult = expander.expandParallel(dag, outerParallelId, 2)
|
||||
|
||||
// After expansion, outer-sentinel-start should point to 2 entry nodes:
|
||||
// branch 0 uses original inner-sentinel-start, branch 1 uses cloned sentinel
|
||||
const outerStart = dag.nodes.get(outerStartId)!
|
||||
const outerStartTargets = Array.from(outerStart.outgoingEdges.values()).map((e) => e.target)
|
||||
expect(outerStartTargets).toHaveLength(2)
|
||||
expect(outerStartTargets).toContain(innerStartId) // branch 0
|
||||
|
||||
// Verify cloned subflow info
|
||||
expect(outerResult.clonedSubflows).toHaveLength(1)
|
||||
expect(outerResult.clonedSubflows[0].originalId).toBe(innerParallelId)
|
||||
expect(outerResult.clonedSubflows[0].outerBranchIndex).toBe(1)
|
||||
|
||||
const clonedInnerParallelId = outerResult.clonedSubflows[0].clonedId
|
||||
const clonedInnerStartId = buildParallelSentinelStartId(clonedInnerParallelId)
|
||||
const clonedInnerEndId = buildParallelSentinelEndId(clonedInnerParallelId)
|
||||
|
||||
expect(outerStartTargets).toContain(clonedInnerStartId) // branch 1
|
||||
expect(dag.nodes.get(clonedInnerStartId)?.metadata).toMatchObject({
|
||||
subflowId: clonedInnerParallelId,
|
||||
subflowType: 'parallel',
|
||||
})
|
||||
|
||||
// Verify cloned parallel config was registered
|
||||
expect(dag.parallelConfigs.has(clonedInnerParallelId)).toBe(true)
|
||||
const clonedConfig = dag.parallelConfigs.get(clonedInnerParallelId)!
|
||||
expect(clonedConfig.count).toBe(3)
|
||||
expect(clonedConfig.nodes).toHaveLength(1)
|
||||
|
||||
// inner-sentinel-end → outer-sentinel-end (branch 0)
|
||||
const innerEnd = dag.nodes.get(innerEndId)!
|
||||
const innerEndTargets = Array.from(innerEnd.outgoingEdges.values()).map((e) => e.target)
|
||||
expect(innerEndTargets).toContain(outerEndId)
|
||||
|
||||
// cloned inner sentinel-end → outer-sentinel-end (branch 1)
|
||||
const clonedInnerEnd = dag.nodes.get(clonedInnerEndId)!
|
||||
const clonedInnerEndTargets = Array.from(clonedInnerEnd.outgoingEdges.values()).map(
|
||||
(e) => e.target
|
||||
)
|
||||
expect(clonedInnerEndTargets).toContain(outerEndId)
|
||||
|
||||
// Entry/terminal nodes from expansion
|
||||
expect(outerResult.entryNodes).toContain(innerStartId)
|
||||
expect(outerResult.entryNodes).toContain(clonedInnerStartId)
|
||||
expect(outerResult.terminalNodes).toContain(innerEndId)
|
||||
expect(outerResult.terminalNodes).toContain(clonedInnerEndId)
|
||||
|
||||
// Step 3: Verify edge manager resolves ready nodes after outer-sentinel-start completes
|
||||
const edgeManager = new EdgeManager(dag)
|
||||
const readyAfterOuterStart = edgeManager.processOutgoingEdges(
|
||||
outerStart,
|
||||
{ sentinelStart: true },
|
||||
false
|
||||
)
|
||||
expect(readyAfterOuterStart).toContain(innerStartId)
|
||||
expect(readyAfterOuterStart).toContain(clonedInnerStartId)
|
||||
|
||||
// Step 4: Expand inner parallel (branch 0's inner) with count=3
|
||||
expander.expandParallel(dag, innerParallelId, 3)
|
||||
|
||||
// Inner sentinel-start should now point to 3 branch nodes
|
||||
const innerStart = dag.nodes.get(innerStartId)!
|
||||
const innerStartTargets = Array.from(innerStart.outgoingEdges.values()).map((e) => e.target)
|
||||
expect(innerStartTargets).toHaveLength(3)
|
||||
|
||||
const branch0 = buildBranchNodeId(functionId, 0)
|
||||
const branch1 = buildBranchNodeId(functionId, 1)
|
||||
const branch2 = buildBranchNodeId(functionId, 2)
|
||||
expect(innerStartTargets).toContain(branch0)
|
||||
expect(innerStartTargets).toContain(branch1)
|
||||
expect(innerStartTargets).toContain(branch2)
|
||||
|
||||
// Step 5: Verify edge manager resolves branch nodes after inner-sentinel-start
|
||||
const readyAfterInnerStart = edgeManager.processOutgoingEdges(
|
||||
innerStart,
|
||||
{ sentinelStart: true },
|
||||
false
|
||||
)
|
||||
expect(readyAfterInnerStart).toContain(branch0)
|
||||
expect(readyAfterInnerStart).toContain(branch1)
|
||||
expect(readyAfterInnerStart).toContain(branch2)
|
||||
|
||||
// Step 6: Simulate branch completions → inner-sentinel-end becomes ready
|
||||
const branch0Node = dag.nodes.get(branch0)!
|
||||
const branch1Node = dag.nodes.get(branch1)!
|
||||
const branch2Node = dag.nodes.get(branch2)!
|
||||
|
||||
edgeManager.processOutgoingEdges(branch0Node, {}, false)
|
||||
edgeManager.processOutgoingEdges(branch1Node, {}, false)
|
||||
const readyAfterBranch2 = edgeManager.processOutgoingEdges(branch2Node, {}, false)
|
||||
expect(readyAfterBranch2).toContain(innerEndId)
|
||||
|
||||
// Step 7: inner-sentinel-end completes → outer-sentinel-end becomes ready
|
||||
// (only if both branches are done — cloned branch must also complete)
|
||||
const readyAfterInnerEnd = edgeManager.processOutgoingEdges(
|
||||
innerEnd,
|
||||
{ sentinelEnd: true, selectedRoute: 'parallel_exit' },
|
||||
false
|
||||
)
|
||||
// outer-sentinel-end has 2 incoming (innerEnd + clonedInnerEnd), not ready yet
|
||||
expect(readyAfterInnerEnd).not.toContain(outerEndId)
|
||||
|
||||
// Expand and complete cloned inner parallel (branch 1's inner)
|
||||
const clonedBlockId = clonedConfig.nodes![0]
|
||||
expander.expandParallel(dag, clonedInnerParallelId, 3)
|
||||
const clonedInnerStart = dag.nodes.get(clonedInnerStartId)!
|
||||
const clonedBranch0 = buildBranchNodeId(clonedBlockId, 0)
|
||||
const clonedBranch1 = buildBranchNodeId(clonedBlockId, 1)
|
||||
const clonedBranch2 = buildBranchNodeId(clonedBlockId, 2)
|
||||
|
||||
edgeManager.processOutgoingEdges(clonedInnerStart, { sentinelStart: true }, false)
|
||||
edgeManager.processOutgoingEdges(dag.nodes.get(clonedBranch0)!, {}, false)
|
||||
edgeManager.processOutgoingEdges(dag.nodes.get(clonedBranch1)!, {}, false)
|
||||
edgeManager.processOutgoingEdges(dag.nodes.get(clonedBranch2)!, {}, false)
|
||||
|
||||
const readyAfterClonedInnerEnd = edgeManager.processOutgoingEdges(
|
||||
clonedInnerEnd,
|
||||
{ sentinelEnd: true, selectedRoute: 'parallel_exit' },
|
||||
false
|
||||
)
|
||||
// Now both branches done → outer-sentinel-end becomes ready
|
||||
expect(readyAfterClonedInnerEnd).toContain(outerEndId)
|
||||
})
|
||||
|
||||
it('preserves regular-to-nested subflow dependencies across expanded branches', () => {
|
||||
const outerParallelId = 'outer-parallel'
|
||||
const innerParallelId = 'inner-parallel'
|
||||
const prepareId = 'prepare'
|
||||
const finishId = 'finish'
|
||||
const innerTaskId = 'inner-task'
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
createBlock(outerParallelId, BlockType.PARALLEL),
|
||||
createBlock(prepareId, BlockType.FUNCTION),
|
||||
createBlock(innerParallelId, BlockType.PARALLEL),
|
||||
createBlock(innerTaskId, BlockType.FUNCTION),
|
||||
createBlock(finishId, BlockType.FUNCTION),
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: outerParallelId },
|
||||
{ source: outerParallelId, target: prepareId, sourceHandle: 'parallel-start-source' },
|
||||
{ source: prepareId, target: innerParallelId },
|
||||
{ source: innerParallelId, target: finishId },
|
||||
{ source: innerParallelId, target: innerTaskId, sourceHandle: 'parallel-start-source' },
|
||||
],
|
||||
loops: {},
|
||||
parallels: {
|
||||
[innerParallelId]: {
|
||||
id: innerParallelId,
|
||||
nodes: [innerTaskId],
|
||||
count: 1,
|
||||
parallelType: 'count',
|
||||
},
|
||||
[outerParallelId]: {
|
||||
id: outerParallelId,
|
||||
nodes: [prepareId, innerParallelId, finishId],
|
||||
count: 2,
|
||||
parallelType: 'count',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const dag = new DAGBuilder().build(workflow)
|
||||
const expander = new ParallelExpander()
|
||||
const result = expander.expandParallel(dag, outerParallelId, 2)
|
||||
|
||||
const clonedInnerId = result.clonedSubflows[0].clonedId
|
||||
const prepareBranchOne = dag.nodes.get(buildBranchNodeId(prepareId, 1))!
|
||||
const clonedInnerStart = buildParallelSentinelStartId(clonedInnerId)
|
||||
const clonedInnerEnd = buildParallelSentinelEndId(clonedInnerId)
|
||||
const finishBranchOne = buildBranchNodeId(finishId, 1)
|
||||
|
||||
expect(
|
||||
Array.from(prepareBranchOne.outgoingEdges.values()).map((edge) => edge.target)
|
||||
).toContain(clonedInnerStart)
|
||||
expect(
|
||||
Array.from(dag.nodes.get(clonedInnerEnd)!.outgoingEdges.values()).map((edge) => edge.target)
|
||||
).toContain(finishBranchOne)
|
||||
expect(
|
||||
Array.from(dag.nodes.get(clonedInnerEnd)!.outgoingEdges.values()).map((edge) => edge.target)
|
||||
).not.toContain(buildBranchNodeId(finishId, 0))
|
||||
|
||||
const outerStartTargets = Array.from(
|
||||
dag.nodes.get(buildParallelSentinelStartId(outerParallelId))!.outgoingEdges.values()
|
||||
).map((edge) => edge.target)
|
||||
expect(outerStartTargets).toEqual([buildBranchNodeId(prepareId, 0), prepareBranchOne.id])
|
||||
|
||||
const outerEndIncoming = dag.nodes.get(
|
||||
buildParallelSentinelEndId(outerParallelId)
|
||||
)!.incomingEdges
|
||||
expect(outerEndIncoming.has(buildBranchNodeId(finishId, 0))).toBe(true)
|
||||
expect(outerEndIncoming.has(finishBranchOne)).toBe(true)
|
||||
})
|
||||
|
||||
it('uses global branch indexes for nested subflow clones in later batches', () => {
|
||||
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: 1,
|
||||
parallelType: 'count',
|
||||
},
|
||||
[outerParallelId]: {
|
||||
id: outerParallelId,
|
||||
nodes: [innerParallelId],
|
||||
count: 4,
|
||||
parallelType: 'count',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const builder = new DAGBuilder()
|
||||
const dag = builder.build(workflow)
|
||||
const expander = new ParallelExpander()
|
||||
const result = expander.expandParallel(dag, outerParallelId, 2, undefined, {
|
||||
branchIndexOffset: 2,
|
||||
totalBranches: 4,
|
||||
})
|
||||
|
||||
expect(result.entryNodes).not.toContain(buildParallelSentinelStartId(innerParallelId))
|
||||
expect(result.clonedSubflows.map((clone) => clone.outerBranchIndex)).toEqual([2, 3])
|
||||
expect(result.clonedSubflows.map((clone) => clone.clonedId)).toEqual([
|
||||
`${innerParallelId}__obranch-2`,
|
||||
`${innerParallelId}__obranch-3`,
|
||||
])
|
||||
})
|
||||
|
||||
it('clears stale regular-to-nested edges when local branch nodes are reused in later batches', () => {
|
||||
const outerParallelId = 'outer-parallel'
|
||||
const innerParallelId = 'inner-parallel'
|
||||
const prepareId = 'prepare'
|
||||
const innerTaskId = 'inner-task'
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
createBlock(outerParallelId, BlockType.PARALLEL),
|
||||
createBlock(prepareId, BlockType.FUNCTION),
|
||||
createBlock(innerParallelId, BlockType.PARALLEL),
|
||||
createBlock(innerTaskId, BlockType.FUNCTION),
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: outerParallelId },
|
||||
{ source: outerParallelId, target: prepareId, sourceHandle: 'parallel-start-source' },
|
||||
{ source: prepareId, target: innerParallelId },
|
||||
{ source: innerParallelId, target: innerTaskId, sourceHandle: 'parallel-start-source' },
|
||||
],
|
||||
loops: {},
|
||||
parallels: {
|
||||
[innerParallelId]: {
|
||||
id: innerParallelId,
|
||||
nodes: [innerTaskId],
|
||||
count: 1,
|
||||
parallelType: 'count',
|
||||
},
|
||||
[outerParallelId]: {
|
||||
id: outerParallelId,
|
||||
nodes: [prepareId, innerParallelId],
|
||||
count: 5,
|
||||
parallelType: 'count',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const dag = new DAGBuilder().build(workflow)
|
||||
const expander = new ParallelExpander()
|
||||
expander.expandParallel(dag, outerParallelId, 2, undefined, {
|
||||
branchIndexOffset: 0,
|
||||
totalBranches: 5,
|
||||
})
|
||||
expander.expandParallel(dag, outerParallelId, 2, undefined, {
|
||||
branchIndexOffset: 2,
|
||||
totalBranches: 5,
|
||||
})
|
||||
expander.expandParallel(dag, outerParallelId, 1, undefined, {
|
||||
branchIndexOffset: 4,
|
||||
totalBranches: 5,
|
||||
})
|
||||
|
||||
const prepareBranchZero = dag.nodes.get(buildBranchNodeId(prepareId, 0))!
|
||||
const originalInnerStart = buildParallelSentinelStartId(innerParallelId)
|
||||
const staleClonedInnerStart = buildParallelSentinelStartId(`${innerParallelId}__obranch-2`)
|
||||
const clonedInnerStart = buildParallelSentinelStartId(`${innerParallelId}__obranch-4`)
|
||||
const prepareTargets = Array.from(prepareBranchZero.outgoingEdges.values()).map(
|
||||
(edge) => edge.target
|
||||
)
|
||||
|
||||
expect(prepareTargets).toEqual([clonedInnerStart])
|
||||
expect(dag.nodes.get(originalInnerStart)!.incomingEdges.has(prepareBranchZero.id)).toBe(false)
|
||||
expect(dag.nodes.get(staleClonedInnerStart)!.incomingEdges.has(prepareBranchZero.id)).toBe(
|
||||
false
|
||||
)
|
||||
expect(dag.nodes.get(clonedInnerStart)!.incomingEdges.has(prepareBranchZero.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves internal edge topology when expanding cloned nested parallels', () => {
|
||||
const outerParallelId = 'outer-parallel'
|
||||
const innerParallelId = 'inner-parallel'
|
||||
const firstTaskId = 'first-task'
|
||||
const secondTaskId = 'second-task'
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
createBlock(outerParallelId, BlockType.PARALLEL),
|
||||
createBlock(innerParallelId, BlockType.PARALLEL),
|
||||
createBlock(firstTaskId, BlockType.FUNCTION),
|
||||
createBlock(secondTaskId, BlockType.FUNCTION),
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: outerParallelId },
|
||||
{ source: outerParallelId, target: innerParallelId, sourceHandle: 'parallel-start-source' },
|
||||
{ source: innerParallelId, target: firstTaskId, sourceHandle: 'parallel-start-source' },
|
||||
{ source: firstTaskId, target: secondTaskId },
|
||||
],
|
||||
loops: {},
|
||||
parallels: {
|
||||
[innerParallelId]: {
|
||||
id: innerParallelId,
|
||||
nodes: [firstTaskId, secondTaskId],
|
||||
count: 2,
|
||||
parallelType: 'count',
|
||||
},
|
||||
[outerParallelId]: {
|
||||
id: outerParallelId,
|
||||
nodes: [innerParallelId],
|
||||
count: 3,
|
||||
parallelType: 'count',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const dag = new DAGBuilder().build(workflow)
|
||||
const expander = new ParallelExpander()
|
||||
const outerResult = expander.expandParallel(dag, outerParallelId, 1, undefined, {
|
||||
branchIndexOffset: 2,
|
||||
totalBranches: 3,
|
||||
})
|
||||
const clonedInnerId = outerResult.clonedSubflows[0].clonedId
|
||||
const clonedInnerConfig = dag.parallelConfigs.get(clonedInnerId)!
|
||||
const [clonedFirstTaskId, clonedSecondTaskId] = clonedInnerConfig.nodes
|
||||
|
||||
const innerResult = expander.expandParallel(dag, clonedInnerId, 2)
|
||||
|
||||
const firstTaskBranchOneId = buildBranchNodeId(clonedFirstTaskId, 1)
|
||||
const secondTaskBranchOneId = buildBranchNodeId(clonedSecondTaskId, 1)
|
||||
const firstTaskBranchOne = dag.nodes.get(firstTaskBranchOneId)!
|
||||
const secondTaskBranchOne = dag.nodes.get(secondTaskBranchOneId)!
|
||||
|
||||
expect(innerResult.entryNodes).toEqual([
|
||||
buildBranchNodeId(clonedFirstTaskId, 0),
|
||||
firstTaskBranchOneId,
|
||||
])
|
||||
expect(
|
||||
Array.from(firstTaskBranchOne.outgoingEdges.values()).map((edge) => edge.target)
|
||||
).toContain(secondTaskBranchOneId)
|
||||
expect(secondTaskBranchOne.incomingEdges.has(firstTaskBranchOneId)).toBe(true)
|
||||
expect(innerResult.entryNodes).not.toContain(secondTaskBranchOneId)
|
||||
})
|
||||
|
||||
it('clears stale sentinel-end incoming edges when a later batch is smaller', () => {
|
||||
const parallelId = 'parallel-1'
|
||||
const functionId = 'func-1'
|
||||
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
createBlock(parallelId, BlockType.PARALLEL),
|
||||
createBlock(functionId, BlockType.FUNCTION),
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: parallelId },
|
||||
{ source: parallelId, target: functionId, sourceHandle: 'parallel-start-source' },
|
||||
],
|
||||
loops: {},
|
||||
parallels: {
|
||||
[parallelId]: {
|
||||
id: parallelId,
|
||||
nodes: [functionId],
|
||||
count: 4,
|
||||
parallelType: 'count',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const builder = new DAGBuilder()
|
||||
const dag = builder.build(workflow)
|
||||
const expander = new ParallelExpander()
|
||||
const sentinelEnd = dag.nodes.get(buildParallelSentinelEndId(parallelId))!
|
||||
|
||||
expander.expandParallel(dag, parallelId, 3, undefined, {
|
||||
branchIndexOffset: 0,
|
||||
totalBranches: 4,
|
||||
})
|
||||
expect(sentinelEnd.incomingEdges).toEqual(
|
||||
new Set([
|
||||
buildBranchNodeId(functionId, 0),
|
||||
buildBranchNodeId(functionId, 1),
|
||||
buildBranchNodeId(functionId, 2),
|
||||
])
|
||||
)
|
||||
|
||||
expander.expandParallel(dag, parallelId, 1, undefined, {
|
||||
branchIndexOffset: 3,
|
||||
totalBranches: 4,
|
||||
})
|
||||
expect(sentinelEnd.incomingEdges).toEqual(new Set([buildBranchNodeId(functionId, 0)]))
|
||||
})
|
||||
|
||||
it('updates unified subflow metadata on cloned nested loop sentinels', () => {
|
||||
const parallelId = 'parallel-1'
|
||||
const loopId = 'loop-1'
|
||||
const functionId = 'func-1'
|
||||
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
createBlock(parallelId, BlockType.PARALLEL),
|
||||
createBlock(loopId, BlockType.LOOP),
|
||||
createBlock(functionId, BlockType.FUNCTION),
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: parallelId },
|
||||
{ source: parallelId, target: loopId, sourceHandle: 'parallel-start-source' },
|
||||
{ source: loopId, target: functionId, sourceHandle: 'loop-start-source' },
|
||||
],
|
||||
loops: {
|
||||
[loopId]: {
|
||||
id: loopId,
|
||||
nodes: [functionId],
|
||||
iterations: 1,
|
||||
loopType: 'for',
|
||||
},
|
||||
},
|
||||
parallels: {
|
||||
[parallelId]: {
|
||||
id: parallelId,
|
||||
nodes: [loopId],
|
||||
count: 2,
|
||||
parallelType: 'count',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const builder = new DAGBuilder()
|
||||
const dag = builder.build(workflow)
|
||||
const expander = new ParallelExpander()
|
||||
|
||||
const result = expander.expandParallel(dag, parallelId, 2)
|
||||
const clonedLoopId = result.clonedSubflows.find(
|
||||
(clone) => clone.originalId === loopId
|
||||
)?.clonedId
|
||||
|
||||
expect(clonedLoopId).toBe(`${loopId}__obranch-1`)
|
||||
expect(dag.nodes.get(buildSentinelStartId(clonedLoopId!))?.metadata).toMatchObject({
|
||||
subflowId: clonedLoopId,
|
||||
subflowType: 'loop',
|
||||
})
|
||||
})
|
||||
|
||||
it('3-level nesting: pre-expansion clone IDs do not collide with runtime expansion', () => {
|
||||
const p1 = 'p1'
|
||||
const p2 = 'p2'
|
||||
const p3 = 'p3'
|
||||
const leafBlock = 'leaf'
|
||||
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [
|
||||
createBlock('start', BlockType.STARTER),
|
||||
createBlock(p1, BlockType.PARALLEL),
|
||||
createBlock(p2, BlockType.PARALLEL),
|
||||
createBlock(p3, BlockType.PARALLEL),
|
||||
createBlock(leafBlock, BlockType.FUNCTION),
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: p1 },
|
||||
{ source: p1, target: p2, sourceHandle: 'parallel-start-source' },
|
||||
{ source: p2, target: p3, sourceHandle: 'parallel-start-source' },
|
||||
{ source: p3, target: leafBlock, sourceHandle: 'parallel-start-source' },
|
||||
],
|
||||
loops: {},
|
||||
parallels: {
|
||||
[p3]: { id: p3, nodes: [leafBlock], count: 2, parallelType: 'count' },
|
||||
[p2]: { id: p2, nodes: [p3], count: 2, parallelType: 'count' },
|
||||
[p1]: { id: p1, nodes: [p2], count: 2, parallelType: 'count' },
|
||||
},
|
||||
}
|
||||
|
||||
const builder = new DAGBuilder()
|
||||
const dag = builder.build(workflow)
|
||||
const expander = new ParallelExpander()
|
||||
|
||||
// Step 1: Expand P1 (outermost) — this pre-clones P2 and recursively P3
|
||||
const p1Result = expander.expandParallel(dag, p1, 2)
|
||||
|
||||
// P1 should have cloned P2 (and recursively P3 inside it)
|
||||
const p2Clone = p1Result.clonedSubflows.find((c) => c.originalId === p2)!
|
||||
expect(p2Clone).toBeDefined()
|
||||
expect(p2Clone.clonedId).toBe('p2__obranch-1')
|
||||
|
||||
// P3 should also be cloned (inside P2__obranch-1) with a __clone prefix
|
||||
const p3Clone = p1Result.clonedSubflows.find((c) => c.originalId === p3)!
|
||||
expect(p3Clone).toBeDefined()
|
||||
expect(p3Clone.clonedId).toMatch(/^p3__clone[0-9a-f]{24}__obranch-1$/)
|
||||
expect(stripCloneSuffixes(p3Clone.clonedId)).toBe('p3')
|
||||
expect(
|
||||
Array.from(
|
||||
dag.nodes.get(buildParallelSentinelEndId(p3Clone.clonedId))!.outgoingEdges.values()
|
||||
).map((edge) => edge.target)
|
||||
).toContain(buildParallelSentinelEndId(p2Clone.clonedId))
|
||||
|
||||
// Step 2: Expand P2 (original, branch 0 of P1) — this creates P3__obranch-1 at runtime
|
||||
const p2Result = expander.expandParallel(dag, p2, 2)
|
||||
|
||||
// P2 should clone P3 as P3__obranch-1 (standard runtime naming)
|
||||
const p3RuntimeClone = p2Result.clonedSubflows.find((c) => c.originalId === p3)!
|
||||
expect(p3RuntimeClone).toBeDefined()
|
||||
expect(p3RuntimeClone.clonedId).toBe('p3__obranch-1')
|
||||
|
||||
// Key assertion: P3__obranch-1 (runtime) !== P3__clone*__obranch-1 (pre-expansion)
|
||||
expect(p3RuntimeClone.clonedId).not.toBe(p3Clone.clonedId)
|
||||
|
||||
// Both P3 configs should exist independently in the DAG
|
||||
expect(dag.parallelConfigs.has(p3RuntimeClone.clonedId)).toBe(true)
|
||||
expect(dag.parallelConfigs.has(p3Clone.clonedId)).toBe(true)
|
||||
|
||||
// Step 3: Expand P2__obranch-1 (cloned, branch 1 of P1)
|
||||
// Its inner P3 is the pre-cloned variant P3__clone*__obranch-1
|
||||
const p2ClonedConfig = dag.parallelConfigs.get(p2Clone.clonedId)!
|
||||
const p3InsideP2Clone = p2ClonedConfig.nodes![0]
|
||||
expect(p3InsideP2Clone).toBe(p3Clone.clonedId)
|
||||
|
||||
const p2CloneResult = expander.expandParallel(dag, p2Clone.clonedId, 2)
|
||||
|
||||
// P2__obranch-1 should clone its P3 (the pre-cloned variant) with __obranch-1 suffix
|
||||
const p3DeepClone = p2CloneResult.clonedSubflows.find((c) => c.originalId === p3Clone.clonedId)!
|
||||
expect(p3DeepClone).toBeDefined()
|
||||
// This ID should be unique (no collision with any earlier P3 clone)
|
||||
expect(dag.parallelConfigs.has(p3DeepClone.clonedId)).toBe(true)
|
||||
|
||||
// Step 4: Expand all P3 variants and verify no node collisions
|
||||
const allP3Variants = [p3, p3RuntimeClone.clonedId, p3Clone.clonedId, p3DeepClone.clonedId]
|
||||
const allLeafNodes = new Set<string>()
|
||||
|
||||
for (const p3Id of allP3Variants) {
|
||||
const p3Config = dag.parallelConfigs.get(p3Id)!
|
||||
const leafId = p3Config.nodes![0]
|
||||
|
||||
const p3Result = expander.expandParallel(dag, p3Id, 2)
|
||||
|
||||
// Each expansion creates branch nodes — verify they're unique
|
||||
const branch0 = buildBranchNodeId(leafId, 0)
|
||||
const branch1 = buildBranchNodeId(leafId, 1)
|
||||
|
||||
expect(dag.nodes.has(branch0)).toBe(true)
|
||||
expect(dag.nodes.has(branch1)).toBe(true)
|
||||
|
||||
// No duplicate node IDs across all expansions
|
||||
expect(allLeafNodes.has(branch0)).toBe(false)
|
||||
expect(allLeafNodes.has(branch1)).toBe(false)
|
||||
allLeafNodes.add(branch0)
|
||||
allLeafNodes.add(branch1)
|
||||
}
|
||||
|
||||
// 4 P3 variants × 2 branches each = 8 unique leaf nodes
|
||||
expect(allLeafNodes.size).toBe(8)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,647 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { sha256Hex } from '@sim/security/hash'
|
||||
import { CONTROL_BACK_EDGE_HANDLES, EDGE } from '@/executor/constants'
|
||||
import type { DAG, DAGNode } from '@/executor/dag/builder'
|
||||
import {
|
||||
buildBranchNodeId,
|
||||
buildClonedSubflowId,
|
||||
buildParallelSentinelEndId,
|
||||
buildParallelSentinelStartId,
|
||||
buildSentinelEndId,
|
||||
buildSentinelStartId,
|
||||
extractBaseBlockId,
|
||||
isLoopSentinelNodeId,
|
||||
isParallelSentinelNodeId,
|
||||
normalizeNodeId,
|
||||
stripOuterBranchSuffix,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('ParallelExpansion')
|
||||
|
||||
export interface ClonedSubflowInfo {
|
||||
clonedId: string
|
||||
originalId: string
|
||||
outerBranchIndex: number
|
||||
}
|
||||
|
||||
export interface ExpansionResult {
|
||||
entryNodes: string[]
|
||||
terminalNodes: string[]
|
||||
allBranchNodes: string[]
|
||||
clonedSubflows: ClonedSubflowInfo[]
|
||||
}
|
||||
|
||||
export class ParallelExpander {
|
||||
expandParallel(
|
||||
dag: DAG,
|
||||
parallelId: string,
|
||||
branchCount: number,
|
||||
distributionItems?: any[],
|
||||
options: { branchIndexOffset?: number; totalBranches?: number } = {}
|
||||
): ExpansionResult {
|
||||
const config = dag.parallelConfigs.get(parallelId)
|
||||
if (!config) {
|
||||
throw new Error(`Parallel config not found: ${parallelId}`)
|
||||
}
|
||||
|
||||
const blocksInParallel = config.nodes || []
|
||||
if (blocksInParallel.length === 0) {
|
||||
return { entryNodes: [], terminalNodes: [], allBranchNodes: [], clonedSubflows: [] }
|
||||
}
|
||||
|
||||
// Separate nested subflow containers from regular expandable blocks.
|
||||
// Nested parallels/loops have sentinel nodes instead of branch template nodes,
|
||||
// so they cannot be cloned per-branch like regular blocks.
|
||||
const regularBlocks: string[] = []
|
||||
const nestedSubflows: string[] = []
|
||||
|
||||
for (const blockId of blocksInParallel) {
|
||||
if (dag.parallelConfigs.has(blockId) || dag.loopConfigs.has(blockId)) {
|
||||
nestedSubflows.push(blockId)
|
||||
} else {
|
||||
regularBlocks.push(blockId)
|
||||
}
|
||||
}
|
||||
|
||||
const allBranchNodes: string[] = []
|
||||
const branchIndexOffset = options.branchIndexOffset ?? 0
|
||||
const branchTotal = options.totalBranches ?? branchCount
|
||||
|
||||
for (const blockId of regularBlocks) {
|
||||
const templateId = buildBranchNodeId(blockId, 0)
|
||||
const templateNode = dag.nodes.get(templateId)
|
||||
|
||||
if (!templateNode) {
|
||||
logger.warn('Template node not found', { blockId, templateId })
|
||||
continue
|
||||
}
|
||||
|
||||
for (let i = 0; i < branchCount; i++) {
|
||||
const branchNodeId = buildBranchNodeId(blockId, i)
|
||||
const globalBranchIndex = branchIndexOffset + i
|
||||
allBranchNodes.push(branchNodeId)
|
||||
|
||||
if (i === 0) {
|
||||
this.updateBranchMetadata(
|
||||
templateNode,
|
||||
globalBranchIndex,
|
||||
branchTotal,
|
||||
distributionItems?.[i]
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const branchNode = this.cloneTemplateNode(
|
||||
templateNode,
|
||||
blockId,
|
||||
i,
|
||||
globalBranchIndex,
|
||||
branchTotal,
|
||||
distributionItems?.[i]
|
||||
)
|
||||
dag.nodes.set(branchNodeId, branchNode)
|
||||
}
|
||||
}
|
||||
|
||||
// Clone nested subflow graphs per outer branch so each branch runs independently.
|
||||
// Branch 0 uses the original sentinel/template nodes; branches 1..N get full clones.
|
||||
const clonedSubflows: ClonedSubflowInfo[] = []
|
||||
|
||||
for (const subflowId of nestedSubflows) {
|
||||
for (let i = 0; i < branchCount; i++) {
|
||||
const globalBranchIndex = branchIndexOffset + i
|
||||
if (globalBranchIndex === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const cloned = this.cloneNestedSubflow(dag, subflowId, globalBranchIndex, clonedSubflows)
|
||||
|
||||
clonedSubflows.push({
|
||||
clonedId: cloned.clonedId,
|
||||
originalId: subflowId,
|
||||
outerBranchIndex: globalBranchIndex,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.wireInternalEdges(
|
||||
dag,
|
||||
blocksInParallel,
|
||||
new Set(blocksInParallel),
|
||||
branchCount,
|
||||
branchIndexOffset
|
||||
)
|
||||
const { entryNodes, terminalNodes } = this.identifyBoundaryNodes(
|
||||
dag,
|
||||
blocksInParallel,
|
||||
branchCount,
|
||||
branchIndexOffset
|
||||
)
|
||||
|
||||
this.wireSentinelEdges(dag, parallelId, entryNodes, terminalNodes, branchCount)
|
||||
|
||||
logger.info('Parallel expanded', {
|
||||
parallelId,
|
||||
branchCount,
|
||||
blocksCount: blocksInParallel.length,
|
||||
nestedSubflows: nestedSubflows.length,
|
||||
totalNodes: allBranchNodes.length,
|
||||
})
|
||||
|
||||
return { entryNodes, terminalNodes, allBranchNodes, clonedSubflows }
|
||||
}
|
||||
|
||||
private updateBranchMetadata(
|
||||
node: DAGNode,
|
||||
branchIndex: number,
|
||||
branchTotal: number,
|
||||
distributionItem?: any
|
||||
): void {
|
||||
node.metadata.branchIndex = branchIndex
|
||||
node.metadata.branchTotal = branchTotal
|
||||
if (distributionItem !== undefined) {
|
||||
node.metadata.distributionItem = distributionItem
|
||||
}
|
||||
}
|
||||
|
||||
private cloneTemplateNode(
|
||||
template: DAGNode,
|
||||
originalBlockId: string,
|
||||
localBranchIndex: number,
|
||||
branchIndex: number,
|
||||
branchTotal: number,
|
||||
distributionItem?: any
|
||||
): DAGNode {
|
||||
const branchNodeId = buildBranchNodeId(originalBlockId, localBranchIndex)
|
||||
const blockClone: SerializedBlock = {
|
||||
...template.block,
|
||||
id: branchNodeId,
|
||||
}
|
||||
|
||||
return {
|
||||
id: branchNodeId,
|
||||
block: blockClone,
|
||||
incomingEdges: new Set(),
|
||||
outgoingEdges: new Map(),
|
||||
metadata: {
|
||||
...template.metadata,
|
||||
branchIndex,
|
||||
branchTotal,
|
||||
distributionItem,
|
||||
originalBlockId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private wireInternalEdges(
|
||||
dag: DAG,
|
||||
blocksInParallel: string[],
|
||||
blocksSet: Set<string>,
|
||||
branchCount: number,
|
||||
branchIndexOffset: number
|
||||
): void {
|
||||
const topology = this.collectInternalEdgeTopology(dag, blocksInParallel, blocksSet)
|
||||
const cleanedSourceNodes = new Set<string>()
|
||||
|
||||
for (const edge of topology) {
|
||||
for (let i = 0; i < branchCount; i++) {
|
||||
const globalBranchIndex = branchIndexOffset + i
|
||||
const sourceNodeId = this.resolveBranchChildBoundaryId(
|
||||
dag,
|
||||
edge.sourceBlockId,
|
||||
i,
|
||||
globalBranchIndex,
|
||||
'end'
|
||||
)
|
||||
const targetNodeId = this.resolveBranchChildBoundaryId(
|
||||
dag,
|
||||
edge.targetBlockId,
|
||||
i,
|
||||
globalBranchIndex,
|
||||
'start'
|
||||
)
|
||||
const sourceNode = dag.nodes.get(sourceNodeId)
|
||||
const targetNode = dag.nodes.get(targetNodeId)
|
||||
|
||||
if (!sourceNode || !targetNode) continue
|
||||
|
||||
if (!cleanedSourceNodes.has(sourceNodeId)) {
|
||||
this.clearStaleInternalEdges(dag, sourceNodeId, sourceNode, blocksSet)
|
||||
cleanedSourceNodes.add(sourceNodeId)
|
||||
}
|
||||
|
||||
const edgeId = edge.sourceHandle
|
||||
? `${sourceNodeId}→${targetNodeId}-${edge.sourceHandle}`
|
||||
: `${sourceNodeId}→${targetNodeId}`
|
||||
|
||||
sourceNode.outgoingEdges.set(edgeId, {
|
||||
target: targetNodeId,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
})
|
||||
targetNode.incomingEdges.add(sourceNodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearStaleInternalEdges(
|
||||
dag: DAG,
|
||||
sourceNodeId: string,
|
||||
sourceNode: DAGNode,
|
||||
blocksSet: Set<string>
|
||||
): void {
|
||||
for (const [edgeId, edge] of Array.from(sourceNode.outgoingEdges.entries())) {
|
||||
if (!blocksSet.has(stripOuterBranchSuffix(normalizeNodeId(edge.target)))) continue
|
||||
|
||||
sourceNode.outgoingEdges.delete(edgeId)
|
||||
dag.nodes.get(edge.target)?.incomingEdges.delete(sourceNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
private identifyBoundaryNodes(
|
||||
dag: DAG,
|
||||
blocksInParallel: string[],
|
||||
branchCount: number,
|
||||
branchIndexOffset: number
|
||||
): { entryNodes: string[]; terminalNodes: string[] } {
|
||||
const entryNodes: string[] = []
|
||||
const terminalNodes: string[] = []
|
||||
const topology = this.collectInternalEdgeTopology(
|
||||
dag,
|
||||
blocksInParallel,
|
||||
new Set(blocksInParallel)
|
||||
)
|
||||
const blocksWithInternalIncoming = new Set(topology.map((edge) => edge.targetBlockId))
|
||||
const blocksWithInternalOutgoing = new Set(topology.map((edge) => edge.sourceBlockId))
|
||||
|
||||
for (const blockId of blocksInParallel) {
|
||||
for (let i = 0; i < branchCount; i++) {
|
||||
const globalBranchIndex = branchIndexOffset + i
|
||||
if (!blocksWithInternalIncoming.has(blockId)) {
|
||||
entryNodes.push(
|
||||
this.resolveBranchChildBoundaryId(dag, blockId, i, globalBranchIndex, 'start')
|
||||
)
|
||||
}
|
||||
if (!blocksWithInternalOutgoing.has(blockId)) {
|
||||
terminalNodes.push(
|
||||
this.resolveBranchChildBoundaryId(dag, blockId, i, globalBranchIndex, 'end')
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { entryNodes, terminalNodes }
|
||||
}
|
||||
|
||||
private collectInternalEdgeTopology(
|
||||
dag: DAG,
|
||||
blocksInParallel: string[],
|
||||
blocksSet: Set<string>
|
||||
): Array<{
|
||||
sourceBlockId: string
|
||||
targetBlockId: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
}> {
|
||||
const topology: Array<{
|
||||
sourceBlockId: string
|
||||
targetBlockId: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
}> = []
|
||||
|
||||
for (const blockId of blocksInParallel) {
|
||||
const sourceNodeId = this.resolveOriginalChildBoundaryId(dag, blockId, 'end')
|
||||
const sourceNode = dag.nodes.get(sourceNodeId)
|
||||
if (!sourceNode) continue
|
||||
|
||||
for (const [, edge] of sourceNode.outgoingEdges) {
|
||||
if (edge.sourceHandle && CONTROL_BACK_EDGE_HANDLES.has(edge.sourceHandle)) continue
|
||||
|
||||
const targetBoundaryId = extractBaseBlockId(normalizeNodeId(edge.target))
|
||||
const targetBlockId = blocksSet.has(targetBoundaryId)
|
||||
? targetBoundaryId
|
||||
: stripOuterBranchSuffix(targetBoundaryId)
|
||||
if (!blocksSet.has(targetBlockId)) continue
|
||||
|
||||
topology.push({
|
||||
sourceBlockId: blockId,
|
||||
targetBlockId,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return topology
|
||||
}
|
||||
|
||||
private resolveOriginalChildBoundaryId(dag: DAG, blockId: string, side: 'start' | 'end'): string {
|
||||
if (dag.parallelConfigs.has(blockId)) {
|
||||
return side === 'start'
|
||||
? buildParallelSentinelStartId(blockId)
|
||||
: buildParallelSentinelEndId(blockId)
|
||||
}
|
||||
if (dag.loopConfigs.has(blockId)) {
|
||||
return side === 'start' ? buildSentinelStartId(blockId) : buildSentinelEndId(blockId)
|
||||
}
|
||||
return buildBranchNodeId(blockId, 0)
|
||||
}
|
||||
|
||||
private resolveBranchChildBoundaryId(
|
||||
dag: DAG,
|
||||
blockId: string,
|
||||
localBranchIndex: number,
|
||||
globalBranchIndex: number,
|
||||
side: 'start' | 'end'
|
||||
): string {
|
||||
if (!dag.parallelConfigs.has(blockId) && !dag.loopConfigs.has(blockId)) {
|
||||
return buildBranchNodeId(blockId, localBranchIndex)
|
||||
}
|
||||
|
||||
const effectiveSubflowId =
|
||||
globalBranchIndex === 0 ? blockId : buildClonedSubflowId(blockId, globalBranchIndex)
|
||||
if (dag.parallelConfigs.has(blockId)) {
|
||||
return side === 'start'
|
||||
? buildParallelSentinelStartId(effectiveSubflowId)
|
||||
: buildParallelSentinelEndId(effectiveSubflowId)
|
||||
}
|
||||
return side === 'start'
|
||||
? buildSentinelStartId(effectiveSubflowId)
|
||||
: buildSentinelEndId(effectiveSubflowId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique clone ID for pre-expansion cloning.
|
||||
*
|
||||
* Pre-expansion clones use `{originalId}__clone{digest}__obranch-{branchIndex}` instead
|
||||
* of the plain `{originalId}__obranch-{branchIndex}` used by runtime expansion.
|
||||
* The clone segment prevents naming collisions when the original (branch-0)
|
||||
* subflow later expands at runtime and creates `{child}__obranch-{branchIndex}`.
|
||||
* Keeping it deterministic lets pause/resume rebuild the same active branch IDs.
|
||||
*/
|
||||
private buildPreCloneIdForParent(
|
||||
originalId: string,
|
||||
outerBranchIndex: number,
|
||||
parentCloneId: string
|
||||
): string {
|
||||
const input = `${parentCloneId}:${originalId}:${outerBranchIndex}`
|
||||
const digest = sha256Hex(input).slice(0, 24)
|
||||
return `${originalId}__clone${digest}__obranch-${outerBranchIndex}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones an entire nested subflow graph for a specific outer branch.
|
||||
*
|
||||
* The top-level subflow gets a standard `__obranch-{N}` clone ID (needed by
|
||||
* `findEffectiveContainerId` at runtime). All deeper children — both containers
|
||||
* and regular blocks — receive deterministic `__clone{N}__obranch-{M}` IDs to
|
||||
* avoid collisions with runtime expansion.
|
||||
*/
|
||||
private cloneNestedSubflow(
|
||||
dag: DAG,
|
||||
subflowId: string,
|
||||
outerBranchIndex: number,
|
||||
clonedSubflows: ClonedSubflowInfo[]
|
||||
): { startId: string; endId: string; clonedId: string; idMap: Map<string, string> } {
|
||||
const clonedId = buildClonedSubflowId(subflowId, outerBranchIndex)
|
||||
const { startId, endId, idMap } = this.cloneSubflowGraph(
|
||||
dag,
|
||||
subflowId,
|
||||
clonedId,
|
||||
outerBranchIndex,
|
||||
clonedSubflows
|
||||
)
|
||||
return { startId, endId, clonedId, idMap }
|
||||
}
|
||||
|
||||
/**
|
||||
* Core recursive cloning: duplicates a subflow's sentinels, config, child blocks,
|
||||
* and DAG nodes under the given `clonedId`. Nested containers are recursively
|
||||
* cloned with unique pre-clone IDs.
|
||||
*/
|
||||
private cloneSubflowGraph(
|
||||
dag: DAG,
|
||||
originalId: string,
|
||||
clonedId: string,
|
||||
outerBranchIndex: number,
|
||||
clonedSubflows: ClonedSubflowInfo[]
|
||||
): { startId: string; endId: string; idMap: Map<string, string> } {
|
||||
const isParallel = dag.parallelConfigs.has(originalId)
|
||||
const config = isParallel
|
||||
? dag.parallelConfigs.get(originalId)!
|
||||
: dag.loopConfigs.get(originalId)!
|
||||
const blockIds = config.nodes || []
|
||||
const idMap = new Map<string, string>()
|
||||
|
||||
// Map sentinel nodes
|
||||
const origStartId = isParallel
|
||||
? buildParallelSentinelStartId(originalId)
|
||||
: buildSentinelStartId(originalId)
|
||||
const origEndId = isParallel
|
||||
? buildParallelSentinelEndId(originalId)
|
||||
: buildSentinelEndId(originalId)
|
||||
const clonedStartId = isParallel
|
||||
? buildParallelSentinelStartId(clonedId)
|
||||
: buildSentinelStartId(clonedId)
|
||||
const clonedEndId = isParallel
|
||||
? buildParallelSentinelEndId(clonedId)
|
||||
: buildSentinelEndId(clonedId)
|
||||
|
||||
idMap.set(origStartId, clonedStartId)
|
||||
idMap.set(origEndId, clonedEndId)
|
||||
|
||||
// Process child blocks — recurse into nested containers, remap regular blocks
|
||||
const clonedBlockIds: string[] = []
|
||||
|
||||
for (const blockId of blockIds) {
|
||||
const isNestedParallel = dag.parallelConfigs.has(blockId)
|
||||
const isNestedLoop = dag.loopConfigs.has(blockId)
|
||||
|
||||
if (isNestedParallel || isNestedLoop) {
|
||||
const nestedClonedId = this.buildPreCloneIdForParent(blockId, outerBranchIndex, clonedId)
|
||||
clonedBlockIds.push(nestedClonedId)
|
||||
|
||||
const innerResult = this.cloneSubflowGraph(
|
||||
dag,
|
||||
blockId,
|
||||
nestedClonedId,
|
||||
outerBranchIndex,
|
||||
clonedSubflows
|
||||
)
|
||||
for (const [k, v] of innerResult.idMap) {
|
||||
idMap.set(k, v)
|
||||
}
|
||||
|
||||
clonedSubflows.push({
|
||||
clonedId: nestedClonedId,
|
||||
originalId: blockId,
|
||||
outerBranchIndex,
|
||||
})
|
||||
} else {
|
||||
const clonedBlockId = this.buildPreCloneIdForParent(blockId, outerBranchIndex, clonedId)
|
||||
clonedBlockIds.push(clonedBlockId)
|
||||
|
||||
if (isParallel) {
|
||||
idMap.set(buildBranchNodeId(blockId, 0), buildBranchNodeId(clonedBlockId, 0))
|
||||
} else {
|
||||
idMap.set(blockId, clonedBlockId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register cloned config
|
||||
if (isParallel) {
|
||||
dag.parallelConfigs.set(clonedId, {
|
||||
...dag.parallelConfigs.get(originalId)!,
|
||||
id: clonedId,
|
||||
nodes: clonedBlockIds,
|
||||
})
|
||||
} else {
|
||||
dag.loopConfigs.set(clonedId, {
|
||||
...dag.loopConfigs.get(originalId)!,
|
||||
id: clonedId,
|
||||
nodes: clonedBlockIds,
|
||||
})
|
||||
}
|
||||
|
||||
// Clone DAG nodes (sentinels + regular blocks) with remapped edges
|
||||
const origNodeIds = [origStartId, origEndId]
|
||||
for (const blockId of blockIds) {
|
||||
if (dag.parallelConfigs.has(blockId) || dag.loopConfigs.has(blockId)) continue
|
||||
if (isParallel) {
|
||||
origNodeIds.push(buildBranchNodeId(blockId, 0))
|
||||
} else {
|
||||
origNodeIds.push(blockId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const origId of origNodeIds) {
|
||||
const origNode = dag.nodes.get(origId)
|
||||
if (!origNode) continue
|
||||
|
||||
const clonedNodeId = idMap.get(origId)!
|
||||
this.cloneDAGNode(dag, origNode, clonedNodeId, clonedId, isParallel)
|
||||
}
|
||||
|
||||
this.remapClonedEdges(dag, idMap)
|
||||
|
||||
return { startId: clonedStartId, endId: clonedEndId, idMap }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones a single DAG node with updated metadata. Edge wiring is owned by remapClonedEdges
|
||||
* after the full clone id map is available.
|
||||
*/
|
||||
private cloneDAGNode(
|
||||
dag: DAG,
|
||||
origNode: DAGNode,
|
||||
clonedNodeId: string,
|
||||
parentClonedId: string,
|
||||
parentIsParallel: boolean
|
||||
): void {
|
||||
const metadataOverride = parentIsParallel
|
||||
? { subflowId: parentClonedId, subflowType: 'parallel' as const }
|
||||
: { subflowId: parentClonedId, subflowType: 'loop' as const }
|
||||
|
||||
dag.nodes.set(clonedNodeId, {
|
||||
id: clonedNodeId,
|
||||
block: { ...origNode.block, id: clonedNodeId },
|
||||
incomingEdges: new Set(),
|
||||
outgoingEdges: new Map(),
|
||||
metadata: {
|
||||
...origNode.metadata,
|
||||
...metadataOverride,
|
||||
...(origNode.metadata.originalBlockId && {
|
||||
originalBlockId: origNode.metadata.originalBlockId,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private remapClonedEdges(dag: DAG, idMap: Map<string, string>): void {
|
||||
for (const [origId, clonedNodeId] of idMap) {
|
||||
const origNode = dag.nodes.get(origId)
|
||||
const clonedNode = dag.nodes.get(clonedNodeId)
|
||||
if (!origNode || !clonedNode) continue
|
||||
|
||||
const remappedOutgoing = new Map<
|
||||
string,
|
||||
{ target: string; sourceHandle?: string; targetHandle?: string }
|
||||
>()
|
||||
for (const [, edge] of origNode.outgoingEdges) {
|
||||
const clonedTarget = idMap.get(edge.target)
|
||||
if (!clonedTarget) continue
|
||||
|
||||
const edgeId = edge.sourceHandle
|
||||
? `${clonedNodeId}→${clonedTarget}-${edge.sourceHandle}`
|
||||
: `${clonedNodeId}→${clonedTarget}`
|
||||
remappedOutgoing.set(edgeId, {
|
||||
target: clonedTarget,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
})
|
||||
}
|
||||
|
||||
const remappedIncoming = new Set<string>()
|
||||
for (const incomingId of origNode.incomingEdges) {
|
||||
const clonedIncomingId = idMap.get(incomingId)
|
||||
if (clonedIncomingId) {
|
||||
remappedIncoming.add(clonedIncomingId)
|
||||
}
|
||||
}
|
||||
|
||||
clonedNode.outgoingEdges = remappedOutgoing
|
||||
clonedNode.incomingEdges = remappedIncoming
|
||||
}
|
||||
}
|
||||
|
||||
private wireSentinelEdges(
|
||||
dag: DAG,
|
||||
parallelId: string,
|
||||
entryNodes: string[],
|
||||
terminalNodes: string[],
|
||||
branchCount: number
|
||||
): void {
|
||||
const sentinelStartId = buildParallelSentinelStartId(parallelId)
|
||||
const sentinelEndId = buildParallelSentinelEndId(parallelId)
|
||||
const sentinelStart = dag.nodes.get(sentinelStartId)
|
||||
const sentinelEnd = dag.nodes.get(sentinelEndId)
|
||||
|
||||
if (!sentinelStart || !sentinelEnd) {
|
||||
logger.warn('Sentinel nodes not found', { parallelId, sentinelStartId, sentinelEndId })
|
||||
return
|
||||
}
|
||||
|
||||
sentinelStart.outgoingEdges.clear()
|
||||
sentinelEnd.incomingEdges.clear()
|
||||
for (const entryNodeId of entryNodes) {
|
||||
const entryNode = dag.nodes.get(entryNodeId)
|
||||
if (!entryNode) continue
|
||||
|
||||
const edgeId = `${sentinelStartId}→${entryNodeId}`
|
||||
sentinelStart.outgoingEdges.set(edgeId, { target: entryNodeId })
|
||||
entryNode.incomingEdges.add(sentinelStartId)
|
||||
}
|
||||
|
||||
for (const terminalNodeId of terminalNodes) {
|
||||
const terminalNode = dag.nodes.get(terminalNodeId)
|
||||
if (!terminalNode) continue
|
||||
|
||||
const handle = isLoopSentinelNodeId(terminalNodeId)
|
||||
? EDGE.LOOP_EXIT
|
||||
: isParallelSentinelNodeId(terminalNodeId)
|
||||
? EDGE.PARALLEL_EXIT
|
||||
: undefined
|
||||
const edgeId = handle
|
||||
? `${terminalNodeId}→${sentinelEndId}-${handle}`
|
||||
: `${terminalNodeId}→${sentinelEndId}`
|
||||
terminalNode.outgoingEdges.set(edgeId, {
|
||||
target: sentinelEndId,
|
||||
sourceHandle: handle,
|
||||
})
|
||||
sentinelEnd.incomingEdges.add(terminalNodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { isLikelyReferenceSegment } from '@/lib/workflows/sanitization/references'
|
||||
import { REFERENCE } from '@/executor/constants'
|
||||
|
||||
/**
|
||||
* Creates a regex pattern for matching variable references.
|
||||
* Uses [^<>]+ to prevent matching across nested brackets (e.g., "<3 <real.ref>" matches separately).
|
||||
*/
|
||||
export function createReferencePattern(): RegExp {
|
||||
return new RegExp(
|
||||
`${REFERENCE.START}([^${REFERENCE.START}${REFERENCE.END}]+)${REFERENCE.END}`,
|
||||
'g'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a regex pattern for matching environment variables {{variable}}
|
||||
*/
|
||||
export function createEnvVarPattern(): RegExp {
|
||||
return new RegExp(`\\${REFERENCE.ENV_VAR_START}([^}]+)\\${REFERENCE.ENV_VAR_END}`, 'g')
|
||||
}
|
||||
|
||||
export interface EnvVarResolveOptions {
|
||||
allowEmbedded?: boolean
|
||||
resolveExactMatch?: boolean
|
||||
trimKeys?: boolean
|
||||
onMissing?: 'keep' | 'throw' | 'empty'
|
||||
deep?: boolean
|
||||
missingKeys?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard defaults for env var resolution across all contexts.
|
||||
*
|
||||
* - `resolveExactMatch: true` - Resolves `{{VAR}}` when it's the entire value
|
||||
* - `allowEmbedded: true` - Resolves `{{VAR}}` embedded in strings like `https://{{HOST}}/api`
|
||||
* - `trimKeys: true` - `{{ VAR }}` works the same as `{{VAR}}` (whitespace tolerant)
|
||||
* - `onMissing: 'keep'` - Unknown patterns pass through (e.g., Grafana's `{{instance}}`)
|
||||
* - `deep: false` - Only processes strings by default; set `true` for nested objects
|
||||
*/
|
||||
export const ENV_VAR_RESOLVE_DEFAULTS: Required<Omit<EnvVarResolveOptions, 'missingKeys'>> = {
|
||||
resolveExactMatch: true,
|
||||
allowEmbedded: true,
|
||||
trimKeys: true,
|
||||
onMissing: 'keep',
|
||||
deep: false,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Resolve {{ENV_VAR}} references in values using provided env vars.
|
||||
*/
|
||||
export function resolveEnvVarReferences(
|
||||
value: unknown,
|
||||
envVars: Record<string, string>,
|
||||
options: EnvVarResolveOptions = {}
|
||||
): unknown {
|
||||
const {
|
||||
allowEmbedded = ENV_VAR_RESOLVE_DEFAULTS.allowEmbedded,
|
||||
resolveExactMatch = ENV_VAR_RESOLVE_DEFAULTS.resolveExactMatch,
|
||||
trimKeys = ENV_VAR_RESOLVE_DEFAULTS.trimKeys,
|
||||
onMissing = ENV_VAR_RESOLVE_DEFAULTS.onMissing,
|
||||
deep = ENV_VAR_RESOLVE_DEFAULTS.deep,
|
||||
} = options
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (resolveExactMatch) {
|
||||
const exactMatchPattern = new RegExp(
|
||||
`^\\${REFERENCE.ENV_VAR_START}([^}]+)\\${REFERENCE.ENV_VAR_END}$`
|
||||
)
|
||||
const exactMatch = exactMatchPattern.exec(value)
|
||||
if (exactMatch) {
|
||||
const envKey = trimKeys ? exactMatch[1].trim() : exactMatch[1]
|
||||
const envValue = envVars[envKey]
|
||||
if (envValue !== undefined) return envValue
|
||||
if (options.missingKeys) options.missingKeys.push(envKey)
|
||||
if (onMissing === 'throw') {
|
||||
throw new Error(`Environment variable "${envKey}" was not found`)
|
||||
}
|
||||
if (onMissing === 'empty') {
|
||||
return ''
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowEmbedded) return value
|
||||
|
||||
const envVarPattern = createEnvVarPattern()
|
||||
return value.replace(envVarPattern, (match, varName) => {
|
||||
const envKey = trimKeys ? String(varName).trim() : String(varName)
|
||||
const envValue = envVars[envKey]
|
||||
if (envValue !== undefined) return envValue
|
||||
if (options.missingKeys) options.missingKeys.push(envKey)
|
||||
if (onMissing === 'throw') {
|
||||
throw new Error(`Environment variable "${envKey}" was not found`)
|
||||
}
|
||||
if (onMissing === 'empty') {
|
||||
return ''
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
if (deep && Array.isArray(value)) {
|
||||
return value.map((item) => resolveEnvVarReferences(item, envVars, options))
|
||||
}
|
||||
|
||||
if (deep && value !== null && typeof value === 'object') {
|
||||
const resolved: Record<string, any> = {}
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
resolved[key] = resolveEnvVarReferences(val, envVars, options)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a regex pattern for matching workflow variables <variable.name>
|
||||
* Captures the variable name (after "variable.") in group 1
|
||||
*/
|
||||
export function createWorkflowVariablePattern(): RegExp {
|
||||
return new RegExp(
|
||||
`${REFERENCE.START}${REFERENCE.PREFIX.VARIABLE}\\${REFERENCE.PATH_DELIMITER}([^${REFERENCE.START}${REFERENCE.END}]+)${REFERENCE.END}`,
|
||||
'g'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined pattern matching both <reference> and {{env_var}}
|
||||
*/
|
||||
export function createCombinedPattern(): RegExp {
|
||||
return new RegExp(
|
||||
`${REFERENCE.START}[^${REFERENCE.START}${REFERENCE.END}]+${REFERENCE.END}|` +
|
||||
`\\${REFERENCE.ENV_VAR_START}[^}]+\\${REFERENCE.ENV_VAR_END}`,
|
||||
'g'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces variable references with smart validation.
|
||||
* Distinguishes < operator from < bracket using isLikelyReferenceSegment.
|
||||
*/
|
||||
export function replaceValidReferences(
|
||||
template: string,
|
||||
replacer: (match: string, index: number, template: string) => string
|
||||
): string {
|
||||
const pattern = createReferencePattern()
|
||||
|
||||
return template.replace(pattern, (match, _content, index) => {
|
||||
if (!isLikelyReferenceSegment(match)) {
|
||||
return match
|
||||
}
|
||||
return replacer(match, index, template)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
||||
import { LOOP, PARALLEL } from '@/executor/constants'
|
||||
import type { DAG } from '@/executor/dag/builder'
|
||||
|
||||
/**
|
||||
* Builds the sentinel-start node ID for a loop.
|
||||
*/
|
||||
function buildLoopSentinelStartId(loopId: string): string {
|
||||
return `${LOOP.SENTINEL.PREFIX}${loopId}${LOOP.SENTINEL.START_SUFFIX}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the sentinel-start node ID for a parallel.
|
||||
*/
|
||||
function buildParallelSentinelStartId(parallelId: string): string {
|
||||
return `${PARALLEL.SENTINEL.PREFIX}${parallelId}${PARALLEL.SENTINEL.START_SUFFIX}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block ID is a loop or parallel container and returns the sentinel-start ID if so.
|
||||
* Returns null if the block is not a container.
|
||||
*/
|
||||
export function resolveContainerToSentinelStart(blockId: string, dag: DAG): string | null {
|
||||
if (dag.loopConfigs.has(blockId)) {
|
||||
return buildLoopSentinelStartId(blockId)
|
||||
}
|
||||
if (dag.parallelConfigs.has(blockId)) {
|
||||
return buildParallelSentinelStartId(blockId)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of validating a block for run-from-block execution.
|
||||
*/
|
||||
export interface RunFromBlockValidation {
|
||||
valid: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for run-from-block execution mode.
|
||||
*/
|
||||
export interface RunFromBlockContext {
|
||||
/** The block ID to start execution from */
|
||||
startBlockId: string
|
||||
/** Set of block IDs that need re-execution (start block + all downstream) */
|
||||
dirtySet: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of computing execution sets for run-from-block mode.
|
||||
*/
|
||||
export interface ExecutionSets {
|
||||
/** Blocks that need re-execution (start block + all downstream) */
|
||||
dirtySet: Set<string>
|
||||
/** Blocks that are upstream (ancestors) of the start block */
|
||||
upstreamSet: Set<string>
|
||||
/** Blocks that are upstream of any dirty block (for snapshot preservation) */
|
||||
reachableUpstreamSet: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the dirty set, upstream set, and reachable upstream set.
|
||||
* - Dirty set: start block + all blocks reachable via outgoing edges (need re-execution)
|
||||
* - Upstream set: all blocks reachable via incoming edges from the start block
|
||||
* - Reachable upstream set: all non-dirty blocks that are upstream of ANY dirty block
|
||||
* (includes sibling branches that dirty blocks may reference)
|
||||
*
|
||||
* For loop/parallel containers, starts from the sentinel-start node and includes
|
||||
* the container ID itself in the dirty set.
|
||||
*
|
||||
* @param dag - The workflow DAG
|
||||
* @param startBlockId - The block to start execution from
|
||||
* @returns Object containing dirtySet, upstreamSet, and reachableUpstreamSet
|
||||
*/
|
||||
export function computeExecutionSets(dag: DAG, startBlockId: string): ExecutionSets {
|
||||
const dirty = new Set<string>([startBlockId])
|
||||
const upstream = new Set<string>()
|
||||
const sentinelStartId = resolveContainerToSentinelStart(startBlockId, dag)
|
||||
const traversalStartId = sentinelStartId ?? startBlockId
|
||||
|
||||
if (sentinelStartId) {
|
||||
dirty.add(sentinelStartId)
|
||||
}
|
||||
|
||||
// BFS downstream for dirty set
|
||||
const downstreamQueue = [traversalStartId]
|
||||
while (downstreamQueue.length > 0) {
|
||||
const nodeId = downstreamQueue.shift()!
|
||||
const node = dag.nodes.get(nodeId)
|
||||
if (!node) continue
|
||||
|
||||
for (const [, edge] of node.outgoingEdges) {
|
||||
if (!dirty.has(edge.target)) {
|
||||
dirty.add(edge.target)
|
||||
downstreamQueue.push(edge.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BFS upstream from start block for upstream set
|
||||
const upstreamQueue = [traversalStartId]
|
||||
while (upstreamQueue.length > 0) {
|
||||
const nodeId = upstreamQueue.shift()!
|
||||
const node = dag.nodes.get(nodeId)
|
||||
if (!node) continue
|
||||
|
||||
for (const sourceId of node.incomingEdges) {
|
||||
if (!upstream.has(sourceId)) {
|
||||
upstream.add(sourceId)
|
||||
upstreamQueue.push(sourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute reachable upstream: all non-dirty blocks upstream of ANY dirty block
|
||||
// This handles the case where a dirty block (like C in A->C, B->C) may reference
|
||||
// sibling branches (like B when running from A)
|
||||
const reachableUpstream = new Set<string>()
|
||||
for (const dirtyNodeId of dirty) {
|
||||
const node = dag.nodes.get(dirtyNodeId)
|
||||
if (!node) continue
|
||||
|
||||
// BFS upstream from this dirty node
|
||||
const queue = [...node.incomingEdges]
|
||||
while (queue.length > 0) {
|
||||
const sourceId = queue.shift()!
|
||||
if (reachableUpstream.has(sourceId) || dirty.has(sourceId)) continue
|
||||
|
||||
reachableUpstream.add(sourceId)
|
||||
const sourceNode = dag.nodes.get(sourceId)
|
||||
if (sourceNode) {
|
||||
queue.push(...sourceNode.incomingEdges)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { dirtySet: dirty, upstreamSet: upstream, reachableUpstreamSet: reachableUpstream }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a block can be used as a run-from-block starting point.
|
||||
*
|
||||
* Validation rules:
|
||||
* - Block must exist in the DAG (or be a loop/parallel container)
|
||||
* - Block cannot be inside a loop (but loop containers are allowed)
|
||||
* - Block cannot be inside a parallel (but parallel containers are allowed)
|
||||
* - Block cannot be a sentinel node
|
||||
* - All upstream dependencies must have been executed (have cached outputs)
|
||||
*
|
||||
* @param blockId - The block ID to validate
|
||||
* @param dag - The workflow DAG
|
||||
* @param executedBlocks - Set of blocks that were executed in the source run
|
||||
* @returns Validation result with error message if invalid
|
||||
*/
|
||||
export function validateRunFromBlock(
|
||||
blockId: string,
|
||||
dag: DAG,
|
||||
executedBlocks: Set<string>
|
||||
): RunFromBlockValidation {
|
||||
const node = dag.nodes.get(blockId)
|
||||
const isLoopContainer = dag.loopConfigs.has(blockId)
|
||||
const isParallelContainer = dag.parallelConfigs.has(blockId)
|
||||
const isContainer = isLoopContainer || isParallelContainer
|
||||
let validationNode = node
|
||||
|
||||
if (!node && !isContainer) {
|
||||
return { valid: false, error: `Block not found in workflow: ${blockId}` }
|
||||
}
|
||||
|
||||
if (isContainer) {
|
||||
const parentLoopId = findParentLoop(blockId, dag)
|
||||
if (parentLoopId) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot run from block inside loop: ${parentLoopId}`,
|
||||
}
|
||||
}
|
||||
|
||||
const parentParallelId = findParentParallel(blockId, dag)
|
||||
if (parentParallelId) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot run from block inside parallel: ${parentParallelId}`,
|
||||
}
|
||||
}
|
||||
|
||||
const sentinelStartId = resolveContainerToSentinelStart(blockId, dag)
|
||||
if (!sentinelStartId || !dag.nodes.has(sentinelStartId)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Container sentinel not found for: ${blockId}`,
|
||||
}
|
||||
}
|
||||
validationNode = dag.nodes.get(sentinelStartId)
|
||||
}
|
||||
|
||||
if (node) {
|
||||
if (node.metadata.isLoopNode) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot run from block inside loop: ${node.metadata.subflowId}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (node.metadata.isParallelBranch) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot run from block inside parallel: ${node.metadata.subflowId}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (node.metadata.isSentinel) {
|
||||
return { valid: false, error: 'Cannot run from sentinel node' }
|
||||
}
|
||||
}
|
||||
|
||||
if (validationNode) {
|
||||
// Check immediate upstream dependencies were executed
|
||||
for (const sourceId of validationNode.incomingEdges) {
|
||||
const sourceNode = dag.nodes.get(sourceId)
|
||||
// Skip sentinel nodes - they're internal and not in executedBlocks
|
||||
if (sourceNode?.metadata.isSentinel) continue
|
||||
|
||||
// Skip trigger nodes - they're entry points and don't need prior execution
|
||||
// A trigger node has no incoming edges
|
||||
if (sourceNode && sourceNode.incomingEdges.size === 0) continue
|
||||
|
||||
if (!executedBlocks.has(sourceId)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Upstream dependency not executed: ${sourceId}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
function findParentLoop(blockId: string, dag: DAG): string | undefined {
|
||||
for (const [loopId, config] of dag.loopConfigs) {
|
||||
if (loopId !== blockId && config.nodes?.includes(blockId)) {
|
||||
return loopId
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function findParentParallel(blockId: string, dag: DAG): string | undefined {
|
||||
for (const [parallelId, config] of dag.parallelConfigs) {
|
||||
if (parallelId !== blockId && config.nodes?.includes(blockId)) {
|
||||
return parallelId
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { StartBlockPath } from '@/lib/workflows/triggers/triggers'
|
||||
import type { UserFile } from '@/executor/types'
|
||||
import {
|
||||
buildResolutionFromBlock,
|
||||
buildStartBlockOutput,
|
||||
resolveExecutorStartBlock,
|
||||
} from '@/executor/utils/start-block'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
function createBlock(
|
||||
type: string,
|
||||
id = type,
|
||||
options?: { subBlocks?: Record<string, unknown> }
|
||||
): SerializedBlock {
|
||||
return {
|
||||
id,
|
||||
position: { x: 0, y: 0 },
|
||||
config: {
|
||||
tool: type,
|
||||
params: options?.subBlocks?.inputFormat ? { inputFormat: options.subBlocks.inputFormat } : {},
|
||||
},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: {
|
||||
id: type,
|
||||
name: `block-${type}`,
|
||||
category: 'triggers',
|
||||
...(options?.subBlocks ? { subBlocks: options.subBlocks } : {}),
|
||||
} as SerializedBlock['metadata'] & { subBlocks?: Record<string, unknown> },
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
describe('start-block utilities', () => {
|
||||
it.concurrent('buildResolutionFromBlock returns null when metadata id missing', () => {
|
||||
const block = createBlock('api_trigger')
|
||||
;(block.metadata as Record<string, unknown>).id = undefined
|
||||
|
||||
expect(buildResolutionFromBlock(block)).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('resolveExecutorStartBlock prefers unified start block', () => {
|
||||
const blocks = [
|
||||
createBlock('api_trigger', 'api'),
|
||||
createBlock('starter', 'starter'),
|
||||
createBlock('start_trigger', 'start'),
|
||||
]
|
||||
|
||||
const resolution = resolveExecutorStartBlock(blocks, {
|
||||
execution: 'api',
|
||||
isChildWorkflow: false,
|
||||
})
|
||||
|
||||
expect(resolution?.blockId).toBe('start')
|
||||
expect(resolution?.path).toBe(StartBlockPath.UNIFIED)
|
||||
})
|
||||
|
||||
it.concurrent('buildStartBlockOutput normalizes unified start payload', () => {
|
||||
const block = createBlock('start_trigger', 'start')
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.UNIFIED,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { payload: 'value' },
|
||||
})
|
||||
|
||||
expect(output.payload).toBe('value')
|
||||
expect(output.input).toBeUndefined()
|
||||
expect(output.conversationId).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('buildStartBlockOutput uses trigger schema for API triggers', () => {
|
||||
const apiBlock = createBlock('api_trigger', 'api', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [
|
||||
{ name: 'name', type: 'string' },
|
||||
{ name: 'count', type: 'number' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const resolution = {
|
||||
blockId: 'api',
|
||||
block: apiBlock,
|
||||
path: StartBlockPath.SPLIT_API,
|
||||
} as const
|
||||
|
||||
const files: UserFile[] = [
|
||||
{
|
||||
id: 'file-1',
|
||||
name: 'document.txt',
|
||||
url: 'https://example.com/document.txt',
|
||||
size: 42,
|
||||
type: 'text/plain',
|
||||
key: 'file-key',
|
||||
},
|
||||
]
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: {
|
||||
input: {
|
||||
name: 'Ada',
|
||||
count: '5',
|
||||
},
|
||||
files,
|
||||
},
|
||||
})
|
||||
|
||||
expect(output.name).toBe('Ada')
|
||||
expect(output.input).toEqual({ name: 'Ada', count: 5 })
|
||||
expect(output.files).toEqual(files)
|
||||
})
|
||||
|
||||
it.concurrent('buildStartBlockOutput normalizes Start files from internal serve URLs', () => {
|
||||
const block = createBlock('start_trigger', 'start')
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.UNIFIED,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: {
|
||||
files: [
|
||||
{
|
||||
id: 'file_1',
|
||||
name: 'screenshot.png',
|
||||
url: '/api/files/serve/s3/execution%2Fworkspace-id%2Fworkflow-id%2Fexecution-id%2Fscreenshot.png?context=execution',
|
||||
size: 243289,
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(output.files).toEqual([
|
||||
{
|
||||
id: 'file_1',
|
||||
name: 'screenshot.png',
|
||||
url: '/api/files/serve/s3/execution%2Fworkspace-id%2Fworkflow-id%2Fexecution-id%2Fscreenshot.png?context=execution',
|
||||
size: 243289,
|
||||
type: 'image/png',
|
||||
key: 'execution/workspace-id/workflow-id/execution-id/screenshot.png',
|
||||
context: 'execution',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it.concurrent('rejects inputFormat fields that collide with executor routing keys', () => {
|
||||
const block = createBlock('start_trigger', 'start', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [
|
||||
{ name: 'error', type: 'string' },
|
||||
{ name: 'error', type: 'string' },
|
||||
{ name: ' selectedOption ', type: 'string' },
|
||||
{ name: 'selectedRoute', type: 'string' },
|
||||
{ name: '_pauseMetadata', type: 'object' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.UNIFIED,
|
||||
} as const
|
||||
|
||||
expect(() =>
|
||||
buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { error: false, selectedRoute: 'source' },
|
||||
})
|
||||
).toThrow(
|
||||
'Start block "block-start_trigger" cannot use reserved input format field name(s): error, selectedOption, selectedRoute, _pauseMetadata'
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'rejects reserved top-level runtime input keys copied to unified Start output',
|
||||
() => {
|
||||
const block = createBlock('start_trigger', 'start')
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.UNIFIED,
|
||||
} as const
|
||||
|
||||
expect(() =>
|
||||
buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { error: 'false', payload: 'value' },
|
||||
})
|
||||
).toThrow(
|
||||
'Start block "block-start_trigger" cannot use reserved runtime input field name(s): error'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('rejects reserved nested API input keys copied to trigger output', () => {
|
||||
const block = createBlock('api_trigger', 'api')
|
||||
const resolution = {
|
||||
blockId: 'api',
|
||||
block,
|
||||
path: StartBlockPath.SPLIT_API,
|
||||
} as const
|
||||
|
||||
expect(() =>
|
||||
buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { input: { selectedRoute: 'route-1', payload: 'value' } },
|
||||
})
|
||||
).toThrow(
|
||||
'Start block "block-api_trigger" cannot use reserved runtime input field name(s): selectedRoute'
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('allows reserved inputFormat field names on split chat trigger output', () => {
|
||||
const block = createBlock('chat_trigger', 'chat', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [{ name: 'error', type: 'string' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
const resolution = {
|
||||
blockId: 'chat',
|
||||
block,
|
||||
path: StartBlockPath.SPLIT_CHAT,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { input: 'hello', conversationId: 'conversation-1' },
|
||||
})
|
||||
|
||||
expect(output).toEqual({ input: 'hello', conversationId: 'conversation-1' })
|
||||
})
|
||||
|
||||
it.concurrent('allows reserved inputFormat field names on legacy chat starter output', () => {
|
||||
const block = createBlock('starter', 'starter', {
|
||||
subBlocks: {
|
||||
startWorkflow: { value: 'chat' },
|
||||
inputFormat: {
|
||||
value: [{ name: 'error', type: 'string' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
const resolution = {
|
||||
blockId: 'starter',
|
||||
block,
|
||||
path: StartBlockPath.LEGACY_STARTER,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { input: 'hello' },
|
||||
})
|
||||
|
||||
expect(output).toEqual({ input: 'hello' })
|
||||
})
|
||||
|
||||
it.concurrent('allows reserved inputFormat field names on serialized legacy chat starter', () => {
|
||||
const block = createBlock('starter', 'starter')
|
||||
block.config.params = {
|
||||
startWorkflow: 'chat',
|
||||
inputFormat: [{ name: 'error', type: 'string' }],
|
||||
}
|
||||
const resolution = {
|
||||
blockId: 'starter',
|
||||
block,
|
||||
path: StartBlockPath.LEGACY_STARTER,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { input: 'hello' },
|
||||
})
|
||||
|
||||
expect(output).toEqual({ input: 'hello' })
|
||||
})
|
||||
|
||||
it.concurrent('ignores malformed non-string inputFormat field names', () => {
|
||||
const block = createBlock('start_trigger', 'start', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [
|
||||
{ name: 123, type: 'string', value: 'ignored' },
|
||||
{ name: 'customField', type: 'string' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.UNIFIED,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { customField: 'value' },
|
||||
})
|
||||
|
||||
expect(output.customField).toBe('value')
|
||||
expect(output[123]).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('inputFormat default values', () => {
|
||||
it.concurrent('uses default value when runtime does not provide the field', () => {
|
||||
const block = createBlock('start_trigger', 'start', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [
|
||||
{ name: 'input', type: 'string' },
|
||||
{ name: 'customField', type: 'string', value: 'defaultValue' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.UNIFIED,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { input: 'hello' },
|
||||
})
|
||||
|
||||
expect(output.input).toBe('hello')
|
||||
expect(output.customField).toBe('defaultValue')
|
||||
})
|
||||
|
||||
it.concurrent('runtime value overrides default value', () => {
|
||||
const block = createBlock('start_trigger', 'start', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [{ name: 'customField', type: 'string', value: 'defaultValue' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.UNIFIED,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { customField: 'runtimeValue' },
|
||||
})
|
||||
|
||||
expect(output.customField).toBe('runtimeValue')
|
||||
})
|
||||
|
||||
it.concurrent('empty string from runtime overrides default value', () => {
|
||||
const block = createBlock('start_trigger', 'start', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [{ name: 'customField', type: 'string', value: 'defaultValue' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.UNIFIED,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { customField: '' },
|
||||
})
|
||||
|
||||
expect(output.customField).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('null from runtime does not override default value', () => {
|
||||
const block = createBlock('start_trigger', 'start', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [{ name: 'customField', type: 'string', value: 'defaultValue' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.UNIFIED,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { customField: null },
|
||||
})
|
||||
|
||||
expect(output.customField).toBe('defaultValue')
|
||||
})
|
||||
|
||||
it.concurrent('preserves coerced types for unified start payload', () => {
|
||||
const block = createBlock('start_trigger', 'start', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [
|
||||
{ name: 'conversation_id', type: 'number' },
|
||||
{ name: 'sender', type: 'object' },
|
||||
{ name: 'is_active', type: 'boolean' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.UNIFIED,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: {
|
||||
conversation_id: '149',
|
||||
sender: '{"id":10,"email":"user@example.com"}',
|
||||
is_active: 'true',
|
||||
},
|
||||
})
|
||||
|
||||
expect(output.conversation_id).toBe(149)
|
||||
expect(output.sender).toEqual({ id: 10, email: 'user@example.com' })
|
||||
expect(output.is_active).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'prefers coerced inputFormat values over duplicated top-level workflowInput keys',
|
||||
() => {
|
||||
const block = createBlock('start_trigger', 'start', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [
|
||||
{ name: 'conversation_id', type: 'number' },
|
||||
{ name: 'sender', type: 'object' },
|
||||
{ name: 'is_active', type: 'boolean' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.UNIFIED,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: {
|
||||
input: {
|
||||
conversation_id: '149',
|
||||
sender: '{"id":10,"email":"user@example.com"}',
|
||||
is_active: 'false',
|
||||
},
|
||||
conversation_id: '150',
|
||||
sender: '{"id":99,"email":"wrong@example.com"}',
|
||||
is_active: 'true',
|
||||
extra: 'keep-me',
|
||||
},
|
||||
})
|
||||
|
||||
expect(output.conversation_id).toBe(149)
|
||||
expect(output.sender).toEqual({ id: 10, email: 'user@example.com' })
|
||||
expect(output.is_active).toBe(false)
|
||||
expect(output.extra).toBe('keep-me')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('EXTERNAL_TRIGGER path', () => {
|
||||
it.concurrent('rejects reserved runtime input keys copied to external trigger output', () => {
|
||||
const block = createBlock('webhook', 'start')
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.EXTERNAL_TRIGGER,
|
||||
} as const
|
||||
|
||||
expect(() =>
|
||||
buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { _pauseMetadata: { contextId: 'fake-pause' }, payload: 'value' },
|
||||
})
|
||||
).toThrow(
|
||||
'Start block "block-webhook" cannot use reserved runtime input field name(s): _pauseMetadata'
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('preserves coerced types for integration trigger payload', () => {
|
||||
const block = createBlock('webhook', 'start', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [
|
||||
{ name: 'count', type: 'number' },
|
||||
{ name: 'payload', type: 'object' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.EXTERNAL_TRIGGER,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: {
|
||||
count: '5',
|
||||
payload: '{"event":"push"}',
|
||||
extra: 'untouched',
|
||||
},
|
||||
})
|
||||
|
||||
expect(output.count).toBe(5)
|
||||
expect(output.payload).toEqual({ event: 'push' })
|
||||
expect(output.extra).toBe('untouched')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,636 @@
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import {
|
||||
inferContextFromKey,
|
||||
isInternalFileUrl,
|
||||
parseInternalFileUrl,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import {
|
||||
classifyStartBlockType,
|
||||
resolveStartCandidates,
|
||||
StartBlockPath,
|
||||
} from '@/lib/workflows/triggers/triggers'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import {
|
||||
EXECUTION_CONTROL_OUTPUT_FIELD_NAMES,
|
||||
type NormalizedBlockOutput,
|
||||
type UserFile,
|
||||
} from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { safeAssign } from '@/tools/safe-assign'
|
||||
|
||||
type ExecutionKind = 'chat' | 'manual' | 'api' | 'external'
|
||||
|
||||
const EXECUTION_CONTROL_OUTPUT_FIELD_NAME_SET = new Set<string>(
|
||||
EXECUTION_CONTROL_OUTPUT_FIELD_NAMES
|
||||
)
|
||||
|
||||
export interface ExecutorStartResolution {
|
||||
blockId: string
|
||||
block: SerializedBlock
|
||||
path: StartBlockPath
|
||||
}
|
||||
|
||||
export interface ResolveExecutorStartOptions {
|
||||
execution: ExecutionKind
|
||||
isChildWorkflow: boolean
|
||||
}
|
||||
|
||||
type StartCandidateWrapper = {
|
||||
type: string
|
||||
subBlocks?: Record<string, unknown>
|
||||
original: SerializedBlock
|
||||
}
|
||||
|
||||
export function resolveExecutorStartBlock(
|
||||
blocks: SerializedBlock[],
|
||||
options: ResolveExecutorStartOptions
|
||||
): ExecutorStartResolution | null {
|
||||
if (blocks.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const blockMap = blocks.reduce<Record<string, StartCandidateWrapper>>((acc, block) => {
|
||||
const type = block.metadata?.id
|
||||
if (!type) {
|
||||
return acc
|
||||
}
|
||||
|
||||
acc[block.id] = {
|
||||
type,
|
||||
subBlocks: extractSubBlocks(block),
|
||||
original: block,
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const candidates = resolveStartCandidates(blockMap, {
|
||||
execution: options.execution,
|
||||
isChildWorkflow: options.isChildWorkflow,
|
||||
})
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (options.isChildWorkflow && candidates.length > 1) {
|
||||
throw new Error('Child workflow has multiple trigger blocks. Keep only one Start block.')
|
||||
}
|
||||
|
||||
const [primary] = candidates
|
||||
return {
|
||||
blockId: primary.blockId,
|
||||
block: primary.block.original,
|
||||
path: primary.path,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildResolutionFromBlock(block: SerializedBlock): ExecutorStartResolution | null {
|
||||
const type = block.metadata?.id
|
||||
if (!type) {
|
||||
return null
|
||||
}
|
||||
|
||||
const category = block.metadata?.category
|
||||
const triggerModeEnabled = block.config?.params?.triggerMode === true
|
||||
|
||||
const path = classifyStartBlockType(type, {
|
||||
category,
|
||||
triggerModeEnabled,
|
||||
})
|
||||
if (!path) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
blockId: block.id,
|
||||
block,
|
||||
path,
|
||||
}
|
||||
}
|
||||
|
||||
function readMetadataSubBlockValue(block: SerializedBlock, key: string): unknown {
|
||||
const metadata = block.metadata
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const maybeWithSubBlocks = metadata as typeof metadata & {
|
||||
subBlocks?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const raw = maybeWithSubBlocks.subBlocks?.[key]
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (raw as { value?: unknown }).value
|
||||
}
|
||||
|
||||
function extractInputFormat(block: SerializedBlock): InputFormatField[] {
|
||||
const fromMetadata = readMetadataSubBlockValue(block, 'inputFormat')
|
||||
const fromParams = block.config?.params?.inputFormat
|
||||
const source = fromMetadata ?? fromParams
|
||||
|
||||
if (!Array.isArray(source)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return source
|
||||
.filter((field): field is InputFormatField => isRecordLike(field))
|
||||
.map((field) => field)
|
||||
}
|
||||
|
||||
function normalizeLegacyStarterMode(modeValue: unknown): 'manual' | 'api' | 'chat' | null {
|
||||
if (modeValue === 'chat') return 'chat'
|
||||
if (modeValue === 'api' || modeValue === 'run') return 'api'
|
||||
if (modeValue === undefined || modeValue === 'manual') return 'manual'
|
||||
return null
|
||||
}
|
||||
|
||||
function getSerializedLegacyStarterMode(block: SerializedBlock): 'manual' | 'api' | 'chat' | null {
|
||||
const fromMetadata = readMetadataSubBlockValue(block, 'startWorkflow')
|
||||
if (fromMetadata !== undefined) {
|
||||
return normalizeLegacyStarterMode(fromMetadata)
|
||||
}
|
||||
|
||||
return normalizeLegacyStarterMode(block.config?.params?.startWorkflow)
|
||||
}
|
||||
|
||||
function readInputFormatFieldName(field: InputFormatField): string | undefined {
|
||||
return typeof field.name === 'string' ? field.name.trim() : undefined
|
||||
}
|
||||
|
||||
function collectExecutionControlFieldNames(fieldNames: Iterable<string | undefined>): string[] {
|
||||
const reservedFieldNames = new Set<string>()
|
||||
|
||||
for (const fieldName of fieldNames) {
|
||||
if (fieldName && EXECUTION_CONTROL_OUTPUT_FIELD_NAME_SET.has(fieldName)) {
|
||||
reservedFieldNames.add(fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(reservedFieldNames)
|
||||
}
|
||||
|
||||
function throwReservedStartOutputFieldsError(
|
||||
block: SerializedBlock,
|
||||
reservedFieldNames: string[],
|
||||
source: 'input format' | 'runtime input'
|
||||
): never {
|
||||
const blockName = block.metadata?.name ?? block.id
|
||||
|
||||
throw new Error(
|
||||
`Start block "${blockName}" cannot use reserved ${source} field name(s): ${reservedFieldNames.join(', ')}. These names control workflow execution and cannot be used as Start outputs. Rename these fields before running the workflow. Reserved names are: ${EXECUTION_CONTROL_OUTPUT_FIELD_NAMES.join(', ')}.`
|
||||
)
|
||||
}
|
||||
|
||||
function assertNoReservedInputFormatFields(
|
||||
inputFormat: InputFormatField[],
|
||||
block: SerializedBlock
|
||||
): void {
|
||||
const reservedFieldNames = collectExecutionControlFieldNames(
|
||||
inputFormat.map(readInputFormatFieldName)
|
||||
)
|
||||
|
||||
if (reservedFieldNames.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
throwReservedStartOutputFieldsError(block, reservedFieldNames, 'input format')
|
||||
}
|
||||
|
||||
function assertNoReservedStartOutputFields(
|
||||
output: NormalizedBlockOutput,
|
||||
block: SerializedBlock
|
||||
): void {
|
||||
const reservedFieldNames = collectExecutionControlFieldNames(Object.keys(output))
|
||||
|
||||
if (reservedFieldNames.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
throwReservedStartOutputFieldsError(block, reservedFieldNames, 'runtime input')
|
||||
}
|
||||
|
||||
function pathConsumesInputFormat(
|
||||
path: StartBlockPath,
|
||||
legacyStarterMode: 'manual' | 'api' | 'chat' | null
|
||||
): boolean {
|
||||
switch (path) {
|
||||
case StartBlockPath.SPLIT_CHAT:
|
||||
return false
|
||||
case StartBlockPath.LEGACY_STARTER:
|
||||
return legacyStarterMode !== 'chat'
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function coerceValue(type: string | null | undefined, value: unknown): unknown {
|
||||
if (value === undefined || value === null) {
|
||||
return value
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return typeof value === 'string' ? value : String(value)
|
||||
case 'number': {
|
||||
if (typeof value === 'number') return value
|
||||
const parsed = Number(value)
|
||||
return Number.isNaN(parsed) ? value : parsed
|
||||
}
|
||||
case 'boolean': {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (value === 'true' || value === '1' || value === 1) return true
|
||||
if (value === 'false' || value === '0' || value === 0) return false
|
||||
return value
|
||||
}
|
||||
case 'object':
|
||||
case 'array': {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
interface DerivedInputResult {
|
||||
structuredInput: Record<string, unknown>
|
||||
finalInput: unknown
|
||||
hasStructured: boolean
|
||||
}
|
||||
|
||||
function deriveInputFromFormat(
|
||||
inputFormat: InputFormatField[],
|
||||
workflowInput: unknown
|
||||
): DerivedInputResult {
|
||||
const structuredInput: Record<string, unknown> = {}
|
||||
|
||||
if (inputFormat.length === 0) {
|
||||
return {
|
||||
structuredInput,
|
||||
finalInput: getRawInputCandidate(workflowInput),
|
||||
hasStructured: false,
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of inputFormat) {
|
||||
const fieldName = readInputFormatFieldName(field)
|
||||
if (!fieldName) continue
|
||||
|
||||
let fieldValue: unknown
|
||||
const workflowRecord = isRecordLike(workflowInput) ? workflowInput : undefined
|
||||
|
||||
if (workflowRecord) {
|
||||
const inputContainer = workflowRecord.input
|
||||
if (isRecordLike(inputContainer) && Object.hasOwn(inputContainer, fieldName)) {
|
||||
fieldValue = inputContainer[fieldName]
|
||||
} else if (Object.hasOwn(workflowRecord, fieldName)) {
|
||||
fieldValue = workflowRecord[fieldName]
|
||||
}
|
||||
}
|
||||
|
||||
// Use the default value from inputFormat if the field value wasn't provided at runtime
|
||||
if (fieldValue === undefined || fieldValue === null) {
|
||||
fieldValue = field.value
|
||||
}
|
||||
|
||||
structuredInput[fieldName] = coerceValue(field.type, fieldValue)
|
||||
}
|
||||
|
||||
const hasStructured = Object.keys(structuredInput).length > 0
|
||||
const finalInput = hasStructured ? structuredInput : getRawInputCandidate(workflowInput)
|
||||
|
||||
return {
|
||||
structuredInput,
|
||||
finalInput,
|
||||
hasStructured,
|
||||
}
|
||||
}
|
||||
|
||||
function getRawInputCandidate(workflowInput: unknown): unknown {
|
||||
if (isRecordLike(workflowInput) && Object.hasOwn(workflowInput, 'input')) {
|
||||
return workflowInput.input
|
||||
}
|
||||
return workflowInput
|
||||
}
|
||||
|
||||
function normalizeStartFile(file: unknown): UserFile | null {
|
||||
if (!isRecordLike(file)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const id = typeof file.id === 'string' ? file.id : ''
|
||||
const name = typeof file.name === 'string' ? file.name : ''
|
||||
const url =
|
||||
typeof file.url === 'string' ? file.url : typeof file.path === 'string' ? file.path : ''
|
||||
const size = typeof file.size === 'number' ? file.size : Number.NaN
|
||||
const type = typeof file.type === 'string' ? file.type : ''
|
||||
const explicitKey = typeof file.key === 'string' ? file.key : ''
|
||||
|
||||
let key = explicitKey
|
||||
let context = typeof file.context === 'string' ? file.context : undefined
|
||||
|
||||
if (!key && url && isInternalFileUrl(url)) {
|
||||
try {
|
||||
const parsed = parseInternalFileUrl(url)
|
||||
key = parsed.key
|
||||
context = context || parsed.context
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!context && key) {
|
||||
try {
|
||||
context = inferContextFromKey(key)
|
||||
} catch {
|
||||
// Older file outputs may have opaque keys; keep the file shape intact.
|
||||
}
|
||||
}
|
||||
|
||||
if (!id || !name || !url || !Number.isFinite(size) || !type || !key) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
url,
|
||||
size,
|
||||
type,
|
||||
key,
|
||||
...(context && { context }),
|
||||
...(typeof file.base64 === 'string' && { base64: file.base64 }),
|
||||
}
|
||||
}
|
||||
|
||||
function getFilesFromWorkflowInput(workflowInput: unknown): UserFile[] | undefined {
|
||||
if (!isRecordLike(workflowInput)) {
|
||||
return undefined
|
||||
}
|
||||
const files = workflowInput.files
|
||||
if (!Array.isArray(files)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalizedFiles = files.map(normalizeStartFile)
|
||||
if (normalizedFiles.every((file): file is UserFile => Boolean(file))) {
|
||||
return normalizedFiles
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function mergeFilesIntoOutput(
|
||||
output: NormalizedBlockOutput,
|
||||
workflowInput: unknown
|
||||
): NormalizedBlockOutput {
|
||||
const files = getFilesFromWorkflowInput(workflowInput)
|
||||
if (files) {
|
||||
output.files = files
|
||||
} else if (isRecordLike(workflowInput) && Object.hasOwn(workflowInput, 'files')) {
|
||||
output.files = undefined
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function ensureString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function buildUnifiedStartOutput(
|
||||
workflowInput: unknown,
|
||||
structuredInput: Record<string, unknown>,
|
||||
hasStructured: boolean
|
||||
): NormalizedBlockOutput {
|
||||
const output: NormalizedBlockOutput = {}
|
||||
const structuredKeys = hasStructured ? new Set(Object.keys(structuredInput)) : null
|
||||
|
||||
if (hasStructured) {
|
||||
for (const [key, value] of Object.entries(structuredInput)) {
|
||||
output[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if (isRecordLike(workflowInput)) {
|
||||
for (const [key, value] of Object.entries(workflowInput)) {
|
||||
if (key === 'onUploadError') continue
|
||||
// Skip keys already set by schema-coerced structuredInput to
|
||||
// prevent raw workflowInput strings from overwriting typed values.
|
||||
if (structuredKeys?.has(key)) continue
|
||||
// Runtime values override defaults (except undefined/null which mean "not provided")
|
||||
if (value !== undefined && value !== null) {
|
||||
output[key] = value
|
||||
} else if (!Object.hasOwn(output, key)) {
|
||||
output[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.hasOwn(output, 'input')) {
|
||||
const fallbackInput =
|
||||
isRecordLike(workflowInput) && typeof workflowInput.input !== 'undefined'
|
||||
? ensureString(workflowInput.input)
|
||||
: ''
|
||||
output.input = fallbackInput ? fallbackInput : undefined
|
||||
} else if (typeof output.input === 'string' && output.input.length === 0) {
|
||||
output.input = undefined
|
||||
}
|
||||
|
||||
if (!Object.hasOwn(output, 'conversationId')) {
|
||||
const conversationId =
|
||||
isRecordLike(workflowInput) && workflowInput.conversationId
|
||||
? ensureString(workflowInput.conversationId)
|
||||
: undefined
|
||||
if (conversationId) {
|
||||
output.conversationId = conversationId
|
||||
}
|
||||
} else if (typeof output.conversationId === 'string' && output.conversationId.length === 0) {
|
||||
output.conversationId = undefined
|
||||
}
|
||||
|
||||
return mergeFilesIntoOutput(output, workflowInput)
|
||||
}
|
||||
|
||||
function buildApiOrInputOutput(finalInput: unknown, workflowInput: unknown): NormalizedBlockOutput {
|
||||
const isObjectInput = isRecordLike(finalInput)
|
||||
|
||||
const output: NormalizedBlockOutput = isObjectInput
|
||||
? {
|
||||
...(finalInput as Record<string, unknown>),
|
||||
input: { ...(finalInput as Record<string, unknown>) },
|
||||
}
|
||||
: { input: finalInput }
|
||||
|
||||
return mergeFilesIntoOutput(output, workflowInput)
|
||||
}
|
||||
|
||||
function buildChatOutput(workflowInput: unknown): NormalizedBlockOutput {
|
||||
const source = isRecordLike(workflowInput) ? workflowInput : undefined
|
||||
|
||||
const output: NormalizedBlockOutput = {
|
||||
input: ensureString(source?.input),
|
||||
}
|
||||
|
||||
const conversationId = ensureString(source?.conversationId)
|
||||
if (conversationId) {
|
||||
output.conversationId = conversationId
|
||||
}
|
||||
|
||||
return mergeFilesIntoOutput(output, workflowInput)
|
||||
}
|
||||
|
||||
function buildLegacyStarterOutput(
|
||||
finalInput: unknown,
|
||||
workflowInput: unknown,
|
||||
mode: 'manual' | 'api' | 'chat' | null
|
||||
): NormalizedBlockOutput {
|
||||
if (mode === 'chat') {
|
||||
return buildChatOutput(workflowInput)
|
||||
}
|
||||
|
||||
const output: NormalizedBlockOutput = {}
|
||||
const finalObject = isRecordLike(finalInput) ? finalInput : undefined
|
||||
|
||||
if (finalObject) {
|
||||
safeAssign(output, finalObject)
|
||||
output.input = { ...finalObject }
|
||||
} else {
|
||||
output.input = finalInput
|
||||
}
|
||||
|
||||
const conversationId = isRecordLike(workflowInput) ? workflowInput.conversationId : undefined
|
||||
if (conversationId) {
|
||||
output.conversationId = ensureString(conversationId)
|
||||
}
|
||||
|
||||
return mergeFilesIntoOutput(output, workflowInput)
|
||||
}
|
||||
|
||||
function buildManualTriggerOutput(
|
||||
finalInput: unknown,
|
||||
workflowInput: unknown
|
||||
): NormalizedBlockOutput {
|
||||
const finalObject = isRecordLike(finalInput) ? (finalInput as Record<string, unknown>) : undefined
|
||||
|
||||
const output: NormalizedBlockOutput = finalObject ? { ...finalObject } : { input: finalInput }
|
||||
|
||||
if (!Object.hasOwn(output, 'input')) {
|
||||
output.input = getRawInputCandidate(workflowInput)
|
||||
}
|
||||
|
||||
return mergeFilesIntoOutput(output, workflowInput)
|
||||
}
|
||||
|
||||
function buildIntegrationTriggerOutput(
|
||||
workflowInput: unknown,
|
||||
structuredInput: Record<string, unknown>,
|
||||
hasStructured: boolean
|
||||
): NormalizedBlockOutput {
|
||||
const output: NormalizedBlockOutput = {}
|
||||
const structuredKeys = hasStructured ? new Set(Object.keys(structuredInput)) : null
|
||||
|
||||
if (hasStructured) {
|
||||
for (const [key, value] of Object.entries(structuredInput)) {
|
||||
output[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if (isRecordLike(workflowInput)) {
|
||||
for (const [key, value] of Object.entries(workflowInput)) {
|
||||
if (structuredKeys?.has(key)) continue
|
||||
if (value !== undefined && value !== null) {
|
||||
output[key] = value
|
||||
} else if (!Object.hasOwn(output, key)) {
|
||||
output[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergeFilesIntoOutput(output, workflowInput)
|
||||
}
|
||||
|
||||
function extractSubBlocks(block: SerializedBlock): Record<string, unknown> | undefined {
|
||||
const metadata = block.metadata
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const maybeWithSubBlocks = metadata as typeof metadata & {
|
||||
subBlocks?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const subBlocks = maybeWithSubBlocks.subBlocks
|
||||
if (subBlocks && typeof subBlocks === 'object' && !Array.isArray(subBlocks)) {
|
||||
return subBlocks
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export interface StartBlockOutputOptions {
|
||||
resolution: ExecutorStartResolution
|
||||
workflowInput: unknown
|
||||
}
|
||||
|
||||
export function buildStartBlockOutput(options: StartBlockOutputOptions): NormalizedBlockOutput {
|
||||
const { resolution, workflowInput } = options
|
||||
const inputFormat = extractInputFormat(resolution.block)
|
||||
const legacyStarterMode =
|
||||
resolution.path === StartBlockPath.LEGACY_STARTER
|
||||
? getSerializedLegacyStarterMode(resolution.block)
|
||||
: null
|
||||
|
||||
if (pathConsumesInputFormat(resolution.path, legacyStarterMode)) {
|
||||
assertNoReservedInputFormatFields(inputFormat, resolution.block)
|
||||
}
|
||||
|
||||
const { finalInput, structuredInput, hasStructured } = deriveInputFromFormat(
|
||||
inputFormat,
|
||||
workflowInput
|
||||
)
|
||||
|
||||
let output: NormalizedBlockOutput
|
||||
|
||||
switch (resolution.path) {
|
||||
case StartBlockPath.UNIFIED:
|
||||
output = buildUnifiedStartOutput(workflowInput, structuredInput, hasStructured)
|
||||
break
|
||||
|
||||
case StartBlockPath.SPLIT_API:
|
||||
case StartBlockPath.SPLIT_INPUT:
|
||||
output = buildApiOrInputOutput(finalInput, workflowInput)
|
||||
break
|
||||
|
||||
case StartBlockPath.SPLIT_CHAT:
|
||||
output = buildChatOutput(workflowInput)
|
||||
break
|
||||
|
||||
case StartBlockPath.SPLIT_MANUAL:
|
||||
output = buildManualTriggerOutput(finalInput, workflowInput)
|
||||
break
|
||||
|
||||
case StartBlockPath.EXTERNAL_TRIGGER:
|
||||
output = buildIntegrationTriggerOutput(workflowInput, structuredInput, hasStructured)
|
||||
break
|
||||
|
||||
case StartBlockPath.LEGACY_STARTER:
|
||||
output = buildLegacyStarterOutput(finalInput, workflowInput, legacyStarterMode)
|
||||
break
|
||||
|
||||
default:
|
||||
output = buildManualTriggerOutput(finalInput, workflowInput)
|
||||
}
|
||||
|
||||
assertNoReservedStartOutputFields(output, resolution.block)
|
||||
return output
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SubflowNodeIdCodec } from '@/executor/utils/subflow-node-id-codec'
|
||||
|
||||
describe('SubflowNodeIdCodec', () => {
|
||||
describe('branch subscripts', () => {
|
||||
it('builds and round-trips branch node IDs', () => {
|
||||
const id = SubflowNodeIdCodec.buildBranchNodeId('block-1', 2)
|
||||
expect(id).toBe('block-1₍2₎')
|
||||
expect(SubflowNodeIdCodec.isBranchNodeId(id)).toBe(true)
|
||||
expect(SubflowNodeIdCodec.extractBaseBlockId(id)).toBe('block-1')
|
||||
expect(SubflowNodeIdCodec.extractBranchIndex(id)).toBe(2)
|
||||
})
|
||||
|
||||
it('returns null index and false predicate for non-branch IDs', () => {
|
||||
expect(SubflowNodeIdCodec.isBranchNodeId('block-1')).toBe(false)
|
||||
expect(SubflowNodeIdCodec.extractBranchIndex('block-1')).toBeNull()
|
||||
expect(SubflowNodeIdCodec.extractBaseBlockId('block-1')).toBe('block-1')
|
||||
})
|
||||
|
||||
it('only strips a trailing branch subscript', () => {
|
||||
expect(SubflowNodeIdCodec.extractBaseBlockId('a₍1₎b')).toBe('a₍1₎b')
|
||||
expect(SubflowNodeIdCodec.extractBaseBlockId('a₍1₎b₍2₎')).toBe('a₍1₎b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loop sentinels', () => {
|
||||
it('builds and parses loop sentinel IDs', () => {
|
||||
const start = SubflowNodeIdCodec.buildLoopSentinelStartId('loop-1')
|
||||
const end = SubflowNodeIdCodec.buildLoopSentinelEndId('loop-1')
|
||||
expect(start).toBe('loop-loop-1-sentinel-start')
|
||||
expect(end).toBe('loop-loop-1-sentinel-end')
|
||||
expect(SubflowNodeIdCodec.isLoopSentinelNodeId(start)).toBe(true)
|
||||
expect(SubflowNodeIdCodec.isLoopSentinelNodeId(end)).toBe(true)
|
||||
expect(SubflowNodeIdCodec.extractLoopIdFromSentinel(start)).toBe('loop-1')
|
||||
expect(SubflowNodeIdCodec.extractLoopIdFromSentinel(end)).toBe('loop-1')
|
||||
})
|
||||
|
||||
it('returns null when not a loop sentinel', () => {
|
||||
expect(SubflowNodeIdCodec.isLoopSentinelNodeId('block-1')).toBe(false)
|
||||
expect(SubflowNodeIdCodec.extractLoopIdFromSentinel('block-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parallel sentinels', () => {
|
||||
it('builds and parses parallel sentinel IDs', () => {
|
||||
const start = SubflowNodeIdCodec.buildParallelSentinelStartId('p-1')
|
||||
const end = SubflowNodeIdCodec.buildParallelSentinelEndId('p-1')
|
||||
expect(start).toBe('parallel-p-1-sentinel-start')
|
||||
expect(end).toBe('parallel-p-1-sentinel-end')
|
||||
expect(SubflowNodeIdCodec.isParallelSentinelNodeId(start)).toBe(true)
|
||||
expect(SubflowNodeIdCodec.isParallelSentinelNodeId(end)).toBe(true)
|
||||
expect(SubflowNodeIdCodec.extractParallelIdFromSentinel(start)).toBe('p-1')
|
||||
expect(SubflowNodeIdCodec.extractParallelIdFromSentinel(end)).toBe('p-1')
|
||||
})
|
||||
|
||||
it('returns null when not a parallel sentinel', () => {
|
||||
expect(SubflowNodeIdCodec.isParallelSentinelNodeId('block-1')).toBe(false)
|
||||
expect(SubflowNodeIdCodec.extractParallelIdFromSentinel('block-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('outer-branch clone scoping', () => {
|
||||
it('builds and extracts outer branch index', () => {
|
||||
const id = SubflowNodeIdCodec.buildOuterBranchScopedId('loop-1', 3)
|
||||
expect(id).toBe('loop-1__obranch-3')
|
||||
expect(SubflowNodeIdCodec.extractOuterBranchIndex(id)).toBe(3)
|
||||
})
|
||||
|
||||
it('extracts the innermost outer branch index for nested clones', () => {
|
||||
const id = 'loop-1__obranch-2__obranch-5'
|
||||
expect(SubflowNodeIdCodec.extractOuterBranchIndex(id)).toBe(2)
|
||||
expect(SubflowNodeIdCodec.extractInnermostOuterBranchIndex(id)).toBe(5)
|
||||
})
|
||||
|
||||
it('returns undefined when no outer branch suffix is present', () => {
|
||||
expect(SubflowNodeIdCodec.extractOuterBranchIndex('loop-1')).toBeUndefined()
|
||||
expect(SubflowNodeIdCodec.extractInnermostOuterBranchIndex('loop-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('strips outer-branch and clone-digest suffixes', () => {
|
||||
expect(SubflowNodeIdCodec.stripOuterBranchSuffix('loop-1__obranch-2')).toBe('loop-1')
|
||||
expect(SubflowNodeIdCodec.stripOuterBranchSuffix('loop-1__cloneABCDEF__obranch-2')).toBe(
|
||||
'loop-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips all clone suffixes and branch subscripts to the base block ID', () => {
|
||||
expect(SubflowNodeIdCodec.stripCloneSuffixes('block-1__obranch-2₍3₎')).toBe('block-1')
|
||||
expect(SubflowNodeIdCodec.stripCloneSuffixes('block-1__clone0a1f__obranch-2₍0₎')).toBe(
|
||||
'block-1'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeNodeId', () => {
|
||||
it('normalizes branch, loop sentinel, and parallel sentinel IDs', () => {
|
||||
expect(SubflowNodeIdCodec.normalizeNodeId('block-1₍2₎')).toBe('block-1')
|
||||
expect(SubflowNodeIdCodec.normalizeNodeId('loop-loop-1-sentinel-start')).toBe('loop-1')
|
||||
expect(SubflowNodeIdCodec.normalizeNodeId('parallel-p-1-sentinel-end')).toBe('p-1')
|
||||
expect(SubflowNodeIdCodec.normalizeNodeId('block-1')).toBe('block-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loop digest lookup helpers', () => {
|
||||
it('strips branch subscripts and loop digests for lookup keys', () => {
|
||||
expect(SubflowNodeIdCodec.normalizeLookupId('block-1₍2₎_loop3')).toBe('block-1')
|
||||
expect(SubflowNodeIdCodec.normalizeLookupId('block-1')).toBe('block-1')
|
||||
})
|
||||
|
||||
it('extracts the leading branch suffix and loop digest segments', () => {
|
||||
expect(SubflowNodeIdCodec.extractBranchSuffix('block-1₍2₎_loop3')).toBe('₍2₎')
|
||||
expect(SubflowNodeIdCodec.extractBranchSuffix('block-1')).toBe('')
|
||||
expect(SubflowNodeIdCodec.extractLoopSuffix('block-1₍2₎_loop3')).toBe('_loop3')
|
||||
expect(SubflowNodeIdCodec.extractLoopSuffix('block-1')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findEffectiveContainerId', () => {
|
||||
it('returns the original ID for branch 0 / missing scope', () => {
|
||||
const map = new Map<string, unknown>([['loop-1', {}]])
|
||||
expect(SubflowNodeIdCodec.findEffectiveContainerId('loop-1', 'block-1', map)).toBe('loop-1')
|
||||
})
|
||||
|
||||
it('prefers the mapped cloned scope when present', () => {
|
||||
const map = new Map<string, unknown>([
|
||||
['loop-1', {}],
|
||||
['loop-1__obranch-2', {}],
|
||||
])
|
||||
expect(SubflowNodeIdCodec.findEffectiveContainerId('loop-1', 'block-1', map, 2)).toBe(
|
||||
'loop-1__obranch-2'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves the cloned scope from the current node ID suffix', () => {
|
||||
const map = new Map<string, unknown>([
|
||||
['loop-1', {}],
|
||||
['loop-1__obranch-3', {}],
|
||||
])
|
||||
expect(SubflowNodeIdCodec.findEffectiveContainerId('loop-1', 'block-1__obranch-3', map)).toBe(
|
||||
'loop-1__obranch-3'
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers __clone scopes when the current node carries a clone marker', () => {
|
||||
const map = new Map<string, unknown>([
|
||||
['loop-1__obranch-2', {}],
|
||||
['loop-1__cloneabc__obranch-2', {}],
|
||||
])
|
||||
expect(
|
||||
SubflowNodeIdCodec.findEffectiveContainerId('loop-1', 'block-1__cloneabc__obranch-2', map)
|
||||
).toBe('loop-1__cloneabc__obranch-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('round-trip parse ∘ build', () => {
|
||||
it('builds then parses branch IDs symmetrically', () => {
|
||||
for (const index of [0, 1, 7, 20]) {
|
||||
const id = SubflowNodeIdCodec.buildBranchNodeId('base-id', index)
|
||||
expect(SubflowNodeIdCodec.extractBranchIndex(id)).toBe(index)
|
||||
expect(SubflowNodeIdCodec.extractBaseBlockId(id)).toBe('base-id')
|
||||
}
|
||||
})
|
||||
|
||||
it('builds then parses outer-branch scoped IDs symmetrically', () => {
|
||||
for (const index of [1, 4, 19]) {
|
||||
const id = SubflowNodeIdCodec.buildOuterBranchScopedId('base-id', index)
|
||||
expect(SubflowNodeIdCodec.extractOuterBranchIndex(id)).toBe(index)
|
||||
expect(SubflowNodeIdCodec.stripOuterBranchSuffix(id)).toBe('base-id')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,263 @@
|
||||
import { LOOP, PARALLEL } from '@/executor/constants'
|
||||
|
||||
/**
|
||||
* Single source of truth for parsing and building subflow node IDs.
|
||||
*
|
||||
* Runtime node IDs layer several encodings onto a workflow-level block ID:
|
||||
* - Branch subscripts `₍N₎` mark a parallel branch instance.
|
||||
* - Loop/parallel sentinels wrap a container ID (`loop-{id}-sentinel-start`).
|
||||
* - Outer-branch clone suffixes `__obranch-N` and clone digests `__clone{hex}`
|
||||
* scope a cloned subflow to a global outer parallel branch.
|
||||
* - Loop digests `_loopN` scope a block output to a loop iteration.
|
||||
*
|
||||
* All regexes and string templates for these encodings live here so callers
|
||||
* never reconstruct them inline.
|
||||
*/
|
||||
|
||||
const SENTINEL = {
|
||||
LOOP_START: new RegExp(`${LOOP.SENTINEL.PREFIX}(.+)${LOOP.SENTINEL.START_SUFFIX}`),
|
||||
LOOP_END: new RegExp(`${LOOP.SENTINEL.PREFIX}(.+)${LOOP.SENTINEL.END_SUFFIX}`),
|
||||
PARALLEL_START: new RegExp(`${PARALLEL.SENTINEL.PREFIX}(.+)${PARALLEL.SENTINEL.START_SUFFIX}`),
|
||||
PARALLEL_END: new RegExp(`${PARALLEL.SENTINEL.PREFIX}(.+)${PARALLEL.SENTINEL.END_SUFFIX}`),
|
||||
} as const
|
||||
|
||||
const BRANCH = {
|
||||
MATCH: new RegExp(`${PARALLEL.BRANCH.PREFIX}\\d+${PARALLEL.BRANCH.SUFFIX}$`),
|
||||
INDEX: new RegExp(`${PARALLEL.BRANCH.PREFIX}(\\d+)${PARALLEL.BRANCH.SUFFIX}$`),
|
||||
SUFFIX: /₍\d+₎/u,
|
||||
SUFFIX_GLOBAL: /₍\d+₎/gu,
|
||||
} as const
|
||||
|
||||
const OUTER_BRANCH = {
|
||||
MATCH: /__obranch-(\d+)/,
|
||||
MATCH_GLOBAL: /__obranch-(\d+)/g,
|
||||
STRIP: /__obranch-\d+/g,
|
||||
} as const
|
||||
|
||||
const CLONE = {
|
||||
DIGEST_STRIP: /__clone[0-9a-f]+/gi,
|
||||
MARKER: '__clone',
|
||||
} as const
|
||||
|
||||
const LOOP_DIGEST = {
|
||||
MATCH: /_loop\d+/,
|
||||
STRIP: /_loop\d+/g,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Builds the loop sentinel-start node ID for a container.
|
||||
*/
|
||||
function buildLoopSentinelStartId(loopId: string): string {
|
||||
return `${LOOP.SENTINEL.PREFIX}${loopId}${LOOP.SENTINEL.START_SUFFIX}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the loop sentinel-end node ID for a container.
|
||||
*/
|
||||
function buildLoopSentinelEndId(loopId: string): string {
|
||||
return `${LOOP.SENTINEL.PREFIX}${loopId}${LOOP.SENTINEL.END_SUFFIX}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the parallel sentinel-start node ID for a container.
|
||||
*/
|
||||
function buildParallelSentinelStartId(parallelId: string): string {
|
||||
return `${PARALLEL.SENTINEL.PREFIX}${parallelId}${PARALLEL.SENTINEL.START_SUFFIX}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the parallel sentinel-end node ID for a container.
|
||||
*/
|
||||
function buildParallelSentinelEndId(parallelId: string): string {
|
||||
return `${PARALLEL.SENTINEL.PREFIX}${parallelId}${PARALLEL.SENTINEL.END_SUFFIX}`
|
||||
}
|
||||
|
||||
function isLoopSentinelNodeId(nodeId: string): boolean {
|
||||
return (
|
||||
nodeId.startsWith(LOOP.SENTINEL.PREFIX) &&
|
||||
(nodeId.endsWith(LOOP.SENTINEL.START_SUFFIX) || nodeId.endsWith(LOOP.SENTINEL.END_SUFFIX))
|
||||
)
|
||||
}
|
||||
|
||||
function isParallelSentinelNodeId(nodeId: string): boolean {
|
||||
return (
|
||||
nodeId.startsWith(PARALLEL.SENTINEL.PREFIX) &&
|
||||
(nodeId.endsWith(PARALLEL.SENTINEL.START_SUFFIX) ||
|
||||
nodeId.endsWith(PARALLEL.SENTINEL.END_SUFFIX))
|
||||
)
|
||||
}
|
||||
|
||||
function extractLoopIdFromSentinel(sentinelId: string): string | null {
|
||||
const startMatch = sentinelId.match(SENTINEL.LOOP_START)
|
||||
if (startMatch) return startMatch[1]
|
||||
const endMatch = sentinelId.match(SENTINEL.LOOP_END)
|
||||
if (endMatch) return endMatch[1]
|
||||
return null
|
||||
}
|
||||
|
||||
function extractParallelIdFromSentinel(sentinelId: string): string | null {
|
||||
const startMatch = sentinelId.match(SENTINEL.PARALLEL_START)
|
||||
if (startMatch) return startMatch[1]
|
||||
const endMatch = sentinelId.match(SENTINEL.PARALLEL_END)
|
||||
if (endMatch) return endMatch[1]
|
||||
return null
|
||||
}
|
||||
|
||||
function buildBranchNodeId(baseId: string, branchIndex: number): string {
|
||||
return `${baseId}${PARALLEL.BRANCH.PREFIX}${branchIndex}${PARALLEL.BRANCH.SUFFIX}`
|
||||
}
|
||||
|
||||
function extractBaseBlockId(branchNodeId: string): string {
|
||||
return branchNodeId.replace(BRANCH.MATCH, '')
|
||||
}
|
||||
|
||||
function extractBranchIndex(branchNodeId: string): number | null {
|
||||
const match = branchNodeId.match(BRANCH.INDEX)
|
||||
return match ? Number.parseInt(match[1], 10) : null
|
||||
}
|
||||
|
||||
function isBranchNodeId(nodeId: string): boolean {
|
||||
return BRANCH.MATCH.test(nodeId)
|
||||
}
|
||||
|
||||
function extractOuterBranchIndex(clonedId: string): number | undefined {
|
||||
const match = clonedId.match(OUTER_BRANCH.MATCH)
|
||||
return match ? Number.parseInt(match[1], 10) : undefined
|
||||
}
|
||||
|
||||
function extractInnermostOuterBranchIndex(clonedId: string): number | undefined {
|
||||
const matches = Array.from(clonedId.matchAll(OUTER_BRANCH.MATCH_GLOBAL))
|
||||
const lastMatch = matches.at(-1)
|
||||
return lastMatch ? Number.parseInt(lastMatch[1], 10) : undefined
|
||||
}
|
||||
|
||||
function stripCloneSuffixes(nodeId: string): string {
|
||||
return extractBaseBlockId(nodeId.replace(OUTER_BRANCH.STRIP, '').replace(CLONE.DIGEST_STRIP, ''))
|
||||
}
|
||||
|
||||
function buildOuterBranchScopedId(originalId: string, branchIndex: number): string {
|
||||
return `${originalId}__obranch-${branchIndex}`
|
||||
}
|
||||
|
||||
function stripOuterBranchSuffix(id: string): string {
|
||||
return id.replace(OUTER_BRANCH.STRIP, '').replace(CLONE.DIGEST_STRIP, '')
|
||||
}
|
||||
|
||||
function hasCloneMarker(id: string): boolean {
|
||||
return id.includes(CLONE.MARKER)
|
||||
}
|
||||
|
||||
function normalizeNodeId(nodeId: string): string {
|
||||
if (isBranchNodeId(nodeId)) {
|
||||
return extractBaseBlockId(nodeId)
|
||||
}
|
||||
if (isLoopSentinelNodeId(nodeId)) {
|
||||
return extractLoopIdFromSentinel(nodeId) || nodeId
|
||||
}
|
||||
if (isParallelSentinelNodeId(nodeId)) {
|
||||
return extractParallelIdFromSentinel(nodeId) || nodeId
|
||||
}
|
||||
return nodeId
|
||||
}
|
||||
|
||||
function findEffectiveContainerId(
|
||||
originalId: string,
|
||||
currentNodeId: string,
|
||||
executionMap: Map<string, unknown>,
|
||||
mappedBranchIndex?: number
|
||||
): string {
|
||||
if (mappedBranchIndex !== undefined && mappedBranchIndex > 0) {
|
||||
const cloneSuffix = `__obranch-${mappedBranchIndex}`
|
||||
const candidateId = buildOuterBranchScopedId(originalId, mappedBranchIndex)
|
||||
if (executionMap.has(candidateId)) {
|
||||
return candidateId
|
||||
}
|
||||
|
||||
for (const scopeId of executionMap.keys()) {
|
||||
if (scopeId.endsWith(cloneSuffix) && stripOuterBranchSuffix(scopeId) === originalId) {
|
||||
return scopeId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const match = currentNodeId.match(OUTER_BRANCH.MATCH)
|
||||
if (match) {
|
||||
const branchIndex = Number.parseInt(match[1], 10)
|
||||
const cloneSuffix = `__obranch-${branchIndex}`
|
||||
if (hasCloneMarker(currentNodeId)) {
|
||||
for (const scopeId of executionMap.keys()) {
|
||||
if (
|
||||
hasCloneMarker(scopeId) &&
|
||||
scopeId.endsWith(cloneSuffix) &&
|
||||
stripOuterBranchSuffix(scopeId) === originalId
|
||||
) {
|
||||
return scopeId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const candidateId = buildOuterBranchScopedId(originalId, branchIndex)
|
||||
if (executionMap.has(candidateId)) {
|
||||
return candidateId
|
||||
}
|
||||
|
||||
for (const scopeId of executionMap.keys()) {
|
||||
if (scopeId.endsWith(cloneSuffix) && stripOuterBranchSuffix(scopeId) === originalId) {
|
||||
return scopeId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return originalId
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips branch subscripts (`₍N₎`) and loop digests (`_loopN`) from a node ID,
|
||||
* yielding the lookup key used by execution-state block-output resolution.
|
||||
*/
|
||||
function normalizeLookupId(id: string): string {
|
||||
return id.replace(BRANCH.SUFFIX_GLOBAL, '').replace(LOOP_DIGEST.STRIP, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the leading branch subscript (`₍N₎`) of a node ID, or '' when absent.
|
||||
*/
|
||||
function extractBranchSuffix(id: string): string {
|
||||
return id.match(BRANCH.SUFFIX)?.[0] ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the loop digest segment (`_loopN`) of a node ID, or '' when absent.
|
||||
*/
|
||||
function extractLoopSuffix(id: string): string {
|
||||
return id.match(LOOP_DIGEST.MATCH)?.[0] ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Codec exposing all subflow node-ID parsing/building operations as a single,
|
||||
* pattern-free interface. Implementation owns every regex and string template.
|
||||
*/
|
||||
export const SubflowNodeIdCodec = {
|
||||
buildLoopSentinelStartId,
|
||||
buildLoopSentinelEndId,
|
||||
buildParallelSentinelStartId,
|
||||
buildParallelSentinelEndId,
|
||||
isLoopSentinelNodeId,
|
||||
isParallelSentinelNodeId,
|
||||
extractLoopIdFromSentinel,
|
||||
extractParallelIdFromSentinel,
|
||||
buildBranchNodeId,
|
||||
extractBaseBlockId,
|
||||
extractBranchIndex,
|
||||
isBranchNodeId,
|
||||
extractOuterBranchIndex,
|
||||
extractInnermostOuterBranchIndex,
|
||||
stripCloneSuffixes,
|
||||
buildOuterBranchScopedId,
|
||||
stripOuterBranchSuffix,
|
||||
normalizeNodeId,
|
||||
findEffectiveContainerId,
|
||||
normalizeLookupId,
|
||||
extractBranchSuffix,
|
||||
extractLoopSuffix,
|
||||
} as const
|
||||
@@ -0,0 +1,127 @@
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys'
|
||||
import {
|
||||
isLargeArrayManifest,
|
||||
LARGE_ARRAY_MANIFEST_MARKER,
|
||||
materializeLargeArrayManifest,
|
||||
} from '@/lib/execution/payloads/large-array-manifest'
|
||||
import { isLargeValueRef, LARGE_VALUE_REF_MARKER } from '@/lib/execution/payloads/large-value-ref'
|
||||
import { MAX_DURABLE_LARGE_VALUE_BYTES } from '@/lib/execution/payloads/materialization.server'
|
||||
import { materializeLargeValueRef } from '@/lib/execution/payloads/store'
|
||||
import { REFERENCE } from '@/executor/constants'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { VariableResolver } from '@/executor/variables/resolver'
|
||||
|
||||
async function normalizeCollectionValue(ctx: ExecutionContext, value: unknown): Promise<any[]> {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (isLargeArrayManifest(value)) {
|
||||
const materialized = await materializeLargeArrayManifest(value, {
|
||||
workspaceId: ctx.workspaceId,
|
||||
workflowId: ctx.workflowId,
|
||||
executionId: ctx.executionId,
|
||||
largeValueExecutionIds: ctx.largeValueExecutionIds,
|
||||
largeValueKeys: ctx.largeValueKeys,
|
||||
fileKeys: ctx.fileKeys,
|
||||
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
|
||||
userId: ctx.userId,
|
||||
maxBytes: MAX_DURABLE_LARGE_VALUE_BYTES,
|
||||
})
|
||||
recordMaterializedAccessKeys(ctx, materialized)
|
||||
return materialized
|
||||
}
|
||||
|
||||
if (isLargeValueRef(value)) {
|
||||
const materialized = await materializeLargeValueRef(value, {
|
||||
workspaceId: ctx.workspaceId,
|
||||
workflowId: ctx.workflowId,
|
||||
executionId: ctx.executionId,
|
||||
largeValueExecutionIds: ctx.largeValueExecutionIds,
|
||||
largeValueKeys: ctx.largeValueKeys,
|
||||
fileKeys: ctx.fileKeys,
|
||||
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
|
||||
userId: ctx.userId,
|
||||
maxBytes: MAX_DURABLE_LARGE_VALUE_BYTES,
|
||||
})
|
||||
if (materialized === undefined) {
|
||||
throw new Error('Large execution value is unavailable.')
|
||||
}
|
||||
recordMaterializedAccessKeys(ctx, materialized)
|
||||
return normalizeCollectionValue(ctx, materialized)
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if ((value as Record<string, unknown>)[LARGE_ARRAY_MANIFEST_MARKER] === true) {
|
||||
throw new Error('Invalid large array manifest.')
|
||||
}
|
||||
if ((value as Record<string, unknown>)[LARGE_VALUE_REF_MARKER] === true) {
|
||||
throw new Error('Invalid large value ref.')
|
||||
}
|
||||
return Object.entries(value)
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return []
|
||||
}
|
||||
|
||||
throw new Error('Value did not resolve to an array or object')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves loop/parallel collection inputs on the server, including durable
|
||||
* execution values that cannot be imported into client-reachable utilities.
|
||||
*/
|
||||
export async function resolveArrayInputAsync(
|
||||
ctx: ExecutionContext,
|
||||
items: any,
|
||||
resolver: VariableResolver | null,
|
||||
currentNodeId = ''
|
||||
): Promise<any[]> {
|
||||
if (typeof items !== 'string') {
|
||||
if (items === null) {
|
||||
return []
|
||||
}
|
||||
if (!Array.isArray(items) && typeof items !== 'object') {
|
||||
if (!resolver) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const resolved = (await resolver.resolveInputs(ctx, currentNodeId, { items })).items
|
||||
return normalizeCollectionValue(ctx, resolved)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Resolved items')) {
|
||||
throw error
|
||||
}
|
||||
throw new Error(`Failed to resolve items: ${toError(error).message}`)
|
||||
}
|
||||
}
|
||||
return normalizeCollectionValue(ctx, items)
|
||||
}
|
||||
|
||||
if (items.startsWith(REFERENCE.START) && items.endsWith(REFERENCE.END) && resolver) {
|
||||
try {
|
||||
const resolved = await resolver.resolveSingleReference(ctx, currentNodeId, items, undefined, {
|
||||
allowLargeValueRefs: true,
|
||||
})
|
||||
return normalizeCollectionValue(ctx, resolved)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Reference "')) {
|
||||
throw error
|
||||
}
|
||||
throw new Error(`Failed to resolve reference "${items}": ${toError(error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const normalized = items.replace(/'/g, '"')
|
||||
const parsed = JSON.parse(normalized)
|
||||
return normalizeCollectionValue(ctx, parsed)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Parsed value')) {
|
||||
throw error
|
||||
}
|
||||
throw new Error(`Failed to parse items as JSON: "${items}"`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cacheLargeValue, clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
|
||||
import {
|
||||
LARGE_ARRAY_MANIFEST_MARKER,
|
||||
LARGE_ARRAY_MANIFEST_VERSION,
|
||||
} from '@/lib/execution/payloads/large-array-manifest-metadata'
|
||||
import { LARGE_VALUE_REF_MARKER } from '@/lib/execution/payloads/large-value-ref'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import { findEffectiveContainerId } from '@/executor/utils/subflow-utils'
|
||||
import { resolveArrayInputAsync } from '@/executor/utils/subflow-utils.server'
|
||||
import type { VariableResolver } from '@/executor/variables/resolver'
|
||||
|
||||
describe('resolveArrayInputAsync', () => {
|
||||
const fakeCtx = {
|
||||
workspaceId: 'workspace-1',
|
||||
workflowId: 'workflow-1',
|
||||
executionId: 'execution-1',
|
||||
userId: 'user-1',
|
||||
} as unknown as ExecutionContext
|
||||
|
||||
beforeEach(() => {
|
||||
clearLargeValueCacheForTests()
|
||||
})
|
||||
|
||||
function createManifest(items: unknown[]) {
|
||||
const json = JSON.stringify(items)
|
||||
const size = Buffer.byteLength(json, 'utf8')
|
||||
const id = 'lv_ABCDEFGHIJKL'
|
||||
cacheLargeValue(id, items, size, fakeCtx)
|
||||
|
||||
return {
|
||||
__simLargeArrayManifest: true,
|
||||
version: LARGE_ARRAY_MANIFEST_VERSION,
|
||||
kind: 'array',
|
||||
totalCount: items.length,
|
||||
chunkCount: 1,
|
||||
byteSize: size,
|
||||
chunks: [
|
||||
{
|
||||
ref: {
|
||||
__simLargeValueRef: true,
|
||||
version: 1,
|
||||
id,
|
||||
kind: 'array',
|
||||
size,
|
||||
executionId: fakeCtx.executionId,
|
||||
},
|
||||
count: items.length,
|
||||
byteSize: size,
|
||||
},
|
||||
],
|
||||
preview: items.slice(0, 3),
|
||||
}
|
||||
}
|
||||
|
||||
it('returns arrays as-is', async () => {
|
||||
await expect(resolveArrayInputAsync(fakeCtx, [1, 2, 3], null)).resolves.toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('converts plain objects to entries', async () => {
|
||||
await expect(resolveArrayInputAsync(fakeCtx, { a: 1, b: 2 }, null)).resolves.toEqual([
|
||||
['a', 1],
|
||||
['b', 2],
|
||||
])
|
||||
})
|
||||
|
||||
it('materializes large array manifests instead of iterating metadata entries', async () => {
|
||||
const items = [{ id: 1 }, { id: 2 }]
|
||||
const manifest = createManifest(items)
|
||||
|
||||
await expect(resolveArrayInputAsync(fakeCtx, manifest, null)).resolves.toEqual(items)
|
||||
})
|
||||
|
||||
it('records exact nested keys discovered while materializing collection values', async () => {
|
||||
const ctx = {
|
||||
...fakeCtx,
|
||||
largeValueKeys: [] as string[],
|
||||
fileKeys: [] as string[],
|
||||
} as ExecutionContext
|
||||
const nestedRef = {
|
||||
__simLargeValueRef: true,
|
||||
version: 1,
|
||||
id: 'lv_MNOPQRSTUVWX',
|
||||
kind: 'object',
|
||||
size: 12,
|
||||
key: 'execution/workspace-1/workflow-1/source-execution/large-value-lv_MNOPQRSTUVWX.json',
|
||||
executionId: 'source-execution',
|
||||
}
|
||||
const file = {
|
||||
id: 'file-1',
|
||||
name: 'nested.txt',
|
||||
key: 'execution/workspace-1/workflow-1/source-execution/nested.txt',
|
||||
url: '/api/files/serve/execution/workspace-1/workflow-1/source-execution/nested.txt?context=execution',
|
||||
size: 5,
|
||||
type: 'text/plain',
|
||||
context: 'execution',
|
||||
}
|
||||
const items = [{ nestedRef, file }]
|
||||
const manifest = createManifest(items)
|
||||
|
||||
await expect(resolveArrayInputAsync(ctx, manifest, null)).resolves.toEqual(items)
|
||||
|
||||
expect(ctx.largeValueKeys).toEqual([nestedRef.key])
|
||||
expect(ctx.fileKeys).toEqual([file.key])
|
||||
})
|
||||
|
||||
it('rejects invalid manifest-shaped collection inputs instead of iterating metadata', async () => {
|
||||
await expect(
|
||||
resolveArrayInputAsync(
|
||||
fakeCtx,
|
||||
{
|
||||
[LARGE_ARRAY_MANIFEST_MARKER]: true,
|
||||
version: LARGE_ARRAY_MANIFEST_VERSION,
|
||||
kind: 'array',
|
||||
totalCount: 1,
|
||||
chunkCount: 0,
|
||||
byteSize: 0,
|
||||
chunks: [],
|
||||
preview: [],
|
||||
},
|
||||
null
|
||||
)
|
||||
).rejects.toThrow('Invalid large array manifest')
|
||||
})
|
||||
|
||||
it('rejects invalid large-ref-shaped collection inputs instead of iterating metadata', async () => {
|
||||
await expect(
|
||||
resolveArrayInputAsync(
|
||||
fakeCtx,
|
||||
{
|
||||
[LARGE_VALUE_REF_MARKER]: true,
|
||||
version: 1,
|
||||
id: 'not-a-valid-large-value-id',
|
||||
kind: 'array',
|
||||
size: 1,
|
||||
},
|
||||
null
|
||||
)
|
||||
).rejects.toThrow('Invalid large value ref')
|
||||
})
|
||||
|
||||
it('returns empty array when a pure reference resolves to null (skipped block)', async () => {
|
||||
// `resolveSingleReference` returns `null` for a reference that points at a
|
||||
// block that exists in the workflow but did not execute on this path.
|
||||
// A loop/parallel over such a reference should run zero iterations rather
|
||||
// than fail the workflow.
|
||||
const resolver = {
|
||||
resolveSingleReference: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as VariableResolver
|
||||
|
||||
const result = await resolveArrayInputAsync(fakeCtx, '<SkippedBlock.result.items>', resolver)
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(resolver.resolveSingleReference).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the array from a pure reference that resolved to an array', async () => {
|
||||
const resolver = {
|
||||
resolveSingleReference: vi.fn().mockResolvedValue([1, 2, 3]),
|
||||
} as unknown as VariableResolver
|
||||
|
||||
await expect(resolveArrayInputAsync(fakeCtx, '<Block.items>', resolver)).resolves.toEqual([
|
||||
1, 2, 3,
|
||||
])
|
||||
})
|
||||
|
||||
it('materializes a manifest returned by a pure reference', async () => {
|
||||
const items = [{ id: 1 }, { id: 2 }]
|
||||
const manifest = createManifest(items)
|
||||
const resolver = {
|
||||
resolveSingleReference: vi.fn().mockResolvedValue(manifest),
|
||||
} as unknown as VariableResolver
|
||||
|
||||
await expect(resolveArrayInputAsync(fakeCtx, '<variable.issues>', resolver)).resolves.toEqual(
|
||||
items
|
||||
)
|
||||
expect(resolver.resolveSingleReference).toHaveBeenCalledWith(
|
||||
fakeCtx,
|
||||
'',
|
||||
'<variable.issues>',
|
||||
undefined,
|
||||
{ allowLargeValueRefs: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('converts resolved objects to entries', async () => {
|
||||
const resolver = {
|
||||
resolveSingleReference: vi.fn().mockResolvedValue({ x: 1, y: 2 }),
|
||||
} as unknown as VariableResolver
|
||||
|
||||
await expect(resolveArrayInputAsync(fakeCtx, '<Block.obj>', resolver)).resolves.toEqual([
|
||||
['x', 1],
|
||||
['y', 2],
|
||||
])
|
||||
})
|
||||
|
||||
it('throws when a pure reference resolves to a non-array, non-object, non-null value', async () => {
|
||||
const resolver = {
|
||||
resolveSingleReference: vi.fn().mockResolvedValue(42),
|
||||
} as unknown as VariableResolver
|
||||
|
||||
await expect(resolveArrayInputAsync(fakeCtx, '<Block.count>', resolver)).rejects.toThrow(
|
||||
/did not resolve to an array or object/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when a pure reference resolves to undefined (unknown block)', async () => {
|
||||
// `undefined` means the reference could not be matched to any block at
|
||||
// all (typo / deleted block). This must still fail loudly.
|
||||
const resolver = {
|
||||
resolveSingleReference: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as VariableResolver
|
||||
|
||||
await expect(resolveArrayInputAsync(fakeCtx, '<Missing.items>', resolver)).rejects.toThrow(
|
||||
/did not resolve to an array or object/
|
||||
)
|
||||
})
|
||||
|
||||
it('parses a JSON array string', async () => {
|
||||
await expect(resolveArrayInputAsync(fakeCtx, '[1, 2, 3]', null)).resolves.toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('throws on a string that is neither a reference nor valid JSON array/object', async () => {
|
||||
await expect(resolveArrayInputAsync(fakeCtx, 'not json', null)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findEffectiveContainerId', () => {
|
||||
it('finds pre-cloned nested subflow IDs with clone sequence suffixes', () => {
|
||||
const executionMap = new Map<string, unknown>([
|
||||
['inner-parallel', {}],
|
||||
['inner-parallel__obranch-2', {}],
|
||||
['inner-parallel__clone3__obranch-2', {}],
|
||||
])
|
||||
|
||||
expect(
|
||||
findEffectiveContainerId('inner-parallel', 'leaf__clone7__obranch-2₍0₎', executionMap)
|
||||
).toBe('inner-parallel__clone3__obranch-2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,336 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { DEFAULTS } from '@/executor/constants'
|
||||
import type { ContextExtensions } from '@/executor/execution/types'
|
||||
import { type BlockLog, type ExecutionContext, getNextExecutionOrder } from '@/executor/types'
|
||||
import { buildContainerIterationContext } from '@/executor/utils/iteration-context'
|
||||
import { SubflowNodeIdCodec } from '@/executor/utils/subflow-node-id-codec'
|
||||
import type { SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('SubflowUtils')
|
||||
|
||||
/**
|
||||
* Builds the loop sentinel-start node ID for a container.
|
||||
*/
|
||||
export function buildSentinelStartId(loopId: string): string {
|
||||
return SubflowNodeIdCodec.buildLoopSentinelStartId(loopId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the loop sentinel-end node ID for a container.
|
||||
*/
|
||||
export function buildSentinelEndId(loopId: string): string {
|
||||
return SubflowNodeIdCodec.buildLoopSentinelEndId(loopId)
|
||||
}
|
||||
|
||||
export function buildParallelSentinelStartId(parallelId: string): string {
|
||||
return SubflowNodeIdCodec.buildParallelSentinelStartId(parallelId)
|
||||
}
|
||||
|
||||
export function buildParallelSentinelEndId(parallelId: string): string {
|
||||
return SubflowNodeIdCodec.buildParallelSentinelEndId(parallelId)
|
||||
}
|
||||
|
||||
export function isLoopSentinelNodeId(nodeId: string): boolean {
|
||||
return SubflowNodeIdCodec.isLoopSentinelNodeId(nodeId)
|
||||
}
|
||||
|
||||
export function isParallelSentinelNodeId(nodeId: string): boolean {
|
||||
return SubflowNodeIdCodec.isParallelSentinelNodeId(nodeId)
|
||||
}
|
||||
|
||||
export function isSentinelNodeId(nodeId: string): boolean {
|
||||
return isLoopSentinelNodeId(nodeId) || isParallelSentinelNodeId(nodeId)
|
||||
}
|
||||
|
||||
export function extractLoopIdFromSentinel(sentinelId: string): string | null {
|
||||
return SubflowNodeIdCodec.extractLoopIdFromSentinel(sentinelId)
|
||||
}
|
||||
|
||||
export function extractParallelIdFromSentinel(sentinelId: string): string | null {
|
||||
return SubflowNodeIdCodec.extractParallelIdFromSentinel(sentinelId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build branch node ID with subscript notation
|
||||
* Example: ("blockId", 2) → "blockId₍2₎"
|
||||
*/
|
||||
export function buildBranchNodeId(baseId: string, branchIndex: number): string {
|
||||
return SubflowNodeIdCodec.buildBranchNodeId(baseId, branchIndex)
|
||||
}
|
||||
|
||||
export function extractBaseBlockId(branchNodeId: string): string {
|
||||
return SubflowNodeIdCodec.extractBaseBlockId(branchNodeId)
|
||||
}
|
||||
|
||||
export function extractBranchIndex(branchNodeId: string): number | null {
|
||||
return SubflowNodeIdCodec.extractBranchIndex(branchNodeId)
|
||||
}
|
||||
|
||||
export function isBranchNodeId(nodeId: string): boolean {
|
||||
return SubflowNodeIdCodec.isBranchNodeId(nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the outer branch index from a cloned subflow ID.
|
||||
* Cloned IDs follow the pattern `{originalId}__obranch-{index}`.
|
||||
* Returns undefined if the ID is not a clone.
|
||||
*/
|
||||
export function extractOuterBranchIndex(clonedId: string): number | undefined {
|
||||
return SubflowNodeIdCodec.extractOuterBranchIndex(clonedId)
|
||||
}
|
||||
|
||||
export function extractInnermostOuterBranchIndex(clonedId: string): number | undefined {
|
||||
return SubflowNodeIdCodec.extractInnermostOuterBranchIndex(clonedId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips all clone suffixes (`__obranch-N`) and branch subscripts (`₍N₎`)
|
||||
* from a node ID, returning the original workflow-level block ID.
|
||||
*/
|
||||
export function stripCloneSuffixes(nodeId: string): string {
|
||||
return SubflowNodeIdCodec.stripCloneSuffixes(nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a stable ID for an output scoped to a global outer parallel branch.
|
||||
*/
|
||||
export function buildOuterBranchScopedId(originalId: string, branchIndex: number): string {
|
||||
return SubflowNodeIdCodec.buildOuterBranchScopedId(originalId, branchIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a cloned subflow ID from an original ID and outer branch index.
|
||||
*/
|
||||
export function buildClonedSubflowId(originalId: string, branchIndex: number): string {
|
||||
return SubflowNodeIdCodec.buildOuterBranchScopedId(originalId, branchIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips outer-branch clone suffixes (`__obranch-N`) from an ID,
|
||||
* returning the original workflow-level subflow ID.
|
||||
*/
|
||||
export function stripOuterBranchSuffix(id: string): string {
|
||||
return SubflowNodeIdCodec.stripOuterBranchSuffix(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the effective (possibly cloned) container ID for a subflow,
|
||||
* given the current node's ID and an execution map (loopExecutions or parallelExecutions).
|
||||
*
|
||||
* When inside a cloned subflow (e.g., loop-1__obranch-2), the execution scope is
|
||||
* stored under the cloned ID, not the original. This function extracts the `__obranch-N`
|
||||
* suffix from the current node ID, constructs the candidate cloned container ID, and
|
||||
* checks if it exists in the execution map.
|
||||
*
|
||||
* Returns the effective ID (cloned or original) that exists in the map.
|
||||
*/
|
||||
export function findEffectiveContainerId(
|
||||
originalId: string,
|
||||
currentNodeId: string,
|
||||
executionMap: Map<string, unknown>,
|
||||
mappedBranchIndex?: number
|
||||
): string {
|
||||
return SubflowNodeIdCodec.findEffectiveContainerId(
|
||||
originalId,
|
||||
currentNodeId,
|
||||
executionMap,
|
||||
mappedBranchIndex
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeNodeId(nodeId: string): string {
|
||||
return SubflowNodeIdCodec.normalizeNodeId(nodeId)
|
||||
}
|
||||
|
||||
type SubflowContainerType = 'loop' | 'parallel'
|
||||
|
||||
function getSubflowNodes(
|
||||
workflow: Pick<SerializedWorkflow, 'loops' | 'parallels'>,
|
||||
type: SubflowContainerType,
|
||||
id: string
|
||||
): string[] | undefined {
|
||||
return type === 'loop' ? workflow.loops?.[id]?.nodes : workflow.parallels?.[id]?.nodes
|
||||
}
|
||||
|
||||
export function subflowContainsBlock(
|
||||
workflow: Pick<SerializedWorkflow, 'loops' | 'parallels'>,
|
||||
containerType: SubflowContainerType,
|
||||
containerId: string,
|
||||
baseBlockId: string,
|
||||
visited = new Set<string>()
|
||||
): boolean {
|
||||
const visitKey = `${containerType}:${containerId}`
|
||||
if (visited.has(visitKey)) return false
|
||||
visited.add(visitKey)
|
||||
|
||||
const nodes = getSubflowNodes(workflow, containerType, containerId)
|
||||
if (!nodes) return false
|
||||
|
||||
for (const nodeId of nodes) {
|
||||
if (nodeId === baseBlockId) return true
|
||||
if (workflow.loops?.[nodeId]) {
|
||||
if (subflowContainsBlock(workflow, 'loop', nodeId, baseBlockId, visited)) return true
|
||||
} else if (workflow.parallels?.[nodeId]) {
|
||||
if (subflowContainsBlock(workflow, 'parallel', nodeId, baseBlockId, visited)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function isSubflowNestedInside(
|
||||
workflow: Pick<SerializedWorkflow, 'loops' | 'parallels'>,
|
||||
childType: SubflowContainerType,
|
||||
childId: string,
|
||||
ancestorType: SubflowContainerType,
|
||||
ancestorId: string,
|
||||
visited = new Set<string>()
|
||||
): boolean {
|
||||
const visitKey = `${ancestorType}:${ancestorId}`
|
||||
if (visited.has(visitKey)) return false
|
||||
visited.add(visitKey)
|
||||
|
||||
const nodes = getSubflowNodes(workflow, ancestorType, ancestorId)
|
||||
if (!nodes) return false
|
||||
|
||||
for (const nodeId of nodes) {
|
||||
if (
|
||||
nodeId === childId &&
|
||||
(childType === 'loop' ? workflow.loops?.[childId] : workflow.parallels?.[childId])
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (workflow.loops?.[nodeId]) {
|
||||
if (isSubflowNestedInside(workflow, childType, childId, 'loop', nodeId, visited)) {
|
||||
return true
|
||||
}
|
||||
} else if (workflow.parallels?.[nodeId]) {
|
||||
if (isSubflowNestedInside(workflow, childType, childId, 'parallel', nodeId, visited)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and logs an error for a subflow (loop or parallel).
|
||||
*/
|
||||
export async function addSubflowErrorLog(
|
||||
ctx: ExecutionContext,
|
||||
blockId: string,
|
||||
blockType: 'loop' | 'parallel',
|
||||
errorMessage: string,
|
||||
inputData: Record<string, any>,
|
||||
contextExtensions: ContextExtensions | null
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
const execOrder = getNextExecutionOrder(ctx)
|
||||
|
||||
const block = ctx.workflow?.blocks?.find((b) => b.id === blockId)
|
||||
const blockName = block?.metadata?.name || (blockType === 'loop' ? 'Loop' : 'Parallel')
|
||||
|
||||
const blockLog: BlockLog = {
|
||||
blockId,
|
||||
blockName,
|
||||
blockType,
|
||||
startedAt: now,
|
||||
executionOrder: execOrder,
|
||||
endedAt: now,
|
||||
durationMs: 0,
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
input: inputData,
|
||||
output: { error: errorMessage },
|
||||
...(blockType === 'loop' ? { loopId: blockId } : { parallelId: blockId }),
|
||||
}
|
||||
ctx.blockLogs.push(blockLog)
|
||||
|
||||
if (contextExtensions?.onBlockStart) {
|
||||
try {
|
||||
await contextExtensions.onBlockStart(blockId, blockName, blockType, execOrder)
|
||||
} catch (error) {
|
||||
logger.warn('Subflow error start callback failed', {
|
||||
blockId,
|
||||
blockType,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (contextExtensions?.onBlockComplete) {
|
||||
try {
|
||||
await contextExtensions.onBlockComplete(blockId, blockName, blockType, {
|
||||
input: inputData,
|
||||
output: { error: errorMessage },
|
||||
executionTime: 0,
|
||||
startedAt: now,
|
||||
executionOrder: execOrder,
|
||||
endedAt: now,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn('Subflow error completion callback failed', {
|
||||
blockId,
|
||||
blockType,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the BlockLog + onBlockComplete callback for a loop/parallel container that
|
||||
* finished successfully. Without this, successful container runs produce no top-level BlockLog,
|
||||
* which forces the trace-span builder to fall back
|
||||
* to generic counter-based names ("Loop 1", "Parallel 1") instead of the user-configured
|
||||
* block name.
|
||||
*/
|
||||
export async function emitSubflowSuccessEvents(
|
||||
ctx: ExecutionContext,
|
||||
blockId: string,
|
||||
blockType: 'loop' | 'parallel',
|
||||
output: { results: unknown },
|
||||
contextExtensions: ContextExtensions | null
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
const executionOrder = getNextExecutionOrder(ctx)
|
||||
const block = ctx.workflow?.blocks.find((b) => b.id === blockId)
|
||||
const blockName = block?.metadata?.name ?? blockType
|
||||
const iterationContext = buildContainerIterationContext(ctx, blockId)
|
||||
|
||||
ctx.blockLogs.push({
|
||||
blockId,
|
||||
blockName,
|
||||
blockType,
|
||||
startedAt: now,
|
||||
endedAt: now,
|
||||
durationMs: DEFAULTS.EXECUTION_TIME,
|
||||
success: true,
|
||||
output,
|
||||
executionOrder,
|
||||
})
|
||||
|
||||
if (contextExtensions?.onBlockComplete) {
|
||||
try {
|
||||
await contextExtensions.onBlockComplete(
|
||||
blockId,
|
||||
blockName,
|
||||
blockType,
|
||||
{
|
||||
output,
|
||||
executionTime: DEFAULTS.EXECUTION_TIME,
|
||||
startedAt: now,
|
||||
executionOrder,
|
||||
endedAt: now,
|
||||
},
|
||||
iterationContext
|
||||
)
|
||||
} catch (error) {
|
||||
logger.warn('Subflow success completion callback failed', {
|
||||
blockId,
|
||||
blockType,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getCredentialActorContext } from '@/lib/credentials/access'
|
||||
import { getServiceAccountToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('VertexCredential')
|
||||
|
||||
/**
|
||||
* Resolves a Vertex AI OAuth credential to an access token.
|
||||
* Shared across agent, evaluator, and router handlers. Authorizes the executing
|
||||
* user against the credential first — workspace credentials are usable by their
|
||||
* members and by derived workspace admins, matching `authorizeCredentialUse`.
|
||||
*/
|
||||
export async function resolveVertexCredential(
|
||||
credentialId: string,
|
||||
actingUserId: string | undefined,
|
||||
callerLabel = 'vertex'
|
||||
): Promise<string> {
|
||||
const requestId = `${callerLabel}-${Date.now()}`
|
||||
|
||||
logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`)
|
||||
|
||||
if (!actingUserId) {
|
||||
throw new Error('Vertex AI credential use requires an authenticated user')
|
||||
}
|
||||
|
||||
const access = await getCredentialActorContext(credentialId, actingUserId)
|
||||
const cred = access.credential
|
||||
if (!cred) {
|
||||
throw new Error(`Vertex AI credential not found: ${credentialId}`)
|
||||
}
|
||||
if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) {
|
||||
throw new Error('Not authorized to use this Vertex AI credential')
|
||||
}
|
||||
|
||||
if (cred.type === 'service_account') {
|
||||
const accessToken = await getServiceAccountToken(cred.id, [
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
])
|
||||
logger.info(`[${requestId}] Successfully resolved Vertex AI service account credential`)
|
||||
return accessToken
|
||||
}
|
||||
|
||||
if (cred.type !== 'oauth' || !cred.accountId) {
|
||||
throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`)
|
||||
}
|
||||
|
||||
const accountRow = await db.query.account.findFirst({
|
||||
where: eq(account.id, cred.accountId),
|
||||
})
|
||||
|
||||
if (!accountRow) {
|
||||
throw new Error(`Vertex AI credential not found: ${credentialId}`)
|
||||
}
|
||||
|
||||
const { accessToken } = await refreshTokenIfNeeded(requestId, accountRow, cred.accountId)
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('Failed to get Vertex AI access token')
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully resolved Vertex AI credential`)
|
||||
return accessToken
|
||||
}
|
||||
Reference in New Issue
Block a user