feat(editor): Add ability to extract sub-workflows to canvas context menu (#15538)

This commit is contained in:
Charlie Kolb
2025-06-02 12:17:27 +02:00
committed by GitHub
parent 096806af15
commit 5985df6e51
23 changed files with 2070 additions and 373 deletions

View File

@@ -1,3 +1,5 @@
import type { IConnection, IConnections } from '../interfaces';
type MultipleInputNodesError = {
errorCode: 'Multiple Input Nodes';
nodes: Set<string>;
@@ -31,21 +33,21 @@ export type ExtractableErrorResult =
| OutputEdgeFromNonLeafNode
| NoContinuousPathFromRootToLeaf;
type AdjacencyList = Map<string, Set<string>>;
export type IConnectionAdjacencyList = Map<string, Set<IConnection>>;
/**
* Find all edges leading into the graph described in `graphIds`.
*/
export function getInputEdges(
graphIds: Set<string>,
adjacencyList: AdjacencyList,
): Array<[string, string]> {
const result: Array<[string, string]> = [];
adjacencyList: IConnectionAdjacencyList,
): Array<[string, IConnection]> {
const result: Array<[string, IConnection]> = [];
for (const [from, tos] of adjacencyList.entries()) {
if (graphIds.has(from)) continue;
for (const to of tos) {
if (graphIds.has(to)) {
if (graphIds.has(to.node)) {
result.push([from, to]);
}
}
@@ -59,14 +61,14 @@ export function getInputEdges(
*/
export function getOutputEdges(
graphIds: Set<string>,
adjacencyList: AdjacencyList,
): Array<[string, string]> {
const result: Array<[string, string]> = [];
adjacencyList: IConnectionAdjacencyList,
): Array<[string, IConnection]> {
const result: Array<[string, IConnection]> = [];
for (const [from, tos] of adjacencyList.entries()) {
if (!graphIds.has(from)) continue;
for (const to of tos) {
if (!graphIds.has(to)) {
if (!graphIds.has(to.node)) {
result.push([from, to]);
}
}
@@ -98,27 +100,49 @@ function difference<T>(minuend: Set<T>, subtrahend: Set<T>): Set<T> {
return result;
}
export function getRootNodes(graphIds: Set<string>, adjacencyList: AdjacencyList): Set<string> {
export function getRootNodes(
graphIds: Set<string>,
adjacencyList: IConnectionAdjacencyList,
): Set<string> {
// Inner nodes are all nodes with an incoming edge from another node in the graph
let innerNodes = new Set<string>();
for (const nodeId of graphIds) {
innerNodes = union(innerNodes, adjacencyList.get(nodeId) ?? new Set());
innerNodes = union(
innerNodes,
new Set(
[...(adjacencyList.get(nodeId) ?? [])]
.filter((x) => x.type === 'main' && x.node !== nodeId)
.map((x) => x.node),
),
);
}
return difference(graphIds, innerNodes);
}
export function getLeafNodes(graphIds: Set<string>, adjacencyList: AdjacencyList): Set<string> {
export function getLeafNodes(
graphIds: Set<string>,
adjacencyList: IConnectionAdjacencyList,
): Set<string> {
const result = new Set<string>();
for (const nodeId of graphIds) {
if (intersection(adjacencyList.get(nodeId) ?? new Set(), graphIds).size === 0) {
if (
intersection(
new Set(
[...(adjacencyList.get(nodeId) ?? [])]
.filter((x) => x.type === 'main' && x.node !== nodeId)
.map((x) => x.node),
),
graphIds,
).size === 0
) {
result.add(nodeId);
}
}
return result;
}
export function hasPath(start: string, end: string, adjacencyList: AdjacencyList) {
export function hasPath(start: string, end: string, adjacencyList: IConnectionAdjacencyList) {
const seen = new Set<string>();
const paths: string[] = [start];
while (true) {
@@ -127,7 +151,14 @@ export function hasPath(start: string, end: string, adjacencyList: AdjacencyList
if (next === undefined) return false;
seen.add(next);
paths.push(...difference(adjacencyList.get(next) ?? new Set<string>(), seen));
paths.push(
...difference(
new Set(
[...(adjacencyList.get(next) ?? [])].filter((x) => x.type === 'main').map((x) => x.node),
),
seen,
),
);
}
}
@@ -136,6 +167,31 @@ export type ExtractableSubgraphData = {
end?: string;
};
export function buildAdjacencyList(
connectionsBySourceNode: IConnections,
): IConnectionAdjacencyList {
const result = new Map<string, Set<IConnection>>();
const addOrCreate = (k: string, v: IConnection) =>
result.set(k, union(result.get(k) ?? new Set(), new Set([v])));
for (const sourceNode of Object.keys(connectionsBySourceNode)) {
for (const type of Object.keys(connectionsBySourceNode[sourceNode])) {
for (const sourceIndex of Object.keys(connectionsBySourceNode[sourceNode][type])) {
for (const connectionIndex of Object.keys(
connectionsBySourceNode[sourceNode][type][parseInt(sourceIndex, 10)] ?? [],
)) {
const connection =
connectionsBySourceNode[sourceNode][type][parseInt(sourceIndex, 10)]?.[
parseInt(connectionIndex, 10)
];
if (connection) addOrCreate(sourceNode, connection);
}
}
}
}
return result;
}
/**
* A subgraph is considered extractable if the following properties hold:
* - 0-1 input nodes from outside the subgraph, to a root node
@@ -152,14 +208,18 @@ export type ExtractableSubgraphData = {
*/
export function parseExtractableSubgraphSelection(
graphIds: Set<string>,
adjacencyList: AdjacencyList,
adjacencyList: IConnectionAdjacencyList,
): ExtractableSubgraphData | ExtractableErrorResult[] {
const errors: ExtractableErrorResult[] = [];
// 0-1 Input nodes
const inputEdges = getInputEdges(graphIds, adjacencyList);
const inputNodes = new Set(inputEdges.map((x) => x[1]));
const rootNodes = getRootNodes(graphIds, adjacencyList);
// This filters out e.g. sub-nodes, which are technically parents
const inputNodes = new Set(inputEdges.filter((x) => x[1].type === 'main').map((x) => x[1].node));
let rootNodes = getRootNodes(graphIds, adjacencyList);
// this enables supporting cases where we have one input and a loop back to it from within the selection
if (rootNodes.size === 0 && inputNodes.size === 1) rootNodes = inputNodes;
for (const inputNode of difference(inputNodes, rootNodes).values()) {
errors.push({
errorCode: 'Input Edge To Non-Root Node',
@@ -176,8 +236,13 @@ export function parseExtractableSubgraphSelection(
// 0-1 Output nodes
const outputEdges = getOutputEdges(graphIds, adjacencyList);
const outputNodes = new Set(outputEdges.map((x) => x[0]));
const leafNodes = getLeafNodes(graphIds, adjacencyList);
const outputNodes = new Set(outputEdges.filter((x) => x[1].type === 'main').map((x) => x[0]));
let leafNodes = getLeafNodes(graphIds, adjacencyList);
// If we have no leaf nodes, and only one output node, we can tolerate this output node
// and connect to it.
// Note that this is fairly theoretical, as return semantics in this case are not well-defined.
if (leafNodes.size === 0 && outputNodes.size === 1) leafNodes = outputNodes;
for (const outputNode of difference(outputNodes, leafNodes).values()) {
errors.push({
errorCode: 'Output Edge From Non-Leaf Node',

View File

@@ -14,6 +14,7 @@ export * from './execution-status';
export * from './expression';
export * from './from-ai-parse-utils';
export * from './node-helpers';
export * from './node-reference-parser-utils';
export * from './metadata-utils';
export * from './workflow';
export * from './workflow-data-proxy';
@@ -50,6 +51,13 @@ export {
isFilterValue,
} from './type-guards';
export {
parseExtractableSubgraphSelection,
buildAdjacencyList,
type ExtractableErrorResult,
type ExtractableSubgraphData,
type IConnectionAdjacencyList as AdjacencyList,
} from './graph/graph-utils';
export { ExpressionExtensions } from './extensions';
export * as ExpressionParser from './extensions/expression-parser';
export { NativeMethods } from './native-methods';

View File

@@ -1,7 +1,7 @@
import { escapeRegExp, mapValues, isEqual, cloneDeep } from 'lodash';
import { OperationalError } from './errors';
import type { INode, NodeParameterValueType } from './interfaces';
import type { INode, INodeParameters, NodeParameterValueType } from './interfaces';
class LazyRegExp {
private regExp?: RegExp;
@@ -176,7 +176,6 @@ function parseExpressionMapping(
for (; partsIdx < parts.length; ++partsIdx) {
if (!DOT_REFERENCEABLE_JS_VARIABLE.test(parts[partsIdx])) break;
}
return {
nodeNameInExpression: null,
originalExpression: `${exprStart}.${parts.slice(0, partsIdx + 1).join('.')}`, // $json.valid.until, but not ['x'] after
@@ -304,8 +303,12 @@ function parseCandidateMatch(
// Handle matches of form `$json.path.to.value`, which is necessary for the selection input node
function parse$jsonMatch(match: RegExpExecArray, expression: string, startNodeName: string) {
const candidate = extractExpressionCandidate(expression, match.index, match[0].length);
if (candidate === null) return;
const candidate = extractExpressionCandidate(
expression,
match.index,
match.index + match[0].length + 1,
);
if (candidate === null) return null;
return parseExpressionMapping(candidate, null, null, startNodeName);
}
@@ -439,6 +442,10 @@ function applyExtractMappingToNode(node: INode, parameterExtractMapping: Paramet
return parameters;
}
if (Array.isArray(parameters) && typeof mapping === 'object' && !Array.isArray(mapping)) {
return parameters.map((x, i) => applyMapping(x, mapping[i]) as INodeParameters);
}
return mapValues(parameters, (v, k) => applyMapping(v, mapping[k])) as NodeParameterValueType;
};
@@ -477,17 +484,16 @@ export function extractReferencesInNodeExpressions(
subGraph: INode[],
nodeNames: string[],
insertedStartName: string,
graphInputNodeName?: string,
graphInputNodeNames?: string[],
) {
////
// STEP 1 - Validate input invariants
////
if (nodeNames.includes(insertedStartName))
throw new OperationalError(
`StartNodeName ${insertedStartName} already exists in nodeNames: ${JSON.stringify(nodeNames)}`,
);
const subGraphNames = subGraph.map((x) => x.name);
if (subGraphNames.includes(insertedStartName))
throw new OperationalError(
`StartNodeName ${insertedStartName} already exists in nodeNames: ${JSON.stringify(subGraphNames)}`,
);
if (subGraphNames.some((x) => !nodeNames.includes(x))) {
throw new OperationalError(
@@ -516,7 +522,8 @@ export function extractReferencesInNodeExpressions(
////
// This map is used to change the actual expressions once resolved
const recMapByNode = new Map<string, ParameterExtractMapping>();
// The value represents fields in the actual parameters object which require change
const parameterTreeMappingByNode = new Map<string, ParameterExtractMapping>();
// This is used to track all candidates for change, necessary for deduplication
const allData = [];
@@ -527,10 +534,10 @@ export function extractReferencesInNodeExpressions(
nodeRegexps,
nodeNames,
insertedStartName,
node.name === graphInputNodeName,
graphInputNodeNames?.includes(node.name) ?? false,
),
);
recMapByNode.set(node.name, parameterMapping);
parameterTreeMappingByNode.set(node.name, parameterMapping);
allData.push(...allMappings);
}
@@ -560,8 +567,8 @@ export function extractReferencesInNodeExpressions(
return triggerArgumentMap.get(key);
};
for (const [key, value] of recMapByNode.entries()) {
recMapByNode.set(key, applyCanonicalMapping(value, getCanonicalData));
for (const [key, value] of parameterTreeMappingByNode.entries()) {
parameterTreeMappingByNode.set(key, applyCanonicalMapping(value, getCanonicalData));
}
const allUsedMappings = [];
@@ -569,7 +576,7 @@ export function extractReferencesInNodeExpressions(
for (const node of subGraph) {
const { result, usedMappings } = applyExtractMappingToNode(
cloneDeep(node),
recMapByNode.get(node.name),
parameterTreeMappingByNode.get(node.name),
);
allUsedMappings.push(...usedMappings);
output.push(result);

View File

@@ -927,4 +927,38 @@ export class Workflow {
return this.__getStartNode(Object.keys(this.nodes));
}
getConnectionsBetweenNodes(
sources: string[],
targets: string[],
): Array<[IConnection, IConnection]> {
const result: Array<[IConnection, IConnection]> = [];
for (const source of sources) {
for (const type of Object.keys(this.connectionsBySourceNode[source] ?? {})) {
for (const sourceIndex of Object.keys(this.connectionsBySourceNode[source][type])) {
for (const connectionIndex of Object.keys(
this.connectionsBySourceNode[source][type][parseInt(sourceIndex, 10)] ?? [],
)) {
const targetConnectionData =
this.connectionsBySourceNode[source][type][parseInt(sourceIndex, 10)]?.[
parseInt(connectionIndex, 10)
];
if (targetConnectionData && targets.includes(targetConnectionData?.node)) {
result.push([
{
node: source,
index: parseInt(sourceIndex, 10),
type: type as NodeConnectionType,
},
targetConnectionData,
]);
}
}
}
}
}
return result;
}
}