chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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})`,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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'],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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 block’s 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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -0,0 +1,102 @@
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { isTriggerBehavior, normalizeName } from '@/executor/constants'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { OutputSchema } from '@/executor/utils/block-reference'
|
||||
import {
|
||||
extractBaseBlockId,
|
||||
extractBranchIndex,
|
||||
isBranchNodeId,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
export interface BlockDataCollection {
|
||||
blockData: Record<string, unknown>
|
||||
blockNameMapping: Record<string, string>
|
||||
blockOutputSchemas: Record<string, OutputSchema>
|
||||
}
|
||||
|
||||
interface SubBlockWithValue {
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
function paramsToSubBlocks(
|
||||
params: Record<string, unknown> | undefined
|
||||
): Record<string, SubBlockWithValue> {
|
||||
if (!params) return {}
|
||||
|
||||
const subBlocks: Record<string, SubBlockWithValue> = {}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
subBlocks[key] = { value }
|
||||
}
|
||||
return subBlocks
|
||||
}
|
||||
|
||||
function getRegistrySchema(block: SerializedBlock): OutputSchema | undefined {
|
||||
const blockType = block.metadata?.id
|
||||
if (!blockType) return undefined
|
||||
|
||||
const subBlocks = paramsToSubBlocks(block.config?.params)
|
||||
const blockConfig = getBlock(blockType)
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const triggerMode = Boolean(isTriggerBehavior(block) && isTriggerCapable)
|
||||
const outputs = getEffectiveBlockOutputs(blockType, subBlocks, {
|
||||
triggerMode,
|
||||
preferToolOutputs: !triggerMode,
|
||||
includeHidden: true,
|
||||
}) as OutputSchema
|
||||
|
||||
if (!outputs || Object.keys(outputs).length === 0) {
|
||||
return undefined
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
export function getBlockSchema(block: SerializedBlock): OutputSchema | undefined {
|
||||
return getRegistrySchema(block)
|
||||
}
|
||||
|
||||
export function collectBlockData(
|
||||
ctx: ExecutionContext,
|
||||
currentNodeId?: string
|
||||
): BlockDataCollection {
|
||||
const blockData: Record<string, unknown> = {}
|
||||
const blockNameMapping: Record<string, string> = {}
|
||||
const blockOutputSchemas: Record<string, OutputSchema> = {}
|
||||
|
||||
const branchIndex =
|
||||
currentNodeId && isBranchNodeId(currentNodeId) ? extractBranchIndex(currentNodeId) : null
|
||||
|
||||
for (const [id, state] of ctx.blockStates.entries()) {
|
||||
if (state.output !== undefined) {
|
||||
blockData[id] = state.output
|
||||
|
||||
if (branchIndex !== null && isBranchNodeId(id)) {
|
||||
const stateBranchIndex = extractBranchIndex(id)
|
||||
if (stateBranchIndex === branchIndex) {
|
||||
const baseId = extractBaseBlockId(id)
|
||||
if (blockData[baseId] === undefined) {
|
||||
blockData[baseId] = state.output
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workflowBlocks = ctx.workflow?.blocks ?? []
|
||||
for (const block of workflowBlocks) {
|
||||
const id = block.id
|
||||
|
||||
if (block.metadata?.name) {
|
||||
blockNameMapping[normalizeName(block.metadata.name)] = id
|
||||
}
|
||||
|
||||
const schema = getBlockSchema(block)
|
||||
if (schema && Object.keys(schema).length > 0) {
|
||||
blockOutputSchemas[id] = schema
|
||||
}
|
||||
}
|
||||
|
||||
return { blockData, blockNameMapping, blockOutputSchemas }
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
type BlockReferenceContext,
|
||||
InvalidFieldError,
|
||||
resolveBlockReference,
|
||||
} from './block-reference'
|
||||
|
||||
describe('resolveBlockReference', () => {
|
||||
const createContext = (
|
||||
overrides: Partial<BlockReferenceContext> = {}
|
||||
): BlockReferenceContext => ({
|
||||
blockNameMapping: { start: 'block-1', agent: 'block-2' },
|
||||
blockData: {},
|
||||
blockOutputSchemas: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('block name resolution', () => {
|
||||
it('should return undefined when block name does not exist', () => {
|
||||
const ctx = createContext()
|
||||
const result = resolveBlockReference('unknown', ['field'], ctx)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should normalize block name before lookup', () => {
|
||||
const ctx = createContext({
|
||||
blockNameMapping: { myblock: 'block-1' },
|
||||
blockData: { 'block-1': { value: 'test' } },
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('MyBlock', ['value'], ctx)
|
||||
expect(result).toEqual({ value: 'test', blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should handle block names with spaces', () => {
|
||||
const ctx = createContext({
|
||||
blockNameMapping: { myblock: 'block-1' },
|
||||
blockData: { 'block-1': { value: 'test' } },
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('My Block', ['value'], ctx)
|
||||
expect(result).toEqual({ value: 'test', blockId: 'block-1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('field resolution', () => {
|
||||
it('should return entire block output when no path specified', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { input: 'hello', other: 'data' } },
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', [], ctx)
|
||||
expect(result).toEqual({
|
||||
value: { input: 'hello', other: 'data' },
|
||||
blockId: 'block-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('should resolve simple field path', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { input: 'hello' } },
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['input'], ctx)
|
||||
expect(result).toEqual({ value: 'hello', blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should resolve nested field path', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { response: { data: { name: 'test' } } } },
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['response', 'data', 'name'], ctx)
|
||||
expect(result).toEqual({ value: 'test', blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should resolve array index path', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { items: ['a', 'b', 'c'] } },
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['items', '1'], ctx)
|
||||
expect(result).toEqual({ value: 'b', blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should return undefined value when field exists but has no value', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { input: undefined } },
|
||||
blockOutputSchemas: {
|
||||
'block-1': { input: { type: 'string' } },
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['input'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should return null value when field has null', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { input: null } },
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['input'], ctx)
|
||||
expect(result).toEqual({ value: null, blockId: 'block-1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('schema validation', () => {
|
||||
it('should throw InvalidFieldError when field not in schema', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { existing: 'value' } },
|
||||
blockOutputSchemas: {
|
||||
'block-1': {
|
||||
input: { type: 'string' },
|
||||
conversationId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(() => resolveBlockReference('start', ['invalid'], ctx)).toThrow(InvalidFieldError)
|
||||
expect(() => resolveBlockReference('start', ['invalid'], ctx)).toThrow(
|
||||
/"invalid" doesn't exist on block "start"/
|
||||
)
|
||||
})
|
||||
|
||||
it('should include available fields in error message', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': {} },
|
||||
blockOutputSchemas: {
|
||||
'block-1': {
|
||||
input: { type: 'string' },
|
||||
conversationId: { type: 'string' },
|
||||
files: { type: 'file[]' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
resolveBlockReference('start', ['typo'], ctx)
|
||||
expect.fail('Should have thrown')
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(InvalidFieldError)
|
||||
const fieldError = error as InvalidFieldError
|
||||
expect(fieldError.availableFields).toContain('input')
|
||||
expect(fieldError.availableFields).toContain('conversationId')
|
||||
expect(fieldError.availableFields).toContain('files')
|
||||
}
|
||||
})
|
||||
|
||||
it('should allow valid field even when value is undefined', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': {} },
|
||||
blockOutputSchemas: {
|
||||
'block-1': { input: { type: 'string' } },
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['input'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should not validate path when block has no output yet', () => {
|
||||
// Blocks with no output typically live on a branched path that wasn't
|
||||
// taken this run. We resolve such references to undefined (which the
|
||||
// caller maps to RESOLVED_EMPTY) rather than throwing on every nested
|
||||
// path the schema doesn't pre-declare.
|
||||
const ctx = createContext({
|
||||
blockData: {},
|
||||
blockOutputSchemas: {
|
||||
'block-1': { input: { type: 'string' } },
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['invalid'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should return undefined for valid field when block has no output', () => {
|
||||
const ctx = createContext({
|
||||
blockData: {},
|
||||
blockOutputSchemas: {
|
||||
'block-1': { input: { type: 'string' } },
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['input'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should return undefined for nested path under json field when block has no output', () => {
|
||||
// Repro for the branched-path bug: a function block with a dynamic
|
||||
// `json` result that never ran should resolve to undefined regardless
|
||||
// of the nested path, not throw.
|
||||
const ctx = createContext({
|
||||
blockData: {},
|
||||
blockOutputSchemas: {
|
||||
'block-1': {
|
||||
result: { type: 'json' },
|
||||
stdout: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['result', 'summary'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should not throw for nested path under json field on executed block', () => {
|
||||
// A `json` field declares dynamic shape, so drilling into it must be
|
||||
// permitted even when the runtime data doesn't happen to include that
|
||||
// key on this run.
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { result: { foo: 1 } } },
|
||||
blockOutputSchemas: {
|
||||
'block-1': {
|
||||
result: { type: 'json' },
|
||||
stdout: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['result', 'summary'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should resolve values nested under json field on executed block', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { result: { summary: 'hello' } } },
|
||||
blockOutputSchemas: {
|
||||
'block-1': {
|
||||
result: { type: 'json' },
|
||||
stdout: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['result', 'summary'], ctx)
|
||||
expect(result).toEqual({ value: 'hello', blockId: 'block-1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('without schema (pass-through mode)', () => {
|
||||
it('should return undefined value without throwing when no schema', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { existing: 'value' } },
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['missing'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('file type handling', () => {
|
||||
it('should allow file property access', () => {
|
||||
const ctx = createContext({
|
||||
blockData: {
|
||||
'block-1': {
|
||||
files: [{ name: 'test.txt', url: 'http://example.com/file' }],
|
||||
},
|
||||
},
|
||||
blockOutputSchemas: {
|
||||
'block-1': { files: { type: 'file[]' } },
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['files', '0', 'name'], ctx)
|
||||
expect(result).toEqual({ value: 'test.txt', blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should validate file property names', () => {
|
||||
const ctx = createContext({
|
||||
blockData: { 'block-1': { files: [] } },
|
||||
blockOutputSchemas: {
|
||||
'block-1': { files: { type: 'file[]' } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(() => resolveBlockReference('start', ['files', '0', 'invalid'], ctx)).toThrow(
|
||||
InvalidFieldError
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('InvalidFieldError', () => {
|
||||
it('should have correct properties', () => {
|
||||
const error = new InvalidFieldError('myBlock', 'invalid.path', ['field1', 'field2'])
|
||||
|
||||
expect(error.blockName).toBe('myBlock')
|
||||
expect(error.fieldPath).toBe('invalid.path')
|
||||
expect(error.availableFields).toEqual(['field1', 'field2'])
|
||||
expect(error.name).toBe('InvalidFieldError')
|
||||
expect(error.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('should format message correctly', () => {
|
||||
const error = new InvalidFieldError('start', 'typo', ['input', 'files'])
|
||||
|
||||
expect(error.message).toBe(
|
||||
'"typo" doesn\'t exist on block "start". Available fields: input, files'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle empty available fields', () => {
|
||||
const error = new InvalidFieldError('start', 'field', [])
|
||||
|
||||
expect(error.message).toBe('"field" doesn\'t exist on block "start". Available fields: none')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,284 @@
|
||||
import { HttpError } from '@/lib/core/utils/http-error'
|
||||
import { USER_FILE_ACCESSIBLE_PROPERTIES } from '@/lib/workflows/types'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import {
|
||||
type AsyncPathNavigator,
|
||||
navigatePath,
|
||||
type ResolutionContext,
|
||||
} from '@/executor/variables/resolvers/reference'
|
||||
|
||||
/**
|
||||
* A single schema node encountered while walking an `OutputSchema`. Captures
|
||||
* only the fields this module inspects — not a full schema type.
|
||||
*/
|
||||
interface SchemaNode {
|
||||
type?: string
|
||||
description?: string
|
||||
properties?: unknown
|
||||
items?: unknown
|
||||
}
|
||||
|
||||
export type OutputSchema = Record<string, SchemaNode | unknown>
|
||||
|
||||
export interface BlockReferenceContext {
|
||||
blockNameMapping: Record<string, string>
|
||||
blockData: Record<string, unknown>
|
||||
blockOutputSchemas?: Record<string, OutputSchema>
|
||||
}
|
||||
|
||||
export interface BlockReferenceResult {
|
||||
value: unknown
|
||||
blockId: string
|
||||
}
|
||||
|
||||
export class InvalidFieldError extends HttpError {
|
||||
readonly statusCode = 400
|
||||
|
||||
constructor(
|
||||
public readonly blockName: string,
|
||||
public readonly fieldPath: string,
|
||||
public readonly availableFields: string[]
|
||||
) {
|
||||
super(
|
||||
`"${fieldPath}" doesn't exist on block "${blockName}". ` +
|
||||
`Available fields: ${availableFields.length > 0 ? availableFields.join(', ') : 'none'}`
|
||||
)
|
||||
this.name = 'InvalidFieldError'
|
||||
}
|
||||
}
|
||||
|
||||
function asSchemaNode(value: unknown): SchemaNode | undefined {
|
||||
if (typeof value !== 'object' || value === null) return undefined
|
||||
return value as SchemaNode
|
||||
}
|
||||
|
||||
function isFileType(value: unknown): boolean {
|
||||
const node = asSchemaNode(value)
|
||||
return node?.type === 'file' || node?.type === 'file[]'
|
||||
}
|
||||
|
||||
function isArrayType(value: unknown): value is { type: 'array'; items?: unknown } {
|
||||
return asSchemaNode(value)?.type === 'array'
|
||||
}
|
||||
|
||||
function getArrayItems(schema: unknown): unknown {
|
||||
return asSchemaNode(schema)?.items
|
||||
}
|
||||
|
||||
function getProperties(schema: unknown): Record<string, unknown> | undefined {
|
||||
const props = asSchemaNode(schema)?.properties
|
||||
return typeof props === 'object' && props !== null
|
||||
? (props as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function lookupField(schema: unknown, fieldName: string): unknown | undefined {
|
||||
if (typeof schema !== 'object' || schema === null) return undefined
|
||||
const typed = schema as Record<string, unknown>
|
||||
|
||||
if (fieldName in typed) {
|
||||
return typed[fieldName]
|
||||
}
|
||||
|
||||
const props = getProperties(schema)
|
||||
if (props && fieldName in props) {
|
||||
return props[fieldName]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isOpaqueSchemaNode(value: unknown): boolean {
|
||||
const node = asSchemaNode(value)
|
||||
if (!node) return false
|
||||
// A schema node whose nested shape isn't enumerated. Any path beneath it
|
||||
// is accepted because there's no declared structure to validate against.
|
||||
// `object` / `json` with declared `properties` are walked via lookupField.
|
||||
if (node.type === 'any') return true
|
||||
if ((node.type === 'json' || node.type === 'object') && node.properties === undefined) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isPathInSchema(schema: OutputSchema | undefined, pathParts: string[]): boolean {
|
||||
if (!schema || pathParts.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
let current: unknown = schema
|
||||
|
||||
for (let i = 0; i < pathParts.length; i++) {
|
||||
const part = pathParts[i]
|
||||
|
||||
if (current === null || current === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isOpaqueSchemaNode(current)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(part)) {
|
||||
if (isFileType(current)) {
|
||||
const nextPart = pathParts[i + 1]
|
||||
return (
|
||||
!nextPart ||
|
||||
USER_FILE_ACCESSIBLE_PROPERTIES.includes(
|
||||
nextPart as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number]
|
||||
)
|
||||
)
|
||||
}
|
||||
if (isArrayType(current)) {
|
||||
current = getArrayItems(current)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const arrayMatch = part.match(/^([^[]+)\[(\d+)\]$/)
|
||||
if (arrayMatch) {
|
||||
const [, prop] = arrayMatch
|
||||
const fieldDef = lookupField(current, prop)
|
||||
if (!fieldDef) return false
|
||||
|
||||
if (isFileType(fieldDef)) {
|
||||
const nextPart = pathParts[i + 1]
|
||||
return (
|
||||
!nextPart ||
|
||||
USER_FILE_ACCESSIBLE_PROPERTIES.includes(
|
||||
nextPart as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
current = isArrayType(fieldDef) ? getArrayItems(fieldDef) : fieldDef
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
isFileType(current) &&
|
||||
USER_FILE_ACCESSIBLE_PROPERTIES.includes(
|
||||
part as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number]
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fieldDef = lookupField(current, part)
|
||||
if (fieldDef !== undefined) {
|
||||
if (isFileType(fieldDef)) {
|
||||
const nextPart = pathParts[i + 1]
|
||||
if (!nextPart) return true
|
||||
if (/^\d+$/.test(nextPart)) {
|
||||
const afterIndex = pathParts[i + 2]
|
||||
return (
|
||||
!afterIndex ||
|
||||
USER_FILE_ACCESSIBLE_PROPERTIES.includes(
|
||||
afterIndex as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number]
|
||||
)
|
||||
)
|
||||
}
|
||||
return USER_FILE_ACCESSIBLE_PROPERTIES.includes(
|
||||
nextPart as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number]
|
||||
)
|
||||
}
|
||||
current = fieldDef
|
||||
continue
|
||||
}
|
||||
|
||||
if (isArrayType(current)) {
|
||||
const items = getArrayItems(current)
|
||||
const itemField = lookupField(items, part)
|
||||
if (itemField !== undefined) {
|
||||
current = itemField
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function getSchemaFieldNames(schema: OutputSchema | undefined): string[] {
|
||||
if (!schema) return []
|
||||
return Object.keys(schema)
|
||||
}
|
||||
|
||||
export function resolveBlockReference(
|
||||
blockName: string,
|
||||
pathParts: string[],
|
||||
context: BlockReferenceContext,
|
||||
options: {
|
||||
allowLargeValueRefs?: boolean
|
||||
executionContext?: ResolutionContext['executionContext']
|
||||
} = {}
|
||||
): BlockReferenceResult | undefined {
|
||||
const normalizedName = normalizeName(blockName)
|
||||
const blockId = context.blockNameMapping[normalizedName]
|
||||
|
||||
if (!blockId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const blockOutput = context.blockData[blockId]
|
||||
|
||||
// When the block has not produced any output (e.g. it lives on a branched
|
||||
// path that wasn't taken), resolve the reference to undefined without
|
||||
// validating against the declared schema. Callers map this to an empty
|
||||
// value so that references to skipped blocks don't fail the workflow.
|
||||
if (blockOutput === undefined) {
|
||||
return { value: undefined, blockId }
|
||||
}
|
||||
|
||||
if (pathParts.length === 0) {
|
||||
return { value: blockOutput, blockId }
|
||||
}
|
||||
|
||||
const value = navigatePath(blockOutput, pathParts, options)
|
||||
|
||||
const schema = context.blockOutputSchemas?.[blockId]
|
||||
if (value === undefined && schema) {
|
||||
if (!isPathInSchema(schema, pathParts)) {
|
||||
throw new InvalidFieldError(blockName, pathParts.join('.'), getSchemaFieldNames(schema))
|
||||
}
|
||||
}
|
||||
|
||||
return { value, blockId }
|
||||
}
|
||||
|
||||
export async function resolveBlockReferenceAsync(
|
||||
blockName: string,
|
||||
pathParts: string[],
|
||||
context: BlockReferenceContext,
|
||||
resolutionContext: ResolutionContext,
|
||||
navigatePathAsync: AsyncPathNavigator
|
||||
): Promise<BlockReferenceResult | undefined> {
|
||||
const normalizedName = normalizeName(blockName)
|
||||
const blockId = context.blockNameMapping[normalizedName]
|
||||
|
||||
if (!blockId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const blockOutput = context.blockData[blockId]
|
||||
if (blockOutput === undefined) {
|
||||
return { value: undefined, blockId }
|
||||
}
|
||||
|
||||
if (pathParts.length === 0) {
|
||||
return { value: blockOutput, blockId }
|
||||
}
|
||||
|
||||
const value = await navigatePathAsync(blockOutput, pathParts, resolutionContext)
|
||||
|
||||
const schema = context.blockOutputSchemas?.[blockId]
|
||||
if (value === undefined && schema) {
|
||||
if (!isPathInSchema(schema, pathParts)) {
|
||||
throw new InvalidFieldError(blockName, pathParts.join('.'), getSchemaFieldNames(schema))
|
||||
}
|
||||
}
|
||||
|
||||
return { value, blockId }
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { REFERENCE } from '@/executor/constants'
|
||||
|
||||
export interface JSONProperty {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
value: unknown
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts builder data (structured JSON properties) into a plain JSON object.
|
||||
*/
|
||||
export function convertBuilderDataToJson(builderData: JSONProperty[]): Record<string, unknown> {
|
||||
if (!Array.isArray(builderData)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
for (const prop of builderData) {
|
||||
if (!prop.name || !prop.name.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const value = convertPropertyValue(prop)
|
||||
result[prop.name] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts builder data into a JSON string with variable references unquoted.
|
||||
*/
|
||||
export function convertBuilderDataToJsonString(builderData: JSONProperty[]): string {
|
||||
if (!Array.isArray(builderData) || builderData.length === 0) {
|
||||
return '{\n \n}'
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
for (const prop of builderData) {
|
||||
if (!prop.name || !prop.name.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
result[prop.name] = prop.value
|
||||
}
|
||||
|
||||
let jsonString = JSON.stringify(result, null, 2)
|
||||
|
||||
jsonString = jsonString.replace(/"(<[^>]+>)"/g, '$1')
|
||||
|
||||
return jsonString
|
||||
}
|
||||
|
||||
export function convertPropertyValue(prop: JSONProperty): unknown {
|
||||
switch (prop.type) {
|
||||
case 'object':
|
||||
return convertObjectValue(prop.value)
|
||||
case 'array':
|
||||
return convertArrayValue(prop.value)
|
||||
case 'number':
|
||||
return convertNumberValue(prop.value)
|
||||
case 'boolean':
|
||||
return convertBooleanValue(prop.value)
|
||||
case 'files':
|
||||
return prop.value
|
||||
default:
|
||||
return prop.value
|
||||
}
|
||||
}
|
||||
|
||||
function convertObjectValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return convertBuilderDataToJson(value as JSONProperty[])
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && !isVariableReference(value)) {
|
||||
return tryParseJson(value, value)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function convertArrayValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item: unknown) => convertArrayItem(item))
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && !isVariableReference(value)) {
|
||||
const parsed = tryParseJson(value, value)
|
||||
return Array.isArray(parsed) ? parsed : value
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function convertArrayItem(item: unknown): unknown {
|
||||
if (typeof item !== 'object' || item === null || !('type' in item)) {
|
||||
return item
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>
|
||||
if (typeof record.type !== 'string') {
|
||||
return item
|
||||
}
|
||||
|
||||
const typed = record as { type: string; value: unknown }
|
||||
|
||||
if (typed.type === 'object' && Array.isArray(typed.value)) {
|
||||
return convertBuilderDataToJson(typed.value as JSONProperty[])
|
||||
}
|
||||
|
||||
if (typed.type === 'array' && Array.isArray(typed.value)) {
|
||||
return (typed.value as unknown[]).map((subItem: unknown) =>
|
||||
typeof subItem === 'object' && subItem !== null && 'value' in subItem
|
||||
? (subItem as { value: unknown }).value
|
||||
: subItem
|
||||
)
|
||||
}
|
||||
|
||||
return typed.value
|
||||
}
|
||||
|
||||
function convertNumberValue(value: unknown): unknown {
|
||||
if (isVariableReference(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
const numValue = Number(value)
|
||||
return Number.isNaN(numValue) ? value : numValue
|
||||
}
|
||||
|
||||
function convertBooleanValue(value: unknown): unknown {
|
||||
if (isVariableReference(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
return value === 'true' || value === true
|
||||
}
|
||||
|
||||
function tryParseJson(jsonString: string, fallback: unknown): unknown {
|
||||
try {
|
||||
return JSON.parse(jsonString)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function isVariableReference(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.trim().startsWith(REFERENCE.START) &&
|
||||
value.trim().includes(REFERENCE.END)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Formats a JavaScript/TypeScript value as a code literal for the target language.
|
||||
* Handles special cases like null, undefined, booleans, and Python-specific number representations.
|
||||
*
|
||||
* @param value - The value to format
|
||||
* @param language - Target language ('javascript' or 'python')
|
||||
* @returns A string literal representation valid in the target language
|
||||
*
|
||||
* @example
|
||||
* formatLiteralForCode(null, 'python') // => 'None'
|
||||
* formatLiteralForCode(true, 'python') // => 'True'
|
||||
* formatLiteralForCode(NaN, 'python') // => "float('nan')"
|
||||
* formatLiteralForCode("hello", 'javascript') // => '"hello"'
|
||||
* formatLiteralForCode({a: 1}, 'python') // => "json.loads('{\"a\":1}')"
|
||||
*/
|
||||
export function formatLiteralForCode(value: unknown, language: 'javascript' | 'python'): string {
|
||||
const isPython = language === 'python'
|
||||
|
||||
if (value === undefined) {
|
||||
return isPython ? 'None' : 'undefined'
|
||||
}
|
||||
if (value === null) {
|
||||
return isPython ? 'None' : 'null'
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return isPython ? (value ? 'True' : 'False') : String(value)
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isNaN(value)) {
|
||||
return isPython ? "float('nan')" : 'NaN'
|
||||
}
|
||||
if (value === Number.POSITIVE_INFINITY) {
|
||||
return isPython ? "float('inf')" : 'Infinity'
|
||||
}
|
||||
if (value === Number.NEGATIVE_INFINITY) {
|
||||
return isPython ? "float('-inf')" : '-Infinity'
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
// Objects and arrays - Python needs json.loads() because JSON true/false/null aren't valid Python
|
||||
if (isPython) {
|
||||
return `json.loads(${JSON.stringify(JSON.stringify(value))})`
|
||||
}
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { ExecutionContext, ExecutionResult } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
/**
|
||||
* Interface for errors that carry an ExecutionResult.
|
||||
* Used when workflow execution fails and we want to preserve partial results.
|
||||
*/
|
||||
export interface ErrorWithExecutionResult extends Error {
|
||||
executionResult: ExecutionResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error carries an ExecutionResult.
|
||||
* Validates that executionResult has required fields (success, output).
|
||||
*/
|
||||
export function hasExecutionResult(error: unknown): error is ErrorWithExecutionResult {
|
||||
if (
|
||||
!(error instanceof Error) ||
|
||||
!('executionResult' in error) ||
|
||||
error.executionResult == null ||
|
||||
typeof error.executionResult !== 'object'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const result = error.executionResult as Record<string, unknown>
|
||||
return typeof result.success === 'boolean' && result.output != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches an ExecutionResult to an error for propagation to parent workflows.
|
||||
*/
|
||||
export function attachExecutionResult(error: Error, executionResult: ExecutionResult): void {
|
||||
Object.assign(error, { executionResult })
|
||||
}
|
||||
|
||||
export interface BlockExecutionErrorDetails {
|
||||
block: SerializedBlock
|
||||
error: Error | string
|
||||
context?: ExecutionContext
|
||||
additionalInfo?: Record<string, any>
|
||||
}
|
||||
|
||||
export function buildBlockExecutionError(details: BlockExecutionErrorDetails): Error {
|
||||
const errorMessage =
|
||||
details.error instanceof Error ? details.error.message : String(details.error)
|
||||
const blockName = details.block.metadata?.name || details.block.id
|
||||
const blockType = details.block.metadata?.id || 'unknown'
|
||||
|
||||
const error = new Error(`${blockName}: ${errorMessage}`)
|
||||
|
||||
const innerStatusCode = readStatusCode(details.error)
|
||||
|
||||
Object.assign(error, {
|
||||
blockId: details.block.id,
|
||||
blockName,
|
||||
blockType,
|
||||
workflowId: details.context?.workflowId,
|
||||
timestamp: new Date().toISOString(),
|
||||
...details.additionalInfo,
|
||||
...(innerStatusCode !== undefined ? { statusCode: innerStatusCode } : {}),
|
||||
})
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
export function buildHTTPError(config: {
|
||||
status: number
|
||||
url?: string
|
||||
method?: string
|
||||
message?: string
|
||||
}): Error {
|
||||
let errorMessage = config.message || `HTTP ${config.method || 'request'} failed`
|
||||
|
||||
if (config.url) {
|
||||
errorMessage += ` - ${config.url}`
|
||||
}
|
||||
|
||||
if (config.status) {
|
||||
errorMessage += ` (Status: ${config.status})`
|
||||
}
|
||||
|
||||
const error = new Error(errorMessage)
|
||||
|
||||
Object.assign(error, {
|
||||
status: config.status,
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
function readStatusCode(value: unknown): number | undefined {
|
||||
if (!(value instanceof Error)) return undefined
|
||||
const status = (value as unknown as { statusCode?: unknown }).statusCode
|
||||
return typeof status === 'number' ? status : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an execution error to an HTTP status code. Errors thrown from the
|
||||
* executor that represent workflow-author mistakes (invalid field references,
|
||||
* etc.) carry a 4xx `statusCode`; everything else is a 500.
|
||||
*/
|
||||
export function getExecutionErrorStatus(error: unknown): number {
|
||||
const status = readStatusCode(error)
|
||||
if (status !== undefined && status >= 400 && status < 500) {
|
||||
return status
|
||||
}
|
||||
return 500
|
||||
}
|
||||
|
||||
export function normalizeError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { isUserFile } from '@/lib/core/utils/user-file'
|
||||
import { uploadExecutionFile, uploadFileFromRawData } from '@/lib/uploads/contexts/execution'
|
||||
import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server'
|
||||
import type { ExecutionContext, UserFile } from '@/executor/types'
|
||||
import type { ToolConfig, ToolFileData } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('FileToolProcessor')
|
||||
|
||||
/**
|
||||
* Processes tool outputs and converts file-typed outputs to UserFile objects.
|
||||
* This enables tools to return file data that gets automatically stored in the
|
||||
* execution filesystem and made available as UserFile objects for workflow use.
|
||||
*/
|
||||
export class FileToolProcessor {
|
||||
/**
|
||||
* Process tool outputs and convert file-typed outputs to UserFile objects
|
||||
*/
|
||||
static async processToolOutputs(
|
||||
toolOutput: any,
|
||||
toolConfig: ToolConfig,
|
||||
executionContext: ExecutionContext
|
||||
): Promise<any> {
|
||||
if (!toolConfig.outputs) {
|
||||
return toolOutput
|
||||
}
|
||||
|
||||
const processedOutput = { ...toolOutput }
|
||||
|
||||
for (const [outputKey, outputDef] of Object.entries(toolConfig.outputs)) {
|
||||
if (!FileToolProcessor.isFileOutput(outputDef.type)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fileData = processedOutput[outputKey]
|
||||
if (!fileData) {
|
||||
logger.warn(`File-typed output '${outputKey}' is missing from tool result`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
processedOutput[outputKey] = await FileToolProcessor.processFileOutput(
|
||||
fileData,
|
||||
outputDef.type,
|
||||
outputKey,
|
||||
executionContext
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`Error processing file output '${outputKey}':`, error)
|
||||
const errorMessage = toError(error).message
|
||||
throw new Error(`Failed to process file output '${outputKey}': ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
return processedOutput
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an output type is file-related
|
||||
*/
|
||||
private static isFileOutput(type: string): boolean {
|
||||
return type === 'file' || type === 'file[]'
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single file output (either single file or array of files)
|
||||
*/
|
||||
private static async processFileOutput(
|
||||
fileData: any,
|
||||
outputType: string,
|
||||
outputKey: string,
|
||||
executionContext: ExecutionContext
|
||||
): Promise<UserFile | UserFile[]> {
|
||||
if (outputType === 'file[]') {
|
||||
return FileToolProcessor.processFileArray(fileData, outputKey, executionContext)
|
||||
}
|
||||
return FileToolProcessor.processFileData(fileData, executionContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an array of files
|
||||
*/
|
||||
private static async processFileArray(
|
||||
fileData: any,
|
||||
outputKey: string,
|
||||
executionContext: ExecutionContext
|
||||
): Promise<UserFile[]> {
|
||||
if (!Array.isArray(fileData)) {
|
||||
throw new Error(`Output '${outputKey}' is marked as file[] but is not an array`)
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
fileData.map((file, index) => FileToolProcessor.processFileData(file, executionContext))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert various file data formats to UserFile by storing in execution filesystem.
|
||||
* If the input is already a UserFile, returns it unchanged.
|
||||
*/
|
||||
private static async processFileData(
|
||||
fileData: ToolFileData | UserFile,
|
||||
context: ExecutionContext
|
||||
): Promise<UserFile> {
|
||||
// If already a UserFile (e.g., from tools that handle their own file storage),
|
||||
// return it directly without re-processing
|
||||
if (isUserFile(fileData)) {
|
||||
return fileData as UserFile
|
||||
}
|
||||
|
||||
const data = fileData as ToolFileData
|
||||
try {
|
||||
let buffer: Buffer | null = null
|
||||
|
||||
if (Buffer.isBuffer(data.data)) {
|
||||
buffer = data.data
|
||||
} else if (
|
||||
data.data &&
|
||||
typeof data.data === 'object' &&
|
||||
'type' in data.data &&
|
||||
'data' in data.data
|
||||
) {
|
||||
const serializedBuffer = data.data as { type: string; data: number[] }
|
||||
if (serializedBuffer.type === 'Buffer' && Array.isArray(serializedBuffer.data)) {
|
||||
buffer = Buffer.from(serializedBuffer.data)
|
||||
} else {
|
||||
throw new Error(`Invalid serialized buffer format for ${data.name}`)
|
||||
}
|
||||
} else if (typeof data.data === 'string' && data.data) {
|
||||
let base64Data = data.data
|
||||
|
||||
if (base64Data.includes('-') || base64Data.includes('_')) {
|
||||
base64Data = base64Data.replace(/-/g, '+').replace(/_/g, '/')
|
||||
}
|
||||
|
||||
buffer = Buffer.from(base64Data, 'base64')
|
||||
}
|
||||
|
||||
if (!buffer && data.url) {
|
||||
buffer = await downloadFileFromUrl(data.url, { userId: context.userId })
|
||||
}
|
||||
|
||||
if (buffer) {
|
||||
if (buffer.length === 0) {
|
||||
throw new Error(`File '${data.name}' has zero bytes`)
|
||||
}
|
||||
|
||||
return await uploadExecutionFile(
|
||||
{
|
||||
workspaceId: context.workspaceId || '',
|
||||
workflowId: context.workflowId,
|
||||
executionId: context.executionId || '',
|
||||
},
|
||||
buffer,
|
||||
data.name,
|
||||
data.mimeType,
|
||||
context.userId
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.data) {
|
||||
throw new Error(
|
||||
`File data for '${data.name}' must have either 'data' (Buffer/base64) or 'url' property`
|
||||
)
|
||||
}
|
||||
|
||||
return uploadFileFromRawData(
|
||||
{
|
||||
name: data.name,
|
||||
data: data.data,
|
||||
mimeType: data.mimeType,
|
||||
},
|
||||
{
|
||||
workspaceId: context.workspaceId || '',
|
||||
workflowId: context.workflowId,
|
||||
executionId: context.executionId || '',
|
||||
},
|
||||
context.userId
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`Error processing file data for '${data.name}':`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool has any file-typed outputs
|
||||
*/
|
||||
static hasFileOutputs(toolConfig: ToolConfig): boolean {
|
||||
if (!toolConfig.outputs) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Object.values(toolConfig.outputs).some(
|
||||
(output) => output.type === 'file' || output.type === 'file[]'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { generateInternalToken } from '@/lib/auth/internal'
|
||||
import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { HTTP } from '@/executor/constants'
|
||||
|
||||
export async function buildAuthHeaders(userId?: string): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': HTTP.CONTENT_TYPE.JSON,
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
const token = await generateInternalToken(userId)
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
export function buildAPIUrl(path: string, params?: Record<string, string>): URL {
|
||||
const baseUrl = path.startsWith('/api/') ? getInternalApiBaseUrl() : getBaseUrl()
|
||||
const url = new URL(path, baseUrl)
|
||||
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
export async function extractAPIErrorMessage(response: Response): Promise<string> {
|
||||
const defaultMessage = `API request failed with status ${response.status}`
|
||||
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return errorData.error || defaultMessage
|
||||
} catch {
|
||||
return defaultMessage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import {
|
||||
buildContainerIterationContext,
|
||||
buildUnifiedParentIterations,
|
||||
getIterationContext,
|
||||
type IterationNodeMetadata,
|
||||
} from './iteration-context'
|
||||
|
||||
function makeCtx(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
|
||||
return {
|
||||
workflowId: 'wf-1',
|
||||
executionId: 'exec-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
blockStates: {},
|
||||
blockLogs: [],
|
||||
executedBlocks: [],
|
||||
environmentVariables: {},
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
completedLoops: new Set(),
|
||||
activeExecutionPath: [],
|
||||
executionOrder: 0,
|
||||
...overrides,
|
||||
} as unknown as ExecutionContext
|
||||
}
|
||||
|
||||
describe('getIterationContext', () => {
|
||||
it('returns undefined for undefined metadata', () => {
|
||||
const ctx = makeCtx()
|
||||
expect(getIterationContext(ctx, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves parallel branch metadata', () => {
|
||||
const ctx = makeCtx({
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'p1',
|
||||
{
|
||||
parallelId: 'p1',
|
||||
totalBranches: 3,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const metadata: IterationNodeMetadata = {
|
||||
branchIndex: 1,
|
||||
branchTotal: 3,
|
||||
subflowId: 'p1',
|
||||
subflowType: 'parallel',
|
||||
}
|
||||
const result = getIterationContext(ctx, metadata)
|
||||
expect(result).toEqual({
|
||||
iterationCurrent: 1,
|
||||
iterationTotal: 3,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'p1',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves loop node metadata', () => {
|
||||
const ctx = makeCtx({
|
||||
loopExecutions: new Map([
|
||||
[
|
||||
'l1',
|
||||
{
|
||||
iteration: 2,
|
||||
maxIterations: 5,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const metadata: IterationNodeMetadata = {
|
||||
isLoopNode: true,
|
||||
subflowId: 'l1',
|
||||
subflowType: 'loop',
|
||||
}
|
||||
const result = getIterationContext(ctx, metadata)
|
||||
expect(result).toEqual({
|
||||
iterationCurrent: 2,
|
||||
iterationTotal: 5,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: 'l1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildUnifiedParentIterations', () => {
|
||||
it('returns empty array when no parent maps exist', () => {
|
||||
const ctx = makeCtx()
|
||||
expect(buildUnifiedParentIterations(ctx, 'some-id')).toEqual([])
|
||||
})
|
||||
|
||||
it('resolves loop-in-loop parent chain', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([['inner-loop', { parentId: 'outer-loop', parentType: 'loop' }]]),
|
||||
loopExecutions: new Map([
|
||||
[
|
||||
'outer-loop',
|
||||
{
|
||||
iteration: 1,
|
||||
maxIterations: 3,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildUnifiedParentIterations(ctx, 'inner-loop')
|
||||
expect(result).toEqual([
|
||||
{
|
||||
iterationCurrent: 1,
|
||||
iterationTotal: 3,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: 'outer-loop',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves parallel-in-parallel parent chain', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
['inner-p__obranch-2', { parentId: 'outer-p', parentType: 'parallel', branchIndex: 2 }],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'outer-p',
|
||||
{
|
||||
parallelId: 'outer-p',
|
||||
totalBranches: 4,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildUnifiedParentIterations(ctx, 'inner-p__obranch-2')
|
||||
expect(result).toEqual([
|
||||
{
|
||||
iterationCurrent: 2,
|
||||
iterationTotal: 4,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'outer-p',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves loop-in-parallel (cross-type nesting)', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
['loop-1__obranch-1', { parentId: 'parallel-1', parentType: 'parallel', branchIndex: 1 }],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'parallel-1',
|
||||
{
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 5,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildUnifiedParentIterations(ctx, 'loop-1__obranch-1')
|
||||
expect(result).toEqual([
|
||||
{
|
||||
iterationCurrent: 1,
|
||||
iterationTotal: 5,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'parallel-1',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves parallel-in-loop (cross-type nesting)', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([['parallel-1', { parentId: 'loop-1', parentType: 'loop' }]]),
|
||||
loopExecutions: new Map([
|
||||
[
|
||||
'loop-1',
|
||||
{
|
||||
iteration: 3,
|
||||
maxIterations: 5,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildUnifiedParentIterations(ctx, 'parallel-1')
|
||||
expect(result).toEqual([
|
||||
{
|
||||
iterationCurrent: 3,
|
||||
iterationTotal: 5,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: 'loop-1',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves deep cross-type nesting: parallel → loop → parallel', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
['inner-p', { parentId: 'mid-loop', parentType: 'loop' }],
|
||||
['mid-loop', { parentId: 'outer-p', parentType: 'parallel', branchIndex: 0 }],
|
||||
['mid-loop__obranch-2', { parentId: 'outer-p', parentType: 'parallel', branchIndex: 2 }],
|
||||
]),
|
||||
loopExecutions: new Map([
|
||||
[
|
||||
'mid-loop',
|
||||
{
|
||||
iteration: 1,
|
||||
maxIterations: 4,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'outer-p',
|
||||
{
|
||||
parallelId: 'outer-p',
|
||||
totalBranches: 3,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildUnifiedParentIterations(ctx, 'inner-p')
|
||||
expect(result).toEqual([
|
||||
{
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 3,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'outer-p',
|
||||
},
|
||||
{
|
||||
iterationCurrent: 1,
|
||||
iterationTotal: 4,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: 'mid-loop',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves 3-level parallel nesting with branchIndex entries', () => {
|
||||
// P1 → P2 → P3, with P2__obranch-1 and P3__clone0__obranch-1
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
['P2', { parentId: 'P1', parentType: 'parallel', branchIndex: 0 }],
|
||||
['P3', { parentId: 'P2', parentType: 'parallel', branchIndex: 0 }],
|
||||
['P2__obranch-1', { parentId: 'P1', parentType: 'parallel', branchIndex: 1 }],
|
||||
[
|
||||
'P3__clone0__obranch-1',
|
||||
{ parentId: 'P2__obranch-1', parentType: 'parallel', branchIndex: 0 },
|
||||
],
|
||||
['P3__obranch-1', { parentId: 'P2', parentType: 'parallel', branchIndex: 1 }],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'P1',
|
||||
{
|
||||
parallelId: 'P1',
|
||||
totalBranches: 2,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
[
|
||||
'P2',
|
||||
{
|
||||
parallelId: 'P2',
|
||||
totalBranches: 2,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
[
|
||||
'P2__obranch-1',
|
||||
{
|
||||
parallelId: 'P2__obranch-1',
|
||||
totalBranches: 2,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
|
||||
// P3 (original): inside P2 branch 0, inside P1 branch 0
|
||||
expect(buildUnifiedParentIterations(ctx, 'P3')).toEqual([
|
||||
{
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 2,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'P1',
|
||||
},
|
||||
{
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 2,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'P2',
|
||||
},
|
||||
])
|
||||
|
||||
// P3__obranch-1 (runtime clone): inside P2 branch 1, inside P1 branch 0
|
||||
expect(buildUnifiedParentIterations(ctx, 'P3__obranch-1')).toEqual([
|
||||
{
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 2,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'P1',
|
||||
},
|
||||
{
|
||||
iterationCurrent: 1,
|
||||
iterationTotal: 2,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'P2',
|
||||
},
|
||||
])
|
||||
|
||||
// P3__clone0__obranch-1 (pre-expansion clone): inside P2__obranch-1 branch 0, inside P1 branch 1
|
||||
expect(buildUnifiedParentIterations(ctx, 'P3__clone0__obranch-1')).toEqual([
|
||||
{
|
||||
iterationCurrent: 1,
|
||||
iterationTotal: 2,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'P1',
|
||||
},
|
||||
{
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 2,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'P2__obranch-1',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('includes parent iterations in getIterationContext for loop-in-parallel', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
['loop-1__obranch-2', { parentId: 'parallel-1', parentType: 'parallel', branchIndex: 2 }],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'parallel-1',
|
||||
{
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 5,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
loopExecutions: new Map([
|
||||
[
|
||||
'loop-1__obranch-2',
|
||||
{
|
||||
iteration: 3,
|
||||
maxIterations: 5,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
|
||||
const metadata: IterationNodeMetadata = {
|
||||
isLoopNode: true,
|
||||
subflowId: 'loop-1__obranch-2',
|
||||
subflowType: 'loop',
|
||||
}
|
||||
const result = getIterationContext(ctx, metadata)
|
||||
expect(result).toEqual({
|
||||
iterationCurrent: 3,
|
||||
iterationTotal: 5,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: 'loop-1__obranch-2',
|
||||
parentIterations: [
|
||||
{
|
||||
iterationCurrent: 2,
|
||||
iterationTotal: 5,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'parallel-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('includes parent iterations in getIterationContext for parallel-in-loop', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([['parallel-1', { parentId: 'loop-1', parentType: 'loop' }]]),
|
||||
loopExecutions: new Map([
|
||||
[
|
||||
'loop-1',
|
||||
{
|
||||
iteration: 2,
|
||||
maxIterations: 5,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'parallel-1',
|
||||
{
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 3,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
|
||||
const metadata: IterationNodeMetadata = {
|
||||
branchIndex: 1,
|
||||
branchTotal: 3,
|
||||
subflowId: 'parallel-1',
|
||||
subflowType: 'parallel',
|
||||
}
|
||||
const result = getIterationContext(ctx, metadata)
|
||||
expect(result).toEqual({
|
||||
iterationCurrent: 1,
|
||||
iterationTotal: 3,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'parallel-1',
|
||||
parentIterations: [
|
||||
{
|
||||
iterationCurrent: 2,
|
||||
iterationTotal: 5,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: 'loop-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildContainerIterationContext', () => {
|
||||
it('returns undefined when no parent map exists', () => {
|
||||
const ctx = makeCtx()
|
||||
expect(buildContainerIterationContext(ctx, 'loop-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when container is not in parent map', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map(),
|
||||
})
|
||||
expect(buildContainerIterationContext(ctx, 'loop-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves loop nested inside parallel', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
['loop-1__obranch-2', { parentId: 'parallel-1', parentType: 'parallel', branchIndex: 2 }],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'parallel-1',
|
||||
{
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 5,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildContainerIterationContext(ctx, 'loop-1__obranch-2')
|
||||
expect(result).toEqual({
|
||||
iterationCurrent: 2,
|
||||
iterationTotal: 5,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'parallel-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves parallel nested inside loop', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([['parallel-1', { parentId: 'loop-1', parentType: 'loop' }]]),
|
||||
loopExecutions: new Map([
|
||||
[
|
||||
'loop-1',
|
||||
{
|
||||
iteration: 3,
|
||||
maxIterations: 10,
|
||||
currentIterationOutputs: new Map(),
|
||||
allIterationOutputs: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildContainerIterationContext(ctx, 'parallel-1')
|
||||
expect(result).toEqual({
|
||||
iterationCurrent: 3,
|
||||
iterationTotal: 10,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: 'loop-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined when parent scope is missing', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
['loop-1', { parentId: 'parallel-1', parentType: 'parallel', branchIndex: 0 }],
|
||||
]),
|
||||
parallelExecutions: new Map(),
|
||||
})
|
||||
expect(buildContainerIterationContext(ctx, 'loop-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves pre-expansion clone with explicit branchIndex', () => {
|
||||
// P1 → P2 → P3: P3__clone0__obranch-1 is pre-cloned inside P2__obranch-1
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
[
|
||||
'P3__clone0__obranch-1',
|
||||
{ parentId: 'P2__obranch-1', parentType: 'parallel', branchIndex: 0 },
|
||||
],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'P2__obranch-1',
|
||||
{
|
||||
parallelId: 'P2__obranch-1',
|
||||
totalBranches: 5,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildContainerIterationContext(ctx, 'P3__clone0__obranch-1')
|
||||
expect(result).toEqual({
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 5,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'P2__obranch-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses branch index 0 for non-cloned container in parallel parent', () => {
|
||||
const ctx = makeCtx({
|
||||
subflowParentMap: new Map([
|
||||
['inner-loop', { parentId: 'outer-parallel', parentType: 'parallel', branchIndex: 0 }],
|
||||
]),
|
||||
parallelExecutions: new Map([
|
||||
[
|
||||
'outer-parallel',
|
||||
{
|
||||
parallelId: 'outer-parallel',
|
||||
totalBranches: 3,
|
||||
branchOutputs: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
})
|
||||
const result = buildContainerIterationContext(ctx, 'inner-loop')
|
||||
expect(result).toEqual({
|
||||
iterationCurrent: 0,
|
||||
iterationTotal: 3,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: 'outer-parallel',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { DEFAULTS } from '@/executor/constants'
|
||||
import type { NodeMetadata } from '@/executor/dag/types'
|
||||
import type { IterationContext, ParentIteration } from '@/executor/execution/types'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import { findEffectiveContainerId } from '@/executor/utils/subflow-utils'
|
||||
|
||||
/** Maximum ancestor depth to prevent runaway traversal in deeply nested subflows. */
|
||||
const MAX_PARENT_DEPTH = DEFAULTS.MAX_NESTING_DEPTH
|
||||
|
||||
/**
|
||||
* Subset of {@link NodeMetadata} needed for iteration context resolution.
|
||||
* Compatible with both DAGNode.metadata and inline metadata objects.
|
||||
*/
|
||||
export type IterationNodeMetadata = Pick<
|
||||
NodeMetadata,
|
||||
'subflowType' | 'subflowId' | 'branchIndex' | 'branchTotal' | 'isLoopNode'
|
||||
>
|
||||
|
||||
/**
|
||||
* Resolves the iteration context for a node based on its metadata and execution state.
|
||||
* Handles both parallel (branch) and loop iteration contexts, including cross-type
|
||||
* nesting (loop-in-parallel, parallel-in-loop) via the unified subflow parent map.
|
||||
*/
|
||||
export function getIterationContext(
|
||||
ctx: ExecutionContext,
|
||||
metadata: IterationNodeMetadata | undefined
|
||||
): IterationContext | undefined {
|
||||
if (!metadata) return undefined
|
||||
|
||||
if (metadata.branchIndex !== undefined && metadata.branchTotal !== undefined) {
|
||||
const parallelId = metadata.subflowType === 'parallel' ? metadata.subflowId : undefined
|
||||
const parentIterations = parallelId ? buildUnifiedParentIterations(ctx, parallelId) : []
|
||||
return {
|
||||
iterationCurrent: metadata.branchIndex,
|
||||
iterationTotal: metadata.branchTotal,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: parallelId,
|
||||
...(parentIterations.length > 0 && { parentIterations }),
|
||||
}
|
||||
}
|
||||
|
||||
const loopId = metadata.subflowType === 'loop' ? metadata.subflowId : undefined
|
||||
if (metadata.isLoopNode && loopId) {
|
||||
const loopScope = ctx.loopExecutions?.get(loopId)
|
||||
if (loopScope && loopScope.iteration !== undefined) {
|
||||
const parentIterations = buildUnifiedParentIterations(ctx, loopId)
|
||||
return {
|
||||
iterationCurrent: loopScope.iteration,
|
||||
iterationTotal: loopScope.maxIterations,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: loopId,
|
||||
...(parentIterations.length > 0 && { parentIterations }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a single-level iteration context for a container (loop/parallel) that is
|
||||
* nested inside a parent subflow. Used by orchestrators when emitting onBlockComplete
|
||||
* for container sentinel nodes.
|
||||
*/
|
||||
export function buildContainerIterationContext(
|
||||
ctx: ExecutionContext,
|
||||
containerId: string
|
||||
): IterationContext | undefined {
|
||||
const parentEntry = ctx.subflowParentMap?.get(containerId)
|
||||
if (!parentEntry) return undefined
|
||||
|
||||
if (parentEntry.parentType === 'parallel') {
|
||||
if (parentEntry.branchIndex !== undefined) {
|
||||
const parentScope = ctx.parallelExecutions?.get(parentEntry.parentId)
|
||||
if (!parentScope) return undefined
|
||||
|
||||
return {
|
||||
iterationCurrent: parentEntry.branchIndex,
|
||||
iterationTotal: parentScope.totalBranches,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: parentEntry.parentId,
|
||||
}
|
||||
}
|
||||
} else if (parentEntry.parentType === 'loop') {
|
||||
const effectiveParentId = ctx.loopExecutions
|
||||
? findEffectiveContainerId(parentEntry.parentId, containerId, ctx.loopExecutions)
|
||||
: parentEntry.parentId
|
||||
const parentScope = ctx.loopExecutions?.get(effectiveParentId)
|
||||
if (parentScope && parentScope.iteration !== undefined) {
|
||||
return {
|
||||
iterationCurrent: parentScope.iteration,
|
||||
iterationTotal: parentScope.maxIterations,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: effectiveParentId,
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the unified subflow parent map to build the full ancestor iteration chain,
|
||||
* handling all nesting combinations (loop-in-loop, parallel-in-parallel,
|
||||
* loop-in-parallel, parallel-in-loop).
|
||||
*
|
||||
* Returns an array of parent iteration contexts, ordered from outermost to innermost.
|
||||
*/
|
||||
export function buildUnifiedParentIterations(
|
||||
ctx: ExecutionContext,
|
||||
subflowId: string
|
||||
): ParentIteration[] {
|
||||
if (!ctx.subflowParentMap) {
|
||||
return []
|
||||
}
|
||||
|
||||
const parents: ParentIteration[] = []
|
||||
const visited = new Set<string>()
|
||||
let currentId = subflowId
|
||||
|
||||
while (
|
||||
ctx.subflowParentMap.has(currentId) &&
|
||||
!visited.has(currentId) &&
|
||||
visited.size < MAX_PARENT_DEPTH
|
||||
) {
|
||||
visited.add(currentId)
|
||||
const entry = ctx.subflowParentMap.get(currentId)!
|
||||
const { parentId, parentType } = entry
|
||||
|
||||
if (parentType === 'loop') {
|
||||
// Resolve the effective (possibly cloned) loop ID — at runtime the scope
|
||||
// may live under a cloned ID like `mid-loop__obranch-2` rather than `mid-loop`
|
||||
const effectiveParentId = ctx.loopExecutions
|
||||
? findEffectiveContainerId(parentId, currentId, ctx.loopExecutions)
|
||||
: parentId
|
||||
const parentScope = ctx.loopExecutions?.get(effectiveParentId)
|
||||
if (parentScope && parentScope.iteration !== undefined) {
|
||||
parents.unshift({
|
||||
iterationCurrent: parentScope.iteration,
|
||||
iterationTotal: parentScope.maxIterations,
|
||||
iterationType: 'loop',
|
||||
iterationContainerId: effectiveParentId,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (entry.branchIndex === undefined) {
|
||||
currentId = parentId
|
||||
continue
|
||||
}
|
||||
const effectiveParentId = parentId
|
||||
const parentScope = ctx.parallelExecutions?.get(effectiveParentId)
|
||||
if (parentScope) {
|
||||
parents.unshift({
|
||||
iterationCurrent: entry.branchIndex,
|
||||
iterationTotal: parentScope.totalBranches,
|
||||
iterationType: 'parallel',
|
||||
iterationContainerId: effectiveParentId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
currentId = parentId
|
||||
}
|
||||
|
||||
return parents
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user