import type { WorkflowBuilder, WorkflowBuilderStatic, WorkflowSettings, WorkflowJSON, NodeInstance, ConnectionTarget, GraphNode, IDataObject, NodeChain, GeneratePinDataOptions, WorkflowBuilderOptions, ToJSONOptions, } from './types/base'; import { isNodeChain } from './types/base'; import type { ValidationOptions, ValidationResult, ValidationErrorCode } from './validation/index'; import { ValidationError, ValidationWarning } from './validation/index'; import { resolveTargetNodeName as resolveTargetNodeNameUtil } from './workflow-builder/connection-utils'; import { isInputTarget, cloneNodeWithId } from './workflow-builder/node-builders/node-builder'; import { shouldGeneratePinData } from './workflow-builder/pin-data-utils'; import { registerDefaultPlugins } from './workflow-builder/plugins/defaults'; import { pluginRegistry, type PluginRegistry } from './workflow-builder/plugins/registry'; import { jsonSerializer } from './workflow-builder/plugins/serializers'; import type { PluginContext, MutablePluginContext, ValidationIssue, SerializerContext, } from './workflow-builder/plugins/types'; import { generateDeterministicNodeId } from './workflow-builder/string-utils'; import { addNodeWithSubnodes as addNodeWithSubnodesUtil } from './workflow-builder/subnode-utils'; import { parseWorkflowJSON } from './workflow-builder/workflow-import'; // Ensure default plugins are registered on module load registerDefaultPlugins(pluginRegistry); /** * Internal workflow builder implementation */ class WorkflowBuilderImpl implements WorkflowBuilder { readonly id: string; readonly name: string; private _settings: WorkflowSettings; private _nodes: Map; private _currentNode: string | null; private _currentOutput: number; private _pinData?: Record; private _meta?: { templateId?: string; instanceId?: string; [key: string]: unknown }; private _registry?: PluginRegistry; private _staleIdToKeyMap?: Map; private _branchDepth = 0; private _dispatchedComposites = new WeakSet(); private static readonly MAX_BRANCH_DEPTH = 500; constructor( id: string, name: string, settings: WorkflowSettings = {}, nodes?: Map, currentNode?: string | null, pinData?: Record, meta?: { templateId?: string; instanceId?: string; [key: string]: unknown }, registry?: PluginRegistry, ) { this.id = id; this.name = name; this._settings = { ...settings }; this._nodes = nodes ? new Map(nodes) : new Map(); this._currentNode = currentNode ?? null; this._currentOutput = 0; this._pinData = pinData; this._meta = meta; this._registry = registry; } /** * Create a MutablePluginContext for composite handlers. * This provides helper methods that allow plugins to add nodes to the graph. * @param nodes The mutable nodes map * @param nameMapping Optional map to track node ID → actual map key for renamed nodes */ private createMutablePluginContext( nodes: Map, nameMapping?: Map, ): MutablePluginContext { const effectiveNameMapping = nameMapping ?? new Map(); return { nodes, workflowId: this.id, workflowName: this.name, settings: this._settings, pinData: this._pinData, nameMapping: effectiveNameMapping, addNodeWithSubnodes: (node: NodeInstance) => { const actualKey = this.addNodeWithSubnodes(nodes, node); // Auto-track renames when node is stored under a different key if (actualKey && actualKey !== node.name) { effectiveNameMapping.set(node.id, actualKey); } return actualKey; }, addBranchToGraph: (branch: unknown) => { return this.addBranchToGraph( nodes, branch as NodeInstance, effectiveNameMapping, ); }, trackRename: (nodeId: string, actualKey: string) => { effectiveNameMapping.set(nodeId, actualKey); }, }; } /** * Collect pinData from a node and merge it with existing pinData */ private collectPinData( node: NodeInstance, ): Record | undefined { const nodePinData = node.config?.pinData; if (!nodePinData || nodePinData.length === 0) { return this._pinData; } // Merge with existing pinData return { ...this._pinData, [node.name]: nodePinData, }; } /** * Collect pinData from all nodes in a chain */ private collectPinDataFromChain(chain: NodeChain): Record | undefined { let pinData = this._pinData; const registry = this._registry ?? pluginRegistry; for (const chainNode of chain.allNodes) { // Try plugin dispatch for composites const handler = registry.findCompositeHandler(chainNode); if (handler?.collectPinData) { handler.collectPinData(chainNode, (node) => { pinData = this.collectPinDataFromNode(node, pinData); }); } else if (chainNode?.config?.pinData) { // Regular node with pinData pinData = this.collectPinDataFromNode(chainNode, pinData); } } return pinData; } /** * Helper to collect pinData from a single node and merge with existing pinData */ private collectPinDataFromNode( node: NodeInstance, existingPinData: Record | undefined, ): Record | undefined { const nodePinData = node.config?.pinData; if (nodePinData && nodePinData.length > 0) { return { ...existingPinData, [node.name]: nodePinData, }; } return existingPinData; } add(node: unknown): WorkflowBuilder { // Handle plain array (fan-out) // This adds all targets without creating a primary connection if (Array.isArray(node)) { for (const target of node) { if (isInputTarget(target)) { // InputTarget - add the target node const inputTargetNode = target.node; if (!this._nodes.has(inputTargetNode.name)) { this.addNodeWithSubnodes(this._nodes, inputTargetNode); } } else if (isNodeChain(target)) { // Chain - add all nodes from the chain for (const chainNode of target.allNodes) { if (!this._nodes.has(chainNode.name)) { this.addNodeWithSubnodes(this._nodes, chainNode); } } this.addConnectionTargetNodes(this._nodes, target); } else { // Regular node const targetNode = target as NodeInstance; if (!this._nodes.has(targetNode.name)) { this.addNodeWithSubnodes(this._nodes, targetNode); } } } return this; } // Check for plugin composite handlers FIRST // This allows registered handlers to intercept composites before built-in handling // Always use global pluginRegistry as fallback (like we do for validators) const addRegistry = this._registry ?? pluginRegistry; const addHandler = addRegistry.findCompositeHandler(node); if (addHandler) { const ctx = this.createMutablePluginContext(this._nodes); const headName = addHandler.addNodes(node, ctx); this._currentNode = headName; this._currentOutput = 0; return this; } // Check if this is a NodeChain if (isNodeChain(node)) { // Track node ID -> actual map key for renamed nodes const nameMapping = new Map(); // Add all nodes from the chain, handling composites that may have been chained for (const chainNode of node.allNodes) { // Try plugin dispatch for composites - nameMapping is propagated through context const pluginResult = this.tryPluginDispatch(this._nodes, chainNode, nameMapping); if (pluginResult === undefined) { // Not a composite - add as regular node const actualKey = this.addNodeWithSubnodes(this._nodes, chainNode); // Track the actual key if it was renamed if (actualKey && actualKey !== chainNode.name) { nameMapping.set(chainNode.id, actualKey); } } } // Also add nodes from connections that aren't in allNodes (e.g., onError handlers) this.addConnectionTargetNodes(this._nodes, node, nameMapping); // Collect pinData from all nodes in the chain this._pinData = this.collectPinDataFromChain(node); // Set currentNode to the tail (last node in the chain) // Use nameMapping to get the actual key if the tail was renamed this._currentNode = nameMapping.get(node.tail.id) ?? node.tail.name; this._currentOutput = 0; return this; } // At this point, plugin dispatch has handled IfElseBuilder/SwitchCaseBuilder, and we've // handled NodeChain. The remaining type is NodeInstance or TriggerInstance. // Cast to NodeInstance to satisfy TypeScript (type narrowing). const regularNode = node as NodeInstance; // Regular node or trigger const actualKey = this.addNodeWithSubnodes(this._nodes, regularNode) ?? regularNode.name; // Also add connection target nodes (e.g., onError handlers) // This is important when re-adding a node that already exists but has new connections this.addSingleNodeConnectionTargets(this._nodes, regularNode); // Collect pinData from the node if present this._pinData = this.collectPinData(regularNode); this._currentNode = actualKey; this._currentOutput = 0; return this; } to(nodeOrComposite: unknown): WorkflowBuilder { // Handle InputTarget (e.g., mergeNode.input(0)) if (isInputTarget(nodeOrComposite)) { const actualNode = nodeOrComposite.node; const actualKey = this.addNodeWithSubnodes(this._nodes, actualNode) ?? actualNode.name; // Connect from current node to the target with the specified input index if (this._currentNode) { const currentGraphNode = this._nodes.get(this._currentNode); if (currentGraphNode) { const mainConns = currentGraphNode.connections.get('main') ?? new Map(); const outputConns: ConnectionTarget[] = mainConns.get(this._currentOutput) ?? []; const alreadyConnected = outputConns.some( (c) => c.node === actualKey && c.index === nodeOrComposite.inputIndex, ); if (!alreadyConnected) { outputConns.push({ node: actualKey, type: 'main', index: nodeOrComposite.inputIndex, }); } mainConns.set(this._currentOutput, outputConns); currentGraphNode.connections.set('main', mainConns); } } this._currentNode = actualKey; this._currentOutput = 0; return this; } // Handle array of nodes (fan-out pattern) if (Array.isArray(nodeOrComposite)) { return this.handleFanOut(nodeOrComposite); } // Handle NodeChain (e.g., node().to().to()) // This must come before composite checks since chains have composite-like properties if (isNodeChain(nodeOrComposite)) { return this.handleNodeChain(nodeOrComposite); } // Check for plugin composite handlers // This allows registered handlers to intercept composites before built-in handling // Always use global pluginRegistry as fallback (like we do for validators) const thenRegistry = this._registry ?? pluginRegistry; const thenHandler = thenRegistry.findCompositeHandler(nodeOrComposite); if (thenHandler) { const ctx = this.createMutablePluginContext(this._nodes); const headName = thenHandler.addNodes(nodeOrComposite, ctx); // Connect current node to head of composite if (this._currentNode) { const currentGraphNode = this._nodes.get(this._currentNode); if (currentGraphNode) { const mainConns = currentGraphNode.connections.get('main') ?? new Map(); const outputConns: ConnectionTarget[] = mainConns.get(this._currentOutput) ?? []; outputConns.push({ node: headName, type: 'main', index: 0 }); mainConns.set(this._currentOutput, outputConns); currentGraphNode.connections.set('main', mainConns); } } this._currentNode = headName; this._currentOutput = 0; return this; } // At this point, plugin dispatch handled all composite types (IfElse, SwitchCase, Merge, SplitInBatches). // Remaining type is a regular NodeInstance. const node = nodeOrComposite as NodeInstance; // addNodeWithSubnodes is idempotent: returns existing key for same instance, // generates unique name for name collisions, or adds new node. const actualKey = this.addNodeWithSubnodes(this._nodes, node) ?? node.name; // Add connection target nodes (e.g., onError handlers) this.addSingleNodeConnectionTargets(this._nodes, node); // Connect from current node if exists if (this._currentNode) { const currentGraphNode = this._nodes.get(this._currentNode); if (currentGraphNode) { const mainConns = currentGraphNode.connections.get('main') ?? new Map(); const outputConnections: ConnectionTarget[] = mainConns.get(this._currentOutput) ?? []; // Check for duplicate connections const alreadyConnected = outputConnections.some((c) => c.node === actualKey); if (!alreadyConnected) { mainConns.set(this._currentOutput, [ ...outputConnections, { node: actualKey, type: 'main', index: 0 }, ]); currentGraphNode.connections.set('main', mainConns); } } } // Collect pinData from the node if present this._pinData = this.collectPinData(node); this._currentNode = actualKey; this._currentOutput = 0; return this; } output(): never { throw new Error( 'Cannot call .output() on the workflow builder. ' + 'Use .output() on a node variable instead: myNode.output(0).to(targetNode)', ); } input(): never { throw new Error( 'Cannot call .input() on the workflow builder. ' + 'Use .input() on a node variable instead: myNode.input(1)', ); } settings(settings: WorkflowSettings): WorkflowBuilder { this._settings = { ...this._settings, ...settings }; return this; } connect( source: NodeInstance, sourceOutput: number, target: NodeInstance, targetInput: number, ): WorkflowBuilder { // Ensure both nodes exist in the graph if (!this._nodes.has(source.name)) { this.addNodeWithSubnodes(this._nodes, source); } if (!this._nodes.has(target.name)) { this.addNodeWithSubnodes(this._nodes, target); } // Add the explicit connection from source to target const sourceNode = this._nodes.get(source.name); if (sourceNode) { const mainConns = sourceNode.connections.get('main') ?? new Map(); const outputConns = mainConns.get(sourceOutput) ?? []; // Check if connection already exists const alreadyExists = outputConns.some( (c: ConnectionTarget) => c.node === target.name && c.index === targetInput, ); if (!alreadyExists) { outputConns.push({ node: target.name, type: 'main', index: targetInput }); mainConns.set(sourceOutput, outputConns); sourceNode.connections.set('main', mainConns); } } return this; } getNode(name: string): NodeInstance | undefined { // First try direct lookup (for backward compatibility and nodes added via add/then) const directLookup = this._nodes.get(name); if (directLookup) { return directLookup.instance; } // Otherwise search by instance.name (for nodes loaded via fromJSON) for (const graphNode of this._nodes.values()) { if (graphNode.instance.name === name) { return graphNode.instance; } } return undefined; } toJSON(options?: ToJSONOptions): WorkflowJSON { // Ensure composite targets from .onError() connections are added to the graph. // This handles cases where a chain node has .onError(ifElseBuilder) — the composite // isn't in the chain's allNodes, so it wasn't dispatched during chain processing. this.addMissingCompositeTargets(); // Merge connections declared on node instances via .to() into the graph this.mergeInstanceConnections(); // Create serializer context and delegate to jsonSerializer const ctx: SerializerContext = { nodes: this._nodes, workflowId: this.id, workflowName: this.name, settings: this._settings, pinData: this._pinData, meta: this._meta, tidyUp: options?.tidyUp ?? false, resolveTargetNodeName: (target: unknown) => this.resolveTargetNodeName(target), }; return jsonSerializer.serialize(ctx); } /** * Scan all nodes in the graph for connection targets that are composite types * (e.g., IfElseBuilder from .onError()) and dispatch them to add their nodes. * This runs once before serialization to catch composites missed during chain processing. */ private addMissingCompositeTargets(): void { const registry = this._registry ?? pluginRegistry; // Iterate over a snapshot of current nodes to avoid issues with map mutation during iteration const currentNodes = [...this._nodes.values()]; for (const graphNode of currentNodes) { if (typeof graphNode.instance.getConnections !== 'function') continue; const connections = graphNode.instance.getConnections(); for (const { target } of connections) { if (registry.isCompositeType(target)) { // Skip composites already dispatched during parsing (.add(), .to(), chain processing). // Only dispatch composites that were missed (e.g., .onError() on chain nodes). if ( typeof target === 'object' && target !== null && this._dispatchedComposites.has(target) ) { continue; } this.tryPluginDispatch(this._nodes, target); } } } } /** * Merge connections declared on node instances via .to() into the graph connections. * This prepares the graph for serialization by ensuring all connections are stored * in graphNode.connections. */ private mergeInstanceConnections(): void { for (const graphNode of this._nodes.values()) { // Only process if the node instance has getConnections() (nodes from builder, not fromJSON) if (typeof graphNode.instance.getConnections === 'function') { const nodeConns = graphNode.instance.getConnections(); for (const { target, outputIndex, targetInputIndex, connectionType } of nodeConns) { const connType = connectionType ?? 'main'; // Resolve target node name - handles both NodeInstance and composites. // Pass _staleIdToKeyMap so stale target references (from pre-clone // instances after regenerateNodeIds) resolve to the correct map key. const targetName = this.resolveTargetNodeName(target, this._staleIdToKeyMap); if (!targetName) continue; const typeConns = graphNode.connections.get(connType) ?? new Map(); const outputConns: ConnectionTarget[] = typeConns.get(outputIndex) ?? []; // Avoid duplicates - check both target node AND input index const targetIndex = targetInputIndex ?? 0; const alreadyExists = outputConns.some( (c) => c.node === targetName && c.index === targetIndex, ); if (!alreadyExists) { outputConns.push({ node: targetName, type: 'main', index: targetIndex }); typeConns.set(outputIndex, outputConns); graphNode.connections.set(connType, typeConns); } } } } } /** * Regenerate all node IDs using deterministic hashing based on workflow ID, node type, and node name. * This ensures that the same workflow structure always produces the same node IDs, * which is critical for the AI workflow builder where code may be re-parsed multiple times. * * Node IDs are generated using SHA-256 hash of `${workflowId}:${nodeType}:${nodeName}`, * formatted as a valid UUID v4 structure. */ regenerateNodeIds(): void { const newNodes = new Map(); // Build mapping from old instance IDs to map keys BEFORE cloning. // Cloned instances' _connections still reference original target instances // with old IDs. This mapping allows mergeInstanceConnections() to resolve // those stale references to the correct map key (important for auto-renamed nodes). const staleIdToKeyMap = new Map(); for (const [mapKey, graphNode] of this._nodes) { const instance = graphNode.instance; staleIdToKeyMap.set(instance.id, mapKey); const newId = generateDeterministicNodeId(this.id, instance.type, mapKey); // Clone the instance with the new deterministic ID const newInstance = cloneNodeWithId(instance, newId); newNodes.set(mapKey, { instance: newInstance, connections: graphNode.connections, }); } this._staleIdToKeyMap = staleIdToKeyMap; // Replace the nodes map this._nodes = newNodes; } validate(options: ValidationOptions = {}): ValidationResult { const errors: ValidationError[] = []; const warnings: ValidationWarning[] = []; // Run plugin-based validators (use provided registry or global) const registry = this._registry ?? pluginRegistry; const pluginCtx: PluginContext = { nodes: this._nodes, workflowId: this.id, workflowName: this.name, settings: this._settings, pinData: this._pinData, validationOptions: { allowDisconnectedNodes: options.allowDisconnectedNodes, allowNoTrigger: options.allowNoTrigger, nodeTypesProvider: options.nodeTypesProvider, }, }; // Run validators for each node for (const [_mapKey, graphNode] of this._nodes) { const nodeType = graphNode.instance.type; const validators = registry.getValidatorsForNodeType(nodeType); for (const validator of validators) { const issues = validator.validateNode(graphNode.instance, graphNode, pluginCtx); this.collectValidationIssues(issues, errors, warnings, ValidationError, ValidationWarning); } } // Run workflow-level validators for (const validator of registry.getValidators()) { if (validator.validateWorkflow) { const issues = validator.validateWorkflow(pluginCtx); this.collectValidationIssues(issues, errors, warnings, ValidationError, ValidationWarning); } } return { valid: errors.length === 0, errors, warnings, }; } /** * Collect validation issues from plugins and add them to errors/warnings arrays */ private collectValidationIssues( issues: ValidationIssue[], errors: ValidationError[], warnings: ValidationWarning[], ValidationErrorClass: typeof ValidationError, ValidationWarningClass: typeof ValidationWarning, ): void { for (const issue of issues) { // Cast code to ValidationErrorCode - plugins can use custom codes // that extend the built-in set const code = issue.code as ValidationErrorCode; if (issue.severity === 'error') { errors.push( new ValidationErrorClass( code, issue.message, issue.nodeName, undefined, issue.violationLevel, ), ); } else { warnings.push( new ValidationWarningClass( code, issue.message, issue.nodeName, issue.parameterPath, issue.originalName, issue.violationLevel, ), ); } } } toString(): string { return JSON.stringify(this.toJSON(), null, 2); } toFormat(format: string): T { const registry = this._registry; if (!registry) { throw new Error( `No serializer registered for format '${format}'. Provide a registry with serializers when creating the workflow.`, ); } const serializer = registry.getSerializer(format); if (!serializer) { throw new Error(`No serializer registered for format '${format}'`); } const ctx: SerializerContext = { nodes: this._nodes, workflowId: this.id, workflowName: this.name, settings: this._settings, pinData: this._pinData, meta: this._meta, resolveTargetNodeName: (target: unknown) => this.resolveTargetNodeName(target), }; return serializer.serialize(ctx) as T; } generatePinData(options?: GeneratePinDataOptions): WorkflowBuilder { const { beforeWorkflow } = options ?? {}; // Build set of existing node names from beforeWorkflow for quick lookup const existingNodeNames = beforeWorkflow ? new Set(beforeWorkflow.nodes.map((n) => n.name)) : undefined; for (const graphNode of this._nodes.values()) { const node = graphNode.instance; const nodeName = node.name; // Skip if node exists in beforeWorkflow (only process NEW nodes) if (existingNodeNames?.has(nodeName)) { continue; } // Skip if node already has pin data in current workflow if (this._pinData?.[nodeName]) { continue; } // Only generate for nodes that meet pin data criteria if (!shouldGeneratePinData(node)) { continue; } // Generate pin data from output declaration const output = node.config?.output; if (output && output.length > 0) { this._pinData = this._pinData ?? {}; this._pinData[nodeName] = output; } } return this; } /** * Resolve the target node name from a connection target. * Delegates to the resolveTargetNodeName utility function. */ private resolveTargetNodeName( target: unknown, nameMapping?: Map, ): string | undefined { const registry = this._registry ?? pluginRegistry; return resolveTargetNodeNameUtil(target, this._nodes, registry, nameMapping); } /** * Add target nodes from a chain's connections that aren't already in the nodes map. * This handles nodes added via .onError() which aren't included in the chain's allNodes. * @param nameMapping - Optional map from node ID to actual map key (used when nodes are renamed) */ private addConnectionTargetNodes( nodes: Map, chain: NodeChain, nameMapping?: Map, ): void { const registry = this._registry ?? pluginRegistry; const connections = chain.getConnections(); for (const { target } of connections) { // Skip composite types — they are handled either: // - In the caller's allNodes iteration (for chain members) // - Via addSingleNodeConnectionTargets (for .onError() targets) if (registry.isCompositeType(target)) continue; // Handle NodeChains - use addBranchToGraph to add all nodes with their connections if (isNodeChain(target)) { this.addBranchToGraph(nodes, target, nameMapping); continue; } // Handle InputTarget - add the referenced node if (isInputTarget(target)) { const inputTargetNode = target.node; if (!nodes.has(inputTargetNode.name)) { const actualKey = this.addNodeWithSubnodes(nodes, inputTargetNode); if (actualKey && nameMapping && actualKey !== inputTargetNode.name) { nameMapping.set(inputTargetNode.id, actualKey); } this.addSingleNodeConnectionTargets(nodes, inputTargetNode); } continue; } // Add the target node if not already in the map const targetNode = target; if (!nodes.has(targetNode.name)) { const actualKey = this.addNodeWithSubnodes(nodes, targetNode); if (actualKey && nameMapping && actualKey !== targetNode.name) { nameMapping.set(targetNode.id, actualKey); } this.addSingleNodeConnectionTargets(nodes, targetNode); } } } /** * Add target nodes from a single node's connections (e.g., onError handlers). * This handles connection targets that aren't part of a chain. */ private addSingleNodeConnectionTargets( nodes: Map, nodeInstance: NodeInstance, ): void { // Check if node has getConnections method (some composites don't) if (typeof nodeInstance.getConnections !== 'function') return; const registry = this._registry ?? pluginRegistry; const connections = nodeInstance.getConnections(); for (const { target } of connections) { // Dispatch composite types (e.g., IfElseBuilder from .onError()) to plugin handlers if (registry.isCompositeType(target)) { this.tryPluginDispatch(nodes, target); continue; } // Handle NodeChains - use addBranchToGraph to add all nodes with their connections if (isNodeChain(target)) { this.addBranchToGraph(nodes, target); continue; } // Handle InputTarget - add the referenced node if (isInputTarget(target)) { const inputTargetNode = target.node; if (!nodes.has(inputTargetNode.name)) { this.addNodeWithSubnodes(nodes, inputTargetNode); this.addSingleNodeConnectionTargets(nodes, inputTargetNode); } continue; } // Add the target node if not already in the map const targetNode = target; if (!nodes.has(targetNode.name)) { this.addNodeWithSubnodes(nodes, targetNode); this.addSingleNodeConnectionTargets(nodes, targetNode); } } } /** * Try to dispatch a composite to a plugin handler. * Returns the head node name if a handler processed it, undefined otherwise. * * This is used to replace inline composite handling methods with plugin-based dispatch. * The method checks for duplicate processing using the main node name and delegates * to the appropriate plugin handler if one is registered. * * @param nodes The mutable nodes map * @param target The target to dispatch (composite, builder, or node) * @param nameMapping Optional map to track node ID → actual map key for renamed nodes */ private tryPluginDispatch( nodes: Map, target: unknown, nameMapping?: Map, ): string | undefined { // NOTE: We intentionally don't skip if the main node already exists. // Handlers like ifElseHandler are designed to MERGE connections when the node exists. // This is important for patterns like: // .add(is_Approved.to(merge1.input(1))) // Adds IF node first // .add(merge_node.to(set_Default_True_2.to(is_Approved.onTrue(x_Post.to(x_Result))))) // The second line needs to add the onTrue() branch even though the IF node already exists. // Skip re-dispatch of already-processed composites. // The first dispatch fully processes all branch nodes. Re-dispatching // the same object causes exponential recursion in convergence patterns // (multiple paths reaching the same node). const registry = this._registry ?? pluginRegistry; if (typeof target === 'object' && target !== null && this._dispatchedComposites.has(target)) { return registry.resolveCompositeHeadName(target, nameMapping); } // Try plugin dispatch const handler = registry.findCompositeHandler(target); if (handler) { // Track dispatched composites so addMissingCompositeTargets can skip them if (typeof target === 'object' && target !== null) { this._dispatchedComposites.add(target); } const ctx = this.createMutablePluginContext(nodes, nameMapping); return handler.addNodes(target, ctx); } return undefined; } /** * Add a node and its subnodes to the nodes map, creating AI connections. * Delegates to the addNodeWithSubnodes utility function. */ private addNodeWithSubnodes( nodes: Map, nodeInstance: NodeInstance, ): string | undefined { return addNodeWithSubnodesUtil(nodes, nodeInstance); } /** * Handle fan-out pattern - connects current node to multiple target nodes * Supports NodeChain targets (e.g., workflow.to([x1, fb, linkedin.to(sheets)])) * * Each array element maps to a different output index (branching). * Use null to skip an output index. */ private handleFanOut(nodes: unknown[]): WorkflowBuilder { if (nodes.length === 0) { return this; } const currentGraphNode = this._currentNode ? this._nodes.get(this._currentNode) : undefined; // Add all target nodes and connect them to the current node nodes.forEach((node, index) => { // Skip null values (empty branches) but preserve the index for correct output mapping if (node === null) { return; } // Use addBranchToGraph to handle NodeChains properly // This returns the head node name for connection const headNodeName = this.addBranchToGraph( this._nodes, node as NodeInstance, ); // Connect from current node to the head of this target // Array syntax always uses incrementing output indices (branching behavior) if (this._currentNode && currentGraphNode) { const mainConns = currentGraphNode.connections.get('main') ?? new Map(); const outputConnections: ConnectionTarget[] = mainConns.get(index) ?? []; mainConns.set(index, [ ...outputConnections, { node: headNodeName, type: 'main', index: 0 }, ]); currentGraphNode.connections.set('main', mainConns); } }); // Set the last non-null node in the array as the current node (for continued chaining) // For NodeChains, use the tail node name (if tail is not null) const nonNullNodes = nodes.filter((n): n is NonNullable => n !== null); const lastNode = nonNullNodes[nonNullNodes.length - 1]; this._currentNode = lastNode ? isNodeChain(lastNode) ? (lastNode.tail?.name ?? this._currentNode) : (lastNode as NodeInstance).name : this._currentNode; this._currentOutput = 0; return this; } /** * Handle a NodeChain passed to workflow.to() * This is used when chained node calls are passed directly, e.g., workflow.to(node().to().to()) */ private handleNodeChain(chain: NodeChain): WorkflowBuilder { // Add the head node and connect from current workflow position const headNodeName = this.addBranchToGraph(this._nodes, chain); // Connect from current workflow node to the head of the chain if (this._currentNode) { const currentGraphNode = this._nodes.get(this._currentNode); if (currentGraphNode) { const mainConns = currentGraphNode.connections.get('main') ?? new Map(); const outputConnections: ConnectionTarget[] = mainConns.get(this._currentOutput) ?? []; // Standard behavior: connect to chain head outputConnections.push({ node: headNodeName, type: 'main', index: 0 }); mainConns.set(this._currentOutput, outputConnections); currentGraphNode.connections.set('main', mainConns); } } // Collect pinData from the chain this._pinData = this.collectPinDataFromChain(chain); // Set current node to the tail of the chain this._currentNode = chain.tail?.name ?? headNodeName; this._currentOutput = 0; return this; } /** * Add a branch to the graph, handling both single nodes and NodeChains. * Returns the name of the first node in the branch (for connection from IF). * @param nameMapping - Optional map from node ID to actual map key (used when nodes are renamed) */ private addBranchToGraph( nodes: Map, branch: NodeInstance, nameMapping?: Map, ): string { // Guard against infinite recursion from cycles in branch chains if (this._branchDepth >= WorkflowBuilderImpl.MAX_BRANCH_DEPTH) { throw new Error( `Maximum branch depth (${WorkflowBuilderImpl.MAX_BRANCH_DEPTH}) exceeded while building workflow graph`, ); } this._branchDepth++; try { return this._addBranchToGraphInner(nodes, branch, nameMapping); } finally { this._branchDepth--; } } private _addBranchToGraphInner( nodes: Map, branch: NodeInstance, nameMapping?: Map, ): string { // Create nameMapping if not passed (tracks node ID -> actual map key for renamed nodes) const effectiveNameMapping = nameMapping ?? new Map(); const registry = this._registry ?? pluginRegistry; // Try plugin dispatch first - handles all composite types const pluginResult = this.tryPluginDispatch(nodes, branch, effectiveNameMapping); if (pluginResult !== undefined) { return pluginResult; } // Check if the branch is a NodeChain if (isNodeChain(branch)) { // Add all nodes from the chain, handling composites that may have been chained for (const chainNode of branch.allNodes) { // Skip null values (can occur when .to([null, node]) is used) if (chainNode === null) { continue; } // Skip invalid objects that aren't valid nodes or composites // An object is valid if it has a 'name' property (NodeInstance) or is a registered composite type if ( typeof chainNode !== 'object' || (!('name' in chainNode) && !registry.isCompositeType(chainNode)) ) { continue; } // Try plugin dispatch for composites const chainPluginResult = this.tryPluginDispatch(nodes, chainNode, effectiveNameMapping); if (chainPluginResult === undefined) { // Not a composite - add as regular node const actualKey = this.addNodeWithSubnodes(nodes, chainNode); // Track the actual key if it was renamed if (actualKey && actualKey !== chainNode.name) { effectiveNameMapping.set(chainNode.id, actualKey); } } } // Process connections declared on the chain (from .to() calls) const connections = branch.getConnections(); for (const { target, outputIndex, targetInputIndex, connectionType } of connections) { const connType = connectionType ?? 'main'; // Find the source node in the chain that declared this connection // by looking for the node whose .to() was called for (const chainNode of branch.allNodes) { // Skip null values (from array syntax like [null, node]) if (chainNode === null) { continue; } // Get the actual node instance that might have connections // Nodes without getConnections (like SplitInBatchesBuilder) are skipped if (typeof chainNode.getConnections !== 'function') { continue; } const nodeToCheck = chainNode; const nodeName = chainNode.name; if (nodeToCheck && nodeName && typeof nodeToCheck.getConnections === 'function') { const nodeConns = nodeToCheck.getConnections(); if ( nodeConns.some( (c) => c.target === target && c.outputIndex === outputIndex && c.targetInputIndex === targetInputIndex && (c.connectionType ?? 'main') === connType, ) ) { // This chain node declared this connection // First, ensure target nodes are added to the graph (e.g., error handler chains) if (isNodeChain(target)) { const chainTarget = target; // Add each node in the chain that isn't already in the map // We can't just check the head because the chain may reuse an existing // node as head (e.g., set_content) while having new nodes after it for (const targetChainNode of chainTarget.allNodes) { if (targetChainNode === null) continue; // Try plugin dispatch for composites const targetPluginResult = this.tryPluginDispatch( nodes, targetChainNode, effectiveNameMapping, ); if (targetPluginResult === undefined && !nodes.has(targetChainNode.name)) { // Not a composite and not already present - add as regular node this.addNodeWithSubnodes(nodes, targetChainNode); this.addSingleNodeConnectionTargets(nodes, targetChainNode); } } } else if (registry.isCompositeType(target)) { // Only dispatch if the composite's head node isn't already in the graph // (avoids re-dispatching composites already handled by the allNodes loop above) const compositeHeadName = this.resolveTargetNodeName(target, effectiveNameMapping); if (!compositeHeadName || !nodes.has(compositeHeadName)) { this.tryPluginDispatch(nodes, target, effectiveNameMapping); } } else if ( typeof (target as NodeInstance).name === 'string' && !nodes.has((target as NodeInstance).name) ) { this.addNodeWithSubnodes(nodes, target as NodeInstance); this.addSingleNodeConnectionTargets( nodes, target as NodeInstance, ); } // Use the effectiveNameMapping to get the actual key if the node was renamed const mappedKey = nodeToCheck && effectiveNameMapping.get(nodeToCheck.id); const actualSourceKey = mappedKey ?? nodeName; const sourceGraphNode = nodes.get(actualSourceKey); if (sourceGraphNode) { const targetName = this.resolveTargetNodeName(target, effectiveNameMapping); if (targetName) { const typeConns = sourceGraphNode.connections.get(connType) ?? new Map(); const outputConns: ConnectionTarget[] = typeConns.get(outputIndex) ?? []; if ( !outputConns.some( (c) => c.node === targetName && c.index === (targetInputIndex ?? 0), ) ) { outputConns.push({ node: targetName, type: 'main', index: targetInputIndex ?? 0, }); typeConns.set(outputIndex, outputConns); sourceGraphNode.connections.set(connType, typeConns); } } } break; // Connection attributed to this node; stop searching chain } } } } // Return the head node name (first node in the chain) // Use effectiveNameMapping to get the actual key if the head was renamed const headKey = effectiveNameMapping.get(branch.head.id) ?? branch.head.name; return headKey; } else { // Single node - add it and return its name // Note: Composites are handled by tryPluginDispatch at the entry point const alreadyPresent = nodes.has(branch.name); const actualKey = this.addNodeWithSubnodes(nodes, branch); if (!alreadyPresent) { this.addSingleNodeConnectionTargets(nodes, branch); } // If the node was renamed, track it and return the actual key if (actualKey && actualKey !== branch.name) { effectiveNameMapping.set(branch.id, actualKey); } return actualKey ?? branch.name; } } } /** * Helper to check if options is a WorkflowBuilderOptions object */ function isWorkflowBuilderOptions( options: WorkflowSettings | WorkflowBuilderOptions | undefined, ): options is WorkflowBuilderOptions { if (!options) return false; // WorkflowBuilderOptions has 'settings' or 'registry' as keys // WorkflowSettings has keys like 'timezone', 'executionOrder', etc. return 'settings' in options || 'registry' in options; } /** * Create a new workflow builder */ function createWorkflow( id: string, name: string, options?: WorkflowSettings | WorkflowBuilderOptions, ): WorkflowBuilder { if (typeof id !== 'string') { const receivedId = Array.isArray(id) ? 'an array' : typeof id; throw new TypeError( // eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string 'workflow() requires (id: string, name: string). ' + `workflow() requires a string id as first argument, but received ${receivedId}. ` + "Example: workflow('my-workflow-id', 'My Workflow Name')", ); } if (typeof name !== 'string') { const receivedName = Array.isArray(name) ? 'an array' : typeof name; throw new TypeError( // eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string 'workflow() requires (id: string, name: string). ' + `workflow() requires a string name as second argument, but received ${receivedName}. ` + "Example: workflow('my-workflow-id', 'My Workflow Name')", ); } if ( options !== undefined && (Array.isArray(options) || (typeof options === 'object' && options !== null && ('nodes' in options || 'connections' in options))) ) { throw new TypeError( 'workflow() third argument is settings, not workflow structure. ' + 'Do not pass nodes or connections here — use .add() and .to() to build the workflow. ' + "Example: workflow('id', 'Name').add(trigger({...})).to(node({...}))", ); } if (isWorkflowBuilderOptions(options)) { return new WorkflowBuilderImpl( id, name, options.settings, undefined, undefined, undefined, undefined, options.registry, ); } return new WorkflowBuilderImpl(id, name, options); } /** * Import workflow from n8n JSON format */ function fromJSON(json: WorkflowJSON): WorkflowBuilder { const parsed = parseWorkflowJSON(json); return new WorkflowBuilderImpl( parsed.id, parsed.name, parsed.settings, parsed.nodes, parsed.lastNode, parsed.pinData, parsed.meta, ); } /** * Workflow builder factory function with static methods */ export const workflow: WorkflowBuilderStatic = Object.assign(createWorkflow, { fromJSON, });