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
+492
View File
@@ -0,0 +1,492 @@
import {
normalizeWorkflowBlockName,
RESERVED_WORKFLOW_BLOCK_NAMES,
} from '@sim/workflow-types/workflow'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import type { LoopType, ParallelType } from '@/lib/workflows/types'
/**
* Runtime-injected keys for trigger blocks that should be hidden from logs/display.
* These are added during execution but aren't part of the block's static output schema.
*/
export const TRIGGER_INTERNAL_KEYS = ['webhook', 'workflowId'] as const
export type TriggerInternalKey = (typeof TRIGGER_INTERNAL_KEYS)[number]
export function isTriggerInternalKey(key: string): key is TriggerInternalKey {
return TRIGGER_INTERNAL_KEYS.includes(key as TriggerInternalKey)
}
export enum BlockType {
PARALLEL = 'parallel',
LOOP = 'loop',
ROUTER = 'router',
ROUTER_V2 = 'router_v2',
CONDITION = 'condition',
START_TRIGGER = 'start_trigger',
STARTER = 'starter',
TRIGGER = 'trigger',
FUNCTION = 'function',
AGENT = 'agent',
MOTHERSHIP = 'mothership',
PI = 'pi',
API = 'api',
EVALUATOR = 'evaluator',
VARIABLES = 'variables',
RESPONSE = 'response',
HUMAN_IN_THE_LOOP = 'human_in_the_loop',
WORKFLOW = 'workflow',
WORKFLOW_INPUT = 'workflow_input',
CREDENTIAL = 'credential',
WAIT = 'wait',
NOTE = 'note',
SENTINEL_START = 'sentinel_start',
SENTINEL_END = 'sentinel_end',
}
export const TRIGGER_BLOCK_TYPES = [
BlockType.START_TRIGGER,
BlockType.STARTER,
BlockType.TRIGGER,
] as const
export const METADATA_ONLY_BLOCK_TYPES = [
BlockType.LOOP,
BlockType.PARALLEL,
BlockType.NOTE,
] as const
export type SentinelType = 'start' | 'end'
export const EDGE = {
CONDITION_PREFIX: 'condition-',
CONDITION_TRUE: 'condition-true',
CONDITION_FALSE: 'condition-false',
ROUTER_PREFIX: 'router-',
LOOP_CONTINUE: 'loop_continue',
LOOP_CONTINUE_ALT: 'loop-continue-source',
LOOP_EXIT: 'loop_exit',
PARALLEL_CONTINUE: 'parallel_continue',
PARALLEL_EXIT: 'parallel_exit',
ERROR: 'error',
SOURCE: 'source',
DEFAULT: 'default',
} as const
export const SUBFLOW_CONTROL_EDGE_HANDLES = new Set<string>([
EDGE.LOOP_CONTINUE,
EDGE.LOOP_CONTINUE_ALT,
EDGE.LOOP_EXIT,
EDGE.PARALLEL_CONTINUE,
EDGE.PARALLEL_EXIT,
])
export const CONTROL_BACK_EDGE_HANDLES = new Set<string>([
EDGE.LOOP_CONTINUE,
EDGE.LOOP_CONTINUE_ALT,
EDGE.PARALLEL_CONTINUE,
])
export const LOOP = {
TYPE: {
FOR: 'for' as LoopType,
FOR_EACH: 'forEach' as LoopType,
WHILE: 'while' as LoopType,
DO_WHILE: 'doWhile',
},
SENTINEL: {
PREFIX: 'loop-',
START_SUFFIX: '-sentinel-start',
END_SUFFIX: '-sentinel-end',
START_TYPE: 'start' as SentinelType,
END_TYPE: 'end' as SentinelType,
START_NAME_PREFIX: 'Loop Start',
END_NAME_PREFIX: 'Loop End',
},
} as const
export const PARALLEL = {
TYPE: {
COLLECTION: 'collection' as ParallelType,
COUNT: 'count' as ParallelType,
},
BRANCH: {
PREFIX: '₍',
SUFFIX: '₎',
},
SENTINEL: {
PREFIX: 'parallel-',
START_SUFFIX: '-sentinel-start',
END_SUFFIX: '-sentinel-end',
START_TYPE: 'start' as SentinelType,
END_TYPE: 'end' as SentinelType,
START_NAME_PREFIX: 'Parallel Start',
END_NAME_PREFIX: 'Parallel End',
},
DEFAULT_COUNT: 1,
} as const
export const REFERENCE = {
START: '<',
END: '>',
PATH_DELIMITER: '.',
ENV_VAR_START: '{{',
ENV_VAR_END: '}}',
PREFIX: {
LOOP: 'loop',
PARALLEL: 'parallel',
VARIABLE: 'variable',
},
} as const
export const SPECIAL_REFERENCE_PREFIXES = [
REFERENCE.PREFIX.LOOP,
REFERENCE.PREFIX.PARALLEL,
REFERENCE.PREFIX.VARIABLE,
] as const
/**
* Delegates to the shared implementation in `@sim/workflow-types` so the
* client store and the realtime persistence layer agree on the same reserved
* names. Values intentionally mirror REFERENCE.PREFIX.{LOOP,PARALLEL,VARIABLE} above.
*/
export const RESERVED_BLOCK_NAMES = RESERVED_WORKFLOW_BLOCK_NAMES
export const LOOP_REFERENCE = {
ITERATION: 'iteration',
INDEX: 'index',
ITEM: 'item',
INDEX_PATH: 'loop.index',
} as const
export const PARALLEL_REFERENCE = {
INDEX: 'index',
CURRENT_ITEM: 'currentItem',
ITEMS: 'items',
} as const
export const DEFAULTS = {
BLOCK_TYPE: 'unknown',
BLOCK_TITLE: 'Untitled Block',
WORKFLOW_NAME: 'Workflow',
DEFAULT_LOOP_ITERATIONS: 1000,
MAX_PARALLEL_BRANCHES: 20,
MAX_NESTING_DEPTH: 10,
/** Maximum child workflow depth for propagating SSE callbacks (block:started, block:completed). */
MAX_SSE_CHILD_DEPTH: 3,
EXECUTION_TIME: 0,
TOKENS: {
PROMPT: 0,
COMPLETION: 0,
TOTAL: 0,
},
COST: {
INPUT: 0,
OUTPUT: 0,
TOTAL: 0,
},
} as const
export const HTTP = {
STATUS: {
OK: 200,
FORBIDDEN: 403,
NOT_FOUND: 404,
TOO_MANY_REQUESTS: 429,
SERVER_ERROR: 500,
},
CONTENT_TYPE: {
JSON: 'application/json',
EVENT_STREAM: 'text/event-stream',
},
} as const
export const AGENT = {
DEFAULT_MODEL: 'claude-sonnet-5',
get DEFAULT_FUNCTION_TIMEOUT() {
return getMaxExecutionTimeout()
},
get REQUEST_TIMEOUT() {
return getMaxExecutionTimeout()
},
CUSTOM_TOOL_PREFIX: 'custom_',
} as const
export const MCP = {
TOOL_PREFIX: 'mcp-',
} as const
export const MEMORY = {
DEFAULT_SLIDING_WINDOW_SIZE: 10,
DEFAULT_SLIDING_WINDOW_TOKENS: 4000,
CONTEXT_WINDOW_UTILIZATION: 0.9,
MAX_CONVERSATION_ID_LENGTH: 255,
MAX_MESSAGE_CONTENT_BYTES: 100 * 1024,
} as const
export const ROUTER = {
DEFAULT_MODEL: 'claude-sonnet-5',
DEFAULT_TEMPERATURE: 0,
INFERENCE_TEMPERATURE: 0.1,
} as const
export const EVALUATOR = {
DEFAULT_MODEL: 'claude-sonnet-5',
DEFAULT_TEMPERATURE: 0.1,
RESPONSE_SCHEMA_NAME: 'evaluation_response',
JSON_INDENT: 2,
} as const
export const CONDITION = {
ELSE_LABEL: 'else',
ELSE_TITLE: 'else',
} as const
export const PAUSE_RESUME = {
OPERATION: {
HUMAN: 'human',
API: 'api',
},
PATH: {
API_RESUME: '/api/resume',
UI_RESUME: '/resume',
},
} as const
export function buildResumeApiUrl(
baseUrl: string | undefined,
workflowId: string,
executionId: string,
contextId: string
): string {
const prefix = baseUrl ?? ''
return `${prefix}${PAUSE_RESUME.PATH.API_RESUME}/${workflowId}/${executionId}/${contextId}`
}
export function buildResumeUiUrl(
baseUrl: string | undefined,
workflowId: string,
executionId: string
): string {
const prefix = baseUrl ?? ''
return `${prefix}${PAUSE_RESUME.PATH.UI_RESUME}/${workflowId}/${executionId}`
}
export const PARSING = {
JSON_RADIX: 10,
PREVIEW_LENGTH: 200,
PREVIEW_SUFFIX: '...',
} as const
export type FieldType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files' | 'plain'
interface ConditionConfig {
id: string
label?: string
condition: string
}
export function isTriggerBlockType(blockType: string | undefined): boolean {
return blockType !== undefined && (TRIGGER_BLOCK_TYPES as readonly string[]).includes(blockType)
}
/**
* Determines if a block behaves as a trigger based on its metadata and config.
* This is used for execution flow decisions where trigger-like behavior matters.
*
* A block is considered trigger-like if:
* - Its category is 'triggers'
* - It has triggerMode enabled
* - It's a starter block (legacy entry point)
*/
export function isTriggerBehavior(block: {
metadata?: { category?: string; id?: string }
config?: { params?: { triggerMode?: boolean } }
}): boolean {
return (
block.metadata?.category === 'triggers' ||
block.config?.params?.triggerMode === true ||
block.metadata?.id === BlockType.STARTER
)
}
export function isMetadataOnlyBlockType(blockType: string | undefined): boolean {
return (
blockType !== undefined && (METADATA_ONLY_BLOCK_TYPES as readonly string[]).includes(blockType)
)
}
export function isWorkflowBlockType(blockType: string | undefined): boolean {
return blockType === BlockType.WORKFLOW || blockType === BlockType.WORKFLOW_INPUT
}
export function isSentinelBlockType(blockType: string | undefined): boolean {
return blockType === BlockType.SENTINEL_START || blockType === BlockType.SENTINEL_END
}
export function isConditionBlockType(blockType: string | undefined): boolean {
return blockType === BlockType.CONDITION
}
export function isRouterBlockType(blockType: string | undefined): boolean {
return blockType === BlockType.ROUTER || blockType === BlockType.ROUTER_V2
}
export function isRouterV2BlockType(blockType: string | undefined): boolean {
return blockType === BlockType.ROUTER_V2
}
export function isAgentBlockType(blockType: string | undefined): boolean {
return blockType === BlockType.AGENT
}
export function isAnnotationOnlyBlock(blockType: string | undefined): boolean {
return blockType === BlockType.NOTE
}
export function supportsHandles(blockType: string | undefined): boolean {
return !isAnnotationOnlyBlock(blockType)
}
export function getDefaultTokens() {
return {
input: DEFAULTS.TOKENS.PROMPT,
output: DEFAULTS.TOKENS.COMPLETION,
total: DEFAULTS.TOKENS.TOTAL,
}
}
export function getDefaultCost() {
return {
input: DEFAULTS.COST.INPUT,
output: DEFAULTS.COST.OUTPUT,
total: DEFAULTS.COST.TOTAL,
}
}
export function buildReference(path: string): string {
return `${REFERENCE.START}${path}${REFERENCE.END}`
}
export function buildLoopReference(property: string): string {
return buildReference(`${REFERENCE.PREFIX.LOOP}${REFERENCE.PATH_DELIMITER}${property}`)
}
export function buildParallelReference(property: string): string {
return buildReference(`${REFERENCE.PREFIX.PARALLEL}${REFERENCE.PATH_DELIMITER}${property}`)
}
export function buildVariableReference(variableName: string): string {
return buildReference(`${REFERENCE.PREFIX.VARIABLE}${REFERENCE.PATH_DELIMITER}${variableName}`)
}
export function buildBlockReference(blockId: string, path?: string): string {
return buildReference(path ? `${blockId}${REFERENCE.PATH_DELIMITER}${path}` : blockId)
}
export function buildLoopIndexCondition(maxIterations: number): string {
return `${buildLoopReference(LOOP_REFERENCE.INDEX)} < ${maxIterations}`
}
export function buildEnvVarReference(varName: string): string {
return `${REFERENCE.ENV_VAR_START}${varName}${REFERENCE.ENV_VAR_END}`
}
export function isReference(value: string): boolean {
return value.startsWith(REFERENCE.START) && value.endsWith(REFERENCE.END)
}
export function isEnvVarReference(value: string): boolean {
return value.startsWith(REFERENCE.ENV_VAR_START) && value.endsWith(REFERENCE.ENV_VAR_END)
}
export function extractEnvVarName(reference: string): string {
return reference.substring(
REFERENCE.ENV_VAR_START.length,
reference.length - REFERENCE.ENV_VAR_END.length
)
}
export function extractReferenceContent(reference: string): string {
return reference.substring(REFERENCE.START.length, reference.length - REFERENCE.END.length)
}
export function parseReferencePath(reference: string): string[] {
const content = extractReferenceContent(reference)
return content.split(REFERENCE.PATH_DELIMITER)
}
export const PATTERNS = {
UUID: /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i,
UUID_V4: /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
UUID_PREFIX: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i,
ENV_VAR_NAME: /^[A-Za-z_][A-Za-z0-9_]*$/,
} as const
export function isUuid(value: string): boolean {
return PATTERNS.UUID.test(value)
}
export function isUuidV4(value: string): boolean {
return PATTERNS.UUID_V4.test(value)
}
export function startsWithUuid(value: string): boolean {
return PATTERNS.UUID_PREFIX.test(value)
}
export function isValidEnvVarName(name: string): boolean {
return PATTERNS.ENV_VAR_NAME.test(name)
}
export function sanitizeFileName(fileName: string | null | undefined): string {
if (!fileName || typeof fileName !== 'string') {
return 'untitled'
}
return fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
}
export function isCustomTool(toolId: string): boolean {
return toolId.startsWith(AGENT.CUSTOM_TOOL_PREFIX)
}
export function isMcpTool(toolId: string): boolean {
return toolId.startsWith(MCP.TOOL_PREFIX)
}
export function stripCustomToolPrefix(name: string): string {
return name.startsWith(AGENT.CUSTOM_TOOL_PREFIX)
? name.slice(AGENT.CUSTOM_TOOL_PREFIX.length)
: name
}
export function stripMcpToolPrefix(name: string): string {
return name.startsWith(MCP.TOOL_PREFIX) ? name.slice(MCP.TOOL_PREFIX.length) : name
}
export function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Normalizes a name for comparison by converting to lowercase and removing
* spaces and dots. Used for both block names and variable names to ensure
* consistent matching.
*
* Delegates to the shared implementation in `@sim/workflow-types` so the
* client store and the realtime persistence layer normalize block names
* identically when checking for reserved/duplicate names.
*/
export function normalizeName(name: string): string {
return normalizeWorkflowBlockName(name)
}
+270
View File
@@ -0,0 +1,270 @@
import { describe, expect, it } from 'vitest'
import { BlockType } from '@/executor/constants'
import { DAGBuilder } from '@/executor/dag/builder'
import {
buildBranchNodeId,
buildParallelSentinelEndId,
buildParallelSentinelStartId,
} from '@/executor/utils/subflow-utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
function createBlock(id: string, metadataId: string): SerializedBlock {
return {
id,
position: { x: 0, y: 0 },
config: {
tool: 'noop',
params: {},
},
inputs: {},
outputs: {},
metadata: {
id: metadataId,
name: id,
},
enabled: true,
}
}
describe('DAGBuilder disabled subflow validation', () => {
it('skips validation for disabled loops with no blocks inside', () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
{ ...createBlock('loop-block', BlockType.FUNCTION), enabled: false },
],
connections: [],
loops: {
'loop-1': {
id: 'loop-1',
nodes: [], // Empty loop - would normally throw
iterations: 3,
},
},
}
const builder = new DAGBuilder()
// Should not throw even though loop has no blocks inside
expect(() => builder.build(workflow)).not.toThrow()
})
it('skips validation for disabled parallels with no blocks inside', () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [createBlock('start', BlockType.STARTER)],
connections: [],
loops: {},
parallels: {
'parallel-1': {
id: 'parallel-1',
nodes: [], // Empty parallel - would normally throw
},
},
}
const builder = new DAGBuilder()
// Should not throw even though parallel has no blocks inside
expect(() => builder.build(workflow)).not.toThrow()
})
it('skips validation for loops where all inner blocks are disabled', () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
{ ...createBlock('inner-block', BlockType.FUNCTION), enabled: false },
],
connections: [],
loops: {
'loop-1': {
id: 'loop-1',
nodes: ['inner-block'], // Has node but it's disabled
iterations: 3,
},
},
}
const builder = new DAGBuilder()
// Should not throw - loop is effectively disabled since all inner blocks are disabled
expect(() => builder.build(workflow)).not.toThrow()
})
it('does not mutate serialized loop config nodes during DAG build', () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock('loop-1', BlockType.LOOP),
{ ...createBlock('inner-block', BlockType.FUNCTION), enabled: false },
],
connections: [{ source: 'start', target: 'loop-1' }],
loops: {
'loop-1': {
id: 'loop-1',
nodes: ['inner-block'],
iterations: 3,
},
},
parallels: {},
}
const builder = new DAGBuilder()
builder.build(workflow)
expect(workflow.loops?.['loop-1']?.nodes).toEqual(['inner-block'])
})
it('does not mutate serialized parallel config nodes during DAG build', () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock('parallel-1', BlockType.PARALLEL),
{ ...createBlock('inner-block', BlockType.FUNCTION), enabled: false },
],
connections: [{ source: 'start', target: 'parallel-1' }],
loops: {},
parallels: {
'parallel-1': {
id: 'parallel-1',
nodes: ['inner-block'],
count: 2,
parallelType: 'count',
},
},
}
const builder = new DAGBuilder()
builder.build(workflow)
expect(workflow.parallels?.['parallel-1']?.nodes).toEqual(['inner-block'])
})
})
describe('DAGBuilder nested parallel support', () => {
it('builds DAG for parallel-in-parallel with correct sentinel wiring', () => {
const outerParallelId = 'outer-parallel'
const innerParallelId = 'inner-parallel'
const functionId = 'func-1'
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock(outerParallelId, BlockType.PARALLEL),
createBlock(innerParallelId, BlockType.PARALLEL),
createBlock(functionId, BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: outerParallelId },
{
source: outerParallelId,
target: innerParallelId,
sourceHandle: 'parallel-start-source',
},
{
source: innerParallelId,
target: functionId,
sourceHandle: 'parallel-start-source',
},
],
loops: {},
parallels: {
[innerParallelId]: {
id: innerParallelId,
nodes: [functionId],
count: 5,
parallelType: 'count',
},
[outerParallelId]: {
id: outerParallelId,
nodes: [innerParallelId],
count: 5,
parallelType: 'count',
},
},
}
const builder = new DAGBuilder()
const dag = builder.build(workflow)
// Outer parallel sentinel pair exists
const outerStartId = buildParallelSentinelStartId(outerParallelId)
const outerEndId = buildParallelSentinelEndId(outerParallelId)
expect(dag.nodes.has(outerStartId)).toBe(true)
expect(dag.nodes.has(outerEndId)).toBe(true)
// Inner parallel sentinel pair exists
const innerStartId = buildParallelSentinelStartId(innerParallelId)
const innerEndId = buildParallelSentinelEndId(innerParallelId)
expect(dag.nodes.has(innerStartId)).toBe(true)
expect(dag.nodes.has(innerEndId)).toBe(true)
// Function 1 branch template node exists
const funcTemplateId = buildBranchNodeId(functionId, 0)
expect(dag.nodes.has(funcTemplateId)).toBe(true)
// Start → outer-sentinel-start
const startNode = dag.nodes.get('start')!
const startTargets = Array.from(startNode.outgoingEdges.values()).map((e) => e.target)
expect(startTargets).toContain(outerStartId)
// Outer-sentinel-start → inner-sentinel-start
const outerStart = dag.nodes.get(outerStartId)!
const outerStartTargets = Array.from(outerStart.outgoingEdges.values()).map((e) => e.target)
expect(outerStartTargets).toContain(innerStartId)
// Inner-sentinel-start → function branch template
const innerStart = dag.nodes.get(innerStartId)!
const innerStartTargets = Array.from(innerStart.outgoingEdges.values()).map((e) => e.target)
expect(innerStartTargets).toContain(funcTemplateId)
// Function branch template → inner-sentinel-end
const funcTemplate = dag.nodes.get(funcTemplateId)!
const funcTargets = Array.from(funcTemplate.outgoingEdges.values()).map((e) => e.target)
expect(funcTargets).toContain(innerEndId)
// Inner-sentinel-end → outer-sentinel-end
const innerEnd = dag.nodes.get(innerEndId)!
const innerEndTargets = Array.from(innerEnd.outgoingEdges.values()).map((e) => e.target)
expect(innerEndTargets).toContain(outerEndId)
})
})
describe('DAGBuilder human-in-the-loop transformation', () => {
it('creates trigger nodes and rewires edges for pause blocks', () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock('pause', BlockType.HUMAN_IN_THE_LOOP),
createBlock('finish', BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: 'pause' },
{ source: 'pause', target: 'finish' },
],
loops: {},
}
const builder = new DAGBuilder()
const dag = builder.build(workflow)
const pauseNode = dag.nodes.get('pause')
expect(pauseNode).toBeDefined()
expect(pauseNode?.metadata.isPauseResponse).toBe(true)
const startNode = dag.nodes.get('start')!
const startOutgoing = Array.from(startNode.outgoingEdges.values())
expect(startOutgoing).toHaveLength(1)
expect(startOutgoing[0].target).toBe('pause')
const pauseOutgoing = Array.from(pauseNode!.outgoingEdges.values())
expect(pauseOutgoing).toHaveLength(1)
expect(pauseOutgoing[0].target).toBe('finish')
const triggerNode = dag.nodes.get('pause__trigger')
expect(triggerNode).toBeUndefined()
})
})
+172
View File
@@ -0,0 +1,172 @@
import { createLogger } from '@sim/logger'
import { EdgeConstructor } from '@/executor/dag/construction/edges'
import { LoopConstructor } from '@/executor/dag/construction/loops'
import { NodeConstructor } from '@/executor/dag/construction/nodes'
import { ParallelConstructor } from '@/executor/dag/construction/parallels'
import { PathConstructor } from '@/executor/dag/construction/paths'
import type { DAGEdge, NodeMetadata } from '@/executor/dag/types'
import {
buildParallelSentinelStartId,
buildSentinelStartId,
normalizeNodeId,
} from '@/executor/utils/subflow-utils'
import type {
SerializedBlock,
SerializedLoop,
SerializedParallel,
SerializedWorkflow,
} from '@/serializer/types'
const logger = createLogger('DAGBuilder')
export interface DAGNode {
id: string
block: SerializedBlock
incomingEdges: Set<string>
outgoingEdges: Map<string, DAGEdge>
metadata: NodeMetadata
}
export interface DAG {
nodes: Map<string, DAGNode>
loopConfigs: Map<string, SerializedLoop>
parallelConfigs: Map<string, SerializedParallel>
}
export interface DAGBuildOptions {
/** Trigger block ID to start path construction from */
triggerBlockId?: string
/** Saved incoming edges from snapshot for resumption */
savedIncomingEdges?: Record<string, string[]>
/** Include all enabled blocks instead of only those reachable from trigger */
includeAllBlocks?: boolean
}
export class DAGBuilder {
private pathConstructor = new PathConstructor()
private loopConstructor = new LoopConstructor()
private parallelConstructor = new ParallelConstructor()
private nodeConstructor = new NodeConstructor()
private edgeConstructor = new EdgeConstructor()
build(workflow: SerializedWorkflow, options: DAGBuildOptions = {}): DAG {
const { triggerBlockId, savedIncomingEdges, includeAllBlocks } = options
const dag: DAG = {
nodes: new Map(),
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
this.initializeConfigs(workflow, dag)
const reachableBlocks = this.pathConstructor.execute(workflow, triggerBlockId, includeAllBlocks)
this.loopConstructor.execute(dag, reachableBlocks)
this.parallelConstructor.execute(dag, reachableBlocks)
const { blocksInLoops, blocksInParallels, pauseTriggerMapping } = this.nodeConstructor.execute(
workflow,
dag,
reachableBlocks
)
this.edgeConstructor.execute(
workflow,
dag,
blocksInParallels,
blocksInLoops,
reachableBlocks,
pauseTriggerMapping
)
if (savedIncomingEdges) {
logger.info('Restoring DAG incoming edges from snapshot', {
nodeCount: Object.keys(savedIncomingEdges).length,
})
for (const [nodeId, incomingEdgeArray] of Object.entries(savedIncomingEdges)) {
const node = dag.nodes.get(nodeId)
if (node) {
node.incomingEdges = new Set(incomingEdgeArray)
}
}
}
// Validate loop and parallel structure
this.validateSubflowStructure(dag)
logger.info('DAG built', {
totalNodes: dag.nodes.size,
loopCount: dag.loopConfigs.size,
parallelCount: dag.parallelConfigs.size,
allNodeIds: Array.from(dag.nodes.keys()),
triggerNodes: Array.from(dag.nodes.values())
.filter((n) => n.metadata?.isResumeTrigger)
.map((n) => ({ id: n.id, originalBlockId: n.metadata?.originalBlockId })),
})
return dag
}
private initializeConfigs(workflow: SerializedWorkflow, dag: DAG): void {
if (workflow.loops) {
for (const [loopId, loopConfig] of Object.entries(workflow.loops)) {
dag.loopConfigs.set(loopId, {
...loopConfig,
nodes: [...(loopConfig.nodes ?? [])],
})
}
}
if (workflow.parallels) {
for (const [parallelId, parallelConfig] of Object.entries(workflow.parallels)) {
dag.parallelConfigs.set(parallelId, {
...parallelConfig,
nodes: [...(parallelConfig.nodes ?? [])],
})
}
}
}
/**
* Validates that loops and parallels have proper internal structure.
* Throws an error if a loop/parallel has no blocks inside or no connections from start.
*/
private validateSubflowStructure(dag: DAG): void {
for (const [id, config] of dag.loopConfigs) {
this.validateSubflow(dag, id, config.nodes, 'Loop')
}
for (const [id, config] of dag.parallelConfigs) {
this.validateSubflow(dag, id, config.nodes, 'Parallel')
}
}
private validateSubflow(
dag: DAG,
id: string,
nodes: string[] | undefined,
type: 'Loop' | 'Parallel'
): void {
const sentinelStartId =
type === 'Loop' ? buildSentinelStartId(id) : buildParallelSentinelStartId(id)
const sentinelStartNode = dag.nodes.get(sentinelStartId)
if (!sentinelStartNode) return
if (!nodes || nodes.length === 0) {
return
}
const hasConnections = Array.from(sentinelStartNode.outgoingEdges.values()).some((edge) =>
nodes.includes(normalizeNodeId(edge.target))
)
if (!hasConnections) {
throw new Error(
`${type} start is not connected to any blocks. Connect a block to the ${type.toLowerCase()} start.`
)
}
}
}
File diff suppressed because it is too large Load Diff
+784
View File
@@ -0,0 +1,784 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import {
CONTROL_BACK_EDGE_HANDLES,
EDGE,
isConditionBlockType,
isRouterBlockType,
isRouterV2BlockType,
} from '@/executor/constants'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import {
buildBranchNodeId,
buildParallelSentinelEndId,
buildParallelSentinelStartId,
buildSentinelEndId,
buildSentinelStartId,
normalizeNodeId,
} from '@/executor/utils/subflow-utils'
import type { SerializedWorkflow } from '@/serializer/types'
const logger = createLogger('EdgeConstructor')
interface ConditionConfig {
id: string
label?: string
condition: string
}
interface RouterV2RouteConfig {
id: string
title: string
description: string
}
interface EdgeMetadata {
blockTypeMap: Map<string, string>
conditionConfigMap: Map<string, ConditionConfig[]>
routerBlockIds: Set<string>
routerV2ConfigMap: Map<string, RouterV2RouteConfig[]>
}
export class EdgeConstructor {
execute(
workflow: SerializedWorkflow,
dag: DAG,
blocksInParallels: Set<string>,
blocksInLoops: Set<string>,
reachableBlocks: Set<string>,
pauseTriggerMapping: Map<string, string>
): void {
const loopBlockIds = new Set(dag.loopConfigs.keys())
const parallelBlockIds = new Set(dag.parallelConfigs.keys())
const metadata = this.buildMetadataMaps(workflow)
this.wireRegularEdges(
workflow,
dag,
blocksInParallels,
blocksInLoops,
reachableBlocks,
loopBlockIds,
parallelBlockIds,
metadata,
pauseTriggerMapping
)
this.wireLoopSentinels(dag)
this.wireParallelSentinels(dag)
}
private buildMetadataMaps(workflow: SerializedWorkflow): EdgeMetadata {
const blockTypeMap = new Map<string, string>()
const conditionConfigMap = new Map<string, ConditionConfig[]>()
const routerBlockIds = new Set<string>()
const routerV2ConfigMap = new Map<string, RouterV2RouteConfig[]>()
for (const block of workflow.blocks) {
const blockType = block.metadata?.id ?? ''
blockTypeMap.set(block.id, blockType)
if (isConditionBlockType(blockType)) {
const conditions = this.parseConditionConfig(block)
if (conditions) {
conditionConfigMap.set(block.id, conditions)
}
} else if (isRouterV2BlockType(blockType)) {
// Router V2 uses port-based routing with route configs
const routes = this.parseRouterV2Config(block)
if (routes) {
routerV2ConfigMap.set(block.id, routes)
}
} else if (isRouterBlockType(blockType)) {
// Legacy router uses target block IDs
routerBlockIds.add(block.id)
}
}
return { blockTypeMap, conditionConfigMap, routerBlockIds, routerV2ConfigMap }
}
private parseConditionConfig(block: any): ConditionConfig[] | null {
try {
const conditionsJson = block.config.params?.conditions
if (typeof conditionsJson === 'string') {
return JSON.parse(conditionsJson)
}
if (Array.isArray(conditionsJson)) {
return conditionsJson
}
return null
} catch (error) {
logger.warn('Failed to parse condition config', {
blockId: block.id,
error: toError(error).message,
})
return null
}
}
private parseRouterV2Config(block: any): RouterV2RouteConfig[] | null {
try {
const routesJson = block.config.params?.routes
if (typeof routesJson === 'string') {
return JSON.parse(routesJson)
}
if (Array.isArray(routesJson)) {
return routesJson
}
return null
} catch (error) {
logger.warn('Failed to parse router v2 config', {
blockId: block.id,
error: toError(error).message,
})
return null
}
}
private generateSourceHandle(
source: string,
target: string,
sourceHandle: string | undefined,
metadata: EdgeMetadata,
workflow: SerializedWorkflow
): string | undefined {
let handle = sourceHandle
if (!handle && isConditionBlockType(metadata.blockTypeMap.get(source) ?? '')) {
const conditions = metadata.conditionConfigMap.get(source)
if (conditions && conditions.length > 0) {
const edgesFromCondition = workflow.connections.filter((c) => c.source === source)
const edgeIndex = edgesFromCondition.findIndex((e) => e.target === target)
if (edgeIndex >= 0 && edgeIndex < conditions.length) {
const correspondingCondition = conditions[edgeIndex]
handle = `${EDGE.CONDITION_PREFIX}${correspondingCondition.id}`
}
}
}
// Router V2 uses port-based routing - handle is already set from UI (router-{routeId})
// We don't modify it here, just validate it exists
if (metadata.routerV2ConfigMap.has(source)) {
// For router_v2, the sourceHandle should already be set from the UI
// If not set and not an error handle, generate based on route index
if (!handle || (!handle.startsWith(EDGE.ROUTER_PREFIX) && handle !== EDGE.ERROR)) {
const routes = metadata.routerV2ConfigMap.get(source)
if (routes && routes.length > 0) {
const edgesFromRouter = workflow.connections.filter((c) => c.source === source)
const edgeIndex = edgesFromRouter.findIndex((e) => e.target === target)
if (edgeIndex >= 0 && edgeIndex < routes.length) {
const correspondingRoute = routes[edgeIndex]
handle = `${EDGE.ROUTER_PREFIX}${correspondingRoute.id}`
}
}
}
}
// Legacy router uses target block ID
if (metadata.routerBlockIds.has(source) && handle !== EDGE.ERROR) {
handle = `${EDGE.ROUTER_PREFIX}${target}`
}
return handle
}
private wireRegularEdges(
workflow: SerializedWorkflow,
dag: DAG,
blocksInParallels: Set<string>,
blocksInLoops: Set<string>,
reachableBlocks: Set<string>,
loopBlockIds: Set<string>,
parallelBlockIds: Set<string>,
metadata: EdgeMetadata,
pauseTriggerMapping: Map<string, string>
): void {
for (const connection of workflow.connections) {
let { source, target } = connection
const originalSource = source
const originalTarget = target
let sourceHandle = this.generateSourceHandle(
source,
target,
connection.sourceHandle,
metadata,
workflow
)
const targetHandle = connection.targetHandle
const sourceIsLoopBlock = loopBlockIds.has(source)
const targetIsLoopBlock = loopBlockIds.has(target)
const sourceIsParallelBlock = parallelBlockIds.has(source)
const targetIsParallelBlock = parallelBlockIds.has(target)
if (this.edgeStaysWithinSameParallel(originalSource, originalTarget, dag)) {
const sourceId = this.resolveSubflowToSentinelEnd(originalSource, dag)
const targetId = this.resolveSubflowToSentinelStart(originalTarget, dag)
const resolvedSourceHandle = this.resolveParallelChildSourceHandle(
originalSource,
dag,
sourceHandle
)
this.addEdge(dag, sourceId, targetId, resolvedSourceHandle, targetHandle)
this.addSubflowStartExitBypass(dag, originalSource)
continue
}
if (sourceIsLoopBlock) {
const sentinelEndId = buildSentinelEndId(originalSource)
const loopSentinelStartId = buildSentinelStartId(originalSource)
if (!dag.nodes.has(sentinelEndId) || !dag.nodes.has(loopSentinelStartId)) {
continue
}
source = sentinelEndId
sourceHandle = EDGE.LOOP_EXIT
this.addSubflowStartExitBypass(dag, originalSource)
}
if (targetIsLoopBlock) {
const sentinelStartId = buildSentinelStartId(target)
if (!dag.nodes.has(sentinelStartId)) {
continue
}
target = sentinelStartId
}
if (sourceIsParallelBlock) {
// Skip intra-parallel edges (start → child); handled by wireParallelSentinels
const sourceParallelNodes = dag.parallelConfigs.get(originalSource)?.nodes
if (sourceParallelNodes?.includes(originalTarget)) {
continue
}
const sentinelEndId = buildParallelSentinelEndId(originalSource)
if (!dag.nodes.has(sentinelEndId)) {
continue
}
source = sentinelEndId
sourceHandle = EDGE.PARALLEL_EXIT
}
if (targetIsParallelBlock) {
const sentinelStartId = buildParallelSentinelStartId(target)
if (!dag.nodes.has(sentinelStartId)) {
continue
}
target = sentinelStartId
}
if (sourceIsParallelBlock) {
this.addSubflowStartExitBypass(dag, originalSource)
}
if (this.edgeCrossesLoopBoundary(originalSource, originalTarget, blocksInLoops, dag)) {
continue
}
if (!this.isEdgeReachable(source, target, reachableBlocks, dag)) {
continue
}
if (blocksInParallels.has(source) && blocksInParallels.has(target)) {
const sourceParallelId = this.getParallelId(source, dag)
const targetParallelId = this.getParallelId(target, dag)
if (sourceParallelId === targetParallelId) {
this.wireParallelTemplateEdge(source, target, dag, sourceHandle, targetHandle)
} else {
logger.warn('Edge between different parallels - invalid workflow', { source, target })
}
} else if (blocksInParallels.has(source) || blocksInParallels.has(target)) {
// Skip - will be handled by sentinel wiring
} else {
const resolvedSource = pauseTriggerMapping.get(originalSource) ?? source
this.addEdge(dag, resolvedSource, target, sourceHandle, targetHandle)
}
}
}
private wireLoopSentinels(dag: DAG): void {
for (const [loopId, loopConfig] of dag.loopConfigs) {
const nodes = loopConfig.nodes
if (nodes.length === 0) continue
const sentinelStartId = buildSentinelStartId(loopId)
const sentinelEndId = buildSentinelEndId(loopId)
if (!dag.nodes.has(sentinelStartId) || !dag.nodes.has(sentinelEndId)) {
continue
}
this.addSubflowStartExitBypass(dag, loopId)
const { startNodes, terminalNodes } = this.findLoopBoundaryNodes(nodes, dag)
for (const startNodeId of startNodes) {
const resolvedId = this.resolveLoopBlockToSentinelStart(startNodeId, dag)
this.addEdge(dag, sentinelStartId, resolvedId)
}
for (const terminalNodeId of terminalNodes) {
const resolvedId = this.resolveLoopBlockToSentinelEnd(terminalNodeId, dag)
if (resolvedId !== terminalNodeId) {
// Use the sourceHandle that matches the nested subflow's exit route.
// Parallel sentinel-end outputs selectedRoute "parallel_exit",
// loop sentinel-end outputs "loop_exit". The edge manager only activates
// edges whose sourceHandle matches the source node's selectedRoute.
const handle = dag.parallelConfigs.has(terminalNodeId)
? EDGE.PARALLEL_EXIT
: EDGE.LOOP_EXIT
this.addEdge(dag, resolvedId, sentinelEndId, handle)
this.addSubflowStartExitBypass(dag, terminalNodeId)
} else {
this.addEdge(dag, resolvedId, sentinelEndId)
}
}
this.addEdge(dag, sentinelEndId, sentinelStartId, EDGE.LOOP_CONTINUE, undefined, {
registerIncoming: false,
})
}
}
private wireParallelSentinels(dag: DAG): void {
for (const [parallelId, parallelConfig] of dag.parallelConfigs) {
const nodes = parallelConfig.nodes
if (nodes.length === 0) continue
const sentinelStartId = buildParallelSentinelStartId(parallelId)
const sentinelEndId = buildParallelSentinelEndId(parallelId)
if (!dag.nodes.has(sentinelStartId) || !dag.nodes.has(sentinelEndId)) {
continue
}
this.addSubflowStartExitBypass(dag, parallelId)
const { entryNodes, terminalNodes } = this.findParallelBoundaryNodes(nodes, dag)
for (const entryNodeId of entryNodes) {
const targetId = this.resolveSubflowToSentinelStart(entryNodeId, dag)
if (dag.nodes.has(targetId)) {
this.addEdge(dag, sentinelStartId, targetId)
}
}
for (const terminalNodeId of terminalNodes) {
const sourceId = this.resolveSubflowToSentinelEnd(terminalNodeId, dag)
if (dag.nodes.has(sourceId)) {
const handle = this.resolveSubflowExitHandle(terminalNodeId, dag)
this.addEdge(dag, sourceId, sentinelEndId, handle)
if (handle) {
this.addSubflowStartExitBypass(dag, terminalNodeId)
}
}
}
this.addEdge(dag, sentinelEndId, sentinelStartId, EDGE.PARALLEL_CONTINUE, undefined, {
registerIncoming: false,
})
}
}
/**
* Resolves a node ID to the appropriate entry point for sentinel wiring.
* Nested parallels → their sentinel-start, nested loops → their sentinel-start,
* regular blocks → their branch template node.
*/
private resolveSubflowToSentinelStart(nodeId: string, dag: DAG): string {
if (dag.parallelConfigs.has(nodeId)) {
return buildParallelSentinelStartId(nodeId)
}
if (dag.loopConfigs.has(nodeId)) {
return buildSentinelStartId(nodeId)
}
return buildBranchNodeId(nodeId, 0)
}
/**
* Resolves a node ID to the appropriate exit point for sentinel wiring.
* Nested parallels → their sentinel-end, nested loops → their sentinel-end,
* regular blocks → their branch template node.
*/
private resolveSubflowToSentinelEnd(nodeId: string, dag: DAG): string {
if (dag.parallelConfigs.has(nodeId)) {
return buildParallelSentinelEndId(nodeId)
}
if (dag.loopConfigs.has(nodeId)) {
return buildSentinelEndId(nodeId)
}
return buildBranchNodeId(nodeId, 0)
}
/**
* Checks whether an edge crosses a loop boundary (source and target are in
* different loops, or one is inside a loop and the other is not). Uses the
* original block IDs (pre-sentinel-remapping) because `blocksInLoops` and
* `loopConfigs.nodes` reference original block IDs from the serialized workflow.
*/
private edgeCrossesLoopBoundary(
source: string,
target: string,
blocksInLoops: Set<string>,
dag: DAG
): boolean {
const sourceInLoop = blocksInLoops.has(source)
const targetInLoop = blocksInLoops.has(target)
if (sourceInLoop !== targetInLoop) {
return true
}
if (!sourceInLoop && !targetInLoop) {
return false
}
// Find the innermost loop for each block. In nested loops a block appears
// in multiple loop configs; we need the most deeply nested one.
const sourceLoopId = this.findInnermostLoop(source, dag)
const targetLoopId = this.findInnermostLoop(target, dag)
return sourceLoopId !== targetLoopId
}
/**
* Finds the innermost loop containing a block. When a block is in nested
* loops (A contains B, both list the block), returns B (the one that
* doesn't contain any other candidate loop).
*/
private findInnermostLoop(blockId: string, dag: DAG): string | undefined {
const candidates: string[] = []
for (const [loopId, loopConfig] of dag.loopConfigs) {
if (loopConfig.nodes.includes(blockId)) {
candidates.push(loopId)
}
}
if (candidates.length <= 1) return candidates[0]
return candidates.find((candidateId) =>
candidates.every((otherId) => {
if (otherId === candidateId) return true
const candidateConfig = dag.loopConfigs.get(candidateId)
return !candidateConfig?.nodes.includes(otherId)
})
)
}
private isEdgeReachable(
source: string,
target: string,
reachableBlocks: Set<string>,
dag: DAG
): boolean {
if (!reachableBlocks.has(source) && !dag.nodes.has(source)) {
return false
}
if (!reachableBlocks.has(target) && !dag.nodes.has(target)) {
return false
}
return true
}
private wireParallelTemplateEdge(
source: string,
target: string,
dag: DAG,
sourceHandle?: string,
targetHandle?: string
): void {
const sourceNodeId = buildBranchNodeId(source, 0)
const targetNodeId = buildBranchNodeId(target, 0)
this.addEdge(dag, sourceNodeId, targetNodeId, sourceHandle, targetHandle)
}
/**
* Resolves the DAG node to inspect for a given loop child.
* If the child is a nested subflow (loop or parallel), returns its sentinel node;
* otherwise returns the regular DAG node.
*/
private resolveLoopChildNode(
nodeId: string,
dag: DAG,
sentinel: 'start' | 'end'
): { resolvedId: string; node: DAGNode | undefined } {
if (dag.loopConfigs.has(nodeId)) {
const resolvedId =
sentinel === 'start' ? buildSentinelStartId(nodeId) : buildSentinelEndId(nodeId)
return { resolvedId, node: dag.nodes.get(resolvedId) }
}
if (dag.parallelConfigs.has(nodeId)) {
const resolvedId =
sentinel === 'start'
? buildParallelSentinelStartId(nodeId)
: buildParallelSentinelEndId(nodeId)
return { resolvedId, node: dag.nodes.get(resolvedId) }
}
return { resolvedId: nodeId, node: dag.nodes.get(nodeId) }
}
private resolveLoopBlockToSentinelStart(nodeId: string, dag: DAG): string {
return this.resolveLoopChildNode(nodeId, dag, 'start').resolvedId
}
private resolveLoopBlockToSentinelEnd(nodeId: string, dag: DAG): string {
return this.resolveLoopChildNode(nodeId, dag, 'end').resolvedId
}
/**
* Builds the set of effective DAG node IDs for a loop's children,
* mapping nested subflow block IDs (loops and parallels) to their sentinel IDs.
*/
private buildEffectiveNodeSet(nodes: string[], dag: DAG): Set<string> {
const effective = new Set<string>()
for (const nodeId of nodes) {
if (dag.loopConfigs.has(nodeId)) {
effective.add(buildSentinelStartId(nodeId))
effective.add(buildSentinelEndId(nodeId))
} else if (dag.parallelConfigs.has(nodeId)) {
effective.add(buildParallelSentinelStartId(nodeId))
effective.add(buildParallelSentinelEndId(nodeId))
} else {
effective.add(nodeId)
}
}
return effective
}
private findLoopBoundaryNodes(
nodes: string[],
dag: DAG
): { startNodes: string[]; terminalNodes: string[] } {
const effectiveNodeSet = this.buildEffectiveNodeSet(nodes, dag)
const startNodesSet = new Set<string>()
const terminalNodesSet = new Set<string>()
for (const nodeId of nodes) {
const { node } = this.resolveLoopChildNode(nodeId, dag, 'start')
if (!node) continue
let hasIncomingFromLoop = false
for (const incomingNodeId of node.incomingEdges) {
if (effectiveNodeSet.has(incomingNodeId)) {
hasIncomingFromLoop = true
break
}
}
if (!hasIncomingFromLoop) {
startNodesSet.add(nodeId)
}
}
for (const nodeId of nodes) {
const { node } = this.resolveLoopChildNode(nodeId, dag, 'end')
if (!node) continue
let hasOutgoingToLoop = false
for (const [, edge] of node.outgoingEdges) {
if (this.isControlBackEdge(edge.sourceHandle)) continue
if (effectiveNodeSet.has(edge.target)) {
hasOutgoingToLoop = true
break
}
}
if (!hasOutgoingToLoop) {
terminalNodesSet.add(nodeId)
}
}
return {
startNodes: Array.from(startNodesSet),
terminalNodes: Array.from(terminalNodesSet),
}
}
private findParallelBoundaryNodes(
nodes: string[],
dag: DAG
): { entryNodes: string[]; terminalNodes: string[] } {
const nodesSet = new Set(nodes)
const entryNodes: string[] = []
const terminalNodes: string[] = []
for (const nodeId of nodes) {
// For nested subflow containers, use their sentinel nodes for boundary detection
const { startNode, endNode } = this.resolveParallelChildNodes(nodeId, dag)
if (!startNode && !endNode) continue
// Entry detection: check if the start-facing node has incoming edges from within the parallel
if (startNode) {
let hasIncomingFromParallel = false
for (const incomingNodeId of startNode.incomingEdges) {
const originalNodeId = normalizeNodeId(incomingNodeId)
if (nodesSet.has(originalNodeId)) {
hasIncomingFromParallel = true
break
}
}
if (!hasIncomingFromParallel) {
entryNodes.push(nodeId)
}
}
// Terminal detection: check if the end-facing node has outgoing edges to within the parallel
if (endNode) {
let hasOutgoingToParallel = false
for (const [, edge] of endNode.outgoingEdges) {
if (this.isControlBackEdge(edge.sourceHandle)) continue
const originalTargetId = normalizeNodeId(edge.target)
if (nodesSet.has(originalTargetId)) {
hasOutgoingToParallel = true
break
}
}
if (!hasOutgoingToParallel) {
terminalNodes.push(nodeId)
}
}
}
return { entryNodes, terminalNodes }
}
/**
* Resolves a child node inside a parallel to the correct DAG nodes for boundary detection.
* For regular blocks, returns the branch template node for both start and end.
* For nested parallels, returns the inner parallel's sentinel-start and sentinel-end.
* For nested loops, returns the inner loop's sentinel-start and sentinel-end.
*/
private resolveParallelChildNodes(
nodeId: string,
dag: DAG
): { startNode: DAGNode | undefined; endNode: DAGNode | undefined } {
if (dag.parallelConfigs.has(nodeId)) {
return {
startNode: dag.nodes.get(buildParallelSentinelStartId(nodeId)),
endNode: dag.nodes.get(buildParallelSentinelEndId(nodeId)),
}
}
if (dag.loopConfigs.has(nodeId)) {
return {
startNode: dag.nodes.get(buildSentinelStartId(nodeId)),
endNode: dag.nodes.get(buildSentinelEndId(nodeId)),
}
}
// Regular block — use branch template node for both
const templateNode = dag.nodes.get(buildBranchNodeId(nodeId, 0))
return { startNode: templateNode, endNode: templateNode }
}
private isControlBackEdge(sourceHandle?: string): boolean {
return sourceHandle !== undefined && CONTROL_BACK_EDGE_HANDLES.has(sourceHandle)
}
private getParallelId(blockId: string, dag: DAG): string | null {
for (const [parallelId, parallelConfig] of dag.parallelConfigs) {
if (parallelConfig.nodes.includes(blockId)) {
return parallelId
}
}
return null
}
private edgeStaysWithinSameParallel(source: string, target: string, dag: DAG): boolean {
const sourceParallelId = this.getParallelId(source, dag)
const targetParallelId = this.getParallelId(target, dag)
return !!sourceParallelId && sourceParallelId === targetParallelId
}
private resolveParallelChildSourceHandle(
source: string,
dag: DAG,
sourceHandle?: string
): string | undefined {
if (dag.parallelConfigs.has(source)) {
return EDGE.PARALLEL_EXIT
}
if (dag.loopConfigs.has(source)) {
return EDGE.LOOP_EXIT
}
return sourceHandle
}
private resolveSubflowExitHandle(nodeId: string, dag: DAG): string | undefined {
if (dag.parallelConfigs.has(nodeId)) {
return EDGE.PARALLEL_EXIT
}
if (dag.loopConfigs.has(nodeId)) {
return EDGE.LOOP_EXIT
}
return undefined
}
private addSubflowStartExitBypass(dag: DAG, subflowId: string): void {
if (dag.parallelConfigs.has(subflowId)) {
const sourceId = buildParallelSentinelStartId(subflowId)
const targetId = buildParallelSentinelEndId(subflowId)
if (dag.nodes.has(sourceId) && dag.nodes.has(targetId)) {
this.addEdge(dag, sourceId, targetId, EDGE.PARALLEL_EXIT, undefined, {
registerIncoming: false,
})
}
return
}
if (dag.loopConfigs.has(subflowId)) {
const sourceId = buildSentinelStartId(subflowId)
const targetId = buildSentinelEndId(subflowId)
if (dag.nodes.has(sourceId) && dag.nodes.has(targetId)) {
this.addEdge(dag, sourceId, targetId, EDGE.LOOP_EXIT, undefined, {
registerIncoming: false,
})
}
}
}
private addEdge(
dag: DAG,
sourceId: string,
targetId: string,
sourceHandle?: string,
targetHandle?: string,
options: { registerIncoming?: boolean } = {}
): void {
const sourceNode = dag.nodes.get(sourceId)
const targetNode = dag.nodes.get(targetId)
if (!sourceNode || !targetNode) {
logger.warn('Edge references non-existent node', { sourceId, targetId })
return
}
const edgeId = `${sourceId}${targetId}${sourceHandle ? `-${sourceHandle}` : ''}`
sourceNode.outgoingEdges.set(edgeId, {
target: targetId,
sourceHandle,
targetHandle,
})
const { registerIncoming = true } = options
if (registerIncoming) {
targetNode.incomingEdges.add(sourceId)
}
}
}
@@ -0,0 +1,52 @@
import { BlockType, LOOP } from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
import { createSubflowSentinelNode } from '@/executor/dag/construction/sentinels'
import { buildSentinelEndId, buildSentinelStartId } from '@/executor/utils/subflow-utils'
export class LoopConstructor {
execute(dag: DAG, reachableBlocks: Set<string>): void {
for (const [loopId, loopConfig] of dag.loopConfigs) {
if (!reachableBlocks.has(loopId)) {
continue
}
const loopNodes = loopConfig.nodes
const hasReachableChildren = loopNodes.some((nodeId) => reachableBlocks.has(nodeId))
if (!hasReachableChildren) {
loopConfig.nodes = []
}
this.createSentinelPair(dag, loopId)
}
}
private createSentinelPair(dag: DAG, loopId: string): void {
const startId = buildSentinelStartId(loopId)
const endId = buildSentinelEndId(loopId)
dag.nodes.set(
startId,
createSubflowSentinelNode({
id: startId,
subflowId: loopId,
subflowType: 'loop',
sentinelType: LOOP.SENTINEL.START_TYPE,
blockType: BlockType.SENTINEL_START,
name: `${LOOP.SENTINEL.START_NAME_PREFIX} (${loopId})`,
})
)
dag.nodes.set(
endId,
createSubflowSentinelNode({
id: endId,
subflowId: loopId,
subflowType: 'loop',
sentinelType: LOOP.SENTINEL.END_TYPE,
blockType: BlockType.SENTINEL_END,
name: `${LOOP.SENTINEL.END_NAME_PREFIX} (${loopId})`,
})
)
}
}
@@ -0,0 +1,46 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { BlockType } from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
import { NodeConstructor } from '@/executor/dag/construction/nodes'
import type { SerializedWorkflow } from '@/serializer/types'
describe('NodeConstructor', () => {
it('assigns nested loop nodes to the innermost loop metadata', () => {
const dag: DAG = {
nodes: new Map(),
loopConfigs: new Map([
['outer-loop', { id: 'outer-loop', nodes: ['inner-loop', 'task'], iterations: 1 }],
['inner-loop', { id: 'inner-loop', nodes: ['task'], iterations: 1 }],
]),
parallelConfigs: new Map(),
}
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
{
id: 'task',
position: { x: 0, y: 0 },
config: { tool: '', params: {} },
inputs: {},
outputs: {},
metadata: { id: BlockType.FUNCTION, name: 'Task' },
enabled: true,
},
],
connections: [],
loops: {},
parallels: {},
}
new NodeConstructor().execute(workflow, dag, new Set(['task']))
expect(dag.nodes.get('task')?.metadata).toMatchObject({
isLoopNode: true,
subflowId: 'inner-loop',
subflowType: 'loop',
})
})
})
+166
View File
@@ -0,0 +1,166 @@
import { BlockType, isMetadataOnlyBlockType } from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
import { buildBranchNodeId } from '@/executor/utils/subflow-utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
export class NodeConstructor {
execute(
workflow: SerializedWorkflow,
dag: DAG,
reachableBlocks: Set<string>
): {
blocksInLoops: Set<string>
blocksInParallels: Set<string>
pauseTriggerMapping: Map<string, string>
} {
const blocksInLoops = new Set<string>()
const blocksInParallels = new Set<string>()
const pauseTriggerMapping = new Map<string, string>()
this.categorizeBlocks(dag, reachableBlocks, blocksInLoops, blocksInParallels)
for (const block of workflow.blocks) {
if (!this.shouldProcessBlock(block, reachableBlocks)) {
continue
}
const parallelId = this.findParallelForBlock(block.id, dag)
if (parallelId) {
this.createParallelTemplateNode(block, parallelId, dag)
} else {
this.createRegularOrLoopNode(block, blocksInLoops, dag)
}
}
return { blocksInLoops, blocksInParallels, pauseTriggerMapping }
}
private shouldProcessBlock(block: SerializedBlock, reachableBlocks: Set<string>): boolean {
if (!block.enabled) {
return false
}
if (!reachableBlocks.has(block.id)) {
return false
}
if (isMetadataOnlyBlockType(block.metadata?.id)) {
return false
}
return true
}
private categorizeBlocks(
dag: DAG,
reachableBlocks: Set<string>,
blocksInLoops: Set<string>,
blocksInParallels: Set<string>
): void {
this.categorizeLoopBlocks(dag, reachableBlocks, blocksInLoops)
this.categorizeParallelBlocks(dag, reachableBlocks, blocksInParallels)
}
private categorizeLoopBlocks(
dag: DAG,
reachableBlocks: Set<string>,
blocksInLoops: Set<string>
): void {
for (const [, loopConfig] of dag.loopConfigs) {
for (const nodeId of loopConfig.nodes) {
if (reachableBlocks.has(nodeId)) {
blocksInLoops.add(nodeId)
}
}
}
}
private categorizeParallelBlocks(
dag: DAG,
reachableBlocks: Set<string>,
blocksInParallels: Set<string>
): void {
for (const [, parallelConfig] of dag.parallelConfigs) {
for (const nodeId of parallelConfig.nodes) {
if (reachableBlocks.has(nodeId)) {
blocksInParallels.add(nodeId)
}
}
}
}
private createParallelTemplateNode(block: SerializedBlock, parallelId: string, dag: DAG): void {
const templateNodeId = buildBranchNodeId(block.id, 0)
const blockClone: SerializedBlock = {
...block,
id: templateNodeId,
}
dag.nodes.set(templateNodeId, {
id: templateNodeId,
block: blockClone,
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {
isParallelBranch: true,
subflowId: parallelId,
subflowType: 'parallel',
branchIndex: 0,
branchTotal: 1,
isPauseResponse: block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP,
originalBlockId: block.id,
},
})
}
private createRegularOrLoopNode(
block: SerializedBlock,
blocksInLoops: Set<string>,
dag: DAG
): void {
const isLoopNode = blocksInLoops.has(block.id)
const loopId = isLoopNode ? this.findLoopIdForBlock(block.id, dag) : undefined
const isPauseBlock = block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP
dag.nodes.set(block.id, {
id: block.id,
block,
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {
isLoopNode,
...(loopId && { subflowId: loopId, subflowType: 'loop' as const }),
isPauseResponse: isPauseBlock,
originalBlockId: block.id,
},
})
}
private findLoopIdForBlock(blockId: string, dag: DAG): string | undefined {
const candidates: string[] = []
for (const [loopId, loopConfig] of dag.loopConfigs) {
if (loopConfig.nodes.includes(blockId)) {
candidates.push(loopId)
}
}
if (candidates.length <= 1) return candidates[0]
return candidates.find((candidateId) =>
candidates.every((otherId) => {
if (otherId === candidateId) return true
const candidateConfig = dag.loopConfigs.get(candidateId)
return !candidateConfig?.nodes.includes(otherId)
})
)
}
private findParallelForBlock(blockId: string, dag: DAG): string | null {
for (const [parallelId, parallelConfig] of dag.parallelConfigs) {
if (parallelConfig.nodes.includes(blockId)) {
return parallelId
}
}
return null
}
}
@@ -0,0 +1,55 @@
import { BlockType, PARALLEL } from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
import { createSubflowSentinelNode } from '@/executor/dag/construction/sentinels'
import {
buildParallelSentinelEndId,
buildParallelSentinelStartId,
} from '@/executor/utils/subflow-utils'
export class ParallelConstructor {
execute(dag: DAG, reachableBlocks: Set<string>): void {
for (const [parallelId, parallelConfig] of dag.parallelConfigs) {
if (!reachableBlocks.has(parallelId)) {
continue
}
const parallelNodes = parallelConfig.nodes
const hasReachableChildren = parallelNodes.some((nodeId) => reachableBlocks.has(nodeId))
if (!hasReachableChildren) {
parallelConfig.nodes = []
}
this.createSentinelPair(dag, parallelId)
}
}
private createSentinelPair(dag: DAG, parallelId: string): void {
const startId = buildParallelSentinelStartId(parallelId)
const endId = buildParallelSentinelEndId(parallelId)
dag.nodes.set(
startId,
createSubflowSentinelNode({
id: startId,
subflowId: parallelId,
subflowType: 'parallel',
sentinelType: PARALLEL.SENTINEL.START_TYPE,
blockType: BlockType.SENTINEL_START,
name: `${PARALLEL.SENTINEL.START_NAME_PREFIX} (${parallelId})`,
})
)
dag.nodes.set(
endId,
createSubflowSentinelNode({
id: endId,
subflowId: parallelId,
subflowType: 'parallel',
sentinelType: PARALLEL.SENTINEL.END_TYPE,
blockType: BlockType.SENTINEL_END,
name: `${PARALLEL.SENTINEL.END_NAME_PREFIX} (${parallelId})`,
})
)
}
}
+184
View File
@@ -0,0 +1,184 @@
import { createLogger } from '@sim/logger'
import { isMetadataOnlyBlockType, isTriggerBlockType } from '@/executor/constants'
import { extractBaseBlockId } from '@/executor/utils/subflow-utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
const logger = createLogger('PathConstructor')
export class PathConstructor {
execute(
workflow: SerializedWorkflow,
triggerBlockId?: string,
includeAllBlocks?: boolean
): Set<string> {
// For run-from-block mode, include all enabled blocks regardless of trigger reachability
if (includeAllBlocks) {
return this.getAllEnabledBlocks(workflow)
}
const resolvedTriggerId = this.findTriggerBlock(workflow, triggerBlockId)
if (!resolvedTriggerId) {
logger.warn('No trigger block found, including all enabled blocks as fallback')
return this.getAllEnabledBlocks(workflow)
}
const adjacency = this.buildAdjacencyMap(workflow)
const reachable = this.performBFS(resolvedTriggerId, adjacency)
return reachable
}
private findTriggerBlock(
workflow: SerializedWorkflow,
triggerBlockId?: string
): string | undefined {
if (triggerBlockId) {
const block = workflow.blocks.find((b) => b.id === triggerBlockId)
if (block) {
if (!block.enabled) {
logger.error('Provided triggerBlockId is disabled, finding alternative', {
triggerBlockId,
blockEnabled: block.enabled,
})
// Try to find an alternative enabled trigger instead of failing
const alternativeTrigger = this.findExplicitTrigger(workflow)
if (alternativeTrigger) {
logger.info('Using alternative enabled trigger', {
disabledTriggerId: triggerBlockId,
alternativeTriggerId: alternativeTrigger,
})
return alternativeTrigger
}
throw new Error(
`Trigger block ${triggerBlockId} is disabled and no alternative enabled trigger found`
)
}
return triggerBlockId
}
const fallbackTriggerId = this.resolveResumeTriggerFallback(triggerBlockId, workflow)
if (fallbackTriggerId) {
return fallbackTriggerId
}
logger.error('Provided triggerBlockId not found in workflow', {
triggerBlockId,
availableBlocks: workflow.blocks.map((b) => ({ id: b.id, type: b.metadata?.id })),
})
throw new Error(`Trigger block not found: ${triggerBlockId}`)
}
const explicitTrigger = this.findExplicitTrigger(workflow)
if (explicitTrigger) {
return explicitTrigger
}
const rootBlock = this.findRootBlock(workflow)
if (rootBlock) {
return rootBlock
}
return undefined
}
private findExplicitTrigger(workflow: SerializedWorkflow): string | undefined {
for (const block of workflow.blocks) {
if (block.enabled && this.isTriggerBlock(block)) {
return block.id
}
}
return undefined
}
private findRootBlock(workflow: SerializedWorkflow): string | undefined {
const hasIncoming = new Set(workflow.connections.map((c) => c.target))
for (const block of workflow.blocks) {
if (
!hasIncoming.has(block.id) &&
block.enabled &&
!isMetadataOnlyBlockType(block.metadata?.id)
) {
return block.id
}
}
return undefined
}
private isTriggerBlock(block: SerializedBlock): boolean {
return isTriggerBlockType(block.metadata?.id)
}
private getAllEnabledBlocks(workflow: SerializedWorkflow): Set<string> {
return new Set(workflow.blocks.filter((b) => b.enabled).map((b) => b.id))
}
private buildAdjacencyMap(workflow: SerializedWorkflow): Map<string, string[]> {
const adjacency = new Map<string, string[]>()
const enabledBlocks = new Set(workflow.blocks.filter((b) => b.enabled).map((b) => b.id))
for (const connection of workflow.connections) {
if (!enabledBlocks.has(connection.source) || !enabledBlocks.has(connection.target)) {
continue
}
const neighbors = adjacency.get(connection.source) ?? []
neighbors.push(connection.target)
adjacency.set(connection.source, neighbors)
}
return adjacency
}
private performBFS(triggerBlockId: string, adjacency: Map<string, string[]>): Set<string> {
const reachable = new Set<string>([triggerBlockId])
const queue = [triggerBlockId]
while (queue.length > 0) {
const currentBlockId = queue.shift()
if (!currentBlockId) break
const neighbors = adjacency.get(currentBlockId) ?? []
for (const neighborId of neighbors) {
if (!reachable.has(neighborId)) {
reachable.add(neighborId)
queue.push(neighborId)
}
}
}
return reachable
}
private resolveResumeTriggerFallback(
triggerBlockId: string,
workflow: SerializedWorkflow
): string | undefined {
if (!triggerBlockId.endsWith('__trigger')) {
return undefined
}
const baseId = triggerBlockId.replace(/__trigger$/, '')
const normalizedBaseId = extractBaseBlockId(baseId)
const candidates = baseId === normalizedBaseId ? [baseId] : [baseId, normalizedBaseId]
for (const candidate of candidates) {
const block = workflow.blocks.find((b) => b.id === candidate)
if (block) {
return candidate
}
}
return undefined
}
}
@@ -0,0 +1,38 @@
import type { BlockType, SentinelType } from '@/executor/constants'
import type { DAGNode } from '@/executor/dag/builder'
import type { SentinelSubflowType } from '@/executor/dag/types'
interface SubflowSentinelNodeConfig {
id: string
subflowId: string
subflowType: SentinelSubflowType
sentinelType: SentinelType
blockType: BlockType
name: string
}
export function createSubflowSentinelNode(config: SubflowSentinelNodeConfig): DAGNode {
return {
id: config.id,
block: {
id: config.id,
enabled: true,
position: { x: 0, y: 0 },
metadata: {
id: config.blockType,
name: config.name,
},
config: { tool: config.blockType, params: {} },
inputs: {},
outputs: {},
},
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {
isSentinel: true,
sentinelType: config.sentinelType,
subflowId: config.subflowId,
subflowType: config.subflowType,
},
}
}
+22
View File
@@ -0,0 +1,22 @@
export interface DAGEdge {
target: string
sourceHandle?: string
targetHandle?: string
}
export type SentinelSubflowType = 'loop' | 'parallel'
export interface NodeMetadata {
isParallelBranch?: boolean
branchIndex?: number
branchTotal?: number
distributionItem?: unknown
isLoopNode?: boolean
isSentinel?: boolean
sentinelType?: 'start' | 'end'
subflowType?: SentinelSubflowType
subflowId?: string
isPauseResponse?: boolean
isResumeTrigger?: boolean
originalBlockId?: string
}
@@ -0,0 +1,38 @@
import type { TraceSpan } from '@/lib/logs/types'
import type { ExecutionResult } from '@/executor/types'
interface ChildWorkflowErrorOptions {
message: string
childWorkflowName: string
childTraceSpans?: TraceSpan[]
executionResult?: ExecutionResult
childWorkflowSnapshotId?: string
childWorkflowInstanceId?: string
cause?: Error
}
/**
* Error raised when a child workflow execution fails.
*/
export class ChildWorkflowError extends Error {
readonly childTraceSpans: TraceSpan[]
readonly childWorkflowName: string
readonly executionResult?: ExecutionResult
readonly childWorkflowSnapshotId?: string
/** Per-invocation unique ID used to correlate child block events with this workflow block. */
readonly childWorkflowInstanceId?: string
constructor(options: ChildWorkflowErrorOptions) {
super(options.message, { cause: options.cause })
this.name = 'ChildWorkflowError'
this.childWorkflowName = options.childWorkflowName
this.childTraceSpans = options.childTraceSpans ?? []
this.executionResult = options.executionResult
this.childWorkflowSnapshotId = options.childWorkflowSnapshotId
this.childWorkflowInstanceId = options.childWorkflowInstanceId
}
static isChildWorkflowError(error: unknown): error is ChildWorkflowError {
return error instanceof ChildWorkflowError
}
}
@@ -0,0 +1,382 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { BlockType } from '@/executor/constants'
import type { DAGNode } from '@/executor/dag/builder'
import { BlockExecutor } from '@/executor/execution/block-executor'
import { ExecutionState } from '@/executor/execution/state'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
const { mockUploadFile } = vi.hoisted(() => ({
mockUploadFile: vi.fn(),
}))
vi.mock('@/ee/access-control/utils/permission-check', () => ({
validateBlockType: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
uploadFile: mockUploadFile,
},
}))
function createBlock(): SerializedBlock {
return {
id: 'function-block-1',
metadata: { id: BlockType.FUNCTION, name: 'Function' },
position: { x: 0, y: 0 },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
}
}
function createContext(state: ExecutionState): ExecutionContext {
return {
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
blockStates: state.getBlockStates(),
blockLogs: [],
metadata: { requestId: 'request-1', duration: 0 },
environmentVariables: {},
workflowVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
} as ExecutionContext
}
function createNode(block: SerializedBlock): DAGNode {
return {
id: block.id,
block,
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {},
}
}
describe('BlockExecutor', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
})
it('persists function output arrays as manifests in execution state', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = {
result: Array.from({ length: 120_000 }, (_, index) => ({
key: `SIM-${index}`,
payload: 'x'.repeat(100),
})),
}
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
await executor.execute(createContext(state), createNode(block), block)
const storedOutput = state.getBlockOutput(block.id)
expect(isLargeArrayManifest(storedOutput?.result)).toBe(true)
expect(storedOutput?.result).toMatchObject({
__simLargeArrayManifest: true,
kind: 'array',
totalCount: output.result.length,
})
})
it('persists stable outer-branch aliases for completed parallel branch outputs', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = { result: 'branch-2' }
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
const node = createNode(block)
node.id = 'function-block-1₍0₎'
node.metadata = {
isParallelBranch: true,
subflowId: 'parallel-1',
subflowType: 'parallel',
originalBlockId: block.id,
branchIndex: 2,
}
await executor.execute(createContext(state), node, block)
expect(state.getBlockOutput('function-block-1__obranch-2')).toEqual(output)
expect(state.getBlockOutput('function-block-1₍2₎')).toEqual(output)
expect(state.getBlockOutput('function-block-1₍0₎')).toEqual(output)
})
it('does not write global aliases for parallel branches inside cloned outer branches', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = { result: 'outer-2-inner-0' }
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
const node = createNode(block)
node.id = 'function-block-1__cloneabc__obranch-2₍0₎'
node.metadata = {
isParallelBranch: true,
subflowId: 'inner-parallel',
subflowType: 'parallel',
originalBlockId: block.id,
branchIndex: 0,
}
await executor.execute(createContext(state), node, block)
expect(state.getBlockOutput(node.id)).toEqual(output)
expect(state.getBlockOutput('function-block-1__obranch-0')).toBeUndefined()
expect(state.getBlockOutput('function-block-1₍0₎')).toBeUndefined()
})
it('does not let block completion callbacks overtake pending start callbacks', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = { result: 'done' }
const execute = vi.fn(async () => {
events.push('execute')
return output
})
const handler: BlockHandler = {
canHandle: () => true,
execute,
}
const events: string[] = []
let resolveStart!: () => void
const startGate = new Promise<void>((resolve) => {
resolveStart = resolve
})
const onBlockStart = vi.fn(async () => {
events.push('start-called')
await startGate
events.push('start-done')
})
const onBlockComplete = vi.fn(async () => {
events.push('complete')
})
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
onBlockStart,
onBlockComplete,
},
state
)
const execution = executor.execute(createContext(state), createNode(block), block)
expect(onBlockStart).toHaveBeenCalled()
expect(execute).not.toHaveBeenCalled()
expect(onBlockComplete).not.toHaveBeenCalled()
resolveStart()
await execution
await vi.waitFor(() => {
expect(onBlockComplete).toHaveBeenCalled()
})
expect(events).toEqual(['start-called', 'start-done', 'execute', 'complete'])
})
it('fires block completion callbacks for pausing blocks so clients receive pause output', async () => {
const block = {
...createBlock(),
id: 'hitl-block-1',
metadata: { id: BlockType.HUMAN_IN_THE_LOOP, name: 'Human in the Loop' },
config: { tool: BlockType.HUMAN_IN_THE_LOOP, params: {} },
}
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = {
response: { status: 'paused' },
_pauseMetadata: {
contextId: 'pause-context-1',
blockId: block.id,
response: { status: 'paused' },
timestamp: new Date().toISOString(),
pauseKind: 'human' as const,
},
}
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const onBlockStart = vi.fn(async () => {})
const onBlockComplete = vi.fn(async () => {})
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
onBlockStart,
onBlockComplete,
},
state
)
await executor.execute(createContext(state), createNode(block), block)
expect(onBlockStart).toHaveBeenCalled()
expect(onBlockComplete).toHaveBeenCalledWith(
block.id,
'Human in the Loop',
BlockType.HUMAN_IN_THE_LOOP,
expect.objectContaining({
output: expect.objectContaining({
response: { status: 'paused' },
}),
}),
undefined,
undefined
)
expect(state.getBlockOutput(block.id)).toEqual(output)
})
})
@@ -0,0 +1,931 @@
import { createLogger, type Logger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { normalizeStringArray } from '@/lib/core/utils/arrays'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import { redactLargeValueRefsInValue } from '@/lib/logs/execution/pii-large-values'
import { redactObjectStrings } from '@/lib/logs/execution/pii-redaction'
import {
containsUserFileWithMetadata,
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
import { sanitizeInputFormat, sanitizeTools } from '@/lib/workflows/comparison/normalize'
import { isCustomBlockType } from '@/blocks/custom/build-config'
import { validateBlockType } from '@/ee/access-control/utils/permission-check'
import {
BlockType,
buildResumeApiUrl,
buildResumeUiUrl,
DEFAULTS,
EDGE,
isSentinelBlockType,
} from '@/executor/constants'
import type { DAGNode } from '@/executor/dag/builder'
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
import type {
BlockStateWriter,
ContextExtensions,
WorkflowNodeMetadata,
} from '@/executor/execution/types'
import {
generatePauseContextId,
mapNodeMetadataToPauseScopes,
} from '@/executor/human-in-the-loop/utils.ts'
import {
type BlockHandler,
type BlockLog,
type BlockState,
type ExecutionContext,
getNextExecutionOrder,
type NormalizedBlockOutput,
type StreamingExecution,
} from '@/executor/types'
import { streamingResponseFormatProcessor } from '@/executor/utils'
import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors'
import {
buildUnifiedParentIterations,
getIterationContext,
} from '@/executor/utils/iteration-context'
import { isJSONString } from '@/executor/utils/json'
import { filterOutputForLog } from '@/executor/utils/output-filter'
import {
buildBranchNodeId,
buildOuterBranchScopedId,
extractOuterBranchIndex,
} from '@/executor/utils/subflow-utils'
import {
FUNCTION_BLOCK_CONTEXT_VARS_KEY,
FUNCTION_BLOCK_DISPLAY_CODE_KEY,
type VariableResolver,
} from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
const logger = createLogger('BlockExecutor')
export class BlockExecutor {
private execLogger: Logger
constructor(
private blockHandlers: BlockHandler[],
private resolver: VariableResolver,
private contextExtensions: ContextExtensions,
private state: BlockStateWriter
) {
this.execLogger = logger.withMetadata({
workflowId: this.contextExtensions.metadata?.workflowId,
workspaceId: this.contextExtensions.workspaceId,
executionId: this.contextExtensions.executionId,
userId: this.contextExtensions.userId,
requestId: this.contextExtensions.metadata?.requestId,
})
}
async execute(
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock
): Promise<NormalizedBlockOutput> {
const handler = this.findHandler(block)
if (!handler) {
throw buildBlockExecutionError({
block,
context: ctx,
error: `No handler found for block type: ${block.metadata?.id ?? 'unknown'}`,
})
}
const blockType = block.metadata?.id ?? ''
const isSentinel = isSentinelBlockType(blockType)
// Capture startedAt and startTime at the same synchronous instant so
// blockLog.startedAt and performance.now()-derived durationMs share a
// single reference point. Any executor work below counts toward this block.
const startedAt = new Date().toISOString()
const startTime = performance.now()
let blockLog: BlockLog | undefined
let blockStartPromise: Promise<void> | undefined
if (!isSentinel) {
blockLog = this.createBlockLog(ctx, node.id, block, node, startedAt)
ctx.blockLogs.push(blockLog)
blockStartPromise = this.fireBlockStartCallback(ctx, node, block, blockLog.executionOrder)
await blockStartPromise
}
let resolvedInputs: Record<string, any> = {}
let inputsForLog: Record<string, any> = {}
const nodeMetadata = {
...this.buildNodeMetadata(node),
executionOrder: blockLog?.executionOrder,
}
let cleanupSelfReference: (() => void) | undefined
if (block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP) {
cleanupSelfReference = this.preparePauseResumeSelfReference(ctx, node, block, nodeMetadata)
}
try {
if (!isSentinel && blockType) {
await validateBlockType(ctx.userId, ctx.workspaceId, blockType, ctx)
}
if (block.metadata?.id === BlockType.FUNCTION) {
const {
resolvedInputs: fnInputs,
displayInputs,
contextVariables,
} = await this.resolver.resolveInputsForFunctionBlock(
ctx,
node.id,
block.config.params,
block
)
resolvedInputs = {
...fnInputs,
[FUNCTION_BLOCK_CONTEXT_VARS_KEY]: contextVariables,
...(displayInputs.code !== undefined
? { [FUNCTION_BLOCK_DISPLAY_CODE_KEY]: displayInputs.code }
: {}),
}
inputsForLog = displayInputs
} else {
resolvedInputs = await this.resolver.resolveInputs(ctx, node.id, block.config.params, block)
inputsForLog = resolvedInputs
}
if (blockLog) {
blockLog.input = this.sanitizeInputsForLog(inputsForLog, block.metadata?.id)
}
} catch (error) {
cleanupSelfReference?.()
return await this.handleBlockError(
error,
ctx,
node,
block,
blockStartPromise,
startTime,
blockLog,
inputsForLog,
isSentinel,
'input_resolution'
)
}
cleanupSelfReference?.()
try {
const output = handler.executeWithNode
? await handler.executeWithNode(ctx, block, resolvedInputs, nodeMetadata)
: await handler.execute(ctx, block, resolvedInputs)
const isStreamingExecution =
output && typeof output === 'object' && 'stream' in output && 'execution' in output
let normalizedOutput: NormalizedBlockOutput
if (isStreamingExecution) {
const streamingExec = output as StreamingExecution
// The stream must still be drained to populate `execution.output`, but
// forwarding raw chunks to the client (or persisting them to memory)
// before redaction would leak PII. When block-output redaction is on we
// drain in buffer-only mode (no `onStream`, content masked before it's
// stored); the masked final output reaches the client via block-complete.
if (ctx.onStream) {
await this.handleStreamingExecution(
ctx,
node,
block,
streamingExec,
resolvedInputs,
normalizeStringArray(ctx.selectedOutputs),
!ctx.piiBlockOutputRedaction?.enabled
)
}
normalizedOutput = this.normalizeOutput(
streamingExec.execution.output ?? streamingExec.execution
)
} else {
normalizedOutput = this.normalizeOutput(output)
}
if (ctx.includeFileBase64 === true && containsUserFileWithMetadata(normalizedOutput)) {
normalizedOutput = (await hydrateUserFilesWithBase64(normalizedOutput, {
requestId: ctx.metadata.requestId,
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: ctx.base64MaxBytes,
preserveLargeValueMetadata: true,
})) as NormalizedBlockOutput
}
if (ctx.piiBlockOutputRedaction?.enabled) {
// In-flight redaction before the log/state split below, so both the
// downstream state copy and the persisted log copy are masked.
// `onFailure: 'throw'` aborts the run rather than feeding corrupted/leaked
// data downstream.
const redactionOptions = {
entityTypes: ctx.piiBlockOutputRedaction.entityTypes,
language: ctx.piiBlockOutputRedaction.language,
onFailure: 'throw' as const,
}
// Tools like the function executor offload large outputs to large-value
// refs BEFORE they reach here, and the string walk treats a ref as opaque.
// So hydrate → mask → re-store any refs first, then mask inline strings —
// otherwise PII inside an offloaded output is never redacted.
normalizedOutput = await redactLargeValueRefsInValue(normalizedOutput, {
...redactionOptions,
store: {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
userId: ctx.userId,
},
})
normalizedOutput = await redactObjectStrings(normalizedOutput, redactionOptions)
}
normalizedOutput = (await compactExecutionPayload(normalizedOutput, {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
userId: ctx.userId,
preserveUserFileBase64: ctx.includeFileBase64 === true,
requireDurable: true,
})) as NormalizedBlockOutput
const endedAt = new Date().toISOString()
const duration = performance.now() - startTime
if (blockLog) {
blockLog.endedAt = endedAt
blockLog.durationMs = duration
blockLog.success = true
blockLog.output = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block })
if (normalizedOutput.childTraceSpans && Array.isArray(normalizedOutput.childTraceSpans)) {
blockLog.childTraceSpans = normalizedOutput.childTraceSpans
}
}
const { childTraceSpans: _traces, ...outputForState } = normalizedOutput
this.setNodeOutput(node, outputForState as NormalizedBlockOutput, duration)
if (!isSentinel && blockLog) {
const childWorkflowInstanceId =
typeof normalizedOutput._childWorkflowInstanceId === 'string'
? normalizedOutput._childWorkflowInstanceId
: undefined
const displayOutput = filterOutputForLog(block.metadata?.id || '', normalizedOutput, {
block,
})
this.fireBlockCompleteCallback(
blockStartPromise,
ctx,
node,
block,
this.sanitizeInputsForLog(inputsForLog, block.metadata?.id),
displayOutput,
duration,
blockLog.startedAt,
blockLog.executionOrder,
blockLog.endedAt,
childWorkflowInstanceId
)
}
return outputForState as NormalizedBlockOutput
} catch (error) {
return await this.handleBlockError(
error,
ctx,
node,
block,
blockStartPromise,
startTime,
blockLog,
inputsForLog,
isSentinel,
'execution'
)
}
}
private buildNodeMetadata(node: DAGNode): WorkflowNodeMetadata {
const metadata = node?.metadata ?? {}
return {
nodeId: node.id,
loopId: metadata.subflowType === 'loop' ? metadata.subflowId : undefined,
parallelId: metadata.subflowType === 'parallel' ? metadata.subflowId : undefined,
subflowId: metadata.subflowId,
subflowType: metadata.subflowType,
branchIndex: metadata.branchIndex,
branchTotal: metadata.branchTotal,
originalBlockId: metadata.originalBlockId,
isLoopNode: metadata.isLoopNode,
}
}
private setNodeOutput(node: DAGNode, output: NormalizedBlockOutput, duration = 0): void {
this.state.setBlockOutput(node.id, output, duration)
const originalBlockId = node.metadata.originalBlockId
const branchIndex = node.metadata.branchIndex
if (
node.metadata.isParallelBranch &&
originalBlockId &&
branchIndex !== undefined &&
extractOuterBranchIndex(node.id) === undefined
) {
const globalBranchNodeId = buildBranchNodeId(originalBlockId, branchIndex)
if (globalBranchNodeId !== node.id) {
this.state.setBlockOutput(globalBranchNodeId, output, duration)
}
this.state.setBlockOutput(
buildOuterBranchScopedId(originalBlockId, branchIndex),
output,
duration
)
}
}
private findHandler(block: SerializedBlock): BlockHandler | undefined {
return this.blockHandlers.find((h) => h.canHandle(block))
}
private async handleBlockError(
error: unknown,
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock,
blockStartPromise: Promise<void> | undefined,
startTime: number,
blockLog: BlockLog | undefined,
inputsForLog: Record<string, any>,
isSentinel: boolean,
phase: 'input_resolution' | 'execution'
): Promise<NormalizedBlockOutput> {
const endedAt = new Date().toISOString()
const duration = performance.now() - startTime
const errorMessage = normalizeError(error)
const hasLogInputs =
inputsForLog && typeof inputsForLog === 'object' && Object.keys(inputsForLog).length > 0
const input = hasLogInputs
? inputsForLog
: ((block.config?.params as Record<string, any> | undefined) ?? {})
const errorOutput: NormalizedBlockOutput = {
error: errorMessage,
}
if (ChildWorkflowError.isChildWorkflowError(error)) {
errorOutput.childWorkflowName = error.childWorkflowName
if (error.childWorkflowSnapshotId) {
errorOutput.childWorkflowSnapshotId = error.childWorkflowSnapshotId
}
}
this.setNodeOutput(node, errorOutput, duration)
if (blockLog) {
blockLog.endedAt = endedAt
blockLog.durationMs = duration
blockLog.success = false
blockLog.error = errorMessage
blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id)
blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
if (ChildWorkflowError.isChildWorkflowError(error) && error.childTraceSpans.length > 0) {
blockLog.childTraceSpans = error.childTraceSpans
}
}
this.execLogger.error(
phase === 'input_resolution' ? 'Failed to resolve block inputs' : 'Block execution failed',
{
blockId: node.id,
blockType: block.metadata?.id,
error: errorMessage,
}
)
if (!isSentinel && blockLog) {
const childWorkflowInstanceId = ChildWorkflowError.isChildWorkflowError(error)
? error.childWorkflowInstanceId
: undefined
const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
this.fireBlockCompleteCallback(
blockStartPromise,
ctx,
node,
block,
this.sanitizeInputsForLog(input, block.metadata?.id),
displayOutput,
duration,
blockLog.startedAt,
blockLog.executionOrder,
blockLog.endedAt,
childWorkflowInstanceId
)
}
const hasErrorPort = this.hasErrorPortEdge(node)
if (hasErrorPort) {
if (blockLog) {
blockLog.errorHandled = true
}
this.execLogger.info('Block has error port - returning error output instead of throwing', {
blockId: node.id,
error: errorMessage,
})
return errorOutput
}
const errorToThrow = error instanceof Error ? error : new Error(errorMessage)
throw buildBlockExecutionError({
block,
error: errorToThrow,
context: ctx,
additionalInfo: {
nodeId: node.id,
executionTime: duration,
},
})
}
private hasErrorPortEdge(node: DAGNode): boolean {
for (const [_, edge] of node.outgoingEdges) {
if (edge.sourceHandle === EDGE.ERROR) {
return true
}
}
return false
}
private createBlockLog(
ctx: ExecutionContext,
blockId: string,
block: SerializedBlock,
node: DAGNode,
startedAt: string
): BlockLog {
let blockName = block.metadata?.name ?? blockId
let loopId: string | undefined
let parallelId: string | undefined
let iterationIndex: number | undefined
if (node?.metadata) {
if (
node.metadata.branchIndex !== undefined &&
node.metadata.subflowType === 'parallel' &&
node.metadata.subflowId
) {
blockName = `${blockName} (iteration ${node.metadata.branchIndex})`
iterationIndex = node.metadata.branchIndex
parallelId = node.metadata.subflowId
} else if (
node.metadata.isLoopNode &&
node.metadata.subflowType === 'loop' &&
node.metadata.subflowId
) {
loopId = node.metadata.subflowId
const loopScope = ctx.loopExecutions?.get(loopId)
if (loopScope && loopScope.iteration !== undefined) {
blockName = `${blockName} (iteration ${loopScope.iteration})`
iterationIndex = loopScope.iteration
} else {
this.execLogger.warn('Loop scope not found for block', { blockId, loopId })
}
}
}
const containerId = parallelId ?? loopId
const parentIterations = containerId
? buildUnifiedParentIterations(ctx, containerId)
: undefined
return {
blockId,
blockName,
blockType: block.metadata?.id ?? DEFAULTS.BLOCK_TYPE,
startedAt,
executionOrder: getNextExecutionOrder(ctx),
endedAt: '',
durationMs: 0,
success: false,
loopId,
parallelId,
iterationIndex,
...(parentIterations?.length && { parentIterations }),
}
}
private normalizeOutput(output: unknown): NormalizedBlockOutput {
if (output === null || output === undefined) {
return {}
}
if (typeof output === 'object' && !Array.isArray(output)) {
return output as NormalizedBlockOutput
}
return { result: output }
}
/**
* Sanitizes inputs for log display.
* - Filters out system fields (UI-only, readonly, internal flags)
* - Removes UI state from inputFormat items (e.g., collapsed)
* - Parses JSON strings to objects for readability
* - Redacts sensitive fields (privateKey, password, tokens, etc.)
* Returns a new object - does not mutate the original inputs.
*/
private sanitizeInputsForLog(
inputs: Record<string, any>,
blockType?: string
): Record<string, any> {
// Custom (deploy-as-block) blocks run via an internal `workflow_executor`; the
// baked `workflowId`/`inputMapping` wrapper is plumbing. Log the mapped input
// field values (the inputMapping contents) instead.
if (isCustomBlockType(blockType)) {
const mapping = inputs.inputMapping
const parsed =
typeof mapping === 'string'
? (() => {
try {
return JSON.parse(mapping)
} catch {
return {}
}
})()
: mapping
inputs = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
}
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(inputs)) {
if (
SYSTEM_SUBBLOCK_IDS.includes(key) ||
key === 'triggerMode' ||
key === FUNCTION_BLOCK_CONTEXT_VARS_KEY ||
key === FUNCTION_BLOCK_DISPLAY_CODE_KEY
) {
continue
}
if (key === 'inputFormat' && Array.isArray(value)) {
result[key] = sanitizeInputFormat(value)
continue
}
if (key === 'tools' && Array.isArray(value)) {
result[key] = sanitizeTools(value)
continue
}
// isJSONString is a quick heuristic (checks for { or [), not a validator.
// Invalid JSON is safely caught below - this just avoids JSON.parse on every string.
if (typeof value === 'string' && isJSONString(value)) {
try {
result[key] = JSON.parse(value.trim())
} catch {
// Not valid JSON, keep original string
result[key] = value
}
} else {
result[key] = value
}
}
return redactApiKeys(result)
}
/**
* Fires the `onBlockStart` progress callback before block execution continues.
* Returning the promise lets completion callbacks preserve lifecycle ordering.
*/
private fireBlockStartCallback(
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock,
executionOrder: number
): Promise<void> | undefined {
if (!this.contextExtensions.onBlockStart) return undefined
const blockId = node.metadata?.originalBlockId ?? node.id
const blockName = block.metadata?.name ?? blockId
const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE
const iterationContext = getIterationContext(ctx, node?.metadata)
return this.contextExtensions
.onBlockStart(
blockId,
blockName,
blockType,
executionOrder,
iterationContext,
ctx.childWorkflowContext
)
.catch((error) => {
this.execLogger.warn('Block start callback failed', {
blockId,
blockType,
error: toError(error).message,
})
})
}
/**
* Fires the `onBlockComplete` progress callback without blocking subsequent blocks.
* Completion is chained behind the matching start callback so SSE/log consumers
* never observe `block:completed` before `block:started` for the same execution.
*/
private fireBlockCompleteCallback(
blockStartPromise: Promise<void> | undefined,
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock,
input: Record<string, any>,
output: NormalizedBlockOutput,
duration: number,
startedAt: string,
executionOrder: number,
endedAt: string,
childWorkflowInstanceId?: string
): void {
if (!this.contextExtensions.onBlockComplete) return
const blockId = node.metadata?.originalBlockId ?? node.id
const blockName = block.metadata?.name ?? blockId
const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE
const iterationContext = getIterationContext(ctx, node?.metadata)
void (async () => {
await blockStartPromise
await this.contextExtensions.onBlockComplete?.(
blockId,
blockName,
blockType,
{
input,
output,
executionTime: duration,
startedAt,
executionOrder,
endedAt,
childWorkflowInstanceId,
},
iterationContext,
ctx.childWorkflowContext
)
})().catch((error) => {
this.execLogger.warn('Block completion callback failed', {
blockId,
blockType,
error: toError(error).message,
})
})
}
private preparePauseResumeSelfReference(
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock,
nodeMetadata: {
nodeId: string
loopId?: string
parallelId?: string
branchIndex?: number
branchTotal?: number
}
): (() => void) | undefined {
const blockId = node.id
const existingState = ctx.blockStates.get(blockId)
if (existingState?.executed) {
return undefined
}
const executionId = ctx.executionId ?? ctx.metadata?.executionId
const workflowId = ctx.workflowId
if (!executionId || !workflowId) {
return undefined
}
const { loopScope } = mapNodeMetadataToPauseScopes(ctx, nodeMetadata)
const contextId = generatePauseContextId(block.id, nodeMetadata, loopScope)
let resumeLinks: { apiUrl: string; uiUrl: string }
try {
const baseUrl = getBaseUrl()
resumeLinks = {
apiUrl: buildResumeApiUrl(baseUrl, workflowId, executionId, contextId),
uiUrl: buildResumeUiUrl(baseUrl, workflowId, executionId),
}
} catch {
resumeLinks = {
apiUrl: buildResumeApiUrl(undefined, workflowId, executionId, contextId),
uiUrl: buildResumeUiUrl(undefined, workflowId, executionId),
}
}
let previousState: BlockState | undefined
if (existingState) {
previousState = { ...existingState }
}
const hadPrevious = existingState !== undefined
const placeholderState: BlockState = {
output: {
url: resumeLinks.uiUrl,
resumeEndpoint: resumeLinks.apiUrl,
},
executed: false,
executionTime: existingState?.executionTime ?? 0,
}
this.state.setBlockState(blockId, placeholderState)
return () => {
if (hadPrevious && previousState) {
this.state.setBlockState(blockId, previousState)
} else {
this.state.deleteBlockState(blockId)
}
}
}
private async handleStreamingExecution(
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock,
streamingExec: StreamingExecution,
resolvedInputs: Record<string, any>,
selectedOutputs: string[],
forwardToClient = true
): Promise<void> {
const blockId = node.id
const responseFormat =
resolvedInputs?.responseFormat ??
(block.config?.params as Record<string, any> | undefined)?.responseFormat ??
(block.config as Record<string, any> | undefined)?.responseFormat
const sourceReader = streamingExec.stream.getReader()
const decoder = new TextDecoder()
const accumulated: string[] = []
let drainError: unknown
let sourceFullyDrained = false
if (forwardToClient) {
const clientSource = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await sourceReader.read()
if (done) {
const tail = decoder.decode()
if (tail) accumulated.push(tail)
sourceFullyDrained = true
controller.close()
return
}
accumulated.push(decoder.decode(value, { stream: true }))
controller.enqueue(value)
} catch (error) {
drainError = error
controller.error(error)
}
},
async cancel(reason) {
try {
await sourceReader.cancel(reason)
} catch {}
},
})
const processedClientStream = streamingResponseFormatProcessor.processStream(
clientSource,
blockId,
selectedOutputs,
responseFormat
)
try {
await ctx.onStream?.({
stream: processedClientStream,
execution: streamingExec.execution,
})
} catch (error) {
this.execLogger.error('Error in onStream callback', { blockId, error })
await processedClientStream.cancel().catch(() => {})
} finally {
try {
sourceReader.releaseLock()
} catch {}
}
} else {
// Buffer-only drain: consume the source so `execution.output` is complete,
// but never forward raw chunks to the client (block-output redaction is on).
try {
while (true) {
const { done, value } = await sourceReader.read()
if (done) {
const tail = decoder.decode()
if (tail) accumulated.push(tail)
sourceFullyDrained = true
break
}
accumulated.push(decoder.decode(value, { stream: true }))
}
} catch (error) {
drainError = error
} finally {
try {
sourceReader.releaseLock()
} catch {}
}
}
if (drainError) {
this.execLogger.error('Error reading stream for block', { blockId, error: drainError })
return
}
// If the onStream consumer exited before the source drained (e.g. it caught
// an internal error and returned normally), `accumulated` holds a truncated
// response. Persisting that to memory or setting it as the block output
// would corrupt downstream state — skip and log instead.
if (!sourceFullyDrained) {
this.execLogger.warn(
'Stream consumer exited before source drained; skipping content persistence',
{
blockId,
}
)
return
}
let fullContent = accumulated.join('')
if (!fullContent) {
return
}
if (!forwardToClient && ctx.piiBlockOutputRedaction?.enabled) {
// Mask before the content is written to `execution.output` or persisted to
// memory via `onFullContent`, so the streamed agent response can't leak PII
// through either path. The block-output redaction below is then idempotent.
fullContent = await redactObjectStrings(fullContent, {
entityTypes: ctx.piiBlockOutputRedaction.entityTypes,
language: ctx.piiBlockOutputRedaction.language,
onFailure: 'throw',
})
}
const executionOutput = streamingExec.execution?.output
if (executionOutput && typeof executionOutput === 'object') {
let parsedForFormat = false
if (responseFormat) {
try {
const parsed = JSON.parse(fullContent.trim())
streamingExec.execution.output = {
...parsed,
tokens: executionOutput.tokens,
toolCalls: executionOutput.toolCalls,
providerTiming: executionOutput.providerTiming,
cost: executionOutput.cost,
model: executionOutput.model,
}
parsedForFormat = true
} catch (error) {
this.execLogger.warn('Failed to parse streamed content for response format', {
blockId,
error,
})
}
}
if (!parsedForFormat) {
executionOutput.content = fullContent
}
}
if (streamingExec.onFullContent) {
try {
await streamingExec.onFullContent(fullContent)
} catch (error) {
this.execLogger.error('onFullContent callback failed', { blockId, error })
}
}
}
}
File diff suppressed because it is too large Load Diff
+431
View File
@@ -0,0 +1,431 @@
import { createLogger } from '@sim/logger'
import { CONTROL_BACK_EDGE_HANDLES, EDGE, SUBFLOW_CONTROL_EDGE_HANDLES } from '@/executor/constants'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import type { DAGEdge } from '@/executor/dag/types'
import type { NormalizedBlockOutput } from '@/executor/types'
const logger = createLogger('EdgeManager')
export class EdgeManager {
private deactivatedEdges = new Set<string>()
private nodesWithActivatedEdge = new Set<string>()
constructor(private dag: DAG) {}
processOutgoingEdges(
node: DAGNode,
output: NormalizedBlockOutput,
skipBackwardsEdge = false
): string[] {
const readyNodes: string[] = []
const activatedTargets: string[] = []
const edgesToDeactivate: Array<{ target: string; handle?: string }> = []
for (const [, edge] of node.outgoingEdges) {
if (skipBackwardsEdge && this.isBackwardsEdge(edge.sourceHandle)) {
continue
}
if (!this.shouldActivateEdge(edge, output)) {
if (!this.isSubflowControlEdge(edge.sourceHandle)) {
edgesToDeactivate.push({ target: edge.target, handle: edge.sourceHandle })
}
continue
}
activatedTargets.push(edge.target)
}
// Track nodes that have received at least one activated edge
for (const targetId of activatedTargets) {
this.nodesWithActivatedEdge.add(targetId)
}
const cascadeTargets = new Set<string>()
for (const { target, handle } of edgesToDeactivate) {
this.deactivateEdgeAndDescendants(node.id, target, handle, cascadeTargets)
}
if (activatedTargets.length === 0) {
for (const { target } of edgesToDeactivate) {
if (this.isTerminalControlNode(target)) {
cascadeTargets.add(target)
}
}
}
for (const targetId of activatedTargets) {
const targetNode = this.dag.nodes.get(targetId)
if (!targetNode) {
logger.warn('Target node not found', { target: targetId })
continue
}
targetNode.incomingEdges.delete(node.id)
}
for (const targetId of activatedTargets) {
if (this.isTargetReady(targetId)) {
readyNodes.push(targetId)
}
}
const isDeadEnd = activatedTargets.length === 0
const isRoutedDeadEnd = isDeadEnd && !!(output.selectedOption || output.selectedRoute)
for (const targetId of cascadeTargets) {
if (!readyNodes.includes(targetId) && !activatedTargets.includes(targetId)) {
if (!isDeadEnd || !this.isTargetReady(targetId)) continue
if (isRoutedDeadEnd) {
// A condition/router deliberately selected a dead-end path.
// Only queue the sentinel if it belongs to the SAME subflow as the
// current node (the condition is inside the loop/parallel and the
// loop still needs to continue/exit). Downstream subflow sentinels
// should NOT fire.
if (this.isEnclosingSentinel(node, targetId)) {
readyNodes.push(targetId)
}
} else {
readyNodes.push(targetId)
}
}
}
if (output.selectedRoute !== EDGE.LOOP_EXIT && output.selectedRoute !== EDGE.PARALLEL_EXIT) {
for (const { target } of edgesToDeactivate) {
if (
!readyNodes.includes(target) &&
!activatedTargets.includes(target) &&
this.nodesWithActivatedEdge.has(target) &&
this.isTargetReady(target)
) {
readyNodes.push(target)
}
}
}
return readyNodes
}
isNodeReady(node: DAGNode): boolean {
return node.incomingEdges.size === 0 || this.countActiveIncomingEdges(node) === 0
}
restoreIncomingEdge(targetNodeId: string, sourceNodeId: string): void {
const targetNode = this.dag.nodes.get(targetNodeId)
if (!targetNode) {
logger.warn('Cannot restore edge - target node not found', { targetNodeId })
return
}
targetNode.incomingEdges.add(sourceNodeId)
}
clearDeactivatedEdges(): void {
this.deactivatedEdges.clear()
this.nodesWithActivatedEdge.clear()
}
getDeactivatedEdges(): string[] {
return Array.from(this.deactivatedEdges)
}
getNodesWithActivatedEdge(): string[] {
return Array.from(this.nodesWithActivatedEdge)
}
hasActivatedEdge(nodeId: string): boolean {
return this.nodesWithActivatedEdge.has(nodeId)
}
restoreDeactivatedEdges(edgeKeys?: string[], activatedNodeIds?: string[]): void {
this.deactivatedEdges = new Set(
(edgeKeys ?? []).map((edgeKey) => this.normalizeSerializedEdgeKey(edgeKey))
)
this.nodesWithActivatedEdge = new Set(activatedNodeIds ?? [])
}
markNodeWithActivatedEdge(nodeId: string): void {
this.nodesWithActivatedEdge.add(nodeId)
}
/**
* Deactivates the `error` edge of a successfully-resumed pause block instead of
* firing it: the block completed normally, so its error path is pruned (and any
* now-dead descendants cascaded), mirroring how a normally-succeeding block's
* error edge is handled in {@link processOutgoingEdges}.
*
* `cascadeTargets` is intentionally left undefined here (unlike the
* {@link processOutgoingEdges} call site, which passes an explicit Set): the
* resume path has no loop/parallel sentinels to queue — the pause block's
* `source` edge drives continuation — so cascade-target collection is omitted.
*/
deactivateResumedEdge(sourceId: string, targetId: string, sourceHandle?: string): void {
this.deactivateEdgeAndDescendants(sourceId, targetId, sourceHandle)
}
/**
* Clear deactivated edges for a set of nodes (used when restoring loop state for next iteration).
*
* Only clears edges whose SOURCE is in the provided set. Edges pointing INTO a node in the set
* whose source lives outside (e.g. an external branch whose path was cascade-deactivated) must
* remain deactivated — otherwise `countActiveIncomingEdges` would count a source that will never
* fire again, stalling the loop on its next iteration.
*
* Deactivated edge keys encode the source separately so node IDs with shared prefixes
* cannot clear each other's deactivated edges.
*/
clearDeactivatedEdgesForNodes(nodeIds: Set<string>): void {
const edgesToRemove: string[] = []
for (const edgeKey of this.deactivatedEdges) {
const sourceId = this.parseEdgeKey(edgeKey)?.sourceId
if (!sourceId) continue
for (const nodeId of nodeIds) {
if (sourceId === nodeId) {
edgesToRemove.push(edgeKey)
break
}
}
}
for (const edgeKey of edgesToRemove) {
this.deactivatedEdges.delete(edgeKey)
}
for (const nodeId of nodeIds) {
this.nodesWithActivatedEdge.delete(nodeId)
}
}
private isTargetReady(targetId: string): boolean {
const targetNode = this.dag.nodes.get(targetId)
return targetNode ? this.isNodeReady(targetNode) : false
}
/**
* Checks if the cascade target sentinel belongs to the same subflow as the source node.
* A condition inside a loop that hits a dead-end should still allow the enclosing
* loop's sentinel to fire so the loop can continue or exit.
*/
private isEnclosingSentinel(sourceNode: DAGNode, sentinelId: string): boolean {
const sentinel = this.dag.nodes.get(sentinelId)
if (!sentinel?.metadata.isSentinel) return false
const sourceSubflowType = sourceNode.metadata.subflowType
const sentinelSubflowType = sentinel.metadata.subflowType
const sourceSubflowId = sourceNode.metadata.subflowId
const sentinelSubflowId = sentinel.metadata.subflowId
if (
sourceSubflowType &&
sentinelSubflowType &&
sourceSubflowType === sentinelSubflowType &&
sourceSubflowId &&
sentinelSubflowId &&
sourceSubflowId === sentinelSubflowId
) {
return true
}
return false
}
private isSubflowControlEdge(handle?: string): boolean {
return handle !== undefined && SUBFLOW_CONTROL_EDGE_HANDLES.has(handle)
}
private isBackwardsEdge(sourceHandle?: string): boolean {
return sourceHandle !== undefined && CONTROL_BACK_EDGE_HANDLES.has(sourceHandle)
}
private isTerminalControlNode(nodeId: string): boolean {
const node = this.dag.nodes.get(nodeId)
if (!node || node.outgoingEdges.size === 0) return false
for (const [, edge] of node.outgoingEdges) {
if (!this.isSubflowControlEdge(edge.sourceHandle)) {
return false
}
}
return true
}
private shouldActivateEdge(edge: DAGEdge, output: NormalizedBlockOutput): boolean {
const handle = edge.sourceHandle
if (output.selectedRoute === EDGE.LOOP_EXIT) {
return handle === EDGE.LOOP_EXIT
}
if (output.selectedRoute === EDGE.LOOP_CONTINUE) {
return handle === EDGE.LOOP_CONTINUE || handle === EDGE.LOOP_CONTINUE_ALT
}
if (output.selectedRoute === EDGE.PARALLEL_EXIT) {
return handle === EDGE.PARALLEL_EXIT
}
if (output.selectedRoute === EDGE.PARALLEL_CONTINUE) {
return handle === EDGE.PARALLEL_CONTINUE
}
if (this.isSubflowControlEdge(handle)) {
return false
}
if (!handle) {
return true
}
if (handle.startsWith(EDGE.CONDITION_PREFIX)) {
const conditionValue = handle.substring(EDGE.CONDITION_PREFIX.length)
return output.selectedOption === conditionValue
}
if (handle.startsWith(EDGE.ROUTER_PREFIX)) {
const routeId = handle.substring(EDGE.ROUTER_PREFIX.length)
return output.selectedRoute === routeId
}
switch (handle) {
case EDGE.ERROR:
return !!output.error
case EDGE.SOURCE:
return !output.error
default:
return true
}
}
private deactivateEdgeAndDescendants(
sourceId: string,
targetId: string,
sourceHandle?: string,
cascadeTargets?: Set<string>,
isCascade = false
): void {
const edgeKey = this.createEdgeKey(sourceId, targetId, sourceHandle)
if (this.deactivatedEdges.has(edgeKey)) {
return
}
this.deactivatedEdges.add(edgeKey)
const targetNode = this.dag.nodes.get(targetId)
if (!targetNode) return
if (isCascade && this.isTerminalControlNode(targetId)) {
cascadeTargets?.add(targetId)
}
// Don't cascade if node has active incoming edges OR has received an activated edge
if (
this.hasActiveIncomingEdges(targetNode, edgeKey) ||
this.nodesWithActivatedEdge.has(targetId)
) {
return
}
for (const [, outgoingEdge] of targetNode.outgoingEdges) {
if (!this.isBackwardsEdge(outgoingEdge.sourceHandle)) {
this.deactivateEdgeAndDescendants(
targetId,
outgoingEdge.target,
outgoingEdge.sourceHandle,
cascadeTargets,
true
)
}
}
}
/**
* Checks if a node has any active incoming edges besides the one being excluded.
*/
private hasActiveIncomingEdges(node: DAGNode, excludeEdgeKey: string): boolean {
for (const incomingSourceId of node.incomingEdges) {
const incomingNode = this.dag.nodes.get(incomingSourceId)
if (!incomingNode) continue
for (const [, incomingEdge] of incomingNode.outgoingEdges) {
if (incomingEdge.target === node.id) {
const incomingEdgeKey = this.createEdgeKey(
incomingSourceId,
node.id,
incomingEdge.sourceHandle
)
if (incomingEdgeKey === excludeEdgeKey) continue
if (!this.deactivatedEdges.has(incomingEdgeKey)) {
return true
}
}
}
}
return false
}
private countActiveIncomingEdges(node: DAGNode): number {
let count = 0
for (const sourceId of node.incomingEdges) {
const sourceNode = this.dag.nodes.get(sourceId)
if (!sourceNode) continue
for (const [, edge] of sourceNode.outgoingEdges) {
if (edge.target === node.id) {
const edgeKey = this.createEdgeKey(sourceId, edge.target, edge.sourceHandle)
if (!this.deactivatedEdges.has(edgeKey)) {
count++
break
}
}
}
}
return count
}
private createEdgeKey(sourceId: string, targetId: string, sourceHandle?: string): string {
return JSON.stringify([sourceId, targetId, sourceHandle ?? EDGE.DEFAULT])
}
private parseEdgeKey(
edgeKey: string
): { sourceId: string; targetId: string; handle: string } | null {
let parsed: unknown
try {
parsed = JSON.parse(edgeKey)
} catch {
return null
}
if (
Array.isArray(parsed) &&
parsed.length === 3 &&
typeof parsed[0] === 'string' &&
typeof parsed[1] === 'string' &&
typeof parsed[2] === 'string'
) {
return { sourceId: parsed[0], targetId: parsed[1], handle: parsed[2] }
}
return null
}
private normalizeSerializedEdgeKey(edgeKey: string): string {
if (this.parseEdgeKey(edgeKey)) {
return edgeKey
}
for (const [sourceId, sourceNode] of this.dag.nodes) {
for (const [, edge] of sourceNode.outgoingEdges) {
const legacyKey = `${sourceId}-${edge.target}-${edge.sourceHandle ?? EDGE.DEFAULT}`
if (legacyKey === edgeKey) {
return this.createEdgeKey(sourceId, edge.target, edge.sourceHandle)
}
}
}
return edgeKey
}
}
File diff suppressed because it is too large Load Diff
+573
View File
@@ -0,0 +1,573 @@
import { createLogger, type Logger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import {
getCancellationChannel,
isExecutionCancelled,
isRedisCancellationEnabled,
} from '@/lib/execution/cancellation'
import { BlockType, EDGE } from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
import type { EdgeManager } from '@/executor/execution/edge-manager'
import { serializePauseSnapshot } from '@/executor/execution/snapshot-serializer'
import type { SerializableExecutionState } from '@/executor/execution/types'
import type { NodeExecutionOrchestrator } from '@/executor/orchestrators/node'
import type {
ExecutionContext,
ExecutionResult,
NormalizedBlockOutput,
PauseMetadata,
PausePoint,
ResumeStatus,
} from '@/executor/types'
import { attachExecutionResult, normalizeError } from '@/executor/utils/errors'
const logger = createLogger('ExecutionEngine')
export class ExecutionEngine {
private readyQueue: string[] = []
private executing = new Set<Promise<void>>()
private queueLock = Promise.resolve()
private finalOutput: NormalizedBlockOutput = {}
private responseOutputLocked = false
private pausedBlocks: Map<string, PauseMetadata> = new Map()
private allowResumeTriggers: boolean
private cancelledFlag = false
private errorFlag = false
private stoppedEarlyFlag = false
private executionError: Error | null = null
private abortPromise!: Promise<void>
private abortResolve!: () => void
private cancellationUnsubscribe: (() => void) | null = null
private execLogger: Logger
constructor(
private context: ExecutionContext,
private dag: DAG,
private edgeManager: EdgeManager,
private nodeOrchestrator: NodeExecutionOrchestrator
) {
this.allowResumeTriggers = this.context.metadata.resumeFromSnapshot === true
this.execLogger = logger.withMetadata({
workflowId: this.context.workflowId,
workspaceId: this.context.workspaceId,
executionId: this.context.executionId,
userId: this.context.userId,
requestId: this.context.metadata.requestId,
})
this.initializeAbortHandler()
this.subscribeToCancellationChannel()
}
private subscribeToCancellationChannel(): void {
if (!this.context.executionId) return
const executionId = this.context.executionId
this.cancellationUnsubscribe = getCancellationChannel().subscribe((event) => {
if (event.executionId !== executionId) return
this.execLogger.info('Execution cancelled via pub/sub', { executionId })
this.signalCancelled()
})
}
private initializeAbortHandler(): void {
this.abortPromise = new Promise<void>((resolve) => {
this.abortResolve = resolve
})
if (!this.context.abortSignal) return
if (this.context.abortSignal.aborted) {
this.signalCancelled()
return
}
this.context.abortSignal.addEventListener('abort', () => this.signalCancelled(), { once: true })
}
private signalCancelled(): void {
if (this.cancelledFlag) return
this.cancelledFlag = true
this.abortResolve()
}
private checkCancellation(): boolean {
return this.cancelledFlag
}
/** Catches cancellations published before this engine subscribed (e.g. resume from snapshot). */
private async checkCancellationBackstop(): Promise<void> {
if (!this.context.executionId || !isRedisCancellationEnabled()) return
const cancelled = await isExecutionCancelled(this.context.executionId)
if (cancelled) {
this.execLogger.info('Execution already cancelled at engine start (Redis backstop)', {
executionId: this.context.executionId,
})
this.signalCancelled()
}
}
async run(triggerBlockId?: string): Promise<ExecutionResult> {
const startTime = performance.now()
try {
this.initializeQueue(triggerBlockId)
await this.checkCancellationBackstop()
while (this.hasWork()) {
if (this.checkCancellation() || this.errorFlag || this.stoppedEarlyFlag) {
break
}
await this.processQueue()
}
if (!this.cancelledFlag) {
await this.waitForAllExecutions()
}
if (this.errorFlag && this.executionError && !this.responseOutputLocked) {
throw this.executionError
}
if (this.pausedBlocks.size > 0) {
return this.buildPausedResult(startTime)
}
const endTime = performance.now()
this.context.metadata.endTime = new Date().toISOString()
this.context.metadata.duration = endTime - startTime
if (this.cancelledFlag) {
this.finalizeIncompleteLogs()
return {
success: false,
output: this.finalOutput,
logs: this.context.blockLogs,
executionState: this.getSerializableExecutionState(),
metadata: this.context.metadata,
status: 'cancelled',
}
}
return {
success: true,
output: this.finalOutput,
logs: this.context.blockLogs,
executionState: this.getSerializableExecutionState(),
metadata: this.context.metadata,
}
} catch (error) {
const endTime = performance.now()
this.context.metadata.endTime = new Date().toISOString()
this.context.metadata.duration = endTime - startTime
if (this.cancelledFlag) {
this.finalizeIncompleteLogs()
return {
success: false,
output: this.finalOutput,
logs: this.context.blockLogs,
executionState: this.getSerializableExecutionState(),
metadata: this.context.metadata,
status: 'cancelled',
}
}
this.finalizeIncompleteLogs()
const errorMessage = normalizeError(error)
this.execLogger.error('Execution failed', { error: errorMessage })
const executionResult: ExecutionResult = {
success: false,
output: this.finalOutput,
error: errorMessage,
logs: this.context.blockLogs,
metadata: this.context.metadata,
}
if (error instanceof Error) {
attachExecutionResult(error, executionResult)
}
throw error
} finally {
this.cleanup()
}
}
private cleanup(): void {
if (this.cancellationUnsubscribe) {
this.cancellationUnsubscribe()
this.cancellationUnsubscribe = null
}
}
private hasWork(): boolean {
return this.readyQueue.length > 0 || this.executing.size > 0
}
private addToQueue(nodeId: string): void {
const node = this.dag.nodes.get(nodeId)
if (node?.metadata?.isResumeTrigger && !this.allowResumeTriggers) {
return
}
if (!this.readyQueue.includes(nodeId)) {
this.readyQueue.push(nodeId)
}
}
private addMultipleToQueue(nodeIds: string[]): void {
for (const nodeId of nodeIds) {
this.addToQueue(nodeId)
}
}
private dequeue(): string | undefined {
return this.readyQueue.shift()
}
private trackExecution(promise: Promise<void>): void {
const trackedPromise = promise
.catch((error) => {
if (!this.errorFlag) {
this.errorFlag = true
this.executionError = toError(error)
}
})
.finally(() => {
this.executing.delete(trackedPromise)
})
this.executing.add(trackedPromise)
}
private async waitForAnyExecution(): Promise<void> {
if (this.executing.size > 0) {
await Promise.race([...this.executing, this.abortPromise])
}
}
private async waitForAllExecutions(): Promise<void> {
await Promise.race([Promise.all(this.executing), this.abortPromise])
if (this.executing.size > 0) {
await Promise.allSettled(this.executing)
}
}
private async withQueueLock<T>(fn: () => Promise<T> | T): Promise<T> {
const prevLock = this.queueLock
let resolveLock: () => void
this.queueLock = new Promise((resolve) => {
resolveLock = resolve
})
await prevLock
try {
return await fn()
} finally {
resolveLock!()
}
}
private initializeQueue(triggerBlockId?: string): void {
if (this.context.runFromBlockContext) {
const { startBlockId } = this.context.runFromBlockContext
this.execLogger.info('Initializing queue for run-from-block mode', {
startBlockId,
dirtySetSize: this.context.runFromBlockContext.dirtySet.size,
})
this.addToQueue(startBlockId)
return
}
const pendingBlocks = this.context.metadata.pendingBlocks
const remainingEdges = (this.context.metadata as any).remainingEdges
if (remainingEdges && Array.isArray(remainingEdges) && remainingEdges.length > 0) {
this.execLogger.info('Removing edges from resumed pause blocks', {
edgeCount: remainingEdges.length,
edges: remainingEdges,
})
for (const edge of remainingEdges) {
const targetNode = this.dag.nodes.get(edge.target)
if (!targetNode) continue
const sourceHandle = this.resolveRemainingEdgeHandle(edge)
if (sourceHandle === EDGE.ERROR) {
this.edgeManager.deactivateResumedEdge(edge.source, targetNode.id, sourceHandle)
if (
this.edgeManager.hasActivatedEdge(targetNode.id) &&
this.edgeManager.isNodeReady(targetNode)
) {
this.execLogger.info('Convergence node ready after pruning resumed error edge', {
nodeId: targetNode.id,
})
this.addToQueue(targetNode.id)
}
continue
}
const hadEdge = targetNode.incomingEdges.has(edge.source)
targetNode.incomingEdges.delete(edge.source)
if (hadEdge) {
this.edgeManager.markNodeWithActivatedEdge(targetNode.id)
}
if (this.edgeManager.isNodeReady(targetNode)) {
this.execLogger.info('Node became ready after edge removal', { nodeId: targetNode.id })
this.addToQueue(targetNode.id)
}
}
this.execLogger.info('Edge removal complete, queued ready nodes', {
queueLength: this.readyQueue.length,
queuedNodes: this.readyQueue,
})
return
}
if (pendingBlocks && pendingBlocks.length > 0) {
this.execLogger.info('Initializing queue from pending blocks (resume mode)', {
pendingBlocks,
allowResumeTriggers: this.allowResumeTriggers,
dagNodeCount: this.dag.nodes.size,
})
for (const nodeId of pendingBlocks) {
this.addToQueue(nodeId)
}
this.execLogger.info('Pending blocks queued', {
queueLength: this.readyQueue.length,
queuedNodes: this.readyQueue,
})
this.context.metadata.pendingBlocks = []
return
}
if (this.context.metadata.resumeFromSnapshot === true) {
this.execLogger.info('Resume snapshot has no downstream work to queue')
return
}
if (triggerBlockId) {
this.addToQueue(triggerBlockId)
return
}
const startNode = Array.from(this.dag.nodes.values()).find(
(node) =>
node.block.metadata?.id === BlockType.START_TRIGGER ||
node.block.metadata?.id === BlockType.STARTER
)
if (startNode) {
this.addToQueue(startNode.id)
} else {
this.execLogger.warn('No start node found in DAG')
}
}
/**
* Resolves the source handle for an edge released during pause/resume.
* Persisted `remainingEdges` may omit the handle, so fall back to the live DAG
* edge. When a source has both a continuation and an `error` edge to the same
* target, the continuation handle wins — a successful resume must not prune it.
*/
private resolveRemainingEdgeHandle(edge: {
source: string
target: string
sourceHandle?: string
}): string | undefined {
if (edge.sourceHandle !== undefined) return edge.sourceHandle
const sourceNode = this.dag.nodes.get(edge.source)
if (!sourceNode) return undefined
let hasErrorEdge = false
for (const [, outgoing] of sourceNode.outgoingEdges) {
if (outgoing.target !== edge.target) continue
if (outgoing.sourceHandle === EDGE.ERROR) {
hasErrorEdge = true
continue
}
return outgoing.sourceHandle
}
return hasErrorEdge ? EDGE.ERROR : undefined
}
private async processQueue(): Promise<void> {
while (this.readyQueue.length > 0) {
if (this.checkCancellation() || this.errorFlag) {
break
}
const nodeId = this.dequeue()
if (!nodeId) continue
const promise = this.executeNodeAsync(nodeId)
this.trackExecution(promise)
}
if (this.executing.size > 0 && !this.cancelledFlag && !this.errorFlag) {
await this.waitForAnyExecution()
}
}
private async executeNodeAsync(nodeId: string): Promise<void> {
try {
const wasAlreadyExecuted = this.context.executedBlocks.has(nodeId)
const result = await this.nodeOrchestrator.executeNode(this.context, nodeId)
if (!wasAlreadyExecuted) {
await this.withQueueLock(async () => {
await this.handleNodeCompletion(nodeId, result.output, result.isFinalOutput)
})
}
} catch (error) {
const errorMessage = normalizeError(error)
this.execLogger.error('Node execution failed', { nodeId, error: errorMessage })
throw error
}
}
private async handleNodeCompletion(
nodeId: string,
output: NormalizedBlockOutput,
isFinalOutput: boolean
): Promise<void> {
const node = this.dag.nodes.get(nodeId)
if (!node) {
this.execLogger.error('Node not found during completion', { nodeId })
return
}
if (this.stoppedEarlyFlag && this.responseOutputLocked) {
// Workflow already ended via Response block. Skip state persistence (setBlockOutput),
// parallel/loop scope tracking, and edge propagation — no downstream blocks will run.
return
}
if (output._pauseMetadata) {
await this.nodeOrchestrator.handleNodeCompletion(this.context, nodeId, output)
const pauseMetadata = output._pauseMetadata
this.pausedBlocks.set(pauseMetadata.contextId, pauseMetadata)
this.context.metadata.status = 'paused'
this.context.metadata.pausePoints = Array.from(this.pausedBlocks.keys())
return
}
await this.nodeOrchestrator.handleNodeCompletion(this.context, nodeId, output)
const isResponseBlock = node.block.metadata?.id === BlockType.RESPONSE
if (isResponseBlock) {
if (!this.responseOutputLocked) {
this.finalOutput = output
this.responseOutputLocked = true
}
this.stoppedEarlyFlag = true
return
}
if (isFinalOutput && !this.responseOutputLocked) {
this.finalOutput = output
}
if (this.context.stopAfterBlockId === nodeId) {
// For loop/parallel sentinels, only stop if the subflow has fully exited (all iterations done)
// shouldContinue: true means more iterations, shouldExit: true means loop is done
const shouldContinue =
output.shouldContinue === true || output.selectedRoute === EDGE.PARALLEL_CONTINUE
if (!shouldContinue) {
this.execLogger.info('Stopping execution after target block', { nodeId })
this.stoppedEarlyFlag = true
return
}
}
const readyNodes = this.edgeManager.processOutgoingEdges(node, output, false)
this.addMultipleToQueue(readyNodes)
}
private buildPausedResult(startTime: number): ExecutionResult {
const endTime = performance.now()
this.context.metadata.endTime = new Date().toISOString()
this.context.metadata.duration = endTime - startTime
this.context.metadata.status = 'paused'
const snapshotSeed = serializePauseSnapshot(this.context, [], this.dag, this.edgeManager)
const pausePoints: PausePoint[] = Array.from(this.pausedBlocks.values()).map((pause) => ({
contextId: pause.contextId,
blockId: pause.blockId,
response: pause.response,
registeredAt: pause.timestamp,
resumeStatus: 'paused' as ResumeStatus,
snapshotReady: true,
parallelScope: pause.parallelScope,
loopScope: pause.loopScope,
resumeLinks: pause.resumeLinks,
pauseKind: pause.pauseKind,
resumeAt: pause.resumeAt,
}))
return {
success: true,
output: this.collectPauseResponses(),
logs: this.context.blockLogs,
executionState: this.getSerializableExecutionState(snapshotSeed),
metadata: this.context.metadata,
status: 'paused',
pausePoints,
snapshotSeed,
}
}
private getSerializableExecutionState(snapshotSeed?: {
snapshot: string
}): SerializableExecutionState | undefined {
try {
const serializedSnapshot =
snapshotSeed?.snapshot ??
serializePauseSnapshot(this.context, [], this.dag, this.edgeManager).snapshot
const parsedSnapshot = JSON.parse(serializedSnapshot) as {
state?: SerializableExecutionState
}
return parsedSnapshot.state
} catch (error) {
this.execLogger.warn('Failed to serialize execution state', {
error: toError(error).message,
})
return undefined
}
}
private collectPauseResponses(): NormalizedBlockOutput {
const responses = Array.from(this.pausedBlocks.values()).map((pause) => pause.response)
if (responses.length === 1) {
return responses[0]
}
return {
pausedBlocks: responses,
pauseCount: responses.length,
}
}
/**
* Finalizes any block logs that were still running when execution was cancelled.
* Sets their endedAt to now and calculates the actual elapsed duration.
*/
private finalizeIncompleteLogs(): void {
const now = new Date()
const nowIso = now.toISOString()
for (const log of this.context.blockLogs) {
if (!log.endedAt) {
log.endedAt = nowIso
log.durationMs = now.getTime() - new Date(log.startedAt).getTime()
}
}
}
}
@@ -0,0 +1,431 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { DAGBuilder } from '@/executor/dag/builder'
import { DAGExecutor } from '@/executor/execution/executor'
import type { SerializableExecutionState } from '@/executor/execution/types'
import type { ExecutionContext, ExecutionResult } from '@/executor/types'
import { buildSentinelStartId } from '@/executor/utils/subflow-utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
function createExecutor(): DAGExecutor {
return new DAGExecutor({
workflow: {
version: '1',
blocks: [],
connections: [],
},
})
}
function createBlock(id: string, metadataId: string): SerializedBlock {
return {
id,
position: { x: 0, y: 0 },
config: { tool: 'noop', params: {} },
inputs: {},
outputs: {},
metadata: { id: metadataId, name: id },
enabled: true,
}
}
describe('DAGExecutor restored cloned subflow registration', () => {
it('registers restored cloned subflows under their parent parallel branch', () => {
const executor = createExecutor() as unknown as {
registerRestoredClonedSubflows: (
parentMap: Map<
string,
{ parentId: string; parentType: 'loop' | 'parallel'; branchIndex?: number }
>,
clonedSubflows: Array<{
originalId: string
clonedId: string
outerBranchIndex: number
parentParallelId: string
}>
) => void
}
const parentMap = new Map<
string,
{ parentId: string; parentType: 'loop' | 'parallel'; branchIndex?: number }
>([['nested-loop', { parentId: 'parent-parallel', parentType: 'parallel' }]])
executor.registerRestoredClonedSubflows(parentMap, [
{
originalId: 'nested-loop',
clonedId: 'nested-loop__obranch-2',
outerBranchIndex: 2,
parentParallelId: 'parent-parallel',
},
])
expect(parentMap.get('nested-loop__obranch-2')).toEqual({
parentId: 'parent-parallel',
parentType: 'parallel',
branchIndex: 2,
})
})
it('preserves cloned nested parent relationships within the same restored branch', () => {
const executor = createExecutor() as unknown as {
registerRestoredClonedSubflows: (
parentMap: Map<
string,
{ parentId: string; parentType: 'loop' | 'parallel'; branchIndex?: number }
>,
clonedSubflows: Array<{
originalId: string
clonedId: string
outerBranchIndex: number
parentParallelId: string
}>
) => void
}
const parentMap = new Map<
string,
{ parentId: string; parentType: 'loop' | 'parallel'; branchIndex?: number }
>([
['middle-loop', { parentId: 'parent-parallel', parentType: 'parallel' }],
['inner-parallel', { parentId: 'middle-loop', parentType: 'loop' }],
])
executor.registerRestoredClonedSubflows(parentMap, [
{
originalId: 'middle-loop',
clonedId: 'middle-loop__obranch-2',
outerBranchIndex: 2,
parentParallelId: 'parent-parallel',
},
{
originalId: 'inner-parallel',
clonedId: 'inner-parallel__obranch-2',
outerBranchIndex: 2,
parentParallelId: 'parent-parallel',
},
])
expect(parentMap.get('inner-parallel__obranch-2')).toEqual({
parentId: 'middle-loop__obranch-2',
parentType: 'loop',
branchIndex: 0,
})
})
it('restores snapshot parallel batches with later global branch indexes', () => {
const parallelId = 'parallel-1'
const loopId = 'loop-1'
const taskId = 'task-1'
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock(parallelId, BlockType.PARALLEL),
createBlock(loopId, BlockType.LOOP),
createBlock(taskId, BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: parallelId },
{ source: parallelId, target: loopId, sourceHandle: 'parallel-start-source' },
{ source: loopId, target: taskId, sourceHandle: 'loop-start-source' },
],
loops: {
[loopId]: {
id: loopId,
nodes: [taskId],
iterations: 1,
loopType: 'for',
},
},
parallels: {
[parallelId]: {
id: parallelId,
nodes: [loopId],
count: 4,
parallelType: 'count',
},
},
}
const dag = new DAGBuilder().build(workflow)
const executor = new DAGExecutor({ workflow }) as unknown as {
restoreSnapshotParallelBatches: (
dag: ReturnType<DAGBuilder['build']>,
snapshotState?: SerializableExecutionState
) => Array<{
originalId: string
clonedId: string
outerBranchIndex: number
parentParallelId: string
}>
}
const restoredClones = executor.restoreSnapshotParallelBatches(dag, {
blockStates: {},
executedBlocks: [],
blockLogs: [],
decisions: { router: {}, condition: {} },
completedLoops: [],
activeExecutionPath: [],
parallelExecutions: {
[parallelId]: {
currentBatchStart: 2,
currentBatchSize: 1,
totalBranches: 4,
items: ['zero', 'one', 'two', 'three'],
},
},
})
expect(dag.nodes.has(buildSentinelStartId(`${loopId}__obranch-2`))).toBe(true)
expect(restoredClones).toContainEqual(
expect.objectContaining({
originalId: loopId,
clonedId: `${loopId}__obranch-2`,
outerBranchIndex: 2,
parentParallelId: parallelId,
})
)
})
})
describe('DAGExecutor run-from-block snapshot metadata', () => {
it('preserves reachable large value and file keys in run-from-block metadata', async () => {
const reachableLargeValue = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEF123456',
kind: 'object',
size: 1024,
key: 'execution/ws/wf/exec/large-value-lv_ABCDEF123456.json',
}
const unreachableLargeValue = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ZYXWVU654321',
kind: 'object',
size: 1024,
key: 'execution/ws/wf/exec/large-value-lv_ZYXWVU654321.json',
}
const reachableFile = {
id: 'file-1',
name: 'reachable.txt',
url: '/api/files/serve/reachable',
size: 10,
type: 'text/plain',
key: 'execution/ws/wf/exec/reachable.txt',
}
const unreachableFile = {
id: 'file-2',
name: 'unreachable.txt',
url: '/api/files/serve/unreachable',
size: 10,
type: 'text/plain',
key: 'execution/ws/wf/exec/unreachable.txt',
}
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock('producer', BlockType.FUNCTION),
createBlock('consumer', BlockType.FUNCTION),
createBlock('unreachable', BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: 'producer' },
{ source: 'producer', target: 'consumer' },
],
loops: {},
parallels: {},
}
const executor = new DAGExecutor({
workflow,
contextExtensions: {
workspaceId: 'ws',
executionId: 'exec',
largeValueKeys: ['existing-large-key'],
fileKeys: ['existing-file-key'],
},
}) as unknown as DAGExecutor & {
buildExecutionPipeline: (context: ExecutionContext) => { run: () => Promise<ExecutionResult> }
}
const run = vi.fn(async (): Promise<ExecutionResult> => {
return {
success: true,
output: { ok: true },
metadata: {} as ExecutionResult['metadata'],
}
})
executor.buildExecutionPipeline = vi.fn(() => ({ run }))
const sourceSnapshot: SerializableExecutionState = {
blockStates: {
producer: { output: { reachableLargeValue, reachableFile } },
consumer: { output: { previous: true } },
unreachable: { output: { unreachableLargeValue, unreachableFile } },
},
executedBlocks: ['producer', 'consumer', 'unreachable'],
blockLogs: [],
decisions: { router: {}, condition: {} },
completedLoops: [],
activeExecutionPath: [],
}
const result = await executor.executeFromBlock('wf', 'consumer', sourceSnapshot)
expect(result.metadata?.largeValueKeys).toEqual(['existing-large-key', reachableLargeValue.key])
expect(result.metadata?.fileKeys).toEqual(['existing-file-key', reachableFile.key])
expect(result.metadata?.largeValueKeys).not.toContain(unreachableLargeValue.key)
expect(result.metadata?.fileKeys).not.toContain(unreachableFile.key)
})
it('preserves reachable stable branch aliases in run-from-block snapshots', async () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock('producer', BlockType.FUNCTION),
createBlock('consumer', BlockType.FUNCTION),
],
connections: [
{ source: 'start', target: 'producer' },
{ source: 'producer', target: 'consumer' },
],
loops: {},
parallels: {},
}
let capturedContext: ExecutionContext | undefined
const executor = new DAGExecutor({ workflow }) as unknown as DAGExecutor & {
buildExecutionPipeline: (context: ExecutionContext) => { run: () => Promise<ExecutionResult> }
}
executor.buildExecutionPipeline = vi.fn((context: ExecutionContext) => {
capturedContext = context
return {
run: async (): Promise<ExecutionResult> => ({
success: true,
output: { ok: true },
metadata: {},
}),
}
})
const sourceSnapshot: SerializableExecutionState = {
blockStates: {
producer: { output: { result: 'latest-local-batch' } },
'producer__obranch-0': { output: { result: 'global-branch-0' } },
'unreachable__obranch-0': { output: { result: 'unreachable' } },
consumer: { output: { previous: true } },
},
executedBlocks: ['producer', 'producer__obranch-0', 'unreachable__obranch-0', 'consumer'],
blockLogs: [],
decisions: { router: {}, condition: {} },
completedLoops: [],
activeExecutionPath: [],
}
await executor.executeFromBlock('wf', 'consumer', sourceSnapshot)
expect(capturedContext?.blockStates.get('producer__obranch-0')?.output).toEqual({
result: 'global-branch-0',
})
expect(capturedContext?.blockStates.has('unreachable__obranch-0')).toBe(false)
})
})
describe('DAGExecutor resume DAG construction', () => {
it('includes non-starter resume targets when a workflow has a disconnected starter', async () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start-t2-v2', BlockType.STARTER),
createBlock('webhook-start', 'generic_webhook'),
createBlock('hitl', BlockType.HUMAN_IN_THE_LOOP),
createBlock('generate-report', BlockType.FUNCTION),
],
connections: [
{ source: 'webhook-start', target: 'hitl', sourceHandle: 'source' },
{ source: 'hitl', target: 'generate-report', sourceHandle: 'source' },
],
loops: {},
parallels: {},
}
let capturedDag: ReturnType<DAGBuilder['build']> | undefined
const executor = new DAGExecutor({
workflow,
contextExtensions: {
resumeFromSnapshot: true,
remainingEdges: [{ source: 'hitl', target: 'generate-report', sourceHandle: 'source' }],
dagIncomingEdges: { 'start-t2-v2': [] },
snapshotState: {
blockStates: {},
executedBlocks: ['webhook-start', 'hitl'],
blockLogs: [],
decisions: { router: {}, condition: {} },
completedLoops: [],
activeExecutionPath: [],
},
},
}) as unknown as DAGExecutor & {
buildExecutionPipeline: (
context: ExecutionContext,
dag: ReturnType<DAGBuilder['build']>
) => { run: () => Promise<ExecutionResult> }
}
executor.buildExecutionPipeline = vi.fn((_context, dag) => {
capturedDag = dag
return {
run: async (): Promise<ExecutionResult> => ({
success: true,
output: { ok: true },
metadata: {},
}),
}
})
await executor.execute('wf')
expect(capturedDag?.nodes.has('generate-report')).toBe(true)
expect(capturedDag?.nodes.get('generate-report')?.incomingEdges.has('hitl')).toBe(true)
})
})
describe('DAGExecutor createExecutionContext useDraftState', () => {
function buildMetadataUseDraftState(opts: {
metadataUseDraftState?: boolean
isDeployedContext?: boolean
}): boolean | undefined {
const executor = new DAGExecutor({
workflow: { version: '1', blocks: [], connections: [] },
contextExtensions: {
workspaceId: 'ws-1',
isDeployedContext: opts.isDeployedContext,
metadata:
opts.metadataUseDraftState === undefined
? undefined
: ({ useDraftState: opts.metadataUseDraftState } as ExecutionContext['metadata']),
},
})
const { context } = (
executor as unknown as {
createExecutionContext: (workflowId: string) => { context: ExecutionContext }
}
).createExecutionContext('wf-1')
return context.metadata.useDraftState
}
it('honors explicit useDraftState=true even when isDeployedContext is true (table dispatcher)', () => {
expect(
buildMetadataUseDraftState({ metadataUseDraftState: true, isDeployedContext: true })
).toBe(true)
})
it('honors explicit useDraftState=false even when isDeployedContext is false', () => {
expect(
buildMetadataUseDraftState({ metadataUseDraftState: false, isDeployedContext: false })
).toBe(false)
})
it('falls back to the isDeployedContext heuristic when useDraftState is not provided', () => {
expect(buildMetadataUseDraftState({ isDeployedContext: true })).toBe(false)
expect(buildMetadataUseDraftState({ isDeployedContext: false })).toBe(true)
})
})
+628
View File
@@ -0,0 +1,628 @@
import { createLogger, type Logger } from '@sim/logger'
import { normalizeStringArray } from '@/lib/core/utils/arrays'
import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records'
import { collectUserFileKeys } from '@/lib/core/utils/user-file'
import { mergeFileKeys, mergeLargeValueKeys } from '@/lib/execution/payloads/access-keys'
import { collectLargeValueKeys } from '@/lib/execution/payloads/large-execution-value'
import { StartBlockPath } from '@/lib/workflows/triggers/triggers'
import type { DAG } from '@/executor/dag/builder'
import { DAGBuilder } from '@/executor/dag/builder'
import { BlockExecutor } from '@/executor/execution/block-executor'
import { EdgeManager } from '@/executor/execution/edge-manager'
import { ExecutionEngine } from '@/executor/execution/engine'
import { ExecutionState } from '@/executor/execution/state'
import type {
ContextExtensions,
SerializableExecutionState,
WorkflowInput,
} from '@/executor/execution/types'
import { createBlockHandlers } from '@/executor/handlers/registry'
import { LoopOrchestrator } from '@/executor/orchestrators/loop'
import { NodeExecutionOrchestrator } from '@/executor/orchestrators/node'
import { ParallelOrchestrator } from '@/executor/orchestrators/parallel'
import type { BlockState, ExecutionContext, ExecutionResult } from '@/executor/types'
import { type ClonedSubflowInfo, ParallelExpander } from '@/executor/utils/parallel-expansion'
import {
computeExecutionSets,
type RunFromBlockContext,
resolveContainerToSentinelStart,
validateRunFromBlock,
} from '@/executor/utils/run-from-block'
import {
buildResolutionFromBlock,
buildStartBlockOutput,
resolveExecutorStartBlock,
} from '@/executor/utils/start-block'
import {
extractLoopIdFromSentinel,
extractParallelIdFromSentinel,
stripCloneSuffixes,
stripOuterBranchSuffix,
} from '@/executor/utils/subflow-utils'
import { VariableResolver } from '@/executor/variables/resolver'
import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server'
import type { SerializedWorkflow } from '@/serializer/types'
import type { SubflowType } from '@/stores/workflows/workflow/types'
const logger = createLogger('DAGExecutor')
interface RestoredClonedSubflowInfo extends ClonedSubflowInfo {
parentParallelId: string
}
export interface DAGExecutorOptions {
workflow: SerializedWorkflow
envVarValues?: Record<string, string>
workflowInput?: WorkflowInput
workflowVariables?: Record<string, unknown>
contextExtensions?: ContextExtensions
}
export class DAGExecutor {
private workflow: SerializedWorkflow
private environmentVariables: Record<string, string>
private workflowInput: WorkflowInput
private workflowVariables: Record<string, unknown>
private contextExtensions: ContextExtensions
private dagBuilder: DAGBuilder
private execLogger: Logger
constructor(options: DAGExecutorOptions) {
this.workflow = options.workflow
this.environmentVariables = normalizeStringRecord(options.envVarValues)
this.workflowInput = options.workflowInput ?? {}
this.workflowVariables = normalizeWorkflowVariables(options.workflowVariables)
this.contextExtensions = options.contextExtensions ?? {}
this.dagBuilder = new DAGBuilder()
this.execLogger = logger.withMetadata({
workflowId: this.contextExtensions.metadata?.workflowId,
workspaceId: this.contextExtensions.workspaceId,
executionId: this.contextExtensions.executionId,
userId: this.contextExtensions.userId,
requestId: this.contextExtensions.metadata?.requestId,
})
}
async execute(workflowId: string, triggerBlockId?: string): Promise<ExecutionResult> {
const savedIncomingEdges = this.contextExtensions.dagIncomingEdges
const dag = this.dagBuilder.build(this.workflow, {
triggerBlockId,
savedIncomingEdges,
includeAllBlocks: this.contextExtensions.resumeFromSnapshot === true,
})
const restoredClonedSubflows = this.restoreSnapshotParallelBatches(
dag,
this.contextExtensions.snapshotState
)
this.restoreSavedIncomingEdges(dag, savedIncomingEdges)
const { context, state } = this.createExecutionContext(workflowId, triggerBlockId)
context.subflowParentMap = this.buildSubflowParentMap(dag)
this.registerRestoredClonedSubflows(context.subflowParentMap, restoredClonedSubflows)
const engine = this.buildExecutionPipeline(context, dag, state)
return await engine.run(triggerBlockId)
}
async continueExecution(
_pendingBlocks: string[],
context: ExecutionContext
): Promise<ExecutionResult> {
this.execLogger.warn(
'Debug mode (continueExecution) is not yet implemented in the refactored executor'
)
return {
success: false,
output: {},
logs: context.blockLogs ?? [],
error: 'Debug mode is not yet supported in the refactored executor',
metadata: {
duration: 0,
startTime: new Date().toISOString(),
},
}
}
/**
* Execute from a specific block using cached outputs for upstream blocks.
*/
async executeFromBlock(
workflowId: string,
startBlockId: string,
sourceSnapshot: SerializableExecutionState
): Promise<ExecutionResult> {
// Build full DAG with all blocks to compute upstream set for snapshot filtering
// includeAllBlocks is needed because the startBlockId might be a trigger not reachable from the main trigger
const dag = this.dagBuilder.build(this.workflow, { includeAllBlocks: true })
const executedBlocks = new Set(sourceSnapshot.executedBlocks)
const validation = validateRunFromBlock(startBlockId, dag, executedBlocks)
if (!validation.valid) {
throw new Error(validation.error)
}
const { dirtySet, upstreamSet, reachableUpstreamSet } = computeExecutionSets(dag, startBlockId)
const effectiveStartBlockId = resolveContainerToSentinelStart(startBlockId, dag) ?? startBlockId
// Extract container IDs from sentinel IDs in reachable upstream set
// Use reachableUpstreamSet (not upstreamSet) to preserve sibling branch outputs
// Example: A->C, B->C where C references A.result || B.result
// When running from A, B's output should be preserved for C to reference
const reachableContainerIds = new Set<string>()
for (const nodeId of reachableUpstreamSet) {
const loopId = extractLoopIdFromSentinel(nodeId)
if (loopId) reachableContainerIds.add(loopId)
const parallelId = extractParallelIdFromSentinel(nodeId)
if (parallelId) reachableContainerIds.add(parallelId)
}
// Filter snapshot to include all blocks reachable from dirty blocks
// This preserves sibling branch outputs that dirty blocks may reference
const filteredBlockStates: Record<string, any> = {}
for (const [blockId, state] of Object.entries(sourceSnapshot.blockStates)) {
const aliasBaseId = stripOuterBranchSuffix(blockId)
const isReachableOuterBranchAlias =
aliasBaseId !== blockId &&
Array.from(reachableUpstreamSet).some(
(reachableId) => stripCloneSuffixes(reachableId) === aliasBaseId
)
if (
reachableUpstreamSet.has(blockId) ||
reachableContainerIds.has(blockId) ||
isReachableOuterBranchAlias
) {
filteredBlockStates[blockId] = state
}
}
const filteredExecutedBlocks = sourceSnapshot.executedBlocks.filter((id) => {
const aliasBaseId = stripOuterBranchSuffix(id)
const isReachableOuterBranchAlias =
aliasBaseId !== id &&
Array.from(reachableUpstreamSet).some(
(reachableId) => stripCloneSuffixes(reachableId) === aliasBaseId
)
return (
reachableUpstreamSet.has(id) || reachableContainerIds.has(id) || isReachableOuterBranchAlias
)
})
// Filter loop/parallel executions to only include reachable containers
const filteredLoopExecutions: Record<string, any> = {}
if (sourceSnapshot.loopExecutions) {
for (const [loopId, execution] of Object.entries(sourceSnapshot.loopExecutions)) {
if (reachableContainerIds.has(loopId)) {
filteredLoopExecutions[loopId] = execution
}
}
}
const filteredParallelExecutions: Record<string, any> = {}
if (sourceSnapshot.parallelExecutions) {
for (const [parallelId, execution] of Object.entries(sourceSnapshot.parallelExecutions)) {
if (reachableContainerIds.has(parallelId)) {
filteredParallelExecutions[parallelId] = execution
}
}
}
const filteredSnapshot: SerializableExecutionState = {
...sourceSnapshot,
blockStates: filteredBlockStates,
executedBlocks: filteredExecutedBlocks,
loopExecutions: filteredLoopExecutions,
parallelExecutions: filteredParallelExecutions,
}
this.execLogger.info('Executing from block', {
workflowId,
startBlockId,
effectiveStartBlockId,
dirtySetSize: dirtySet.size,
upstreamSetSize: upstreamSet.size,
reachableUpstreamSetSize: reachableUpstreamSet.size,
})
// Remove incoming edges from non-dirty sources so convergent blocks don't wait for cached upstream
for (const nodeId of dirtySet) {
const node = dag.nodes.get(nodeId)
if (!node) continue
const nonDirtyIncoming: string[] = []
for (const sourceId of node.incomingEdges) {
if (!dirtySet.has(sourceId)) {
nonDirtyIncoming.push(sourceId)
}
}
for (const sourceId of nonDirtyIncoming) {
node.incomingEdges.delete(sourceId)
}
}
const runFromBlockContext = { startBlockId: effectiveStartBlockId, dirtySet }
const { context, state } = this.createExecutionContext(workflowId, undefined, {
snapshotState: filteredSnapshot,
runFromBlockContext,
})
const filteredLargeValueKeys = collectLargeValueKeys({
blockStates: filteredBlockStates,
loopExecutions: filteredLoopExecutions,
parallelExecutions: filteredParallelExecutions,
})
mergeLargeValueKeys(context, filteredLargeValueKeys)
const filteredFileKeys = collectUserFileKeys({
blockStates: filteredBlockStates,
loopExecutions: filteredLoopExecutions,
parallelExecutions: filteredParallelExecutions,
})
mergeFileKeys(context, filteredFileKeys)
context.subflowParentMap = this.buildSubflowParentMap(dag)
const engine = this.buildExecutionPipeline(context, dag, state, filteredSnapshot)
const result = await engine.run()
if (result.metadata) {
result.metadata.largeValueKeys = context.largeValueKeys
result.metadata.fileKeys = context.fileKeys
}
return result
}
private restoreSavedIncomingEdges(dag: DAG, savedIncomingEdges?: Record<string, string[]>): void {
if (!savedIncomingEdges) return
for (const [nodeId, incomingEdges] of Object.entries(savedIncomingEdges)) {
const node = dag.nodes.get(nodeId)
if (node) {
node.incomingEdges = new Set(incomingEdges)
}
}
}
private restoreSnapshotParallelBatches(
dag: DAG,
snapshotState?: SerializableExecutionState
): RestoredClonedSubflowInfo[] {
if (!snapshotState?.parallelExecutions) return []
const expander = new ParallelExpander()
const clonedSubflows: RestoredClonedSubflowInfo[] = []
for (const [parallelId, scope] of Object.entries(snapshotState.parallelExecutions)) {
const currentBatchSize = Number(scope.currentBatchSize ?? 0)
if (!Number.isFinite(currentBatchSize) || currentBatchSize <= 0) continue
const currentBatchStart = Number(scope.currentBatchStart ?? 0)
const totalBranches = Number(scope.totalBranches ?? currentBatchStart + currentBatchSize)
const items = Array.isArray(scope.items)
? scope.items.slice(currentBatchStart, currentBatchStart + currentBatchSize)
: undefined
const restoredBatch = expander.expandParallel(dag, parallelId, currentBatchSize, items, {
branchIndexOffset: currentBatchStart,
totalBranches,
})
clonedSubflows.push(
...restoredBatch.clonedSubflows.map((clone) => ({
...clone,
parentParallelId: parallelId,
}))
)
}
return clonedSubflows
}
private registerRestoredClonedSubflows(
parentMap: Map<string, { parentId: string; parentType: SubflowType; branchIndex?: number }>,
clonedSubflows: RestoredClonedSubflowInfo[]
): void {
const branchCloneMaps = new Map<string, Map<number, Map<string, string>>>()
for (const clone of clonedSubflows) {
let parallelBranchMaps = branchCloneMaps.get(clone.parentParallelId)
if (!parallelBranchMaps) {
parallelBranchMaps = new Map()
branchCloneMaps.set(clone.parentParallelId, parallelBranchMaps)
}
let cloneMap = parallelBranchMaps.get(clone.outerBranchIndex)
if (!cloneMap) {
cloneMap = new Map()
parallelBranchMaps.set(clone.outerBranchIndex, cloneMap)
}
cloneMap.set(clone.originalId, clone.clonedId)
}
for (const clone of clonedSubflows) {
const originalEntry = parentMap.get(clone.originalId)
const cloneMap = branchCloneMaps.get(clone.parentParallelId)?.get(clone.outerBranchIndex)
const clonedParentId = originalEntry ? cloneMap?.get(originalEntry.parentId) : undefined
parentMap.set(clone.clonedId, {
parentId: clonedParentId ?? clone.parentParallelId,
parentType: clonedParentId && originalEntry ? originalEntry.parentType : 'parallel',
branchIndex: clonedParentId ? 0 : clone.outerBranchIndex,
})
}
}
private buildExecutionPipeline(
context: ExecutionContext,
dag: DAG,
state: ExecutionState,
snapshotState = this.contextExtensions.snapshotState
) {
const resolver = new VariableResolver(this.workflow, this.workflowVariables, state, {
navigatePathAsync,
})
const allHandlers = createBlockHandlers()
const blockExecutor = new BlockExecutor(allHandlers, resolver, this.contextExtensions, state)
const edgeManager = new EdgeManager(dag)
const loopOrchestrator = new LoopOrchestrator(
dag,
state,
resolver,
this.contextExtensions,
edgeManager
)
const parallelOrchestrator = new ParallelOrchestrator(
dag,
state,
resolver,
this.contextExtensions,
edgeManager
)
edgeManager.restoreDeactivatedEdges(
snapshotState?.deactivatedEdges,
snapshotState?.nodesWithActivatedEdge
)
const nodeOrchestrator = new NodeExecutionOrchestrator(
dag,
state,
blockExecutor,
loopOrchestrator,
parallelOrchestrator
)
return new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
}
private createExecutionContext(
workflowId: string,
triggerBlockId?: string,
overrides?: {
snapshotState?: SerializableExecutionState
runFromBlockContext?: RunFromBlockContext
}
): { context: ExecutionContext; state: ExecutionState } {
const snapshotState = overrides?.snapshotState ?? this.contextExtensions.snapshotState
const blockStates = snapshotState?.blockStates
? new Map(Object.entries(snapshotState.blockStates))
: new Map<string, BlockState>()
let executedBlocks = snapshotState?.executedBlocks
? new Set(snapshotState.executedBlocks)
: new Set<string>()
if (overrides?.runFromBlockContext) {
const { dirtySet } = overrides.runFromBlockContext
executedBlocks = new Set([...executedBlocks].filter((id) => !dirtySet.has(id)))
this.execLogger.info('Cleared executed status for dirty blocks', {
dirtySetSize: dirtySet.size,
remainingExecutedBlocks: executedBlocks.size,
})
}
const state = new ExecutionState(blockStates, executedBlocks)
const context: ExecutionContext = {
workflowId,
workspaceId: this.contextExtensions.workspaceId,
executionId: this.contextExtensions.executionId,
largeValueExecutionIds: this.contextExtensions.largeValueExecutionIds,
largeValueKeys: this.contextExtensions.largeValueKeys,
fileKeys: this.contextExtensions.fileKeys,
allowLargeValueWorkflowScope: this.contextExtensions.allowLargeValueWorkflowScope,
userId: this.contextExtensions.userId,
isDeployedContext: this.contextExtensions.isDeployedContext,
enforceCredentialAccess: this.contextExtensions.enforceCredentialAccess,
piiBlockOutputRedaction: this.contextExtensions.piiBlockOutputRedaction,
blockStates: state.getBlockStates(),
blockLogs: overrides?.runFromBlockContext ? [] : (snapshotState?.blockLogs ?? []),
metadata: {
...this.contextExtensions.metadata,
startTime: new Date().toISOString(),
duration: 0,
useDraftState:
this.contextExtensions.metadata?.useDraftState ??
this.contextExtensions.isDeployedContext !== true,
},
environmentVariables: this.environmentVariables,
workflowVariables: this.workflowVariables,
decisions: {
router: snapshotState?.decisions?.router
? new Map(Object.entries(snapshotState.decisions.router))
: new Map(),
condition: snapshotState?.decisions?.condition
? new Map(Object.entries(snapshotState.decisions.condition))
: new Map(),
},
completedLoops: snapshotState?.completedLoops
? new Set(snapshotState.completedLoops)
: new Set(),
loopExecutions: snapshotState?.loopExecutions
? new Map(
Object.entries(snapshotState.loopExecutions).map(([loopId, scope]) => [
loopId,
{
...scope,
currentIterationOutputs: scope.currentIterationOutputs
? new Map(Object.entries(scope.currentIterationOutputs))
: new Map(),
},
])
)
: new Map(),
parallelExecutions: snapshotState?.parallelExecutions
? new Map(
Object.entries(snapshotState.parallelExecutions).map(([parallelId, scope]) => [
parallelId,
{
...scope,
branchOutputs: scope.branchOutputs
? new Map(Object.entries(scope.branchOutputs).map(([k, v]) => [Number(k), v]))
: new Map(),
accumulatedOutputs: scope.accumulatedOutputs
? new Map(
Object.entries(scope.accumulatedOutputs).map(([k, v]) => [Number(k), v])
)
: new Map(),
},
])
)
: new Map(),
parallelBlockMapping: snapshotState?.parallelBlockMapping
? new Map(Object.entries(snapshotState.parallelBlockMapping))
: new Map(),
executedBlocks: state.getExecutedBlocks(),
activeExecutionPath: snapshotState?.activeExecutionPath
? new Set(snapshotState.activeExecutionPath)
: new Set(),
workflow: this.workflow,
stream: this.contextExtensions.stream ?? false,
selectedOutputs: normalizeStringArray(this.contextExtensions.selectedOutputs),
edges: this.contextExtensions.edges ?? [],
onStream: this.contextExtensions.onStream,
onBlockStart: this.contextExtensions.onBlockStart,
onBlockComplete: this.contextExtensions.onBlockComplete,
onChildWorkflowInstanceReady: this.contextExtensions.onChildWorkflowInstanceReady,
abortSignal: this.contextExtensions.abortSignal,
childWorkflowContext: this.contextExtensions.childWorkflowContext,
includeFileBase64: this.contextExtensions.includeFileBase64,
base64MaxBytes: this.contextExtensions.base64MaxBytes,
runFromBlockContext: overrides?.runFromBlockContext,
stopAfterBlockId: this.contextExtensions.stopAfterBlockId,
callChain: this.contextExtensions.callChain,
}
if (this.contextExtensions.resumeFromSnapshot) {
context.metadata.resumeFromSnapshot = true
this.execLogger.info('Resume from snapshot enabled', {
resumePendingQueue: this.contextExtensions.resumePendingQueue,
remainingEdges: this.contextExtensions.remainingEdges,
triggerBlockId,
})
}
if (this.contextExtensions.remainingEdges) {
;(context.metadata as any).remainingEdges = this.contextExtensions.remainingEdges
this.execLogger.info('Set remaining edges for resume', {
edgeCount: this.contextExtensions.remainingEdges.length,
})
}
if (this.contextExtensions.resumePendingQueue?.length) {
context.metadata.pendingBlocks = [...this.contextExtensions.resumePendingQueue]
this.execLogger.info('Set pending blocks from resume queue', {
pendingBlocks: context.metadata.pendingBlocks,
skipStarterBlockInit: true,
})
} else if (overrides?.runFromBlockContext) {
// In run-from-block mode, initialize the start block only if it's a regular block
// Skip for sentinels/containers (loop/parallel) which aren't real blocks
const startBlockId = overrides.runFromBlockContext.startBlockId
const isRegularBlock = this.workflow.blocks.some((b) => b.id === startBlockId)
if (isRegularBlock) {
this.initializeStarterBlock(context, state, startBlockId)
}
} else {
this.initializeStarterBlock(context, state, triggerBlockId)
}
return { context, state }
}
/**
* Builds a unified child-subflow → parent-subflow mapping that covers all nesting
* combinations: loop-in-loop, parallel-in-parallel, loop-in-parallel, parallel-in-loop.
* Used by the iteration context builder to walk the full ancestor chain for SSE events.
*/
private buildSubflowParentMap(
dag: DAG
): Map<string, { parentId: string; parentType: SubflowType; branchIndex?: number }> {
const parentMap = new Map<
string,
{ parentId: string; parentType: SubflowType; branchIndex?: number }
>()
// Scan loop configs: children can be loops or parallels
for (const [loopId, config] of dag.loopConfigs) {
for (const nodeId of config.nodes) {
if (dag.loopConfigs.has(nodeId) || dag.parallelConfigs.has(nodeId)) {
parentMap.set(nodeId, { parentId: loopId, parentType: 'loop' })
}
}
}
// Scan parallel configs: children can be parallels or loops
for (const [parallelId, config] of dag.parallelConfigs) {
for (const nodeId of config.nodes ?? []) {
if (dag.parallelConfigs.has(nodeId) || dag.loopConfigs.has(nodeId)) {
parentMap.set(nodeId, { parentId: parallelId, parentType: 'parallel', branchIndex: 0 })
}
}
}
return parentMap
}
private initializeStarterBlock(
context: ExecutionContext,
state: ExecutionState,
triggerBlockId?: string
): void {
let startResolution: ReturnType<typeof resolveExecutorStartBlock> | null = null
if (triggerBlockId) {
const triggerBlock = this.workflow.blocks.find((b) => b.id === triggerBlockId)
if (!triggerBlock) {
this.execLogger.error('Specified trigger block not found in workflow', {
triggerBlockId,
})
throw new Error(`Trigger block not found: ${triggerBlockId}`)
}
startResolution = buildResolutionFromBlock(triggerBlock)
if (!startResolution) {
startResolution = {
blockId: triggerBlock.id,
block: triggerBlock,
path: StartBlockPath.SPLIT_MANUAL,
}
}
} else {
startResolution = resolveExecutorStartBlock(this.workflow.blocks, {
execution: 'manual',
isChildWorkflow: false,
})
if (!startResolution?.block) {
this.execLogger.warn('No start block found in workflow')
return
}
}
if (state.getBlockStates().has(startResolution.block.id)) {
return
}
const blockOutput = buildStartBlockOutput({
resolution: startResolution,
workflowInput: this.workflowInput,
})
state.setBlockState(startResolution.block.id, {
output: blockOutput,
executed: false,
executionTime: 0,
})
}
}
@@ -0,0 +1,166 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import { EdgeManager } from '@/executor/execution/edge-manager'
import { serializePauseSnapshot } from '@/executor/execution/snapshot-serializer'
import type { ExecutionContext } from '@/executor/types'
function createContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
blockStates: new Map(),
executedBlocks: new Set(),
blockLogs: [],
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: true,
startTime: '2026-01-01T00:00:00.000Z',
},
environmentVariables: {},
decisions: {
router: new Map(),
condition: new Map(),
},
completedLoops: new Set(),
activeExecutionPath: new Set(),
...overrides,
} as ExecutionContext
}
describe('serializePauseSnapshot', () => {
it('serializes batched parallel accumulated outputs for cross-process resume', () => {
const context = createContext({
parallelExecutions: new Map([
[
'parallel-1',
{
parallelId: 'parallel-1',
totalBranches: 3,
branchOutputs: new Map([[2, [{ output: 'current-batch' }]]]),
accumulatedOutputs: new Map([
[0, [{ output: 'batch-0' }]],
[1, [{ output: 'batch-1' }]],
]),
},
],
]),
})
const snapshot = serializePauseSnapshot(context, ['next-block'])
const serialized = JSON.parse(snapshot.snapshot)
expect(serialized.state.parallelExecutions?.['parallel-1']).toMatchObject({
branchOutputs: {
2: [{ output: 'current-batch' }],
},
accumulatedOutputs: {
0: [{ output: 'batch-0' }],
1: [{ output: 'batch-1' }],
},
})
})
it('serializes deactivated edge state for resume', () => {
const context = createContext()
const sourceNode = {
id: 'condition',
block: {} as DAGNode['block'],
incomingEdges: new Set<string>(),
outgoingEdges: new Map([['if-edge', { target: 'target', sourceHandle: 'condition-if' }]]),
metadata: {},
}
const targetNode = {
id: 'target',
block: {} as DAGNode['block'],
incomingEdges: new Set(['condition']),
outgoingEdges: new Map(),
metadata: {},
}
const activeSourceNode = {
id: 'active-source',
block: {} as DAGNode['block'],
incomingEdges: new Set<string>(),
outgoingEdges: new Map([['active-edge', { target: 'active-target' }]]),
metadata: {},
}
const activeTargetNode = {
id: 'active-target',
block: {} as DAGNode['block'],
incomingEdges: new Set(['active-source']),
outgoingEdges: new Map(),
metadata: {},
}
const dag: DAG = {
nodes: new Map([
[sourceNode.id, sourceNode],
[targetNode.id, targetNode],
[activeSourceNode.id, activeSourceNode],
[activeTargetNode.id, activeTargetNode],
]),
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
const edgeManager = new EdgeManager(dag)
edgeManager.processOutgoingEdges(sourceNode, { selectedOption: 'else' })
edgeManager.processOutgoingEdges(activeSourceNode, { result: true })
const snapshot = serializePauseSnapshot(context, ['next-block'], dag, edgeManager)
const serialized = JSON.parse(snapshot.snapshot)
expect(serialized.state.deactivatedEdges).toHaveLength(1)
expect(serialized.state.nodesWithActivatedEdge).toEqual(['active-target'])
})
it('rejects oversized snapshot values without full JSON serialization', () => {
const stringifySpy = vi.spyOn(JSON, 'stringify').mockImplementation(() => {
throw new Error('full stringify should not be used for compactness checks')
})
const context = createContext({
workflowVariables: {
oversized: {
type: 'string',
value: 'x'.repeat(9 * 1024 * 1024),
},
},
})
try {
expect(() => serializePauseSnapshot(context, ['next-block'])).toThrow(
'Cannot serialize pause snapshot with oversized workflow variables'
)
} finally {
stringifySpy.mockRestore()
}
})
it('preserves an explicit useDraftState=true even when the context is a deployed (server-side) context', () => {
const context = createContext({
isDeployedContext: true,
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: true,
startTime: '2026-01-01T00:00:00.000Z',
},
})
const snapshot = serializePauseSnapshot(context, ['next-block'])
const serialized = JSON.parse(snapshot.snapshot)
expect(serialized.metadata.useDraftState).toBe(true)
})
})
@@ -0,0 +1,269 @@
import { LARGE_VALUE_THRESHOLD_BYTES } from '@/lib/execution/payloads/large-value-ref'
import type { DAG } from '@/executor/dag/builder'
import type { EdgeManager } from '@/executor/execution/edge-manager'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types'
import type { ExecutionContext, SerializedSnapshot } from '@/executor/types'
const JSON_SYNTAX_BYTES = {
QUOTE: 1,
COLON: 1,
COMMA: 1,
ARRAY_BRACKETS: 2,
OBJECT_BRACES: 2,
NULL: 4,
} as const
function getEscapedJsonStringByteLength(value: string): number {
let bytes = JSON_SYNTAX_BYTES.QUOTE * 2
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index)
if (code === 0x22 || code === 0x5c) {
bytes += 2
} else if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) {
bytes += 2
} else if (code < 0x20) {
bytes += 6
} else if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1)
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4
index++
} else {
bytes += 6
}
} else if (code >= 0xdc00 && code <= 0xdfff) {
bytes += 6
} else if (code < 0x80) {
bytes += 1
} else if (code < 0x800) {
bytes += 2
} else {
bytes += 3
}
}
return bytes
}
function getPrimitiveJsonByteLength(value: unknown): number | undefined {
if (value === null) {
return JSON_SYNTAX_BYTES.NULL
}
if (typeof value === 'string') {
return getEscapedJsonStringByteLength(value)
}
if (typeof value === 'number') {
return Number.isFinite(value)
? Buffer.byteLength(String(value), 'utf8')
: JSON_SYNTAX_BYTES.NULL
}
if (typeof value === 'boolean') {
return value ? 4 : 5
}
if (typeof value === 'bigint') {
throw new TypeError('Do not know how to serialize a BigInt')
}
return undefined
}
function getBoundedJsonByteLength(
value: unknown,
maxBytes: number,
seen = new WeakSet<object>()
): number | undefined {
const primitiveSize = getPrimitiveJsonByteLength(value)
if (primitiveSize !== undefined) {
return primitiveSize
}
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
return undefined
}
if (!value || typeof value !== 'object') {
return undefined
}
if (seen.has(value)) {
throw new TypeError('Converting circular structure to JSON')
}
seen.add(value)
let bytes = Array.isArray(value)
? JSON_SYNTAX_BYTES.ARRAY_BRACKETS
: JSON_SYNTAX_BYTES.OBJECT_BRACES
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index++) {
if (index > 0) bytes += JSON_SYNTAX_BYTES.COMMA
const itemSize = getBoundedJsonByteLength(value[index], maxBytes - bytes, seen)
bytes += itemSize ?? JSON_SYNTAX_BYTES.NULL
if (bytes > maxBytes) return bytes
}
seen.delete(value)
return bytes
}
let hasEntries = false
for (const key of Object.keys(value)) {
const entryValue = (value as Record<string, unknown>)[key]
if (
entryValue === undefined ||
typeof entryValue === 'function' ||
typeof entryValue === 'symbol'
) {
continue
}
if (hasEntries) bytes += JSON_SYNTAX_BYTES.COMMA
bytes += getEscapedJsonStringByteLength(key) + JSON_SYNTAX_BYTES.COLON
const entrySize = getBoundedJsonByteLength(entryValue, maxBytes - bytes, seen)
bytes += entrySize ?? JSON_SYNTAX_BYTES.NULL
hasEntries = true
if (bytes > maxBytes) return bytes
}
seen.delete(value)
return bytes
}
function assertSnapshotValueIsCompact(value: unknown, label: string): void {
const byteLength = getBoundedJsonByteLength(value, LARGE_VALUE_THRESHOLD_BYTES)
if (byteLength !== undefined && byteLength > LARGE_VALUE_THRESHOLD_BYTES) {
throw new Error(`Cannot serialize pause snapshot with oversized ${label}; compact it first.`)
}
}
function mapFromEntries<T>(map?: Map<string, T>): Record<string, T> | undefined {
if (!map) return undefined
return Object.fromEntries(map)
}
function serializeLoopExecutions(
loopExecutions?: Map<string, any>
): Record<string, any> | undefined {
if (!loopExecutions) return undefined
const result: Record<string, any> = {}
for (const [loopId, scope] of loopExecutions.entries()) {
let currentIterationOutputs: any
if (scope.currentIterationOutputs instanceof Map) {
currentIterationOutputs = Object.fromEntries(scope.currentIterationOutputs)
} else {
currentIterationOutputs = scope.currentIterationOutputs ?? {}
}
result[loopId] = {
...scope,
currentIterationOutputs,
}
}
return result
}
function serializeParallelExecutions(
parallelExecutions?: Map<string, any>
): Record<string, any> | undefined {
if (!parallelExecutions) return undefined
const result: Record<string, any> = {}
for (const [parallelId, scope] of parallelExecutions.entries()) {
const branchOutputs =
scope.branchOutputs instanceof Map
? Object.fromEntries(scope.branchOutputs)
: (scope.branchOutputs ?? {})
const accumulatedOutputs =
scope.accumulatedOutputs instanceof Map
? Object.fromEntries(scope.accumulatedOutputs)
: (scope.accumulatedOutputs ?? {})
result[parallelId] = {
...scope,
branchOutputs,
accumulatedOutputs,
}
}
return result
}
export function serializePauseSnapshot(
context: ExecutionContext,
triggerBlockIds: string[],
dag?: DAG,
edgeManager?: EdgeManager
): SerializedSnapshot {
const metadataFromContext = context.metadata as ExecutionMetadata | undefined
let useDraftState: boolean
if (metadataFromContext?.useDraftState !== undefined) {
useDraftState = metadataFromContext.useDraftState
} else if (context.isDeployedContext === true) {
useDraftState = false
} else {
useDraftState = true
}
const dagIncomingEdges: Record<string, string[]> | undefined = dag
? Object.fromEntries(
Array.from(dag.nodes.entries()).map(([nodeId, node]) => [
nodeId,
Array.from(node.incomingEdges),
])
)
: undefined
const state: SerializableExecutionState = {
blockStates: Object.fromEntries(context.blockStates),
executedBlocks: Array.from(context.executedBlocks),
blockLogs: context.blockLogs,
decisions: {
router: Object.fromEntries(context.decisions.router),
condition: Object.fromEntries(context.decisions.condition),
},
completedLoops: Array.from(context.completedLoops),
loopExecutions: serializeLoopExecutions(context.loopExecutions),
parallelExecutions: serializeParallelExecutions(context.parallelExecutions),
parallelBlockMapping: mapFromEntries(context.parallelBlockMapping),
activeExecutionPath: Array.from(context.activeExecutionPath),
pendingQueue: triggerBlockIds,
dagIncomingEdges,
deactivatedEdges: edgeManager?.getDeactivatedEdges(),
nodesWithActivatedEdge: edgeManager?.getNodesWithActivatedEdge(),
}
assertSnapshotValueIsCompact(context.workflowVariables, 'workflow variables')
assertSnapshotValueIsCompact(state.loopExecutions, 'loop execution state')
const workspaceId = metadataFromContext?.workspaceId ?? context.workspaceId
if (!workspaceId) {
throw new Error(
`Cannot serialize pause snapshot: missing workspaceId for workflow ${context.workflowId}`
)
}
const executionMetadata: ExecutionMetadata = {
requestId:
metadataFromContext?.requestId ?? context.executionId ?? context.workflowId ?? 'unknown',
executionId: context.executionId ?? 'unknown',
workflowId: context.workflowId,
workspaceId,
userId: metadataFromContext?.userId ?? '',
sessionUserId: metadataFromContext?.sessionUserId,
workflowUserId: metadataFromContext?.workflowUserId,
triggerType: metadataFromContext?.triggerType ?? 'manual',
triggerBlockId: triggerBlockIds[0],
useDraftState,
startTime: metadataFromContext?.startTime ?? new Date().toISOString(),
isClientSession: metadataFromContext?.isClientSession,
executionMode: metadataFromContext?.executionMode,
}
const snapshot = new ExecutionSnapshot(
executionMetadata,
context.workflow,
{},
context.workflowVariables,
context.selectedOutputs,
state
)
return {
snapshot: snapshot.toJSON(),
triggerIds: triggerBlockIds,
}
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata } from '@/executor/execution/types'
const metadata: ExecutionMetadata = {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
startTime: '2026-05-06T00:00:00.000Z',
}
describe('ExecutionSnapshot', () => {
it('normalizes untyped persisted execution state at construction', () => {
const variable = { id: 'var-1', name: 'brand', type: 'plain', value: 'myfitness' }
const snapshot = new ExecutionSnapshot(
metadata,
{ blocks: [] },
{},
[variable],
['agent.content', 123, 'function.result']
)
expect(snapshot.workflowVariables).toEqual({ 'var-1': variable })
expect(snapshot.selectedOutputs).toEqual(['agent.content', 'function.result'])
})
})
+51
View File
@@ -0,0 +1,51 @@
import { normalizeStringArray } from '@/lib/core/utils/arrays'
import { normalizeWorkflowVariables } from '@/lib/core/utils/records'
import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types'
export class ExecutionSnapshot {
public readonly metadata: ExecutionMetadata
public readonly workflow: any
public readonly input: any
public readonly workflowVariables: Record<string, any>
public readonly selectedOutputs: string[]
public readonly state?: SerializableExecutionState
constructor(
metadata: ExecutionMetadata,
workflow: any,
input: any,
workflowVariables: unknown,
selectedOutputs: unknown = [],
state?: SerializableExecutionState
) {
this.metadata = metadata
this.workflow = workflow
this.input = input
this.workflowVariables = normalizeWorkflowVariables(workflowVariables)
this.selectedOutputs = normalizeStringArray(selectedOutputs)
this.state = state
}
toJSON(): string {
return JSON.stringify({
metadata: this.metadata,
workflow: this.workflow,
input: this.input,
workflowVariables: this.workflowVariables,
selectedOutputs: this.selectedOutputs,
state: this.state,
})
}
static fromJSON(json: string): ExecutionSnapshot {
const data = JSON.parse(json)
return new ExecutionSnapshot(
data.metadata,
data.workflow,
data.input,
data.workflowVariables,
data.selectedOutputs,
data.state
)
}
}
+77
View File
@@ -0,0 +1,77 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { ExecutionState } from '@/executor/execution/state'
describe('ExecutionState', () => {
it('returns exact suffixed cached node outputs', () => {
const state = new ExecutionState()
state.setBlockOutput('producer₍1₎', { value: 'branch-1' })
state.setBlockOutput('producer_loop1', { value: 'loop-1' })
expect(state.getBlockOutput('producer₍1₎')).toEqual({ value: 'branch-1' })
expect(state.getBlockOutput('producer_loop1')).toEqual({ value: 'loop-1' })
})
it('prefers branch-local cloned outputs when resolving original block references', () => {
const state = new ExecutionState()
state.setBlockOutput('producer', { value: 'branch-0' })
state.setBlockOutput('producer__cloneaaa__obranch-2', { value: 'branch-2' })
expect(state.getBlockOutput('producer', 'consumer__clonebbb__obranch-2')).toEqual({
value: 'branch-2',
})
})
it('keeps cloned parallel branch references scoped to the same branch index', () => {
const state = new ExecutionState()
state.setBlockOutput('producer__cloneaaa__obranch-2₍0₎', { value: 'branch-0' })
state.setBlockOutput('producer__cloneaaa__obranch-2₍1₎', { value: 'branch-1' })
expect(state.getBlockOutput('producer', 'consumer__clonebbb__obranch-2₍1₎')).toEqual({
value: 'branch-1',
})
})
it('does not fall back to another branch when cloned scoped output is missing', () => {
const state = new ExecutionState()
state.setBlockOutput('producer₍0₎', { value: 'wrong-branch' })
expect(state.getBlockOutput('producer', 'consumer__clonebbb__obranch-2')).toBeUndefined()
})
it('resolves regular sibling outputs from the same parent parallel branch', () => {
const state = new ExecutionState()
state.setBlockOutput('producer₍2₎', { value: 'parent-branch-2' })
expect(state.getBlockOutput('producer', 'consumer__clonebbb__obranch-2₍0₎')).toEqual({
value: 'parent-branch-2',
})
})
it('does not fall back to direct branch-zero output from cloned nodes', () => {
const state = new ExecutionState()
state.setBlockOutput('producer', { value: 'branch-0' })
expect(state.getBlockOutput('producer', 'consumer__clonebbb__obranch-2')).toBeUndefined()
})
it('resolves branch-zero sibling output deterministically for unsuffixed nested branch nodes', () => {
const state = new ExecutionState()
state.setBlockOutput('producer₍1₎', { value: 'branch-1' })
state.setBlockOutput('producer₍0₎', { value: 'branch-0' })
expect(state.getBlockOutput('producer', 'nested-condition')).toEqual({ value: 'branch-0' })
})
it('prefers stable branch-zero aliases when later batches reuse local branch ids', () => {
const state = new ExecutionState()
state.setBlockOutput('producer__obranch-0', { value: 'global-branch-0' })
state.setBlockOutput('producer₍0₎', { value: 'later-batch-local-0' })
expect(state.getBlockOutput('producer', 'after-parallel')).toEqual({
value: 'global-branch-0',
})
})
})
+176
View File
@@ -0,0 +1,176 @@
import type { BlockStateController } from '@/executor/execution/types'
import type { BlockState, NormalizedBlockOutput } from '@/executor/types'
import { SubflowNodeIdCodec } from '@/executor/utils/subflow-node-id-codec'
import {
buildOuterBranchScopedId,
extractOuterBranchIndex,
stripCloneSuffixes,
} from '@/executor/utils/subflow-utils'
function normalizeLookupId(id: string): string {
return SubflowNodeIdCodec.normalizeLookupId(id)
}
function extractBranchSuffix(id: string): string {
return SubflowNodeIdCodec.extractBranchSuffix(id)
}
function extractLoopSuffix(id: string): string {
return SubflowNodeIdCodec.extractLoopSuffix(id)
}
export interface LoopScope {
iteration: number
currentIterationOutputs: Map<string, NormalizedBlockOutput>
allIterationOutputs: NormalizedBlockOutput[][]
maxIterations?: number
item?: any
items?: any[]
condition?: string
loopType?: 'for' | 'forEach' | 'while' | 'doWhile'
skipFirstConditionCheck?: boolean
skippedAtStart?: boolean
/** Error message if loop validation failed (e.g., exceeded max iterations) */
validationError?: string
}
export interface ParallelScope {
parallelId: string
totalBranches: number
batchSize?: number
currentBatchStart?: number
currentBatchSize?: number
accumulatedOutputs?: Map<number, NormalizedBlockOutput[]>
branchOutputs: Map<number, NormalizedBlockOutput[]>
items?: any[]
/** Error message if parallel validation failed (e.g., exceeded max branches) */
validationError?: string
/** Whether the parallel has an empty distribution and should be skipped */
isEmpty?: boolean
}
export class ExecutionState implements BlockStateController {
private readonly blockStates: Map<string, BlockState>
private readonly executedBlocks: Set<string>
constructor(blockStates?: Map<string, BlockState>, executedBlocks?: Set<string>) {
this.blockStates = blockStates ?? new Map()
this.executedBlocks = executedBlocks ?? new Set()
}
getBlockStates(): ReadonlyMap<string, BlockState> {
return this.blockStates
}
getExecutedBlocks(): ReadonlySet<string> {
return this.executedBlocks
}
getBlockOutput(blockId: string, currentNodeId?: string): NormalizedBlockOutput | undefined {
const normalizedId = normalizeLookupId(blockId)
if (normalizedId !== blockId) {
return this.blockStates.get(blockId)?.output
}
if (currentNodeId) {
const scopedOutput = this.getScopedBlockOutput(blockId, currentNodeId)
if (scopedOutput !== undefined) {
return scopedOutput
}
if (extractOuterBranchIndex(currentNodeId) !== undefined) {
return undefined
}
}
const direct = this.blockStates.get(blockId)?.output
if (direct !== undefined) {
return direct
}
if (currentNodeId && extractBranchSuffix(currentNodeId) === '') {
const stableBranchZeroOutput = this.blockStates.get(
buildOuterBranchScopedId(blockId, 0)
)?.output
if (stableBranchZeroOutput !== undefined) {
return stableBranchZeroOutput
}
const branchZeroOutput = this.blockStates.get(
`${blockId}₍0₎${extractLoopSuffix(currentNodeId)}`
)?.output
if (branchZeroOutput !== undefined) {
return branchZeroOutput
}
}
for (const [storedId, state] of this.blockStates.entries()) {
if (normalizeLookupId(storedId) === blockId) {
return state.output
}
}
return undefined
}
private getScopedBlockOutput(
blockId: string,
currentNodeId: string
): NormalizedBlockOutput | undefined {
const currentBranchSuffix = extractBranchSuffix(currentNodeId)
const loopSuffix = extractLoopSuffix(currentNodeId)
const currentOuterBranchIndex = extractOuterBranchIndex(currentNodeId)
if (currentOuterBranchIndex !== undefined) {
for (const [storedId, state] of this.blockStates.entries()) {
if (stripCloneSuffixes(storedId) !== blockId) continue
if (extractOuterBranchIndex(storedId) !== currentOuterBranchIndex) continue
if (extractBranchSuffix(storedId) !== currentBranchSuffix) continue
if (extractLoopSuffix(storedId) !== loopSuffix) continue
return state.output
}
const siblingBranchOutput = this.blockStates.get(
`${blockId}${currentOuterBranchIndex}`
)?.output
if (siblingBranchOutput !== undefined) {
return siblingBranchOutput
}
} else {
const withSuffix = `${blockId}${currentBranchSuffix}${loopSuffix}`
const suffixedOutput = this.blockStates.get(withSuffix)?.output
if (suffixedOutput !== undefined) {
return suffixedOutput
}
}
return undefined
}
setBlockOutput(blockId: string, output: NormalizedBlockOutput, executionTime = 0): void {
this.blockStates.set(blockId, { output, executed: true, executionTime })
this.executedBlocks.add(blockId)
}
setBlockState(blockId: string, state: BlockState): void {
this.blockStates.set(blockId, state)
if (state.executed) {
this.executedBlocks.add(blockId)
} else {
this.executedBlocks.delete(blockId)
}
}
deleteBlockState(blockId: string): void {
this.blockStates.delete(blockId)
this.executedBlocks.delete(blockId)
}
unmarkExecuted(blockId: string): void {
this.executedBlocks.delete(blockId)
}
hasExecuted(blockId: string): boolean {
return this.executedBlocks.has(blockId)
}
}
+271
View File
@@ -0,0 +1,271 @@
import type { Edge } from 'reactflow'
import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types'
import type { NodeMetadata } from '@/executor/dag/types'
import type {
BlockLog,
BlockState,
NormalizedBlockOutput,
StreamingExecution,
} from '@/executor/types'
import type { RunFromBlockContext } from '@/executor/utils/run-from-block'
import type { SubflowType } from '@/stores/workflows/workflow/types'
export interface ExecutionMetadata {
requestId: string
executionId: string
workflowId: string
workspaceId: string
userId: string
sessionUserId?: string
workflowUserId?: string
triggerType: string
triggerBlockId?: string
useDraftState: boolean
startTime: string
isClientSession?: boolean
enforceCredentialAccess?: boolean
pendingBlocks?: string[]
resumeFromSnapshot?: boolean
resumeTerminalNoop?: boolean
credentialAccountUserId?: string
workflowStateOverride?: {
blocks: Record<string, any>
edges: Edge[]
loops?: Record<string, any>
parallels?: Record<string, any>
deploymentVersionId?: string
}
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
callChain?: string[]
correlation?: AsyncExecutionCorrelation
executionMode?: 'sync' | 'stream' | 'async'
}
export interface SerializableExecutionState {
blockStates: Record<string, BlockState>
executedBlocks: string[]
blockLogs: BlockLog[]
decisions: {
router: Record<string, string>
condition: Record<string, string>
}
completedLoops: string[]
loopExecutions?: Record<string, any>
parallelExecutions?: Record<string, any>
parallelBlockMapping?: Record<string, any>
activeExecutionPath: string[]
pendingQueue?: string[]
remainingEdges?: Edge[]
resumeTerminalNoop?: boolean
dagIncomingEdges?: Record<string, string[]>
deactivatedEdges?: string[]
nodesWithActivatedEdge?: string[]
completedPauseContexts?: string[]
}
/**
* Represents the iteration state of an ancestor subflow in a nested chain.
* Used to propagate parent iteration context through SSE events for both
* loop-in-loop and parallel-in-parallel nesting hierarchies.
*/
export interface ParentIteration {
iterationCurrent: number
iterationTotal?: number
iterationType: SubflowType
iterationContainerId: string
}
export interface IterationContext {
iterationCurrent: number
iterationTotal?: number
iterationType: SubflowType
/**
* Block ID of the loop or parallel container owning this iteration.
* Optional because generic `<loop.index>` references may resolve before
* the container ID is known (e.g., via `context.loopScope` fallback).
* Always present on {@link ParentIteration} entries since those are built
* from fully resolved ancestor loops.
*/
iterationContainerId?: string
parentIterations?: ParentIteration[]
}
/**
* Metadata passed to block handlers that execute within subflow contexts
* (loops, parallels, child workflows). Extends the DAG node metadata with
* runtime identifiers needed for execution tracking.
*/
export interface WorkflowNodeMetadata
extends Pick<
NodeMetadata,
'subflowType' | 'subflowId' | 'branchIndex' | 'branchTotal' | 'originalBlockId' | 'isLoopNode'
> {
nodeId: string
loopId?: string
parallelId?: string
executionOrder?: number
}
export interface ChildWorkflowContext {
/** The workflow block's ID in the parent execution */
parentBlockId: string
/** Display name of the child workflow */
workflowName: string
/** Child workflow ID */
workflowId: string
/** Nesting depth (1 = first level child) */
depth: number
}
export interface ExecutionCallbacks {
onStream?: (streamingExec: StreamingExecution) => Promise<void>
onBlockStart?: (
blockId: string,
blockName: string,
blockType: string,
executionOrder: number,
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
onBlockComplete?: (
blockId: string,
blockName: string,
blockType: string,
output: any,
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
/** Fires immediately after instanceId is generated, before child execution begins. */
onChildWorkflowInstanceReady?: (
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext,
executionOrder?: number,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
}
/** In-flight block-output redaction policy (the resolved `blockOutputs` stage). */
export interface PiiBlockOutputRedaction {
enabled: boolean
/** Presidio entity types to mask. Empty = redact all detected PII. */
entityTypes: string[]
/** Language whose Presidio recognizers apply. */
language: string
}
export interface ContextExtensions {
workspaceId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
stream?: boolean
selectedOutputs?: string[]
edges?: Array<{ source: string; target: string }>
isDeployedContext?: boolean
enforceCredentialAccess?: boolean
isChildExecution?: boolean
resumeFromSnapshot?: boolean
resumePendingQueue?: string[]
remainingEdges?: Array<{
source: string
target: string
sourceHandle?: string
targetHandle?: string
}>
dagIncomingEdges?: Record<string, string[]>
snapshotState?: SerializableExecutionState
metadata?: ExecutionMetadata
/**
* AbortSignal for cancellation support.
* When aborted, the execution should stop gracefully.
*/
abortSignal?: AbortSignal
includeFileBase64?: boolean
base64MaxBytes?: number
/**
* When enabled, every block output is masked in-flight before downstream blocks
* consume it. Resolved from the org/workspace PII redaction policy's
* `blockOutputs` stage. Serializable, so it crosses into the trigger.dev worker.
*/
piiBlockOutputRedaction?: PiiBlockOutputRedaction
onStream?: (streamingExecution: StreamingExecution) => Promise<void>
onBlockStart?: (
blockId: string,
blockName: string,
blockType: string,
executionOrder: number,
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
onBlockComplete?: (
blockId: string,
blockName: string,
blockType: string,
output: {
input?: any
output: NormalizedBlockOutput
executionTime: number
startedAt: string
executionOrder: number
endedAt: string
/** Per-invocation unique ID linking this workflow block execution to its child block events. */
childWorkflowInstanceId?: string
},
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
/** Context identifying this execution as a child of a workflow block */
childWorkflowContext?: ChildWorkflowContext
/** Fires immediately after instanceId is generated, before child execution begins. */
onChildWorkflowInstanceReady?: (
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext,
executionOrder?: number,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
/**
* Run-from-block configuration. When provided, executor runs in partial
* execution mode starting from the specified block.
*/
runFromBlockContext?: RunFromBlockContext
/**
* Stop execution after this block completes. Used for "run until block" feature.
*/
stopAfterBlockId?: string
/**
* Ordered list of workflow IDs in the current call chain, used for cycle detection.
* Each hop appends the current workflow ID before making outgoing requests.
*/
callChain?: string[]
}
export interface WorkflowInput {
[key: string]: unknown
}
interface BlockStateReader {
getBlockOutput(blockId: string, currentNodeId?: string): NormalizedBlockOutput | undefined
hasExecuted(blockId: string): boolean
}
export interface BlockStateWriter {
setBlockOutput(blockId: string, output: NormalizedBlockOutput, executionTime?: number): void
setBlockState(blockId: string, state: BlockState): void
deleteBlockState(blockId: string): void
unmarkExecuted(blockId: string): void
}
export type BlockStateController = BlockStateReader & BlockStateWriter
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,228 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MEMORY } from '@/executor/constants'
import { Memory } from '@/executor/handlers/agent/memory'
import type { Message } from '@/executor/handlers/agent/types'
vi.mock('@/lib/tokenization/estimators', () => ({
getAccurateTokenCount: vi.fn((text: string) => {
return Math.ceil(text.length / 4)
}),
}))
describe('Memory', () => {
let memoryService: Memory
beforeEach(() => {
memoryService = new Memory()
})
describe('applyWindow (message-based)', () => {
it('should keep last N messages', () => {
const messages: Message[] = [
{ role: 'user', content: 'Message 1' },
{ role: 'assistant', content: 'Response 1' },
{ role: 'user', content: 'Message 2' },
{ role: 'assistant', content: 'Response 2' },
{ role: 'user', content: 'Message 3' },
{ role: 'assistant', content: 'Response 3' },
]
const result = (memoryService as any).applyWindow(messages, 4)
expect(result.length).toBe(4)
expect(result[0].content).toBe('Message 2')
expect(result[3].content).toBe('Response 3')
})
it('should return all messages if limit exceeds array length', () => {
const messages: Message[] = [
{ role: 'user', content: 'Test' },
{ role: 'assistant', content: 'Response' },
]
const result = (memoryService as any).applyWindow(messages, 10)
expect(result.length).toBe(2)
})
it('should handle invalid window size', () => {
const messages: Message[] = [{ role: 'user', content: 'Test' }]
const result = (memoryService as any).applyWindow(messages, Number.NaN)
expect(result).toEqual(messages)
})
it('should handle zero limit', () => {
const messages: Message[] = [{ role: 'user', content: 'Test' }]
const result = (memoryService as any).applyWindow(messages, 0)
expect(result).toEqual(messages)
})
})
describe('applyTokenWindow (token-based)', () => {
it('should keep messages within token limit', () => {
const messages: Message[] = [
{ role: 'user', content: 'Short' },
{ role: 'assistant', content: 'This is a longer response message' },
{ role: 'user', content: 'Another user message here' },
{ role: 'assistant', content: 'Final response' },
]
const result = (memoryService as any).applyTokenWindow(messages, 15, 'gpt-4o')
expect(result.length).toBeGreaterThan(0)
expect(result.length).toBeLessThan(messages.length)
expect(result[result.length - 1].content).toBe('Final response')
})
it('should include at least 1 message even if it exceeds limit', () => {
const messages: Message[] = [
{
role: 'user',
content:
'This is a very long message that definitely exceeds our small token limit of just 5 tokens',
},
]
const result = (memoryService as any).applyTokenWindow(messages, 5, 'gpt-4o')
expect(result.length).toBe(1)
expect(result[0].content).toBe(messages[0].content)
})
it('should process messages from newest to oldest', () => {
const messages: Message[] = [
{ role: 'user', content: 'Old message' },
{ role: 'assistant', content: 'Old response' },
{ role: 'user', content: 'New message' },
{ role: 'assistant', content: 'New response' },
]
const result = (memoryService as any).applyTokenWindow(messages, 10, 'gpt-4o')
expect(result[result.length - 1].content).toBe('New response')
})
it('should handle invalid token limit', () => {
const messages: Message[] = [{ role: 'user', content: 'Test' }]
const result = (memoryService as any).applyTokenWindow(messages, Number.NaN, 'gpt-4o')
expect(result).toEqual(messages)
})
it('should handle zero or negative token limit', () => {
const messages: Message[] = [{ role: 'user', content: 'Test' }]
const result1 = (memoryService as any).applyTokenWindow(messages, 0, 'gpt-4o')
expect(result1).toEqual(messages)
const result2 = (memoryService as any).applyTokenWindow(messages, -5, 'gpt-4o')
expect(result2).toEqual(messages)
})
it('should work without model specified', () => {
const messages: Message[] = [{ role: 'user', content: 'Test message' }]
const result = (memoryService as any).applyTokenWindow(messages, 100, undefined)
expect(result.length).toBe(1)
})
it('should handle empty messages array', () => {
const messages: Message[] = []
const result = (memoryService as any).applyTokenWindow(messages, 100, 'gpt-4o')
expect(result).toEqual([])
})
})
describe('validateConversationId', () => {
it('should throw error for missing conversationId', () => {
expect(() => {
;(memoryService as any).validateConversationId(undefined)
}).toThrow('Conversation ID is required')
})
it('should throw error for empty conversationId', () => {
expect(() => {
;(memoryService as any).validateConversationId(' ')
}).toThrow('Conversation ID is required')
})
it('should throw error for too long conversationId', () => {
const longId = 'a'.repeat(MEMORY.MAX_CONVERSATION_ID_LENGTH + 1)
expect(() => {
;(memoryService as any).validateConversationId(longId)
}).toThrow('Conversation ID too long')
})
it('should accept valid conversationId', () => {
expect(() => {
;(memoryService as any).validateConversationId('user-123')
}).not.toThrow()
})
})
describe('validateContent', () => {
it('should throw error for content exceeding max size', () => {
const largeContent = 'x'.repeat(MEMORY.MAX_MESSAGE_CONTENT_BYTES + 1)
expect(() => {
;(memoryService as any).validateContent(largeContent)
}).toThrow('Message content too large')
})
it('should accept content within limit', () => {
const content = 'Normal sized content'
expect(() => {
;(memoryService as any).validateContent(content)
}).not.toThrow()
})
})
describe('sanitizeMessageForStorage', () => {
it('should strip file payloads but preserve tool-call fields before memory persistence', () => {
const message: Message = {
role: 'user',
content: 'Analyze this file',
executionId: 'exec-1',
files: [
{
id: 'file-1',
key: 'workspace/ws-1/example.png',
name: 'example.png',
url: '/api/files/serve/workspace%2Fws-1%2Fexample.png?context=workspace',
size: 128,
type: 'image/png',
base64: 'iVBORw0KGgo=',
},
],
tool_calls: [{ id: 'call-1' }],
}
expect((memoryService as any).sanitizeMessageForStorage(message)).toEqual({
role: 'user',
content: 'Analyze this file',
executionId: 'exec-1',
tool_calls: [{ id: 'call-1' }],
})
})
})
describe('Token-based vs Message-based comparison', () => {
it('should produce different results for same limit concept', () => {
const messages: Message[] = [
{ role: 'user', content: 'A' },
{
role: 'assistant',
content: 'This is a much longer response that takes many more tokens',
},
{ role: 'user', content: 'B' },
]
const messageResult = (memoryService as any).applyWindow(messages, 2)
expect(messageResult.length).toBe(2)
const tokenResult = (memoryService as any).applyTokenWindow(messages, 10, 'gpt-4o')
expect(tokenResult.length).toBeGreaterThanOrEqual(1)
})
})
})
+300
View File
@@ -0,0 +1,300 @@
import { db } from '@sim/db'
import { memory } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, sql } from 'drizzle-orm'
import { redactObjectStrings } from '@/lib/logs/execution/pii-redaction'
import { getAccurateTokenCount } from '@/lib/tokenization/estimators'
import { MEMORY } from '@/executor/constants'
import type { AgentInputs, Message } from '@/executor/handlers/agent/types'
import type { ExecutionContext } from '@/executor/types'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
const logger = createLogger('Memory')
export class Memory {
async fetchMemoryMessages(ctx: ExecutionContext, inputs: AgentInputs): Promise<Message[]> {
if (!inputs.memoryType || inputs.memoryType === 'none') {
return []
}
const workspaceId = this.requireWorkspaceId(ctx)
this.validateConversationId(inputs.conversationId)
const messages = await this.fetchMemory(workspaceId, inputs.conversationId!)
switch (inputs.memoryType) {
case 'conversation':
return this.applyContextWindowLimit(messages, inputs.model)
case 'sliding_window': {
const limit = this.parsePositiveInt(
inputs.slidingWindowSize,
MEMORY.DEFAULT_SLIDING_WINDOW_SIZE
)
return this.applyWindow(messages, limit)
}
case 'sliding_window_tokens': {
const maxTokens = this.parsePositiveInt(
inputs.slidingWindowTokens,
MEMORY.DEFAULT_SLIDING_WINDOW_TOKENS
)
return this.applyTokenWindow(messages, maxTokens, inputs.model)
}
default:
return messages
}
}
async appendToMemory(
ctx: ExecutionContext,
inputs: AgentInputs,
message: Message
): Promise<void> {
if (!inputs.memoryType || inputs.memoryType === 'none') {
return
}
const workspaceId = this.requireWorkspaceId(ctx)
this.validateConversationId(inputs.conversationId)
message = await this.maskContentForStorage(ctx, message)
this.validateContent(message.content)
const key = inputs.conversationId!
await this.appendMessage(workspaceId, key, message)
logger.debug('Appended message to memory', {
workspaceId,
key,
role: message.role,
})
}
async seedMemory(ctx: ExecutionContext, inputs: AgentInputs, messages: Message[]): Promise<void> {
if (!inputs.memoryType || inputs.memoryType === 'none') {
return
}
const workspaceId = this.requireWorkspaceId(ctx)
const conversationMessages = messages.filter((m) => m.role !== 'system')
if (conversationMessages.length === 0) {
return
}
this.validateConversationId(inputs.conversationId)
const key = inputs.conversationId!
let messagesToStore = conversationMessages
if (inputs.memoryType === 'sliding_window') {
const limit = this.parsePositiveInt(
inputs.slidingWindowSize,
MEMORY.DEFAULT_SLIDING_WINDOW_SIZE
)
messagesToStore = this.applyWindow(conversationMessages, limit)
} else if (inputs.memoryType === 'sliding_window_tokens') {
const maxTokens = this.parsePositiveInt(
inputs.slidingWindowTokens,
MEMORY.DEFAULT_SLIDING_WINDOW_TOKENS
)
messagesToStore = this.applyTokenWindow(conversationMessages, maxTokens, inputs.model)
}
messagesToStore = await Promise.all(
messagesToStore.map((message) => this.maskContentForStorage(ctx, message))
)
await this.seedMemoryRecord(workspaceId, key, messagesToStore)
logger.debug('Seeded memory', {
workspaceId,
key,
count: messagesToStore.length,
})
}
/**
* Handlers persist messages to memory before the executor redacts block
* output, so mask content here too when the block-output stage is enabled —
* otherwise raw PII is stored in the memory table and read back on later runs.
* `onFailure: 'throw'` aborts rather than persisting unredacted content.
*/
private async maskContentForStorage(ctx: ExecutionContext, message: Message): Promise<Message> {
if (!ctx.piiBlockOutputRedaction?.enabled || !message.content) {
return message
}
return {
...message,
content: await redactObjectStrings(message.content, {
entityTypes: ctx.piiBlockOutputRedaction.entityTypes,
language: ctx.piiBlockOutputRedaction.language,
onFailure: 'throw',
}),
}
}
private requireWorkspaceId(ctx: ExecutionContext): string {
if (!ctx.workspaceId) {
throw new Error('workspaceId is required for memory operations')
}
return ctx.workspaceId
}
private applyWindow(messages: Message[], limit: number): Message[] {
return messages.slice(-limit)
}
private sanitizeMessageForStorage(message: Message): Message {
const { files: _files, ...messageWithoutFiles } = message
return messageWithoutFiles
}
private applyTokenWindow(messages: Message[], maxTokens: number, model?: string): Message[] {
const result: Message[] = []
let tokenCount = 0
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
const msgTokens = getAccurateTokenCount(msg.content, model)
if (tokenCount + msgTokens <= maxTokens) {
result.unshift(msg)
tokenCount += msgTokens
} else if (result.length === 0) {
result.unshift(msg)
break
} else {
break
}
}
return result
}
private applyContextWindowLimit(messages: Message[], model?: string): Message[] {
if (!model) return messages
for (const provider of Object.values(PROVIDER_DEFINITIONS)) {
if (provider.contextInformationAvailable === false) continue
const matchesPattern = provider.modelPatterns?.some((p) => p.test(model))
const matchesModel = provider.models.some((m) => m.id === model)
if (matchesPattern || matchesModel) {
const modelDef = provider.models.find((m) => m.id === model)
if (modelDef?.contextWindow) {
const maxTokens = Math.floor(modelDef.contextWindow * MEMORY.CONTEXT_WINDOW_UTILIZATION)
return this.applyTokenWindow(messages, maxTokens, model)
}
}
}
return messages
}
private async fetchMemory(workspaceId: string, key: string): Promise<Message[]> {
const result = await db
.select({ data: memory.data })
.from(memory)
.where(and(eq(memory.workspaceId, workspaceId), eq(memory.key, key)))
.limit(1)
if (result.length === 0) return []
const data = result[0].data
if (!Array.isArray(data)) return []
return data
.filter(
(msg): msg is Message =>
msg &&
typeof msg === 'object' &&
'role' in msg &&
'content' in msg &&
['system', 'user', 'assistant'].includes(msg.role) &&
typeof msg.content === 'string'
)
.map((msg) => this.sanitizeMessageForStorage(msg))
}
private async seedMemoryRecord(
workspaceId: string,
key: string,
messages: Message[]
): Promise<void> {
const now = new Date()
const sanitizedMessages = messages.map((message) => this.sanitizeMessageForStorage(message))
await db
.insert(memory)
.values({
id: generateId(),
workspaceId,
key,
data: sanitizedMessages,
createdAt: now,
updatedAt: now,
})
.onConflictDoNothing()
}
private async appendMessage(workspaceId: string, key: string, message: Message): Promise<void> {
const now = new Date()
const sanitizedMessage = this.sanitizeMessageForStorage(message)
await db
.insert(memory)
.values({
id: generateId(),
workspaceId,
key,
data: [sanitizedMessage],
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [memory.workspaceId, memory.key],
set: {
data: sql`${memory.data} || ${JSON.stringify([sanitizedMessage])}::jsonb`,
updatedAt: now,
},
})
}
private parsePositiveInt(value: string | undefined, defaultValue: number): number {
if (!value) return defaultValue
const parsed = Number.parseInt(value, 10)
if (Number.isNaN(parsed) || parsed <= 0) return defaultValue
return parsed
}
private validateConversationId(conversationId?: string): void {
if (!conversationId || conversationId.trim() === '') {
throw new Error('Conversation ID is required')
}
if (conversationId.length > MEMORY.MAX_CONVERSATION_ID_LENGTH) {
throw new Error(
`Conversation ID too long (max ${MEMORY.MAX_CONVERSATION_ID_LENGTH} characters)`
)
}
}
private validateContent(content: string): void {
const size = Buffer.byteLength(content, 'utf8')
if (size > MEMORY.MAX_MESSAGE_CONTENT_BYTES) {
throw new Error(
`Message content too large (${size} bytes, max ${MEMORY.MAX_MESSAGE_CONTENT_BYTES})`
)
}
}
}
export const memoryService = new Memory()
@@ -0,0 +1,50 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { limitMock } = vi.hoisted(() => ({ limitMock: vi.fn() }))
vi.mock('@sim/db', () => ({
db: { select: () => ({ from: () => ({ where: () => ({ limit: limitMock }) }) }) },
skill: { workspaceId: 'workspaceId', name: 'name', content: 'content' },
}))
vi.mock('@sim/logger', () => ({
createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }),
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn(() => ({})),
eq: vi.fn(() => ({})),
inArray: vi.fn(() => ({})),
}))
import { resolveSkillContent } from './skills-resolver'
// resolveSkillContent is the shared resolver invoked when the mothership calls
// load_user_skill (and when a workflow agent block calls load_skill).
describe('resolveSkillContent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns null without a skill name or workspace', async () => {
expect(await resolveSkillContent('', 'ws-1')).toBeNull()
expect(await resolveSkillContent('x', '')).toBeNull()
})
it('resolves builtin skills without touching the database', async () => {
const content = await resolveSkillContent('research', 'ws-1')
expect(content).toBeTruthy()
expect(limitMock).not.toHaveBeenCalled()
})
it('resolves a workspace user skill by name', async () => {
limitMock.mockResolvedValue([{ content: '# Playbook', name: 'posthog-playbook' }])
expect(await resolveSkillContent('posthog-playbook', 'ws-1')).toBe('# Playbook')
})
it('returns null when the user skill is not found', async () => {
limitMock.mockResolvedValue([])
expect(await resolveSkillContent('missing', 'ws-1')).toBeNull()
})
})
@@ -0,0 +1,165 @@
import { db } from '@sim/db'
import { skill } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { getBuiltinSkillById, getBuiltinSkillByName } from '@/lib/workflows/skills/builtin-skills'
import type { SkillInput } from '@/executor/handlers/agent/types'
const logger = createLogger('SkillsResolver')
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
interface SkillMetadata {
name: string
description: string
}
/**
* Fetch skill metadata (name + description) for system prompt injection.
* Only returns lightweight data so the LLM knows what skills are available.
*/
export async function resolveSkillMetadata(
skillInputs: SkillInput[],
workspaceId: string
): Promise<SkillMetadata[]> {
if (!skillInputs.length || !workspaceId) return []
const metadata: SkillMetadata[] = []
const dbSkillIds: string[] = []
for (const input of skillInputs) {
const builtin = getBuiltinSkillById(input.skillId)
if (builtin) {
metadata.push({ name: builtin.name, description: builtin.description })
} else {
dbSkillIds.push(input.skillId)
}
}
if (dbSkillIds.length === 0) return metadata
try {
const rows = await db
.select({ name: skill.name, description: skill.description })
.from(skill)
.where(and(eq(skill.workspaceId, workspaceId), inArray(skill.id, dbSkillIds)))
return [...metadata, ...rows]
} catch (error) {
logger.error('Failed to resolve skill metadata', { error, dbSkillIds, workspaceId })
return metadata
}
}
/**
* Fetch full skill content for a load_skill tool response.
* Called when the LLM decides a skill is relevant and invokes load_skill.
*/
export async function resolveSkillContent(
skillName: string,
workspaceId: string
): Promise<string | null> {
if (!skillName || !workspaceId) return null
const builtin = getBuiltinSkillByName(skillName)
if (builtin) return builtin.content
try {
const rows = await db
.select({ content: skill.content, name: skill.name })
.from(skill)
.where(and(eq(skill.workspaceId, workspaceId), eq(skill.name, skillName)))
.limit(1)
if (rows.length === 0) {
logger.warn('Skill not found', { skillName, workspaceId })
return null
}
return rows[0].content
} catch (error) {
logger.error('Failed to resolve skill content', { error, skillName, workspaceId })
return null
}
}
export async function resolveSkillContentById(
skillId: string,
workspaceId: string
): Promise<{ name: string; content: string } | null> {
if (!skillId || !workspaceId) return null
const builtin = getBuiltinSkillById(skillId)
if (builtin) return { name: builtin.name, content: builtin.content }
try {
const rows = await db
.select({ content: skill.content, name: skill.name })
.from(skill)
.where(and(eq(skill.workspaceId, workspaceId), eq(skill.id, skillId)))
.limit(1)
if (rows.length === 0) {
logger.warn('Skill not found', { skillId, workspaceId })
return null
}
return rows[0]
} catch (error) {
logger.error('Failed to resolve skill content', { error, skillId, workspaceId })
return null
}
}
/**
* Build the system prompt section that lists available skills.
* Uses XML format per the agentskills.io integration guide.
*/
export function buildSkillsSystemPromptSection(skills: SkillMetadata[]): string {
if (!skills.length) return ''
const skillEntries = skills
.map(
(s) =>
` <skill name="${escapeXml(s.name)}">\n <description>${escapeXml(s.description)}</description>\n </skill>`
)
.join('\n')
return [
'',
'You have access to the following skills. Use the load_skill tool to activate a skill when relevant.',
'',
'<available_skills>',
skillEntries,
'</available_skills>',
].join('\n')
}
/**
* Build the load_skill tool definition for injection into the tools array.
* Returns a ProviderToolConfig-compatible object so all providers can process it.
*/
export function buildLoadSkillTool(skillNames: string[]) {
return {
id: 'load_skill',
name: 'load_skill',
description: `Load a skill to get specialized instructions. Available skills: ${skillNames.join(', ')}`,
params: {},
parameters: {
type: 'object',
properties: {
skill_name: {
type: 'string',
description: 'Name of the skill to load',
enum: skillNames,
},
},
required: ['skill_name'],
},
}
}
+82
View File
@@ -0,0 +1,82 @@
import type { UserFile } from '@/executor/types'
export interface SkillInput {
skillId: string
name?: string
description?: string
}
export interface AgentInputs {
model?: string
responseFormat?: string | object
tools?: ToolInput[]
skills?: SkillInput[]
// Legacy inputs (backward compatible)
systemPrompt?: string
userPrompt?: string | object
memories?: any // Legacy memory block output
// New message array input (from messages-input subblock)
messages?: Message[]
// Memory configuration
memoryType?: 'none' | 'conversation' | 'sliding_window' | 'sliding_window_tokens'
conversationId?: string // Required for all non-none memory types
slidingWindowSize?: string // For message-based sliding window
slidingWindowTokens?: string // For token-based sliding window
// Deep research multi-turn
previousInteractionId?: string // Interactions API previous interaction reference
// LLM parameters
temperature?: string
maxTokens?: string
apiKey?: string
azureEndpoint?: string
azureApiVersion?: string
vertexProject?: string
vertexLocation?: string
vertexCredential?: string
bedrockAccessKeyId?: string
bedrockSecretKey?: string
bedrockRegion?: string
reasoningEffort?: string
verbosity?: string
thinkingLevel?: string
files?: unknown
}
/**
* Represents a tool input for the agent block.
*
* @remarks
* Valid types include:
* - Standard block types (e.g., 'api', 'search', 'function')
* - 'custom-tool': User-defined tools with custom code
* - 'mcp': Individual MCP tool from a connected server
*/
export interface ToolInput {
/** Tool type identifier */
type?: string
schema?: any
title?: string
code?: string
/** Tool parameters */
params?: Record<string, any>
timeout?: number
usageControl?: 'auto' | 'force' | 'none'
operation?: string
/** Database ID for custom tools (new reference format) */
customToolId?: string
}
export interface Message {
role: 'system' | 'user' | 'assistant'
content: string
files?: UserFile[]
executionId?: string
function_call?: any
tool_calls?: any[]
}
export interface StreamingConfig {
shouldUseStreaming: boolean
isBlockSelectedForOutput: boolean
hasOutgoingConnections: boolean
}
@@ -0,0 +1,263 @@
import '@sim/testing/mocks/executor'
import { inputValidationMock, inputValidationMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import type { ToolConfig } from '@/tools/types'
import { getTool } from '@/tools/utils'
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
const mockGetTool = vi.mocked(getTool)
const mockExecuteTool = executeTool as Mock
const mockValidateUrlWithDNS = inputValidationMockFns.mockValidateUrlWithDNS
describe('ApiBlockHandler', () => {
let handler: ApiBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let mockApiTool: ToolConfig
beforeEach(() => {
handler = new ApiBlockHandler()
mockBlock = {
id: 'api-block-1',
metadata: { id: BlockType.API, name: 'Test API Block' },
position: { x: 10, y: 10 },
config: { tool: 'http_request', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockContext = {
workflowId: 'test-workflow-id',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
}
mockApiTool = {
id: 'http_request',
name: 'HTTP Request Tool',
description: 'Makes an HTTP request',
version: '1.0',
params: {
url: { type: 'string', required: true },
method: { type: 'string', default: 'GET' },
headers: { type: 'object' },
body: { type: 'any' },
},
request: {
url: 'https://example.com/api',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => params,
},
}
// Reset mocks using vi
vi.clearAllMocks()
mockValidateUrlWithDNS.mockResolvedValue({
isValid: true,
resolvedIP: '93.184.216.34',
originalHostname: 'example.com',
})
// Set up mockGetTool to return the mockApiTool
mockGetTool.mockImplementation((toolId) => {
if (toolId === 'http_request') {
return mockApiTool
}
return undefined
})
// Default mock implementations
mockExecuteTool.mockResolvedValue({ success: true, output: { data: 'Success' } })
})
it('should handle api blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
const nonApiBlock: SerializedBlock = {
...mockBlock,
metadata: { id: 'other-block' },
}
expect(handler.canHandle(nonApiBlock)).toBe(false)
})
it('should execute api block correctly with valid inputs', async () => {
const inputs = {
url: 'https://example.com/api',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' }),
}
const expectedOutput = { data: 'Success' }
mockExecuteTool.mockResolvedValue({ success: true, output: { data: 'Success' } })
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockGetTool).toHaveBeenCalledWith('http_request')
expect(mockExecuteTool).toHaveBeenCalledWith(
'http_request',
{
...inputs,
body: { key: 'value' }, // Expect parsed body
_context: { workflowId: 'test-workflow-id' },
},
{ executionContext: mockContext }
)
expect(result).toEqual(expectedOutput)
})
it('should handle missing URL gracefully (empty success response)', async () => {
const inputs = {
url: '', // Empty URL
method: 'GET',
}
const expectedOutput = { data: null, status: 200, headers: {} }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockGetTool).toHaveBeenCalledWith('http_request')
expect(mockExecuteTool).not.toHaveBeenCalled()
expect(result).toEqual(expectedOutput)
})
it('should throw error for invalid URL format (no protocol)', async () => {
const inputs = { url: 'example.com/api' }
mockValidateUrlWithDNS.mockResolvedValueOnce({
isValid: false,
error: 'url must be a valid URL',
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'url must be a valid URL'
)
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('should throw error for generally invalid URL format', async () => {
const inputs = { url: 'htp:/invalid-url' }
mockValidateUrlWithDNS.mockResolvedValueOnce({
isValid: false,
error: 'url must use https:// protocol',
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'url must use https:// protocol'
)
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('should parse JSON string body correctly', async () => {
const inputs = {
url: 'https://example.com/api',
body: ' { "key": "value", "nested": { "num": 1 } } ', // With extra whitespace
}
const expectedParsedBody = { key: 'value', nested: { num: 1 } }
await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith(
'http_request',
expect.objectContaining({ body: expectedParsedBody }),
{ executionContext: mockContext }
)
})
it('should keep non-JSON string body as string', async () => {
const inputs = {
url: 'https://example.com/api',
body: 'This is plain text',
}
await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith(
'http_request',
expect.objectContaining({ body: 'This is plain text' }),
{ executionContext: mockContext }
)
})
it('should handle null body by converting to undefined', async () => {
const inputs = {
url: 'https://example.com/api',
body: null,
}
await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith(
'http_request',
expect.objectContaining({ body: undefined }),
{ executionContext: mockContext }
)
})
it('should handle API errors correctly and format message', async () => {
const inputs = {
url: 'https://example.com/notfound',
method: 'GET',
}
const errorOutput = { status: 404, statusText: 'Not Found' }
mockExecuteTool.mockResolvedValue({
success: false,
output: errorOutput,
error: 'Resource not found',
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'HTTP Request failed: URL: https://example.com/notfound | Method: GET | Error: Resource not found | Status: 404 | Status text: Not Found - The requested resource was not found'
)
expect(mockExecuteTool).toHaveBeenCalled()
})
it('should throw error if tool is not found', async () => {
const inputs = { url: 'https://example.com' }
// Override mock to return undefined for this test
mockGetTool.mockImplementation(() => undefined)
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Tool not found: http_request'
)
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('should handle CORS error suggestion', async () => {
const inputs = { url: 'https://example.com/cors-issue' }
mockExecuteTool.mockResolvedValue({
success: false,
error: 'Request failed due to CORS policy',
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
/CORS policy prevented the request, try using a proxy or server-side request/
)
})
it('should handle generic fetch error suggestion', async () => {
const inputs = { url: 'https://unreachable.local' }
mockExecuteTool.mockResolvedValue({ success: false, error: 'Failed to fetch' })
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
/Network error, check if the URL is accessible and if you have internet connectivity/
)
})
})
@@ -0,0 +1,166 @@
import { createLogger } from '@sim/logger'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { BlockType, HTTP } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import { getTool } from '@/tools/utils'
const logger = createLogger('ApiBlockHandler')
/**
* Handler for API blocks that make external HTTP requests.
*/
export class ApiBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.API
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<any> {
const tool = getTool(block.config.tool)
if (!tool) {
throw new Error(`Tool not found: ${block.config.tool}`)
}
if (tool.name?.includes('HTTP') && (!inputs.url || inputs.url.trim() === '')) {
return { data: null, status: HTTP.STATUS.OK, headers: {} }
}
if (tool.name?.includes('HTTP') && inputs.url) {
let urlToValidate = inputs.url
if (typeof urlToValidate === 'string') {
if (
(urlToValidate.startsWith('"') && urlToValidate.endsWith('"')) ||
(urlToValidate.startsWith("'") && urlToValidate.endsWith("'"))
) {
urlToValidate = urlToValidate.slice(1, -1)
inputs.url = urlToValidate
}
}
const urlValidation = await validateUrlWithDNS(urlToValidate, 'url')
if (!urlValidation.isValid) {
throw new Error(urlValidation.error)
}
}
try {
const processedInputs = { ...inputs }
if (processedInputs.body !== undefined) {
if (typeof processedInputs.body === 'string') {
try {
const trimmedBody = processedInputs.body.trim()
if (trimmedBody.startsWith('{') || trimmedBody.startsWith('[')) {
processedInputs.body = JSON.parse(trimmedBody)
}
} catch (e) {}
} else if (processedInputs.body === null) {
processedInputs.body = undefined
}
}
const result = await executeTool(
block.config.tool,
{
...processedInputs,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
executionId: ctx.executionId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
callChain: ctx.callChain,
},
},
{ executionContext: ctx }
)
if (!result.success) {
const errorDetails = []
if (inputs.url) errorDetails.push(`URL: ${inputs.url}`)
if (inputs.method) errorDetails.push(`Method: ${inputs.method}`)
if (result.error) errorDetails.push(`Error: ${result.error}`)
if (result.output?.status) errorDetails.push(`Status: ${result.output.status}`)
if (result.output?.statusText) errorDetails.push(`Status text: ${result.output.statusText}`)
let suggestion = ''
if (result.output?.status === HTTP.STATUS.FORBIDDEN) {
suggestion = ' - This may be due to CORS restrictions or authorization issues'
} else if (result.output?.status === HTTP.STATUS.NOT_FOUND) {
suggestion = ' - The requested resource was not found'
} else if (result.output?.status === HTTP.STATUS.TOO_MANY_REQUESTS) {
suggestion = ' - Too many requests, you may need to implement rate limiting'
} else if (result.output?.status >= HTTP.STATUS.SERVER_ERROR) {
suggestion = ' - Server error, the target server is experiencing issues'
} else if (result.error?.includes('CORS')) {
suggestion =
' - CORS policy prevented the request, try using a proxy or server-side request'
} else if (result.error?.includes('Failed to fetch')) {
suggestion =
' - Network error, check if the URL is accessible and if you have internet connectivity'
}
const errorMessage =
errorDetails.length > 0
? `HTTP Request failed: ${errorDetails.join(' | ')}${suggestion}`
: `API request to ${tool.name || block.config.tool} failed with no error message`
const error = new Error(errorMessage)
Object.assign(error, {
toolId: block.config.tool,
toolName: tool.name || 'Unknown tool',
blockId: block.id,
blockName: block.metadata?.name || 'Unnamed Block',
output: result.output || {},
status: result.output?.status || null,
request: {
url: inputs.url,
method: inputs.method || 'GET',
},
timestamp: new Date().toISOString(),
})
throw error
}
return result.output
} catch (error: any) {
if (!error.message || error.message === 'undefined (undefined)') {
let errorMessage = `API request to ${tool.name || block.config.tool} failed`
if (inputs.url) errorMessage += `: ${inputs.url}`
if (error.status) errorMessage += ` (Status: ${error.status})`
if (error.statusText) errorMessage += ` - ${error.statusText}`
if (errorMessage === `API request to ${tool.name || block.config.tool} failed`) {
errorMessage += ` - ${block.metadata?.name || 'Unknown error'}`
}
error.message = errorMessage
}
if (typeof error === 'object' && error !== null) {
if (!error.toolId) error.toolId = block.config.tool
if (!error.blockName) error.blockName = block.metadata?.name || 'Unnamed Block'
if (inputs && !error.request) {
error.request = {
url: inputs.url,
method: inputs.method || 'GET',
}
}
}
throw error
}
}
}
@@ -0,0 +1,987 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler'
import type { BlockState, ExecutionContext } from '@/executor/types'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
vi.mock('@/tools', () => ({
executeTool: vi.fn(),
}))
vi.mock('@/executor/utils/block-data', () => ({
collectBlockData: vi.fn(() => ({
blockData: { 'source-block-1': { value: 10, text: 'hello' } },
blockNameMapping: { sourceblock: 'source-block-1' },
})),
}))
import { collectBlockData } from '@/executor/utils/block-data'
import { executeTool } from '@/tools'
const mockExecuteTool = executeTool as ReturnType<typeof vi.fn>
const mockCollectBlockData = collectBlockData as ReturnType<typeof vi.fn>
describe('ConditionBlockHandler', () => {
let handler: ConditionBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let mockWorkflow: Partial<SerializedWorkflow>
let mockSourceBlock: SerializedBlock
let mockTargetBlock1: SerializedBlock
let mockTargetBlock2: SerializedBlock
beforeEach(() => {
mockSourceBlock = {
id: 'source-block-1',
metadata: { id: 'source', name: 'Source Block' },
position: { x: 10, y: 10 },
config: { tool: 'source_tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockBlock = {
id: 'cond-block-1',
metadata: { id: BlockType.CONDITION, name: 'Test Condition' },
position: { x: 50, y: 50 },
config: { tool: BlockType.CONDITION, params: {} },
inputs: { conditions: 'json' },
outputs: {},
enabled: true,
}
mockTargetBlock1 = {
id: 'target-block-1',
metadata: { id: 'target', name: 'Target Block 1' },
position: { x: 100, y: 100 },
config: { tool: 'target_tool_1', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockTargetBlock2 = {
id: 'target-block-2',
metadata: { id: 'target', name: 'Target Block 2' },
position: { x: 100, y: 150 },
config: { tool: 'target_tool_2', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockWorkflow = {
blocks: [mockSourceBlock, mockBlock, mockTargetBlock1, mockTargetBlock2],
connections: [
{ source: mockSourceBlock.id, target: mockBlock.id },
{
source: mockBlock.id,
target: mockTargetBlock1.id,
sourceHandle: 'condition-cond1',
},
{
source: mockBlock.id,
target: mockTargetBlock2.id,
sourceHandle: 'condition-else1',
},
],
}
handler = new ConditionBlockHandler()
mockContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
blockStates: new Map<string, BlockState>([
[
mockSourceBlock.id,
{
output: { value: 10, text: 'hello' },
executed: true,
executionTime: 100,
},
],
]),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: { API_KEY: 'test-key' },
workflowVariables: { userName: { name: 'userName', value: 'john', type: 'plain' } },
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set([mockSourceBlock.id]),
activeExecutionPath: new Set(),
workflow: mockWorkflow as SerializedWorkflow,
completedLoops: new Set(),
}
vi.clearAllMocks()
// Default: condition evaluates to false (else path). Individual tests override with mockResolvedValueOnce.
mockExecuteTool.mockResolvedValue({ success: true, output: { result: false } })
})
it('should handle condition blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
const nonCondBlock: SerializedBlock = { ...mockBlock, metadata: { id: 'other' } }
expect(handler.canHandle(nonCondBlock)).toBe(false)
})
it('should execute condition block correctly and select first path', async () => {
// Mock executeTool to return true for the condition
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const expectedOutput = {
value: 10,
text: 'hello',
conditionResult: true,
selectedPath: {
blockId: mockTargetBlock1.id,
blockType: 'target',
blockTitle: 'Target Block 1',
},
selectedOption: 'cond1',
}
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(result).toEqual(expectedOutput)
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
})
it('should pass correct parameters to function_execute tool', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expect.objectContaining({
code: expect.stringContaining('context.value > 5'),
timeout: 5000,
envVars: mockContext.environmentVariables,
workflowVariables: mockContext.workflowVariables,
blockData: { 'source-block-1': { value: 10, text: 'hello' } },
blockNameMapping: { sourceblock: 'source-block-1' },
_context: {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
},
}),
{ executionContext: mockContext }
)
})
it('should select the else path if other conditions fail', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value < 0' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const expectedOutput = {
value: 10,
text: 'hello',
conditionResult: true,
selectedPath: {
blockId: mockTargetBlock2.id,
blockType: 'target',
blockTitle: 'Target Block 2',
},
selectedOption: 'else1',
}
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(result).toEqual(expectedOutput)
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
})
it('should handle invalid conditions JSON format', async () => {
const inputs = { conditions: '{ "invalid json ' }
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
/^Invalid conditions format:/
)
})
it('should handle evaluation errors gracefully', async () => {
mockExecuteTool.mockResolvedValueOnce({
success: false,
error: 'Cannot read property "doSomething" of undefined',
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.nonExistentProperty.doSomething()' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
/Evaluation error in condition "if"/
)
})
it('should handle missing source block output gracefully', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const conditions = [{ id: 'cond1', title: 'if', value: 'true' }]
const inputs = { conditions: JSON.stringify(conditions) }
const contextWithoutSource = {
...mockContext,
blockStates: new Map<string, BlockState>(),
}
const result = await handler.execute(contextWithoutSource, mockBlock, inputs)
expect(result).toHaveProperty('conditionResult', true)
expect(result).toHaveProperty('selectedOption', 'cond1')
})
it('should throw error if target block is missing', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const conditions = [{ id: 'cond1', title: 'if', value: 'true' }]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.blocks = [mockSourceBlock, mockBlock, mockTargetBlock2]
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
`Target block ${mockTargetBlock1.id} not found`
)
})
it('should return no-match result if no condition matches and no else exists', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'false' },
{ id: 'cond2', title: 'else if', value: 'context.value === 99' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{
source: mockBlock.id,
target: mockTargetBlock1.id,
sourceHandle: 'condition-cond1',
},
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(false)
expect((result as any).selectedPath).toBeNull()
expect((result as any).selectedOption).toBeNull()
expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false)
})
it('falls back to else path when loop context data is unavailable', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.item === "apple"' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
expect((result as any).selectedOption).toBe('else1')
})
it('should use collectBlockData to gather block state', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'true' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
await handler.execute(mockContext, mockBlock, inputs)
expect(mockCollectBlockData).toHaveBeenCalledWith(mockContext, mockBlock.id)
})
it('should handle function_execute tool failure', async () => {
mockExecuteTool.mockResolvedValueOnce({
success: false,
error: 'Execution timeout',
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
/Evaluation error in condition "if".*Execution timeout/
)
})
describe('Multiple branches to same target', () => {
it('should handle if and else pointing to same target', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('cond1')
expect((result as any).selectedPath).toEqual({
blockId: mockTargetBlock1.id,
blockType: 'target',
blockTitle: 'Target Block 1',
})
})
it('should select else branch to same target when if fails', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value < 0' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('else1')
expect((result as any).selectedPath).toEqual({
blockId: mockTargetBlock1.id,
blockType: 'target',
blockTitle: 'Target Block 1',
})
})
it('should handle if→A, elseif→B, else→A pattern', async () => {
// First condition (cond1): false
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
// Second condition (cond2): false
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value === 1' },
{ id: 'cond2', title: 'else if', value: 'context.value === 2' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-cond2' },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('else1')
expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock1.id)
})
})
describe('Condition evaluation with different data types', () => {
it('should evaluate string comparison conditions', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { name: 'test', status: 'active' },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.status === "active"' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond1')
})
it('should evaluate boolean conditions', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { isEnabled: true, count: 5 },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.isEnabled' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond1')
})
it('should evaluate array length conditions', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { items: [1, 2, 3, 4, 5] },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.items.length > 3' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond1')
})
it('should evaluate null/undefined check conditions', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { data: null },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.data === null' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond1')
})
})
describe('Multiple else-if conditions', () => {
it('should evaluate multiple else-if conditions in order', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { score: 75 },
executed: true,
executionTime: 100,
})
const mockTargetBlock3: SerializedBlock = {
id: 'target-block-3',
metadata: { id: 'target', name: 'Target Block 3' },
position: { x: 100, y: 200 },
config: { tool: 'target_tool_3', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockContext.workflow!.blocks!.push(mockTargetBlock3)
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.score >= 90' },
{ id: 'cond2', title: 'else if', value: 'context.score >= 70' },
{ id: 'cond3', title: 'else if', value: 'context.score >= 50' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-cond2' },
{ source: mockBlock.id, target: mockTargetBlock3.id, sourceHandle: 'condition-cond3' },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond2')
expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock2.id)
})
it('should skip to else when all else-if fail', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { score: 30 },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.score >= 90' },
{ id: 'cond2', title: 'else if', value: 'context.score >= 70' },
{ id: 'cond3', title: 'else if', value: 'context.score >= 50' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('else1')
})
})
describe('Condition with no outgoing edge', () => {
it('should set selectedOption when condition matches but has no edge', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'true' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-else1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedPath).toBeNull()
expect((result as any).selectedOption).toBe('cond1')
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
})
it('should set selectedOption when else is selected but has no edge', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'false' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedPath).toBeNull()
expect((result as any).selectedOption).toBe('else1')
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
})
it('should deactivate if-path when else is selected with no edge', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 100' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('else1')
expect((result as any).conditionResult).toBe(true)
})
})
describe('Empty conditions handling', () => {
it('should handle empty conditions array', async () => {
const conditions: unknown[] = []
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(false)
expect((result as any).selectedPath).toBeNull()
expect((result as any).selectedOption).toBeNull()
})
it('should handle conditions passed as array directly', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'true' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond1')
})
})
describe('Source output filtering', () => {
it('should not propagate error field from source block output', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { value: 10, text: 'hello', error: 'upstream block failed' },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('cond1')
expect(result).not.toHaveProperty('error')
})
it('should not propagate _pauseMetadata from source block output', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { value: 10, _pauseMetadata: { contextId: 'abc' } },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect(result).not.toHaveProperty('_pauseMetadata')
})
it('should still pass through non-control fields from source output', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { value: 10, text: 'hello', customData: { nested: true } },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).value).toBe(10)
expect((result as any).text).toBe('hello')
expect((result as any).customData).toEqual({ nested: true })
})
})
describe('Virtual block ID handling', () => {
it('should use currentVirtualBlockId for decision key when available', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
mockContext.currentVirtualBlockId = 'virtual-block-123'
const conditions = [
{ id: 'cond1', title: 'if', value: 'true' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
await handler.execute(mockContext, mockBlock, inputs)
expect(mockContext.decisions.condition.get('virtual-block-123')).toBe('cond1')
expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false)
})
})
describe('Parallel branch handling', () => {
it('should resolve connections and block data correctly when inside a parallel branch', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const parallelConditionBlock: SerializedBlock = {
id: 'cond-block-1₍0₎',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 0, y: 0 },
config: {},
}
const sourceBlockVirtualId = 'agent-block-1₍0₎'
const parallelWorkflow: SerializedWorkflow = {
blocks: [
{
id: 'agent-block-1',
metadata: { id: 'agent', name: 'Agent' },
position: { x: 0, y: 0 },
config: {},
},
{
id: 'cond-block-1',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 100, y: 0 },
config: {},
},
{
id: 'target-block-1',
metadata: { id: 'api', name: 'Target' },
position: { x: 200, y: 0 },
config: {},
},
],
connections: [
{ source: 'agent-block-1', target: 'cond-block-1' },
{ source: 'cond-block-1', target: 'target-block-1', sourceHandle: 'condition-cond1' },
],
loops: [],
parallels: [],
}
const parallelBlockStates = new Map<string, BlockState>([
[
sourceBlockVirtualId,
{ output: { response: 'hello from branch 0', success: true }, executed: true },
],
])
const parallelContext: ExecutionContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
workflow: parallelWorkflow,
blockStates: parallelBlockStates,
blockLogs: [],
completedBlocks: new Set(),
decisions: {
router: new Map(),
condition: new Map(),
},
environmentVariables: {},
workflowVariables: {},
}
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.response === "hello from branch 0"' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('cond1')
expect((result as any).selectedPath).toEqual({
blockId: 'target-block-1',
blockType: 'api',
blockTitle: 'Target',
})
})
it('should find correct source block output in parallel branch context', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const parallelConditionBlock: SerializedBlock = {
id: 'cond-block-1₍1₎',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 0, y: 0 },
config: {},
}
const parallelWorkflow: SerializedWorkflow = {
blocks: [
{
id: 'agent-block-1',
metadata: { id: 'agent', name: 'Agent' },
position: { x: 0, y: 0 },
config: {},
},
{
id: 'cond-block-1',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 100, y: 0 },
config: {},
},
{
id: 'target-block-1',
metadata: { id: 'api', name: 'Target' },
position: { x: 200, y: 0 },
config: {},
},
],
connections: [
{ source: 'agent-block-1', target: 'cond-block-1' },
{ source: 'cond-block-1', target: 'target-block-1', sourceHandle: 'condition-cond1' },
],
loops: [],
parallels: [],
}
const parallelBlockStates = new Map<string, BlockState>([
['agent-block-1₍0₎', { output: { value: 10 }, executed: true }],
['agent-block-1₍1₎', { output: { value: 25 }, executed: true }],
['agent-block-1₍2₎', { output: { value: 5 }, executed: true }],
])
const parallelContext: ExecutionContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
workflow: parallelWorkflow,
blockStates: parallelBlockStates,
blockLogs: [],
completedBlocks: new Set(),
decisions: {
router: new Map(),
condition: new Map(),
},
environmentVariables: {},
workflowVariables: {},
}
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 20' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('cond1')
})
it('should fall back to else when condition is false in parallel branch', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const parallelConditionBlock: SerializedBlock = {
id: 'cond-block-1₍2₎',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 0, y: 0 },
config: {},
}
const parallelWorkflow: SerializedWorkflow = {
blocks: [
{
id: 'agent-block-1',
metadata: { id: 'agent', name: 'Agent' },
position: { x: 0, y: 0 },
config: {},
},
{
id: 'cond-block-1',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 100, y: 0 },
config: {},
},
{
id: 'target-true',
metadata: { id: 'api', name: 'True Path' },
position: { x: 200, y: 0 },
config: {},
},
{
id: 'target-false',
metadata: { id: 'api', name: 'False Path' },
position: { x: 200, y: 100 },
config: {},
},
],
connections: [
{ source: 'agent-block-1', target: 'cond-block-1' },
{ source: 'cond-block-1', target: 'target-true', sourceHandle: 'condition-cond1' },
{ source: 'cond-block-1', target: 'target-false', sourceHandle: 'condition-else1' },
],
loops: [],
parallels: [],
}
const parallelBlockStates = new Map<string, BlockState>([
['agent-block-1₍0₎', { output: { value: 100 }, executed: true }],
['agent-block-1₍1₎', { output: { value: 50 }, executed: true }],
['agent-block-1₍2₎', { output: { value: 5 }, executed: true }],
])
const parallelContext: ExecutionContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
workflow: parallelWorkflow,
blockStates: parallelBlockStates,
blockLogs: [],
completedBlocks: new Set(),
decisions: {
router: new Map(),
condition: new Map(),
},
environmentVariables: {},
workflowVariables: {},
}
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 20' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('else1')
expect((result as any).selectedPath.blockId).toBe('target-false')
})
})
})
@@ -0,0 +1,264 @@
import { createLogger } from '@sim/logger'
import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records'
import type { BlockOutput } from '@/blocks/types'
import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import {
buildBranchNodeId,
extractBaseBlockId,
extractBranchIndex,
isBranchNodeId,
} from '@/executor/utils/subflow-utils'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
const logger = createLogger('ConditionBlockHandler')
const CONDITION_TIMEOUT_MS = 5000
/**
* Evaluates a single condition expression.
* Variable resolution is handled consistently with the function block via the function_execute tool.
* Returns true if condition is met, false otherwise.
*/
async function evaluateConditionExpression(
ctx: ExecutionContext,
conditionExpression: string,
providedEvalContext?: Record<string, any>,
currentNodeId?: string
): Promise<boolean> {
const evalContext = providedEvalContext || {}
try {
const contextSetup = `const context = ${JSON.stringify(evalContext)};`
const code = `${contextSetup}\nreturn Boolean(${conditionExpression})`
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx, currentNodeId)
const result = await executeTool(
'function_execute',
{
code,
timeout: CONDITION_TIMEOUT_MS,
envVars: normalizeStringRecord(ctx.environmentVariables),
workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
blockData,
blockNameMapping,
blockOutputSchemas,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
},
},
{ executionContext: ctx }
)
if (!result.success) {
logger.error(`Failed to evaluate condition: ${result.error}`, {
originalCondition: conditionExpression,
evalContext,
error: result.error,
})
throw new Error(`Evaluation error in condition: ${result.error}`)
}
return Boolean(result.output?.result)
} catch (evalError: any) {
logger.error(`Failed to evaluate condition: ${evalError.message}`, {
originalCondition: conditionExpression,
evalContext,
evalError,
})
throw new Error(`Evaluation error in condition: ${evalError.message}`)
}
}
/**
* Handler for Condition blocks that evaluate expressions to determine execution paths.
*/
export class ConditionBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.CONDITION
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const conditions = this.parseConditions(inputs.conditions)
const baseBlockId = extractBaseBlockId(block.id)
const branchIndex = isBranchNodeId(block.id) ? extractBranchIndex(block.id) : null
const sourceConnection = ctx.workflow?.connections.find((conn) => conn.target === baseBlockId)
let sourceBlockId = sourceConnection?.source
if (sourceBlockId && branchIndex !== null) {
const virtualSourceId = buildBranchNodeId(sourceBlockId, branchIndex)
if (ctx.blockStates.has(virtualSourceId)) {
sourceBlockId = virtualSourceId
}
}
const evalContext = this.buildEvaluationContext(ctx, sourceBlockId)
const rawSourceOutput = sourceBlockId ? ctx.blockStates.get(sourceBlockId)?.output : null
const sourceOutput = this.filterSourceOutput(rawSourceOutput)
const outgoingConnections = ctx.workflow?.connections.filter(
(conn) => conn.source === baseBlockId
)
const { selectedConnection, selectedCondition } = await this.evaluateConditions(
conditions,
outgoingConnections || [],
evalContext,
ctx,
block.id
)
if (!selectedCondition) {
return {
...((sourceOutput as any) || {}),
conditionResult: false,
selectedPath: null,
selectedOption: null,
}
}
if (!selectedConnection) {
const decisionKey = ctx.currentVirtualBlockId || block.id
ctx.decisions.condition.set(decisionKey, selectedCondition.id)
return {
...((sourceOutput as any) || {}),
conditionResult: true,
selectedPath: null,
selectedOption: selectedCondition.id,
}
}
const targetBlock = ctx.workflow?.blocks.find((b) => b.id === selectedConnection?.target)
if (!targetBlock) {
throw new Error(`Target block ${selectedConnection?.target} not found`)
}
const decisionKey = ctx.currentVirtualBlockId || block.id
ctx.decisions.condition.set(decisionKey, selectedCondition.id)
return {
...((sourceOutput as any) || {}),
conditionResult: true,
selectedPath: {
blockId: targetBlock.id,
blockType: targetBlock.metadata?.id || DEFAULTS.BLOCK_TYPE,
blockTitle: targetBlock.metadata?.name || DEFAULTS.BLOCK_TITLE,
},
selectedOption: selectedCondition.id,
}
}
private filterSourceOutput(output: any): any {
if (!output || typeof output !== 'object') {
return output
}
const { _pauseMetadata, error, providerTiming, tokens, toolCalls, model, cost, ...rest } =
output
return rest
}
private parseConditions(input: any): Array<{ id: string; title: string; value: string }> {
try {
const conditions = Array.isArray(input) ? input : JSON.parse(input || '[]')
return conditions
} catch (error: any) {
logger.error('Failed to parse conditions:', { input, error })
throw new Error(`Invalid conditions format: ${error.message}`)
}
}
private buildEvaluationContext(
ctx: ExecutionContext,
sourceBlockId?: string
): Record<string, any> {
let evalContext: Record<string, any> = {}
if (sourceBlockId) {
const sourceOutput = ctx.blockStates.get(sourceBlockId)?.output
if (sourceOutput && typeof sourceOutput === 'object' && sourceOutput !== null) {
evalContext = {
...evalContext,
...sourceOutput,
}
}
}
return evalContext
}
private async evaluateConditions(
conditions: Array<{ id: string; title: string; value: string }>,
outgoingConnections: Array<{ source: string; target: string; sourceHandle?: string }>,
evalContext: Record<string, any>,
ctx: ExecutionContext,
currentNodeId?: string
): Promise<{
selectedConnection: { target: string; sourceHandle?: string } | null
selectedCondition: { id: string; title: string; value: string } | null
}> {
for (const condition of conditions) {
if (condition.title === CONDITION.ELSE_TITLE) {
const connection = this.findConnectionForCondition(outgoingConnections, condition.id)
if (connection) {
return { selectedConnection: connection, selectedCondition: condition }
}
continue
}
const conditionValueString = String(condition.value || '')
try {
const conditionMet = await evaluateConditionExpression(
ctx,
conditionValueString,
evalContext,
currentNodeId
)
if (conditionMet) {
const connection = this.findConnectionForCondition(outgoingConnections, condition.id)
if (connection) {
return { selectedConnection: connection, selectedCondition: condition }
}
return { selectedConnection: null, selectedCondition: condition }
}
} catch (error: any) {
logger.error(`Failed to evaluate condition "${condition.title}": ${error.message}`)
throw new Error(`Evaluation error in condition "${condition.title}": ${error.message}`)
}
}
const elseCondition = conditions.find((c) => c.title === CONDITION.ELSE_TITLE)
if (elseCondition) {
const elseConnection = this.findConnectionForCondition(outgoingConnections, elseCondition.id)
if (elseConnection) {
return { selectedConnection: elseConnection, selectedCondition: elseCondition }
}
return { selectedConnection: null, selectedCondition: elseCondition }
}
return { selectedConnection: null, selectedCondition: null }
}
private findConnectionForCondition(
connections: Array<{ source: string; target: string; sourceHandle?: string }>,
conditionId: string
): { target: string; sourceHandle?: string } | undefined {
return connections.find(
(conn) => conn.sourceHandle === `${EDGE.CONDITION_PREFIX}${conditionId}`
)
}
}
@@ -0,0 +1,111 @@
import { db } from '@sim/db'
import { credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, inArray } from 'drizzle-orm'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('CredentialBlockHandler')
export class CredentialBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.CREDENTIAL
}
async execute(
ctx: ExecutionContext,
_block: SerializedBlock,
inputs: Record<string, unknown>
): Promise<BlockOutput> {
if (!ctx.workspaceId) {
throw new Error('workspaceId is required for credential resolution')
}
const operation = typeof inputs.operation === 'string' ? inputs.operation : 'select'
if (operation === 'list') {
return this.listCredentials(ctx.workspaceId, inputs)
}
return this.selectCredential(ctx.workspaceId, inputs)
}
private async selectCredential(
workspaceId: string,
inputs: Record<string, unknown>
): Promise<BlockOutput> {
const credentialId = typeof inputs.credentialId === 'string' ? inputs.credentialId.trim() : ''
if (!credentialId) {
throw new Error('No credential selected')
}
const record = await db.query.credential.findFirst({
where: and(
eq(credential.id, credentialId),
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'oauth')
),
columns: {
id: true,
displayName: true,
providerId: true,
},
})
if (!record) {
throw new Error(`Credential not found: ${credentialId}`)
}
logger.info('Credential block resolved', { credentialId: record.id })
return {
credentialId: record.id,
displayName: record.displayName,
providerId: record.providerId ?? '',
}
}
private async listCredentials(
workspaceId: string,
inputs: Record<string, unknown>
): Promise<BlockOutput> {
const providerFilter = Array.isArray(inputs.providerFilter)
? (inputs.providerFilter as string[]).filter(Boolean)
: []
const conditions = [eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth')]
if (providerFilter.length > 0) {
conditions.push(inArray(credential.providerId, providerFilter))
}
const records = await db.query.credential.findMany({
where: and(...conditions),
columns: {
id: true,
displayName: true,
providerId: true,
},
orderBy: [asc(credential.displayName)],
})
const credentials = records.map((r) => ({
credentialId: r.id,
displayName: r.displayName,
providerId: r.providerId ?? '',
}))
logger.info('Credential block listed credentials', {
count: credentials.length,
providerFilter: providerFilter.length > 0 ? providerFilter : undefined,
})
return {
credentials,
count: credentials.length,
}
}
}
@@ -0,0 +1,499 @@
import '@sim/testing/mocks/executor'
import { authOAuthUtilsMock, authOAuthUtilsMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
vi.mock('@/lib/credentials/access', () => ({
getCredentialActorContext: vi.fn().mockResolvedValue({
credential: {
id: 'test-vertex-credential-id',
type: 'oauth',
workspaceId: 'test-workspace',
accountId: 'test-vertex-credential-id',
},
member: { role: 'admin', status: 'active' },
hasWorkspaceAccess: true,
canWriteWorkspace: true,
isAdmin: true,
}),
}))
import { BlockType } from '@/executor/constants'
import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler'
import type { ExecutionContext } from '@/executor/types'
import { getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
const mockGetProviderFromModel = getProviderFromModel as Mock
const mockFetch = global.fetch as unknown as Mock
describe('EvaluatorBlockHandler', () => {
let handler: EvaluatorBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
beforeEach(() => {
handler = new EvaluatorBlockHandler()
mockBlock = {
id: 'eval-block-1',
metadata: { id: BlockType.EVALUATOR, name: 'Test Evaluator' },
position: { x: 20, y: 20 },
config: { tool: BlockType.EVALUATOR, params: {} },
inputs: {
content: 'string',
metrics: 'json',
model: 'string',
temperature: 'number',
}, // Using ParamType strings
outputs: {},
enabled: true,
}
mockContext = {
workflowId: 'test-workflow-id',
userId: 'test-user',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
completedLoops: new Set(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
}
// Reset mocks using vi
vi.clearAllMocks()
// Default mock implementations
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValue({
accountId: 'test-vertex-credential-id',
usedCredentialTable: false,
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValue({
accessToken: 'mock-access-token',
refreshed: false,
})
mockGetProviderFromModel.mockReturnValue('openai')
// Set up fetch mock to return a successful response
mockFetch.mockImplementation(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ score1: 5, score2: 8 }),
model: 'mock-model',
tokens: { input: 50, output: 10, total: 60 },
cost: 0.002,
timing: { total: 200 },
}),
})
})
})
it('should handle evaluator blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
const nonEvalBlock: SerializedBlock = { ...mockBlock, metadata: { id: 'other' } }
expect(handler.canHandle(nonEvalBlock)).toBe(false)
})
it('should execute evaluator block correctly with basic inputs', async () => {
const inputs = {
content: 'This is the content to evaluate.',
metrics: [
{ name: 'score1', description: 'First score', range: { min: 0, max: 10 } },
{ name: 'score2', description: 'Second score', range: { min: 0, max: 10 } },
],
model: 'gpt-4o',
apiKey: 'test-api-key',
temperature: 0.1,
}
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockGetProviderFromModel).toHaveBeenCalledWith('gpt-4o')
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'POST',
headers: expect.any(Object),
body: expect.any(String),
})
)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
provider: 'openai',
model: 'gpt-4o',
systemPrompt: expect.stringContaining(inputs.content),
responseFormat: expect.objectContaining({
schema: {
type: 'object',
properties: {
score1: { type: 'number' },
score2: { type: 'number' },
},
required: ['score1', 'score2'],
additionalProperties: false,
},
}),
temperature: 0.1,
})
expect(result).toEqual({
content: 'This is the content to evaluate.',
model: 'mock-model',
tokens: { input: 50, output: 10, total: 60 },
cost: {
input: 0,
output: 0,
total: 0,
},
score1: 5,
score2: 8,
})
})
it('should process JSON string content correctly', async () => {
const contentObj = { text: 'Evaluate this JSON.', value: 42 }
const inputs = {
content: JSON.stringify(contentObj),
metrics: [{ name: 'clarity', description: 'Clarity score', range: { min: 1, max: 5 } }],
apiKey: 'test-api-key',
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ clarity: 4 }),
model: 'm',
tokens: {},
cost: 0,
timing: {},
}),
})
})
await handler.execute(mockContext, mockBlock, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
systemPrompt: expect.stringContaining(JSON.stringify(contentObj, null, 2)),
})
})
it('should process object content correctly', async () => {
const contentObj = { data: [1, 2, 3], status: 'ok' }
const inputs = {
content: contentObj,
metrics: [
{ name: 'completeness', description: 'Data completeness', range: { min: 0, max: 1 } },
],
apiKey: 'test-api-key',
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ completeness: 1 }),
model: 'm',
tokens: {},
cost: 0,
timing: {},
}),
})
})
await handler.execute(mockContext, mockBlock, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
systemPrompt: expect.stringContaining(JSON.stringify(contentObj, null, 2)),
})
})
it('should parse valid JSON response correctly', async () => {
const inputs = {
content: 'Test content',
metrics: [{ name: 'quality', description: 'Quality score', range: { min: 1, max: 10 } }],
apiKey: 'test-api-key',
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: '```json\n{ "quality": 9 }\n```',
model: 'm',
tokens: {},
cost: 0,
timing: {},
}),
})
})
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).quality).toBe(9)
})
it('should handle invalid/non-JSON response gracefully (scores = 0)', async () => {
const inputs = {
content: 'Test content',
metrics: [{ name: 'score', description: 'Score', range: { min: 0, max: 5 } }],
apiKey: 'test-api-key',
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: 'Sorry, I cannot provide a score.',
model: 'm',
tokens: {},
cost: 0,
timing: {},
}),
})
})
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).score).toBe(0)
})
it('should handle partially valid JSON response (extracts what it can)', async () => {
const inputs = {
content: 'Test content',
metrics: [
{ name: 'accuracy', description: 'Acc', range: { min: 0, max: 1 } },
{ name: 'fluency', description: 'Flu', range: { min: 0, max: 1 } },
],
apiKey: 'test-api-key',
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: '{ "accuracy": 1, "fluency": invalid }',
model: 'm',
tokens: {},
cost: 0,
timing: {},
}),
})
})
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).accuracy).toBe(0)
expect((result as any).fluency).toBe(0)
})
it('should extract metric scores ignoring case', async () => {
const inputs = {
content: 'Test',
metrics: [{ name: 'CamelCaseScore', description: 'Desc', range: { min: 0, max: 10 } }],
apiKey: 'test-api-key',
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ camelcasescore: 7 }),
model: 'm',
tokens: {},
cost: 0,
timing: {},
}),
})
})
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).camelcasescore).toBe(7)
})
it('should handle missing metrics in response (score = 0)', async () => {
const inputs = {
content: 'Test',
metrics: [
{ name: 'presentScore', description: 'Desc1', range: { min: 0, max: 5 } },
{ name: 'missingScore', description: 'Desc2', range: { min: 0, max: 5 } },
],
apiKey: 'test-api-key',
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ presentScore: 4 }),
model: 'm',
tokens: {},
cost: 0,
timing: {},
}),
})
})
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).presentscore).toBe(4)
expect((result as any).missingscore).toBe(0)
})
it('should handle server error responses', async () => {
const inputs = { content: 'Test error handling.', apiKey: 'test-api-key' }
// Override fetch mock to return an error
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: false,
status: 500,
json: () => Promise.resolve({ error: 'Server error' }),
})
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow('Server error')
})
it('should handle Azure OpenAI models with endpoint and API version', async () => {
const inputs = {
content: 'Test content to evaluate',
metrics: [{ name: 'quality', description: 'Quality score', range: { min: 1, max: 10 } }],
model: 'gpt-4o',
apiKey: 'test-azure-key',
azureEndpoint: 'https://test.openai.azure.com',
azureApiVersion: '2024-07-01-preview',
}
mockGetProviderFromModel.mockReturnValue('azure-openai')
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ quality: 8 }),
model: 'gpt-4o',
tokens: {},
cost: 0,
timing: {},
}),
})
})
await handler.execute(mockContext, mockBlock, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
provider: 'azure-openai',
model: 'gpt-4o',
apiKey: 'test-azure-key',
azureEndpoint: 'https://test.openai.azure.com',
azureApiVersion: '2024-07-01-preview',
})
})
it('should handle Vertex AI models with OAuth credential', async () => {
const inputs = {
content: 'Test content to evaluate',
metrics: [{ name: 'quality', description: 'Quality score', range: { min: 1, max: 10 } }],
model: 'gemini-2.0-flash-exp',
vertexCredential: 'test-vertex-credential-id',
vertexProject: 'test-gcp-project',
vertexLocation: 'us-central1',
}
mockGetProviderFromModel.mockReturnValue('vertex')
// Mock the database query for Vertex credential
const mockDb = await import('@sim/db')
const mockAccount = {
id: 'test-vertex-credential-id',
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
expiresAt: new Date(Date.now() + 3600000), // 1 hour from now
}
;(mockDb.db.query as any).account = { findFirst: vi.fn() }
vi.spyOn(mockDb.db.query.account, 'findFirst').mockResolvedValue(mockAccount as any)
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ quality: 9 }),
model: 'gemini-2.0-flash-exp',
tokens: {},
cost: 0,
timing: {},
}),
})
})
await handler.execute(mockContext, mockBlock, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
provider: 'vertex',
model: 'gemini-2.0-flash-exp',
vertexProject: 'test-gcp-project',
vertexLocation: 'us-central1',
})
expect(requestBody.apiKey).toBe('mock-access-token')
})
it('should use default model when not provided', async () => {
const inputs = {
content: 'Test content',
metrics: [{ name: 'score', description: 'Score', range: { min: 0, max: 10 } }],
apiKey: 'test-api-key',
// No model provided - should use default
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ score: 7 }),
model: 'claude-sonnet-5',
tokens: {},
cost: 0,
timing: {},
}),
})
})
await handler.execute(mockContext, mockBlock, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody.model).toBe('claude-sonnet-5')
})
})
@@ -0,0 +1,279 @@
import { createLogger } from '@sim/logger'
import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('EvaluatorBlockHandler')
/**
* Handler for Evaluator blocks that assess content against criteria.
*/
export class EvaluatorBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.EVALUATOR
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const evaluatorConfig = {
model: inputs.model || EVALUATOR.DEFAULT_MODEL,
apiKey: inputs.apiKey,
vertexProject: inputs.vertexProject,
vertexLocation: inputs.vertexLocation,
vertexCredential: inputs.vertexCredential,
bedrockAccessKeyId: inputs.bedrockAccessKeyId,
bedrockSecretKey: inputs.bedrockSecretKey,
bedrockRegion: inputs.bedrockRegion,
}
await validateModelProvider(ctx.userId, ctx.workspaceId, evaluatorConfig.model, ctx)
const providerId = getProviderFromModel(evaluatorConfig.model)
let finalApiKey: string | undefined = evaluatorConfig.apiKey
if (providerId === 'vertex' && evaluatorConfig.vertexCredential) {
finalApiKey = await resolveVertexCredential(
evaluatorConfig.vertexCredential,
ctx.userId,
'vertex-evaluator'
)
}
const processedContent = this.processContent(inputs.content)
let systemPromptObj: { systemPrompt: string; responseFormat: any } = {
systemPrompt: '',
responseFormat: null,
}
logger.info('Inputs for evaluator:', inputs)
let metrics: any[]
if (Array.isArray(inputs.metrics)) {
metrics = inputs.metrics
} else {
metrics = []
}
logger.info('Metrics for evaluator:', metrics)
const metricDescriptions = metrics
.filter((m: any) => m?.name && m.range)
.map((m: any) => `"${m.name}" (${m.range.min}-${m.range.max}): ${m.description || ''}`)
.join('\n')
const responseProperties: Record<string, any> = {}
metrics.forEach((m: any) => {
if (m?.name) {
responseProperties[m.name.toLowerCase()] = { type: 'number' }
} else {
logger.warn('Skipping invalid metric entry during response format generation:', m)
}
})
systemPromptObj = {
systemPrompt: `You are an evaluation agent. Analyze this content against the metrics and provide scores.
Metrics:
${metricDescriptions}
Content:
${processedContent}
Return a JSON object with each metric name as a key and a numeric score as the value. No explanations, only scores.`,
responseFormat: {
name: EVALUATOR.RESPONSE_SCHEMA_NAME,
schema: {
type: 'object',
properties: responseProperties,
required: metrics.filter((m: any) => m?.name).map((m: any) => m.name.toLowerCase()),
additionalProperties: false,
},
strict: true,
},
}
if (!systemPromptObj.systemPrompt) {
systemPromptObj.systemPrompt =
'Evaluate the content and provide scores for each metric as JSON.'
}
try {
const url = buildAPIUrl('/api/providers', ctx.userId ? { userId: ctx.userId } : {})
const providerRequest: Record<string, any> = {
provider: providerId,
model: evaluatorConfig.model,
systemPrompt: systemPromptObj.systemPrompt,
responseFormat: systemPromptObj.responseFormat,
context: stringifyJSON([
{
role: 'user',
content:
'Please evaluate the content provided in the system prompt. Return ONLY a valid JSON with metric scores.',
},
]),
temperature: EVALUATOR.DEFAULT_TEMPERATURE,
apiKey: finalApiKey,
azureEndpoint: inputs.azureEndpoint,
azureApiVersion: inputs.azureApiVersion,
vertexProject: evaluatorConfig.vertexProject,
vertexLocation: evaluatorConfig.vertexLocation,
bedrockAccessKeyId: evaluatorConfig.bedrockAccessKeyId,
bedrockSecretKey: evaluatorConfig.bedrockSecretKey,
bedrockRegion: evaluatorConfig.bedrockRegion,
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
}
const response = await fetch(url.toString(), {
method: 'POST',
headers: await buildAuthHeaders(ctx.userId),
body: stringifyJSON(providerRequest),
})
if (!response.ok) {
const errorMessage = await extractAPIErrorMessage(response)
throw new Error(errorMessage)
}
const result = await response.json()
const parsedContent = this.extractJSONFromResponse(result.content)
const metricScores = this.extractMetricScores(parsedContent, inputs.metrics)
const inputTokens = result.tokens?.input || result.tokens?.prompt || DEFAULTS.TOKENS.PROMPT
const outputTokens =
result.tokens?.output || result.tokens?.completion || DEFAULTS.TOKENS.COMPLETION
const costCalculation = calculateCost(result.model, inputTokens, outputTokens, false)
return {
content: inputs.content,
model: result.model,
tokens: {
input: inputTokens,
output: outputTokens,
total: result.tokens?.total || DEFAULTS.TOKENS.TOTAL,
},
cost: {
input: costCalculation.input,
output: costCalculation.output,
total: costCalculation.total,
},
...metricScores,
}
} catch (error) {
logger.error('Evaluator execution failed:', error)
throw error
}
}
private processContent(content: any): string {
if (typeof content === 'string') {
if (isJSONString(content)) {
const parsed = parseJSON(content, null)
if (parsed) {
return stringifyJSON(parsed)
}
return content
}
return content
}
if (typeof content === 'object') {
return stringifyJSON(content)
}
return String(content || '')
}
private extractJSONFromResponse(responseContent: string): Record<string, any> {
try {
const contentStr = responseContent.trim()
const fullMatch = contentStr.match(/(\{[\s\S]*\})/)
if (fullMatch) {
return parseJSON(fullMatch[0], {})
}
if (contentStr.includes('{') && contentStr.includes('}')) {
const startIdx = contentStr.indexOf('{')
const endIdx = contentStr.lastIndexOf('}') + 1
const jsonStr = contentStr.substring(startIdx, endIdx)
return parseJSON(jsonStr, {})
}
return parseJSON(contentStr, {})
} catch (error) {
logger.error('Error parsing evaluator response:', error)
logger.error('Raw response content:', responseContent)
return {}
}
}
private extractMetricScores(
parsedContent: Record<string, any>,
metrics: any
): Record<string, number> {
const metricScores: Record<string, number> = {}
let validMetrics: any[]
if (Array.isArray(metrics)) {
validMetrics = metrics
} else {
validMetrics = []
}
if (Object.keys(parsedContent).length === 0) {
validMetrics.forEach((metric: any) => {
if (metric?.name) {
metricScores[metric.name.toLowerCase()] = 0
}
})
return metricScores
}
validMetrics.forEach((metric: any) => {
if (!metric?.name) {
logger.warn('Skipping invalid metric entry:', metric)
return
}
const score = this.findMetricScore(parsedContent, metric.name)
metricScores[metric.name.toLowerCase()] = score
})
return metricScores
}
private findMetricScore(parsedContent: Record<string, any>, metricName: string): number {
const lowerMetricName = metricName.toLowerCase()
if (parsedContent[metricName] !== undefined) {
return Number(parsedContent[metricName])
}
if (parsedContent[lowerMetricName] !== undefined) {
return Number(parsedContent[lowerMetricName])
}
const matchingKey = Object.keys(parsedContent).find((key) => {
return typeof key === 'string' && key.toLowerCase() === lowerMetricName
})
if (matchingKey) {
return Number(parsedContent[matchingKey])
}
logger.warn(`Metric "${metricName}" not found in LLM response`)
return 0
}
}
@@ -0,0 +1,244 @@
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
import { BlockType } from '@/executor/constants'
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
import type { ExecutionContext } from '@/executor/types'
import {
FUNCTION_BLOCK_CONTEXT_VARS_KEY,
FUNCTION_BLOCK_DISPLAY_CODE_KEY,
} from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
vi.mock('@/tools', () => ({
executeTool: vi.fn(),
}))
const mockExecuteTool = executeTool as Mock
describe('FunctionBlockHandler', () => {
let handler: FunctionBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
beforeEach(() => {
handler = new FunctionBlockHandler()
mockBlock = {
id: 'func-block-1',
metadata: { id: BlockType.FUNCTION, name: 'Test Function' },
position: { x: 30, y: 30 },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: { code: 'string', timeout: 'number' }, // Using ParamType strings
outputs: {},
enabled: true,
}
mockContext = {
workflowId: 'test-workflow-id',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
}
// Reset mocks using vi
vi.clearAllMocks()
// Default mock implementation for executeTool
mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'Success' } })
})
it('should handle function blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
const nonFuncBlock: SerializedBlock = { ...mockBlock, metadata: { id: 'other' } }
expect(handler.canHandle(nonFuncBlock)).toBe(false)
})
it('should execute function block with string code', async () => {
const inputs = {
code: 'console.log("Hello"); return 1 + 1;',
timeout: 10000,
envVars: {},
isCustomTool: false,
workflowId: undefined,
}
const expectedToolParams = {
code: inputs.code,
language: 'javascript',
timeout: inputs.timeout,
envVars: {},
workflowVariables: {},
blockData: {},
blockNameMapping: {},
blockOutputSchemas: {},
contextVariables: {},
_context: {
workflowId: mockContext.workflowId,
workspaceId: mockContext.workspaceId,
executionId: mockContext.executionId,
userId: mockContext.userId,
isDeployedContext: mockContext.isDeployedContext,
enforceCredentialAccess: mockContext.enforceCredentialAccess,
},
}
const expectedOutput: any = { result: 'Success' }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith('function_execute', expectedToolParams, {
executionContext: mockContext,
})
expect(result).toEqual(expectedOutput)
})
it('should execute function block with array code', async () => {
const inputs = {
code: [{ content: 'const x = 5;' }, { content: 'return x * 2;' }],
timeout: 5000,
envVars: {},
isCustomTool: false,
workflowId: undefined,
}
const expectedCode = 'const x = 5;\nreturn x * 2;'
const expectedToolParams = {
code: expectedCode,
language: 'javascript',
timeout: inputs.timeout,
envVars: {},
workflowVariables: {},
blockData: {},
blockNameMapping: {},
blockOutputSchemas: {},
contextVariables: {},
_context: {
workflowId: mockContext.workflowId,
workspaceId: mockContext.workspaceId,
executionId: mockContext.executionId,
userId: mockContext.userId,
isDeployedContext: mockContext.isDeployedContext,
enforceCredentialAccess: mockContext.enforceCredentialAccess,
},
}
const expectedOutput: any = { result: 'Success' }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith('function_execute', expectedToolParams, {
executionContext: mockContext,
})
expect(result).toEqual(expectedOutput)
})
it('should use default timeout if not provided', async () => {
const inputs = { code: 'return true;' }
const expectedToolParams = {
code: inputs.code,
language: 'javascript',
timeout: DEFAULT_EXECUTION_TIMEOUT_MS,
envVars: {},
workflowVariables: {},
blockData: {},
blockNameMapping: {},
blockOutputSchemas: {},
contextVariables: {},
_context: {
workflowId: mockContext.workflowId,
workspaceId: mockContext.workspaceId,
executionId: mockContext.executionId,
userId: mockContext.userId,
isDeployedContext: mockContext.isDeployedContext,
enforceCredentialAccess: mockContext.enforceCredentialAccess,
},
}
await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith('function_execute', expectedToolParams, {
executionContext: mockContext,
})
})
it('should handle execution errors from the tool', async () => {
const inputs = { code: 'throw new Error("Code failed");' }
const errorResult = { success: false, error: 'Function execution failed: Code failed' }
mockExecuteTool.mockResolvedValue(errorResult)
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Function execution failed: Code failed'
)
expect(mockExecuteTool).toHaveBeenCalled()
})
it('should pass runtime context variables to function_execute', async () => {
const contextVariables = { __blockRef_0: { result: 'from-block' } }
await handler.execute(mockContext, mockBlock, {
code: 'return globalThis["__blockRef_0"]',
[FUNCTION_BLOCK_CONTEXT_VARS_KEY]: contextVariables,
})
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expect.objectContaining({
contextVariables,
}),
{ executionContext: mockContext }
)
})
it('should pass display-resolved function code for error display', async () => {
mockBlock.config.params = { code: 'retur <start.reqerror>' }
await handler.execute(mockContext, mockBlock, {
code: 'retur globalThis["__blockRef_0"]',
[FUNCTION_BLOCK_DISPLAY_CODE_KEY]: 'retur "value"',
[FUNCTION_BLOCK_CONTEXT_VARS_KEY]: { __blockRef_0: 'value' },
})
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expect.objectContaining({
code: 'retur globalThis["__blockRef_0"]',
sourceCode: 'retur "value"',
}),
{ executionContext: mockContext }
)
})
it('should normalize malformed execution context records before calling function_execute', async () => {
const legacyVariable = { id: 'var-1', name: 'brand', type: 'plain', value: 'myfitness' }
mockContext.workflowVariables = [legacyVariable] as unknown as Record<string, any>
mockContext.environmentVariables = ['invalid-env'] as unknown as Record<string, string>
await handler.execute(mockContext, mockBlock, {
code: 'return "myfitness"',
[FUNCTION_BLOCK_CONTEXT_VARS_KEY]: ['invalid-context'],
})
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expect.objectContaining({
envVars: {},
workflowVariables: { 'var-1': legacyVariable },
contextVariables: {},
}),
{ executionContext: mockContext }
)
})
it('should handle tool error with no specific message', async () => {
const inputs = { code: 'some code' }
const errorResult = { success: false }
mockExecuteTool.mockResolvedValue(errorResult)
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Function execution failed'
)
})
})
@@ -0,0 +1,93 @@
import {
normalizeRecord,
normalizeStringRecord,
normalizeWorkflowVariables,
} from '@/lib/core/utils/records'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages'
import { mergeFileKeys, mergeLargeValueKeys } from '@/lib/execution/payloads/access-keys'
import { BlockType } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import {
FUNCTION_BLOCK_CONTEXT_VARS_KEY,
FUNCTION_BLOCK_DISPLAY_CODE_KEY,
} from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
function readCodeContent(value: unknown): string | undefined {
if (typeof value === 'string') {
return value
}
if (Array.isArray(value)) {
return value
.map((entry) =>
entry && typeof entry === 'object' && typeof entry.content === 'string' ? entry.content : ''
)
.join('\n')
}
return undefined
}
/**
* Handler for Function blocks that execute custom code.
*/
export class FunctionBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.FUNCTION
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<any> {
const codeContent = readCodeContent(inputs.code) ?? inputs.code
const sourceCode =
readCodeContent(inputs[FUNCTION_BLOCK_DISPLAY_CODE_KEY]) ??
readCodeContent((block.config?.params as Record<string, unknown> | undefined)?.code)
const { blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
const contextVariables = normalizeRecord(inputs[FUNCTION_BLOCK_CONTEXT_VARS_KEY])
const toolParams = {
code: codeContent,
...(sourceCode ? { sourceCode } : {}),
language: inputs.language || DEFAULT_CODE_LANGUAGE,
timeout: inputs.timeout || DEFAULT_EXECUTION_TIMEOUT_MS,
envVars: normalizeStringRecord(ctx.environmentVariables),
workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
blockData: {},
blockNameMapping,
blockOutputSchemas,
contextVariables,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
fileKeys: ctx.fileKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
},
}
const result = await executeTool('function_execute', toolParams, { executionContext: ctx })
if (!result.success) {
throw new Error(result.error || 'Function execution failed')
}
mergeLargeValueKeys(ctx, result.largeValueKeys ?? [])
mergeFileKeys(ctx, result.fileKeys ?? [])
return result.output
}
}
@@ -0,0 +1,147 @@
import '@sim/testing/mocks/executor'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import type { ToolConfig } from '@/tools/types'
import { getTool } from '@/tools/utils'
const mockGetTool = vi.mocked(getTool)
const mockExecuteTool = executeTool as Mock
describe('GenericBlockHandler', () => {
let handler: GenericBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let mockTool: ToolConfig
beforeEach(() => {
handler = new GenericBlockHandler()
mockBlock = {
id: 'generic-block-1',
metadata: { id: 'custom-type', name: 'Test Generic Block' },
position: { x: 40, y: 40 },
config: { tool: 'some_custom_tool', params: { param1: 'value1' } },
inputs: { param1: 'string' }, // Using ParamType strings
outputs: {},
enabled: true,
}
mockContext = {
workflowId: 'test-workflow-id',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
}
mockTool = {
id: 'some_custom_tool',
name: 'Some Custom Tool',
description: 'Does something custom',
version: '1.0',
params: { param1: { type: 'string' } },
request: {
url: 'https://example.com/api',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => params,
},
}
// Reset mocks using vi
vi.clearAllMocks()
// Set up mockGetTool to return mockTool
mockGetTool.mockImplementation((toolId) => {
if (toolId === 'some_custom_tool') {
return mockTool
}
return undefined
})
// Default mock implementations
mockExecuteTool.mockResolvedValue({ success: true, output: { customResult: 'OK' } })
})
it.concurrent('should always handle any block type', () => {
const agentBlock: SerializedBlock = { ...mockBlock, metadata: { id: BlockType.AGENT } }
expect(handler.canHandle(agentBlock)).toBe(true)
expect(handler.canHandle(mockBlock)).toBe(true)
const noMetaIdBlock: SerializedBlock = { ...mockBlock, metadata: undefined }
expect(handler.canHandle(noMetaIdBlock)).toBe(true)
})
it.concurrent('should execute generic block by calling its associated tool', async () => {
const inputs = { param1: 'resolvedValue1' }
const expectedToolParams = {
...inputs,
_context: { workflowId: mockContext.workflowId },
}
const expectedOutput: any = { customResult: 'OK' }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockGetTool).toHaveBeenCalledWith('some_custom_tool')
expect(mockExecuteTool).toHaveBeenCalledWith('some_custom_tool', expectedToolParams, {
executionContext: mockContext,
})
expect(result).toEqual(expectedOutput)
})
it('should throw error if the associated tool is not found', async () => {
const inputs = { param1: 'value' }
// Override mock to return undefined for this test
mockGetTool.mockImplementation(() => undefined)
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Tool not found: some_custom_tool'
)
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('should handle tool execution errors correctly', async () => {
const inputs = { param1: 'value' }
const errorResult = {
success: false,
error: 'Custom tool failed',
output: { detail: 'error detail' },
}
mockExecuteTool.mockResolvedValue(errorResult)
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Custom tool failed'
)
// Re-execute to check error properties after catching
try {
await handler.execute(mockContext, mockBlock, inputs)
} catch (e: any) {
expect(e.toolId).toBe('some_custom_tool')
expect(e.blockName).toBe('Test Generic Block')
expect(e.output).toEqual({ detail: 'error detail' })
}
expect(mockExecuteTool).toHaveBeenCalledTimes(2) // Called twice now
})
it.concurrent('should handle tool execution errors with no specific message', async () => {
const inputs = { param1: 'value' }
const errorResult = { success: false, output: {} }
mockExecuteTool.mockResolvedValue(errorResult)
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Block execution of Some Custom Tool failed with no error message'
)
})
})
@@ -0,0 +1,125 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { getBlock } from '@/blocks/index'
import { isMcpTool } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import { getTool } from '@/tools/utils'
const logger = createLogger('GenericBlockHandler')
export class GenericBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return true
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<any> {
const isMcp = block.config.tool ? isMcpTool(block.config.tool) : false
let tool = null
if (!isMcp) {
tool = getTool(block.config.tool)
if (!tool) {
throw new Error(`Tool not found: ${block.config.tool}`)
}
}
let finalInputs = { ...inputs }
const blockType = block.metadata?.id
if (blockType) {
const blockConfig = getBlock(blockType)
if (blockConfig?.tools?.config?.params) {
const transformedParams = blockConfig.tools.config.params(inputs)
finalInputs = { ...inputs, ...transformedParams }
}
if (blockConfig?.inputs) {
for (const [key, inputSchema] of Object.entries(blockConfig.inputs)) {
const value = finalInputs[key]
if (typeof value === 'string' && value.trim().length > 0) {
const inputType = typeof inputSchema === 'object' ? inputSchema.type : inputSchema
if (inputType === 'json' || inputType === 'array') {
try {
finalInputs[key] = JSON.parse(value.trim())
} catch (error) {
logger.warn(`Failed to parse ${inputType} field "${key}":`, {
error: toError(error).message,
})
}
}
}
}
}
}
try {
const result = await executeTool(
block.config.tool,
{
...finalInputs,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
executionId: ctx.executionId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
},
},
{ executionContext: ctx }
)
if (!result.success) {
const errorDetails = []
if (result.error) errorDetails.push(result.error)
const errorMessage =
errorDetails.length > 0
? errorDetails.join(' - ')
: `Block execution of ${tool?.name || block.config.tool} failed with no error message`
const error = new Error(errorMessage)
Object.assign(error, {
toolId: block.config.tool,
toolName: tool?.name || 'Unknown tool',
blockId: block.id,
blockName: block.metadata?.name || 'Unnamed Block',
output: result.output || {},
timestamp: new Date().toISOString(),
})
throw error
}
return result.output
} catch (error: any) {
if (!error.message || error.message === 'undefined (undefined)') {
let errorMessage = `Block execution of ${tool?.name || block.config.tool} failed`
if (block.metadata?.name) {
errorMessage += `: ${block.metadata.name}`
}
if (error.status) {
errorMessage += ` (Status: ${error.status})`
}
error.message = errorMessage
}
if (typeof error === 'object' && error !== null) {
if (!error.toolId) error.toolId = block.config.tool
if (!error.blockName) error.blockName = block.metadata?.name || 'Unnamed Block'
}
throw error
}
}
}
@@ -0,0 +1,520 @@
import { createLogger } from '@sim/logger'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { BlockOutput } from '@/blocks/types'
import {
BlockType,
buildResumeApiUrl,
buildResumeUiUrl,
type FieldType,
HTTP,
normalizeName,
PAUSE_RESUME,
} from '@/executor/constants'
import {
generatePauseContextId,
mapNodeMetadataToPauseScopes,
} from '@/executor/human-in-the-loop/utils'
import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import { convertBuilderDataToJson, convertPropertyValue } from '@/executor/utils/builder-data'
import { parseObjectStrings } from '@/executor/utils/json'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
const logger = createLogger('HumanInTheLoopBlockHandler')
interface JSONProperty {
id: string
name: string
type: FieldType
value: any
collapsed?: boolean
}
interface ResponseStructureEntry {
name: string
type: string
value: any
}
interface NormalizedInputField {
id: string
name: string
label: string
type: string
description?: string
placeholder?: string
value?: any
required?: boolean
options?: any[]
}
interface NotificationToolResult {
toolId: string
title?: string
operation?: string
success: boolean
durationMs?: number
}
export class HumanInTheLoopBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
return this.executeWithNode(ctx, block, inputs, {
nodeId: block.id,
})
}
async executeWithNode(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>,
nodeMetadata: {
nodeId: string
loopId?: string
parallelId?: string
branchIndex?: number
branchTotal?: number
}
): Promise<BlockOutput> {
try {
const operation = inputs.operation ?? PAUSE_RESUME.OPERATION.HUMAN
const { parallelScope, loopScope } = mapNodeMetadataToPauseScopes(ctx, nodeMetadata)
const contextId = generatePauseContextId(block.id, nodeMetadata, loopScope)
const timestamp = new Date().toISOString()
const executionId = ctx.executionId ?? ctx.metadata?.executionId
const workflowId = ctx.workflowId
let resumeLinks: typeof pauseMetadata.resumeLinks | undefined
if (executionId && workflowId) {
try {
const baseUrl = getBaseUrl()
resumeLinks = {
apiUrl: buildResumeApiUrl(baseUrl, workflowId, executionId, contextId),
uiUrl: buildResumeUiUrl(baseUrl, workflowId, executionId),
contextId,
executionId,
workflowId,
}
} catch (error) {
logger.warn('Failed to get base URL, using relative paths', { error })
resumeLinks = {
apiUrl: buildResumeApiUrl(undefined, workflowId, executionId, contextId),
uiUrl: buildResumeUiUrl(undefined, workflowId, executionId),
contextId,
executionId,
workflowId,
}
}
}
const normalizedInputFormat = this.normalizeInputFormat(inputs.inputFormat)
const responseStructure = this.normalizeResponseStructure(inputs.builderData)
let responseData: any
let statusCode: number
let responseHeaders: Record<string, string>
if (operation === PAUSE_RESUME.OPERATION.API) {
const parsed = this.parseResponseData(inputs)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
responseData = {
...parsed,
operation,
responseStructure:
parsed.responseStructure && Array.isArray(parsed.responseStructure)
? parsed.responseStructure
: responseStructure,
}
} else {
responseData = parsed
}
statusCode = this.parseStatus(inputs.status)
responseHeaders = this.parseHeaders(inputs.headers)
} else {
responseData = {
operation,
responseStructure,
inputFormat: normalizedInputFormat,
submission: null,
}
statusCode = HTTP.STATUS.OK
responseHeaders = { 'Content-Type': HTTP.CONTENT_TYPE.JSON }
}
let notificationResults: NotificationToolResult[] | undefined
if (
operation === PAUSE_RESUME.OPERATION.HUMAN &&
inputs.notification &&
Array.isArray(inputs.notification)
) {
notificationResults = await this.executeNotificationTools(ctx, block, inputs.notification, {
resumeLinks,
executionId,
workflowId,
inputFormat: normalizedInputFormat,
responseStructure,
operation,
})
}
const responseDataWithResume =
resumeLinks &&
responseData &&
typeof responseData === 'object' &&
!Array.isArray(responseData)
? { ...responseData, _resume: resumeLinks }
: responseData
const pauseMetadata: PauseMetadata = {
contextId,
blockId: nodeMetadata.nodeId,
response: {
data: responseDataWithResume,
status: statusCode,
headers: responseHeaders,
},
timestamp,
parallelScope,
loopScope,
resumeLinks,
pauseKind: 'human',
}
const responseOutput: Record<string, any> = {
data: responseDataWithResume,
status: statusCode,
headers: responseHeaders,
operation,
}
if (operation === PAUSE_RESUME.OPERATION.HUMAN) {
responseOutput.responseStructure = responseStructure
responseOutput.inputFormat = normalizedInputFormat
responseOutput.submission = null
}
if (resumeLinks) {
responseOutput.resume = resumeLinks
}
const structuredFields: Record<string, any> = {}
if (operation === PAUSE_RESUME.OPERATION.HUMAN) {
for (const field of normalizedInputFormat) {
if (field.name) {
structuredFields[field.name] = field.value !== undefined ? field.value : null
}
}
}
const output: Record<string, any> = {
...structuredFields,
response: responseOutput,
_pauseMetadata: pauseMetadata,
}
if (notificationResults && notificationResults.length > 0) {
output.notificationResults = notificationResults
}
if (resumeLinks) {
output.url = resumeLinks.uiUrl
output.resumeEndpoint = resumeLinks.apiUrl
}
return output
} catch (error: any) {
logger.error('Pause resume block execution failed:', error)
return {
response: {
data: {
error: 'Pause resume block execution failed',
message: error.message || 'Unknown error',
},
status: HTTP.STATUS.SERVER_ERROR,
headers: { 'Content-Type': HTTP.CONTENT_TYPE.JSON },
},
}
}
}
private parseResponseData(inputs: Record<string, any>): any {
const dataMode = inputs.dataMode || 'structured'
if (dataMode === 'json' && inputs.data) {
if (typeof inputs.data === 'string') {
try {
return JSON.parse(inputs.data)
} catch (error) {
logger.warn('Failed to parse JSON data, returning as string:', error)
return inputs.data
}
} else if (typeof inputs.data === 'object' && inputs.data !== null) {
return inputs.data
}
return inputs.data
}
if (dataMode === 'structured' && inputs.builderData) {
const convertedData = convertBuilderDataToJson(inputs.builderData)
return parseObjectStrings(convertedData)
}
return inputs.data || {}
}
private normalizeResponseStructure(
builderData?: JSONProperty[],
prefix = ''
): ResponseStructureEntry[] {
if (!Array.isArray(builderData)) {
return []
}
const entries: ResponseStructureEntry[] = []
for (const prop of builderData) {
const fieldName = typeof prop.name === 'string' ? prop.name.trim() : ''
if (!fieldName) continue
const path = prefix ? `${prefix}.${fieldName}` : fieldName
if (prop.type === 'object' && Array.isArray(prop.value)) {
const nested = this.normalizeResponseStructure(prop.value, path)
if (nested.length > 0) {
entries.push(...nested)
continue
}
}
const value = convertPropertyValue(prop)
entries.push({
name: path,
type: prop.type,
value,
})
}
return entries
}
private normalizeInputFormat(inputFormat: any): NormalizedInputField[] {
if (!Array.isArray(inputFormat)) {
return []
}
return inputFormat
.map((field: any, index: number) => {
const name = typeof field?.name === 'string' ? field.name.trim() : ''
if (!name) return null
const id =
typeof field?.id === 'string' && field.id.length > 0 ? field.id : `field_${index}`
const label =
typeof field?.label === 'string' && field.label.trim().length > 0
? field.label.trim()
: name
const type =
typeof field?.type === 'string' && field.type.trim().length > 0 ? field.type : 'string'
const description =
typeof field?.description === 'string' && field.description.trim().length > 0
? field.description.trim()
: undefined
const placeholder =
typeof field?.placeholder === 'string' && field.placeholder.trim().length > 0
? field.placeholder.trim()
: undefined
const required = field?.required === true
const options = Array.isArray(field?.options) ? field.options : undefined
return {
id,
name,
label,
type,
description,
placeholder,
value: field?.value,
required,
options,
} as NormalizedInputField
})
.filter((field): field is NormalizedInputField => field !== null)
}
private parseStatus(status?: string): number {
if (!status) return HTTP.STATUS.OK
const parsed = Number(status)
if (Number.isNaN(parsed) || parsed < 100 || parsed > 599) {
return HTTP.STATUS.OK
}
return parsed
}
private parseHeaders(
headers: {
id: string
cells: { Key: string; Value: string }
}[]
): Record<string, string> {
const defaultHeaders = { 'Content-Type': HTTP.CONTENT_TYPE.JSON }
if (!headers) return defaultHeaders
const headerObj = headers.reduce((acc: Record<string, string>, header) => {
if (header?.cells?.Key && header?.cells?.Value) {
acc[header.cells.Key] = header.cells.Value
}
return acc
}, {})
return { ...defaultHeaders, ...headerObj }
}
private async executeNotificationTools(
ctx: ExecutionContext,
block: SerializedBlock,
tools: any[],
context: {
resumeLinks?: {
apiUrl: string
uiUrl: string
contextId: string
executionId: string
workflowId: string
}
executionId?: string
workflowId?: string
inputFormat?: NormalizedInputField[]
responseStructure?: ResponseStructureEntry[]
operation?: string
}
): Promise<NotificationToolResult[]> {
if (!tools || tools.length === 0) {
return []
}
const { blockData: collectedBlockData, blockNameMapping: collectedBlockNameMapping } =
collectBlockData(ctx)
const blockDataWithPause: Record<string, any> = { ...collectedBlockData }
const blockNameMappingWithPause: Record<string, string> = { ...collectedBlockNameMapping }
const pauseBlockId = block.id
const pauseBlockName = block.metadata?.name
const pauseOutput: Record<string, any> = {
...(blockDataWithPause[pauseBlockId] || {}),
}
if (context.resumeLinks) {
if (context.resumeLinks.uiUrl) {
pauseOutput.url = context.resumeLinks.uiUrl
}
if (context.resumeLinks.apiUrl) {
pauseOutput.resumeEndpoint = context.resumeLinks.apiUrl
}
}
if (Array.isArray(context.inputFormat)) {
for (const field of context.inputFormat) {
if (field?.name) {
const fieldName = field.name.trim()
if (fieldName.length > 0 && !(fieldName in pauseOutput)) {
pauseOutput[fieldName] = field.value !== undefined ? field.value : null
}
}
}
}
blockDataWithPause[pauseBlockId] = pauseOutput
if (pauseBlockName) {
blockNameMappingWithPause[normalizeName(pauseBlockName)] = pauseBlockId
}
const notificationPromises = tools.map<Promise<NotificationToolResult>>(async (toolConfig) => {
const startTime = Date.now()
try {
const toolId = toolConfig.toolId
if (!toolId) {
logger.warn('Notification tool missing toolId', { toolConfig })
return {
toolId: 'unknown',
title: toolConfig.title,
operation: toolConfig.operation,
success: false,
}
}
const toolParams = {
...toolConfig.params,
_pauseContext: {
resumeApiUrl: context.resumeLinks?.apiUrl,
resumeUiUrl: context.resumeLinks?.uiUrl,
executionId: context.executionId,
workflowId: context.workflowId,
contextId: context.resumeLinks?.contextId,
inputFormat: context.inputFormat,
responseStructure: context.responseStructure,
operation: context.operation,
},
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
},
blockData: blockDataWithPause,
blockNameMapping: blockNameMappingWithPause,
}
const result = await executeTool(toolId, toolParams, { executionContext: ctx })
const durationMs = Date.now() - startTime
if (!result.success) {
logger.warn('Notification tool execution failed', {
toolId,
error: result.error,
})
return {
toolId,
title: toolConfig.title,
operation: toolConfig.operation,
success: false,
durationMs,
}
}
return {
toolId,
title: toolConfig.title,
operation: toolConfig.operation,
success: true,
durationMs,
}
} catch (error) {
logger.error('Error executing notification tool', { error, toolConfig })
return {
toolId: toolConfig.toolId || 'unknown',
title: toolConfig.title,
operation: toolConfig.operation,
success: false,
}
}
})
return Promise.all(notificationPromises)
}
}
@@ -0,0 +1,599 @@
import '@sim/testing/mocks/executor'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler'
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const {
mockBuildAuthHeaders,
mockBuildAPIUrl,
mockExtractAPIErrorMessage,
mockGenerateId,
mockIsExecutionCancelled,
mockIsRedisCancellationEnabled,
mockReadUserFileContent,
} = vi.hoisted(() => ({
mockBuildAuthHeaders: vi.fn(),
mockBuildAPIUrl: vi.fn(),
mockExtractAPIErrorMessage: vi.fn(),
mockGenerateId: vi.fn(),
mockIsExecutionCancelled: vi.fn(),
mockIsRedisCancellationEnabled: vi.fn(),
mockReadUserFileContent: vi.fn(),
}))
vi.mock('@/executor/utils/http', () => ({
buildAuthHeaders: mockBuildAuthHeaders,
buildAPIUrl: mockBuildAPIUrl,
extractAPIErrorMessage: mockExtractAPIErrorMessage,
}))
vi.mock('@sim/utils/id', () => ({
generateId: mockGenerateId,
}))
vi.mock('@/lib/execution/cancellation', () => ({
isExecutionCancelled: mockIsExecutionCancelled,
isRedisCancellationEnabled: mockIsRedisCancellationEnabled,
}))
vi.mock('@/lib/execution/payloads/materialization.server', () => ({
readUserFileContent: mockReadUserFileContent,
}))
function createAbortError(): Error {
const error = new Error('The operation was aborted')
error.name = 'AbortError'
return error
}
function createAbortableFetchPromise(signal?: AbortSignal): Promise<Response> {
return new Promise((_resolve, reject) => {
if (signal?.aborted) {
reject(createAbortError())
return
}
signal?.addEventListener(
'abort',
() => {
reject(createAbortError())
},
{ once: true }
)
})
}
async function readStreamText(stream: ReadableStream): Promise<string> {
const reader = stream.getReader()
const decoder = new TextDecoder()
let text = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
text += decoder.decode(value, { stream: true })
}
text += decoder.decode()
reader.releaseLock()
return text
}
describe('MothershipBlockHandler', () => {
let handler: MothershipBlockHandler
let block: SerializedBlock
let context: ExecutionContext
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
handler = new MothershipBlockHandler()
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
mockBuildAuthHeaders.mockResolvedValue({ Authorization: 'Bearer internal' })
mockBuildAPIUrl.mockReturnValue(new URL('/api/mothership/execute', 'http://localhost:3000'))
mockExtractAPIErrorMessage.mockResolvedValue('boom')
mockGenerateId.mockReset()
mockIsExecutionCancelled.mockReset()
mockIsRedisCancellationEnabled.mockReset()
mockIsRedisCancellationEnabled.mockReturnValue(false)
mockReadUserFileContent.mockReset()
block = {
id: 'mothership-block-1',
metadata: { id: BlockType.MOTHERSHIP, name: 'Mothership' },
position: { x: 0, y: 0 },
config: { tool: BlockType.MOTHERSHIP, params: {} },
inputs: { prompt: 'string', conversationId: 'string', files: 'file[]' },
outputs: {},
enabled: true,
} as SerializedBlock
context = {
workflowId: 'workflow-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
userId: 'user-1',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
completedLoops: new Set(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
} as ExecutionContext
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
vi.unstubAllGlobals()
})
function createNdjsonResponse(events: unknown[]): Response {
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
start(controller) {
for (const event of events) {
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`))
}
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' },
}
)
}
it('forwards workflow and execution metadata with generated UUID ids', async () => {
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
content: 'done',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: { total: 5 },
toolCalls: [],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
)
const result = await handler.execute(context, block, { prompt: 'Hello from workflow' })
expect(result).toEqual({
content: 'done',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: { total: 5 },
toolCalls: { list: [], count: 0 },
cost: undefined,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('http://localhost:3000/api/mothership/execute')
expect(options.method).toBe('POST')
expect(options.signal).toBeInstanceOf(AbortSignal)
expect(options.headers).toMatchObject({
Accept: 'application/x-ndjson',
'X-Mothership-Execute-Stream': 'ndjson',
})
const body = JSON.parse(String(options.body))
expect(body).toEqual({
messages: [{ role: 'user', content: 'Hello from workflow' }],
workspaceId: 'workspace-1',
userId: 'user-1',
chatId: 'chat-uuid',
messageId: 'message-uuid',
requestId: 'request-uuid',
workflowId: 'workflow-1',
executionId: 'execution-1',
})
})
it('uses a provided conversation ID as the mothership chat ID', async () => {
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
content: 'continued',
model: 'mothership',
conversationId: 'existing-chat-id',
tokens: {},
toolCalls: [],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
)
const result = await handler.execute(context, block, {
prompt: 'Continue this thread',
conversationId: ' existing-chat-id ',
})
expect(result).toEqual({
content: 'continued',
model: 'mothership',
conversationId: 'existing-chat-id',
tokens: {},
toolCalls: { list: [], count: 0 },
cost: undefined,
})
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
const body = JSON.parse(String(options.body))
expect(body).toEqual({
messages: [{ role: 'user', content: 'Continue this thread' }],
workspaceId: 'workspace-1',
userId: 'user-1',
chatId: 'existing-chat-id',
messageId: 'message-uuid',
requestId: 'request-uuid',
workflowId: 'workflow-1',
executionId: 'execution-1',
})
expect(mockGenerateId).toHaveBeenCalledTimes(2)
})
it('consumes mothership execute heartbeat streams until the final result', async () => {
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
fetchMock.mockResolvedValue(
createNdjsonResponse([
{ type: 'heartbeat', timestamp: '2026-05-15T18:13:48.000Z' },
{
type: 'final',
data: {
content: 'streamed done',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: { total: 7 },
toolCalls: [{ name: 'tool_a', params: { a: 1 }, result: 'ok', durationMs: 42 }],
cost: { total: 0.1 },
},
},
])
)
const result = await handler.execute(context, block, { prompt: 'Hello from workflow' })
expect(result).toEqual({
content: 'streamed done',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: { total: 7 },
toolCalls: {
list: [
{
name: 'tool_a',
arguments: { a: 1 },
result: 'ok',
error: undefined,
duration: 42,
},
],
count: 1,
},
cost: { total: 0.1 },
})
})
it('preserves failed tool calls as output metadata without throwing', async () => {
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
fetchMock.mockResolvedValue(
createNdjsonResponse([
{
type: 'final',
data: {
content: 'The lookup failed, so I could not use that result.',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: { total: 7 },
toolCalls: [
{
name: 'lookup_customer',
status: 'error',
params: { email: 'missing@example.com' },
result: { success: false, error: 'Customer not found' },
error: 'Customer not found',
durationMs: 42,
},
],
},
},
])
)
const result = await handler.execute(context, block, { prompt: 'Hello from workflow' })
expect(result).toEqual({
content: 'The lookup failed, so I could not use that result.',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: { total: 7 },
toolCalls: {
list: [
expect.objectContaining({
name: 'lookup_customer',
status: 'error',
arguments: { email: 'missing@example.com' },
result: { success: false, error: 'Customer not found' },
error: 'Customer not found',
duration: 42,
}),
],
count: 1,
},
cost: undefined,
})
})
it('surfaces mothership execute stream errors', async () => {
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
fetchMock.mockResolvedValue(
createNdjsonResponse([
{ type: 'heartbeat', timestamp: '2026-05-15T18:13:48.000Z' },
{ type: 'error', error: 'Mothership execution aborted' },
])
)
await expect(
handler.execute(context, block, { prompt: 'Hello from workflow' })
).rejects.toThrow('Sim execution failed: Mothership execution aborted')
})
it('streams mothership assistant chunks and preserves final metadata', async () => {
context.stream = true
context.selectedOutputs = [`${block.id}_content`]
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
fetchMock.mockResolvedValue(
createNdjsonResponse([
{ type: 'heartbeat', timestamp: '2026-05-15T18:13:48.000Z' },
{ type: 'chunk', content: 'Hello' },
{ type: 'heartbeat', timestamp: '2026-05-15T18:14:03.000Z' },
{ type: 'chunk', content: ' world' },
{
type: 'final',
data: {
content: 'Hello world',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: { total: 7 },
toolCalls: [{ name: 'tool_a', params: { a: 1 }, result: 'ok', durationMs: 42 }],
cost: { total: 0.1 },
},
},
])
)
const result = await handler.execute(context, block, { prompt: 'Hello from workflow' })
expect(result).toHaveProperty('stream')
const streamingExecution = result as StreamingExecution
await expect(readStreamText(streamingExecution.stream)).resolves.toBe('Hello world')
expect(streamingExecution.execution.output).toEqual({
content: 'Hello world',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: { total: 7 },
toolCalls: {
list: [
{
name: 'tool_a',
arguments: { a: 1 },
result: 'ok',
error: undefined,
duration: 42,
},
],
count: 1,
},
cost: { total: 0.1 },
})
})
it('surfaces mothership streaming errors while streaming selected content', async () => {
context.stream = true
context.selectedOutputs = [`${block.id}_content`]
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
fetchMock.mockResolvedValue(
createNdjsonResponse([
{ type: 'chunk', content: 'partial' },
{ type: 'error', error: 'Mothership execution aborted' },
])
)
const result = (await handler.execute(context, block, {
prompt: 'Hello from workflow',
})) as StreamingExecution
await expect(readStreamText(result.stream)).rejects.toThrow(
'Sim execution failed: Mothership execution aborted'
)
})
it('embeds attached files for the mothership execute request', async () => {
const fileContent = Buffer.from('hello mothership', 'utf8').toString('base64')
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
mockReadUserFileContent.mockResolvedValueOnce(fileContent)
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
content: 'analyzed',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: {},
toolCalls: [],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
)
const result = await handler.execute(context, block, {
prompt: 'Analyze this file',
files: [
{
name: 'notes.txt',
key: 'workspace/workspace-1/notes.txt',
size: 16,
type: 'text/plain',
},
],
})
expect(result).toMatchObject({
content: 'analyzed',
model: 'mothership',
conversationId: 'chat-uuid',
})
expect(mockReadUserFileContent).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.stringMatching(/^file-/),
key: 'workspace/workspace-1/notes.txt',
name: 'notes.txt',
url: '',
size: 16,
type: 'text/plain',
}),
expect.objectContaining({
encoding: 'base64',
userId: 'user-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
requestId: 'request-uuid',
})
)
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
const body = JSON.parse(String(options.body))
expect(body.fileAttachments).toEqual([
{
type: 'document',
source: {
type: 'base64',
media_type: 'text/plain',
data: fileContent,
},
filename: 'notes.txt',
},
])
})
it('propagates local aborts to the mothership request', async () => {
const abortController = new AbortController()
context.abortSignal = abortController.signal
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
fetchMock.mockImplementation((_url: string, options?: RequestInit) =>
createAbortableFetchPromise(options?.signal as AbortSignal | undefined)
)
const executionPromise = handler.execute(context, block, { prompt: 'Abort me' })
const abortedExecution = executionPromise.catch((error) => error)
abortController.abort()
await expect(abortedExecution).resolves.toMatchObject({ name: 'AbortError' })
})
it('propagates durable workflow cancellation to the mothership request', async () => {
vi.useFakeTimers()
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
mockIsRedisCancellationEnabled.mockReturnValue(true)
mockIsExecutionCancelled.mockResolvedValueOnce(false).mockResolvedValueOnce(true)
fetchMock.mockImplementation((_url: string, options?: RequestInit) =>
createAbortableFetchPromise(options?.signal as AbortSignal | undefined)
)
const executionPromise = handler.execute(context, block, { prompt: 'Cancel me durably' })
const abortedExecution = executionPromise.catch((error) => error)
await vi.advanceTimersByTimeAsync(1000)
await expect(abortedExecution).resolves.toMatchObject({ name: 'AbortError' })
expect(mockIsExecutionCancelled).toHaveBeenCalledWith('execution-1')
})
it('aborts the mothership request when selected-output streaming is cancelled', async () => {
context.stream = true
context.selectedOutputs = [`${block.id}_content`]
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
let fetchSignal: AbortSignal | undefined
fetchMock.mockImplementation((_url: string, options?: RequestInit) => {
fetchSignal = options?.signal as AbortSignal | undefined
return Promise.resolve(
new Response(
new ReadableStream({
start() {},
}),
{
status: 200,
headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' },
}
)
)
})
const result = (await handler.execute(context, block, { prompt: 'Cancel stream' })) as
| StreamingExecution
| undefined
await result?.stream.cancel('client_cancelled')
expect(fetchSignal?.aborted).toBe(true)
})
})
@@ -0,0 +1,459 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
import { readUserFileContent } from '@/lib/execution/payloads/materialization.server'
import {
createFileContentFromBase64,
type MessageContent,
processSingleFileToUserFile,
type RawFileInput,
} from '@/lib/uploads/utils/file-utils'
import type { BlockOutput } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
import { BlockType } from '@/executor/constants'
import type {
BlockHandler,
ExecutionContext,
NormalizedBlockOutput,
StreamingExecution,
} from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('MothershipBlockHandler')
const CANCELLATION_CHECK_INTERVAL_MS = 500
const MAX_MOTHERSHIP_ATTACHMENT_BYTES = 10 * 1024 * 1024
const MOTHERSHIP_EXECUTE_STREAM_HEADER = 'X-Mothership-Execute-Stream'
const MOTHERSHIP_EXECUTE_STREAM_VALUE = 'ndjson'
type MothershipFileAttachment = MessageContent & {
filename?: string
}
type MothershipExecuteResult = {
content?: string
model?: string
conversationId?: string
tokens?: Record<string, unknown>
toolCalls?: Array<Record<string, unknown>>
cost?: unknown
}
type MothershipExecuteStreamEvent =
| { type: 'heartbeat'; timestamp?: string }
| { type: 'chunk'; content?: string }
| { type: 'final'; data: MothershipExecuteResult }
| { type: 'error'; error?: string }
function parseMothershipExecuteStreamLine(line: string): MothershipExecuteStreamEvent | undefined {
const trimmed = line.trim()
if (!trimmed) return undefined
try {
return JSON.parse(trimmed) as MothershipExecuteStreamEvent
} catch {
throw new Error('Sim execution stream returned malformed data')
}
}
function formatMothershipBlockOutput(
result: MothershipExecuteResult,
fallbackChatId: string
): NormalizedBlockOutput {
const formattedList = (result.toolCalls || []).map((tc: Record<string, unknown>) => ({
name: typeof tc.name === 'string' ? tc.name : String(tc.name ?? ''),
...(typeof tc.status === 'string' ? { status: tc.status } : {}),
arguments: (tc.arguments || tc.params || tc.input || {}) as Record<string, unknown>,
result: (tc.result ?? tc.output) as any,
error: typeof tc.error === 'string' ? tc.error : undefined,
duration: typeof tc.durationMs === 'number' ? tc.durationMs : 0,
}))
const toolCalls: NormalizedBlockOutput['toolCalls'] = {
list: formattedList,
count: formattedList.length,
}
return {
content: result.content || '',
model: result.model || 'mothership',
conversationId: result.conversationId || fallbackChatId,
tokens: (result.tokens || {}) as NormalizedBlockOutput['tokens'],
toolCalls,
cost: result.cost as NormalizedBlockOutput['cost'] | undefined,
}
}
function isContentSelectedForStreaming(ctx: ExecutionContext, block: SerializedBlock): boolean {
if (!ctx.stream) return false
return (
ctx.selectedOutputs?.some((outputId) => {
if (outputId === block.id) return true
return outputId === `${block.id}.content` || outputId === `${block.id}_content`
}) ?? false
)
}
async function readMothershipExecuteResponse(response: Response): Promise<MothershipExecuteResult> {
const contentType = response.headers.get('content-type') || ''
if (!contentType.includes('application/x-ndjson')) {
return response.json()
}
if (!response.body) {
throw new Error('Sim execution stream ended without a response body')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let finalResult: MothershipExecuteResult | undefined
const processLine = (line: string) => {
const event = parseMothershipExecuteStreamLine(line)
if (!event) return
if (event.type === 'heartbeat' || event.type === 'chunk') {
return
}
if (event.type === 'error') {
throw new Error(`Sim execution failed: ${event.error || 'Unknown error'}`)
}
if (event.type === 'final') {
finalResult = event.data
return
}
throw new Error('Sim execution stream returned an unknown event')
}
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
processLine(line)
}
}
buffer += decoder.decode()
processLine(buffer)
if (!finalResult) {
throw new Error('Sim execution stream ended without a final result')
}
return finalResult
} finally {
reader.releaseLock()
}
}
function createMothershipStreamingExecution(
response: Response,
fallbackChatId: string,
blockId: string,
options: {
onCancel?: (reason?: unknown) => void
onDone?: () => void
} = {}
): StreamingExecution {
if (!response.body) {
throw new Error('Sim execution stream ended without a response body')
}
const output = formatMothershipBlockOutput({}, fallbackChatId)
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
let cancelled = false
let cleanedUp = false
const cleanup = () => {
if (cleanedUp) return
cleanedUp = true
options.onDone?.()
}
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
reader = response.body!.getReader()
const decoder = new TextDecoder()
const encoder = new TextEncoder()
let buffer = ''
let sawFinal = false
const processLine = (line: string) => {
const event = parseMothershipExecuteStreamLine(line)
if (!event) return
if (event.type === 'heartbeat') {
return
}
if (event.type === 'chunk') {
if (event.content) {
controller.enqueue(encoder.encode(event.content))
}
return
}
if (event.type === 'error') {
throw new Error(`Sim execution failed: ${event.error || 'Unknown error'}`)
}
if (event.type === 'final') {
sawFinal = true
Object.assign(output, formatMothershipBlockOutput(event.data, fallbackChatId))
return
}
throw new Error('Sim execution stream returned an unknown event')
}
try {
while (true) {
const { done, value } = await reader.read()
if (cancelled) return
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
processLine(line)
}
}
buffer += decoder.decode()
processLine(buffer)
if (!sawFinal) {
throw new Error('Sim execution stream ended without a final result')
}
if (!cancelled) {
controller.close()
}
} catch (error) {
if (!cancelled) {
controller.error(error)
}
} finally {
cleanup()
reader?.releaseLock()
}
},
cancel(reason) {
cancelled = true
options.onCancel?.(reason)
cleanup()
return reader?.cancel(reason)
},
})
return {
stream,
execution: {
success: true,
output,
blockId,
logs: [],
metadata: {
duration: 0,
startTime: new Date().toISOString(),
},
isStreaming: true,
} as StreamingExecution['execution'] & { blockId: string },
}
}
async function buildMothershipFileAttachments(
filesInput: unknown,
ctx: ExecutionContext,
requestId: string
): Promise<MothershipFileAttachment[] | undefined> {
const files = normalizeFileInput(filesInput)
if (!files || files.length === 0) {
return undefined
}
if (!ctx.userId) {
throw new Error('Mothership file attachments require an authenticated user.')
}
const attachments: MothershipFileAttachment[] = []
for (const file of files) {
const userFile = processSingleFileToUserFile(file as RawFileInput, requestId, logger)
const base64 = await readUserFileContent(userFile, {
encoding: 'base64',
userId: ctx.userId,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
fileKeys: ctx.fileKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
requestId,
logger,
maxBytes: MAX_MOTHERSHIP_ATTACHMENT_BYTES,
maxSourceBytes: MAX_MOTHERSHIP_ATTACHMENT_BYTES,
})
const content = createFileContentFromBase64(base64, userFile.type)
if (!content) {
throw new Error(`File type is not supported for Mothership attachments: ${userFile.name}`)
}
attachments.push({ ...content, filename: userFile.name })
}
return attachments
}
/**
* Handler for Mothership blocks that proxy requests to the Mothership AI agent.
*
* Unlike the Agent block (which calls LLM providers directly), the Mothership
* block delegates to the full Mothership infrastructure: main agent, subagents,
* integration tools, memory, and workspace context.
*/
export class MothershipBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.MOTHERSHIP
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput | StreamingExecution> {
const prompt = inputs.prompt
if (!prompt || typeof prompt !== 'string') {
throw new Error('Prompt input is required')
}
const messages = [{ role: 'user' as const, content: prompt }]
const providedConversationId =
typeof inputs.conversationId === 'string' ? inputs.conversationId.trim() : ''
const chatId = providedConversationId || generateId()
const messageId = generateId()
const requestId = generateId()
const fileAttachments = await buildMothershipFileAttachments(inputs.files, ctx, requestId)
const url = buildAPIUrl('/api/mothership/execute')
const headers = await buildAuthHeaders(ctx.userId)
headers.Accept = 'application/x-ndjson'
headers[MOTHERSHIP_EXECUTE_STREAM_HEADER] = MOTHERSHIP_EXECUTE_STREAM_VALUE
const body: Record<string, unknown> = {
messages,
workspaceId: ctx.workspaceId || '',
userId: ctx.userId || '',
chatId,
messageId,
requestId,
...(fileAttachments && { fileAttachments }),
...(ctx.workflowId ? { workflowId: ctx.workflowId } : {}),
...(ctx.executionId ? { executionId: ctx.executionId } : {}),
}
logger.info('Executing Mothership block', {
blockId: block.id,
messageId,
requestId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
chatId,
fileAttachmentCount: fileAttachments?.length ?? 0,
})
const abortController = new AbortController()
const onAbort = () => {
if (!abortController.signal.aborted) {
abortController.abort(ctx.abortSignal?.reason ?? 'workflow_abort')
}
}
if (ctx.abortSignal?.aborted) {
onAbort()
} else {
ctx.abortSignal?.addEventListener('abort', onAbort, { once: true })
}
const executionId = ctx.executionId
const useRedisCancellation = isRedisCancellationEnabled() && !!executionId
let pollInFlight = false
const cancellationPoller =
useRedisCancellation && executionId
? setInterval(() => {
if (pollInFlight || abortController.signal.aborted) {
return
}
pollInFlight = true
void isExecutionCancelled(executionId)
.then((cancelled) => {
if (cancelled && !abortController.signal.aborted) {
abortController.abort('workflow_execution_cancelled')
}
})
.catch((error) => {
logger.warn('Failed to poll workflow cancellation for Mothership block', {
blockId: block.id,
executionId,
error: toError(error).message,
})
})
.finally(() => {
pollInFlight = false
})
}, CANCELLATION_CHECK_INTERVAL_MS)
: undefined
const cleanupAbortListeners = () => {
if (cancellationPoller) {
clearInterval(cancellationPoller)
}
ctx.abortSignal?.removeEventListener('abort', onAbort)
}
let response: Response
let cleanupImmediately = true
try {
response = await fetch(url.toString(), {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: abortController.signal,
})
if (!response.ok) {
const errorMsg = await extractAPIErrorMessage(response)
throw new Error(`Sim execution failed: ${errorMsg}`)
}
if (isContentSelectedForStreaming(ctx, block)) {
const streamingExecution = createMothershipStreamingExecution(response, chatId, block.id, {
onCancel: (reason) => {
if (!abortController.signal.aborted) {
abortController.abort(reason ?? 'mothership_stream_cancelled')
}
},
onDone: cleanupAbortListeners,
})
cleanupImmediately = false
return streamingExecution
}
const result = await readMothershipExecuteResponse(response)
return formatMothershipBlockOutput(result, chatId)
} finally {
if (cleanupImmediately) {
cleanupAbortListeners()
}
}
}
}
+99
View File
@@ -0,0 +1,99 @@
/**
* The seam between the Pi handler and its execution environments. The handler
* resolves keys, skills, memory, and tools, then hands a {@link PiRunParams} to
* one backend ({@link PiBackendRun}) selected by `mode`. Backends own only the
* environment-specific execution (SSH vs E2B) and report progress through
* {@link PiRunContext.onEvent}.
*/
import type { SSHConnectionConfig } from '@/app/api/tools/ssh/utils'
import type { Message } from '@/executor/handlers/agent/types'
import type { PiEvent, PiRunTotals } from '@/executor/handlers/pi/events'
/** A conversation message seeded into the Pi run (subset of the Agent block's message). */
export type PiMessage = Pick<Message, 'role' | 'content'>
/** A resolved skill (name + full content) made available to Pi. */
export interface PiSkill {
name: string
content: string
}
/** SSH connection parameters for local mode (subset of the shared SSH config). */
export type PiSshConnection = Pick<
SSHConnectionConfig,
'host' | 'port' | 'username' | 'password' | 'privateKey' | 'passphrase'
>
/** Result of invoking a tool Pi called. */
export interface PiToolResult {
text: string
isError: boolean
}
/**
* A tool exposed to Pi in a backend-neutral shape (the SSH file/bash tools and
* adapted Sim tools both use it). The local backend converts these into Pi
* `customTools`; keeping them Pi-SDK-free keeps this seam typed.
*/
export interface PiToolSpec {
name: string
description: string
parameters: Record<string, unknown>
execute: (args: Record<string, unknown>) => Promise<PiToolResult>
}
interface PiRunBaseParams {
model: string
providerId: string
apiKey: string
isBYOK: boolean
task: string
thinkingLevel?: string
skills: PiSkill[]
initialMessages: PiMessage[]
}
/** Parameters for a local (SSH) Pi run. */
export interface PiLocalRunParams extends PiRunBaseParams {
mode: 'local'
ssh: PiSshConnection
repoPath: string
tools: PiToolSpec[]
}
/** Parameters for a cloud (E2B) Pi run. */
export interface PiCloudRunParams extends PiRunBaseParams {
mode: 'cloud'
owner: string
repo: string
githubToken: string
baseBranch?: string
branchName?: string
draft: boolean
prTitle?: string
prBody?: string
}
export type PiRunParams = PiLocalRunParams | PiCloudRunParams
/** Progress callbacks and cancellation passed into a backend run. */
export interface PiRunContext {
onEvent: (event: PiEvent) => void
signal?: AbortSignal
}
/** Final result of a Pi run. */
export interface PiRunResult {
totals: PiRunTotals
changedFiles?: string[]
diff?: string
prUrl?: string
branch?: string
}
/** A Pi execution backend. Implemented by the local (SSH) and cloud (E2B) runners. */
export type PiBackendRun<P extends PiRunParams = PiRunParams> = (
params: P,
context: PiRunContext
) => Promise<PiRunResult>
@@ -0,0 +1,281 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockRun, mockReadFile, mockWriteFile, mockExecuteTool, mockProviderEnvVar } = vi.hoisted(
() => ({
mockRun: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockExecuteTool: vi.fn(),
mockProviderEnvVar: vi.fn(),
})
)
vi.mock('@/lib/execution/e2b', () => ({
withPiSandbox: (fn: (runner: unknown) => unknown) =>
fn({ run: mockRun, readFile: mockReadFile, writeFile: mockWriteFile }),
}))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
vi.mock('@/executor/handlers/pi/keys', () => ({
providerApiKeyEnvVar: mockProviderEnvVar,
mapThinkingLevel: () => 'medium',
}))
vi.mock('@/executor/handlers/pi/context', () => ({ buildPiPrompt: () => 'PROMPT' }))
import type { PiCloudRunParams } from '@/executor/handlers/pi/backend'
import { runCloudPi } from '@/executor/handlers/pi/cloud-backend'
function baseParams(overrides: Partial<PiCloudRunParams> = {}): PiCloudRunParams {
return {
mode: 'cloud',
model: 'claude',
providerId: 'anthropic',
apiKey: 'sk-byok',
isBYOK: true,
task: 'do it',
skills: [],
initialMessages: [],
owner: 'octo',
repo: 'demo',
githubToken: 'ghp_secret',
branchName: 'feature-x',
draft: true,
...overrides,
}
}
describe('runCloudPi', () => {
beforeEach(() => {
vi.clearAllMocks()
mockProviderEnvVar.mockReturnValue('ANTHROPIC_API_KEY')
mockReadFile.mockResolvedValue('diff content')
mockExecuteTool.mockResolvedValue({
success: true,
output: { metadata: { html_url: 'https://github.com/octo/demo/pull/1' } },
})
mockRun.mockImplementation(
(command: string, options: { onStdout?: (chunk: string) => void }) => {
if (command.includes('git clone')) {
return Promise.resolve({
stdout: '__BASE_SHA__=abc123\n__DEFAULT_BRANCH__=main',
stderr: '',
exitCode: 0,
})
}
if (command.includes('pi -p')) {
options.onStdout?.(
'{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"done"}}\n'
)
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
}
if (command.includes('push')) {
return Promise.resolve({ stdout: '__PUSHED__=1', stderr: '', exitCode: 0 })
}
return Promise.resolve({
stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1',
stderr: '',
exitCode: 0,
})
}
)
})
it('isolates secrets per command: token only in clone/push, model key only in the Pi loop', async () => {
const onEvent = vi.fn()
await runCloudPi(baseParams(), { onEvent })
const [cloneCmd, cloneOpts] = mockRun.mock.calls[0]
const [piCmd, piOpts] = mockRun.mock.calls[1]
const [prepareCmd, prepareOpts] = mockRun.mock.calls[2]
const [pushCmd, pushOpts] = mockRun.mock.calls[3]
expect(cloneCmd).toContain('git clone')
expect(cloneOpts.envs.GITHUB_TOKEN).toBe('ghp_secret')
expect(cloneOpts.envs.ANTHROPIC_API_KEY).toBeUndefined()
expect(piCmd).toContain('pi -p')
expect(piCmd).toContain('--provider')
expect(piOpts.envs.ANTHROPIC_API_KEY).toBe('sk-byok')
expect(piOpts.envs.GITHUB_TOKEN).toBeUndefined()
expect(piOpts.envs.PI_MODEL).toBe('claude')
expect(piOpts.envs.PI_PROVIDER).toBe('anthropic')
// PREPARE (add/commit/diff) must NOT carry the token: a repo-config-driven
// program the agent may have planted (clean filter, fsmonitor, textconv) runs
// on these commands and `core.hooksPath` does not stop it, so the credential
// must simply be absent.
expect(prepareCmd).toContain('add -A')
expect(prepareCmd).toContain('core.hooksPath=/dev/null')
expect(prepareOpts.envs.GITHUB_TOKEN).toBeUndefined()
expect(prepareOpts.envs.ANTHROPIC_API_KEY).toBeUndefined()
// PUSH is the only token-bearing command, hardened against planted git-config
// program execution (hooks, credential.helper, fsmonitor).
expect(pushCmd).toContain('push')
expect(pushCmd).toContain('core.hooksPath=/dev/null')
expect(pushCmd).toContain('credential.helper=')
expect(pushCmd).toContain('core.fsmonitor=')
expect(pushOpts.envs.GITHUB_TOKEN).toBe('ghp_secret')
expect(pushOpts.envs.ANTHROPIC_API_KEY).toBeUndefined()
expect(onEvent).toHaveBeenCalledWith({ type: 'text', text: 'done' })
})
it('delivers the prompt and commit message via files, never the command line', async () => {
await runCloudPi(baseParams(), { onEvent: vi.fn() })
// Untrusted text is written through the sandbox FS API, not interpolated into a shell command.
expect(mockWriteFile).toHaveBeenCalledWith('/workspace/pi-prompt.txt', 'PROMPT')
expect(mockWriteFile).toHaveBeenCalledWith('/workspace/pi-commit.txt', 'Pi: do it')
const [piCmd, piOpts] = mockRun.mock.calls[1]
// Prompt arrives on stdin from a fixed path; never a CLI arg or env value.
expect(piCmd).toContain('< /workspace/pi-prompt.txt')
expect(piCmd).not.toContain('PROMPT')
expect(piOpts.envs.PI_TASK).toBeUndefined()
const [prepareCmd, prepareOpts] = mockRun.mock.calls[2]
// Commit message is read from a file, not passed as -m "...".
expect(prepareCmd).toContain('commit -F /workspace/pi-commit.txt')
expect(prepareCmd).not.toContain('commit -m')
expect(prepareOpts.envs.COMMIT_MSG).toBeUndefined()
})
it('opens a PR from the pushed branch and returns its URL', async () => {
const result = await runCloudPi(baseParams(), { onEvent: vi.fn() })
expect(mockExecuteTool).toHaveBeenCalledWith(
'github_create_pr',
expect.objectContaining({
owner: 'octo',
repo: 'demo',
head: 'feature-x',
base: 'main',
draft: true,
apiKey: 'ghp_secret',
})
)
expect(result.prUrl).toBe('https://github.com/octo/demo/pull/1')
expect(result.branch).toBe('feature-x')
expect(result.changedFiles).toEqual(['src/x.ts'])
expect(result.diff).toBe('diff content')
})
it('skips the PR when nothing was pushed', async () => {
mockRun.mockImplementation((command: string) => {
if (command.includes('git clone')) {
return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 })
}
if (command.includes('pi -p')) {
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
}
return Promise.resolve({ stdout: '__NO_CHANGES__=1', stderr: '', exitCode: 0 })
})
const result = await runCloudPi(baseParams(), { onEvent: vi.fn() })
expect(mockExecuteTool).not.toHaveBeenCalled()
expect(result.prUrl).toBeUndefined()
// No changes => the token-bearing push command must never run.
expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false)
})
it('rejects a non-BYOK key (no Sim-owned key in the sandbox)', async () => {
await expect(runCloudPi(baseParams({ isBYOK: false }), { onEvent: vi.fn() })).rejects.toThrow(
/BYOK/
)
})
it('rejects providers that cannot run via a single key', async () => {
mockProviderEnvVar.mockReturnValue(null)
await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/not supported/)
})
it('fails when the Pi CLI exits non-zero (no PR opened)', async () => {
mockRun.mockImplementation((command: string) => {
if (command.includes('git clone')) {
return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 })
}
if (command.includes('pi -p')) {
return Promise.resolve({ stdout: '', stderr: 'model not found', exitCode: 1 })
}
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
})
await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/Pi agent failed/)
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('does not commit, push, or open a PR when the run reports an error on a zero exit', async () => {
mockRun.mockImplementation(
(command: string, options: { onStdout?: (chunk: string) => void }) => {
if (command.includes('git clone')) {
return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 })
}
if (command.includes('pi -p')) {
options.onStdout?.('{"type":"error","error":"model exploded"}\n')
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
}
return Promise.resolve({
stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1',
stderr: '',
exitCode: 0,
})
}
)
await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/model exploded/)
expect(mockExecuteTool).not.toHaveBeenCalled()
expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false)
})
it('fails (no PR) when finalize reports neither no-changes nor a push', async () => {
mockRun.mockImplementation((command: string) => {
if (command.includes('git clone')) {
return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 })
}
if (command.includes('pi -p')) {
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
}
// PREPARE aborted before emitting a marker (e.g. the repo dir vanished).
return Promise.resolve({
stdout: '',
stderr: 'cd: /workspace/repo: No such file or directory',
exitCode: 1,
})
})
await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/finalize failed/)
expect(mockExecuteTool).not.toHaveBeenCalled()
expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false)
})
it('surfaces the real git push error when the push fails, with the token scrubbed', async () => {
mockRun.mockImplementation((command: string) => {
if (command.includes('git clone')) {
return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 })
}
if (command.includes('pi -p')) {
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
}
if (command.includes('push')) {
return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 })
}
return Promise.resolve({
stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1',
stderr: '',
exitCode: 0,
})
})
// The push step writes its stderr to a file; the backend reads + scrubs it.
mockReadFile.mockResolvedValue(
"remote: Permission to octo/demo.git denied.\nfatal: unable to access 'https://x-access-token:ghp_secret@github.com/octo/demo.git/': 403"
)
const error = (await runCloudPi(baseParams(), { onEvent: vi.fn() }).catch((e) => e)) as Error
expect(error.message).toMatch(/git push failed/)
expect(error.message).toMatch(/Permission to octo\/demo\.git denied/)
expect(error.message).not.toContain('ghp_secret')
expect(mockExecuteTool).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,353 @@
/**
* Cloud-mode backend: runs the Pi CLI inside an E2B sandbox against a cloned
* GitHub repo, then pushes a branch and opens a PR. Secrets are isolated per
* command (S2/KTD10): the GitHub token is present only for the clone and push
* commands (and stripped from the cloned remote), while the Pi loop runs with a
* BYOK model key only. The model key is never a Sim-owned hosted key (S1).
*
* Untrusted text (the assembled prompt, which folds in workspace-shared skills
* and memory, and the commit message) is never placed on a shell command line.
* It is written into sandbox files via the E2B filesystem API and read back from
* fixed paths (Pi's prompt on stdin, `git commit -F <file>`), so a collaborator-
* authored skill cannot inject shell into the Pi step where the model key lives.
*/
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { withPiSandbox } from '@/lib/execution/e2b'
import type { PiBackendRun, PiCloudRunParams } from '@/executor/handlers/pi/backend'
import { buildPiPrompt } from '@/executor/handlers/pi/context'
import {
applyPiEvent,
createPiTotals,
type PiRunTotals,
parseJsonLine,
} from '@/executor/handlers/pi/events'
import { mapThinkingLevel, providerApiKeyEnvVar } from '@/executor/handlers/pi/keys'
import { executeTool } from '@/tools'
const logger = createLogger('PiCloudBackend')
const REPO_DIR = '/workspace/repo'
const DIFF_PATH = '/workspace/pi.diff'
const PROMPT_PATH = '/workspace/pi-prompt.txt'
const COMMIT_MSG_PATH = '/workspace/pi-commit.txt'
const PUSH_ERR_PATH = '/workspace/pi-push-err.txt'
const CLONE_TIMEOUT_MS = 10 * 60 * 1000
const PI_TIMEOUT_MS = getMaxExecutionTimeout()
const FINALIZE_TIMEOUT_MS = 10 * 60 * 1000
const MAX_DIFF_BYTES = 200_000
const COMMIT_TITLE_MAX = 72
const PR_SUMMARY_MAX = 2000
const PUSH_ERROR_MAX = 1000
// The agent only edits files; Sim commits, pushes, and opens the PR after the run.
// Without this, the coding agent tries to git push / open a PR / run the test
// toolchain itself and fails — the sandbox has no GitHub auth (the token is
// stripped from the remote after clone) and may lack the project's tooling.
const CLOUD_GUIDANCE =
'You are running inside an automated sandbox. Make only the file changes needed to complete the task. ' +
'Do not run git commands (commit, push, branch, remote), do not configure git credentials or authenticate ' +
'with GitHub, and do not open a pull request — after you finish, Sim automatically commits your changes, ' +
"pushes the branch, and opens the pull request. The project's package manager and test tooling may not be " +
'installed, so do not block on running the full build or test suite; focus on correct, minimal edits.'
const CLONE_SCRIPT = `set -e
rm -rf ${REPO_DIR}
git clone "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" ${REPO_DIR}
cd ${REPO_DIR}
if [ -n "$BASE_BRANCH" ]; then git checkout "$BASE_BRANCH"; fi
git rev-parse HEAD | sed "s/^/__BASE_SHA__=/"
DEFAULT_BRANCH=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed "s#^origin/##" || true)
echo "__DEFAULT_BRANCH__=$DEFAULT_BRANCH"
git checkout -b "$BRANCH"
git remote set-url origin "https://github.com/$REPO_OWNER/$REPO_NAME.git"`
const PI_SCRIPT = `cd ${REPO_DIR}
pi -p --mode json --provider "$PI_PROVIDER" --model "$PI_MODEL" --thinking "$PI_THINKING" < ${PROMPT_PATH}`
// Finalize is split so the GitHub token is in scope for ONLY the push. `git add`,
// `commit`, and `diff` run repo-config-driven programs that `core.hooksPath` does
// NOT disable — gitattributes clean/smudge filters (on add), `core.fsmonitor`
// (on add/diff), and `diff.external`/textconv (on diff). The untrusted Pi loop can
// plant `.gitattributes` + `.git/config` to run code during these. Keeping the
// token out of PREPARE's env means a planted program has no credential to steal;
// hooks are disabled too as defense-in-depth. Commit runs unconditionally
// (`|| true` tolerates an empty commit); the push decision is gated on HEAD
// advancing past base, so commits the agent made itself are still pushed.
const PREPARE_SCRIPT = `set -e
cd ${REPO_DIR}
git -c core.hooksPath=/dev/null add -A
git -c core.hooksPath=/dev/null -c user.email="pi@sim.ai" -c user.name="Sim Pi Agent" commit -F ${COMMIT_MSG_PATH} >/dev/null 2>&1 || true
git diff --name-only "$BASE_SHA" HEAD | sed "s/^/__CHANGED__=/"
git diff "$BASE_SHA" HEAD > ${DIFF_PATH} 2>/dev/null || true
if git diff --quiet "$BASE_SHA" HEAD; then echo "__NO_CHANGES__=1"; else echo "__NEEDS_PUSH__=1"; fi`
// The only token-bearing command. The agent-planted `.git/config` is still active,
// so neutralize every config key that could run a program during push: hooks
// (pre-push), `credential.helper` (runs during auth), and `core.fsmonitor`.
// Filters/textconv don't run on push (no checkout/add/diff here).
const PUSH_SCRIPT = `cd ${REPO_DIR}
git -c core.hooksPath=/dev/null -c credential.helper= -c core.fsmonitor= push "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" "$BRANCH" >/dev/null 2>${PUSH_ERR_PATH} && echo "__PUSHED__=1"`
function raceAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return promise
if (signal.aborted) return Promise.reject(new Error('Pi run aborted'))
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(new Error('Pi run aborted'))
signal.addEventListener('abort', onAbort, { once: true })
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error) => {
signal.removeEventListener('abort', onAbort)
reject(error)
}
)
})
}
function extractMarkerValues(stdout: string, prefix: string): string[] {
return stdout
.split('\n')
.filter((line) => line.startsWith(prefix))
.map((line) => line.slice(prefix.length).trim())
.filter(Boolean)
}
/**
* Redacts the GitHub token from git output before it is surfaced in an error.
* Removes the literal token and any URL userinfo (`//user:token@`), so a failure
* message can quote git's real stderr without leaking the credential.
*/
function scrubGitSecrets(text: string, token: string): string {
const withoutToken = token ? text.split(token).join('***') : text
return withoutToken.replace(/\/\/[^/@\s]+@/g, '//***@')
}
function buildPrBody(task: string, finalText: string): string {
const summary = finalText.trim()
? truncate(finalText.trim(), PR_SUMMARY_MAX)
: 'Automated changes by the Pi Coding Agent.'
return `## Task\n\n${task}\n\n## Summary\n\n${summary}`
}
/** The commit message and PR title share one default, derived from the PR title or task. */
function defaultTitle(params: PiCloudRunParams): string {
return params.prTitle?.trim() || truncate(`Pi: ${params.task}`, COMMIT_TITLE_MAX)
}
async function openPullRequest(
params: PiCloudRunParams,
branch: string,
detectedBase: string | undefined,
totals: PiRunTotals
): Promise<string | undefined> {
const base = params.baseBranch?.trim() || detectedBase
if (!base) {
throw new Error(
`Branch ${branch} pushed, but the base branch could not be determined — set "Base Branch" on the block and re-run.`
)
}
const title = defaultTitle(params)
const body = params.prBody?.trim() || buildPrBody(params.task, totals.finalText)
const result = await executeTool('github_create_pr', {
owner: params.owner,
repo: params.repo,
title,
head: branch,
base,
body,
draft: params.draft,
apiKey: params.githubToken,
})
if (!result.success) {
throw new Error(
`Branch ${branch} pushed but PR creation failed: ${result.error ?? 'unknown error'}`
)
}
const output = result.output as { metadata?: { html_url?: string } } | undefined
return output?.metadata?.html_url
}
export const runCloudPi: PiBackendRun<PiCloudRunParams> = async (params, context) => {
if (!params.isBYOK) {
throw new Error(
'Cloud mode requires your own provider API key (BYOK). Set one in Settings > BYOK.'
)
}
const keyEnvVar = providerApiKeyEnvVar(params.providerId)
if (!keyEnvVar) {
throw new Error(
`Provider "${params.providerId}" is not supported in cloud mode. Use a key-based provider or run in local mode.`
)
}
const branch = params.branchName?.trim() || `pi/${generateShortId(8)}`
const commitMessage = defaultTitle(params)
const prompt = buildPiPrompt({
skills: params.skills,
initialMessages: params.initialMessages,
task: params.task,
guidance: CLOUD_GUIDANCE,
})
const totals = createPiTotals()
const thinking = mapThinkingLevel(params.thinkingLevel) ?? 'medium'
return withPiSandbox(async (runner) => {
try {
const clone = await raceAbort(
runner.run(CLONE_SCRIPT, {
envs: {
GITHUB_TOKEN: params.githubToken,
REPO_OWNER: params.owner,
REPO_NAME: params.repo,
BASE_BRANCH: params.baseBranch?.trim() ?? '',
BRANCH: branch,
},
timeoutMs: CLONE_TIMEOUT_MS,
}),
context.signal
)
if (clone.exitCode !== 0) {
throw new Error(
`git clone failed: ${scrubGitSecrets(clone.stderr || clone.stdout || 'unknown error', params.githubToken)}`
)
}
const baseSha = extractMarkerValues(clone.stdout, '__BASE_SHA__=')[0]
if (!baseSha) {
throw new Error('Clone did not report a base commit')
}
const detectedBase = extractMarkerValues(clone.stdout, '__DEFAULT_BRANCH__=')[0]
// Deliver the prompt as a file (read back on Pi's stdin), not a CLI
// arg/env, so its skill/memory content can't be parsed by the shell that
// launches the Pi loop.
await runner.writeFile(PROMPT_PATH, prompt)
let buffer = ''
const handleChunk = (chunk: string) => {
buffer += chunk
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const event = parseJsonLine(line)
if (!event) continue
applyPiEvent(totals, event)
context.onEvent(event)
}
}
const piRun = await raceAbort(
runner.run(PI_SCRIPT, {
envs: {
[keyEnvVar]: params.apiKey,
PI_PROVIDER: params.providerId,
PI_MODEL: params.model,
PI_THINKING: thinking,
},
timeoutMs: PI_TIMEOUT_MS,
onStdout: handleChunk,
}),
context.signal
)
const remaining = buffer.trim() ? parseJsonLine(buffer) : null
if (remaining) {
applyPiEvent(totals, remaining)
context.onEvent(remaining)
}
if (piRun.exitCode !== 0) {
throw new Error(
`Pi agent failed (exit ${piRun.exitCode}): ${piRun.stderr || piRun.stdout}`.trim()
)
}
if (totals.errorMessage) {
throw new Error(`Pi agent failed: ${totals.errorMessage}`)
}
// Same rationale as the prompt: keep the commit message off the command line.
await runner.writeFile(COMMIT_MSG_PATH, commitMessage)
// PREPARE stages, commits, and diffs WITHOUT the GitHub token in scope, so a
// repo-config-driven program the agent may have planted can't exfiltrate it.
const prepare = await raceAbort(
runner.run(PREPARE_SCRIPT, {
envs: { BASE_SHA: baseSha },
timeoutMs: FINALIZE_TIMEOUT_MS,
}),
context.signal
)
const changedFiles = extractMarkerValues(prepare.stdout, '__CHANGED__=')
const noChanges = prepare.stdout.includes('__NO_CHANGES__=1')
const needsPush = prepare.stdout.includes('__NEEDS_PUSH__=1')
// PREPARE (`set -e`) emits exactly one of the two markers on success. Neither
// means the finalize step itself failed (e.g. the repo dir vanished mid-run) —
// surface that rather than silently reporting success with no push.
if (!noChanges && !needsPush) {
const reason = (prepare.stderr || prepare.stdout || 'no status reported').trim()
throw new Error(`Pi finalize failed: ${truncate(reason, PUSH_ERROR_MAX)}`)
}
let diff: string | undefined
try {
const raw = await runner.readFile(DIFF_PATH)
diff =
raw.length > MAX_DIFF_BYTES ? `${raw.slice(0, MAX_DIFF_BYTES)}\n[diff truncated]` : raw
} catch {
diff = undefined
}
if (noChanges) {
logger.info('Pi cloud run produced no changes to push', {
owner: params.owner,
repo: params.repo,
})
return { totals, changedFiles, diff }
}
// PUSH is the only command that carries the token, hardened against any
// git-config program execution the agent may have planted.
const push = await raceAbort(
runner.run(PUSH_SCRIPT, {
envs: {
GITHUB_TOKEN: params.githubToken,
REPO_OWNER: params.owner,
REPO_NAME: params.repo,
BRANCH: branch,
},
timeoutMs: FINALIZE_TIMEOUT_MS,
}),
context.signal
)
if (!push.stdout.includes('__PUSHED__=1')) {
let reason = push.stderr?.trim()
try {
const pushErr = (await runner.readFile(PUSH_ERR_PATH)).trim()
if (pushErr) reason = pushErr
} catch {}
const scrubbed = scrubGitSecrets(reason || 'unknown error', params.githubToken)
throw new Error(`git push failed: ${truncate(scrubbed, PUSH_ERROR_MAX)}`)
}
const prUrl = await openPullRequest(params, branch, detectedBase, totals)
return { totals, changedFiles, diff, prUrl, branch }
} catch (error) {
// Aborts propagate as errors so a cancelled/timed-out run is not reported as
// success and no partial memory turn is persisted (local mode mirrors this).
if (context.signal?.aborted) {
logger.info('Pi cloud run aborted', { owner: params.owner, repo: params.repo })
}
throw error
}
})
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Reuses the Agent block's skills and memory subsystems for Pi runs. Skills
* resolve to full `{ name, content }` entries (so a backend can surface them as
* Pi skills), and multi-turn memory goes through the shared `memoryService`
* keyed by `memoryType`/`conversationId` — seeding the run and persisting the
* user task plus the agent's final message.
*/
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { memoryService } from '@/executor/handlers/agent/memory'
import { resolveSkillContentById } from '@/executor/handlers/agent/skills-resolver'
import type { AgentInputs, Message, SkillInput } from '@/executor/handlers/agent/types'
import type { PiMessage, PiSkill } from '@/executor/handlers/pi/backend'
import type { ExecutionContext } from '@/executor/types'
const logger = createLogger('PiContext')
/** Memory configuration — the Agent block's memory input fields, reused as-is. */
export type PiMemoryConfig = Pick<
AgentInputs,
'memoryType' | 'conversationId' | 'slidingWindowSize' | 'slidingWindowTokens' | 'model'
>
function isMemoryEnabled(config: PiMemoryConfig): boolean {
return !!config.memoryType && config.memoryType !== 'none'
}
/** Resolves selected skill inputs to full `{ name, content }` entries for Pi. */
export async function resolvePiSkills(
skillInputs: unknown,
workspaceId: string | undefined
): Promise<PiSkill[]> {
if (!Array.isArray(skillInputs) || !workspaceId) return []
const skills: PiSkill[] = []
for (const input of skillInputs as SkillInput[]) {
if (!input?.skillId) continue
try {
const resolved = await resolveSkillContentById(input.skillId, workspaceId)
if (resolved) skills.push({ name: resolved.name, content: resolved.content })
} catch (error) {
logger.warn('Failed to resolve skill for Pi', {
skillId: input.skillId,
error: getErrorMessage(error),
})
}
}
return skills
}
/** Loads prior conversation messages to seed the Pi run. */
export async function loadPiMemory(
ctx: ExecutionContext,
config: PiMemoryConfig
): Promise<PiMessage[]> {
if (!isMemoryEnabled(config)) return []
try {
const messages = await memoryService.fetchMemoryMessages(ctx, config)
return messages.map((message: Message) => ({ role: message.role, content: message.content }))
} catch (error) {
logger.warn('Failed to load Pi memory', { error: getErrorMessage(error) })
return []
}
}
/**
* Builds the prompt: optional operating `guidance` (mode-specific constraints),
* then skills, prior memory, and the task.
*/
export function buildPiPrompt(input: {
skills: PiSkill[]
initialMessages: PiMessage[]
task: string
guidance?: string
}): string {
const parts: string[] = []
if (input.guidance) {
parts.push(`# Operating instructions\n${input.guidance}`)
}
if (input.skills.length > 0) {
parts.push('# Available skills')
for (const skill of input.skills) {
parts.push(`## ${skill.name}\n${skill.content}`)
}
}
if (input.initialMessages.length > 0) {
parts.push('# Prior conversation')
for (const message of input.initialMessages) {
parts.push(`${message.role}: ${message.content}`)
}
}
parts.push('# Task')
parts.push(input.task)
return parts.join('\n\n')
}
/** Persists the user task and the agent's final message to memory. */
export async function appendPiMemory(
ctx: ExecutionContext,
config: PiMemoryConfig,
task: string,
finalText: string
): Promise<void> {
if (!isMemoryEnabled(config)) return
try {
await memoryService.appendToMemory(ctx, config, { role: 'user', content: task })
if (finalText) {
await memoryService.appendToMemory(ctx, config, { role: 'assistant', content: finalText })
}
} catch (error) {
logger.warn('Failed to append Pi memory', { error: getErrorMessage(error) })
}
}
@@ -0,0 +1,116 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
applyPiEvent,
createPiTotals,
normalizePiEvent,
parseJsonLine,
streamTextForEvent,
} from '@/executor/handlers/pi/events'
describe('normalizePiEvent', () => {
it('maps a text_delta message_update to a text event', () => {
expect(
normalizePiEvent({
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'hello' },
})
).toEqual({ type: 'text', text: 'hello' })
})
it('maps a thinking_delta message_update to a thinking event', () => {
expect(
normalizePiEvent({
type: 'message_update',
assistantMessageEvent: { type: 'thinking_delta', delta: 'hmm' },
})
).toEqual({ type: 'thinking', text: 'hmm' })
})
it('maps tool execution start and end', () => {
expect(normalizePiEvent({ type: 'tool_execution_start', toolName: 'bash' })).toEqual({
type: 'tool_start',
toolName: 'bash',
})
expect(
normalizePiEvent({ type: 'tool_execution_end', toolName: 'bash', isError: true })
).toEqual({
type: 'tool_end',
toolName: 'bash',
isError: true,
})
})
it('extracts usage from turn_end via message.usage and direct usage', () => {
expect(
normalizePiEvent({ type: 'turn_end', message: { usage: { input: 5, output: 7 } } })
).toEqual({ type: 'usage', inputTokens: 5, outputTokens: 7 })
expect(
normalizePiEvent({ type: 'turn_end', usage: { prompt_tokens: 3, completion_tokens: 2 } })
).toEqual({ type: 'usage', inputTokens: 3, outputTokens: 2 })
})
it('maps agent_end to final and error to error', () => {
expect(normalizePiEvent({ type: 'agent_end' })).toEqual({ type: 'final' })
expect(normalizePiEvent({ type: 'error', error: 'boom' })).toEqual({
type: 'error',
message: 'boom',
})
})
it('returns other for unknown types and null for non-objects', () => {
expect(normalizePiEvent({ type: 'queue_update' })).toEqual({ type: 'other' })
expect(normalizePiEvent('nope')).toBeNull()
expect(normalizePiEvent(null)).toBeNull()
})
})
describe('parseJsonLine', () => {
it('parses a valid json line', () => {
expect(parseJsonLine('{"type":"agent_end"}')).toEqual({ type: 'final' })
})
it('returns null for blank or malformed lines', () => {
expect(parseJsonLine(' ')).toBeNull()
expect(parseJsonLine('{not json')).toBeNull()
})
})
describe('applyPiEvent', () => {
it('accumulates text, sums usage, records tool calls and errors', () => {
const totals = createPiTotals()
applyPiEvent(totals, { type: 'text', text: 'a' })
applyPiEvent(totals, { type: 'text', text: 'b' })
applyPiEvent(totals, { type: 'usage', inputTokens: 3, outputTokens: 4 })
applyPiEvent(totals, { type: 'usage', inputTokens: 1, outputTokens: 1 })
applyPiEvent(totals, { type: 'tool_end', toolName: 'read', isError: false })
applyPiEvent(totals, { type: 'error', message: 'boom' })
expect(totals.finalText).toBe('ab')
expect(totals.inputTokens).toBe(4)
expect(totals.outputTokens).toBe(5)
expect(totals.toolCalls).toEqual([{ name: 'read', isError: false }])
expect(totals.errorMessage).toBe('boom')
})
it('uses final text only when no streamed text was seen', () => {
const empty = createPiTotals()
applyPiEvent(empty, { type: 'final', text: 'fallback' })
expect(empty.finalText).toBe('fallback')
const streamed = createPiTotals()
applyPiEvent(streamed, { type: 'text', text: 'streamed' })
applyPiEvent(streamed, { type: 'final', text: 'fallback' })
expect(streamed.finalText).toBe('streamed')
})
})
describe('streamTextForEvent', () => {
it('returns text for text events and null otherwise', () => {
expect(streamTextForEvent({ type: 'text', text: 'x' })).toBe('x')
expect(streamTextForEvent({ type: 'thinking', text: 'x' })).toBeNull()
expect(streamTextForEvent({ type: 'final' })).toBeNull()
})
})
+160
View File
@@ -0,0 +1,160 @@
/**
* Normalization layer for the Pi agent event stream. Both backends produce the
* same logical events — the local backend via the SDK `session.subscribe`
* callback, the cloud backend via `pi --mode json` stdout lines — so this module
* maps either source into a single {@link PiEvent} union and accumulates the
* run totals (final text, token usage, tool calls) the handler reports.
*/
/** A single normalized event emitted during a Pi run. */
export type PiEvent =
| { type: 'text'; text: string }
| { type: 'thinking'; text: string }
| { type: 'tool_start'; toolName: string }
| { type: 'tool_end'; toolName: string; isError: boolean }
| { type: 'usage'; inputTokens: number; outputTokens: number }
| { type: 'final'; text?: string }
| { type: 'error'; message: string }
| { type: 'other' }
/** A tool invocation observed during the run. */
export interface PiToolCallRecord {
name: string
isError?: boolean
}
/** Running totals accumulated across a Pi run. */
export interface PiRunTotals {
finalText: string
inputTokens: number
outputTokens: number
toolCalls: PiToolCallRecord[]
errorMessage?: string
}
/** Creates an empty totals accumulator. */
export function createPiTotals(): PiRunTotals {
return { finalText: '', inputTokens: 0, outputTokens: 0, toolCalls: [] }
}
/**
* Folds a normalized event into the totals. Text deltas accumulate into
* `finalText`; usage events sum (Pi reports per-turn usage on `turn_end`).
*/
export function applyPiEvent(totals: PiRunTotals, event: PiEvent): void {
switch (event.type) {
case 'text':
totals.finalText += event.text
break
case 'final':
if (event.text && totals.finalText.length === 0) {
totals.finalText = event.text
}
break
case 'usage':
totals.inputTokens += event.inputTokens
totals.outputTokens += event.outputTokens
break
case 'tool_end':
totals.toolCalls.push({ name: event.toolName, isError: event.isError })
break
case 'error':
totals.errorMessage = event.message
break
default:
break
}
}
/** Returns the text to enqueue onto the content stream for an event, if any. */
export function streamTextForEvent(event: PiEvent): string | null {
return event.type === 'text' ? event.text : null
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
}
function asString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function asNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
/**
* Extracts token usage from an event, tolerating the field names Pi and common
* provider payloads use (`input`/`output`, `inputTokens`/`outputTokens`,
* `prompt_tokens`/`completion_tokens`), checked on the event and on a nested
* `message`/`usage` object.
*/
function extractUsage(
ev: Record<string, unknown>
): { inputTokens: number; outputTokens: number } | null {
const candidates: Array<Record<string, unknown>> = []
const direct = asRecord(ev.usage)
if (direct) candidates.push(direct)
const message = asRecord(ev.message)
if (message) {
const messageUsage = asRecord(message.usage)
if (messageUsage) candidates.push(messageUsage)
}
for (const usage of candidates) {
const input =
asNumber(usage.input) || asNumber(usage.inputTokens) || asNumber(usage.prompt_tokens)
const output =
asNumber(usage.output) || asNumber(usage.outputTokens) || asNumber(usage.completion_tokens)
if (input > 0 || output > 0) {
return { inputTokens: input, outputTokens: output }
}
}
return null
}
/** Normalizes a raw Pi/SDK event object into a {@link PiEvent}. */
export function normalizePiEvent(raw: unknown): PiEvent | null {
const ev = asRecord(raw)
if (!ev) return null
switch (asString(ev.type)) {
case 'message_update': {
const assistantEvent = asRecord(ev.assistantMessageEvent)
const deltaType = assistantEvent ? asString(assistantEvent.type) : ''
const delta = assistantEvent ? asString(assistantEvent.delta) : ''
if (deltaType === 'text_delta') return { type: 'text', text: delta }
if (deltaType === 'thinking_delta') return { type: 'thinking', text: delta }
return { type: 'other' }
}
case 'tool_execution_start':
return { type: 'tool_start', toolName: asString(ev.toolName) }
case 'tool_execution_end':
return { type: 'tool_end', toolName: asString(ev.toolName), isError: ev.isError === true }
case 'turn_end': {
const usage = extractUsage(ev)
return usage ? { type: 'usage', ...usage } : { type: 'other' }
}
case 'agent_end':
return { type: 'final' }
case 'error':
return {
type: 'error',
message: asString(ev.error) || asString(ev.message) || 'Pi run failed',
}
default:
return { type: 'other' }
}
}
/** Parses one `pi --mode json` stdout line into a {@link PiEvent}. */
export function parseJsonLine(line: string): PiEvent | null {
const trimmed = line.trim()
if (!trimmed) return null
try {
return normalizePiEvent(JSON.parse(trimmed))
} catch {
return null
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetApiKeyWithBYOK,
mockGetBYOKKey,
mockGetProviderFromModel,
mockCalculateCost,
mockShouldBill,
mockResolveVertex,
} = vi.hoisted(() => ({
mockGetApiKeyWithBYOK: vi.fn(),
mockGetBYOKKey: vi.fn(),
mockGetProviderFromModel: vi.fn(),
mockCalculateCost: vi.fn(),
mockShouldBill: vi.fn(),
mockResolveVertex: vi.fn(),
}))
vi.mock('@/lib/api-key/byok', () => ({
getApiKeyWithBYOK: mockGetApiKeyWithBYOK,
getBYOKKey: mockGetBYOKKey,
}))
vi.mock('@/providers/utils', () => ({
getProviderFromModel: mockGetProviderFromModel,
calculateCost: mockCalculateCost,
shouldBillModelUsage: mockShouldBill,
}))
vi.mock('@/executor/utils/vertex-credential', () => ({
resolveVertexCredential: mockResolveVertex,
}))
vi.mock('@/lib/core/config/env-flags', () => ({ getCostMultiplier: () => 2 }))
import { computePiCost, providerApiKeyEnvVar, resolvePiModelKey } from '@/executor/handlers/pi/keys'
describe('providerApiKeyEnvVar', () => {
it('maps key-based providers and rejects unsupported ones', () => {
expect(providerApiKeyEnvVar('anthropic')).toBe('ANTHROPIC_API_KEY')
expect(providerApiKeyEnvVar('openai')).toBe('OPENAI_API_KEY')
expect(providerApiKeyEnvVar('vertex')).toBeNull()
expect(providerApiKeyEnvVar('bedrock')).toBeNull()
expect(providerApiKeyEnvVar('something-else')).toBeNull()
})
})
describe('computePiCost', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns zero cost for BYOK keys without billing', () => {
expect(computePiCost('claude', 100, 200, true)).toEqual({ input: 0, output: 0, total: 0 })
expect(mockCalculateCost).not.toHaveBeenCalled()
})
it('returns zero cost for non-billable models', () => {
mockShouldBill.mockReturnValue(false)
expect(computePiCost('local-model', 100, 200, false)).toEqual({ input: 0, output: 0, total: 0 })
expect(mockCalculateCost).not.toHaveBeenCalled()
})
it('computes billed cost with the cost multiplier', () => {
mockShouldBill.mockReturnValue(true)
mockCalculateCost.mockReturnValue({ input: 1, output: 2, total: 3 })
expect(computePiCost('claude', 10, 20, false)).toEqual({ input: 1, output: 2, total: 3 })
expect(mockCalculateCost).toHaveBeenCalledWith('claude', 10, 20, false, 2, 2)
})
})
describe('resolvePiModelKey', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('resolves Vertex credentials when the provider is vertex', async () => {
mockGetProviderFromModel.mockReturnValue('vertex')
mockResolveVertex.mockResolvedValue('vertex-token')
const result = await resolvePiModelKey({
model: 'gemini-pro',
mode: 'local',
userId: 'user-1',
vertexCredential: 'cred-1',
})
expect(result).toEqual({ providerId: 'vertex', apiKey: 'vertex-token', isBYOK: true })
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
})
it('local mode resolves keys through getApiKeyWithBYOK (hosted keys allowed)', async () => {
mockGetProviderFromModel.mockReturnValue('anthropic')
mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-test', isBYOK: false })
const result = await resolvePiModelKey({
model: 'claude',
mode: 'local',
workspaceId: 'ws-1',
apiKey: 'sk-test',
})
expect(result).toEqual({ providerId: 'anthropic', apiKey: 'sk-test', isBYOK: false })
expect(mockGetApiKeyWithBYOK).toHaveBeenCalledWith('anthropic', 'claude', 'ws-1', 'sk-test')
})
it('cloud mode uses the block API Key field directly as a BYOK key', async () => {
mockGetProviderFromModel.mockReturnValue('anthropic')
const result = await resolvePiModelKey({
model: 'claude',
mode: 'cloud',
workspaceId: 'ws-1',
apiKey: 'sk-user',
})
expect(result).toEqual({ providerId: 'anthropic', apiKey: 'sk-user', isBYOK: true })
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
expect(mockGetBYOKKey).not.toHaveBeenCalled()
})
it('cloud mode falls back to a stored workspace key when the field is empty', async () => {
mockGetProviderFromModel.mockReturnValue('openai')
mockGetBYOKKey.mockResolvedValue({ apiKey: 'sk-workspace', isBYOK: true })
const result = await resolvePiModelKey({
model: 'gpt-5',
mode: 'cloud',
workspaceId: 'ws-1',
})
expect(result).toEqual({ providerId: 'openai', apiKey: 'sk-workspace', isBYOK: true })
expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'openai')
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
})
it('cloud mode falls back to a stored workspace key for xAI', async () => {
mockGetProviderFromModel.mockReturnValue('xai')
mockGetBYOKKey.mockResolvedValue({ apiKey: 'xai-workspace-key', isBYOK: true })
const result = await resolvePiModelKey({
model: 'grok-4.5',
mode: 'cloud',
workspaceId: 'ws-1',
})
expect(result).toEqual({ providerId: 'xai', apiKey: 'xai-workspace-key', isBYOK: true })
expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'xai')
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
})
it('cloud mode rejects when no user key is available (never a hosted key)', async () => {
mockGetProviderFromModel.mockReturnValue('anthropic')
mockGetBYOKKey.mockResolvedValue(null)
await expect(
resolvePiModelKey({ model: 'claude', mode: 'cloud', workspaceId: 'ws-1' })
).rejects.toThrow(/your own provider API key/)
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
})
})
+133
View File
@@ -0,0 +1,133 @@
/**
* Model, provider-key, and cost resolution shared by both Pi backends. Local
* mode mirrors the Agent block — keys resolve through `getApiKeyWithBYOK`, so a
* Sim-hosted key may be used and billed. Cloud mode requires the user's own key
* (the block's API Key field, or a stored workspace BYOK key) and never a hosted
* key, since the key is handed to an untrusted sandbox. Vertex resolves through
* `resolveVertexCredential`; cost uses the billing multiplier and is zeroed for
* BYOK / non-billable models.
*/
import type { CreateAgentSessionOptions } from '@earendil-works/pi-coding-agent'
import { getApiKeyWithBYOK, getBYOKKey } from '@/lib/api-key/byok'
import { getCostMultiplier } from '@/lib/core/config/env-flags'
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { isPiSupportedProvider, type PiSupportedProvider } from '@/providers/pi-providers'
import { calculateCost, getProviderFromModel, shouldBillModelUsage } from '@/providers/utils'
import type { BYOKProviderId } from '@/tools/types'
/** Resolved provider, key, and BYOK flag for a Pi run. */
export interface PiKeyResolution {
providerId: string
apiKey: string
isBYOK: boolean
}
interface ResolvePiModelKeyParams {
model: string
mode: 'cloud' | 'local'
workspaceId?: string
userId?: string
apiKey?: string
vertexCredential?: string
}
/** Providers whose key Sim can store as a workspace BYOK key (read back for cloud). */
const WORKSPACE_BYOK_PROVIDERS = new Set<string>([
'anthropic',
'openai',
'google',
'mistral',
'xai',
])
/** Resolves the provider and a usable API key for the selected model. */
export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promise<PiKeyResolution> {
const providerId = getProviderFromModel(params.model)
if (providerId === 'vertex' && params.vertexCredential) {
const apiKey = await resolveVertexCredential(
params.vertexCredential,
params.userId,
'vertex-pi'
)
return { providerId, apiKey, isBYOK: true }
}
// Cloud hands the model key to an untrusted sandbox, so it must be the user's
// own key — never a Sim-hosted/rotating key. Prefer the block's API Key field,
// then a stored workspace BYOK key; refuse to fall back to a hosted key.
if (params.mode === 'cloud') {
if (params.apiKey) {
return { providerId, apiKey: params.apiKey, isBYOK: true }
}
if (params.workspaceId && WORKSPACE_BYOK_PROVIDERS.has(providerId)) {
const byok = await getBYOKKey(params.workspaceId, providerId as BYOKProviderId)
if (byok) {
return { providerId, apiKey: byok.apiKey, isBYOK: true }
}
}
throw new Error(
WORKSPACE_BYOK_PROVIDERS.has(providerId)
? 'Cloud mode requires your own provider API key (BYOK). Enter it in the API Key field, or store one in Settings > BYOK.'
: 'Cloud mode requires your own provider API key (BYOK). Enter it in the API Key field.'
)
}
const { apiKey, isBYOK } = await getApiKeyWithBYOK(
providerId,
params.model,
params.workspaceId,
params.apiKey
)
return { providerId, apiKey, isBYOK }
}
/** Run cost, zeroed for BYOK keys and models Sim does not bill. */
export function computePiCost(
model: string,
inputTokens: number,
outputTokens: number,
isBYOK: boolean
) {
if (isBYOK || !shouldBillModelUsage(model)) {
return { input: 0, output: 0, total: 0 }
}
const multiplier = getCostMultiplier()
return calculateCost(model, inputTokens, outputTokens, false, multiplier, multiplier)
}
/**
* Env var the Pi CLI reads each provider's key from in the cloud sandbox. Keyed
* by {@link PiSupportedProvider}, so this map and the shared support set (which
* also drives the block's model dropdown) cannot drift — adding a provider to the
* set forces adding its env var here.
*/
const PROVIDER_API_KEY_ENV_VARS: Record<PiSupportedProvider, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
google: 'GEMINI_API_KEY',
xai: 'XAI_API_KEY',
deepseek: 'DEEPSEEK_API_KEY',
mistral: 'MISTRAL_API_KEY',
groq: 'GROQ_API_KEY',
cerebras: 'CEREBRAS_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
}
/**
* Env var name a provider's API key is exposed under for the Pi CLI in the cloud
* sandbox, or `null` when Pi cannot run the provider via a single key. The cloud
* backend rejects `null` providers with a clear error rather than guessing.
*/
export function providerApiKeyEnvVar(providerId: string): string | null {
return isPiSupportedProvider(providerId) ? PROVIDER_API_KEY_ENV_VARS[providerId] : null
}
/** Maps a Sim thinking level to Pi's `ThinkingLevel` (shared by both backends). */
export function mapThinkingLevel(level?: string): CreateAgentSessionOptions['thinkingLevel'] {
if (!level || level === 'none') return 'off'
if (level === 'max') return 'xhigh'
if (level === 'low' || level === 'medium' || level === 'high') return level
return undefined
}
@@ -0,0 +1,204 @@
/**
* Local-mode backend: runs the Pi harness embedded in Sim with its built-in
* tools disabled and replaced by SSH-backed file/bash tools (plus any adapted
* Sim tools), all over a single reused SSH connection. The provider key stays in
* Sim's process (injected via `authStorage.setRuntimeApiKey`); only file/bash
* operations cross to the target machine.
*
* The Pi SDK is imported dynamically and externalized from the bundle, mirroring
* how `@e2b/code-interpreter` is loaded, so the package is resolved at runtime.
*/
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ModelRegistry, ToolDefinition } from '@earendil-works/pi-coding-agent'
import { createLogger } from '@sim/logger'
import type { PiBackendRun, PiLocalRunParams, PiToolSpec } from '@/executor/handlers/pi/backend'
import { buildPiPrompt } from '@/executor/handlers/pi/context'
import { applyPiEvent, createPiTotals, normalizePiEvent } from '@/executor/handlers/pi/events'
import { mapThinkingLevel } from '@/executor/handlers/pi/keys'
import {
buildSshToolSpecs,
captureRepoChanges,
openSshSession,
} from '@/executor/handlers/pi/ssh-tools'
const logger = createLogger('PiLocalBackend')
const MAX_DIFF_BYTES = 200_000
// Local mode edits in place and reports the working-tree diff. The agent must not
// commit (a commit would hide the changes from `git diff HEAD`) or push/open a PR.
const LOCAL_GUIDANCE =
'Use the provided read/write/edit/bash tools to make the file changes needed to complete the task; they ' +
'operate on the target repository. Do not commit, push, or open a pull request — leave your changes in the ' +
'working tree; Sim reports them after you finish.'
/** The Pi SDK module, loaded dynamically so it stays externalized from the bundle. */
type PiSdk = typeof import('@earendil-works/pi-coding-agent')
let sdkPromise: Promise<PiSdk> | undefined
function loadPiSdk(): Promise<PiSdk> {
if (!sdkPromise) {
// A static specifier (not a variable) is required so Next's dependency tracer
// copies the package + its transitive deps into the standalone Docker output,
// the same way `@e2b/code-interpreter` is handled. Clear the cache on failure
// so a transient import error doesn't permanently break later local runs.
sdkPromise = import('@earendil-works/pi-coding-agent').catch((error) => {
sdkPromise = undefined
throw error
})
}
return sdkPromise
}
function toPiTool(sdk: PiSdk, spec: PiToolSpec): ToolDefinition {
return sdk.defineTool({
name: spec.name,
label: spec.name,
description: spec.description,
// double-cast-allowed: Pi accepts a plain JSON Schema at runtime (pi-ai validation.js coerceWithJsonSchema); the static type requires a TypeBox TSchema
parameters: spec.parameters as unknown as ToolDefinition['parameters'],
execute: async (_toolCallId, params) => {
const result = await spec.execute(params as Record<string, unknown>)
return {
content: [{ type: 'text', text: result.text }],
details: { isError: result.isError },
}
},
})
}
/**
* Builds a model definition for a provider Pi supports but whose bundled catalog
* doesn't list this exact id (e.g. a newer model Pi wires to a different
* provider). Mirrors the cloud CLI's passthrough: clone one of the provider's
* models as a template, swap in the requested id, and force reasoning when a
* thinking level is requested. Returns undefined only when the provider has no
* models at all, so even passthrough can't route it.
*/
function buildPiFallbackModel(
modelRegistry: ModelRegistry,
provider: string,
modelId: string,
thinkingLevel: ReturnType<typeof mapThinkingLevel>
) {
const providerModels = modelRegistry.getAll().filter((m) => m.provider === provider)
if (providerModels.length === 0) return undefined
const fallback = { ...providerModels[0], id: modelId, name: modelId }
return thinkingLevel && thinkingLevel !== 'off' ? { ...fallback, reasoning: true } : fallback
}
export const runLocalPi: PiBackendRun<PiLocalRunParams> = async (params, context) => {
// Isolate Pi resource discovery: an empty cwd/agentDir keeps DefaultResourceLoader
// from loading the Sim server's own .agents/skills, AGENTS.md, extensions, or settings.
const isolatedDir = await mkdtemp(join(tmpdir(), 'sim-pi-'))
// Clean up the scratch dir if the SSH connection fails — the try/finally below
// is only entered once the session is open, so an early handshake failure would
// otherwise orphan the directory.
const session = await openSshSession(params.ssh).catch(async (error) => {
await rm(isolatedDir, { recursive: true, force: true }).catch(() => {})
throw error
})
try {
const sdk = await loadPiSdk()
const authStorage = sdk.AuthStorage.create()
authStorage.setRuntimeApiKey(params.providerId, params.apiKey)
const modelRegistry = sdk.ModelRegistry.create(authStorage)
const thinkingLevel = mapThinkingLevel(params.thinkingLevel)
// Parity with cloud: when the model isn't in Pi's bundled catalog under the
// resolved provider, pass it through on that provider instead of failing.
const model =
modelRegistry.find(params.providerId, params.model) ??
buildPiFallbackModel(modelRegistry, params.providerId, params.model, thinkingLevel)
if (!model) {
throw new Error(
`Pi has no models for provider "${params.providerId}" (cannot run ${params.model})`
)
}
const specs = [...buildSshToolSpecs(session, params.repoPath), ...params.tools]
const customTools = specs.map((spec) => toPiTool(sdk, spec))
const { session: agentSession } = await sdk.createAgentSession({
cwd: isolatedDir,
agentDir: isolatedDir,
model,
thinkingLevel,
noTools: 'builtin',
customTools,
authStorage,
modelRegistry,
sessionManager: sdk.SessionManager.inMemory(isolatedDir),
})
const totals = createPiTotals()
const unsubscribe = agentSession.subscribe((raw) => {
const event = normalizePiEvent(raw)
if (!event) return
applyPiEvent(totals, event)
context.onEvent(event)
})
const onAbort = () => {
void agentSession.abort()
}
if (context.signal?.aborted) {
onAbort()
} else {
context.signal?.addEventListener('abort', onAbort, { once: true })
}
let runErrorMessage: string | undefined
try {
await agentSession.prompt(
buildPiPrompt({
skills: params.skills,
initialMessages: params.initialMessages,
task: params.task,
guidance: LOCAL_GUIDANCE,
})
)
// Pi has no error event; a failed run surfaces on the agent state. Capture
// it before `dispose()` so the failure can't be missed by a later read.
runErrorMessage = agentSession.agent.state.errorMessage
} finally {
unsubscribe()
context.signal?.removeEventListener('abort', onAbort)
try {
agentSession.dispose()
} catch (error) {
logger.warn('Failed to dispose Pi session', { error })
}
}
// Aborts propagate as errors so a cancelled/timed-out run is not reported as
// success and no partial memory turn is persisted (cloud mode mirrors this).
// Pi resolves `prompt()` on abort rather than rejecting, so check explicitly.
if (context.signal?.aborted) {
throw new Error('Pi run aborted')
}
if (runErrorMessage) {
totals.errorMessage = runErrorMessage
return { totals }
}
// Local mode edits in place (no PR), so report what changed via the repo's
// working-tree diff over the same SSH session.
const { changedFiles, diff } = await captureRepoChanges(
session,
params.repoPath,
MAX_DIFF_BYTES
)
return { totals, changedFiles, diff }
} finally {
session.close()
await rm(isolatedDir, { recursive: true, force: true }).catch(() => {})
}
}
@@ -0,0 +1,153 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockRunLocal, mockRunCloud, mockResolveKey } = vi.hoisted(() => ({
mockRunLocal: vi.fn(),
mockRunCloud: vi.fn(),
mockResolveKey: vi.fn(),
}))
vi.mock('@/executor/handlers/pi/keys', () => ({
resolvePiModelKey: mockResolveKey,
computePiCost: () => ({ input: 0, output: 0, total: 0 }),
}))
vi.mock('@/executor/handlers/pi/context', () => ({
resolvePiSkills: vi.fn().mockResolvedValue([]),
loadPiMemory: vi.fn().mockResolvedValue([]),
appendPiMemory: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/executor/handlers/pi/sim-tools', () => ({
buildSimToolSpecs: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/executor/handlers/pi/local-backend', () => ({ runLocalPi: mockRunLocal }))
vi.mock('@/executor/handlers/pi/cloud-backend', () => ({ runCloudPi: mockRunCloud }))
vi.mock('@/blocks/utils', () => ({
parseOptionalNumberInput: (value: unknown) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
},
}))
import { PiBlockHandler } from '@/executor/handlers/pi/pi-handler'
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const block = { id: 'blk', metadata: { id: 'pi' } } as unknown as SerializedBlock
function ctx(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
workflowId: 'wf',
workspaceId: 'ws',
userId: 'user',
...overrides,
} as ExecutionContext
}
function localInputs(extra: Record<string, unknown> = {}) {
return {
mode: 'local',
task: 'do the thing',
model: 'claude',
host: 'box.example.com',
username: 'deploy',
authMethod: 'password',
password: 'pw',
repoPath: '/srv/repo',
...extra,
}
}
describe('PiBlockHandler', () => {
const handler = new PiBlockHandler()
beforeEach(() => {
vi.clearAllMocks()
mockResolveKey.mockResolvedValue({ providerId: 'anthropic', apiKey: 'k', isBYOK: true })
mockRunLocal.mockResolvedValue({
totals: { finalText: 'hi', inputTokens: 1, outputTokens: 2, toolCalls: [] },
})
mockRunCloud.mockResolvedValue({
totals: { finalText: 'done', inputTokens: 0, outputTokens: 0, toolCalls: [] },
prUrl: 'https://github.com/o/r/pull/1',
branch: 'pi/abc',
changedFiles: ['a.ts'],
diff: 'diff',
})
})
it('canHandle matches the pi block type', () => {
expect(handler.canHandle(block)).toBe(true)
expect(
handler.canHandle({ id: 'x', metadata: { id: 'agent' } } as unknown as SerializedBlock)
).toBe(false)
})
it('throws when the task is missing', async () => {
await expect(handler.execute(ctx(), block, { mode: 'local', task: '' })).rejects.toThrow(/Task/)
})
it('routes local mode to the local backend with SSH params', async () => {
const output = await handler.execute(ctx(), block, localInputs())
expect(mockRunLocal).toHaveBeenCalledTimes(1)
expect(mockRunCloud).not.toHaveBeenCalled()
const params = mockRunLocal.mock.calls[0][0]
expect(params.mode).toBe('local')
expect(params.ssh.host).toBe('box.example.com')
expect(params.repoPath).toBe('/srv/repo')
expect((output as Record<string, unknown>).content).toBe('hi')
})
it('routes cloud mode to the cloud backend and surfaces PR output', async () => {
const output = (await handler.execute(ctx(), block, {
mode: 'cloud',
task: 'do it',
model: 'claude',
owner: 'o',
repo: 'r',
githubToken: 'ghp',
})) as Record<string, unknown>
expect(mockRunCloud).toHaveBeenCalledTimes(1)
expect(output.prUrl).toBe('https://github.com/o/r/pull/1')
expect(output.branch).toBe('pi/abc')
})
it('requires SSH fields in local mode', async () => {
await expect(
handler.execute(ctx(), block, { mode: 'local', task: 'x', model: 'claude', host: 'h' })
).rejects.toThrow(/Local mode requires/)
})
it('requires repo + token in cloud mode', async () => {
await expect(
handler.execute(ctx(), block, { mode: 'cloud', task: 'x', model: 'claude', owner: 'o' })
).rejects.toThrow(/Cloud mode requires/)
})
it('streams text when the block is selected for streaming output', async () => {
mockRunLocal.mockImplementation(async (_params, runCtx) => {
runCtx.onEvent({ type: 'text', text: 'streamed' })
return { totals: { finalText: 'streamed', inputTokens: 0, outputTokens: 0, toolCalls: [] } }
})
const result = (await handler.execute(
ctx({ stream: true, selectedOutputs: ['blk'] }),
block,
localInputs()
)) as StreamingExecution
expect('stream' in result).toBe(true)
const reader = result.stream.getReader()
const decoder = new TextDecoder()
let text = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
text += decoder.decode(value)
}
expect(text).toContain('streamed')
expect(result.execution.output.content).toBe('streamed')
})
})
+262
View File
@@ -0,0 +1,262 @@
/**
* Executor handler for the Pi Coding Agent block. Resolves the model key,
* skills, and memory, selects a backend by `mode`, and runs it — streaming the
* agent's text to the client when the block is selected for streaming output,
* otherwise returning a plain block output. The handler depends only on the
* {@link PiBackendRun} seam and never reaches into backend internals.
*/
import { createLogger } from '@sim/logger'
import type { BlockOutput } from '@/blocks/types'
import { parseOptionalNumberInput } from '@/blocks/utils'
import { BlockType } from '@/executor/constants'
import type {
PiBackendRun,
PiCloudRunParams,
PiLocalRunParams,
PiRunParams,
PiRunResult,
} from '@/executor/handlers/pi/backend'
import { runCloudPi } from '@/executor/handlers/pi/cloud-backend'
import {
appendPiMemory,
loadPiMemory,
type PiMemoryConfig,
resolvePiSkills,
} from '@/executor/handlers/pi/context'
import { streamTextForEvent } from '@/executor/handlers/pi/events'
import { computePiCost, resolvePiModelKey } from '@/executor/handlers/pi/keys'
import { runLocalPi } from '@/executor/handlers/pi/local-backend'
import { buildSimToolSpecs } from '@/executor/handlers/pi/sim-tools'
import type {
BlockHandler,
ExecutionContext,
NormalizedBlockOutput,
StreamingExecution,
} from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('PiBlockHandler')
const DEFAULT_MODEL = 'claude-sonnet-5'
function asOptString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed ? trimmed : undefined
}
function asRawString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined
}
export class PiBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.PI
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput | StreamingExecution> {
const task = asOptString(inputs.task)
if (!task) throw new Error('Task is required')
const model = asOptString(inputs.model) ?? DEFAULT_MODEL
// Validate the mode up front so an invalid value reports a mode error rather
// than a misattributed credential error from key resolution below.
if (inputs.mode !== 'cloud' && inputs.mode !== 'local') {
throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`)
}
const mode: 'cloud' | 'local' = inputs.mode
const { providerId, apiKey, isBYOK } = await resolvePiModelKey({
model,
mode,
workspaceId: ctx.workspaceId,
userId: ctx.userId,
apiKey: asRawString(inputs.apiKey),
vertexCredential: asOptString(inputs.vertexCredential),
})
const skills = await resolvePiSkills(inputs.skills, ctx.workspaceId)
const memoryConfig: PiMemoryConfig = {
memoryType: asOptString(inputs.memoryType) as PiMemoryConfig['memoryType'],
conversationId: asOptString(inputs.conversationId),
slidingWindowSize: asOptString(inputs.slidingWindowSize),
slidingWindowTokens: asOptString(inputs.slidingWindowTokens),
model,
}
const initialMessages = await loadPiMemory(ctx, memoryConfig)
const base = {
model,
providerId,
apiKey,
isBYOK,
task,
thinkingLevel: asOptString(inputs.thinkingLevel),
skills,
initialMessages,
}
if (mode === 'local') {
const host = asOptString(inputs.host)
const username = asOptString(inputs.username)
const repoPath = asOptString(inputs.repoPath)
if (!host || !username || !repoPath) {
throw new Error('Local mode requires host, username, and repository path')
}
const usePrivateKey = inputs.authMethod === 'privateKey'
const port = parseOptionalNumberInput(inputs.port, 'port', { integer: true, min: 1 }) ?? 22
const tools = await buildSimToolSpecs(ctx, inputs.tools)
const params: PiLocalRunParams = {
...base,
mode: 'local',
repoPath,
tools,
ssh: {
host,
port,
username,
password: usePrivateKey ? undefined : asRawString(inputs.password),
privateKey: usePrivateKey ? asRawString(inputs.privateKey) : undefined,
passphrase: usePrivateKey ? asRawString(inputs.passphrase) : undefined,
},
}
return this.runPi(ctx, block, runLocalPi, params, memoryConfig)
}
if (mode === 'cloud') {
const owner = asOptString(inputs.owner)
const repo = asOptString(inputs.repo)
const githubToken = asRawString(inputs.githubToken)
if (!owner || !repo || !githubToken) {
throw new Error('Cloud mode requires repository owner, name, and a GitHub token')
}
const params: PiCloudRunParams = {
...base,
mode: 'cloud',
owner,
repo,
githubToken,
baseBranch: asOptString(inputs.baseBranch),
branchName: asOptString(inputs.branchName),
draft: inputs.draft !== false,
prTitle: asOptString(inputs.prTitle),
prBody: asOptString(inputs.prBody),
}
return this.runPi(ctx, block, runCloudPi, params, memoryConfig)
}
throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`)
}
private isContentSelectedForStreaming(ctx: ExecutionContext, block: SerializedBlock): boolean {
if (!ctx.stream) return false
return (
ctx.selectedOutputs?.some((outputId) => {
if (outputId === block.id) return true
return outputId === `${block.id}.content` || outputId === `${block.id}_content`
}) ?? false
)
}
private buildOutput(
result: PiRunResult,
model: string,
isBYOK: boolean,
startTime: number,
startTimeISO: string
): NormalizedBlockOutput {
const { totals } = result
const endTime = Date.now()
return {
content: totals.finalText,
model,
changedFiles: result.changedFiles ?? [],
diff: result.diff ?? '',
...(result.prUrl ? { prUrl: result.prUrl } : {}),
...(result.branch ? { branch: result.branch } : {}),
tokens: {
input: totals.inputTokens,
output: totals.outputTokens,
total: totals.inputTokens + totals.outputTokens,
},
cost: computePiCost(model, totals.inputTokens, totals.outputTokens, isBYOK),
providerTiming: {
startTime: startTimeISO,
endTime: new Date(endTime).toISOString(),
duration: endTime - startTime,
},
}
}
private async runPi<P extends PiRunParams>(
ctx: ExecutionContext,
block: SerializedBlock,
backend: PiBackendRun<P>,
params: P,
memoryConfig: PiMemoryConfig
): Promise<BlockOutput | StreamingExecution> {
const startTime = Date.now()
const startTimeISO = new Date(startTime).toISOString()
logger.info('Executing Pi block', {
blockId: block.id,
mode: params.mode,
model: params.model,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
})
if (this.isContentSelectedForStreaming(ctx, block)) {
const output: NormalizedBlockOutput = { content: '', model: params.model }
const stream = new ReadableStream<Uint8Array>({
start: async (controller) => {
const encoder = new TextEncoder()
try {
const result = await backend(params, {
onEvent: (event) => {
const text = streamTextForEvent(event)
if (text) controller.enqueue(encoder.encode(text))
},
signal: ctx.abortSignal,
})
if (result.totals.errorMessage) {
controller.error(new Error(result.totals.errorMessage))
return
}
Object.assign(
output,
this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO)
)
await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText)
controller.close()
} catch (error) {
controller.error(error)
}
},
})
return {
stream,
execution: {
success: true,
output,
blockId: block.id,
logs: [],
metadata: { startTime: startTimeISO, duration: 0 },
isStreaming: true,
} as StreamingExecution['execution'] & { blockId: string },
}
}
const result = await backend(params, { onEvent: () => {}, signal: ctx.abortSignal })
if (result.totals.errorMessage) {
throw new Error(result.totals.errorMessage)
}
await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText)
return this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO)
}
}
@@ -0,0 +1,84 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockTransformBlockTool, mockExecuteTool } = vi.hoisted(() => ({
mockTransformBlockTool: vi.fn(),
mockExecuteTool: vi.fn(),
}))
vi.mock('@/providers/utils', () => ({ transformBlockTool: mockTransformBlockTool }))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
vi.mock('@/tools/utils', () => ({ getTool: vi.fn() }))
vi.mock('@/tools/utils.server', () => ({ getToolAsync: vi.fn() }))
import { buildSimToolSpecs } from '@/executor/handlers/pi/sim-tools'
import type { ExecutionContext } from '@/executor/types'
const ctx = { workspaceId: 'ws-1' } as ExecutionContext
describe('buildSimToolSpecs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('names the Pi tool with the snake_case tool id, not the human label', async () => {
// transformBlockTool returns a human label with a space, which the model
// provider rejects (tool names must match /^[a-zA-Z0-9_-]{1,128}$/).
mockTransformBlockTool.mockResolvedValue({
id: 'exa_search',
name: 'Exa Search',
description: 'Search the web',
params: {},
parameters: { type: 'object', properties: {} },
})
const specs = await buildSimToolSpecs(ctx, [
{ type: 'exa', operation: 'exa_search', usageControl: 'auto' },
])
expect(specs).toHaveLength(1)
expect(specs[0].name).toBe('exa_search')
expect(specs[0].name).toMatch(/^[a-zA-Z0-9_-]{1,128}$/)
})
it('skips mcp, custom, and usage-none tools without adapting them', async () => {
const specs = await buildSimToolSpecs(ctx, [
{ type: 'mcp', usageControl: 'auto' },
{ type: 'custom-tool', usageControl: 'auto' },
{ type: 'exa', usageControl: 'none' },
])
expect(specs).toHaveLength(0)
expect(mockTransformBlockTool).not.toHaveBeenCalled()
})
it('forwards a trusted _context that an LLM-supplied _context cannot override', async () => {
mockTransformBlockTool.mockResolvedValue({
id: 'exa_search',
name: 'Exa Search',
description: 'Search the web',
params: { apiKey: 'k' },
parameters: { type: 'object', properties: {} },
})
mockExecuteTool.mockResolvedValue({ success: true, output: 'ok' })
const trustedCtx = {
workspaceId: 'ws-1',
workflowId: 'wf-1',
userId: 'user-1',
} as ExecutionContext
const [spec] = await buildSimToolSpecs(trustedCtx, [
{ type: 'exa', operation: 'exa_search', usageControl: 'auto' },
])
// An attacker-influenced tool arg tries to spoof the execution context.
await spec.execute({ query: 'cats', _context: { userId: 'attacker', workspaceId: 'evil' } })
const [toolId, callParams] = mockExecuteTool.mock.calls[0]
expect(toolId).toBe('exa_search')
expect(callParams._context.userId).toBe('user-1')
expect(callParams._context.workspaceId).toBe('ws-1')
expect(callParams._context.workflowId).toBe('wf-1')
})
})
+107
View File
@@ -0,0 +1,107 @@
/**
* Adapts user-selected Sim tools into backend-neutral {@link PiToolSpec}s that
* Pi can call in local mode. Each spec carries the tool's JSON-schema parameters
* and an `execute` that runs the real Sim tool through `executeTool`, so the
* agent's calls go through the same credential-access checks as any block.
*
* MCP and custom tools are skipped in v1; block/integration tools are supported.
*/
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { getAllBlocks } from '@/blocks/registry'
import type { ToolInput } from '@/executor/handlers/agent/types'
import type { PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend'
import type { ExecutionContext } from '@/executor/types'
import { transformBlockTool } from '@/providers/utils'
import { executeTool } from '@/tools'
import type { ToolResponse } from '@/tools/types'
import { getTool } from '@/tools/utils'
import { getToolAsync } from '@/tools/utils.server'
const logger = createLogger('PiSimTools')
function toToolResult(result: ToolResponse): PiToolResult {
if (result.success) {
const text =
typeof result.output === 'string' ? result.output : JSON.stringify(result.output ?? {})
return { text, isError: false }
}
return { text: result.error || 'Tool execution failed', isError: true }
}
/**
* Builds the Sim tool specs exposed to Pi for a local run. Only tools the user
* added to the block are included, and `usageControl: 'none'` tools are dropped.
*/
export async function buildSimToolSpecs(
ctx: ExecutionContext,
inputTools: unknown
): Promise<PiToolSpec[]> {
if (!Array.isArray(inputTools)) return []
const specs: PiToolSpec[] = []
for (const tool of inputTools as ToolInput[]) {
if ((tool.usageControl || 'auto') === 'none') continue
if (!tool.type || tool.type === 'mcp' || tool.type === 'custom-tool') continue
try {
const provider = await transformBlockTool(tool, {
selectedOperation: tool.operation,
getAllBlocks,
getTool,
getToolAsync,
})
if (!provider?.id) continue
const toolId = provider.id
const preseededParams = provider.params || {}
specs.push({
name: toolId,
description: provider.description || '',
parameters: (provider.parameters as Record<string, unknown>) || {
type: 'object',
properties: {},
},
execute: async (args) => {
try {
const result = await executeTool(
toolId,
{
...preseededParams,
...args,
// Trusted execution context, spread last so an LLM-supplied
// `_context` arg can't override it. executeTool reads this directly
// for OAuth-credential resolution and internal-route identity, the
// same way the Agent block's tool calls do.
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
executionId: ctx.executionId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
callChain: ctx.callChain,
},
},
{ executionContext: ctx }
)
return toToolResult(result)
} catch (error) {
return { text: getErrorMessage(error, 'Tool execution failed'), isError: true }
}
},
})
} catch (error) {
logger.warn('Failed to adapt Sim tool for Pi', {
type: tool.type,
error: getErrorMessage(error),
})
}
}
return specs
}
@@ -0,0 +1,106 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockExecuteSSHCommand } = vi.hoisted(() => ({
mockExecuteSSHCommand: vi.fn(),
}))
vi.mock('@/app/api/tools/ssh/utils', () => ({
createSSHConnection: vi.fn(),
executeSSHCommand: mockExecuteSSHCommand,
escapeShellArg: (value: string) => value.replace(/'/g, "'\\''"),
sanitizeCommand: (value: string) => value,
sanitizePath: (value: string) => {
if (value.split(/[/\\]/).includes('..')) {
throw new Error('Path contains invalid path traversal sequences')
}
return value.trim()
},
}))
import type { PiSshSession } from '@/executor/handlers/pi/ssh-tools'
import { buildSshToolSpecs } from '@/executor/handlers/pi/ssh-tools'
function createSession(files: Record<string, string>): PiSshSession {
const sftp = {
readFile: (path: string, cb: (err: Error | undefined, data: Buffer) => void) => {
if (!(path in files)) {
cb(new Error(`no such file: ${path}`), Buffer.from(''))
return
}
cb(undefined, Buffer.from(files[path]))
},
writeFile: (path: string, data: string, cb: (err?: Error) => void) => {
files[path] = data
cb(undefined)
},
}
return {
client: {} as PiSshSession['client'],
sftp: sftp as unknown as PiSshSession['sftp'],
close: vi.fn(),
}
}
function getTool(repoPath: string, files: Record<string, string>, name: string) {
const tools = buildSshToolSpecs(createSession(files), repoPath)
const tool = tools.find((t) => t.name === name)
if (!tool) throw new Error(`tool not found: ${name}`)
return tool
}
describe('buildSshToolSpecs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('reads a file resolved against repoPath', async () => {
const read = getTool('/repo', { '/repo/a.txt': 'contents' }, 'read')
expect(await read.execute({ path: 'a.txt' })).toEqual({ text: 'contents', isError: false })
})
it('writes a file resolved against repoPath', async () => {
const files: Record<string, string> = {}
const write = getTool('/repo', files, 'write')
const result = await write.execute({ path: 'b.txt', content: 'hello' })
expect(result.isError).toBe(false)
expect(files['/repo/b.txt']).toBe('hello')
})
it('edits the first occurrence of old_string', async () => {
const files = { '/repo/c.txt': 'foo bar foo' }
const edit = getTool('/repo', files, 'edit')
const result = await edit.execute({ path: 'c.txt', old_string: 'foo', new_string: 'baz' })
expect(result.isError).toBe(false)
expect(files['/repo/c.txt']).toBe('baz bar foo')
})
it('reports an error when old_string is absent', async () => {
const edit = getTool('/repo', { '/repo/c.txt': 'nothing here' }, 'edit')
const result = await edit.execute({ path: 'c.txt', old_string: 'missing', new_string: 'x' })
expect(result.isError).toBe(true)
})
it('runs bash scoped to the repo directory', async () => {
mockExecuteSSHCommand.mockResolvedValue({ stdout: 'out', stderr: '', exitCode: 0 })
const bash = getTool('/repo', {}, 'bash')
const result = await bash.execute({ command: 'ls -la' })
expect(result).toEqual({ text: 'out', isError: false })
expect(mockExecuteSSHCommand).toHaveBeenCalledWith(expect.anything(), "cd '/repo' && ls -la")
})
it('marks a non-zero bash exit as an error', async () => {
mockExecuteSSHCommand.mockResolvedValue({ stdout: '', stderr: 'boom', exitCode: 2 })
const bash = getTool('/repo', {}, 'bash')
const result = await bash.execute({ command: 'false' })
expect(result.isError).toBe(true)
})
it('rejects path traversal and paths outside the repo', async () => {
const read = getTool('/repo', {}, 'read')
expect((await read.execute({ path: '../etc/passwd' })).isError).toBe(true)
expect((await read.execute({ path: '/outside/repo' })).isError).toBe(true)
})
})
+229
View File
@@ -0,0 +1,229 @@
/**
* SSH-backed file and shell tools for local-mode Pi runs. A single `ssh2`
* connection is opened per run and reused across every tool call: `read`/`write`/
* `edit` go over SFTP, `bash` over a shell exec scoped to the repo directory.
* All paths are sanitized and confined to the configured `repoPath` (S4).
*/
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { Client, SFTPWrapper } from 'ssh2'
import {
createSSHConnection,
escapeShellArg,
executeSSHCommand,
sanitizeCommand,
sanitizePath,
} from '@/app/api/tools/ssh/utils'
import type { PiSshConnection, PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend'
const logger = createLogger('PiSshTools')
/** An open SSH session reused for the duration of a local Pi run. */
export interface PiSshSession {
client: Client
sftp: SFTPWrapper
close: () => void
}
/** Opens one SSH connection plus an SFTP channel for the run. */
export async function openSshSession(connection: PiSshConnection): Promise<PiSshSession> {
const client = await createSSHConnection({
host: connection.host,
port: connection.port,
username: connection.username,
password: connection.password ?? null,
privateKey: connection.privateKey ?? null,
passphrase: connection.passphrase ?? null,
})
const close = () => {
try {
client.end()
} catch (error) {
logger.warn('Failed to close SSH session', { error: getErrorMessage(error) })
}
}
// The TCP/SSH connection is already open here, so close it if opening the SFTP
// channel fails (e.g. the server has the SFTP subsystem disabled) — otherwise
// the connection is orphaned when this function throws.
try {
const sftp = await new Promise<SFTPWrapper>((resolve, reject) => {
client.sftp((err, channel) => (err ? reject(err) : resolve(channel)))
})
return { client, sftp, close }
} catch (error) {
close()
throw error
}
}
function readRemoteFile(sftp: SFTPWrapper, path: string): Promise<string> {
return new Promise((resolve, reject) => {
sftp.readFile(path, (err, data) => (err ? reject(err) : resolve(data.toString('utf-8'))))
})
}
function writeRemoteFile(sftp: SFTPWrapper, path: string, content: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.writeFile(path, content, (err) => (err ? reject(err) : resolve()))
})
}
/** Resolves a tool-supplied path against `repoPath`, rejecting traversal/escape. */
function resolveRepoPath(repoPath: string, candidate: string): string {
const clean = sanitizePath(candidate)
const root = repoPath.replace(/\/+$/, '')
if (clean.startsWith('/')) {
if (clean !== root && !clean.startsWith(`${root}/`)) {
throw new Error(`Path is outside the repository: ${candidate}`)
}
return clean
}
return `${root}/${clean}`
}
function asString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
async function guard(run: () => Promise<PiToolResult>): Promise<PiToolResult> {
try {
return await run()
} catch (error) {
return { text: getErrorMessage(error, 'SSH tool failed'), isError: true }
}
}
/**
* Best-effort working-tree snapshot of the repo over the run's SSH session, for
* the block's `changedFiles`/`diff` outputs — Local mode edits in place rather
* than opening a PR. `changedFiles` covers both tracked modifications and untracked
* (newly created) files so files the agent created are reported; `diff` reflects
* tracked changes against HEAD. Returns empty on any failure (not a git repo, git
* missing, non-zero exit).
*/
export async function captureRepoChanges(
session: PiSshSession,
repoPath: string,
maxDiffBytes: number
): Promise<{ changedFiles: string[]; diff: string }> {
const scoped = `cd '${escapeShellArg(repoPath)}'`
try {
const tracked = await executeSSHCommand(
session.client,
`${scoped} && git diff --name-only HEAD`
)
const untracked = await executeSSHCommand(
session.client,
`${scoped} && git ls-files --others --exclude-standard`
)
const fileSet = new Set<string>()
for (const result of [tracked, untracked]) {
if (result.exitCode !== 0) continue
for (const line of result.stdout.split('\n')) {
const file = line.trim()
if (file) fileSet.add(file)
}
}
const raw = await executeSSHCommand(session.client, `${scoped} && git diff HEAD`)
const out = raw.exitCode === 0 ? raw.stdout : ''
const diff = out.length > maxDiffBytes ? `${out.slice(0, maxDiffBytes)}\n[diff truncated]` : out
return { changedFiles: [...fileSet], diff }
} catch {
return { changedFiles: [], diff: '' }
}
}
/** Builds the SSH-backed `read`/`write`/`edit`/`bash` tools scoped to `repoPath`. */
export function buildSshToolSpecs(session: PiSshSession, repoPath: string): PiToolSpec[] {
const { client, sftp } = session
return [
{
name: 'read',
description: 'Read the full contents of a file in the repository.',
parameters: {
type: 'object',
properties: { path: { type: 'string', description: 'File path within the repository' } },
required: ['path'],
},
execute: (args) =>
guard(async () => {
const path = asString(args.path)
if (!path) return { text: 'path is required', isError: true }
const content = await readRemoteFile(sftp, resolveRepoPath(repoPath, path))
return { text: content, isError: false }
}),
},
{
name: 'write',
description: 'Write (create or overwrite) a file in the repository.',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'File path within the repository' },
content: { type: 'string', description: 'Full file contents to write' },
},
required: ['path', 'content'],
},
execute: (args) =>
guard(async () => {
const path = asString(args.path)
if (!path) return { text: 'path is required', isError: true }
const resolved = resolveRepoPath(repoPath, path)
await writeRemoteFile(sftp, resolved, asString(args.content))
return { text: `Wrote ${resolved}`, isError: false }
}),
},
{
name: 'edit',
description: 'Replace the first occurrence of old_string with new_string in a file.',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'File path within the repository' },
old_string: { type: 'string', description: 'Exact text to replace' },
new_string: { type: 'string', description: 'Replacement text' },
},
required: ['path', 'old_string', 'new_string'],
},
execute: (args) =>
guard(async () => {
const path = asString(args.path)
if (!path) return { text: 'path is required', isError: true }
const oldString = asString(args.old_string)
const resolved = resolveRepoPath(repoPath, path)
const current = await readRemoteFile(sftp, resolved)
if (!current.includes(oldString)) {
return { text: `old_string not found in ${resolved}`, isError: true }
}
const updated = current.replace(oldString, asString(args.new_string))
await writeRemoteFile(sftp, resolved, updated)
return { text: `Edited ${resolved}`, isError: false }
}),
},
{
name: 'bash',
description: 'Run a shell command in the repository directory and return its output.',
parameters: {
type: 'object',
properties: { command: { type: 'string', description: 'Shell command to run' } },
required: ['command'],
},
execute: (args) =>
guard(async () => {
const command = asString(args.command)
if (!command) return { text: 'command is required', isError: true }
const scoped = `cd '${escapeShellArg(repoPath)}' && ${sanitizeCommand(command)}`
const result = await executeSSHCommand(client, scoped)
const text = [result.stdout, result.stderr].filter(Boolean).join('\n')
return {
text: text || `Exited with code ${result.exitCode}`,
isError: result.exitCode !== 0,
}
}),
},
]
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Handler Registry
*
* Central registry for all block handlers.
* Creates handlers for real user blocks (not infrastructure like sentinels).
*/
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler'
import { CredentialBlockHandler } from '@/executor/handlers/credential/credential-handler'
import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler'
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
import { HumanInTheLoopBlockHandler } from '@/executor/handlers/human-in-the-loop/human-in-the-loop-handler'
import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler'
import { PiBlockHandler } from '@/executor/handlers/pi/pi-handler'
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
import { RouterBlockHandler } from '@/executor/handlers/router/router-handler'
import { TriggerBlockHandler } from '@/executor/handlers/trigger/trigger-handler'
import { VariablesBlockHandler } from '@/executor/handlers/variables/variables-handler'
import { WaitBlockHandler } from '@/executor/handlers/wait/wait-handler'
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
import type { BlockHandler } from '@/executor/types'
/**
* Create all block handlers
*
* Note: Sentinels are NOT included here - they're infrastructure handled
* by NodeExecutionOrchestrator, not user blocks.
*/
export function createBlockHandlers(): BlockHandler[] {
return [
new TriggerBlockHandler(),
new FunctionBlockHandler(),
new ApiBlockHandler(),
new ConditionBlockHandler(),
new RouterBlockHandler(),
new ResponseBlockHandler(),
new HumanInTheLoopBlockHandler(),
new AgentBlockHandler(),
new MothershipBlockHandler(),
new PiBlockHandler(),
new VariablesBlockHandler(),
new WorkflowBlockHandler(),
new WaitBlockHandler(),
new EvaluatorBlockHandler(),
new CredentialBlockHandler(),
new GenericBlockHandler(),
]
}
@@ -0,0 +1,110 @@
import { createLogger } from '@sim/logger'
import { BlockType, HTTP } from '@/executor/constants'
import type { BlockHandler, ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
import {
convertBuilderDataToJson,
convertBuilderDataToJsonString,
} from '@/executor/utils/builder-data'
import { parseObjectStrings } from '@/executor/utils/json'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('ResponseBlockHandler')
export class ResponseBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.RESPONSE
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<NormalizedBlockOutput> {
logger.info(`Executing response block: ${block.id}`)
try {
const responseData = this.parseResponseData(inputs)
const statusCode = this.parseStatus(inputs.status)
const responseHeaders = this.parseHeaders(inputs.headers)
logger.info('Response prepared', {
status: statusCode,
dataKeys: Object.keys(responseData),
headerKeys: Object.keys(responseHeaders),
})
return {
data: responseData,
status: statusCode,
headers: responseHeaders,
}
} catch (error: any) {
logger.error('Response block execution failed:', error)
return {
data: {
error: 'Response block execution failed',
message: error.message || 'Unknown error',
},
status: HTTP.STATUS.SERVER_ERROR,
headers: { 'Content-Type': HTTP.CONTENT_TYPE.JSON },
}
}
}
private parseResponseData(inputs: Record<string, any>): any {
const dataMode = inputs.dataMode || 'structured'
if (dataMode === 'json' && inputs.data) {
if (typeof inputs.data === 'string') {
try {
return JSON.parse(inputs.data)
} catch (error) {
logger.warn('Failed to parse JSON data, returning as string:', error)
return inputs.data
}
} else if (typeof inputs.data === 'object' && inputs.data !== null) {
return inputs.data
}
return inputs.data
}
if (dataMode === 'structured' && inputs.builderData) {
const convertedData = convertBuilderDataToJson(inputs.builderData)
return parseObjectStrings(convertedData)
}
return inputs.data || {}
}
static convertBuilderDataToJsonString(builderData: any[]): string {
return convertBuilderDataToJsonString(builderData)
}
private parseStatus(status?: string): number {
if (!status) return HTTP.STATUS.OK
const parsed = Number(status)
if (Number.isNaN(parsed) || parsed < 100 || parsed > 599) {
return HTTP.STATUS.OK
}
return parsed
}
private parseHeaders(
headers: {
id: string
cells: { Key: string; Value: string }
}[]
): Record<string, string> {
const defaultHeaders = { 'Content-Type': HTTP.CONTENT_TYPE.JSON }
if (!headers) return defaultHeaders
const headerObj = headers.reduce((acc: Record<string, string>, header) => {
if (header?.cells?.Key && header?.cells?.Value) {
acc[header.cells.Key] = header.cells.Value
}
return acc
}, {})
return { ...defaultHeaders, ...headerObj }
}
}
@@ -0,0 +1,617 @@
import '@sim/testing/mocks/executor'
import { authOAuthUtilsMock, authOAuthUtilsMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
vi.mock('@/lib/credentials/access', () => ({
getCredentialActorContext: vi.fn().mockResolvedValue({
credential: {
id: 'test-vertex-credential',
type: 'oauth',
workspaceId: 'test-workspace',
accountId: 'test-vertex-credential-id',
},
member: { role: 'admin', status: 'active' },
hasWorkspaceAccess: true,
canWriteWorkspace: true,
isAdmin: true,
}),
}))
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import { BlockType } from '@/executor/constants'
import { RouterBlockHandler } from '@/executor/handlers/router/router-handler'
import type { ExecutionContext } from '@/executor/types'
import { getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
const mockGenerateRouterPrompt = generateRouterPrompt as Mock
const mockGenerateRouterV2Prompt = generateRouterV2Prompt as Mock
const mockGetProviderFromModel = getProviderFromModel as Mock
const mockFetch = global.fetch as unknown as Mock
describe('RouterBlockHandler', () => {
let handler: RouterBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let mockWorkflow: Partial<SerializedWorkflow>
let mockTargetBlock1: SerializedBlock
let mockTargetBlock2: SerializedBlock
beforeEach(() => {
mockTargetBlock1 = {
id: 'target-block-1',
metadata: { id: 'target', name: 'Option A', description: 'Choose A' },
position: { x: 100, y: 100 },
config: { tool: 'tool_a', params: { p: 'a' } },
inputs: {},
outputs: {},
enabled: true,
}
mockTargetBlock2 = {
id: 'target-block-2',
metadata: { id: 'target', name: 'Option B', description: 'Choose B' },
position: { x: 100, y: 150 },
config: { tool: 'tool_b', params: { p: 'b' } },
inputs: {},
outputs: {},
enabled: true,
}
mockBlock = {
id: 'router-block-1',
metadata: { id: BlockType.ROUTER, name: 'Test Router' },
position: { x: 50, y: 50 },
config: { tool: BlockType.ROUTER, params: {} },
inputs: { prompt: 'string', model: 'string' },
outputs: {},
enabled: true,
}
mockWorkflow = {
blocks: [mockBlock, mockTargetBlock1, mockTargetBlock2],
connections: [
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-then1' },
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-else1' },
],
}
handler = new RouterBlockHandler({})
mockContext = {
workflowId: 'test-workflow-id',
userId: 'test-user',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
completedLoops: new Set(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
workflow: mockWorkflow as SerializedWorkflow,
}
vi.clearAllMocks()
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValue({
accountId: 'test-vertex-credential-id',
usedCredentialTable: false,
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValue({
accessToken: 'mock-access-token',
refreshed: false,
})
mockGetProviderFromModel.mockReturnValue('openai')
mockGenerateRouterPrompt.mockReturnValue('Generated System Prompt')
mockFetch.mockImplementation(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: 'target-block-1',
model: 'mock-model',
tokens: { input: 100, output: 5, total: 105 },
cost: 0.003,
timing: { total: 300 },
}),
})
})
})
it('should handle router blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
const nonRouterBlock: SerializedBlock = { ...mockBlock, metadata: { id: 'other' } }
expect(handler.canHandle(nonRouterBlock)).toBe(false)
})
it('should execute router block correctly and select a path', async () => {
const inputs = {
prompt: 'Choose the best option.',
model: 'gpt-4o',
apiKey: 'test-api-key',
temperature: 0.1,
}
const expectedTargetBlocks = [
{
id: 'target-block-1',
type: 'target',
title: 'Option A',
description: 'Choose A',
subBlocks: {
p: 'a',
systemPrompt: '',
},
currentState: undefined,
},
{
id: 'target-block-2',
type: 'target',
title: 'Option B',
description: 'Choose B',
subBlocks: {
p: 'b',
systemPrompt: '',
},
currentState: undefined,
},
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockGenerateRouterPrompt).toHaveBeenCalledWith(inputs.prompt, expectedTargetBlocks)
expect(mockGetProviderFromModel).toHaveBeenCalledWith('gpt-4o')
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'POST',
headers: expect.any(Object),
body: expect.any(String),
})
)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
provider: 'openai',
model: 'gpt-4o',
systemPrompt: 'Generated System Prompt',
context: JSON.stringify([{ role: 'user', content: 'Choose the best option.' }]),
temperature: 0.1,
})
expect(result).toEqual({
prompt: 'Choose the best option.',
model: 'mock-model',
tokens: { input: 100, output: 5, total: 105 },
cost: {
input: 0,
output: 0,
total: 0,
},
selectedPath: {
blockId: 'target-block-1',
blockType: 'target',
blockTitle: 'Option A',
},
selectedRoute: 'target-block-1',
})
})
it('should throw error if target block is missing', async () => {
const inputs = { prompt: 'Test' }
mockContext.workflow!.blocks = [mockBlock, mockTargetBlock2]
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Target block target-block-1 not found'
)
expect(mockFetch).not.toHaveBeenCalled()
})
it('should throw error if LLM response is not a valid target block ID', async () => {
const inputs = { prompt: 'Test', apiKey: 'test-api-key' }
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: 'invalid-block-id',
model: 'mock-model',
tokens: {},
cost: 0,
timing: {},
}),
})
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Invalid routing decision: invalid-block-id'
)
})
it('should use default model and temperature if not provided', async () => {
const inputs = { prompt: 'Choose.', apiKey: 'test-api-key' }
await handler.execute(mockContext, mockBlock, inputs)
expect(mockGetProviderFromModel).toHaveBeenCalledWith('claude-sonnet-5')
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
model: 'claude-sonnet-5',
temperature: 0.1,
})
})
it('should handle server error responses', async () => {
const inputs = { prompt: 'Test error handling.', apiKey: 'test-api-key' }
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: false,
status: 500,
json: () => Promise.resolve({ error: 'Server error' }),
})
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow('Server error')
})
it('should handle Azure OpenAI models with endpoint and API version', async () => {
const inputs = {
prompt: 'Choose the best option.',
model: 'gpt-4o',
apiKey: 'test-azure-key',
azureEndpoint: 'https://test.openai.azure.com',
azureApiVersion: '2024-07-01-preview',
}
mockGetProviderFromModel.mockReturnValue('azure-openai')
await handler.execute(mockContext, mockBlock, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
provider: 'azure-openai',
model: 'gpt-4o',
apiKey: 'test-azure-key',
azureEndpoint: 'https://test.openai.azure.com',
azureApiVersion: '2024-07-01-preview',
})
})
it('should handle Vertex AI models with OAuth credential', async () => {
const inputs = {
prompt: 'Choose the best option.',
model: 'gemini-2.0-flash-exp',
vertexCredential: 'test-vertex-credential-id',
vertexProject: 'test-gcp-project',
vertexLocation: 'us-central1',
}
mockGetProviderFromModel.mockReturnValue('vertex')
const mockDb = await import('@sim/db')
const mockAccount = {
id: 'test-vertex-credential-id',
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
expiresAt: new Date(Date.now() + 3600000),
}
;(mockDb.db.query as any).account = { findFirst: vi.fn() }
vi.spyOn(mockDb.db.query.account, 'findFirst').mockResolvedValue(mockAccount as any)
await handler.execute(mockContext, mockBlock, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
provider: 'vertex',
model: 'gemini-2.0-flash-exp',
vertexProject: 'test-gcp-project',
vertexLocation: 'us-central1',
})
expect(requestBody.apiKey).toBe('mock-access-token')
})
})
describe('RouterBlockHandler V2', () => {
let handler: RouterBlockHandler
let mockRouterV2Block: SerializedBlock
let mockContext: ExecutionContext
let mockWorkflow: Partial<SerializedWorkflow>
let mockTargetBlock1: SerializedBlock
let mockTargetBlock2: SerializedBlock
beforeEach(() => {
mockTargetBlock1 = {
id: 'target-block-1',
metadata: { id: 'agent', name: 'Support Agent' },
position: { x: 100, y: 100 },
config: { tool: 'agent', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockTargetBlock2 = {
id: 'target-block-2',
metadata: { id: 'agent', name: 'Sales Agent' },
position: { x: 100, y: 150 },
config: { tool: 'agent', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockRouterV2Block = {
id: 'router-v2-block-1',
metadata: { id: BlockType.ROUTER_V2, name: 'Test Router V2' },
position: { x: 50, y: 50 },
config: { tool: BlockType.ROUTER_V2, params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockWorkflow = {
blocks: [mockRouterV2Block, mockTargetBlock1, mockTargetBlock2],
connections: [
{
source: mockRouterV2Block.id,
target: mockTargetBlock1.id,
sourceHandle: 'router-route-support',
},
{
source: mockRouterV2Block.id,
target: mockTargetBlock2.id,
sourceHandle: 'router-route-sales',
},
],
}
handler = new RouterBlockHandler({})
mockContext = {
workflowId: 'test-workflow-id',
userId: 'test-user',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
completedLoops: new Set(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
workflow: mockWorkflow as SerializedWorkflow,
}
vi.clearAllMocks()
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValue({
accountId: 'test-vertex-credential-id',
usedCredentialTable: false,
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValue({
accessToken: 'mock-access-token',
refreshed: false,
})
mockGetProviderFromModel.mockReturnValue('openai')
mockGenerateRouterV2Prompt.mockReturnValue('Generated V2 System Prompt')
})
it('should handle router_v2 blocks', () => {
expect(handler.canHandle(mockRouterV2Block)).toBe(true)
})
it('should execute router V2 and return reasoning', async () => {
const inputs = {
context: 'I need help with a billing issue',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([
{ id: 'route-support', title: 'Support', value: 'Customer support inquiries' },
{ id: 'route-sales', title: 'Sales', value: 'Sales and pricing questions' },
]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({
route: 'route-support',
reasoning: 'The user mentioned a billing issue which is a customer support matter.',
}),
model: 'gpt-4o',
tokens: { input: 150, output: 25, total: 175 },
}),
})
})
const result = await handler.execute(mockContext, mockRouterV2Block, inputs)
expect(result).toMatchObject({
context: 'I need help with a billing issue',
model: 'gpt-4o',
selectedRoute: 'route-support',
reasoning: 'The user mentioned a billing issue which is a customer support matter.',
selectedPath: {
blockId: 'target-block-1',
blockType: 'agent',
blockTitle: 'Support Agent',
},
})
})
it('should include responseFormat in provider request', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Description 1' }]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ route: 'route-1', reasoning: 'Test reasoning' }),
model: 'gpt-4o',
tokens: { input: 100, output: 20, total: 120 },
}),
})
})
await handler.execute(mockContext, mockRouterV2Block, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody.responseFormat).toEqual({
name: 'router_response',
schema: {
type: 'object',
properties: {
route: {
type: 'string',
description: 'The selected route ID or NO_MATCH',
},
reasoning: {
type: 'string',
description: 'Brief explanation of why this route was chosen',
},
},
required: ['route', 'reasoning'],
additionalProperties: false,
},
strict: true,
})
})
it('should handle NO_MATCH response with reasoning', async () => {
const inputs = {
context: 'Random unrelated query',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Specific topic' }]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({
route: 'NO_MATCH',
reasoning: 'The query does not relate to any available route.',
}),
model: 'gpt-4o',
tokens: { input: 100, output: 20, total: 120 },
}),
})
})
await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow(
'Router could not determine a matching route: The query does not relate to any available route.'
)
})
it('should throw error for invalid route ID in response', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Description' }]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ route: 'invalid-route', reasoning: 'Some reasoning' }),
model: 'gpt-4o',
tokens: { input: 100, output: 20, total: 120 },
}),
})
})
await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow(
/Router could not determine a valid route/
)
})
it('should handle routes passed as array instead of JSON string', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: [{ id: 'route-1', title: 'Route 1', value: 'Description' }],
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ route: 'route-1', reasoning: 'Matched route 1' }),
model: 'gpt-4o',
tokens: { input: 100, output: 20, total: 120 },
}),
})
})
const result = await handler.execute(mockContext, mockRouterV2Block, inputs)
expect(result.selectedRoute).toBe('route-1')
expect(result.reasoning).toBe('Matched route 1')
})
it('should throw error when no routes are defined', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: '[]',
}
await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow(
'No routes defined for router'
)
})
it('should handle fallback when JSON parsing fails', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Description' }]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: 'route-1',
model: 'gpt-4o',
tokens: { input: 100, output: 5, total: 105 },
}),
})
})
const result = await handler.execute(mockContext, mockRouterV2Block, inputs)
expect(result.selectedRoute).toBe('route-1')
expect(result.reasoning).toBe('')
})
})
@@ -0,0 +1,424 @@
import { createLogger } from '@sim/logger'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import {
BlockType,
DEFAULTS,
isAgentBlockType,
isRouterV2BlockType,
ROUTER,
} from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAuthHeaders } from '@/executor/utils/http'
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('RouterBlockHandler')
interface RouteDefinition {
id: string
title: string
value: string
}
/**
* Handler for Router blocks that dynamically select execution paths.
* Supports both legacy router (block-based) and router_v2 (port-based).
*/
export class RouterBlockHandler implements BlockHandler {
constructor(private pathTracker?: any) {}
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.ROUTER || block.metadata?.id === BlockType.ROUTER_V2
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const isV2 = isRouterV2BlockType(block.metadata?.id)
if (isV2) {
return this.executeV2(ctx, block, inputs)
}
return this.executeLegacy(ctx, block, inputs)
}
/**
* Execute legacy router (block-based routing).
*/
private async executeLegacy(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const targetBlocks = this.getTargetBlocks(ctx, block)
const routerConfig = {
prompt: inputs.prompt,
model: inputs.model || ROUTER.DEFAULT_MODEL,
apiKey: inputs.apiKey,
vertexProject: inputs.vertexProject,
vertexLocation: inputs.vertexLocation,
vertexCredential: inputs.vertexCredential,
bedrockAccessKeyId: inputs.bedrockAccessKeyId,
bedrockSecretKey: inputs.bedrockSecretKey,
bedrockRegion: inputs.bedrockRegion,
}
await validateModelProvider(ctx.userId, ctx.workspaceId, routerConfig.model, ctx)
const providerId = getProviderFromModel(routerConfig.model)
try {
const url = new URL('/api/providers', getInternalApiBaseUrl())
if (ctx.userId) url.searchParams.set('userId', ctx.userId)
const messages = [{ role: 'user', content: routerConfig.prompt }]
const systemPrompt = generateRouterPrompt(routerConfig.prompt, targetBlocks)
let finalApiKey: string | undefined = routerConfig.apiKey
if (providerId === 'vertex' && routerConfig.vertexCredential) {
finalApiKey = await resolveVertexCredential(
routerConfig.vertexCredential,
ctx.userId,
'vertex-router'
)
}
const providerRequest: Record<string, any> = {
provider: providerId,
model: routerConfig.model,
systemPrompt: systemPrompt,
context: JSON.stringify(messages),
temperature: ROUTER.INFERENCE_TEMPERATURE,
apiKey: finalApiKey,
azureEndpoint: inputs.azureEndpoint,
azureApiVersion: inputs.azureApiVersion,
vertexProject: routerConfig.vertexProject,
vertexLocation: routerConfig.vertexLocation,
bedrockAccessKeyId: routerConfig.bedrockAccessKeyId,
bedrockSecretKey: routerConfig.bedrockSecretKey,
bedrockRegion: routerConfig.bedrockRegion,
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
}
const response = await fetch(url.toString(), {
method: 'POST',
headers: await buildAuthHeaders(ctx.userId),
body: JSON.stringify(providerRequest),
})
if (!response.ok) {
let errorMessage = `Provider API request failed with status ${response.status}`
try {
const errorData = await response.json()
if (errorData.error) {
errorMessage = errorData.error
}
} catch (_e) {}
throw new Error(errorMessage)
}
const result = await response.json()
const chosenBlockId = result.content.trim().toLowerCase()
const chosenBlock = targetBlocks?.find((b) => b.id === chosenBlockId)
if (!chosenBlock) {
logger.error(
`Invalid routing decision. Response content: "${result.content}", available blocks:`,
targetBlocks?.map((b) => ({ id: b.id, title: b.title })) || []
)
throw new Error(`Invalid routing decision: ${chosenBlockId}`)
}
const tokens = result.tokens || {
input: DEFAULTS.TOKENS.PROMPT,
output: DEFAULTS.TOKENS.COMPLETION,
total: DEFAULTS.TOKENS.TOTAL,
}
const cost = calculateCost(
result.model,
tokens.input || DEFAULTS.TOKENS.PROMPT,
tokens.output || DEFAULTS.TOKENS.COMPLETION,
false
)
return {
prompt: inputs.prompt,
model: result.model,
tokens: {
input: tokens.input || DEFAULTS.TOKENS.PROMPT,
output: tokens.output || DEFAULTS.TOKENS.COMPLETION,
total: tokens.total || DEFAULTS.TOKENS.TOTAL,
},
cost: {
input: cost.input,
output: cost.output,
total: cost.total,
},
selectedPath: {
blockId: chosenBlock.id,
blockType: chosenBlock.type || DEFAULTS.BLOCK_TYPE,
blockTitle: chosenBlock.title || DEFAULTS.BLOCK_TITLE,
},
selectedRoute: String(chosenBlock.id),
} as BlockOutput
} catch (error) {
logger.error('Router execution failed:', error)
throw error
}
}
/**
* Execute router v2 (port-based routing).
* Uses route definitions with descriptions instead of downstream block names.
*/
private async executeV2(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const routes = this.parseRoutes(inputs.routes)
if (routes.length === 0) {
throw new Error('No routes defined for router')
}
const routerConfig = {
context: inputs.context,
model: inputs.model || ROUTER.DEFAULT_MODEL,
apiKey: inputs.apiKey,
vertexProject: inputs.vertexProject,
vertexLocation: inputs.vertexLocation,
vertexCredential: inputs.vertexCredential,
bedrockAccessKeyId: inputs.bedrockAccessKeyId,
bedrockSecretKey: inputs.bedrockSecretKey,
bedrockRegion: inputs.bedrockRegion,
}
await validateModelProvider(ctx.userId, ctx.workspaceId, routerConfig.model, ctx)
const providerId = getProviderFromModel(routerConfig.model)
try {
const url = new URL('/api/providers', getInternalApiBaseUrl())
if (ctx.userId) url.searchParams.set('userId', ctx.userId)
const messages = [{ role: 'user', content: routerConfig.context }]
const systemPrompt = generateRouterV2Prompt(routerConfig.context, routes)
let finalApiKey: string | undefined = routerConfig.apiKey
if (providerId === 'vertex' && routerConfig.vertexCredential) {
finalApiKey = await resolveVertexCredential(
routerConfig.vertexCredential,
ctx.userId,
'vertex-router'
)
}
const providerRequest: Record<string, any> = {
provider: providerId,
model: routerConfig.model,
systemPrompt: systemPrompt,
context: JSON.stringify(messages),
temperature: ROUTER.INFERENCE_TEMPERATURE,
apiKey: finalApiKey,
azureEndpoint: inputs.azureEndpoint,
azureApiVersion: inputs.azureApiVersion,
vertexProject: routerConfig.vertexProject,
vertexLocation: routerConfig.vertexLocation,
bedrockAccessKeyId: routerConfig.bedrockAccessKeyId,
bedrockSecretKey: routerConfig.bedrockSecretKey,
bedrockRegion: routerConfig.bedrockRegion,
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
responseFormat: {
name: 'router_response',
schema: {
type: 'object',
properties: {
route: {
type: 'string',
description: 'The selected route ID or NO_MATCH',
},
reasoning: {
type: 'string',
description: 'Brief explanation of why this route was chosen',
},
},
required: ['route', 'reasoning'],
additionalProperties: false,
},
strict: true,
},
}
const response = await fetch(url.toString(), {
method: 'POST',
headers: await buildAuthHeaders(ctx.userId),
body: JSON.stringify(providerRequest),
})
if (!response.ok) {
let errorMessage = `Provider API request failed with status ${response.status}`
try {
const errorData = await response.json()
if (errorData.error) {
errorMessage = errorData.error
}
} catch (_e) {}
throw new Error(errorMessage)
}
const result = await response.json()
let chosenRouteId: string
let reasoning = ''
try {
const parsedResponse = JSON.parse(result.content)
chosenRouteId = parsedResponse.route?.trim() || ''
reasoning = parsedResponse.reasoning || ''
} catch (_parseError) {
logger.error('Router response was not valid JSON despite responseFormat', {
content: result.content,
})
chosenRouteId = result.content.trim()
}
if (chosenRouteId === 'NO_MATCH' || chosenRouteId.toUpperCase() === 'NO_MATCH') {
logger.info('Router determined no route matches the context, routing to error path')
throw new Error(
reasoning
? `Router could not determine a matching route: ${reasoning}`
: 'Router could not determine a matching route for the given context'
)
}
const chosenRoute = routes.find((r) => r.id === chosenRouteId)
if (!chosenRoute) {
const availableRoutes = routes.map((r) => ({ id: r.id, title: r.title }))
logger.error(
`Invalid routing decision. Response content: "${result.content}". Available routes:`,
availableRoutes
)
throw new Error(
`Router could not determine a valid route. LLM response: "${result.content}". Available route IDs: ${routes.map((r) => r.id).join(', ')}`
)
}
const connection = ctx.workflow?.connections.find(
(conn) => conn.source === block.id && conn.sourceHandle === `router-${chosenRoute.id}`
)
const targetBlock = connection
? ctx.workflow?.blocks.find((b) => b.id === connection.target)
: null
const tokens = result.tokens || {
input: DEFAULTS.TOKENS.PROMPT,
output: DEFAULTS.TOKENS.COMPLETION,
total: DEFAULTS.TOKENS.TOTAL,
}
const cost = calculateCost(
result.model,
tokens.input || DEFAULTS.TOKENS.PROMPT,
tokens.output || DEFAULTS.TOKENS.COMPLETION,
false
)
return {
context: inputs.context,
model: result.model,
tokens: {
input: tokens.input || DEFAULTS.TOKENS.PROMPT,
output: tokens.output || DEFAULTS.TOKENS.COMPLETION,
total: tokens.total || DEFAULTS.TOKENS.TOTAL,
},
cost: {
input: cost.input,
output: cost.output,
total: cost.total,
},
selectedRoute: chosenRoute.id,
reasoning,
selectedPath: targetBlock
? {
blockId: targetBlock.id,
blockType: targetBlock.metadata?.id || DEFAULTS.BLOCK_TYPE,
blockTitle: targetBlock.metadata?.name || DEFAULTS.BLOCK_TITLE,
}
: {
blockId: '',
blockType: DEFAULTS.BLOCK_TYPE,
blockTitle: chosenRoute.title,
},
} as BlockOutput
} catch (error) {
logger.error('Router V2 execution failed:', error)
throw error
}
}
/**
* Parse routes from input (can be JSON string or array)
*/
private parseRoutes(input: any): RouteDefinition[] {
try {
if (typeof input === 'string') {
return JSON.parse(input)
}
if (Array.isArray(input)) {
return input
}
return []
} catch (error) {
logger.error('Failed to parse routes:', { input, error })
return []
}
}
private getTargetBlocks(ctx: ExecutionContext, block: SerializedBlock) {
return ctx.workflow?.connections
.filter((conn) => conn.source === block.id)
.map((conn) => {
const targetBlock = ctx.workflow?.blocks.find((b) => b.id === conn.target)
if (!targetBlock) {
throw new Error(`Target block ${conn.target} not found`)
}
let systemPrompt = ''
if (isAgentBlockType(targetBlock.metadata?.id)) {
const paramsPrompt = targetBlock.config?.params?.systemPrompt
const inputsPrompt = targetBlock.inputs?.systemPrompt
systemPrompt =
(typeof paramsPrompt === 'string' ? paramsPrompt : '') ||
(typeof inputsPrompt === 'string' ? inputsPrompt : '') ||
''
}
return {
id: targetBlock.id,
type: targetBlock.metadata?.id,
title: targetBlock.metadata?.name,
description: targetBlock.metadata?.description,
subBlocks: {
...targetBlock.config.params,
systemPrompt: systemPrompt,
},
currentState: ctx.blockStates.get(targetBlock.id)?.output,
}
})
}
}
@@ -0,0 +1,107 @@
import { createLogger } from '@sim/logger'
import type { BlockOutput } from '@/blocks/types'
import { REFERENCE } from '@/executor/constants'
const logger = createLogger('SharedResponseFormat')
/**
* Parse a raw responseFormat value (string or object) into a usable schema.
*
* Handles:
* - Empty / falsy → undefined
* - Already an object → wraps bare schemas with `{ name, schema, strict }`
* - JSON string → parsed, then same wrapping logic
* - Unresolved block references (`<block.field>`) → undefined
*/
export function parseResponseFormat(responseFormat?: string | object): any {
if (!responseFormat || responseFormat === '') return undefined
if (typeof responseFormat === 'object' && responseFormat !== null) {
const formatObj = responseFormat as any
if (!formatObj.schema && !formatObj.name) {
return { name: 'response_schema', schema: responseFormat, strict: true }
}
return responseFormat
}
if (typeof responseFormat === 'string') {
const trimmed = responseFormat.trim()
if (!trimmed) return undefined
if (trimmed.startsWith(REFERENCE.START) && trimmed.includes(REFERENCE.END)) {
return undefined
}
try {
const parsed = JSON.parse(trimmed)
if (parsed && typeof parsed === 'object' && !parsed.schema && !parsed.name) {
return { name: 'response_schema', schema: parsed, strict: true }
}
return parsed
} catch (error: any) {
logger.warn('Failed to parse response format as JSON', {
error: error.message,
preview: trimmed.slice(0, 100),
})
return undefined
}
}
return undefined
}
/**
* Validate and extract messages from a raw input value.
*
* Accepts a JSON string or an array. Each entry must have
* `role` (string) and `content` (string).
*/
export function resolveMessages(raw: unknown): Array<{ role: string; content: string }> {
if (!raw) {
throw new Error('Messages input is required')
}
let messages: unknown[]
if (typeof raw === 'string') {
try {
messages = JSON.parse(raw)
} catch {
throw new Error('Messages must be a valid JSON array')
}
} else if (Array.isArray(raw)) {
messages = raw
} else {
throw new Error('Messages must be an array of {role, content} objects')
}
return messages.map((msg: any, i: number) => {
if (!msg.role || typeof msg.content !== 'string') {
throw new Error(`Message at index ${i} must have "role" (string) and "content" (string)`)
}
return { role: String(msg.role), content: msg.content }
})
}
/**
* Try to parse the LLM response content as structured JSON and spread
* the fields into the block output. Falls back to returning raw content.
*/
export function processStructuredResponse(
result: { content?: string; model?: string; tokens?: any },
defaultModel: string
): BlockOutput {
const content = result.content ?? ''
try {
const parsed = JSON.parse(content.trim())
return {
...parsed,
model: result.model || defaultModel,
tokens: result.tokens || {},
}
} catch {
logger.warn('Failed to parse structured response, returning raw content')
return {
content,
model: result.model || defaultModel,
tokens: result.tokens || {},
}
}
}
@@ -0,0 +1,314 @@
import '@sim/testing/mocks/executor'
import { beforeEach, describe, expect, it } from 'vitest'
import { TriggerBlockHandler } from '@/executor/handlers/trigger/trigger-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
describe('TriggerBlockHandler', () => {
let handler: TriggerBlockHandler
let mockContext: ExecutionContext
beforeEach(() => {
handler = new TriggerBlockHandler()
mockContext = {
workflowId: 'test-workflow-id',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
}
})
describe('canHandle', () => {
it.concurrent('should handle blocks with triggers category', () => {
const triggerBlock: SerializedBlock = {
id: 'trigger-1',
metadata: { id: 'schedule', name: 'Schedule Block', category: 'triggers' },
position: { x: 0, y: 0 },
config: { tool: 'schedule', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
expect(handler.canHandle(triggerBlock)).toBe(true)
})
it.concurrent('should handle blocks with triggerMode enabled', () => {
const gmailTriggerBlock: SerializedBlock = {
id: 'gmail-1',
metadata: { id: 'gmail', name: 'Gmail Block', category: 'tools' },
position: { x: 0, y: 0 },
config: { tool: 'gmail', params: { triggerMode: true } },
inputs: {},
outputs: {},
enabled: true,
}
expect(handler.canHandle(gmailTriggerBlock)).toBe(true)
})
it.concurrent('should not handle regular tool blocks without triggerMode', () => {
const toolBlock: SerializedBlock = {
id: 'tool-1',
metadata: { id: 'gmail', name: 'Gmail Block', category: 'tools' },
position: { x: 0, y: 0 },
config: { tool: 'gmail', params: { triggerMode: false } },
inputs: {},
outputs: {},
enabled: true,
}
expect(handler.canHandle(toolBlock)).toBe(false)
})
it.concurrent('should not handle blocks without trigger indicators', () => {
const regularBlock: SerializedBlock = {
id: 'regular-1',
metadata: { id: 'api', name: 'API Block', category: 'tools' },
position: { x: 0, y: 0 },
config: { tool: 'api', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
expect(handler.canHandle(regularBlock)).toBe(false)
})
it.concurrent('should handle generic webhook blocks', () => {
const webhookBlock: SerializedBlock = {
id: 'webhook-1',
metadata: { id: 'generic_webhook', name: 'Generic Webhook', category: 'triggers' },
position: { x: 0, y: 0 },
config: { tool: 'generic_webhook', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
expect(handler.canHandle(webhookBlock)).toBe(true)
})
})
describe('execute', () => {
it.concurrent('should return inputs directly when provided', async () => {
const triggerBlock: SerializedBlock = {
id: 'trigger-1',
metadata: { id: 'gmail', name: 'Gmail Trigger', category: 'triggers' },
position: { x: 0, y: 0 },
config: { tool: 'gmail', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
const triggerInputs = {
email: {
id: '12345',
subject: 'Test Email',
from: 'test@example.com',
body: 'Hello world',
},
timestamp: '2023-01-01T12:00:00Z',
}
const result = await handler.execute(mockContext, triggerBlock, triggerInputs)
expect(result).toEqual(triggerInputs)
})
it.concurrent('should return empty object when no inputs provided', async () => {
const triggerBlock: SerializedBlock = {
id: 'trigger-1',
metadata: { id: 'schedule', name: 'Schedule Trigger', category: 'triggers' },
position: { x: 0, y: 0 },
config: { tool: 'schedule', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
const result = await handler.execute(mockContext, triggerBlock, {})
expect(result).toEqual({})
})
it.concurrent('should handle webhook payload inputs', async () => {
const webhookBlock: SerializedBlock = {
id: 'webhook-1',
metadata: { id: 'generic_webhook', name: 'Generic Webhook', category: 'triggers' },
position: { x: 0, y: 0 },
config: { tool: 'generic_webhook', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
const webhookInputs = {
webhook: {
data: {
provider: 'github',
payload: { event: 'push', repo: 'test-repo' },
},
},
}
const result = await handler.execute(mockContext, webhookBlock, webhookInputs)
expect(result).toEqual(webhookInputs)
})
it.concurrent('should handle Outlook trigger inputs', async () => {
const outlookBlock: SerializedBlock = {
id: 'outlook-1',
metadata: { id: 'outlook', name: 'Outlook Block', category: 'tools' },
position: { x: 0, y: 0 },
config: { tool: 'outlook', params: { triggerMode: true } },
inputs: {},
outputs: {},
enabled: true,
}
const outlookInputs = {
email: {
id: 'outlook123',
subject: 'Meeting Invitation',
from: 'colleague@company.com',
bodyPreview: 'Join us for the quarterly review...',
},
timestamp: '2023-01-01T14:30:00Z',
}
const result = await handler.execute(mockContext, outlookBlock, outlookInputs)
expect(result).toEqual(outlookInputs)
})
it.concurrent('should handle schedule trigger with no inputs', async () => {
const scheduleBlock: SerializedBlock = {
id: 'schedule-1',
metadata: { id: 'schedule', name: 'Daily Schedule', category: 'triggers' },
position: { x: 0, y: 0 },
config: { tool: 'schedule', params: { scheduleType: 'daily' } },
inputs: {},
outputs: {},
enabled: true,
}
const result = await handler.execute(mockContext, scheduleBlock, {})
expect(result).toEqual({})
})
it.concurrent('should handle complex nested trigger data', async () => {
const triggerBlock: SerializedBlock = {
id: 'complex-trigger-1',
metadata: { id: 'webhook', name: 'Complex Webhook', category: 'triggers' },
position: { x: 0, y: 0 },
config: { tool: 'webhook', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
const complexInputs = {
webhook: {
data: {
provider: 'github',
payload: {
action: 'opened',
pull_request: {
id: 123,
title: 'Fix bug in authentication',
user: { login: 'developer' },
base: { ref: 'main' },
head: { ref: 'fix-auth-bug' },
},
},
headers: { 'x-github-event': 'pull_request' },
},
},
timestamp: '2023-01-01T15:45:00Z',
}
const result = await handler.execute(mockContext, triggerBlock, complexInputs)
expect(result).toEqual(complexInputs)
})
})
describe('integration scenarios', () => {
it.concurrent('should work with different trigger block types', () => {
const testCases = [
{
name: 'Gmail in trigger mode',
block: {
id: 'gmail-trigger',
metadata: { id: 'gmail', category: 'tools' },
config: { tool: 'gmail', params: { triggerMode: true } },
},
shouldHandle: true,
},
{
name: 'Generic webhook',
block: {
id: 'webhook-trigger',
metadata: { id: 'generic_webhook', category: 'triggers' },
config: { tool: 'generic_webhook', params: {} },
},
shouldHandle: true,
},
{
name: 'Schedule block',
block: {
id: 'schedule-trigger',
metadata: { id: 'schedule', category: 'triggers' },
config: { tool: 'schedule', params: {} },
},
shouldHandle: true,
},
{
name: 'Regular API block',
block: {
id: 'api-block',
metadata: { id: 'api', category: 'tools' },
config: { tool: 'api', params: {} },
},
shouldHandle: false,
},
{
name: 'Gmail in tool mode',
block: {
id: 'gmail-tool',
metadata: { id: 'gmail', category: 'tools' },
config: { tool: 'gmail', params: { triggerMode: false } },
},
shouldHandle: false,
},
]
testCases.forEach(({ name, block, shouldHandle }) => {
const serializedBlock: SerializedBlock = {
...block,
position: { x: 0, y: 0 },
inputs: {},
outputs: {},
enabled: true,
} as SerializedBlock
expect(
handler.canHandle(serializedBlock),
`${name} should ${shouldHandle ? '' : 'not '}be handled`
).toBe(shouldHandle)
})
})
})
})
@@ -0,0 +1,78 @@
import { createLogger } from '@sim/logger'
import { BlockType, isTriggerBehavior, isTriggerInternalKey } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('TriggerBlockHandler')
export class TriggerBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return isTriggerBehavior(block)
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<any> {
logger.info(`Executing trigger block: ${block.id} (Type: ${block.metadata?.id})`)
if (block.metadata?.id === BlockType.STARTER) {
return this.executeStarterBlock(ctx, block, inputs)
}
const existingState = ctx.blockStates.get(block.id)
if (existingState?.output) {
return existingState.output
}
const starterBlock = ctx.workflow?.blocks?.find((b) => b.metadata?.id === 'starter')
if (starterBlock) {
const starterState = ctx.blockStates.get(starterBlock.id)
if (starterState?.output && Object.keys(starterState.output).length > 0) {
const starterOutput = starterState.output
if (starterOutput.webhook?.data) {
const cleanOutput: Record<string, unknown> = {}
for (const [key, value] of Object.entries(starterOutput)) {
if (!isTriggerInternalKey(key)) {
cleanOutput[key] = value
}
}
return cleanOutput
}
return starterOutput
}
}
if (inputs && Object.keys(inputs).length > 0) {
return inputs
}
return {}
}
private executeStarterBlock(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): any {
logger.info(`Executing starter block: ${block.id}`, {
blockName: block.metadata?.name,
})
const existingState = ctx.blockStates.get(block.id)
if (existingState?.output && Object.keys(existingState.output).length > 0) {
return existingState.output
}
logger.warn('Starter block output not found in context, returning empty output', {
blockId: block.id,
})
return {
input: inputs.input || '',
}
}
}
@@ -0,0 +1,296 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { BlockType } from '@/executor/constants'
import { VariablesBlockHandler } from '@/executor/handlers/variables/variables-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const { mockUploadFile } = vi.hoisted(() => ({
mockUploadFile: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
uploadFile: mockUploadFile,
},
}))
function createContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
workflowVariables: {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: [] },
},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
...overrides,
}
}
function createBlock(): SerializedBlock {
return {
id: 'variables-block-1',
metadata: { id: BlockType.VARIABLES, name: 'Variables' },
position: { x: 0, y: 0 },
config: { tool: BlockType.VARIABLES, params: {} },
inputs: {},
outputs: {},
enabled: true,
}
}
describe('VariablesBlockHandler', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
})
it('preserves small assignments inline', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext()
const value = [{ key: 'SIM-1', summary: 'Small issue' }]
const output = await handler.execute(ctx, createBlock(), {
variables: [
{
variableId: 'var-1',
variableName: 'issues',
type: 'array',
value,
},
],
})
expect(ctx.workflowVariables?.['var-1'].value).toEqual(value)
expect(output).toEqual({ issues: value })
expect(mockUploadFile).not.toHaveBeenCalled()
})
it('includes unmatched assignments in block output without mutating workflow variables', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext()
const value = [{ key: 'SIM-1', summary: 'Transient issue' }]
const output = await handler.execute(ctx, createBlock(), {
variables: [
{
variableName: 'transientIssues',
type: 'array',
value,
},
],
})
expect(ctx.workflowVariables).not.toHaveProperty('transientIssues')
expect(output).toEqual({ transientIssues: value })
})
it('keeps special unmatched assignment names as own output fields', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext()
const value = { polluted: true }
const output = await handler.execute(ctx, createBlock(), {
variables: [
{
variableName: '__proto__',
type: 'object',
value,
},
],
})
expect(Object.hasOwn(output, '__proto__')).toBe(true)
expect(output.__proto__).toEqual(value)
expect(Object.getPrototypeOf(output)).toBe(Object.prototype)
})
it('does not treat inherited prototype keys as existing workflow variable IDs', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext()
const value = { safe: true }
const originalPrototype = Object.getPrototypeOf(ctx.workflowVariables)
const output = await handler.execute(ctx, createBlock(), {
variables: [
{
variableId: '__proto__',
variableName: 'prototypeAssignment',
type: 'object',
value,
},
],
})
expect(Object.getPrototypeOf(ctx.workflowVariables)).toBe(originalPrototype)
expect(ctx.workflowVariables).not.toHaveProperty('__proto__')
expect(output).toEqual({ prototypeAssignment: value })
})
it('stores oversized array assignments as durable manifests in variables and block output', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext()
const value = Array.from({ length: 120_000 }, (_, index) => ({
key: `SIM-${index}`,
summary: 'Issue summary that keeps each item small',
}))
const output = await handler.execute(ctx, createBlock(), {
variables: [
{
variableId: 'var-1',
variableName: 'issues',
type: 'array',
value,
},
],
})
const storedValue = ctx.workflowVariables?.['var-1'].value
expect(isLargeArrayManifest(storedValue)).toBe(true)
expect(output.issues).toBe(storedValue)
expect(storedValue).toMatchObject({
__simLargeArrayManifest: true,
kind: 'array',
totalCount: value.length,
})
expect(storedValue.chunkCount).toBeGreaterThan(1)
expect(mockUploadFile).toHaveBeenCalledWith(
expect.objectContaining({
context: 'execution',
preserveKey: true,
customKey: expect.stringContaining('/execution-1/large-value-'),
})
)
})
it('fails clearly when durable context is missing for oversized assignments', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext({ workspaceId: undefined, executionId: undefined })
const value = Array.from({ length: 120_000 }, (_, index) => ({
key: `SIM-${index}`,
summary: 'Issue summary that keeps each item small',
}))
await expect(
handler.execute(ctx, createBlock(), {
variables: [
{
variableId: 'var-1',
variableName: 'issues',
type: 'array',
value,
},
],
})
).rejects.toThrow(
'Cannot persist large execution value without workspace, workflow, and execution IDs'
)
expect(mockUploadFile).not.toHaveBeenCalled()
})
it('preserves whole large refs before scalar type coercion', async () => {
const handler = new VariablesBlockHandler()
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 12 * 1024 * 1024,
executionId: 'execution-1',
}
const ctx = createContext({
workflowVariables: {
stringVar: { id: 'stringVar', name: 'stringRef', type: 'string', value: '' },
plainVar: { id: 'plainVar', name: 'plainRef', type: 'plain', value: '' },
numberVar: { id: 'numberVar', name: 'numberRef', type: 'number', value: 0 },
booleanVar: { id: 'booleanVar', name: 'booleanRef', type: 'boolean', value: false },
},
})
await handler.execute(ctx, createBlock(), {
variables: [
{
variableId: 'stringVar',
variableName: 'stringRef',
type: 'string',
value: JSON.stringify(ref),
},
{
variableId: 'plainVar',
variableName: 'plainRef',
type: 'plain',
value: JSON.stringify(ref),
},
{
variableId: 'numberVar',
variableName: 'numberRef',
type: 'number',
value: JSON.stringify(ref),
},
{
variableId: 'booleanVar',
variableName: 'booleanRef',
type: 'boolean',
value: JSON.stringify(ref),
},
],
})
expect(ctx.workflowVariables?.stringVar.value).toEqual(ref)
expect(ctx.workflowVariables?.plainVar.value).toEqual(ref)
expect(ctx.workflowVariables?.numberVar.value).toEqual(ref)
expect(ctx.workflowVariables?.booleanVar.value).toEqual(ref)
})
it('preserves existing variable metadata when compacting reassignment', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext({
workflowVariables: {
'var-1': {
id: 'var-1',
name: 'issues',
type: 'array',
value: [],
isExisting: true,
},
},
})
const value = [{ key: 'SIM-1', summary: 'Updated' }]
await handler.execute(ctx, createBlock(), {
variables: [
{
variableId: 'var-1',
variableName: 'issues',
type: 'array',
value,
},
],
})
expect(ctx.workflowVariables?.['var-1']).toEqual({
id: 'var-1',
name: 'issues',
type: 'array',
value,
isExisting: true,
})
})
})
@@ -0,0 +1,207 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { parseLargeExecutionValue } from '@/lib/execution/payloads/large-execution-value'
import { compactWorkflowVariableValue } from '@/lib/execution/payloads/serializer'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('VariablesBlockHandler')
function setOutputValue(output: Record<string, any>, key: string, value: any): void {
Object.defineProperty(output, key, {
value,
enumerable: true,
configurable: true,
writable: true,
})
}
function getWorkflowVariableEntry(
workflowVariables: Record<string, any>,
variableId: string | undefined
): [string, any] | undefined {
if (!variableId || !Object.hasOwn(workflowVariables, variableId)) {
return undefined
}
return [variableId, workflowVariables[variableId]]
}
function setWorkflowVariableEntry(
workflowVariables: Record<string, any>,
id: string,
value: any
): void {
Object.defineProperty(workflowVariables, id, {
value,
enumerable: true,
configurable: true,
writable: true,
})
}
export class VariablesBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
const canHandle = block.metadata?.id === BlockType.VARIABLES
return canHandle
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
try {
if (!ctx.workflowVariables) {
ctx.workflowVariables = {}
}
const assignments = this.parseAssignments(inputs.variables)
const output: Record<string, any> = {}
for (const assignment of assignments) {
const existingEntry =
getWorkflowVariableEntry(ctx.workflowVariables, assignment.variableId) ??
Object.entries(ctx.workflowVariables).find(([_, v]) => v.name === assignment.variableName)
const value = await this.compactAssignmentValue(ctx, assignment.value)
if (existingEntry?.[1]) {
const [id, variable] = existingEntry
setWorkflowVariableEntry(ctx.workflowVariables, id, {
...variable,
value,
})
} else {
logger.warn(`Variable "${assignment.variableName}" not found in workflow variables`)
}
setOutputValue(output, assignment.variableName, value)
}
return output
} catch (error) {
const normalizedError = toError(error)
logger.error('Variables block execution failed:', normalizedError)
throw new Error(`Variables block execution failed: ${normalizedError.message}`)
}
}
private async compactAssignmentValue(ctx: ExecutionContext, value: any): Promise<any> {
return compactWorkflowVariableValue(value, {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
userId: ctx.userId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
})
}
private parseAssignments(
assignmentsInput: any
): Array<{ variableId?: string; variableName: string; type: string; value: any }> {
const result: Array<{ variableId?: string; variableName: string; type: string; value: any }> =
[]
if (!assignmentsInput || !Array.isArray(assignmentsInput)) {
return result
}
for (const assignment of assignmentsInput) {
if (assignment?.variableName?.trim()) {
const name = assignment.variableName.trim()
const type = assignment.type || 'string'
const value = this.parseValueByType(assignment.value, type, name)
result.push({
variableId: assignment.variableId,
variableName: name,
type,
value,
})
}
}
return result
}
private parseValueByType(value: any, type: string, variableName?: string): any {
const refValue = parseLargeExecutionValue(value)
if (refValue !== undefined) {
return refValue
}
if (value === null || value === undefined || value === '') {
if (type === 'number') return 0
if (type === 'boolean') return false
if (type === 'array') return []
if (type === 'object') return {}
return ''
}
if (type === 'string' || type === 'plain') {
return typeof value === 'string' ? value : String(value)
}
if (type === 'number') {
if (typeof value === 'number') return value
if (typeof value === 'string') {
const trimmed = value.trim()
if (trimmed === '') return 0
const num = Number(trimmed)
if (Number.isNaN(num)) {
throw new Error(
`Invalid number value for variable "${variableName || 'unknown'}": "${value}". Expected a valid number.`
)
}
return num
}
throw new Error(
`Invalid type for variable "${variableName || 'unknown'}": expected number, got ${typeof value}`
)
}
if (type === 'boolean') {
if (typeof value === 'boolean') return value
if (typeof value === 'string') {
const lower = value.toLowerCase().trim()
if (lower === 'true') return true
if (lower === 'false') return false
throw new Error(
`Invalid boolean value for variable "${variableName || 'unknown'}": "${value}". Expected "true" or "false".`
)
}
return Boolean(value)
}
if (type === 'object' || type === 'array') {
// If value is already an object or array, accept it as-is
// The type hint is for UI purposes and string parsing, not runtime validation
if (typeof value === 'object' && value !== null) {
return value
}
// If it's a string, try to parse it as JSON
if (typeof value === 'string' && value.trim()) {
try {
const parsed = JSON.parse(value)
// Accept any valid JSON object or array
if (typeof parsed === 'object' && parsed !== null) {
return parsed
}
throw new Error(
`Invalid JSON for variable "${variableName || 'unknown'}": parsed value is not an object or array`
)
} catch (error: any) {
throw new Error(
`Invalid JSON for variable "${variableName || 'unknown'}": ${error.message}`
)
}
}
return type === 'array' ? [] : {}
}
return value
}
}
@@ -0,0 +1,331 @@
/**
* @vitest-environment node
*/
import '@sim/testing/mocks/executor'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { WaitBlockHandler } from '@/executor/handlers/wait/wait-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
describe('WaitBlockHandler', () => {
let handler: WaitBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
beforeEach(() => {
vi.useFakeTimers()
handler = new WaitBlockHandler()
mockBlock = {
id: 'wait-block-1',
metadata: { id: BlockType.WAIT, name: 'Test Wait' },
position: { x: 50, y: 50 },
config: { tool: BlockType.WAIT, params: {} },
inputs: { timeValue: 'string', timeUnit: 'string' },
outputs: {},
enabled: true,
}
mockContext = {
workflowId: 'test-workflow-id',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
completedLoops: new Set(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
}
})
afterEach(() => {
vi.useRealTimers()
})
it('should handle wait blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
const nonWaitBlock: SerializedBlock = { ...mockBlock, metadata: { id: 'other' } }
expect(handler.canHandle(nonWaitBlock)).toBe(false)
})
it('should wait in-process for short waits in seconds', async () => {
const inputs = { timeValue: '5', timeUnit: 'seconds' }
const executePromise = handler.execute(mockContext, mockBlock, inputs)
await vi.advanceTimersByTimeAsync(5000)
const result = await executePromise
expect(result).toEqual({
waitDuration: 5000,
status: 'completed',
})
})
it('should wait in-process for short waits in minutes', async () => {
const inputs = { timeValue: '2', timeUnit: 'minutes' }
const executePromise = handler.execute(mockContext, mockBlock, inputs)
await vi.advanceTimersByTimeAsync(120_000)
const result = await executePromise
expect(result).toEqual({
waitDuration: 120_000,
status: 'completed',
})
})
it('should default to 10 seconds when inputs are not provided', async () => {
const executePromise = handler.execute(mockContext, mockBlock, {})
await vi.advanceTimersByTimeAsync(10_000)
const result = await executePromise
expect(result).toEqual({
waitDuration: 10_000,
status: 'completed',
})
})
it('should reject negative wait amounts', async () => {
await expect(
handler.execute(mockContext, mockBlock, { timeValue: '-5', timeUnit: 'seconds' })
).rejects.toThrow('Wait amount must be a positive number')
})
it('should reject zero wait amounts', async () => {
await expect(
handler.execute(mockContext, mockBlock, { timeValue: '0', timeUnit: 'seconds' })
).rejects.toThrow('Wait amount must be a positive number')
})
it('should reject non-numeric wait amounts', async () => {
await expect(
handler.execute(mockContext, mockBlock, { timeValue: 'abc', timeUnit: 'seconds' })
).rejects.toThrow('Wait amount must be a positive number')
})
it('should reject unknown wait units', async () => {
await expect(
handler.execute(mockContext, mockBlock, { timeValue: '5', timeUnit: 'fortnights' })
).rejects.toThrow('Unknown wait unit: fortnights')
})
it('should reject async waits longer than the 30-day ceiling', async () => {
await expect(
handler.execute(mockContext, mockBlock, {
async: true,
timeValue: '31',
timeUnitLong: 'days',
})
).rejects.toThrow('Wait time exceeds maximum of 30 days')
})
it('should reject synchronous waits longer than 5 minutes', async () => {
await expect(
handler.execute(mockContext, mockBlock, { timeValue: '10', timeUnit: 'minutes' })
).rejects.toThrow('Wait time exceeds maximum of 5 minutes')
})
it('should default the async unit to minutes when timeUnitLong is missing', async () => {
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
const result = (await handler.execute(mockContext, mockBlock, {
async: true,
timeValue: '3',
})) as Record<string, any>
const waitMs = 3 * 60 * 1000
expect(result.waitDuration).toBe(waitMs)
expect(result.status).toBe('waiting')
})
it('should reject seconds as a unit in async mode', async () => {
await expect(
handler.execute(mockContext, mockBlock, {
async: true,
timeValue: '30',
timeUnitLong: 'seconds',
})
).rejects.toThrow('Seconds are not allowed in async mode')
})
it('should still execute in-process at the 5-minute boundary', async () => {
const inputs = { timeValue: '5', timeUnit: 'minutes' }
const executePromise = handler.execute(mockContext, mockBlock, inputs)
await vi.advanceTimersByTimeAsync(5 * 60 * 1000)
const result = await executePromise
expect(result).toEqual({
waitDuration: 5 * 60 * 1000,
status: 'completed',
})
})
it('should suspend the workflow when wait exceeds the in-process threshold', async () => {
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
const inputs = { async: true, timeValue: '10', timeUnitLong: 'minutes' }
const result = (await handler.execute(mockContext, mockBlock, inputs)) as Record<string, any>
const waitMs = 10 * 60 * 1000
const expectedResumeAt = new Date(Date.now() + waitMs).toISOString()
expect(result.status).toBe('waiting')
expect(result.waitDuration).toBe(waitMs)
expect(result.resumeAt).toBe(expectedResumeAt)
const pauseMetadata = result._pauseMetadata
expect(pauseMetadata).toBeDefined()
expect(pauseMetadata.pauseKind).toBe('time')
expect(pauseMetadata.resumeAt).toBe(expectedResumeAt)
expect(pauseMetadata.contextId).toBe('wait-block-1')
expect(pauseMetadata.blockId).toBe('wait-block-1')
expect(pauseMetadata.response).toEqual({ waitDuration: waitMs, resumeAt: expectedResumeAt })
})
it('should suspend the workflow for multi-day waits', async () => {
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
const inputs = { async: true, timeValue: '2', timeUnitLong: 'days' }
const result = (await handler.execute(mockContext, mockBlock, inputs)) as Record<string, any>
const waitMs = 2 * 24 * 60 * 60 * 1000
const expectedResumeAt = new Date(Date.now() + waitMs).toISOString()
expect(result.status).toBe('waiting')
expect(result.waitDuration).toBe(waitMs)
expect(result.resumeAt).toBe(expectedResumeAt)
expect(result._pauseMetadata.pauseKind).toBe('time')
expect(result._pauseMetadata.resumeAt).toBe(expectedResumeAt)
})
it('should accept hours and convert to milliseconds', async () => {
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
const result = (await handler.execute(mockContext, mockBlock, {
async: true,
timeValue: '3',
timeUnitLong: 'hours',
})) as Record<string, any>
const waitMs = 3 * 60 * 60 * 1000
expect(result.waitDuration).toBe(waitMs)
expect(result.status).toBe('waiting')
expect(result._pauseMetadata.pauseKind).toBe('time')
})
it('should handle cancellation via AbortSignal', async () => {
const abortController = new AbortController()
mockContext.abortSignal = abortController.signal
const inputs = { timeValue: '30', timeUnit: 'seconds' }
const executePromise = handler.execute(mockContext, mockBlock, inputs)
await vi.advanceTimersByTimeAsync(10000)
abortController.abort()
await vi.advanceTimersByTimeAsync(1)
const result = await executePromise
expect(result).toEqual({
waitDuration: 30000,
status: 'cancelled',
})
})
it('should return cancelled immediately if signal is already aborted', async () => {
const abortController = new AbortController()
abortController.abort()
mockContext.abortSignal = abortController.signal
const inputs = { timeValue: '10', timeUnit: 'seconds' }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(result).toEqual({
waitDuration: 10000,
status: 'cancelled',
})
})
it('should not invoke the in-process sleep when suspending; AbortSignal is irrelevant for long waits', async () => {
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
const abortController = new AbortController()
abortController.abort()
mockContext.abortSignal = abortController.signal
const result = (await handler.execute(mockContext, mockBlock, {
async: true,
timeValue: '1',
timeUnitLong: 'hours',
})) as Record<string, any>
expect(result.status).toBe('waiting')
expect(result._pauseMetadata.pauseKind).toBe('time')
})
it('should preserve fractional time values for larger units', async () => {
const inputs = { timeValue: '5.5', timeUnit: 'seconds' }
const executePromise = handler.execute(mockContext, mockBlock, inputs)
await vi.advanceTimersByTimeAsync(5500)
const result = await executePromise
expect(result).toEqual({
waitDuration: 5500,
status: 'completed',
})
})
it('should suspend a 1.5-day wait without truncating', async () => {
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
const result = (await handler.execute(mockContext, mockBlock, {
async: true,
timeValue: '1.5',
timeUnitLong: 'days',
})) as Record<string, any>
const waitMs = 1.5 * 24 * 60 * 60 * 1000
expect(result.waitDuration).toBe(waitMs)
expect(result.status).toBe('waiting')
expect(result._pauseMetadata.pauseKind).toBe('time')
})
it('should always suspend when async is enabled, even for short waits', async () => {
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
const result = (await handler.execute(mockContext, mockBlock, {
async: true,
timeValue: '2',
timeUnitLong: 'minutes',
})) as Record<string, any>
const waitMs = 2 * 60 * 1000
const expectedResumeAt = new Date(Date.now() + waitMs).toISOString()
expect(result.status).toBe('waiting')
expect(result.waitDuration).toBe(waitMs)
expect(result.resumeAt).toBe(expectedResumeAt)
expect(result._pauseMetadata.pauseKind).toBe('time')
expect(result._pauseMetadata.resumeAt).toBe(expectedResumeAt)
})
})
@@ -0,0 +1,197 @@
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/constants'
import {
generatePauseContextId,
mapNodeMetadataToPauseScopes,
} from '@/executor/human-in-the-loop/utils'
import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const CANCELLATION_CHECK_INTERVAL_MS = 500
/** Hard ceiling for in-process (synchronous) waits. */
const MAX_INPROCESS_WAIT_MS = 5 * 60 * 1000
/** Hard ceiling for async waits. */
const MAX_ASYNC_WAIT_MS = 30 * 24 * 60 * 60 * 1000
interface SleepOptions {
signal?: AbortSignal
executionId?: string
}
const sleep = async (ms: number, options: SleepOptions = {}): Promise<boolean> => {
const { signal, executionId } = options
const useRedis = isRedisCancellationEnabled() && !!executionId
if (signal?.aborted) {
return false
}
return new Promise((resolve) => {
// biome-ignore lint/style/useConst: needs to be declared before cleanup() but assigned later
let mainTimeoutId: NodeJS.Timeout | undefined
let checkIntervalId: NodeJS.Timeout | undefined
let resolved = false
const cleanup = () => {
if (mainTimeoutId) clearTimeout(mainTimeoutId)
if (checkIntervalId) clearInterval(checkIntervalId)
if (signal) signal.removeEventListener('abort', onAbort)
}
const onAbort = () => {
if (resolved) return
resolved = true
cleanup()
resolve(false)
}
if (signal) {
signal.addEventListener('abort', onAbort, { once: true })
}
if (useRedis) {
checkIntervalId = setInterval(async () => {
if (resolved) return
try {
const cancelled = await isExecutionCancelled(executionId!)
if (cancelled) {
resolved = true
cleanup()
resolve(false)
}
} catch {}
}, CANCELLATION_CHECK_INTERVAL_MS)
}
mainTimeoutId = setTimeout(() => {
if (resolved) return
resolved = true
cleanup()
resolve(true)
}, ms)
})
}
const UNIT_TO_MS = {
seconds: 1000,
minutes: 60 * 1000,
hours: 60 * 60 * 1000,
days: 24 * 60 * 60 * 1000,
} as const satisfies Record<string, number>
type WaitUnit = keyof typeof UNIT_TO_MS
function isWaitUnit(value: string): value is WaitUnit {
return value in UNIT_TO_MS
}
/**
* Handler for Wait blocks that pause workflow execution for a time delay.
*
* Default (async=false) waits are held in-process via an interruptible sleep and capped at 5 minutes.
* When async=true is set, the workflow is always suspended by returning {@link PauseMetadata} with
* `pauseKind: 'time'`; the cron-driven resume poller (see `/api/resume/poll`) picks the execution back
* up once `resumeAt` is reached. Async caps at 30 days.
*/
export class WaitBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.WAIT
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
return this.executeWithNode(ctx, block, inputs, { nodeId: block.id })
}
async executeWithNode(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>,
nodeMetadata: {
nodeId: string
loopId?: string
parallelId?: string
branchIndex?: number
branchTotal?: number
originalBlockId?: string
isLoopNode?: boolean
executionOrder?: number
}
): Promise<BlockOutput> {
const isAsync = inputs.async === true || inputs.async === 'true'
const timeValue = Number.parseFloat(inputs.timeValue || '10')
const timeUnit = isAsync ? inputs.timeUnitLong || 'minutes' : inputs.timeUnit || 'seconds'
if (!Number.isFinite(timeValue) || timeValue <= 0) {
throw new Error('Wait amount must be a positive number')
}
if (!isWaitUnit(timeUnit)) {
throw new Error(`Unknown wait unit: ${timeUnit}`)
}
if (isAsync && timeUnit === 'seconds') {
throw new Error('Seconds are not allowed in async mode')
}
const waitMs = Math.round(timeValue * UNIT_TO_MS[timeUnit])
if (isAsync) {
if (waitMs > MAX_ASYNC_WAIT_MS) {
throw new Error('Wait time exceeds maximum of 30 days')
}
} else if (waitMs > MAX_INPROCESS_WAIT_MS) {
throw new Error(
'Wait time exceeds maximum of 5 minutes; enable async mode to wait up to 30 days'
)
}
if (!isAsync) {
const completed = await sleep(waitMs, {
signal: ctx.abortSignal,
executionId: ctx.executionId,
})
if (!completed) {
return {
waitDuration: waitMs,
status: 'cancelled',
}
}
return {
waitDuration: waitMs,
status: 'completed',
}
}
const { parallelScope, loopScope } = mapNodeMetadataToPauseScopes(ctx, nodeMetadata)
const contextId = generatePauseContextId(block.id, nodeMetadata, loopScope)
const now = new Date()
const resumeAt = new Date(now.getTime() + waitMs).toISOString()
const pauseMetadata: PauseMetadata = {
contextId,
blockId: nodeMetadata.nodeId,
response: { waitDuration: waitMs, resumeAt },
timestamp: now.toISOString(),
parallelScope,
loopScope,
pauseKind: 'time',
resumeAt,
}
return {
waitDuration: waitMs,
status: 'waiting',
resumeAt,
_pauseMetadata: pauseMetadata,
}
}
}
@@ -0,0 +1,499 @@
import { setupGlobalFetchMock } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import {
findMissingRequiredCustomBlockInputs,
remapCustomBlockInputKeys,
WorkflowBlockHandler,
} from '@/executor/handlers/workflow/workflow-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const { mockExecutorExecute, mockCreateSnapshot } = vi.hoisted(() => ({
mockExecutorExecute: vi.fn(),
mockCreateSnapshot: vi.fn(),
}))
vi.mock('@/executor', () => ({
Executor: class {
execute = mockExecutorExecute
},
}))
vi.mock('@/lib/logs/execution/snapshot/service', () => ({
snapshotService: { createSnapshotWithDeduplication: mockCreateSnapshot },
}))
vi.mock('@/lib/auth/internal', () => ({
generateInternalToken: vi.fn().mockResolvedValue('test-token'),
}))
vi.mock('@/executor/utils/http', () => ({
buildAuthHeaders: vi.fn().mockResolvedValue({ 'Content-Type': 'application/json' }),
buildAPIUrl: vi.fn((path: string) => new URL(path, 'http://localhost:3000')),
extractAPIErrorMessage: vi.fn(async (response: Response) => {
const defaultMessage = `API request failed with status ${response.status}`
try {
const errorData = await response.json()
return errorData.error || defaultMessage
} catch {
return defaultMessage
}
}),
}))
// Mock fetch globally
setupGlobalFetchMock()
describe('WorkflowBlockHandler', () => {
let handler: WorkflowBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let mockFetch: Mock
beforeEach(() => {
// Mock window.location.origin for getBaseUrl()
;(global as any).window = {
location: {
origin: 'http://localhost:3000',
},
}
handler = new WorkflowBlockHandler()
mockFetch = global.fetch as Mock
mockBlock = {
id: 'workflow-block-1',
metadata: { id: BlockType.WORKFLOW, name: 'Test Workflow Block' },
position: { x: 0, y: 0 },
config: { tool: BlockType.WORKFLOW, params: {} },
inputs: { workflowId: 'string' },
outputs: {},
enabled: true,
}
mockContext = {
workflowId: 'parent-workflow-id',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
workflow: {
version: '1.0',
blocks: [],
connections: [],
loops: {},
},
}
// Reset all mocks
vi.clearAllMocks()
// Setup default fetch mock
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: {
name: 'Child Workflow',
state: {
blocks: [
{
id: 'starter',
metadata: { id: BlockType.STARTER, name: 'Starter' },
position: { x: 0, y: 0 },
config: { tool: BlockType.STARTER, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
],
edges: [],
loops: {},
parallels: {},
},
},
}),
})
})
describe('canHandle', () => {
it('should handle workflow blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
})
it('should not handle non-workflow blocks', () => {
const nonWorkflowBlock = { ...mockBlock, metadata: { id: BlockType.FUNCTION } }
expect(handler.canHandle(nonWorkflowBlock)).toBe(false)
})
})
describe('execute', () => {
it('should throw error when no workflowId is provided', async () => {
const inputs = {}
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'No workflow selected for execution'
)
})
it('should enforce maximum call chain depth limit', async () => {
const inputs = { workflowId: 'child-workflow-id' }
const deepContext = {
...mockContext,
callChain: Array.from({ length: 25 }, (_, i) => `wf-${i}`),
}
await expect(handler.execute(deepContext, mockBlock, inputs)).rejects.toThrow(
'Maximum workflow call chain depth (25) exceeded'
)
})
it('should handle child workflow not found', async () => {
const inputs = { workflowId: 'non-existent-workflow' }
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
text: () => Promise.resolve(''),
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'"non-existent-workflow" failed: Child workflow non-existent-workflow not found'
)
})
it('should handle fetch errors gracefully', async () => {
const inputs = { workflowId: 'child-workflow-id' }
mockFetch.mockRejectedValueOnce(new Error('Network error'))
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'"child-workflow-id" failed: Network error'
)
})
})
describe('workspace containment', () => {
const inputs = { workflowId: 'child-workflow-id' }
it('should fail a cross-workspace child in the draft loader path', async () => {
const ctx = { ...mockContext, workspaceId: 'workspace-parent' }
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: {
name: 'Foreign Workflow',
workspaceId: 'workspace-other',
state: { blocks: {}, edges: [], loops: {}, parallels: {} },
},
}),
})
await expect(handler.execute(ctx, mockBlock, inputs)).rejects.toThrow(
'Child workflow child-workflow-id belongs to a different workspace and cannot be executed'
)
expect(mockCreateSnapshot).not.toHaveBeenCalled()
expect(mockExecutorExecute).not.toHaveBeenCalled()
})
it('should fail a cross-workspace child in the deployed loader path', async () => {
const ctx = {
...mockContext,
workspaceId: 'workspace-parent',
isDeployedContext: true,
}
mockFetch.mockImplementation(async (url: unknown) => {
if (String(url).includes('/deployed')) {
return {
ok: true,
json: () =>
Promise.resolve({
data: {
deployedState: { blocks: {}, edges: [], loops: {}, parallels: {} },
},
}),
}
}
return {
ok: true,
json: () =>
Promise.resolve({
data: {
name: 'Foreign Workflow',
workspaceId: 'workspace-other',
variables: {},
},
}),
}
})
await expect(handler.execute(ctx, mockBlock, inputs)).rejects.toThrow(
'Child workflow child-workflow-id belongs to a different workspace and cannot be executed'
)
expect(mockCreateSnapshot).not.toHaveBeenCalled()
expect(mockExecutorExecute).not.toHaveBeenCalled()
})
it('should execute a same-workspace child as before', async () => {
const ctx = { ...mockContext, workspaceId: 'workspace-parent' }
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: {
name: 'Child Workflow',
workspaceId: 'workspace-parent',
state: { blocks: {}, edges: [], loops: {}, parallels: {} },
},
}),
})
mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } })
mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } })
const result = await handler.execute(ctx, mockBlock, inputs)
expect(result).toMatchObject({
success: true,
childWorkflowId: 'child-workflow-id',
childWorkflowName: 'Child Workflow',
childWorkflowSnapshotId: 'snapshot-1',
result: { data: 'ok' },
})
expect(mockExecutorExecute).toHaveBeenCalledWith('child-workflow-id')
})
it('should fail closed when the executing context has no workspace', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: {
name: 'Child Workflow',
workspaceId: 'workspace-parent',
state: { blocks: {}, edges: [], loops: {}, parallels: {} },
},
}),
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Cannot execute child workflow child-workflow-id: executing context has no workspace'
)
expect(mockExecutorExecute).not.toHaveBeenCalled()
})
})
describe('loadChildWorkflow', () => {
it('should return null for 404 responses', async () => {
const workflowId = 'non-existent-workflow'
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
text: () => Promise.resolve(''),
})
const result = await (handler as any).loadChildWorkflow(workflowId)
expect(result).toBeNull()
})
it('should handle invalid workflow state', async () => {
const workflowId = 'invalid-workflow'
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: {
name: 'Invalid Workflow',
state: null, // Invalid state
},
}),
})
await expect((handler as any).loadChildWorkflow(workflowId)).rejects.toThrow(
'Child workflow invalid-workflow has invalid state'
)
})
})
describe('mapChildOutputToParent', () => {
it('should map successful child output correctly', () => {
const childResult = {
success: true,
output: { data: 'test result' },
}
const result = (handler as any).mapChildOutputToParent(
childResult,
'child-id',
'Child Workflow',
100
)
expect(result).toEqual({
success: true,
childWorkflowId: 'child-id',
childWorkflowName: 'Child Workflow',
result: { data: 'test result' },
childTraceSpans: [],
})
})
it('should throw error for failed child output so BlockExecutor can check error port', () => {
const childResult = {
success: false,
error: 'Child workflow failed',
}
expect(() =>
(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
).toThrow('"Child Workflow" failed: Child workflow failed')
try {
;(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
} catch (error: any) {
expect(error.childTraceSpans).toEqual([])
}
})
it('should handle nested response structures', () => {
const childResult = {
output: { nested: 'data' },
}
const result = (handler as any).mapChildOutputToParent(
childResult,
'child-id',
'Child Workflow',
100
)
expect(result).toEqual({
success: true,
childWorkflowId: 'child-id',
childWorkflowName: 'Child Workflow',
result: { nested: 'data' },
childTraceSpans: [],
})
})
})
})
describe('remapCustomBlockInputKeys', () => {
const childBlocks = {
start: {
type: 'start_trigger',
subBlocks: {
inputFormat: {
value: [
{ id: 'f1', name: 'firstName', type: 'string' },
{ id: 'f2', name: 'payload', type: 'object' },
],
},
},
},
}
it('maps field ids to current names and drops keys with no matching field', () => {
const out = remapCustomBlockInputKeys(
{ f1: 'Theodore', removed: 'stale' },
childBlocks as Record<string, unknown>
)
expect(out).toEqual({ firstName: 'Theodore' })
expect('removed' in out).toBe(false)
})
it('decodes an object/array input from its JSON-string value (no double-encoding)', () => {
const out = remapCustomBlockInputKeys(
{ f1: 'Theodore', f2: '"hello"' },
childBlocks as Record<string, unknown>
)
expect(out).toEqual({ firstName: 'Theodore', payload: 'hello' })
})
it('parses a real object value and leaves invalid JSON as a raw string', () => {
expect(
remapCustomBlockInputKeys({ f2: '{"a":1}' }, childBlocks as Record<string, unknown>)
).toEqual({ payload: { a: 1 } })
expect(
remapCustomBlockInputKeys({ f2: 'not json' }, childBlocks as Record<string, unknown>)
).toEqual({ payload: 'not json' })
})
})
describe('findMissingRequiredCustomBlockInputs', () => {
const childBlocks = {
start: {
type: 'start_trigger',
subBlocks: {
inputFormat: {
value: [
{ id: 'f1', name: 'firstName', type: 'string' },
{ id: 'f2', name: 'payload', type: 'object' },
{ name: 'legacyField', type: 'string' },
],
},
},
},
} as Record<string, unknown>
it('flags a required field left empty and reports its display name', () => {
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, {})).toEqual(['firstName'])
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: '' })).toEqual([
'firstName',
])
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: null })).toEqual([
'firstName',
])
})
it('passes when the required field has a value', () => {
expect(
findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 'Theodore' })
).toEqual([])
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 0 })).toEqual([])
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: false })).toEqual(
[]
)
})
it('ignores a stale required override whose field was removed from the Start', () => {
expect(findMissingRequiredCustomBlockInputs(['removed-field'], childBlocks, {})).toEqual([])
})
it('treats fields without an override as optional', () => {
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 'x' })).toEqual(
[]
)
expect(findMissingRequiredCustomBlockInputs([], childBlocks, {})).toEqual([])
})
it('keys legacy fields without a stable id by name', () => {
expect(findMissingRequiredCustomBlockInputs(['legacyField'], childBlocks, {})).toEqual([
'legacyField',
])
expect(
findMissingRequiredCustomBlockInputs(['legacyField'], childBlocks, { legacyField: 'v' })
).toEqual([])
})
it('reports every missing required field at once', () => {
expect(findMissingRequiredCustomBlockInputs(['f1', 'f2'], childBlocks, {})).toEqual([
'firstName',
'payload',
])
})
})
@@ -0,0 +1,920 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { buildNextCallChain, validateCallChain } from '@/lib/execution/call-chain'
import { calculateCostSummary } from '@/lib/logs/execution/logging-factory'
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import type { TraceSpan } from '@/lib/logs/types'
import { getCustomBlockAuthority } from '@/lib/workflows/custom-blocks/operations'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import { type CustomBlockOutput, isCustomBlockType } from '@/blocks/custom/build-config'
import type { BlockOutput } from '@/blocks/types'
import { Executor } from '@/executor'
import { BlockType, DEFAULTS, HTTP } from '@/executor/constants'
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
import type { WorkflowNodeMetadata } from '@/executor/execution/types'
import type {
BlockHandler,
ExecutionContext,
ExecutionResult,
StreamingExecution,
} from '@/executor/types'
import { hasExecutionResult } from '@/executor/utils/errors'
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
import { getIterationContext } from '@/executor/utils/iteration-context'
import { parseJSON } from '@/executor/utils/json'
import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup'
import { Serializer } from '@/serializer'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('WorkflowBlockHandler')
/** Read a dot-path (e.g. `content.text`) out of a block output object. */
function getValueAtPath(source: unknown, path: string): unknown {
return path.split('.').reduce<unknown>((acc, key) => {
if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[key]
return undefined
}, source)
}
/**
* Remap a custom block's resolved input mapping from source-field ids to the
* child workflow's current field names. The consumer's sub-block values are keyed
* by the stable field id (so renames don't cook them); the child is addressed by
* name. Legacy fields without an id are keyed by name and pass through unchanged.
* Keys that match no current field are dropped.
*/
/**
* A consumer left a publisher-required custom block input empty. The message is
* consumer-safe (it names only the block's own input labels), so the catch's
* custom-block sanitizer rethrows it verbatim instead of the generic failure.
*/
export class CustomBlockMissingInputsError extends Error {
constructor(message: string) {
super(message)
this.name = 'CustomBlockMissingInputsError'
}
}
/**
* Names of publisher-required custom block inputs the consumer left empty, checked
* against the child's LIVE deployed Start fields — a required override whose field
* was removed is inert, and a field added after publish has no override, so schema
* drift can never block a run. `childWorkflowInput` is the post-remap mapping
* (keyed by field name). Same empty semantics as the serializer's required check.
*/
export function findMissingRequiredCustomBlockInputs(
requiredInputIds: string[],
childBlocks: Record<string, unknown>,
childWorkflowInput: Record<string, unknown>
): string[] {
if (requiredInputIds.length === 0) return []
const requiredIds = new Set(requiredInputIds)
return extractInputFieldsFromBlocks(childBlocks)
.filter((field) => requiredIds.has(field.id ?? field.name))
.filter((field) => {
const value = childWorkflowInput[field.name]
return value === undefined || value === null || value === ''
})
.map((field) => field.name)
}
export function remapCustomBlockInputKeys(
mapping: Record<string, unknown>,
childBlocks: Record<string, unknown>
): Record<string, unknown> {
const fields = extractInputFieldsFromBlocks(childBlocks)
const remapped: Record<string, unknown> = {}
for (const field of fields) {
const key =
field.id && field.id in mapping ? field.id : field.name in mapping ? field.name : null
if (key === null) continue
let value = mapping[key]
// object/array inputs are authored in a JSON code editor, so their value is a
// JSON *string*. Decode it against the child's real Start field type so the
// child receives the actual object/array (or primitive) — not the string
// re-encoded by the mapping's `JSON.stringify` (`"Theodore"` → `\"Theodore\"`).
if ((field.type === 'object' || field.type === 'array') && typeof value === 'string') {
try {
value = JSON.parse(value)
} catch {
// Not valid JSON — pass the raw string through unchanged.
}
}
remapped[field.name] = value
}
return remapped
}
/**
* Canonical hosted-key spend of a child run: the model/tool cost the way the
* parent bills it (recursing nested/iteration spans and de-duping model
* breakdowns), minus the base execution charge the parent applies once itself.
* A naive top-level `cost.total` sum undercounts when spend sits on nested children.
*/
function aggregateChildCost(childTraceSpans: TraceSpan[]): number {
if (childTraceSpans.length === 0) return 0
const summary = calculateCostSummary(childTraceSpans)
return Math.max(0, summary.totalCost - summary.baseExecutionCharge)
}
/**
* A single cost-only span so a FAILED custom block still bills the hosted-key spend
* its child already consumed (`block-executor` bills `error.childTraceSpans`),
* without exposing any of the source workflow's internal spans. Empty when free.
*/
function buildCostCarrierSpans(childCost: number, blockName: string, type: string): TraceSpan[] {
if (childCost <= 0) return []
const now = new Date().toISOString()
return [
{
id: generateId(),
name: blockName,
type,
duration: 0,
startTime: now,
endTime: now,
status: 'error',
cost: { total: childCost },
},
]
}
type WorkflowTraceSpan = TraceSpan & {
metadata?: Record<string, unknown>
children?: WorkflowTraceSpan[]
output?: (Record<string, unknown> & { childTraceSpans?: WorkflowTraceSpan[] }) | null
}
/**
* Handler for workflow blocks that execute other workflows inline.
* Creates sub-execution contexts and manages data flow between parent and child workflows.
*/
export class WorkflowBlockHandler implements BlockHandler {
private serializer = new Serializer()
canHandle(block: SerializedBlock): boolean {
const id = block.metadata?.id
return id === BlockType.WORKFLOW || id === BlockType.WORKFLOW_INPUT || isCustomBlockType(id)
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput | StreamingExecution> {
return this.executeCore(ctx, block, inputs)
}
async executeWithNode(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>,
nodeMetadata: WorkflowNodeMetadata
): Promise<BlockOutput | StreamingExecution> {
return this.executeCore(ctx, block, inputs, nodeMetadata)
}
private async executeCore(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>,
nodeMetadata?: WorkflowNodeMetadata
): Promise<BlockOutput | StreamingExecution> {
logger.info(`Executing workflow block: ${block.id}`)
const blockTypeId = block.metadata?.id
const isCustomBlock = isCustomBlockType(blockTypeId)
// Custom (deploy-as-block) blocks are an invocation boundary: resolve the bound
// workflow + authority from the DB (never trust the serialized value) and run the
// source workflow's LATEST deployment under its OWNER's authority — the same
// identity a normal deployed API/schedule/webhook run uses — so a cross-workspace
// consumer needs no permission on the source workflow. Owner deletion cascade-
// deletes the workflow → the custom_block row, so the block never orphans.
let workflowId = inputs.workflowId
let loadUserId = ctx.userId
let exposedOutputs: CustomBlockOutput[] = []
let requiredInputIds: string[] = []
if (isCustomBlock) {
const authority = await getCustomBlockAuthority(blockTypeId as string, ctx.workspaceId)
if (!authority) {
throw new Error('This custom block is no longer available')
}
workflowId = authority.workflowId
loadUserId = authority.ownerUserId
exposedOutputs = authority.exposedOutputs
requiredInputIds = authority.requiredInputIds
}
if (!workflowId) {
throw new Error('No workflow selected for execution')
}
// Always run the latest deployment for custom blocks, even from a draft-context parent run.
const useDeployed = isCustomBlock || ctx.isDeployedContext
let childWorkflowName = workflowId
// Unique ID per invocation — used to correlate child block events with this specific
// workflow block execution, preventing cross-iteration child mixing in loop contexts.
const instanceId = generateId()
const childCallChain = buildNextCallChain(ctx.callChain || [], workflowId)
const depthError = validateCallChain(childCallChain)
if (depthError) {
throw new ChildWorkflowError({
message: depthError,
childWorkflowName,
})
}
// A custom block runs the source's latest deployment; if the source has been
// undeployed there's nothing to run. Check + throw a clear, consumer-safe
// reason BEFORE the try so the catch's generic sanitizer doesn't mask it (the
// message names no source internals). The block still renders (its schema comes
// from stored curated inputs), so this is the only failure mode to surface.
if (isCustomBlock) {
const deployed = await this.checkChildDeployment(workflowId, loadUserId)
if (!deployed) {
throw new Error('This blocks workflow is not deployed. Redeploy it to use this block.')
}
}
let childWorkflowSnapshotId: string | undefined
try {
if (useDeployed && !isCustomBlock) {
const hasActiveDeployment = await this.checkChildDeployment(workflowId, loadUserId)
if (!hasActiveDeployment) {
throw new Error(
`Child workflow is not deployed. Please deploy the workflow before invoking it.`
)
}
}
const childWorkflow = useDeployed
? await this.loadChildWorkflowDeployed(workflowId, loadUserId)
: await this.loadChildWorkflow(workflowId, ctx.userId)
if (!childWorkflow) {
throw new Error(`Child workflow ${workflowId} not found`)
}
// Custom blocks are org-scoped and deliberately cross-workspace: the source
// workflow lives in the publisher's workspace, not the consumer's. Their
// boundary is the org overlay + `getCustomBlockAuthority`, so the
// same-workspace assert (which guards regular workflow blocks) must be
// skipped or every custom-block invocation from another workspace throws.
if (!isCustomBlock) {
this.assertChildWorkflowInWorkspace(workflowId, childWorkflow.workspaceId, ctx.workspaceId)
}
childWorkflowName = childWorkflow.name || 'Unknown Workflow'
logger.info(
`Executing child workflow: ${childWorkflowName} (${workflowId}), call chain depth ${ctx.callChain?.length || 0}`
)
let childWorkflowInput: Record<string, any> = {}
if (inputs.inputMapping !== undefined && inputs.inputMapping !== null) {
const normalized = parseJSON(inputs.inputMapping, inputs.inputMapping)
if (normalized && typeof normalized === 'object' && !Array.isArray(normalized)) {
// Custom blocks key their mapping by the source field's stable id so a
// rename never orphans the consumer's value; remap id → current name
// before the child (which is addressed by name) receives it.
const remapped = isCustomBlock
? remapCustomBlockInputKeys(
normalized as Record<string, unknown>,
childWorkflow.rawBlocks || {}
)
: (normalized as Record<string, unknown>)
const cleanedMapping = await lazyCleanupInputMapping(
ctx.workflowId || 'unknown',
block.id,
remapped,
childWorkflow.rawBlocks || {}
)
childWorkflowInput = cleanedMapping as Record<string, any>
} else {
childWorkflowInput = {}
}
} else if (inputs.input !== undefined) {
childWorkflowInput = inputs.input
}
if (isCustomBlock) {
const missing = findMissingRequiredCustomBlockInputs(
requiredInputIds,
childWorkflow.rawBlocks || {},
childWorkflowInput
)
if (missing.length > 0) {
throw new CustomBlockMissingInputsError(
`${block.metadata?.name || 'Custom block'} is missing required fields: ${missing.join(', ')}`
)
}
}
const childSnapshotResult = await snapshotService.createSnapshotWithDeduplication(
workflowId,
childWorkflow.workflowState
)
childWorkflowSnapshotId = childSnapshotResult.snapshot.id
const childDepth = (ctx.childWorkflowContext?.depth ?? 0) + 1
const shouldPropagateCallbacks = childDepth <= DEFAULTS.MAX_SSE_CHILD_DEPTH
if (!shouldPropagateCallbacks) {
logger.info('Dropping SSE callbacks beyond max child depth', {
childDepth,
maxDepth: DEFAULTS.MAX_SSE_CHILD_DEPTH,
childWorkflowName,
})
}
if (shouldPropagateCallbacks) {
const effectiveBlockId = nodeMetadata
? (nodeMetadata.originalBlockId ?? nodeMetadata.nodeId)
: block.id
const iterationContext = nodeMetadata ? getIterationContext(ctx, nodeMetadata) : undefined
await ctx.onChildWorkflowInstanceReady?.(
effectiveBlockId,
instanceId,
iterationContext,
nodeMetadata?.executionOrder,
ctx.childWorkflowContext
)
}
// A custom block is an invocation boundary: the child runs under the SOURCE
// workflow owner's identity, workspace, and environment — not the consumer's —
// so it resolves credentials/integrations/env exactly as published and the
// consumer needs no access to any of them. (Billing still lands on the
// consumer's org, aggregated onto the block above.) Regular workflow blocks
// keep running in the parent's context.
let childUserId = ctx.userId
let childWorkspaceId = ctx.workspaceId
let childEnvVarValues = ctx.environmentVariables
if (isCustomBlock) {
if (!loadUserId) {
throw new Error('Custom block source workflow has no owner')
}
if (!childWorkflow.workspaceId) {
throw new Error('Custom block source workflow has no workspace')
}
childUserId = loadUserId
childWorkspaceId = childWorkflow.workspaceId
const ownerEnv = await getPersonalAndWorkspaceEnv(loadUserId, childWorkflow.workspaceId)
childEnvVarValues = { ...ownerEnv.personalDecrypted, ...ownerEnv.workspaceDecrypted }
}
const subExecutor = new Executor({
workflow: childWorkflow.serializedState,
workflowInput: childWorkflowInput,
envVarValues: childEnvVarValues,
workflowVariables: childWorkflow.variables || {},
contextExtensions: {
isChildExecution: true,
// Custom blocks always run the source's latest deployment, so the child
// context must be deployed too — otherwise its metadata treats the
// deployed graph as draft. `useDeployed` folds in the custom-block case.
isDeployedContext: useDeployed,
enforceCredentialAccess: ctx.enforceCredentialAccess,
workspaceId: childWorkspaceId,
userId: childUserId,
executionId: ctx.executionId,
abortSignal: ctx.abortSignal,
// Propagate in-flight block-output redaction into child workflows so
// nested blocks mask outputs too (recurses: each child forwards it).
piiBlockOutputRedaction: ctx.piiBlockOutputRedaction,
callChain: childCallChain,
...(shouldPropagateCallbacks && {
onBlockStart: ctx.onBlockStart,
onBlockComplete: ctx.onBlockComplete,
onStream: ctx.onStream,
onChildWorkflowInstanceReady: ctx.onChildWorkflowInstanceReady,
childWorkflowContext: {
parentBlockId: instanceId,
workflowName: childWorkflowName,
workflowId,
depth: childDepth,
},
}),
},
})
const startTime = performance.now()
const result = await subExecutor.execute(workflowId)
const executionResult = this.toExecutionResult(result)
const duration = performance.now() - startTime
logger.info(`Child workflow ${childWorkflowName} completed in ${Math.round(duration)}ms`, {
success: executionResult.success,
hasLogs: (executionResult.logs?.length ?? 0) > 0,
})
const childTraceSpans = this.captureChildWorkflowLogs(executionResult, childWorkflowName, ctx)
const mappedResult = this.mapChildOutputToParent(
executionResult,
workflowId,
childWorkflowName,
duration,
instanceId,
childTraceSpans,
childWorkflowSnapshotId
)
// Custom blocks expose only curated outputs — never the child workflow id,
// name, or trace spans. `mapChildOutputToParent` above still runs so failures
// surface identically; we just reshape the successful output.
if (isCustomBlock) {
// The child's spans are stripped for privacy, but they're the only carrier
// of the run's cost into billing — so roll their aggregate cost onto the
// block itself. Custom blocks are org-scoped, so this bills the same org the
// source workflow would bill if run directly, exactly as if it ran the key.
const childCost = aggregateChildCost(childTraceSpans)
return this.projectCustomBlockOutput(executionResult, exposedOutputs, childCost)
}
return mappedResult
} catch (error: unknown) {
logger.error(`Error executing child workflow ${workflowId}:`, error)
// Custom blocks are an invocation boundary: on failure the consumer must not
// see the source workflow's name, nested error text (which names internal
// blocks), trace spans, or execution result — the success path hides all of
// these too. The real error is logged above for the publisher/ops; the
// consumer gets only a generic failure attributed to the block they placed.
// But a child that failed AFTER consuming hosted keys still owes that spend,
// so capture the child's spans server-side, distill to the aggregate cost, and
// carry only that (no internals) so `block-executor` still bills it.
if (isCustomBlock) {
// Missing-required-inputs is the consumer's own mistake and its message
// names only the block's input labels — surface it instead of the generic
// failure so they can actually fix it. The child never ran: no spend.
if (error instanceof CustomBlockMissingInputsError) {
throw new ChildWorkflowError({
message: error.message,
childWorkflowName: block.metadata?.name || 'Custom block',
childWorkflowInstanceId: instanceId,
})
}
let failedChildSpans: WorkflowTraceSpan[] = []
if (hasExecutionResult(error) && error.executionResult.logs) {
failedChildSpans = this.captureChildWorkflowLogs(
error.executionResult,
childWorkflowName,
ctx
)
} else if (ChildWorkflowError.isChildWorkflowError(error)) {
failedChildSpans = error.childTraceSpans
}
const blockName = block.metadata?.name || 'Custom block'
throw new ChildWorkflowError({
message: 'Custom block execution failed',
childWorkflowName: blockName,
childTraceSpans: buildCostCarrierSpans(
aggregateChildCost(failedChildSpans),
blockName,
block.metadata?.id ?? 'custom_block'
),
childWorkflowInstanceId: instanceId,
})
}
let childTraceSpans: WorkflowTraceSpan[] = []
let executionResult: ExecutionResult | undefined
if (hasExecutionResult(error) && error.executionResult.logs) {
executionResult = error.executionResult
logger.info(`Extracting child trace spans from error.executionResult`, {
hasLogs: (executionResult.logs?.length ?? 0) > 0,
logCount: executionResult.logs?.length ?? 0,
})
childTraceSpans = this.captureChildWorkflowLogs(executionResult, childWorkflowName, ctx)
logger.info(`Captured ${childTraceSpans.length} child trace spans from failed execution`)
} else if (ChildWorkflowError.isChildWorkflowError(error)) {
childTraceSpans = error.childTraceSpans
}
// Build a cleaner error message for nested workflow errors
const errorMessage = this.buildNestedWorkflowErrorMessage(childWorkflowName, error)
throw new ChildWorkflowError({
message: errorMessage,
childWorkflowName,
childTraceSpans,
executionResult,
childWorkflowSnapshotId,
childWorkflowInstanceId: instanceId,
cause: error instanceof Error ? error : undefined,
})
}
}
/**
* Builds a cleaner error message for nested workflow errors.
* Parses nested error messages to extract workflow chain and root error.
*/
private buildNestedWorkflowErrorMessage(childWorkflowName: string, error: unknown): string {
const originalError = getErrorMessage(error, 'Unknown error')
// Extract any nested workflow names from the error message
const { chain, rootError } = this.parseNestedWorkflowError(originalError)
// Add current workflow to the beginning of the chain
chain.unshift(childWorkflowName)
// If we have a chain (nested workflows), format nicely
if (chain.length > 1) {
return `Workflow chain: ${chain.join(' → ')} | ${rootError}`
}
// Single workflow failure
return `"${childWorkflowName}" failed: ${rootError}`
}
/**
* Parses a potentially nested workflow error message to extract:
* - The chain of workflow names
* - The actual root error message (preserving the block name prefix for the failing block)
*
* Handles formats like:
* - "workflow-name" failed: error
* - Block Name: "workflow-name" failed: error
* - Workflow chain: A → B | error
*/
private parseNestedWorkflowError(message: string): { chain: string[]; rootError: string } {
const chain: string[] = []
const remaining = message
// First, check if it's already in chain format
const chainMatch = remaining.match(/^Workflow chain: (.+?) \| (.+)$/)
if (chainMatch) {
const chainPart = chainMatch[1]
const errorPart = chainMatch[2]
chain.push(...chainPart.split(' → ').map((s) => s.trim()))
return { chain, rootError: errorPart }
}
// Extract workflow names from patterns like:
// - "workflow-name" failed:
// - Block Name: "workflow-name" failed:
const workflowPattern = /(?:\[[^\]]+\]\s*)?(?:[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
let match: RegExpExecArray | null
let lastIndex = 0
match = workflowPattern.exec(remaining)
while (match !== null) {
chain.push(match[1])
lastIndex = match.index + match[0].length
match = workflowPattern.exec(remaining)
}
// The root error is everything after the last match
// Keep the block name prefix (e.g., Function 1:) so we know which block failed
const rootError = lastIndex > 0 ? remaining.slice(lastIndex) : remaining
return { chain, rootError: rootError.trim() || 'Unknown error' }
}
/**
* Ensures the child workflow belongs to the same workspace as the executing
* context before any child execution starts. Blocks silent cross-workspace
* execution (e.g. a manual workflow id still pointing at the source
* workspace after a fork), which would otherwise run the foreign workflow
* with the parent workspace's environment and billing. Fails closed when the
* executing context carries no workspace id: every server execution path
* populates it via execution-core, so a missing value indicates a context
* that must not silently bypass the check. The error message intentionally
* omits the foreign workspace id.
*/
private assertChildWorkflowInWorkspace(
childWorkflowId: string,
childWorkspaceId: string | null | undefined,
parentWorkspaceId: string | undefined
): void {
if (!parentWorkspaceId) {
throw new Error(
`Cannot execute child workflow ${childWorkflowId}: executing context has no workspace`
)
}
if (childWorkspaceId !== parentWorkspaceId) {
throw new Error(
`Child workflow ${childWorkflowId} belongs to a different workspace and cannot be executed`
)
}
}
private async loadChildWorkflow(workflowId: string, userId?: string) {
const headers = await buildAuthHeaders(userId)
const url = buildAPIUrl(`/api/workflows/${workflowId}`)
const response = await fetch(url.toString(), { headers })
if (!response.ok) {
await response.text().catch(() => {})
if (response.status === HTTP.STATUS.NOT_FOUND) {
logger.warn(`Child workflow ${workflowId} not found`)
return null
}
throw new Error(`Failed to fetch workflow: ${response.status} ${response.statusText}`)
}
const { data: workflowData } = await response.json()
if (!workflowData) {
throw new Error(`Child workflow ${workflowId} returned empty data`)
}
logger.info(`Loaded child workflow: ${workflowData.name} (${workflowId})`)
const workflowState = workflowData.state
if (!workflowState || !workflowState.blocks) {
throw new Error(`Child workflow ${workflowId} has invalid state`)
}
const serializedWorkflow = this.serializer.serializeWorkflow(
workflowState.blocks,
workflowState.edges || [],
workflowState.loops || {},
workflowState.parallels || {},
true
)
const workflowVariables = (workflowData.variables as Record<string, any>) || {}
const workflowStateWithVariables = {
...workflowState,
variables: workflowVariables,
metadata: {
...(workflowState.metadata || {}),
name: workflowData.name || DEFAULTS.WORKFLOW_NAME,
},
}
if (Object.keys(workflowVariables).length > 0) {
logger.info(
`Loaded ${Object.keys(workflowVariables).length} variables for child workflow: ${workflowId}`
)
}
return {
name: workflowData.name,
workspaceId: (workflowData.workspaceId ?? null) as string | null,
serializedState: serializedWorkflow,
variables: workflowVariables,
workflowState: workflowStateWithVariables,
rawBlocks: workflowState.blocks,
}
}
private async checkChildDeployment(workflowId: string, userId?: string): Promise<boolean> {
try {
const headers = await buildAuthHeaders(userId)
const url = buildAPIUrl(`/api/workflows/${workflowId}/deployed`)
const response = await fetch(url.toString(), {
headers,
cache: 'no-store',
})
if (!response.ok) return false
const json = await response.json()
return !!json?.data?.deployedState || !!json?.deployedState
} catch (e) {
logger.error(`Failed to check child deployment for ${workflowId}:`, e)
return false
}
}
private async loadChildWorkflowDeployed(workflowId: string, userId?: string) {
const headers = await buildAuthHeaders(userId)
const deployedUrl = buildAPIUrl(`/api/workflows/${workflowId}/deployed`)
const deployedRes = await fetch(deployedUrl.toString(), {
headers,
cache: 'no-store',
})
if (!deployedRes.ok) {
if (deployedRes.status === HTTP.STATUS.NOT_FOUND) {
return null
}
throw new Error(
`Failed to fetch deployed workflow: ${deployedRes.status} ${deployedRes.statusText}`
)
}
const deployedJson = await deployedRes.json()
const deployedState = deployedJson?.data?.deployedState || deployedJson?.deployedState
if (!deployedState || !deployedState.blocks) {
throw new Error(`Deployed state missing or invalid for child workflow ${workflowId}`)
}
const metaUrl = buildAPIUrl(`/api/workflows/${workflowId}`)
const metaRes = await fetch(metaUrl.toString(), {
headers,
cache: 'no-store',
})
if (!metaRes.ok) {
throw new Error(`Failed to fetch workflow metadata: ${metaRes.status} ${metaRes.statusText}`)
}
const metaJson = await metaRes.json()
const wfData = metaJson?.data
const serializedWorkflow = this.serializer.serializeWorkflow(
deployedState.blocks,
deployedState.edges || [],
deployedState.loops || {},
deployedState.parallels || {},
true
)
const workflowVariables = (wfData?.variables as Record<string, any>) || {}
const childName = wfData?.name || DEFAULTS.WORKFLOW_NAME
const workflowStateWithVariables = {
...deployedState,
variables: workflowVariables,
metadata: {
...(deployedState.metadata || {}),
name: childName,
},
}
return {
name: childName,
workspaceId: (wfData?.workspaceId ?? null) as string | null,
serializedState: serializedWorkflow,
variables: workflowVariables,
workflowState: workflowStateWithVariables,
rawBlocks: deployedState.blocks,
}
}
/**
* Captures and transforms child workflow logs into trace spans
*/
private captureChildWorkflowLogs(
childResult: ExecutionResult,
childWorkflowName: string,
parentContext: ExecutionContext
): WorkflowTraceSpan[] {
try {
if (!childResult.logs || !Array.isArray(childResult.logs)) {
return []
}
const { traceSpans } = buildTraceSpans(childResult)
if (!traceSpans || traceSpans.length === 0) {
return []
}
const processedSpans = this.processChildWorkflowSpans(traceSpans)
if (processedSpans.length === 0) {
return []
}
const transformedSpans = processedSpans.map((span) =>
this.transformSpanForChildWorkflow(span, childWorkflowName)
)
return transformedSpans
} catch (error) {
logger.error(`Error capturing child workflow logs for ${childWorkflowName}:`, error)
return []
}
}
private transformSpanForChildWorkflow(
span: WorkflowTraceSpan,
childWorkflowName: string
): WorkflowTraceSpan {
const metadata: Record<string, unknown> = {
...(span.metadata ?? {}),
isFromChildWorkflow: true,
childWorkflowName,
}
const transformedChildren = Array.isArray(span.children)
? span.children.map((childSpan) =>
this.transformSpanForChildWorkflow(childSpan, childWorkflowName)
)
: undefined
return {
...span,
metadata,
...(transformedChildren ? { children: transformedChildren } : {}),
}
}
private processChildWorkflowSpans(spans: TraceSpan[]): WorkflowTraceSpan[] {
const processed: WorkflowTraceSpan[] = []
spans.forEach((span) => {
if (this.isSyntheticWorkflowWrapper(span)) {
if (span.children && Array.isArray(span.children)) {
processed.push(...this.processChildWorkflowSpans(span.children))
}
return
}
const workflowSpan: WorkflowTraceSpan = {
...span,
}
if (Array.isArray(workflowSpan.children)) {
workflowSpan.children = this.processChildWorkflowSpans(workflowSpan.children as TraceSpan[])
}
processed.push(workflowSpan)
})
return processed
}
private toExecutionResult(result: ExecutionResult | StreamingExecution): ExecutionResult {
return 'execution' in result ? result.execution : result
}
private isSyntheticWorkflowWrapper(span: TraceSpan | undefined): boolean {
if (!span || span.type !== 'workflow') return false
return !span.blockId
}
/**
* Shape a custom block's successful output. With curated `exposedOutputs`, each
* maps a child block output (blockId + dot-path, read from the child's per-block
* logs) to a named top-level field. With none, exposes the child's whole
* `result`. Never leaks child workflow id/name/trace spans.
*/
private projectCustomBlockOutput(
executionResult: ExecutionResult,
exposedOutputs: CustomBlockOutput[],
childCost: number
): BlockOutput {
// Aggregate child cost only (never the child's spans/model breakdown) so the
// run is billed while the source workflow's internals stay hidden.
const cost = childCost > 0 ? { cost: { total: childCost } } : {}
if (exposedOutputs.length === 0) {
return { success: true, result: executionResult.output ?? {}, ...cost }
}
const logs = executionResult.logs ?? []
const output: Record<string, unknown> = { success: true, ...cost }
for (const { blockId, path, name } of exposedOutputs) {
const log =
[...logs].reverse().find((l) => l.blockId === blockId && l.success) ??
[...logs].reverse().find((l) => l.blockId === blockId)
output[name] = log ? getValueAtPath(log.output, path) : undefined
}
return output as BlockOutput
}
private mapChildOutputToParent(
childResult: ExecutionResult,
childWorkflowId: string,
childWorkflowName: string,
duration: number,
instanceId: string,
childTraceSpans?: WorkflowTraceSpan[],
childWorkflowSnapshotId?: string
): BlockOutput {
const success = childResult.success !== false
const result = childResult.output || {}
if (!success) {
logger.warn(`Child workflow ${childWorkflowName} failed`)
throw new ChildWorkflowError({
message: `"${childWorkflowName}" failed: ${childResult.error || 'Child workflow execution failed'}`,
childWorkflowName,
childTraceSpans: childTraceSpans || [],
childWorkflowSnapshotId,
childWorkflowInstanceId: instanceId,
})
}
const output: BlockOutput = {
success: true,
childWorkflowName,
childWorkflowId,
...(childWorkflowSnapshotId ? { childWorkflowSnapshotId } : {}),
result,
childTraceSpans: childTraceSpans || [],
_childWorkflowInstanceId: instanceId,
}
return output
}
}
@@ -0,0 +1,73 @@
import { PARALLEL } from '@/executor/constants'
import type { ExecutionContext, LoopPauseScope, ParallelPauseScope } from '@/executor/types'
interface NodeMetadataLike {
nodeId: string
loopId?: string
parallelId?: string
branchIndex?: number
branchTotal?: number
}
export function generatePauseContextId(
baseBlockId: string,
nodeMetadata: NodeMetadataLike,
loopScope?: LoopPauseScope
): string {
let contextId = baseBlockId
if (typeof nodeMetadata.branchIndex === 'number') {
contextId = `${contextId}${PARALLEL.BRANCH.PREFIX}${nodeMetadata.branchIndex}${PARALLEL.BRANCH.SUFFIX}`
}
if (loopScope) {
contextId = `${contextId}_loop${loopScope.iteration}`
}
return contextId
}
export function buildTriggerBlockId(nodeId: string): string {
if (nodeId.includes('__response')) {
return nodeId.replace('__response', '__trigger')
}
if (nodeId.endsWith('_response')) {
return nodeId.replace(/_response$/, '_trigger')
}
return `${nodeId}__trigger`
}
export function mapNodeMetadataToPauseScopes(
ctx: ExecutionContext,
nodeMetadata: NodeMetadataLike
): {
parallelScope?: ParallelPauseScope
loopScope?: LoopPauseScope
} {
let parallelScope: ParallelPauseScope | undefined
let loopScope: LoopPauseScope | undefined
if (nodeMetadata.parallelId && typeof nodeMetadata.branchIndex === 'number') {
parallelScope = {
parallelId: nodeMetadata.parallelId,
branchIndex: nodeMetadata.branchIndex,
branchTotal: nodeMetadata.branchTotal,
}
}
if (nodeMetadata.loopId) {
const loopExecution = ctx.loopExecutions?.get(nodeMetadata.loopId)
const iteration = loopExecution?.iteration ?? 0
loopScope = {
loopId: nodeMetadata.loopId,
iteration,
}
}
return {
parallelScope,
loopScope,
}
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Executor - Main entry point
* Exports the DAG executor as the default executor
*/
export { DAGExecutor as Executor } from '@/executor/execution/executor'
@@ -0,0 +1,404 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { EDGE } from '@/executor/constants'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import type { EdgeManager } from '@/executor/execution/edge-manager'
import type { BlockStateController } from '@/executor/execution/types'
import { LoopOrchestrator } from '@/executor/orchestrators/loop'
import type { ExecutionContext } from '@/executor/types'
const { mockExecuteInIsolatedVM, mockUploadFile } = vi.hoisted(() => ({
mockExecuteInIsolatedVM: vi.fn(),
mockUploadFile: vi.fn(),
}))
vi.mock('@/lib/execution/isolated-vm', () => ({
executeInIsolatedVM: mockExecuteInIsolatedVM,
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
uploadFile: mockUploadFile,
},
}))
function createNode(id: string): DAGNode {
return {
id,
block: {
id,
position: { x: 0, y: 0 },
enabled: true,
metadata: { id: 'function', name: id },
config: { params: {} },
inputs: {},
outputs: {},
},
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {},
}
}
function createState(): BlockStateController {
return {
getBlockOutput: vi.fn(),
hasExecuted: vi.fn(() => false),
setBlockOutput: vi.fn(),
setBlockState: vi.fn(),
deleteBlockState: vi.fn(),
unmarkExecuted: vi.fn(),
}
}
function createContext(scope: Record<string, unknown> = {}, loopId = 'loop-1'): ExecutionContext {
return {
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
blockStates: new Map(),
executedBlocks: new Set(),
blockLogs: [],
metadata: { requestId: 'request-1' },
environmentVariables: {},
workflowVariables: {},
decisions: { router: new Map(), condition: new Map() },
completedLoops: new Set(),
activeExecutionPath: new Set(),
loopExecutions: new Map([[loopId, scope as any]]),
} as ExecutionContext
}
function createOrchestrator(loopConfigs = new Map<string, any>()) {
const state = createState()
const orchestrator = new LoopOrchestrator(
{ loopConfigs, parallelConfigs: new Map(), nodes: new Map() } as any,
state,
{ resolveSingleReference: vi.fn() } as any
)
return { orchestrator, setBlockOutput: vi.mocked(state.setBlockOutput) }
}
describe('LoopOrchestrator', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
mockExecuteInIsolatedVM.mockResolvedValue({ result: true })
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
})
it('does not restore parallel_continue back edges for nested parallels', () => {
const loopId = 'loop-1'
const parallelId = 'parallel-1'
const loopStartId = `loop-${loopId}-sentinel-start`
const loopEndId = `loop-${loopId}-sentinel-end`
const parallelStartId = `parallel-${parallelId}-sentinel-start`
const parallelEndId = `parallel-${parallelId}-sentinel-end`
const loopStart = createNode(loopStartId)
const loopEnd = createNode(loopEndId)
const parallelStart = createNode(parallelStartId)
const parallelEnd = createNode(parallelEndId)
loopStart.outgoingEdges.set(`${loopStartId}->${parallelStartId}`, { target: parallelStartId })
loopStart.outgoingEdges.set(`${loopStartId}->${loopEndId}-exit`, {
target: loopEndId,
sourceHandle: EDGE.LOOP_EXIT,
})
parallelStart.outgoingEdges.set(`${parallelStartId}->${parallelEndId}-exit`, {
target: parallelEndId,
sourceHandle: EDGE.PARALLEL_EXIT,
})
parallelEnd.outgoingEdges.set(`${parallelEndId}->${parallelStartId}-continue`, {
target: parallelStartId,
sourceHandle: EDGE.PARALLEL_CONTINUE,
})
parallelEnd.outgoingEdges.set(`${parallelEndId}->${loopEndId}-exit`, {
target: loopEndId,
sourceHandle: EDGE.PARALLEL_EXIT,
})
const dag: DAG = {
nodes: new Map([
[loopStartId, loopStart],
[loopEndId, loopEnd],
[parallelStartId, parallelStart],
[parallelEndId, parallelEnd],
]),
loopConfigs: new Map([[loopId, { id: loopId, nodes: [parallelId], loopType: 'for' }]]),
parallelConfigs: new Map([
[parallelId, { id: parallelId, nodes: [], parallelType: 'count' }],
]),
}
const edgeManager = {
clearDeactivatedEdgesForNodes: vi.fn(),
} as unknown as EdgeManager
const orchestrator = new LoopOrchestrator(dag, createState(), null as any, {}, edgeManager)
orchestrator.restoreLoopEdges(loopId)
expect(parallelStart.incomingEdges.has(loopStartId)).toBe(true)
expect(parallelStart.incomingEdges.has(parallelEndId)).toBe(false)
expect(loopEnd.incomingEdges.has(loopStartId)).toBe(false)
expect(parallelEnd.incomingEdges.has(parallelStartId)).toBe(false)
expect(loopEnd.incomingEdges.has(parallelEndId)).toBe(true)
})
it('resolves forEach collections with the loop start sentinel scope', async () => {
const loopId = 'loop-1'
const dag: DAG = {
nodes: new Map(),
loopConfigs: new Map([
[
loopId,
{
id: loopId,
nodes: ['task-1'],
loopType: 'forEach',
forEachItems: '<Producer.items>',
},
],
]),
parallelConfigs: new Map(),
}
const resolver = {
resolveSingleReference: vi.fn().mockResolvedValue(['item-1']),
}
const orchestrator = new LoopOrchestrator(dag, createState(), resolver as any, {}, {
clearDeactivatedEdgesForNodes: vi.fn(),
} as unknown as EdgeManager)
const ctx = createContext()
const scope = await orchestrator.initializeLoopScope(ctx, loopId)
expect(resolver.resolveSingleReference).toHaveBeenCalledWith(
expect.any(Object),
'loop-loop-1-sentinel-start',
'<Producer.items>',
undefined,
{ allowLargeValueRefs: true }
)
expect(scope.maxIterations).toBe(1)
})
it('exits immediately when a loop was skipped at start', async () => {
const loopId = 'loop-1'
const state = createState()
const dag: DAG = {
nodes: new Map(),
loopConfigs: new Map([[loopId, { id: loopId, nodes: ['task-1'], loopType: 'while' }]]),
parallelConfigs: new Map(),
}
const resolver = {
resolveSingleReference: vi.fn().mockResolvedValue(1),
}
const orchestrator = new LoopOrchestrator(dag, state, resolver as any, {}, {
clearDeactivatedEdgesForNodes: vi.fn(),
} as unknown as EdgeManager)
const ctx = createContext(
{
iteration: 0,
currentIterationOutputs: new Map(),
allIterationOutputs: [],
loopType: 'while',
condition: '<loop.index> > 0',
skippedAtStart: true,
},
loopId
)
const result = await orchestrator.evaluateLoopContinuation(ctx, loopId)
expect(result).toMatchObject({
shouldContinue: false,
shouldExit: true,
selectedRoute: EDGE.LOOP_EXIT,
aggregatedResults: [],
})
expect(resolver.resolveSingleReference).not.toHaveBeenCalled()
expect(state.setBlockOutput).toHaveBeenCalledWith(loopId, { results: [] }, 0)
})
it('marks empty forEach loops as skipped at the initial condition check', async () => {
const { orchestrator, setBlockOutput } = createOrchestrator()
const scope = {
iteration: 0,
currentIterationOutputs: new Map(),
allIterationOutputs: [],
loopType: 'forEach',
items: [],
maxIterations: 0,
condition: '<loop.index> < 0',
} as { skippedAtStart?: boolean } & Record<string, unknown>
const ctx = createContext(scope)
const shouldExecute = await orchestrator.evaluateInitialCondition(ctx, 'loop-1')
expect(shouldExecute).toBe(false)
expect(scope.skippedAtStart).toBe(true)
expect(setBlockOutput).not.toHaveBeenCalled()
const result = await orchestrator.evaluateLoopContinuation(ctx, 'loop-1')
expect(result).toMatchObject({
shouldContinue: false,
shouldExit: true,
selectedRoute: EDGE.LOOP_EXIT,
aggregatedResults: [],
})
expect(scope.skippedAtStart).toBe(false)
expect(setBlockOutput).toHaveBeenCalledWith('loop-1', { results: [] }, 0)
})
it.each([
['for loop with zero iterations', { loopType: 'for', maxIterations: 0 }],
['while loop with no condition', { loopType: 'while' }],
])('marks %s as skipped at the initial condition check', async (_name, overrides) => {
const { orchestrator, setBlockOutput } = createOrchestrator()
const scope = {
iteration: 0,
currentIterationOutputs: new Map(),
allIterationOutputs: [],
...overrides,
} as { skippedAtStart?: boolean } & Record<string, unknown>
const ctx = createContext(scope)
const shouldExecute = await orchestrator.evaluateInitialCondition(ctx, 'loop-1')
expect(shouldExecute).toBe(false)
expect(scope.skippedAtStart).toBe(true)
expect(setBlockOutput).not.toHaveBeenCalled()
await orchestrator.evaluateLoopContinuation(ctx, 'loop-1')
expect(scope.skippedAtStart).toBe(false)
expect(setBlockOutput).toHaveBeenCalledWith('loop-1', { results: [] }, 0)
})
it('marks while loops with false initial conditions as skipped at start', async () => {
const state = createState()
const resolver = { resolveSingleReference: vi.fn().mockResolvedValue(false) }
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: false })
const orchestrator = new LoopOrchestrator(
{ loopConfigs: new Map(), parallelConfigs: new Map(), nodes: new Map() },
state,
resolver as any
)
const scope = {
iteration: 0,
currentIterationOutputs: new Map(),
allIterationOutputs: [],
loopType: 'while',
condition: '<condition.output>',
} as { skippedAtStart?: boolean } & Record<string, unknown>
const ctx = createContext(scope)
const shouldExecute = await orchestrator.evaluateInitialCondition(ctx, 'loop-1')
expect(shouldExecute).toBe(false)
expect(scope.skippedAtStart).toBe(true)
expect(state.setBlockOutput).not.toHaveBeenCalled()
expect(mockExecuteInIsolatedVM).toHaveBeenCalledWith(
expect.objectContaining({
code: 'return Boolean(false)',
})
)
})
it('exits doWhile loops when the configured iteration cap is reached', async () => {
const { orchestrator } = createOrchestrator()
const ctx = createContext({
iteration: 4,
maxIterations: 5,
loopType: 'doWhile',
condition: 'true',
currentIterationOutputs: new Map([['block-1', { result: 'done' }]]),
allIterationOutputs: [],
})
const result = await orchestrator.evaluateLoopContinuation(ctx, 'loop-1')
expect(result).toMatchObject({
shouldContinue: false,
shouldExit: true,
selectedRoute: EDGE.LOOP_EXIT,
totalIterations: 1,
})
})
it('does not treat doWhile iterations of zero as an immediate configured cap', async () => {
const { orchestrator } = createOrchestrator(
new Map([
[
'loop-1',
{
loopType: 'doWhile',
iterations: 0,
doWhileCondition: 'true',
nodes: ['block-1'],
},
],
])
)
const ctx = createContext()
const scope = await orchestrator.initializeLoopScope(ctx, 'loop-1')
expect(scope.maxIterations).toBeUndefined()
expect(scope.condition).toBe('true')
})
it('keeps doWhile condition semantics when iterations are also configured', async () => {
const { orchestrator } = createOrchestrator(
new Map([
[
'loop-1',
{
loopType: 'doWhile',
iterations: 2,
doWhileCondition: 'true',
nodes: ['block-1'],
},
],
])
)
const ctx = createContext()
const scope = await orchestrator.initializeLoopScope(ctx, 'loop-1')
expect(scope.maxIterations).toBeUndefined()
expect(scope.condition).toBe('true')
})
it('compacts current iteration outputs before retaining them', async () => {
const { orchestrator, setBlockOutput } = createOrchestrator()
const ctx = createContext({
iteration: 0,
maxIterations: 1,
loopType: 'doWhile',
condition: 'true',
currentIterationOutputs: new Map([
[
'block-1',
{
result: Array.from({ length: 200_000 }, (_, index) => ({
id: index,
summary: 'Issue summary that keeps each item small',
})),
},
],
]),
allIterationOutputs: [],
})
await orchestrator.evaluateLoopContinuation(ctx, 'loop-1')
const output = setBlockOutput.mock.calls[0][1]
expect(Array.isArray(output.results[0])).toBe(true)
expect(isLargeArrayManifest(output.results[0][0].result)).toBe(true)
expect(output.results[0][0].result.totalCount).toBe(200_000)
})
})
+782
View File
@@ -0,0 +1,782 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateRequestId } from '@/lib/core/utils/request'
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
import { compactSubflowResults } from '@/lib/execution/payloads/serializer'
import { isLikelyReferenceSegment } from '@/lib/workflows/sanitization/references'
import {
buildLoopIndexCondition,
CONTROL_BACK_EDGE_HANDLES,
DEFAULTS,
EDGE,
PARALLEL,
} from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
import type { EdgeManager } from '@/executor/execution/edge-manager'
import type { LoopScope } from '@/executor/execution/state'
import type { BlockStateController, ContextExtensions } from '@/executor/execution/types'
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
import type { LoopConfigWithNodes } from '@/executor/types/loop'
import { createReferencePattern } from '@/executor/utils/reference-validation'
import {
addSubflowErrorLog,
buildParallelSentinelEndId,
buildParallelSentinelStartId,
buildSentinelEndId,
buildSentinelStartId,
emitSubflowSuccessEvents,
extractBaseBlockId,
extractLoopIdFromSentinel,
extractParallelIdFromSentinel,
} from '@/executor/utils/subflow-utils'
import { resolveArrayInputAsync } from '@/executor/utils/subflow-utils.server'
import type { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedLoop } from '@/serializer/types'
const logger = createLogger('LoopOrchestrator')
const LOOP_CONDITION_TIMEOUT_MS = 5000
async function replaceLoopConditionReferences(
condition: string,
replacer: (match: string) => Promise<string>
): Promise<string> {
const pattern = createReferencePattern()
let cursor = 0
let result = ''
for (const match of condition.matchAll(pattern)) {
const fullMatch = match[0]
const index = match.index ?? 0
result += condition.slice(cursor, index)
result += isLikelyReferenceSegment(fullMatch) ? await replacer(fullMatch) : fullMatch
cursor = index + fullMatch.length
}
return result + condition.slice(cursor)
}
export type LoopRoute = typeof EDGE.LOOP_CONTINUE | typeof EDGE.LOOP_EXIT
export interface LoopContinuationResult {
shouldContinue: boolean
shouldExit: boolean
selectedRoute: LoopRoute
aggregatedResults?: unknown
totalIterations?: number
}
export class LoopOrchestrator {
constructor(
private dag: DAG,
private state: BlockStateController,
private resolver: VariableResolver,
private contextExtensions: ContextExtensions | null = null,
private edgeManager: EdgeManager | null = null
) {}
async initializeLoopScope(ctx: ExecutionContext, loopId: string): Promise<LoopScope> {
const loopConfig = this.dag.loopConfigs.get(loopId) as SerializedLoop | undefined
if (!loopConfig) {
throw new Error(`Loop config not found: ${loopId}`)
}
if (loopConfig.nodes.length === 0) {
const errorMessage =
'Loop has no executable blocks inside. Add or enable at least one block in the loop.'
const loopType = loopConfig.loopType || 'for'
logger.error(errorMessage, { loopId })
await this.addLoopErrorLog(ctx, loopId, loopType, errorMessage, {})
const errorScope: LoopScope = {
iteration: 0,
maxIterations: 0,
loopType,
currentIterationOutputs: new Map(),
allIterationOutputs: [],
condition: 'false',
validationError: errorMessage,
}
if (!ctx.loopExecutions) {
ctx.loopExecutions = new Map()
}
ctx.loopExecutions.set(loopId, errorScope)
throw new Error(errorMessage)
}
const scope: LoopScope = {
iteration: 0,
currentIterationOutputs: new Map(),
allIterationOutputs: [],
}
const loopType = loopConfig.loopType
switch (loopType) {
case 'for': {
scope.loopType = 'for'
const requestedIterations = loopConfig.iterations || DEFAULTS.DEFAULT_LOOP_ITERATIONS
scope.maxIterations = requestedIterations
scope.condition = buildLoopIndexCondition(scope.maxIterations)
break
}
case 'forEach': {
scope.loopType = 'forEach'
if (
loopConfig.forEachItems === undefined ||
loopConfig.forEachItems === null ||
loopConfig.forEachItems === ''
) {
const errorMessage =
'ForEach loop collection is empty. Provide an array or a reference that resolves to a collection.'
logger.error(errorMessage, { loopId })
await this.addLoopErrorLog(ctx, loopId, loopType, errorMessage, {
forEachItems: loopConfig.forEachItems,
})
scope.items = []
scope.maxIterations = 0
scope.validationError = errorMessage
scope.condition = buildLoopIndexCondition(0)
ctx.loopExecutions?.set(loopId, scope)
throw new Error(errorMessage)
}
let items: any[]
try {
items = await resolveArrayInputAsync(
ctx,
loopConfig.forEachItems,
this.resolver,
buildSentinelStartId(loopId)
)
} catch (error) {
const errorMessage = `ForEach loop resolution failed: ${toError(error).message}`
logger.error(errorMessage, { loopId, forEachItems: loopConfig.forEachItems })
await this.addLoopErrorLog(ctx, loopId, loopType, errorMessage, {
forEachItems: loopConfig.forEachItems,
})
scope.items = []
scope.maxIterations = 0
scope.validationError = errorMessage
scope.condition = buildLoopIndexCondition(0)
ctx.loopExecutions?.set(loopId, scope)
throw new Error(errorMessage)
}
scope.items = items
scope.maxIterations = items.length
scope.item = items[0]
scope.condition = buildLoopIndexCondition(scope.maxIterations)
break
}
case 'while':
scope.loopType = 'while'
scope.condition = loopConfig.whileCondition
break
case 'doWhile': {
scope.loopType = 'doWhile'
if (loopConfig.doWhileCondition) {
scope.condition = loopConfig.doWhileCondition
} else {
const requestedIterations = loopConfig.iterations || DEFAULTS.DEFAULT_LOOP_ITERATIONS
scope.maxIterations = requestedIterations
scope.condition = buildLoopIndexCondition(scope.maxIterations)
}
break
}
default:
throw new Error(`Unknown loop type: ${loopType}`)
}
if (!ctx.loopExecutions) {
ctx.loopExecutions = new Map()
}
ctx.loopExecutions.set(loopId, scope)
return scope
}
private async addLoopErrorLog(
ctx: ExecutionContext,
loopId: string,
loopType: string,
errorMessage: string,
inputData?: any
): Promise<void> {
await addSubflowErrorLog(
ctx,
loopId,
'loop',
errorMessage,
{ loopType, ...inputData },
this.contextExtensions
)
}
storeLoopNodeOutput(
ctx: ExecutionContext,
loopId: string,
nodeId: string,
output: NormalizedBlockOutput
): void {
const scope = ctx.loopExecutions?.get(loopId)
if (!scope) {
logger.warn('Loop scope not found for node output storage', { loopId, nodeId })
return
}
const baseId = extractBaseBlockId(nodeId)
scope.currentIterationOutputs.set(baseId, output)
}
async evaluateLoopContinuation(
ctx: ExecutionContext,
loopId: string
): Promise<LoopContinuationResult> {
const scope = ctx.loopExecutions?.get(loopId)
if (!scope) {
logger.error('Loop scope not found during continuation evaluation', { loopId })
return {
shouldContinue: false,
shouldExit: true,
selectedRoute: EDGE.LOOP_EXIT,
}
}
const useRedis = isRedisCancellationEnabled() && !!ctx.executionId
let isCancelled = false
if (useRedis) {
isCancelled = await isExecutionCancelled(ctx.executionId!)
} else {
isCancelled = ctx.abortSignal?.aborted ?? false
}
if (isCancelled) {
logger.info('Loop execution cancelled', { loopId, iteration: scope.iteration })
return await this.createExitResult(ctx, loopId, scope)
}
if (scope.skippedAtStart) {
scope.skippedAtStart = false
return await this.createExitResult(ctx, loopId, scope)
}
const iterationResults: NormalizedBlockOutput[] = []
for (const blockOutput of scope.currentIterationOutputs.values()) {
iterationResults.push(blockOutput)
}
if (iterationResults.length > 0) {
const compactedIterationResults = await compactSubflowResults(iterationResults, {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
requireDurable: true,
})
scope.allIterationOutputs.push(compactedIterationResults)
}
scope.currentIterationOutputs.clear()
if (this.hasReachedConfiguredIterationLimit(scope, scope.iteration + 1)) {
return await this.createExitResult(ctx, loopId, scope)
}
if (!(await this.evaluateCondition(ctx, scope, scope.iteration + 1))) {
return await this.createExitResult(ctx, loopId, scope)
}
scope.iteration++
if (scope.items && scope.iteration < scope.items.length) {
scope.item = scope.items[scope.iteration]
}
return {
shouldContinue: true,
shouldExit: false,
selectedRoute: EDGE.LOOP_CONTINUE,
}
}
private hasReachedConfiguredIterationLimit(scope: LoopScope, nextIteration: number): boolean {
if (scope.loopType !== 'doWhile' || scope.maxIterations === undefined) {
return false
}
return nextIteration >= scope.maxIterations
}
private async createExitResult(
ctx: ExecutionContext,
loopId: string,
scope: LoopScope
): Promise<LoopContinuationResult> {
const results = scope.allIterationOutputs
const totalIterations = results.length
const compactedResults = await compactSubflowResults(results, {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
requireDurable: true,
})
const output = { results: compactedResults }
this.state.setBlockOutput(loopId, output, DEFAULTS.EXECUTION_TIME)
scope.allIterationOutputs = []
await emitSubflowSuccessEvents(ctx, loopId, 'loop', output, this.contextExtensions)
return {
shouldContinue: false,
shouldExit: true,
selectedRoute: EDGE.LOOP_EXIT,
aggregatedResults: output.results,
totalIterations,
}
}
private async evaluateCondition(
ctx: ExecutionContext,
scope: LoopScope,
iteration?: number
): Promise<boolean> {
if (!scope.condition) {
logger.warn('No condition defined for loop')
return false
}
const currentIteration = scope.iteration
if (iteration !== undefined) {
scope.iteration = iteration
}
const result = await this.evaluateWhileCondition(ctx, scope.condition, scope)
if (iteration !== undefined) {
scope.iteration = currentIteration
}
return result
}
clearLoopExecutionState(loopId: string, ctx: ExecutionContext): void {
const allNodeIds = this.collectAllLoopNodeIds(loopId)
for (const nodeId of allNodeIds) {
this.state.unmarkExecuted(nodeId)
}
this.resetNestedLoopScopes(loopId, ctx)
this.resetNestedParallelScopes(loopId, ctx)
}
/**
* Deletes loop scopes for any nested loops so they re-initialize
* on the next outer iteration.
*/
private resetNestedLoopScopes(loopId: string, ctx: ExecutionContext): void {
const loopConfig = this.dag.loopConfigs.get(loopId) as LoopConfigWithNodes | undefined
if (!loopConfig) return
for (const nodeId of loopConfig.nodes) {
if (this.dag.loopConfigs.has(nodeId)) {
ctx.loopExecutions?.delete(nodeId)
// Delete cloned loop variants (__obranch-N and __clone*) but not original
// subflowParentMap entries which are needed for SSE iteration context.
if (ctx.loopExecutions) {
const obranchPrefix = `${nodeId}__obranch-`
const cloneSeqPrefix = `${nodeId}__clone`
for (const key of ctx.loopExecutions.keys()) {
if (key.startsWith(obranchPrefix) || key.startsWith(cloneSeqPrefix)) {
ctx.loopExecutions.delete(key)
ctx.subflowParentMap?.delete(key)
}
}
}
this.resetNestedLoopScopes(nodeId, ctx)
}
}
}
/**
* Deletes parallel scopes for any nested parallels (including cloned
* subflows with `__obranch-N` suffixes) so they re-initialize on the
* next outer loop iteration.
*/
private resetNestedParallelScopes(loopId: string, ctx: ExecutionContext): void {
const loopConfig = this.dag.loopConfigs.get(loopId) as LoopConfigWithNodes | undefined
if (!loopConfig) return
for (const nodeId of loopConfig.nodes) {
if (this.dag.parallelConfigs.has(nodeId)) {
this.deleteParallelScopeAndClones(nodeId, ctx)
} else if (this.dag.loopConfigs.has(nodeId)) {
this.resetNestedParallelScopes(nodeId, ctx)
}
}
}
/**
* Deletes a parallel scope and any cloned variants (`__obranch-N`),
* recursively handling nested subflows within the parallel.
*/
private deleteParallelScopeAndClones(parallelId: string, ctx: ExecutionContext): void {
ctx.parallelExecutions?.delete(parallelId)
// Delete cloned scopes (__obranch-N and __clone*) but not original subflowParentMap entries
if (ctx.parallelExecutions) {
const obranchPrefix = `${parallelId}__obranch-`
const clonePrefix = `${parallelId}__clone`
for (const key of ctx.parallelExecutions.keys()) {
if (key.startsWith(obranchPrefix) || key.startsWith(clonePrefix)) {
ctx.parallelExecutions.delete(key)
ctx.subflowParentMap?.delete(key)
}
}
}
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
if (parallelConfig?.nodes) {
for (const nodeId of parallelConfig.nodes) {
if (this.dag.parallelConfigs.has(nodeId)) {
this.deleteParallelScopeAndClones(nodeId, ctx)
} else if (this.dag.loopConfigs.has(nodeId)) {
ctx.loopExecutions?.delete(nodeId)
// Also delete cloned loop scopes (__obranch-N and __clone*) created by expandParallel
if (ctx.loopExecutions) {
const obranchPrefix = `${nodeId}__obranch-`
const cloneSeqPrefix = `${nodeId}__clone`
for (const key of ctx.loopExecutions.keys()) {
if (key.startsWith(obranchPrefix) || key.startsWith(cloneSeqPrefix)) {
ctx.loopExecutions.delete(key)
ctx.subflowParentMap?.delete(key)
}
}
}
this.resetNestedParallelScopes(nodeId, ctx)
}
}
}
}
/**
* Collects all effective DAG node IDs for a loop, recursively including
* sentinel IDs for any nested subflow blocks (loops and parallels).
*/
private collectAllLoopNodeIds(loopId: string, visited = new Set<string>()): Set<string> {
if (visited.has(loopId)) return new Set()
visited.add(loopId)
const loopConfig = this.dag.loopConfigs.get(loopId) as LoopConfigWithNodes | undefined
if (!loopConfig) return new Set()
const sentinelStartId = buildSentinelStartId(loopId)
const sentinelEndId = buildSentinelEndId(loopId)
const result = new Set([sentinelStartId, sentinelEndId])
for (const nodeId of loopConfig.nodes) {
if (this.dag.loopConfigs.has(nodeId)) {
for (const id of this.collectAllLoopNodeIds(nodeId, visited)) {
result.add(id)
}
this.collectClonedSubflowNodes(nodeId, result, visited)
} else if (this.dag.parallelConfigs.has(nodeId)) {
for (const id of this.collectAllParallelNodeIds(nodeId, visited)) {
result.add(id)
}
this.collectClonedSubflowNodes(nodeId, result, visited)
} else {
result.add(nodeId)
}
}
return result
}
/**
* Collects all effective DAG node IDs for a parallel, including
* sentinel IDs and branch template nodes, recursively handling nested subflows.
*/
private collectAllParallelNodeIds(parallelId: string, visited = new Set<string>()): Set<string> {
if (visited.has(parallelId)) return new Set()
visited.add(parallelId)
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
if (!parallelConfig) return new Set()
const sentinelStartId = buildParallelSentinelStartId(parallelId)
const sentinelEndId = buildParallelSentinelEndId(parallelId)
const result = new Set([sentinelStartId, sentinelEndId])
for (const nodeId of parallelConfig.nodes) {
if (this.dag.loopConfigs.has(nodeId)) {
for (const id of this.collectAllLoopNodeIds(nodeId, visited)) {
result.add(id)
}
this.collectClonedSubflowNodes(nodeId, result, visited)
} else if (this.dag.parallelConfigs.has(nodeId)) {
for (const id of this.collectAllParallelNodeIds(nodeId, visited)) {
result.add(id)
}
this.collectClonedSubflowNodes(nodeId, result, visited)
} else {
result.add(nodeId)
this.collectAllBranchNodes(nodeId, result)
}
}
return result
}
/**
* Collects all branch nodes for a given base block ID by scanning the DAG.
* This captures dynamically created branches (1, 2, ...) beyond the template (0).
*/
private collectAllBranchNodes(baseNodeId: string, result: Set<string>): void {
const prefix = `${baseNodeId}${PARALLEL.BRANCH.PREFIX}`
for (const dagNodeId of this.dag.nodes.keys()) {
if (dagNodeId.startsWith(prefix)) {
result.add(dagNodeId)
}
}
}
/**
* Collects all cloned subflow variants (e.g., loop-1__obranch-N) and their
* descendant nodes by scanning the DAG configs.
*/
private collectClonedSubflowNodes(
originalId: string,
result: Set<string>,
visited: Set<string>
): void {
const obranchPrefix = `${originalId}__obranch-`
const clonePrefix = `${originalId}__clone`
for (const loopId of this.dag.loopConfigs.keys()) {
if (loopId.startsWith(obranchPrefix) || loopId.startsWith(clonePrefix)) {
for (const id of this.collectAllLoopNodeIds(loopId, visited)) {
result.add(id)
}
}
}
for (const parallelId of this.dag.parallelConfigs.keys()) {
if (parallelId.startsWith(obranchPrefix) || parallelId.startsWith(clonePrefix)) {
for (const id of this.collectAllParallelNodeIds(parallelId, visited)) {
result.add(id)
}
}
}
}
restoreLoopEdges(loopId: string): void {
const loopConfig = this.dag.loopConfigs.get(loopId) as LoopConfigWithNodes | undefined
if (!loopConfig) {
logger.warn('Loop config not found for edge restoration', { loopId })
return
}
const allLoopNodeIds = this.collectAllLoopNodeIds(loopId)
if (this.edgeManager) {
this.edgeManager.clearDeactivatedEdgesForNodes(allLoopNodeIds)
}
for (const nodeId of allLoopNodeIds) {
const nodeToRestore = this.dag.nodes.get(nodeId)
if (!nodeToRestore) continue
for (const potentialSourceId of allLoopNodeIds) {
const potentialSourceNode = this.dag.nodes.get(potentialSourceId)
if (!potentialSourceNode) continue
for (const [, edge] of potentialSourceNode.outgoingEdges) {
if (edge.target === nodeId) {
if (
!this.isSubflowStartExitBypassEdge(potentialSourceId, nodeId, edge.sourceHandle) &&
(edge.sourceHandle === undefined || !CONTROL_BACK_EDGE_HANDLES.has(edge.sourceHandle))
) {
nodeToRestore.incomingEdges.add(potentialSourceId)
}
}
}
}
}
}
private isSubflowStartExitBypassEdge(
sourceId: string,
targetId: string,
sourceHandle?: string
): boolean {
if (sourceHandle === EDGE.LOOP_EXIT) {
const loopId = extractLoopIdFromSentinel(sourceId)
return (
!!loopId &&
sourceId === buildSentinelStartId(loopId) &&
targetId === buildSentinelEndId(loopId)
)
}
if (sourceHandle === EDGE.PARALLEL_EXIT) {
const parallelId = extractParallelIdFromSentinel(sourceId)
return (
!!parallelId &&
sourceId === buildParallelSentinelStartId(parallelId) &&
targetId === buildParallelSentinelEndId(parallelId)
)
}
return false
}
getLoopScope(ctx: ExecutionContext, loopId: string): LoopScope | undefined {
return ctx.loopExecutions?.get(loopId)
}
/**
* Evaluates the initial condition for loops at the sentinel start.
* - For while loops, the condition must be checked BEFORE the first iteration.
* - For forEach loops, skip if the items array is empty.
* - For for loops, skip if maxIterations is 0.
* - For doWhile loops, always execute at least once.
*
* @returns true if the loop should execute, false if it should be skipped
*/
async evaluateInitialCondition(ctx: ExecutionContext, loopId: string): Promise<boolean> {
const scope = ctx.loopExecutions?.get(loopId)
if (!scope) {
logger.warn('Loop scope not found for initial condition evaluation', { loopId })
return true
}
if (scope.loopType === 'forEach') {
if (!scope.items || scope.items.length === 0) {
logger.info('ForEach loop has empty collection, skipping loop body', { loopId })
scope.skippedAtStart = true
return false
}
return true
}
if (scope.loopType === 'for') {
if (scope.maxIterations === 0) {
logger.info('For loop has 0 iterations, skipping loop body', { loopId })
scope.skippedAtStart = true
return false
}
return true
}
if (scope.loopType === 'doWhile') {
return true
}
if (scope.loopType === 'while') {
if (!scope.condition) {
logger.warn('No condition defined for while loop', { loopId })
scope.skippedAtStart = true
return false
}
const result = await this.evaluateWhileCondition(ctx, scope.condition, scope)
logger.info('While loop initial condition evaluation', {
loopId,
condition: scope.condition,
result,
})
if (!result) {
logger.info('While loop initial condition is false, skipping loop body', { loopId })
scope.skippedAtStart = true
}
return result
}
return true
}
private async evaluateWhileCondition(
ctx: ExecutionContext,
condition: string,
scope: LoopScope
): Promise<boolean> {
if (!condition) {
return false
}
try {
logger.info('Evaluating loop condition', {
originalCondition: condition,
iteration: scope.iteration,
workflowVariableCount: Object.keys(ctx.workflowVariables ?? {}).length,
})
const evaluatedCondition = await replaceLoopConditionReferences(condition, async (match) => {
const resolved = await this.resolver.resolveSingleReference(ctx, '', match, scope)
logger.debug('Resolved variable reference in loop condition', {
reference: match,
resolvedType: resolved === null ? 'null' : typeof resolved,
})
if (resolved !== undefined) {
if (typeof resolved === 'boolean' || typeof resolved === 'number') {
return String(resolved)
}
if (typeof resolved === 'string') {
const lower = resolved.toLowerCase().trim()
if (lower === 'true' || lower === 'false') {
return lower
}
return `"${resolved}"`
}
return JSON.stringify(resolved)
}
return match
})
const requestId = generateRequestId()
const code = `return Boolean(${evaluatedCondition})`
const vmResult = await executeInIsolatedVM({
code,
params: {},
envVars: {},
contextVariables: {},
timeoutMs: LOOP_CONDITION_TIMEOUT_MS,
requestId,
ownerKey: `user:${ctx.userId}`,
ownerWeight: 1,
})
if (vmResult.error) {
const isSystemError = vmResult.error.isSystemError === true
const logFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger)
logFn('Failed to evaluate loop condition', {
condition,
evaluatedCondition,
error: vmResult.error,
isSystemError,
})
return false
}
const result = Boolean(vmResult.result)
logger.info('Loop condition evaluation result', {
originalCondition: condition,
evaluatedCondition,
result,
})
return result
} catch (error) {
logger.error('Failed to evaluate loop condition', { condition, error })
return false
}
}
}
@@ -0,0 +1,464 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { EDGE } from '@/executor/constants'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import type { BlockExecutor } from '@/executor/execution/block-executor'
import type { BlockStateController } from '@/executor/execution/types'
import type { LoopOrchestrator } from '@/executor/orchestrators/loop'
import { NodeExecutionOrchestrator } from '@/executor/orchestrators/node'
import type { ParallelOrchestrator } from '@/executor/orchestrators/parallel'
import type { ExecutionContext } from '@/executor/types'
function createContext(): ExecutionContext {
return {
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
blockStates: new Map(),
executedBlocks: new Set(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: {
router: new Map(),
condition: new Map(),
},
completedLoops: new Set(),
activeExecutionPath: new Set(),
workflow: {
version: '1',
blocks: [],
connections: [],
loops: {},
parallels: {},
},
}
}
function createState(): BlockStateController {
return {
getBlockOutput: vi.fn(),
hasExecuted: vi.fn(() => false),
setBlockOutput: vi.fn(),
setBlockState: vi.fn(),
deleteBlockState: vi.fn(),
unmarkExecuted: vi.fn(),
}
}
function createSentinelNode(id: string, sentinelType: 'start' | 'end'): DAGNode {
return {
id,
block: {
id,
position: { x: 0, y: 0 },
enabled: true,
metadata: { id: 'parallel', name: id },
config: { params: {} },
inputs: {},
outputs: {},
},
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {
isSentinel: true,
sentinelType,
subflowId: 'parallel-1',
subflowType: 'parallel',
},
}
}
function createOrchestrator(
dag: DAG,
state: BlockStateController,
parallelOrchestrator: Partial<ParallelOrchestrator>,
loopOrchestratorOverrides: Partial<LoopOrchestrator> = {}
): NodeExecutionOrchestrator {
const blockExecutor = { execute: vi.fn() } as unknown as BlockExecutor
const loopOrchestrator = {
getLoopScope: vi.fn(),
initializeLoopScope: vi.fn(),
evaluateInitialCondition: vi.fn(),
evaluateLoopContinuation: vi.fn(),
clearLoopExecutionState: vi.fn(),
restoreLoopEdges: vi.fn(),
storeLoopNodeOutput: vi.fn(),
...loopOrchestratorOverrides,
} as unknown as LoopOrchestrator
return new NodeExecutionOrchestrator(
dag,
state,
blockExecutor,
loopOrchestrator,
parallelOrchestrator as ParallelOrchestrator
)
}
describe('NodeExecutionOrchestrator parallel sentinel batching', () => {
it('returns loop_exit from a loop start sentinel when the initial condition is false', async () => {
const startNode = {
...createSentinelNode('loop-loop-1-sentinel-start', 'start'),
metadata: {
isSentinel: true,
sentinelType: 'start' as const,
subflowId: 'loop-1',
subflowType: 'loop' as const,
},
}
const dag: DAG = {
nodes: new Map([[startNode.id, startNode]]),
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
const state = createState()
const loopOrchestrator = {
getLoopScope: vi.fn(() => ({})),
evaluateInitialCondition: vi.fn().mockResolvedValue(false),
}
const orchestrator = createOrchestrator(dag, state, {}, loopOrchestrator)
const result = await orchestrator.executeNode(createContext(), startNode.id)
expect(result.output).toMatchObject({
shouldExit: true,
selectedRoute: EDGE.LOOP_EXIT,
})
})
it('prepares the current batch when executing a parallel start sentinel', async () => {
const startNode = createSentinelNode('parallel-parallel-1-sentinel-start', 'start')
const dag: DAG = {
nodes: new Map([[startNode.id, startNode]]),
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
const state = createState()
const parallelOrchestrator = {
getParallelScope: vi.fn(() => ({ parallelId: 'parallel-1', totalBranches: 2 })),
initializeParallelScope: vi.fn(),
prepareCurrentBatch: vi.fn(),
}
const orchestrator = createOrchestrator(dag, state, parallelOrchestrator)
const result = await orchestrator.executeNode(createContext(), startNode.id)
expect(result.output).toEqual({ sentinelStart: true })
expect(parallelOrchestrator.prepareCurrentBatch).toHaveBeenCalledWith(
expect.any(Object),
'parallel-1'
)
})
it('returns parallel_exit from an empty parallel start sentinel without preparing a batch', async () => {
const startNode = createSentinelNode('parallel-parallel-1-sentinel-start', 'start')
const dag: DAG = {
nodes: new Map([[startNode.id, startNode]]),
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
const state = createState()
const parallelOrchestrator = {
getParallelScope: vi.fn(() => ({
parallelId: 'parallel-1',
totalBranches: 0,
isEmpty: true,
})),
initializeParallelScope: vi.fn(),
prepareCurrentBatch: vi.fn(),
}
const orchestrator = createOrchestrator(dag, state, parallelOrchestrator)
const result = await orchestrator.executeNode(createContext(), startNode.id)
expect(result.output).toMatchObject({
shouldExit: true,
selectedRoute: EDGE.PARALLEL_EXIT,
})
expect(parallelOrchestrator.prepareCurrentBatch).not.toHaveBeenCalled()
})
it('prepares a batch continuation when parallel end selects parallel_continue', async () => {
const endNode = createSentinelNode('parallel-parallel-1-sentinel-end', 'end')
const dag: DAG = {
nodes: new Map([[endNode.id, endNode]]),
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
const state = createState()
const parallelOrchestrator = {
getParallelScope: vi.fn(),
prepareForBatchContinuation: vi.fn(),
}
const orchestrator = createOrchestrator(dag, state, parallelOrchestrator)
await orchestrator.handleNodeCompletion(createContext(), endNode.id, {
selectedRoute: EDGE.PARALLEL_CONTINUE,
})
expect(state.setBlockOutput).toHaveBeenCalledWith(endNode.id, {
selectedRoute: EDGE.PARALLEL_CONTINUE,
})
expect(parallelOrchestrator.prepareForBatchContinuation).toHaveBeenCalledWith('parallel-1')
})
it('marks terminal parallel exit output as final when only the continue back edge remains', async () => {
const endNode = createSentinelNode('parallel-parallel-1-sentinel-end', 'end')
endNode.outgoingEdges.set('continue', {
target: 'parallel-parallel-1-sentinel-start',
sourceHandle: EDGE.PARALLEL_CONTINUE,
})
const dag: DAG = {
nodes: new Map([[endNode.id, endNode]]),
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
const parallelOrchestrator = {
getParallelScope: vi.fn(() => ({ parallelId: 'parallel-1', totalBranches: 1 })),
aggregateParallelResults: vi.fn().mockResolvedValue({
allBranchesComplete: true,
results: [['result']],
totalBranches: 1,
}),
}
const orchestrator = createOrchestrator(dag, createState(), parallelOrchestrator)
const result = await orchestrator.executeNode(createContext(), endNode.id)
expect(result.isFinalOutput).toBe(true)
expect(result.output).toMatchObject({
results: [['result']],
selectedRoute: EDGE.PARALLEL_EXIT,
})
})
it('does not mark a continuing parallel batch as final output', async () => {
const endNode = createSentinelNode('parallel-parallel-1-sentinel-end', 'end')
endNode.outgoingEdges.set('continue', {
target: 'parallel-parallel-1-sentinel-start',
sourceHandle: EDGE.PARALLEL_CONTINUE,
})
const dag: DAG = {
nodes: new Map([[endNode.id, endNode]]),
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
const parallelOrchestrator = {
getParallelScope: vi.fn(() => ({ parallelId: 'parallel-1', totalBranches: 3 })),
aggregateParallelResults: vi.fn().mockResolvedValue({
allBranchesComplete: false,
totalBranches: 3,
}),
}
const orchestrator = createOrchestrator(dag, createState(), parallelOrchestrator)
const result = await orchestrator.executeNode(createContext(), endNode.id)
expect(result.isFinalOutput).toBe(false)
expect(result.output).toMatchObject({
selectedRoute: EDGE.PARALLEL_CONTINUE,
})
})
it('records completed nested subflow sentinels as parent parallel branch output', async () => {
const endNode = {
...createSentinelNode('loop-nested-loop-sentinel-end', 'end'),
metadata: {
isSentinel: true,
sentinelType: 'end' as const,
subflowId: 'nested-loop',
subflowType: 'loop' as const,
},
}
const dag: DAG = {
nodes: new Map([[endNode.id, endNode]]),
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
const state = createState()
const parallelOrchestrator = {
getParallelScope: vi.fn(),
handleParallelBranchCompletion: vi.fn(),
prepareForBatchContinuation: vi.fn(),
}
const orchestrator = createOrchestrator(dag, state, parallelOrchestrator)
const ctx = createContext()
ctx.subflowParentMap = new Map([
['nested-loop', { parentId: 'parent-parallel', parentType: 'parallel', branchIndex: 3 }],
])
await orchestrator.handleNodeCompletion(ctx, endNode.id, {
results: ['loop-result'],
shouldExit: true,
selectedRoute: EDGE.LOOP_EXIT,
})
expect(parallelOrchestrator.handleParallelBranchCompletion).toHaveBeenCalledWith(
ctx,
'parent-parallel',
endNode.id,
{ results: ['loop-result'] },
3
)
})
it('does not record continuing nested parallel batches as parent parallel branch output', async () => {
const endNode = {
...createSentinelNode('parallel-nested-parallel-sentinel-end', 'end'),
metadata: {
isSentinel: true,
sentinelType: 'end' as const,
subflowId: 'nested-parallel',
subflowType: 'parallel' as const,
},
}
const dag: DAG = {
nodes: new Map([[endNode.id, endNode]]),
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
const state = createState()
const parallelOrchestrator = {
getParallelScope: vi.fn(),
handleParallelBranchCompletion: vi.fn(),
prepareForBatchContinuation: vi.fn(),
}
const orchestrator = createOrchestrator(dag, state, parallelOrchestrator)
const ctx = createContext()
ctx.subflowParentMap = new Map([
['nested-parallel', { parentId: 'parent-parallel', parentType: 'parallel', branchIndex: 3 }],
])
await orchestrator.handleNodeCompletion(ctx, endNode.id, {
sentinelEnd: true,
selectedRoute: EDGE.PARALLEL_CONTINUE,
})
expect(parallelOrchestrator.handleParallelBranchCompletion).not.toHaveBeenCalled()
})
it('writes stable outer-branch output aliases for completed parallel branch nodes', async () => {
const branchNode: DAGNode = {
id: 'worker₍0₎',
block: {
id: 'worker',
position: { x: 0, y: 0 },
enabled: true,
metadata: { id: 'function', name: 'Worker' },
config: { params: {} },
inputs: {},
outputs: {},
},
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {
isParallelBranch: true,
subflowId: 'parallel-1',
subflowType: 'parallel',
originalBlockId: 'worker',
branchIndex: 2,
},
}
const dag: DAG = {
nodes: new Map([[branchNode.id, branchNode]]),
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
const state = createState()
const parallelOrchestrator = {
getParallelScope: vi.fn(() => ({ parallelId: 'parallel-1', totalBranches: 3 })),
initializeParallelScope: vi.fn(),
handleParallelBranchCompletion: vi.fn(),
}
const orchestrator = createOrchestrator(dag, state, parallelOrchestrator)
const output = { result: 'branch-2' }
const ctx = createContext()
await orchestrator.handleNodeCompletion(ctx, branchNode.id, output)
expect(parallelOrchestrator.handleParallelBranchCompletion).toHaveBeenCalledWith(
ctx,
'parallel-1',
branchNode.id,
output
)
expect(state.setBlockOutput).toHaveBeenCalledWith('worker__obranch-2', output)
expect(state.setBlockOutput).toHaveBeenCalledWith(branchNode.id, output)
})
it('records completed nested subflow sentinels as parent loop iteration output', async () => {
const endNode = {
...createSentinelNode('parallel-nested-parallel-sentinel-end', 'end'),
metadata: {
isSentinel: true,
sentinelType: 'end' as const,
subflowId: 'nested-parallel',
subflowType: 'parallel' as const,
},
}
const dag: DAG = {
nodes: new Map([[endNode.id, endNode]]),
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
const state = createState()
const loopOrchestrator = {
storeLoopNodeOutput: vi.fn(),
}
const orchestrator = createOrchestrator(dag, state, {}, loopOrchestrator)
const ctx = createContext()
ctx.subflowParentMap = new Map([
['nested-parallel', { parentId: 'parent-loop', parentType: 'loop' }],
])
await orchestrator.handleNodeCompletion(ctx, endNode.id, {
results: ['parallel-result'],
sentinelEnd: true,
selectedRoute: EDGE.PARALLEL_EXIT,
})
expect(loopOrchestrator.storeLoopNodeOutput).toHaveBeenCalledWith(
ctx,
'parent-loop',
'nested-parallel',
{ results: ['parallel-result'] }
)
})
it('does not record continuing nested loop iterations as parent loop output', async () => {
const endNode = {
...createSentinelNode('loop-nested-loop-sentinel-end', 'end'),
metadata: {
isSentinel: true,
sentinelType: 'end' as const,
subflowId: 'nested-loop',
subflowType: 'loop' as const,
},
}
const dag: DAG = {
nodes: new Map([[endNode.id, endNode]]),
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
const state = createState()
const loopOrchestrator = {
storeLoopNodeOutput: vi.fn(),
}
const orchestrator = createOrchestrator(dag, state, {}, loopOrchestrator)
const ctx = createContext()
ctx.subflowParentMap = new Map([
['nested-loop', { parentId: 'parent-loop', parentType: 'loop' }],
])
await orchestrator.handleNodeCompletion(ctx, endNode.id, {
shouldContinue: true,
selectedRoute: EDGE.LOOP_CONTINUE,
})
expect(loopOrchestrator.storeLoopNodeOutput).not.toHaveBeenCalled()
})
})
+377
View File
@@ -0,0 +1,377 @@
import { createLogger } from '@sim/logger'
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import { EDGE } from '@/executor/constants'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import type { BlockExecutor } from '@/executor/execution/block-executor'
import type { BlockStateController } from '@/executor/execution/types'
import type { LoopOrchestrator } from '@/executor/orchestrators/loop'
import type { ParallelOrchestrator } from '@/executor/orchestrators/parallel'
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
import {
buildOuterBranchScopedId,
extractBaseBlockId,
extractOuterBranchIndex,
} from '@/executor/utils/subflow-utils'
const logger = createLogger('NodeExecutionOrchestrator')
function getResultCount(value: unknown): number {
if (isLargeValueRef(value)) {
const preview = value.preview
if (
preview &&
typeof preview === 'object' &&
typeof (preview as Record<string, unknown>).length === 'number'
) {
return (preview as { length: number }).length
}
}
return Array.isArray(value) ? value.length : 0
}
function getSubflowResultOutput(output: NormalizedBlockOutput): NormalizedBlockOutput {
return { results: output.results ?? [] }
}
export interface NodeExecutionResult {
nodeId: string
output: NormalizedBlockOutput
isFinalOutput: boolean
}
export class NodeExecutionOrchestrator {
constructor(
private dag: DAG,
private state: BlockStateController,
private blockExecutor: BlockExecutor,
private loopOrchestrator: LoopOrchestrator,
private parallelOrchestrator: ParallelOrchestrator
) {}
async executeNode(ctx: ExecutionContext, nodeId: string): Promise<NodeExecutionResult> {
const node = this.dag.nodes.get(nodeId)
if (!node) {
throw new Error(`Node not found in DAG: ${nodeId}`)
}
if (ctx.runFromBlockContext && !ctx.runFromBlockContext.dirtySet.has(nodeId)) {
const cachedOutput = this.state.getBlockOutput(nodeId) || {}
logger.debug('Skipping non-dirty block in run-from-block mode', { nodeId })
return {
nodeId,
output: cachedOutput,
isFinalOutput: false,
}
}
const isDirtyBlock = ctx.runFromBlockContext?.dirtySet.has(nodeId) ?? false
if (!isDirtyBlock && this.state.hasExecuted(nodeId)) {
const output = this.state.getBlockOutput(nodeId) || {}
return {
nodeId,
output,
isFinalOutput: false,
}
}
const loopId = node.metadata.subflowType === 'loop' ? node.metadata.subflowId : undefined
if (loopId && !this.loopOrchestrator.getLoopScope(ctx, loopId)) {
await this.loopOrchestrator.initializeLoopScope(ctx, loopId)
}
const parallelId =
node.metadata.subflowType === 'parallel' ? node.metadata.subflowId : undefined
if (parallelId && !this.parallelOrchestrator.getParallelScope(ctx, parallelId)) {
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId)
}
if (node.metadata.isSentinel) {
const output = await this.handleSentinel(ctx, node)
const isFinalOutput = this.isFinalSentinelOutput(node, output)
return {
nodeId,
output,
isFinalOutput,
}
}
const output = await this.blockExecutor.execute(ctx, node, node.block)
const isFinalOutput = node.outgoingEdges.size === 0
return {
nodeId,
output,
isFinalOutput,
}
}
private isFinalSentinelOutput(node: DAGNode, output: NormalizedBlockOutput): boolean {
const selectedRoute = output.selectedRoute
if (selectedRoute === EDGE.LOOP_CONTINUE || selectedRoute === EDGE.PARALLEL_CONTINUE) {
return false
}
if (selectedRoute === EDGE.LOOP_EXIT || selectedRoute === EDGE.PARALLEL_EXIT) {
return !Array.from(node.outgoingEdges.values()).some(
(edge) => edge.sourceHandle === selectedRoute
)
}
return node.outgoingEdges.size === 0
}
private async handleSentinel(
ctx: ExecutionContext,
node: DAGNode
): Promise<NormalizedBlockOutput> {
const sentinelType = node.metadata.sentinelType
const subflowType = node.metadata.subflowType
const subflowId = node.metadata.subflowId
if (!subflowType || !subflowId) {
logger.warn('Sentinel missing subflow metadata', { nodeId: node.id, sentinelType })
return {}
}
if (subflowType === 'parallel') {
return await this.handleParallelSentinel(ctx, node, sentinelType, subflowId)
}
switch (sentinelType) {
case 'start': {
const shouldExecute = await this.loopOrchestrator.evaluateInitialCondition(ctx, subflowId)
if (!shouldExecute) {
logger.info('Loop initial condition false, skipping loop body', { loopId: subflowId })
return {
sentinelStart: true,
shouldExit: true,
selectedRoute: EDGE.LOOP_EXIT,
}
}
return { sentinelStart: true }
}
case 'end': {
const continuationResult = await this.loopOrchestrator.evaluateLoopContinuation(
ctx,
subflowId
)
if (continuationResult.shouldContinue) {
return {
shouldContinue: true,
shouldExit: false,
selectedRoute: continuationResult.selectedRoute,
}
}
return {
results: continuationResult.aggregatedResults || [],
shouldContinue: false,
shouldExit: true,
selectedRoute: continuationResult.selectedRoute,
totalIterations:
continuationResult.totalIterations ??
getResultCount(continuationResult.aggregatedResults),
}
}
default:
logger.warn('Unknown sentinel type', { sentinelType })
return {}
}
}
private async handleParallelSentinel(
ctx: ExecutionContext,
node: DAGNode,
sentinelType: string | undefined,
parallelId: string
): Promise<NormalizedBlockOutput> {
if (sentinelType === 'start') {
if (!this.parallelOrchestrator.getParallelScope(ctx, parallelId)) {
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
if (parallelConfig) {
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId)
}
}
const scope = this.parallelOrchestrator.getParallelScope(ctx, parallelId)
if (scope?.isEmpty) {
logger.info('Parallel has empty distribution, skipping parallel body', { parallelId })
return {
sentinelStart: true,
shouldExit: true,
selectedRoute: EDGE.PARALLEL_EXIT,
}
}
this.parallelOrchestrator.prepareCurrentBatch(ctx, parallelId)
return { sentinelStart: true }
}
if (sentinelType === 'end') {
const result = await this.parallelOrchestrator.aggregateParallelResults(ctx, parallelId)
if (!result.allBranchesComplete) {
return {
results: [],
sentinelEnd: true,
selectedRoute: EDGE.PARALLEL_CONTINUE,
totalBranches: result.totalBranches,
}
}
return {
results: result.results || [],
sentinelEnd: true,
selectedRoute: EDGE.PARALLEL_EXIT,
totalBranches: result.totalBranches,
}
}
logger.warn('Unknown parallel sentinel type', { sentinelType })
return {}
}
async handleNodeCompletion(
ctx: ExecutionContext,
nodeId: string,
output: NormalizedBlockOutput
): Promise<void> {
const node = this.dag.nodes.get(nodeId)
if (!node) {
logger.error('Node not found during completion handling', { nodeId })
return
}
const loopId = node.metadata.subflowType === 'loop' ? node.metadata.subflowId : undefined
const isParallelBranch = node.metadata.isParallelBranch
const isSentinel = node.metadata.isSentinel
if (isSentinel) {
this.handleRegularNodeCompletion(ctx, node, output)
this.handleParentSubflowCompletion(ctx, node, output)
} else if (loopId) {
this.handleLoopNodeCompletion(ctx, node, output, loopId)
} else if (isParallelBranch) {
const parallelId =
node.metadata.subflowType === 'parallel' ? node.metadata.subflowId : undefined
if (parallelId) {
await this.handleParallelNodeCompletion(ctx, node, output, parallelId)
} else {
logger.warn('Parallel branch missing subflow metadata', { nodeId: node.id })
this.handleRegularNodeCompletion(ctx, node, output)
}
} else {
this.handleRegularNodeCompletion(ctx, node, output)
}
}
private handleLoopNodeCompletion(
ctx: ExecutionContext,
node: DAGNode,
output: NormalizedBlockOutput,
loopId: string
): void {
this.loopOrchestrator.storeLoopNodeOutput(ctx, loopId, node.id, output)
this.state.setBlockOutput(node.id, output)
}
private async handleParallelNodeCompletion(
ctx: ExecutionContext,
node: DAGNode,
output: NormalizedBlockOutput,
parallelId: string
): Promise<void> {
const scope = this.parallelOrchestrator.getParallelScope(ctx, parallelId)
if (!scope) {
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId)
}
this.parallelOrchestrator.handleParallelBranchCompletion(ctx, parallelId, node.id, output)
const branchIndex = node.metadata.branchIndex
if (branchIndex !== undefined && extractOuterBranchIndex(node.id) === undefined) {
const originalBlockId = node.metadata.originalBlockId ?? extractBaseBlockId(node.id)
this.state.setBlockOutput(buildOuterBranchScopedId(originalBlockId, branchIndex), output)
}
this.state.setBlockOutput(node.id, output)
}
private handleParentSubflowCompletion(
ctx: ExecutionContext,
node: DAGNode,
output: NormalizedBlockOutput
): void {
if (node.metadata.sentinelType !== 'end' || !node.metadata.subflowId) {
return
}
if (
output.selectedRoute === EDGE.LOOP_CONTINUE ||
output.selectedRoute === EDGE.LOOP_CONTINUE_ALT ||
output.selectedRoute === EDGE.PARALLEL_CONTINUE
) {
return
}
const subflowId = node.metadata.subflowId
const parentEntry = ctx.subflowParentMap?.get(subflowId)
if (!parentEntry) {
return
}
if (parentEntry.parentType === 'parallel') {
if (parentEntry.branchIndex === undefined) {
return
}
this.parallelOrchestrator.handleParallelBranchCompletion(
ctx,
parentEntry.parentId,
node.id,
getSubflowResultOutput(output),
parentEntry.branchIndex
)
return
}
this.loopOrchestrator.storeLoopNodeOutput(
ctx,
parentEntry.parentId,
subflowId,
getSubflowResultOutput(output)
)
}
private handleRegularNodeCompletion(
ctx: ExecutionContext,
node: DAGNode,
output: NormalizedBlockOutput
): void {
this.state.setBlockOutput(node.id, output)
if (
node.metadata.isSentinel &&
node.metadata.subflowType === 'loop' &&
node.metadata.sentinelType === 'end' &&
output.selectedRoute === 'loop_continue'
) {
const loopId = node.metadata.subflowId
if (!loopId) {
logger.warn('Loop sentinel missing subflow metadata', { nodeId: node.id })
return
}
this.loopOrchestrator.clearLoopExecutionState(loopId, ctx)
this.loopOrchestrator.restoreLoopEdges(loopId)
}
if (
node.metadata.subflowType === 'parallel' &&
node.metadata.sentinelType === 'end' &&
output.selectedRoute === EDGE.PARALLEL_CONTINUE
) {
const parallelId = node.metadata.subflowId
if (!parallelId) {
logger.warn('Parallel sentinel missing subflow metadata', { nodeId: node.id })
return
}
this.parallelOrchestrator.prepareForBatchContinuation(parallelId)
}
}
}
@@ -0,0 +1,610 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DEFAULTS } from '@/executor/constants'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import type { BlockStateWriter, ContextExtensions } from '@/executor/execution/types'
import { ParallelOrchestrator } from '@/executor/orchestrators/parallel'
import type { ExecutionContext } from '@/executor/types'
import {
buildBranchNodeId,
buildParallelSentinelEndId,
buildParallelSentinelStartId,
buildSentinelEndId,
buildSentinelStartId,
} from '@/executor/utils/subflow-utils'
const { mockCompactSubflowResults } = vi.hoisted(() => ({
mockCompactSubflowResults: vi.fn(async (results: unknown) => results),
}))
vi.mock('@/lib/execution/payloads/serializer', () => ({
compactSubflowResults: mockCompactSubflowResults,
}))
function createDag(): DAG {
return {
nodes: new Map(),
loopConfigs: new Map(),
parallelConfigs: new Map([
[
'parallel-1',
{
id: 'parallel-1',
nodes: ['task-1'],
distribution: [],
parallelType: 'collection',
},
],
]),
}
}
function createDagNode(id: string, metadata: DAGNode['metadata'] = {}): DAGNode {
return {
id,
block: {
id,
position: { x: 0, y: 0 },
config: { tool: '', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'function', name: id },
enabled: true,
},
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata,
}
}
function createState(): BlockStateWriter {
return {
setBlockOutput: vi.fn(),
setBlockState: vi.fn(),
deleteBlockState: vi.fn(),
unmarkExecuted: vi.fn(),
}
}
function createEdgeManager() {
return {
clearDeactivatedEdgesForNodes: vi.fn(),
}
}
function createContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
blockStates: new Map(),
executedBlocks: new Set(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: {
router: new Map(),
condition: new Map(),
},
completedLoops: new Set(),
activeExecutionPath: new Set(),
workflow: {
version: '1',
blocks: [
{
id: 'parallel-1',
position: { x: 0, y: 0 },
config: { tool: '', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'parallel', name: 'Parallel 1' },
enabled: true,
},
],
connections: [],
loops: {},
parallels: {},
},
...overrides,
}
}
describe('ParallelOrchestrator', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCompactSubflowResults.mockImplementation(async (results: unknown) => results)
})
it('defers empty-subflow lifecycle callbacks to the sentinel end path', async () => {
const onBlockStart = vi.fn()
const onBlockComplete = vi.fn()
const contextExtensions: ContextExtensions = {
onBlockStart,
onBlockComplete,
}
const orchestrator = new ParallelOrchestrator(
createDag(),
createState(),
null,
contextExtensions
)
const ctx = createContext()
const scope = await orchestrator.initializeParallelScope(ctx, 'parallel-1')
expect(onBlockStart).not.toHaveBeenCalled()
expect(onBlockComplete).not.toHaveBeenCalled()
expect(scope.isEmpty).toBe(true)
})
it('returns an empty scope without emitting start-side lifecycle callbacks', async () => {
const contextExtensions: ContextExtensions = {
onBlockStart: vi.fn().mockRejectedValue(new Error('start failed')),
onBlockComplete: vi.fn().mockRejectedValue(new Error('complete failed')),
}
const orchestrator = new ParallelOrchestrator(
createDag(),
createState(),
null,
contextExtensions
)
await expect(
orchestrator.initializeParallelScope(createContext(), 'parallel-1', 1)
).resolves.toMatchObject({
parallelId: 'parallel-1',
isEmpty: true,
})
expect(contextExtensions.onBlockStart).not.toHaveBeenCalled()
expect(contextExtensions.onBlockComplete).not.toHaveBeenCalled()
})
it('resolves collection distributions with the parallel start sentinel scope', async () => {
const dag = createDag()
const parallelConfig = dag.parallelConfigs.get('parallel-1')!
parallelConfig.distribution = '<Producer.items>'
const resolver = {
resolveSingleReference: vi.fn().mockResolvedValue(['item-1', 'item-2']),
}
const orchestrator = new ParallelOrchestrator(
dag,
createState(),
resolver as any,
{},
undefined,
createEdgeManager() as any
)
const scope = await orchestrator.initializeParallelScope(createContext(), 'parallel-1')
expect(resolver.resolveSingleReference).toHaveBeenCalledWith(
expect.any(Object),
'parallel-parallel-1-sentinel-start',
'<Producer.items>',
undefined,
{ allowLargeValueRefs: true }
)
expect(scope.totalBranches).toBe(2)
})
it('records resumed later-batch outputs under restored global branch indexes', () => {
const dag = createDag()
dag.nodes.set('task-1', {
id: 'task-1',
block: {
id: 'task-1',
position: { x: 0, y: 0 },
config: { tool: '', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'function', name: 'Task 1' },
enabled: true,
},
incomingEdges: new Set(),
outgoingEdges: new Set(),
metadata: { branchIndex: 0 },
})
const orchestrator = new ParallelOrchestrator(dag, createState(), null, {})
const ctx = createContext({
parallelBlockMapping: new Map([
['task-1', { originalBlockId: 'task', parallelId: 'parallel-1', iterationIndex: 20 }],
]),
parallelExecutions: new Map([
[
'parallel-1',
{
parallelId: 'parallel-1',
totalBranches: 25,
currentBatchStart: 20,
currentBatchSize: 5,
accumulatedOutputs: new Map([[0, [{ output: 'previous' }]]]),
branchOutputs: new Map(),
},
],
]),
})
orchestrator.handleParallelBranchCompletion(ctx, 'parallel-1', 'task-1', { output: 'resumed' })
const scope = ctx.parallelExecutions?.get('parallel-1')
expect(scope?.branchOutputs.get(20)).toEqual([{ output: 'resumed' }])
expect(scope?.branchOutputs.has(0)).toBe(false)
})
it('clamps batch size and caps current batch to total branch count', async () => {
const dag = createDag()
const parallelConfig = dag.parallelConfigs.get('parallel-1')!
parallelConfig.parallelType = 'count'
parallelConfig.count = 9
parallelConfig.batchSize = 0
const orchestrator = new ParallelOrchestrator(dag, createState(), null, {})
const zeroBatchScope = await orchestrator.initializeParallelScope(createContext(), 'parallel-1')
expect(zeroBatchScope.batchSize).toBe(1)
expect(zeroBatchScope.currentBatchSize).toBe(1)
parallelConfig.batchSize = 50
const oversizedBatchScope = await orchestrator.initializeParallelScope(
createContext(),
'parallel-1'
)
expect(oversizedBatchScope.currentBatchSize).toBe(9)
})
it.each([
['oversized numeric batch size', 999, DEFAULTS.MAX_PARALLEL_BRANCHES],
['negative batch size', -1, 1],
['undefined batch size', undefined, DEFAULTS.MAX_PARALLEL_BRANCHES],
['nonnumeric batch size', 'not-a-number', DEFAULTS.MAX_PARALLEL_BRANCHES],
])('normalizes %s', async (_name, batchSize, expectedBatchSize) => {
const dag = createDag()
const parallelConfig = dag.parallelConfigs.get('parallel-1')!
parallelConfig.parallelType = 'count'
parallelConfig.count = DEFAULTS.MAX_PARALLEL_BRANCHES + 10
parallelConfig.batchSize = batchSize as never
const orchestrator = new ParallelOrchestrator(dag, createState(), null, {})
const scope = await orchestrator.initializeParallelScope(createContext(), 'parallel-1')
expect(scope.batchSize).toBe(expectedBatchSize)
expect(scope.currentBatchSize).toBe(expectedBatchSize)
})
it('advances batch state at sentinel end and prepares the next batch at sentinel start', async () => {
const dag = createDag()
const templateBranchId = buildBranchNodeId('task-1', 0)
const secondBranchId = buildBranchNodeId('task-1', 1)
dag.nodes.set(templateBranchId, {
id: templateBranchId,
block: {
id: 'task-1',
position: { x: 0, y: 0 },
config: { tool: '', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'function', name: 'Task 1' },
enabled: true,
},
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {
subflowId: 'parallel-1',
subflowType: 'parallel',
isParallelBranch: true,
branchIndex: 0,
},
})
const state = createState()
const edgeManager = createEdgeManager()
const orchestrator = new ParallelOrchestrator(dag, state, null, {}, edgeManager)
const scope = {
parallelId: 'parallel-1',
totalBranches: 4,
batchSize: 2,
currentBatchStart: 0,
currentBatchSize: 2,
accumulatedOutputs: new Map<number, any[]>(),
branchOutputs: new Map<number, any[]>([
[0, [{ output: 'branch-0' }]],
[1, [{ output: 'branch-1' }]],
]),
}
const ctx = createContext({
parallelExecutions: new Map([['parallel-1', scope]]),
})
const result = await orchestrator.aggregateParallelResults(ctx, 'parallel-1')
expect(result.allBranchesComplete).toBe(false)
expect(scope.currentBatchStart).toBe(2)
expect(scope.currentBatchSize).toBe(2)
expect(ctx.parallelBlockMapping?.size ?? 0).toBe(0)
orchestrator.prepareCurrentBatch(ctx, 'parallel-1')
expect(ctx.parallelBlockMapping?.get(templateBranchId)).toMatchObject({
originalBlockId: 'task-1',
parallelId: 'parallel-1',
iterationIndex: 2,
})
expect(ctx.parallelBlockMapping?.get(secondBranchId)).toMatchObject({
originalBlockId: 'task-1',
parallelId: 'parallel-1',
iterationIndex: 3,
})
expect(state.deleteBlockState).toHaveBeenCalledWith(templateBranchId)
expect(state.deleteBlockState).toHaveBeenCalledWith(secondBranchId)
expect(edgeManager.clearDeactivatedEdgesForNodes).toHaveBeenCalledWith(
new Set([templateBranchId, secondBranchId])
)
})
it('resets only incoming batch branch state when scheduling later batches', async () => {
const dag = createDag()
const incomingBranchId = buildBranchNodeId('task-1', 0)
const previousBranchId = buildBranchNodeId('task-1', 1)
dag.nodes.set(incomingBranchId, {
id: incomingBranchId,
block: {
id: 'task-1',
position: { x: 0, y: 0 },
config: { tool: '', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'function', name: 'Task 1' },
enabled: true,
},
incomingEdges: new Set(),
outgoingEdges: new Set(),
metadata: {
subflowId: 'parallel-1',
subflowType: 'parallel',
isParallelBranch: true,
branchIndex: 0,
},
})
dag.nodes.set(previousBranchId, {
id: previousBranchId,
block: {
id: 'task-1',
position: { x: 0, y: 0 },
config: { tool: '', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'function', name: 'Task 1' },
enabled: true,
},
incomingEdges: new Set(),
outgoingEdges: new Set(),
metadata: {
subflowId: 'parallel-1',
subflowType: 'parallel',
isParallelBranch: true,
branchIndex: 1,
},
})
const state = createState()
const orchestrator = new ParallelOrchestrator(dag, state, null, {})
orchestrator.prepareCurrentBatch(
createContext({
parallelExecutions: new Map([
[
'parallel-1',
{
parallelId: 'parallel-1',
totalBranches: 3,
batchSize: 1,
currentBatchStart: 2,
currentBatchSize: 1,
accumulatedOutputs: new Map([[1, [{ output: 'previous' }]]]),
branchOutputs: new Map(),
},
],
]),
}),
'parallel-1'
)
expect(state.deleteBlockState).toHaveBeenCalledWith(incomingBranchId)
expect(state.deleteBlockState).not.toHaveBeenCalledWith(previousBranchId)
expect(state.unmarkExecuted).toHaveBeenCalledWith(incomingBranchId)
expect(state.unmarkExecuted).not.toHaveBeenCalledWith(previousBranchId)
})
it('marks expanded branch nodes dirty when running from a dirty parallel container', () => {
const dag = createDag()
const templateBranchId = buildBranchNodeId('task-1', 0)
const secondBranchId = buildBranchNodeId('task-1', 1)
dag.nodes.set(templateBranchId, {
id: templateBranchId,
block: {
id: 'task-1',
position: { x: 0, y: 0 },
config: { tool: '', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'function', name: 'Task 1' },
enabled: true,
},
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {
subflowId: 'parallel-1',
subflowType: 'parallel',
isParallelBranch: true,
branchIndex: 0,
},
})
const dirtySet = new Set(['parallel-1'])
const orchestrator = new ParallelOrchestrator(dag, createState(), null, {})
orchestrator.prepareCurrentBatch(
createContext({
runFromBlockContext: { startBlockId: 'parallel-1', dirtySet },
parallelExecutions: new Map([
[
'parallel-1',
{
parallelId: 'parallel-1',
totalBranches: 2,
batchSize: 2,
currentBatchStart: 0,
currentBatchSize: 2,
branchOutputs: new Map(),
},
],
]),
}),
'parallel-1'
)
expect(dirtySet.has(templateBranchId)).toBe(true)
expect(dirtySet.has(secondBranchId)).toBe(true)
})
it('marks cloned nested loop body nodes dirty for non-zero branches', () => {
const dag = createDag()
const parallelId = 'parallel-1'
const loopId = 'loop-1'
const taskId = 'task-1'
const parallelStartId = buildParallelSentinelStartId(parallelId)
const parallelEndId = buildParallelSentinelEndId(parallelId)
const loopStartId = buildSentinelStartId(loopId)
const loopEndId = buildSentinelEndId(loopId)
dag.parallelConfigs.set(parallelId, {
id: parallelId,
nodes: [loopId],
count: 2,
parallelType: 'count',
})
dag.loopConfigs.set(loopId, {
id: loopId,
nodes: [taskId],
loopType: 'for',
iterations: 1,
})
dag.nodes.set(parallelStartId, createDagNode(parallelStartId))
dag.nodes.set(parallelEndId, createDagNode(parallelEndId))
dag.nodes.set(
loopStartId,
createDagNode(loopStartId, {
isSentinel: true,
sentinelType: 'start',
subflowId: loopId,
subflowType: 'loop',
})
)
dag.nodes.set(
taskId,
createDagNode(taskId, {
isLoopNode: true,
subflowId: loopId,
subflowType: 'loop',
originalBlockId: taskId,
})
)
dag.nodes.set(
loopEndId,
createDagNode(loopEndId, {
isSentinel: true,
sentinelType: 'end',
subflowId: loopId,
subflowType: 'loop',
})
)
dag.nodes.get(loopStartId)!.outgoingEdges.set(`${loopStartId}->${taskId}`, { target: taskId })
dag.nodes.get(taskId)!.incomingEdges.add(loopStartId)
dag.nodes.get(taskId)!.outgoingEdges.set(`${taskId}->${loopEndId}`, { target: loopEndId })
dag.nodes.get(loopEndId)!.incomingEdges.add(taskId)
const dirtySet = new Set([parallelId])
const orchestrator = new ParallelOrchestrator(dag, createState(), null, {})
orchestrator.prepareCurrentBatch(
createContext({
runFromBlockContext: { startBlockId: parallelId, dirtySet },
parallelExecutions: new Map([
[
parallelId,
{
parallelId,
totalBranches: 2,
batchSize: 2,
currentBatchStart: 0,
currentBatchSize: 2,
branchOutputs: new Map(),
},
],
]),
}),
parallelId
)
expect([...dirtySet]).toContain(taskId)
expect([...dirtySet].some((nodeId) => nodeId.startsWith(`${taskId}__clone`))).toBe(true)
})
it('compacts accumulated outputs before scheduling later batches', async () => {
const dag = createDag()
const templateBranchId = buildBranchNodeId('task-1', 0)
dag.nodes.set(templateBranchId, {
id: templateBranchId,
block: {
id: 'task-1',
position: { x: 0, y: 0 },
config: { tool: '', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'function', name: 'Task 1' },
enabled: true,
},
incomingEdges: new Set(),
outgoingEdges: new Set(),
metadata: {
subflowId: 'parallel-1',
subflowType: 'parallel',
isParallelBranch: true,
branchIndex: 0,
},
})
const orchestrator = new ParallelOrchestrator(dag, createState(), null, {})
const previousOutputs = [{ output: 'previous' }]
const incomingOutputs = [{ output: 'incoming' }]
const compactedPrevious = [{ output: 'compacted-previous' }]
const compactedIncoming = [{ output: 'compacted-incoming' }]
mockCompactSubflowResults.mockResolvedValueOnce([compactedPrevious, compactedIncoming])
const scope = {
parallelId: 'parallel-1',
totalBranches: 3,
batchSize: 1,
currentBatchStart: 0,
currentBatchSize: 2,
accumulatedOutputs: new Map([[0, previousOutputs]]),
branchOutputs: new Map([[1, incomingOutputs]]),
}
const ctx = createContext({
parallelExecutions: new Map([['parallel-1', scope]]),
})
const result = await orchestrator.aggregateParallelResults(ctx, 'parallel-1')
expect(result).toMatchObject({ allBranchesComplete: false, completedBranches: 2 })
expect(mockCompactSubflowResults).toHaveBeenCalledWith(
[previousOutputs, incomingOutputs],
expect.objectContaining({
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
requireDurable: true,
})
)
expect(scope.accumulatedOutputs.get(0)).toBe(compactedPrevious)
expect(scope.accumulatedOutputs.get(1)).toBe(compactedIncoming)
})
})
+595
View File
@@ -0,0 +1,595 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { compactSubflowResults } from '@/lib/execution/payloads/serializer'
import { DEFAULTS } from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
import type { EdgeManager } from '@/executor/execution/edge-manager'
import type { ParallelScope } from '@/executor/execution/state'
import type { BlockStateWriter, ContextExtensions } from '@/executor/execution/types'
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
import { type ClonedSubflowInfo, ParallelExpander } from '@/executor/utils/parallel-expansion'
import {
addSubflowErrorLog,
buildBranchNodeId,
buildParallelSentinelEndId,
buildParallelSentinelStartId,
buildSentinelEndId,
buildSentinelStartId,
emitSubflowSuccessEvents,
extractBaseBlockId,
extractBranchIndex,
} from '@/executor/utils/subflow-utils'
import { resolveArrayInputAsync } from '@/executor/utils/subflow-utils.server'
import type { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedParallel } from '@/serializer/types'
const logger = createLogger('ParallelOrchestrator')
const DEFAULT_PARALLEL_BATCH_SIZE = 20
export interface ParallelBranchMetadata {
branchIndex: number
branchTotal: number
distributionItem?: any
parallelId: string
}
export interface ParallelAggregationResult {
allBranchesComplete: boolean
results?: unknown
completedBranches?: number
totalBranches?: number
}
export class ParallelOrchestrator {
private expander = new ParallelExpander()
constructor(
private dag: DAG,
private state: BlockStateWriter,
private resolver: VariableResolver | null = null,
private contextExtensions: ContextExtensions | null = null,
private edgeManager: Pick<EdgeManager, 'clearDeactivatedEdgesForNodes'> | null = null
) {}
async initializeParallelScope(ctx: ExecutionContext, parallelId: string): Promise<ParallelScope> {
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
if (!parallelConfig) {
throw new Error(`Parallel config not found: ${parallelId}`)
}
if (parallelConfig.nodes.length === 0) {
const errorMessage =
'Parallel has no executable blocks inside. Add or enable at least one block in the parallel.'
logger.error(errorMessage, { parallelId })
await this.addParallelErrorLog(ctx, parallelId, errorMessage, {})
this.setErrorScope(ctx, parallelId, errorMessage)
throw new Error(errorMessage)
}
let items: any[] | undefined
let branchCount: number
let isEmpty = false
try {
const resolved = await this.resolveBranchCount(ctx, parallelConfig, parallelId)
branchCount = resolved.branchCount
items = resolved.items
isEmpty = resolved.isEmpty ?? false
} catch (error) {
const baseErrorMessage = toError(error).message
const errorMessage = baseErrorMessage.startsWith('Parallel collection distribution is empty')
? baseErrorMessage
: `Parallel Items did not resolve: ${baseErrorMessage}`
logger.error(errorMessage, { parallelId, distribution: parallelConfig.distribution })
await this.addParallelErrorLog(ctx, parallelId, errorMessage, {
distribution: parallelConfig.distribution,
})
this.setErrorScope(ctx, parallelId, errorMessage)
throw new Error(errorMessage)
}
if (isEmpty || branchCount === 0) {
const scope: ParallelScope = {
parallelId,
totalBranches: 0,
branchOutputs: new Map(),
items: [],
isEmpty: true,
}
if (!ctx.parallelExecutions) {
ctx.parallelExecutions = new Map()
}
ctx.parallelExecutions.set(parallelId, scope)
logger.info('Parallel scope initialized with empty distribution, skipping body', {
parallelId,
branchCount: 0,
})
return scope
}
const batchSize = this.resolveBatchSize(parallelConfig.batchSize)
const currentBatchSize = Math.min(batchSize, branchCount)
const scope: ParallelScope = {
parallelId,
totalBranches: branchCount,
batchSize,
currentBatchStart: 0,
currentBatchSize,
accumulatedOutputs: new Map(),
branchOutputs: new Map(),
items,
}
if (!ctx.parallelExecutions) {
ctx.parallelExecutions = new Map()
}
ctx.parallelExecutions.set(parallelId, scope)
logger.info('Parallel scope initialized', {
parallelId,
branchCount,
batchSize,
currentBatchSize,
})
return scope
}
prepareCurrentBatch(ctx: ExecutionContext, parallelId: string): void {
const scope = ctx.parallelExecutions?.get(parallelId)
if (!scope || scope.isEmpty) {
return
}
const currentBatchStart = scope.currentBatchStart ?? 0
const currentBatchSize =
scope.currentBatchSize ??
Math.min(scope.batchSize ?? DEFAULT_PARALLEL_BATCH_SIZE, scope.totalBranches)
if (currentBatchSize <= 0) {
return
}
const batchItems = scope.items?.slice(currentBatchStart, currentBatchStart + currentBatchSize)
const { clonedSubflows, allBranchNodes, entryNodes, terminalNodes } =
this.expander.expandParallel(this.dag, parallelId, currentBatchSize, batchItems, {
branchIndexOffset: currentBatchStart,
totalBranches: scope.totalBranches,
})
this.markRunFromBlockBatchDirty(
ctx,
parallelId,
allBranchNodes,
entryNodes,
terminalNodes,
clonedSubflows
)
this.registerClonedSubflows(ctx, parallelId, clonedSubflows)
this.registerBranchMappings(ctx, parallelId, allBranchNodes)
this.resetBatchExecutionState(allBranchNodes)
this.edgeManager?.clearDeactivatedEdgesForNodes(new Set(allBranchNodes))
logger.info('Prepared parallel batch', {
parallelId,
currentBatchStart,
currentBatchSize,
totalBranches: scope.totalBranches,
branchNodeCount: allBranchNodes.length,
})
}
private async resolveBranchCount(
ctx: ExecutionContext,
config: SerializedParallel,
parallelId: string
): Promise<{ branchCount: number; items?: any[]; isEmpty?: boolean }> {
if (config.parallelType === 'count') {
return { branchCount: config.count ?? 1 }
}
const items = await this.resolveDistributionItems(ctx, config)
if (items.length === 0) {
logger.info('Parallel has empty distribution, skipping parallel body', { parallelId })
return { branchCount: 0, items: [], isEmpty: true }
}
return { branchCount: items.length, items }
}
private async addParallelErrorLog(
ctx: ExecutionContext,
parallelId: string,
errorMessage: string,
inputData?: any
): Promise<void> {
await addSubflowErrorLog(
ctx,
parallelId,
'parallel',
errorMessage,
inputData || {},
this.contextExtensions
)
}
private setErrorScope(ctx: ExecutionContext, parallelId: string, errorMessage: string): void {
const scope: ParallelScope = {
parallelId,
totalBranches: 0,
branchOutputs: new Map(),
items: [],
validationError: errorMessage,
}
if (!ctx.parallelExecutions) {
ctx.parallelExecutions = new Map()
}
ctx.parallelExecutions.set(parallelId, scope)
}
private async resolveDistributionItems(
ctx: ExecutionContext,
config: SerializedParallel
): Promise<any[]> {
if (
config.distribution === undefined ||
config.distribution === null ||
config.distribution === ''
) {
throw new Error(
'Parallel collection distribution is empty. Provide an array or a reference that resolves to a collection.'
)
}
return resolveArrayInputAsync(
ctx,
config.distribution,
this.resolver,
buildParallelSentinelStartId(config.id)
)
}
private resolveBatchSize(batchSize: unknown): number {
const parsed =
typeof batchSize === 'number' ? batchSize : Number.parseInt(String(batchSize), 10)
if (Number.isNaN(parsed)) {
return DEFAULT_PARALLEL_BATCH_SIZE
}
return Math.max(1, Math.min(DEFAULTS.MAX_PARALLEL_BRANCHES, parsed))
}
private registerClonedSubflows(
ctx: ExecutionContext,
parallelId: string,
clonedSubflows: ClonedSubflowInfo[]
): void {
if (clonedSubflows.length === 0 || !ctx.subflowParentMap) {
return
}
const branchCloneMaps = new Map<number, Map<string, string>>()
for (const clone of clonedSubflows) {
let map = branchCloneMaps.get(clone.outerBranchIndex)
if (!map) {
map = new Map()
branchCloneMaps.set(clone.outerBranchIndex, map)
}
map.set(clone.originalId, clone.clonedId)
}
for (const clone of clonedSubflows) {
const originalEntry = ctx.subflowParentMap.get(clone.originalId)
if (originalEntry) {
const cloneMap = branchCloneMaps.get(clone.outerBranchIndex)
const clonedParentId = cloneMap?.get(originalEntry.parentId)
if (clonedParentId) {
ctx.subflowParentMap.set(clone.clonedId, {
parentId: clonedParentId,
parentType: originalEntry.parentType,
branchIndex: 0,
})
} else {
ctx.subflowParentMap.set(clone.clonedId, {
parentId: parallelId,
parentType: 'parallel',
branchIndex: clone.outerBranchIndex,
})
}
} else {
ctx.subflowParentMap.set(clone.clonedId, {
parentId: parallelId,
parentType: 'parallel',
branchIndex: clone.outerBranchIndex,
})
}
}
}
private markRunFromBlockBatchDirty(
ctx: ExecutionContext,
parallelId: string,
allBranchNodes: string[],
entryNodes: string[],
terminalNodes: string[],
clonedSubflows: ClonedSubflowInfo[]
): void {
const dirtySet = ctx.runFromBlockContext?.dirtySet
if (!dirtySet) return
const parallelStartId = buildParallelSentinelStartId(parallelId)
const parallelEndId = buildParallelSentinelEndId(parallelId)
if (
!dirtySet.has(parallelId) &&
!dirtySet.has(parallelStartId) &&
!dirtySet.has(parallelEndId)
) {
return
}
for (const nodeId of allBranchNodes) dirtySet.add(nodeId)
for (const nodeId of entryNodes) dirtySet.add(nodeId)
for (const nodeId of terminalNodes) dirtySet.add(nodeId)
const config = this.dag.parallelConfigs.get(parallelId)
for (const nodeId of config?.nodes ?? []) {
if (this.dag.parallelConfigs.has(nodeId) || this.dag.loopConfigs.has(nodeId)) {
this.collectSubflowNodeIds(nodeId, dirtySet)
}
}
for (const clone of clonedSubflows) {
dirtySet.add(clone.clonedId)
this.collectSubflowNodeIds(clone.clonedId, dirtySet)
}
}
private collectSubflowNodeIds(
subflowId: string,
nodeIds: Set<string>,
visited = new Set<string>()
): void {
if (visited.has(subflowId)) return
visited.add(subflowId)
if (this.dag.parallelConfigs.has(subflowId)) {
nodeIds.add(buildParallelSentinelStartId(subflowId))
nodeIds.add(buildParallelSentinelEndId(subflowId))
for (const childId of this.dag.parallelConfigs.get(subflowId)?.nodes ?? []) {
if (this.dag.parallelConfigs.has(childId) || this.dag.loopConfigs.has(childId)) {
this.collectSubflowNodeIds(childId, nodeIds, visited)
} else {
nodeIds.add(buildBranchNodeId(childId, 0))
}
}
return
}
if (this.dag.loopConfigs.has(subflowId)) {
nodeIds.add(buildSentinelStartId(subflowId))
nodeIds.add(buildSentinelEndId(subflowId))
for (const childId of this.dag.loopConfigs.get(subflowId)?.nodes ?? []) {
if (this.dag.parallelConfigs.has(childId) || this.dag.loopConfigs.has(childId)) {
this.collectSubflowNodeIds(childId, nodeIds, visited)
} else {
nodeIds.add(childId)
}
}
}
}
/**
* Stores a node's output in the branch outputs for later aggregation.
* Aggregation is triggered by the sentinel-end node via the edge mechanism,
* not by counting individual node completions. This avoids incorrect completion
* detection when branches have conditional paths (error edges, conditions).
*/
handleParallelBranchCompletion(
ctx: ExecutionContext,
parallelId: string,
nodeId: string,
output: NormalizedBlockOutput,
branchIndexOverride?: number
): void {
const scope = ctx.parallelExecutions?.get(parallelId)
if (!scope) {
logger.warn('Parallel scope not found for branch completion', { parallelId, nodeId })
return
}
const mappedBranch = ctx.parallelBlockMapping?.get(nodeId)
const branchIndex =
branchIndexOverride ??
(mappedBranch?.parallelId === parallelId
? mappedBranch.iterationIndex
: (this.dag.nodes.get(nodeId)?.metadata.branchIndex ?? extractBranchIndex(nodeId)))
if (branchIndex === null) {
logger.warn('Could not extract branch index from node ID', { nodeId })
return
}
if (!scope.branchOutputs.has(branchIndex)) {
scope.branchOutputs.set(branchIndex, [])
}
scope.branchOutputs.get(branchIndex)!.push(output)
}
async aggregateParallelResults(
ctx: ExecutionContext,
parallelId: string
): Promise<ParallelAggregationResult> {
const scope = ctx.parallelExecutions?.get(parallelId)
if (!scope) {
logger.error('Parallel scope not found for aggregation', { parallelId })
return { allBranchesComplete: false }
}
const accumulatedOutputs =
scope.accumulatedOutputs ?? new Map<number, NormalizedBlockOutput[]>()
for (const [branchIndex, outputs] of scope.branchOutputs.entries()) {
accumulatedOutputs.set(branchIndex, outputs)
}
scope.accumulatedOutputs = accumulatedOutputs
scope.branchOutputs = new Map()
const nextBatchStart =
(scope.currentBatchStart ?? 0) + (scope.currentBatchSize ?? scope.totalBranches)
if (nextBatchStart < scope.totalBranches) {
/**
* Compact accumulated outputs before scheduling the next batch. Each
* block output is already individually compacted by `block-executor`, but
* many below-threshold branch results can still exceed the aggregate
* threshold over time. Re-running the existing subflow compactor over the
* accumulated entries forces aggregate-size spills while existing
* LargeValueRefs stay stable.
*/
if (accumulatedOutputs.size > 0) {
const accumulatedBranchIndexes = Array.from(accumulatedOutputs.keys()).sort((a, b) => a - b)
const accumulatedResults = accumulatedBranchIndexes.map(
(idx) => accumulatedOutputs.get(idx) ?? []
)
const compactedAccumulated = await compactSubflowResults(accumulatedResults, {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
requireDurable: true,
})
accumulatedBranchIndexes.forEach((branchIdx, position) => {
accumulatedOutputs.set(branchIdx, compactedAccumulated[position])
})
}
this.advanceToNextBatch(scope, nextBatchStart)
return {
allBranchesComplete: false,
completedBranches: accumulatedOutputs.size,
totalBranches: scope.totalBranches,
}
}
const results: NormalizedBlockOutput[][] = []
for (let i = 0; i < scope.totalBranches; i++) {
const branchOutputs = accumulatedOutputs.get(i)
if (!branchOutputs) {
logger.warn('Missing branch output during parallel aggregation', { parallelId, branch: i })
}
results.push(branchOutputs ?? [])
}
const compactedResults = await compactSubflowResults(results, {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
requireDurable: true,
})
const output = { results: compactedResults }
this.state.setBlockOutput(parallelId, output)
scope.accumulatedOutputs = new Map()
await emitSubflowSuccessEvents(ctx, parallelId, 'parallel', output, this.contextExtensions)
return {
allBranchesComplete: true,
results: output.results,
completedBranches: scope.totalBranches,
totalBranches: scope.totalBranches,
}
}
private advanceToNextBatch(scope: ParallelScope, nextBatchStart: number): void {
const batchSize = scope.batchSize ?? DEFAULT_PARALLEL_BATCH_SIZE
const remaining = scope.totalBranches - nextBatchStart
const currentBatchSize = Math.min(batchSize, remaining)
scope.currentBatchStart = nextBatchStart
scope.currentBatchSize = currentBatchSize
logger.info('Advanced to next parallel batch', {
parallelId: scope.parallelId,
nextBatchStart,
currentBatchSize,
totalBranches: scope.totalBranches,
})
}
prepareForBatchContinuation(parallelId: string): void {
this.state.unmarkExecuted(buildParallelSentinelStartId(parallelId))
this.state.unmarkExecuted(buildParallelSentinelEndId(parallelId))
this.state.deleteBlockState(buildParallelSentinelEndId(parallelId))
}
private resetBatchExecutionState(branchNodeIds: string[]): void {
for (const nodeId of branchNodeIds) {
const node = this.dag.nodes.get(nodeId)
if (!node?.metadata.isParallelBranch) {
continue
}
this.state.unmarkExecuted(nodeId)
this.state.deleteBlockState(nodeId)
}
}
private registerBranchMappings(
ctx: ExecutionContext,
parallelId: string,
branchNodeIds: string[]
): void {
if (branchNodeIds.length === 0) {
return
}
if (!ctx.parallelBlockMapping) {
ctx.parallelBlockMapping = new Map()
}
for (const nodeId of branchNodeIds) {
const node = this.dag.nodes.get(nodeId)
const branchIndex = node?.metadata.branchIndex ?? extractBranchIndex(nodeId)
if (branchIndex === null || branchIndex === undefined) {
continue
}
ctx.parallelBlockMapping.set(nodeId, {
originalBlockId: node?.metadata.originalBlockId ?? extractBaseBlockId(nodeId),
parallelId,
iterationIndex: branchIndex,
})
}
}
extractBranchMetadata(nodeId: string): ParallelBranchMetadata | null {
const node = this.dag.nodes.get(nodeId)
if (!node?.metadata.isParallelBranch) {
return null
}
const branchIndex = node.metadata.branchIndex ?? extractBranchIndex(nodeId)
if (branchIndex === null) {
return null
}
const parallelId =
node.metadata.subflowType === 'parallel' ? node.metadata.subflowId : undefined
if (!parallelId) {
return null
}
return {
branchIndex,
branchTotal: node.metadata.branchTotal ?? 1,
distributionItem: node.metadata.distributionItem,
parallelId,
}
}
getParallelScope(ctx: ExecutionContext, parallelId: string): ParallelScope | undefined {
return ctx.parallelExecutions?.get(parallelId)
}
}
+582
View File
@@ -0,0 +1,582 @@
import type { TraceSpan } from '@/lib/logs/types'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import type { BlockOutput } from '@/blocks/types'
import type {
ChildWorkflowContext,
IterationContext,
ParentIteration,
PiiBlockOutputRedaction,
SerializableExecutionState,
} from '@/executor/execution/types'
import type { RunFromBlockContext } from '@/executor/utils/run-from-block'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import type { SubflowType } from '@/stores/workflows/workflow/types'
export interface UserFile {
id: string
name: string
url: string
size: number
type: string
key: string
context?: string
base64?: string
/** Provider Files API handle (OpenAI/Anthropic `file_...` id) set when a large file is uploaded instead of inlined as base64. */
providerFileId?: string
/** Provider File API uri (Gemini `fileUri`) set when a large file is uploaded instead of inlined as base64. */
providerFileUri?: string
/** Short-lived signed HTTPS URL passed to providers that fetch attachments by remote URL instead of inlining base64. */
remoteUrl?: string
}
export interface ParallelPauseScope {
parallelId: string
branchIndex: number
branchTotal?: number
}
export interface LoopPauseScope {
loopId: string
iteration: number
}
export type PauseKind = 'human' | 'time'
export interface PauseMetadata {
contextId: string
blockId: string
response: any
timestamp: string
parallelScope?: ParallelPauseScope
loopScope?: LoopPauseScope
resumeLinks?: {
apiUrl: string
uiUrl: string
contextId: string
executionId: string
workflowId: string
}
pauseKind: PauseKind
/** ISO timestamp at which a `pauseKind: 'time'` pause becomes due for automatic resume. */
resumeAt?: string
}
export type ResumeStatus = 'paused' | 'resumed' | 'failed' | 'queued' | 'resuming'
export interface PausePoint {
contextId: string
blockId?: string
response: any
registeredAt: string
resumeStatus: ResumeStatus
snapshotReady: boolean
parallelScope?: ParallelPauseScope
loopScope?: LoopPauseScope
resumeLinks?: {
apiUrl: string
uiUrl: string
contextId: string
executionId: string
workflowId: string
}
pauseKind: PauseKind
resumeAt?: string
}
export interface SerializedSnapshot {
snapshot: string
triggerIds: string[]
}
/**
* Identifies a tool call emitted by a model iteration. Matches the
* `tool_call.id` convention used by OpenAI, Anthropic, and the OTel GenAI
* spec so tool segments can be correlated back to the iteration that issued
* them.
*/
export interface IterationToolCall {
id: string
name: string
arguments: Record<string, unknown> | string
}
/**
* A single phase of provider execution (model call or tool invocation).
*
* Providers emit these per iteration. Model segments carry the assistant's
* output for that iteration (text, thinking, tool_calls, tokens, finish
* reason) so the trace reveals *why* each tool was invoked — not just that
* it was. All content fields are optional; providers fill in what they have.
*/
export interface ProviderTimingSegment {
type: 'model' | 'tool'
name?: string
startTime: number
endTime: number
duration: number
assistantContent?: string
thinkingContent?: string
toolCalls?: IterationToolCall[]
toolCallId?: string
finishReason?: string
tokens?: BlockTokens
/** Cost for this segment in USD, derived from tokens + model pricing. */
cost?: { input?: number; output?: number; total?: number }
/** Time-to-first-token in ms (streaming only; first segment typically). */
ttft?: number
/** Provider system identifier (anthropic, openai, gemini, etc.) — `gen_ai.system`. */
provider?: string
/** Structured error class (e.g. `rate_limit`, `context_length`). */
errorType?: string
/** Human-readable error message when this segment failed. */
errorMessage?: string
}
/** Timing info reported by an LLM provider for a single block execution. */
interface BlockProviderTiming {
startTime: string
endTime: string
duration: number
modelTime?: number
toolsTime?: number
firstResponseTime?: number
iterations?: number
timeSegments?: ProviderTimingSegment[]
}
/** Cost breakdown from provider usage. */
interface BlockCost {
input: number
output: number
total: number
toolCost?: number
pricing?: {
input: number
output: number
cachedInput?: number
updatedAt: string
}
}
/** Token usage from provider. `prompt`/`completion` are legacy aliases. */
export interface BlockTokens {
input?: number
output?: number
total?: number
prompt?: number
completion?: number
/** Input tokens served from the provider's prompt cache. */
cacheRead?: number
/** Input tokens newly written to the provider's prompt cache. */
cacheWrite?: number
/** Output tokens consumed by reasoning/thinking (o-series, Claude, Gemini). */
reasoning?: number
}
/** A single tool invocation recorded by an agent-type block. */
export interface BlockToolCall {
name: string
duration?: number
startTime?: string
endTime?: string
error?: string
arguments?: Record<string, unknown>
input?: Record<string, unknown>
result?: Record<string, unknown>
output?: Record<string, unknown>
}
/** Normalized tool-call container emitted by providers. */
interface BlockToolCalls {
list: BlockToolCall[]
count: number
}
export interface NormalizedBlockOutput {
[key: string]: any
content?: string
model?: string
tokens?: BlockTokens
toolCalls?: BlockToolCalls
providerTiming?: BlockProviderTiming
cost?: BlockCost
files?: UserFile[]
selectedPath?: {
blockId: string
blockType?: string
blockTitle?: string
}
selectedOption?: string
conditionResult?: boolean
result?: any
stdout?: string
executionTime?: number
data?: any
status?: number
headers?: Record<string, string>
error?: string
childTraceSpans?: TraceSpan[]
childWorkflowName?: string
_pauseMetadata?: PauseMetadata
}
export const EXECUTION_CONTROL_OUTPUT_FIELD_NAMES = [
'error',
'selectedOption',
'selectedRoute',
'_pauseMetadata',
] as const
export type ExecutionControlOutputFieldName = (typeof EXECUTION_CONTROL_OUTPUT_FIELD_NAMES)[number]
export interface BlockLog {
blockId: string
blockName?: string
blockType?: string
startedAt: string
endedAt: string
durationMs: number
success: boolean
output?: NormalizedBlockOutput
input?: Record<string, unknown>
error?: string
/** Whether this error was handled by an error handler path (error port) */
errorHandled?: boolean
loopId?: string
parallelId?: string
iterationIndex?: number
/** Full ancestor iteration chain for nested subflows (outermost → innermost). */
parentIterations?: ParentIteration[]
/**
* Monotonically increasing integer (1, 2, 3, ...) for accurate block ordering.
* Generated via getNextExecutionOrder() to ensure deterministic sorting.
*/
executionOrder: number
/**
* Child workflow trace spans for nested workflow execution.
* Stored separately from output to keep output clean for display
* while preserving data for trace-spans processing.
*/
childTraceSpans?: TraceSpan[]
}
interface ExecutionMetadata {
requestId?: string
workflowId?: string
workspaceId?: string
startTime?: string
endTime?: string
duration: number
pendingBlocks?: string[]
isDebugSession?: boolean
context?: ExecutionContext
workflowConnections?: Array<{ source: string; target: string }>
credentialAccountUserId?: string
largeValueKeys?: string[]
fileKeys?: string[]
status?: 'running' | 'paused' | 'completed'
pausePoints?: string[]
resumeChain?: {
parentExecutionId?: string
depth: number
}
userId?: string
executionId?: string
triggerType?: string
triggerBlockId?: string
useDraftState?: boolean
resumeFromSnapshot?: boolean
resumeTerminalNoop?: boolean
}
export interface BlockState {
output: NormalizedBlockOutput
executed: boolean
executionTime: number
}
export interface ExecutionContext {
workflowId: string
workspaceId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
isDeployedContext?: boolean
enforceCredentialAccess?: boolean
copilotToolExecution?: boolean
/** In-flight block-output PII redaction policy (resolved `blockOutputs` stage). */
piiBlockOutputRedaction?: PiiBlockOutputRedaction
permissionConfig?: PermissionGroupConfig | null
permissionConfigLoaded?: boolean
blockStates: ReadonlyMap<string, BlockState>
executedBlocks: ReadonlySet<string>
blockLogs: BlockLog[]
metadata: ExecutionMetadata
environmentVariables: Record<string, string>
workflowVariables?: Record<string, any>
decisions: {
router: Map<string, string>
condition: Map<string, string>
}
completedLoops: Set<string>
/**
* Unified parent map for subflow nesting (loop-in-loop, parallel-in-parallel,
* loop-in-parallel, parallel-in-loop). Maps any child subflow ID to its parent
* subflow ID and type, enabling the iteration context builder to walk the full
* ancestor chain regardless of subflow type.
*/
subflowParentMap?: Map<
string,
{ parentId: string; parentType: SubflowType; branchIndex?: number }
>
loopExecutions?: Map<
string,
{
iteration: number
currentIterationOutputs: Map<string, any>
allIterationOutputs: any[][]
maxIterations?: number
item?: any
items?: any[]
condition?: string
skipFirstConditionCheck?: boolean
skippedAtStart?: boolean
loopType?: 'for' | 'forEach' | 'while' | 'doWhile'
}
>
parallelExecutions?: Map<
string,
{
parallelId: string
totalBranches: number
batchSize?: number
currentBatchStart?: number
currentBatchSize?: number
accumulatedOutputs?: Map<number, any[]>
branchOutputs: Map<number, any[]>
parallelType?: 'count' | 'collection'
items?: any[]
validationError?: string
isEmpty?: boolean
}
>
parallelBlockMapping?: Map<
string,
{
originalBlockId: string
parallelId: string
iterationIndex: number
}
>
currentVirtualBlockId?: string
activeExecutionPath: Set<string>
workflow?: SerializedWorkflow
stream?: boolean
selectedOutputs?: string[]
edges?: Array<{ source: string; target: string }>
onStream?: (streamingExecution: StreamingExecution) => Promise<void>
onBlockStart?: (
blockId: string,
blockName: string,
blockType: string,
executionOrder: number,
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
onBlockComplete?: (
blockId: string,
blockName: string,
blockType: string,
output: any,
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
/** Context identifying this execution as a child of a workflow block */
childWorkflowContext?: ChildWorkflowContext
/** Fires immediately after instanceId is generated, before child execution begins. */
onChildWorkflowInstanceReady?: (
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext,
executionOrder?: number,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
/**
* AbortSignal for cancellation support.
* When the signal is aborted, execution should stop gracefully.
* This is triggered when the SSE client disconnects.
*/
abortSignal?: AbortSignal
/**
* When true, UserFile objects in block outputs will be hydrated with base64 content
* before being stored in execution state. This ensures base64 is available for
* variable resolution in downstream blocks.
*/
includeFileBase64?: boolean
/**
* Maximum file size in bytes for base64 hydration. Files larger than this limit
* will not have their base64 content fetched.
*/
base64MaxBytes?: number
/**
* Context for "run from block" mode. When present, only blocks in dirtySet
* will be executed; others return cached outputs from the source snapshot.
*/
runFromBlockContext?: RunFromBlockContext
/**
* Stop execution after this block completes. Used for "run until block" feature.
*/
stopAfterBlockId?: string
/**
* Ordered list of workflow IDs in the current call chain, used for cycle detection.
* Passed to outgoing HTTP requests via the X-Sim-Via header.
*/
callChain?: string[]
/**
* Counter for generating monotonically increasing execution order values.
* Starts at 0 and increments for each block. Use getNextExecutionOrder() to access.
*/
executionOrderCounter?: { value: number }
}
/**
* Gets the next execution order value for a block.
* Returns a simple incrementing integer (1, 2, 3, ...) for clear ordering.
*/
export function getNextExecutionOrder(ctx: ExecutionContext): number {
if (!ctx.executionOrderCounter) {
ctx.executionOrderCounter = { value: 0 }
}
return ++ctx.executionOrderCounter.value
}
export interface ExecutionResult {
success: boolean
output: NormalizedBlockOutput
error?: string
logs?: BlockLog[]
executionState?: SerializableExecutionState
metadata?: ExecutionMetadata
status?: 'completed' | 'paused' | 'cancelled'
pausePoints?: PausePoint[]
snapshotSeed?: SerializedSnapshot
_streamingMetadata?: {
loggingSession: any
processedInput: any
}
}
export interface StreamingExecution {
stream: ReadableStream
execution: ExecutionResult & { isStreaming?: boolean }
/**
* Invoked with the assembled response text after the stream drains. Lets agent
* blocks persist the full response without interposing a TransformStream on a
* fetch-backed source — that pattern amplifies memory on Bun via #28035.
*/
onFullContent?: (content: string) => void | Promise<void>
}
interface BlockExecutor {
canExecute(block: SerializedBlock): boolean
execute(
block: SerializedBlock,
inputs: Record<string, any>,
context: ExecutionContext
): Promise<BlockOutput>
}
export interface BlockHandler {
canHandle(block: SerializedBlock): boolean
execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput | StreamingExecution>
executeWithNode?: (
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>,
nodeMetadata: {
nodeId: string
loopId?: string
parallelId?: string
branchIndex?: number
branchTotal?: number
originalBlockId?: string
isLoopNode?: boolean
executionOrder?: number
}
) => Promise<BlockOutput | StreamingExecution>
}
interface Tool<P = any, O = Record<string, any>> {
id: string
name: string
description: string
version: string
params: {
[key: string]: {
type: string
required?: boolean
description?: string
default?: any
}
}
request?: {
url?: string | ((params: P) => string)
method?: string
headers?: (params: P) => Record<string, string>
body?: (params: P) => Record<string, any>
}
transformResponse?: (response: any) => Promise<{
success: boolean
output: O
error?: string
}>
}
interface ToolRegistry {
[key: string]: Tool
}
export interface ResponseFormatStreamProcessor {
processStream(
originalStream: ReadableStream,
blockId: string,
selectedOutputs: string[],
responseFormat?: any
): ReadableStream
}
+9
View File
@@ -0,0 +1,9 @@
import type { SerializedLoop } from '@/serializer/types'
export interface LoopConfigWithNodes extends SerializedLoop {
nodes: string[]
}
export function isLoopConfigWithNodes(config: SerializedLoop): config is LoopConfigWithNodes {
return Array.isArray((config as any).nodes)
}
+11
View File
@@ -0,0 +1,11 @@
import type { SerializedParallel } from '@/serializer/types'
export interface ParallelConfigWithNodes extends SerializedParallel {
nodes: string[]
}
export function isParallelConfigWithNodes(
config: SerializedParallel
): config is ParallelConfigWithNodes {
return Array.isArray((config as any).nodes)
}
+352
View File
@@ -0,0 +1,352 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
StreamingResponseFormatProcessor,
streamingResponseFormatProcessor,
} from '@/executor/utils'
describe('StreamingResponseFormatProcessor', () => {
let processor: StreamingResponseFormatProcessor
beforeEach(() => {
processor = new StreamingResponseFormatProcessor()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('processStream', () => {
it.concurrent('should return original stream when no response format selection', async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"content": "test"}'))
controller.close()
},
})
const result = processor.processStream(
mockStream,
'block-1',
['block-1.content'], // No underscore, not response format
{ schema: { properties: { username: { type: 'string' } } } }
)
expect(result).toBe(mockStream)
})
it.concurrent('should return original stream when no response format provided', async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"content": "test"}'))
controller.close()
},
})
const result = processor.processStream(
mockStream,
'block-1',
['block-1_username'], // Has underscore but no response format
undefined
)
expect(result).toBe(mockStream)
})
it.concurrent('should process stream and extract single selected field', async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"username": "alice", "age": 25}'))
controller.close()
},
})
const processedStream = processor.processStream(mockStream, 'block-1', ['block-1_username'], {
schema: { properties: { username: { type: 'string' }, age: { type: 'number' } } },
})
const reader = processedStream.getReader()
const decoder = new TextDecoder()
let result = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
result += decoder.decode(value)
}
expect(result).toBe('alice')
})
it.concurrent('should process stream and extract multiple selected fields', async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode('{"username": "bob", "age": 30, "email": "bob@test.com"}')
)
controller.close()
},
})
const processedStream = processor.processStream(
mockStream,
'block-1',
['block-1_username', 'block-1_age'],
{
schema: { properties: { username: { type: 'string' }, age: { type: 'number' } } },
}
)
const reader = processedStream.getReader()
const decoder = new TextDecoder()
let result = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
result += decoder.decode(value)
}
expect(result).toBe('bob\n30')
})
it.concurrent('should handle non-string field values by JSON stringifying them', async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
'{"config": {"theme": "dark", "notifications": true}, "count": 42}'
)
)
controller.close()
},
})
const processedStream = processor.processStream(
mockStream,
'block-1',
['block-1_config', 'block-1_count'],
{
schema: { properties: { config: { type: 'object' }, count: { type: 'number' } } },
}
)
const reader = processedStream.getReader()
const decoder = new TextDecoder()
let result = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
result += decoder.decode(value)
}
expect(result).toBe('{"theme":"dark","notifications":true}\n42')
})
it.concurrent('should handle streaming JSON that comes in chunks', async () => {
const mockStream = new ReadableStream({
start(controller) {
// Simulate streaming JSON in chunks
controller.enqueue(new TextEncoder().encode('{"username": "charlie"'))
controller.enqueue(new TextEncoder().encode(', "age": 35}'))
controller.close()
},
})
const processedStream = processor.processStream(mockStream, 'block-1', ['block-1_username'], {
schema: { properties: { username: { type: 'string' }, age: { type: 'number' } } },
})
const reader = processedStream.getReader()
const decoder = new TextDecoder()
let result = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
result += decoder.decode(value)
}
expect(result).toBe('charlie')
})
it.concurrent('should handle missing fields gracefully', async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"username": "diana"}'))
controller.close()
},
})
const processedStream = processor.processStream(
mockStream,
'block-1',
['block-1_username', 'block-1_missing_field'],
{ schema: { properties: { username: { type: 'string' } } } }
)
const reader = processedStream.getReader()
const decoder = new TextDecoder()
let result = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
result += decoder.decode(value)
}
expect(result).toBe('diana')
})
it.concurrent('should handle invalid JSON gracefully', async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('invalid json'))
controller.close()
},
})
const processedStream = processor.processStream(mockStream, 'block-1', ['block-1_username'], {
schema: { properties: { username: { type: 'string' } } },
})
const reader = processedStream.getReader()
const decoder = new TextDecoder()
let result = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
result += decoder.decode(value)
}
expect(result).toBe('')
})
it.concurrent('should filter selected fields for correct block ID', async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"username": "eve", "age": 28}'))
controller.close()
},
})
const processedStream = processor.processStream(
mockStream,
'block-1',
['block-1_username', 'block-2_age'], // Different block ID should be filtered out
{ schema: { properties: { username: { type: 'string' }, age: { type: 'number' } } } }
)
const reader = processedStream.getReader()
const decoder = new TextDecoder()
let result = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
result += decoder.decode(value)
}
expect(result).toBe('eve')
})
it.concurrent('should handle empty result when no matching fields', async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"other_field": "value"}'))
controller.close()
},
})
const processedStream = processor.processStream(mockStream, 'block-1', ['block-1_username'], {
schema: { properties: { username: { type: 'string' } } },
})
const reader = processedStream.getReader()
const decoder = new TextDecoder()
let result = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
result += decoder.decode(value)
}
expect(result).toBe('')
})
})
describe('singleton instance', () => {
it.concurrent('should export a singleton instance', () => {
expect(streamingResponseFormatProcessor).toBeInstanceOf(StreamingResponseFormatProcessor)
})
it.concurrent('should return the same instance on multiple imports', () => {
const instance1 = streamingResponseFormatProcessor
const instance2 = streamingResponseFormatProcessor
expect(instance1).toBe(instance2)
})
})
describe('edge cases', () => {
it.concurrent('should handle empty stream', async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.close()
},
})
const processedStream = processor.processStream(mockStream, 'block-1', ['block-1_username'], {
schema: { properties: { username: { type: 'string' } } },
})
const reader = processedStream.getReader()
const decoder = new TextDecoder()
let result = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
result += decoder.decode(value)
}
expect(result).toBe('')
})
it.concurrent('should handle very large JSON objects', async () => {
const largeObject = {
username: 'frank',
data: 'x'.repeat(10000), // Large string
nested: {
deep: {
value: 'test',
},
},
}
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(JSON.stringify(largeObject)))
controller.close()
},
})
const processedStream = processor.processStream(mockStream, 'block-1', ['block-1_username'], {
schema: { properties: { username: { type: 'string' } } },
})
const reader = processedStream.getReader()
const decoder = new TextDecoder()
let result = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
result += decoder.decode(value)
}
expect(result).toBe('frank')
})
})
})
+175
View File
@@ -0,0 +1,175 @@
import { createLogger } from '@sim/logger'
import type { ResponseFormatStreamProcessor } from '@/executor/types'
const logger = createLogger('ExecutorUtils')
/**
* Processes a streaming response to extract only the selected response format fields
* instead of streaming the full JSON wrapper.
*/
export class StreamingResponseFormatProcessor implements ResponseFormatStreamProcessor {
processStream(
originalStream: ReadableStream,
blockId: string,
selectedOutputs: string[],
responseFormat?: any
): ReadableStream {
const hasResponseFormatSelection = selectedOutputs.some((outputId) => {
const blockIdForOutput = outputId.includes('_')
? outputId.split('_')[0]
: outputId.split('.')[0]
return blockIdForOutput === blockId && outputId.includes('_')
})
if (!hasResponseFormatSelection || !responseFormat) {
return originalStream
}
const selectedFields = selectedOutputs
.filter((outputId) => {
const blockIdForOutput = outputId.includes('_')
? outputId.split('_')[0]
: outputId.split('.')[0]
return blockIdForOutput === blockId && outputId.includes('_')
})
.map((outputId) => outputId.substring(blockId.length + 1))
logger.info('Processing streaming response format', {
blockId,
selectedFields,
hasResponseFormat: !!responseFormat,
selectedFieldsCount: selectedFields.length,
})
return this.createProcessedStream(originalStream, selectedFields, blockId)
}
private createProcessedStream(
originalStream: ReadableStream,
selectedFields: string[],
blockId: string
): ReadableStream {
let buffer = ''
let hasProcessedComplete = false
const self = this
return new ReadableStream({
async start(controller) {
const reader = originalStream.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
if (buffer.trim() && !hasProcessedComplete) {
self.processCompleteJson(buffer, selectedFields, controller)
}
controller.close()
break
}
const chunk = decoder.decode(value, { stream: true })
buffer += chunk
if (!hasProcessedComplete) {
const processedChunk = self.processStreamingChunk(buffer, selectedFields)
if (processedChunk) {
controller.enqueue(new TextEncoder().encode(processedChunk))
hasProcessedComplete = true
}
}
}
} catch (error) {
logger.error('Error processing streaming response format:', { error, blockId })
controller.error(error)
} finally {
reader.releaseLock()
}
},
})
}
private processStreamingChunk(buffer: string, selectedFields: string[]): string | null {
try {
const parsed = JSON.parse(buffer.trim())
if (typeof parsed === 'object' && parsed !== null) {
const results: string[] = []
for (const field of selectedFields) {
if (field in parsed) {
const value = parsed[field]
const formattedValue = typeof value === 'string' ? value : JSON.stringify(value)
results.push(formattedValue)
}
}
if (results.length > 0) {
const result = results.join('\n')
return result
}
return null
}
} catch (e) {}
const openBraces = (buffer.match(/\{/g) || []).length
const closeBraces = (buffer.match(/\}/g) || []).length
if (openBraces > 0 && openBraces === closeBraces) {
try {
const parsed = JSON.parse(buffer.trim())
if (typeof parsed === 'object' && parsed !== null) {
const results: string[] = []
for (const field of selectedFields) {
if (field in parsed) {
const value = parsed[field]
const formattedValue = typeof value === 'string' ? value : JSON.stringify(value)
results.push(formattedValue)
}
}
if (results.length > 0) {
const result = results.join('\n')
return result
}
return null
}
} catch (e) {}
}
return null
}
private processCompleteJson(
buffer: string,
selectedFields: string[],
controller: ReadableStreamDefaultController
): void {
try {
const parsed = JSON.parse(buffer.trim())
if (typeof parsed === 'object' && parsed !== null) {
const results: string[] = []
for (const field of selectedFields) {
if (field in parsed) {
const value = parsed[field]
const formattedValue = typeof value === 'string' ? value : JSON.stringify(value)
results.push(formattedValue)
}
}
if (results.length > 0) {
const result = results.join('\n')
controller.enqueue(new TextEncoder().encode(result))
}
}
} catch (error) {
logger.warn('Failed to parse complete JSON in streaming processor:', { error })
}
}
}
export const streamingResponseFormatProcessor = new StreamingResponseFormatProcessor()
+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
}

Some files were not shown because too many files have changed in this diff Show More