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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+102
View File
@@ -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')
})
})
+284
View File
@@ -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 }
}
+158
View File
@@ -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)
}
+119
View File
@@ -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[]'
)
}
}
+42
View 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
}
+70
View File
@@ -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
}
+140
View File
@@ -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)
})
})
+74
View File
@@ -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
+257
View File
@@ -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
}
+544
View File
@@ -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')
})
})
})
+636
View File
@@ -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')
})
})
+336
View File
@@ -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
}