mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
611 lines
21 KiB
TypeScript
611 lines
21 KiB
TypeScript
import { STICKY_NODE_TYPE } from './constants';
|
|
import {
|
|
buildAdjacencyList,
|
|
parseExtractableSubgraphSelection,
|
|
type ExtractableErrorResult,
|
|
type ExtractableSubgraphData,
|
|
type IConnectionAdjacencyList,
|
|
} from './graph/graph-utils';
|
|
import {
|
|
NodeConnectionTypes,
|
|
type IConnections,
|
|
type INode,
|
|
type INodeInputConfiguration,
|
|
type INodeOutputConfiguration,
|
|
type INodeTypeDescription,
|
|
type INodeTypes,
|
|
type IWorkflowGroup,
|
|
type NodeConnectionType,
|
|
} from './interfaces';
|
|
import { isTriggerNode } from './node-helpers';
|
|
|
|
type NodeIo = NodeConnectionType | INodeInputConfiguration | INodeOutputConfiguration;
|
|
type IODirection = 'inputs' | 'outputs';
|
|
|
|
/** Character cap on a node group description; keeps it within 3 lines in the collapsed panel. */
|
|
export const GROUP_DESCRIPTION_MAX_LENGTH = 145;
|
|
|
|
/**
|
|
* Drops non-string values, caps to the max length, and treats empty as "no description".
|
|
*/
|
|
export function normalizeGroupDescription(description: unknown): string | undefined {
|
|
if (typeof description !== 'string') return undefined;
|
|
const capped = description.slice(0, GROUP_DESCRIPTION_MAX_LENGTH);
|
|
return capped.length > 0 ? capped : undefined;
|
|
}
|
|
|
|
export type NodeGroupingValidationInput<TNode extends INode = INode> = {
|
|
nodes: TNode[];
|
|
connectionsBySourceNode: IConnections;
|
|
getNodeType: (node: TNode) => INodeTypeDescription | null | undefined;
|
|
existingNodeGroups?: IWorkflowGroup[];
|
|
getNodeInputs?: (
|
|
node: TNode,
|
|
nodeType: INodeTypeDescription,
|
|
) => Array<NodeConnectionType | INodeInputConfiguration>;
|
|
getNodeOutputs?: (
|
|
node: TNode,
|
|
nodeType: INodeTypeDescription,
|
|
) => Array<NodeConnectionType | INodeOutputConfiguration>;
|
|
};
|
|
|
|
export type NodeSelectionValidationResult<TNode extends INode = INode> =
|
|
| { valid: true; subGraph: TNode[]; subGraphData: ExtractableSubgraphData }
|
|
| { valid: false; reason: 'trigger-selected'; triggers: string[] }
|
|
| { valid: false; reason: 'invalid-subgraph'; errors: ExtractableErrorResult[] }
|
|
| { valid: false; reason: 'multiple-input-branches'; node: string }
|
|
| { valid: false; reason: 'multiple-output-branches'; node: string };
|
|
|
|
export type NodeGroupValidationResult<TNode extends INode = INode> =
|
|
| NodeSelectionValidationResult<TNode>
|
|
| { valid: false; reason: 'node-already-grouped'; nodeIds: string[] }
|
|
| {
|
|
valid: false;
|
|
reason: 'non-main-boundary';
|
|
connection: { source: string; target: string; type: string };
|
|
};
|
|
|
|
/**
|
|
* Single source of truth for the structural grouping rules, shared by three
|
|
* surfaces so they cannot drift:
|
|
* - `sdkReference`: the rule as stated in the SDK/MCP docs an agent reads.
|
|
* - `violation`: the fragment used in the save-path rejection message below.
|
|
*
|
|
* Only the four reasons `validateNodeSelectionForGrouping` can actually return
|
|
* live here. The per-node `multiple-input-branches` / `multiple-output-branches`
|
|
* checks are extraction-only (`validateNodeSelectionForExtraction`) and are
|
|
* deliberately absent — a group is never rejected for them.
|
|
*/
|
|
export const NODE_GROUPING_RULES = {
|
|
triggerSelected: {
|
|
sdkReference: '**No trigger nodes.** Trigger nodes cannot be part of a group.',
|
|
violation: 'cannot contain trigger nodes',
|
|
},
|
|
invalidSubgraph: {
|
|
sdkReference:
|
|
'**One connected section with a single entry and exit.** The connectable members must ' +
|
|
'form a single connected section of the graph — reachable from one another, not two ' +
|
|
'unrelated islands — with at most one incoming and one outgoing main connection crossing ' +
|
|
'the group boundary. Sticky notes may accompany the selection without participating in ' +
|
|
'connectivity, and a sticky-only group is valid.',
|
|
violation: 'must form a single connected subgraph with a single entry and exit',
|
|
},
|
|
nonMainBoundary: {
|
|
sdkReference:
|
|
'**Keep AI sub-nodes with their Agent.** If an AI Agent is in a group, its language-model, ' +
|
|
'tool, and memory sub-nodes belong in the same group — put them either all inside the group ' +
|
|
'or all outside it, never split. A model/tool/memory connection must not cross the group boundary.',
|
|
// Fragment: reads as `<label> cannot cross the "<type>" connection between "<a>" and "<b>".`
|
|
violation: 'cannot cross the',
|
|
},
|
|
nodeAlreadyGrouped: {
|
|
sdkReference: '**One group per node.** A node can belong to at most one group at a time.',
|
|
violation: 'contains nodes that already belong to another group',
|
|
},
|
|
} as const;
|
|
|
|
export function validateNodeSelectionForExtraction<TNode extends INode>(
|
|
input: NodeGroupingValidationInput<TNode>,
|
|
): NodeSelectionValidationResult<TNode> {
|
|
const subgraphResult = validateNodeSelectionSubgraph(input);
|
|
if (!subgraphResult.valid) return subgraphResult;
|
|
|
|
const { nodes, getNodeType, getNodeInputs, getNodeOutputs } = input;
|
|
const { start, end } = subgraphResult.subGraphData;
|
|
const nodesByName = new Map(nodes.map((node) => [node.name, node]));
|
|
|
|
if (
|
|
start &&
|
|
!hasSingleMainIO(start, 'inputs', nodesByName, getNodeType, getNodeInputs, getNodeOutputs)
|
|
) {
|
|
return { valid: false, reason: 'multiple-input-branches', node: start };
|
|
}
|
|
|
|
if (
|
|
end &&
|
|
!hasSingleMainIO(end, 'outputs', nodesByName, getNodeType, getNodeInputs, getNodeOutputs)
|
|
) {
|
|
return { valid: false, reason: 'multiple-output-branches', node: end };
|
|
}
|
|
|
|
return subgraphResult;
|
|
}
|
|
|
|
export function validateNodeSelectionForGrouping<TNode extends INode>(
|
|
input: NodeGroupingValidationInput<TNode>,
|
|
): NodeGroupValidationResult<TNode> {
|
|
const alreadyGroupedNodeIds = findAlreadyGroupedNodeIds(
|
|
input.nodes.map((node) => node.id),
|
|
input.existingNodeGroups ?? [],
|
|
);
|
|
if (alreadyGroupedNodeIds.length > 0) {
|
|
return { valid: false, reason: 'node-already-grouped', nodeIds: alreadyGroupedNodeIds };
|
|
}
|
|
|
|
// Sticky notes have no connections, so the subgraph/connectivity rules
|
|
// below are checked against connectable nodes only — stickies ride along
|
|
// as plain members. A sticky-only group is valid *data* (a group can
|
|
// degenerate to one when its last connectable node is deleted); stricter
|
|
// rules live with the callers: creation surfaces require at least one
|
|
// connectable node (see `resolveGroupableNodeIds` in the editor) and
|
|
// persistence rejects memberless groups (see `validateWorkflowNodeGroups`
|
|
// in the CLI), which also keeps empty selections out of this fast path.
|
|
const connectableNodes = input.nodes.filter((node) => node.type !== STICKY_NODE_TYPE);
|
|
if (connectableNodes.length === 0) {
|
|
return {
|
|
valid: true,
|
|
subGraph: input.nodes,
|
|
subGraphData: { start: undefined, end: undefined },
|
|
};
|
|
}
|
|
|
|
const subgraphResult = validateNodeSelectionSubgraph({ ...input, nodes: connectableNodes });
|
|
if (!subgraphResult.valid) return subgraphResult;
|
|
|
|
const nodeNames = new Set(subgraphResult.subGraph.map((node) => node.name));
|
|
const boundaryConnection = findNonMainBoundaryConnection(
|
|
nodeNames,
|
|
input.connectionsBySourceNode,
|
|
);
|
|
|
|
if (boundaryConnection) {
|
|
return { valid: false, reason: 'non-main-boundary', connection: boundaryConnection };
|
|
}
|
|
|
|
// Report the full selection (stickies included) as the resulting subgraph.
|
|
return { ...subgraphResult, subGraph: input.nodes };
|
|
}
|
|
|
|
export type WorkflowGroupViolationCode =
|
|
| 'duplicate-group-id'
|
|
| 'duplicate-group-name'
|
|
| 'empty-group'
|
|
| 'unknown-node-id'
|
|
| 'node-in-multiple-groups'
|
|
| Extract<NodeGroupValidationResult, { valid: false }>['reason'];
|
|
|
|
export type WorkflowGroupViolation = {
|
|
groupId: string;
|
|
groupName: string;
|
|
code: WorkflowGroupViolationCode;
|
|
/** Actionable, user-facing description; identical to the save-path rejection message. */
|
|
message: string;
|
|
};
|
|
|
|
type WorkflowGroupViolationWithGroup = WorkflowGroupViolation & { group: IWorkflowGroup };
|
|
|
|
export type WorkflowGroupsValidationInput<TNode extends INode = INode> = {
|
|
nodes: TNode[];
|
|
connectionsBySourceNode?: IConnections;
|
|
nodeGroups?: IWorkflowGroup[];
|
|
/**
|
|
* Resolves a node to its type description (used to detect trigger nodes), or
|
|
* `undefined`/`null` for unknown node types so validation degrades gracefully.
|
|
* Pass `null` to run basic checks only.
|
|
*/
|
|
getNodeType: ((node: TNode) => INodeTypeDescription | null | undefined) | null;
|
|
};
|
|
|
|
export type WorkflowGroupsValidationResult =
|
|
| { valid: true }
|
|
| { valid: false; violations: [WorkflowGroupViolation, ...WorkflowGroupViolation[]] };
|
|
|
|
export type GetNodeTypeForGrouping = (node: INode) => INodeTypeDescription | null;
|
|
|
|
/**
|
|
* Builds the `getNodeType` callback that the grouping validator needs to resolve
|
|
* a node to its type description. Returns `null` for unknown node types so
|
|
* validation degrades gracefully rather than throwing.
|
|
*/
|
|
export function makeGetNodeTypeForGrouping(nodeTypes: INodeTypes): GetNodeTypeForGrouping {
|
|
return (node: INode) => {
|
|
try {
|
|
return nodeTypes.getByNameAndVersion(node.type, node.typeVersion).description;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validates a workflow's `nodeGroups` without throwing, collecting all violations.
|
|
* Single source of truth for group rules: persistence (CLI save path) rejects with
|
|
* the first violation's message, and validate-time surfaces (e.g. the MCP
|
|
* `validate_workflow` tool) report all of them as errors.
|
|
*
|
|
* Basic checks (always run): unique group IDs, unique group names, at least one
|
|
* member, all referenced node IDs exist, and each node belongs to at most one group.
|
|
*
|
|
* Full checks (run only when `getNodeType` is non-null, and skipped for groups that
|
|
* already have a basic violation): each group must satisfy the same grouping rules
|
|
* the canvas enforces — no triggers, a single connected subgraph, and no non-main
|
|
* connection crossing the group boundary — validated against the other groups as
|
|
* existing groups. Pass the `getNodeType` callback to run the full checks (on
|
|
* create, and on an update that changed the graph or the groups); pass `null` to
|
|
* run basic checks only (e.g. a git import, so legacy-invalid groups don't block
|
|
* the import).
|
|
*
|
|
* Violations are collected in the same order the save path checks them, so
|
|
* `violations[0]` is always the error a save would reject with.
|
|
*
|
|
* Note: must be called after node IDs are assigned (see `addNodeIds` in the CLI),
|
|
* since nodes created via the API may not have IDs until that step assigns them.
|
|
*/
|
|
export function validateWorkflowGroups<TNode extends INode>({
|
|
nodes,
|
|
connectionsBySourceNode,
|
|
nodeGroups,
|
|
getNodeType,
|
|
}: WorkflowGroupsValidationInput<TNode>): WorkflowGroupsValidationResult {
|
|
const result = validateWorkflowGroupsWithGroupIdentity({
|
|
nodes,
|
|
connectionsBySourceNode,
|
|
nodeGroups,
|
|
getNodeType,
|
|
});
|
|
|
|
if (result.valid) return { valid: true };
|
|
|
|
const [firstViolation, ...restViolations] = result.violations;
|
|
return {
|
|
valid: false,
|
|
violations: [
|
|
stripWorkflowGroupIdentity(firstViolation),
|
|
...restViolations.map(stripWorkflowGroupIdentity),
|
|
],
|
|
};
|
|
}
|
|
|
|
function validateWorkflowGroupsWithGroupIdentity<TNode extends INode>({
|
|
nodes,
|
|
connectionsBySourceNode,
|
|
nodeGroups,
|
|
getNodeType,
|
|
}: WorkflowGroupsValidationInput<TNode>):
|
|
| { valid: true }
|
|
| {
|
|
valid: false;
|
|
violations: [WorkflowGroupViolationWithGroup, ...WorkflowGroupViolationWithGroup[]];
|
|
} {
|
|
if (!nodeGroups || nodeGroups.length === 0) return { valid: true };
|
|
|
|
const violations: WorkflowGroupViolationWithGroup[] = [];
|
|
// Tracked by object identity: duplicate IDs/names make `group.id` ambiguous.
|
|
const groupsWithBasicViolations = new Set<IWorkflowGroup>();
|
|
const addViolation = (
|
|
group: IWorkflowGroup,
|
|
code: WorkflowGroupViolationCode,
|
|
message: string,
|
|
) => {
|
|
violations.push({ group, groupId: group.id, groupName: group.name, code, message });
|
|
};
|
|
|
|
const nodeById = new Map(nodes.filter((node) => Boolean(node.id)).map((node) => [node.id, node]));
|
|
// Node names are how users and agents identify nodes, so messages use them;
|
|
// the id is only a fallback for members without a resolvable name.
|
|
const nodeLabel = (nodeId: string) => nodeById.get(nodeId)?.name || nodeId;
|
|
const seenGroupIds = new Set<string>();
|
|
const seenGroupNames = new Set<string>();
|
|
const nodeToGroup = new Map<string, string>();
|
|
|
|
for (const group of nodeGroups) {
|
|
const addBasicViolation = (code: WorkflowGroupViolationCode, message: string) => {
|
|
addViolation(group, code, message);
|
|
groupsWithBasicViolations.add(group);
|
|
};
|
|
|
|
// Unique group IDs
|
|
if (seenGroupIds.has(group.id)) {
|
|
addBasicViolation('duplicate-group-id', `Duplicate node group ID "${group.id}".`);
|
|
}
|
|
seenGroupIds.add(group.id);
|
|
|
|
// Unique group names
|
|
if (seenGroupNames.has(group.name)) {
|
|
addBasicViolation('duplicate-group-name', `Duplicate node group name "${group.name}".`);
|
|
}
|
|
seenGroupNames.add(group.name);
|
|
|
|
if (group.nodeIds.length === 0) {
|
|
addBasicViolation('empty-group', `Group "${group.name}" has no members.`);
|
|
}
|
|
|
|
for (const nodeId of group.nodeIds) {
|
|
// All referenced nodes must exist
|
|
if (!nodeById.has(nodeId)) {
|
|
addBasicViolation(
|
|
'unknown-node-id',
|
|
`Group "${group.name}" references node ID "${nodeId}" that does not exist in the workflow.`,
|
|
);
|
|
continue;
|
|
}
|
|
// A node can only belong to one group
|
|
const existingGroup = nodeToGroup.get(nodeId);
|
|
if (existingGroup) {
|
|
addBasicViolation(
|
|
'node-in-multiple-groups',
|
|
`Node "${nodeLabel(nodeId)}" belongs to multiple groups: "${existingGroup}" and "${group.name}".`,
|
|
);
|
|
} else {
|
|
nodeToGroup.set(nodeId, group.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (getNodeType) {
|
|
const connections = connectionsBySourceNode ?? {};
|
|
|
|
for (const group of nodeGroups) {
|
|
// A basic violation makes the group's member set unreliable, so the
|
|
// graph rules would only produce misleading follow-up violations.
|
|
if (groupsWithBasicViolations.has(group)) continue;
|
|
|
|
const groupNodes = group.nodeIds.flatMap((id) => nodeById.get(id) ?? []);
|
|
const result = validateNodeSelectionForGrouping({
|
|
nodes: groupNodes,
|
|
connectionsBySourceNode: connections,
|
|
getNodeType,
|
|
existingNodeGroups: nodeGroups.filter((other) => other.id !== group.id),
|
|
});
|
|
if (!result.valid) {
|
|
addViolation(group, result.reason, groupRuleViolationMessage(group, result, nodeLabel));
|
|
}
|
|
}
|
|
}
|
|
|
|
const [firstViolation, ...restViolations] = violations;
|
|
if (!firstViolation) return { valid: true };
|
|
return { valid: false, violations: [firstViolation, ...restViolations] };
|
|
}
|
|
|
|
function stripWorkflowGroupIdentity({
|
|
groupId,
|
|
groupName,
|
|
code,
|
|
message,
|
|
}: WorkflowGroupViolationWithGroup): WorkflowGroupViolation {
|
|
return { groupId, groupName, code, message };
|
|
}
|
|
|
|
/**
|
|
* Non-fatal twin of `validateWorkflowGroups`: drops every offending group instead
|
|
* of throwing, returning every violation for the groups it dropped.
|
|
* Mutates `nodeGroups`.
|
|
*
|
|
* `shouldDrop` filters which violating groups are removed, letting a caller
|
|
* drop the groups it can blame first and re-check the rest afterwards.
|
|
*/
|
|
export function dropInvalidWorkflowGroups<TNode extends INode>(
|
|
workflow: { nodes: TNode[]; nodeGroups?: IWorkflowGroup[]; connections?: IConnections },
|
|
getNodeType: GetNodeTypeForGrouping | null,
|
|
shouldDrop: (violation: WorkflowGroupViolation) => boolean = () => true,
|
|
): WorkflowGroupViolation[] {
|
|
if (!workflow.nodeGroups?.length) {
|
|
return [];
|
|
}
|
|
|
|
const result = validateWorkflowGroupsWithGroupIdentity({
|
|
nodes: workflow.nodes,
|
|
connectionsBySourceNode: workflow.connections,
|
|
nodeGroups: workflow.nodeGroups,
|
|
getNodeType,
|
|
});
|
|
|
|
if (result.valid) {
|
|
return [];
|
|
}
|
|
|
|
const dropped = result.violations.filter(shouldDrop);
|
|
if (dropped.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const droppedGroups = new Set(dropped.map((violation) => violation.group));
|
|
workflow.nodeGroups = workflow.nodeGroups.filter((group) => !droppedGroups.has(group));
|
|
|
|
return dropped.map(stripWorkflowGroupIdentity);
|
|
}
|
|
|
|
/**
|
|
* Maps a failed `validateNodeSelectionForGrouping` result to an actionable message
|
|
* that names the offending group and the rule it broke. These strings are the
|
|
* public save-rejection messages — change them only deliberately.
|
|
*/
|
|
function groupRuleViolationMessage(
|
|
group: IWorkflowGroup,
|
|
result: Extract<NodeGroupValidationResult, { valid: false }>,
|
|
nodeLabel: (nodeId: string) => string,
|
|
): string {
|
|
const label = `Node group "${group.name}"`;
|
|
switch (result.reason) {
|
|
case 'trigger-selected':
|
|
return `${label} ${NODE_GROUPING_RULES.triggerSelected.violation}: ${result.triggers.join(', ')}.`;
|
|
case 'invalid-subgraph':
|
|
return `${label} ${NODE_GROUPING_RULES.invalidSubgraph.violation}.`;
|
|
case 'node-already-grouped':
|
|
return `${label} ${NODE_GROUPING_RULES.nodeAlreadyGrouped.violation}: ${result.nodeIds.map(nodeLabel).join(', ')}.`;
|
|
case 'non-main-boundary':
|
|
return `${label} ${NODE_GROUPING_RULES.nonMainBoundary.violation} "${result.connection.type}" connection between "${result.connection.source}" and "${result.connection.target}".`;
|
|
// Extraction-only reasons; unreachable from grouping but required for exhaustiveness.
|
|
case 'multiple-input-branches':
|
|
return `${label} has multiple input branches at node "${result.node}".`;
|
|
case 'multiple-output-branches':
|
|
return `${label} has multiple output branches at node "${result.node}".`;
|
|
}
|
|
}
|
|
|
|
function validateNodeSelectionSubgraph<TNode extends INode>({
|
|
nodes,
|
|
connectionsBySourceNode,
|
|
getNodeType,
|
|
}: NodeGroupingValidationInput<TNode>): NodeSelectionValidationResult<TNode> {
|
|
const triggers = nodes.filter((node) => {
|
|
const nodeType = getNodeType(node);
|
|
return nodeType ? isTriggerNode(nodeType) : false;
|
|
});
|
|
if (triggers.length > 0) {
|
|
return {
|
|
valid: false,
|
|
reason: 'trigger-selected',
|
|
triggers: triggers.map((node) => node.name),
|
|
};
|
|
}
|
|
|
|
const adjacencyList = buildAdjacencyList(connectionsBySourceNode);
|
|
const selectedNodeNames = new Set(nodes.map((node) => node.name));
|
|
const selection = parseExtractableSubgraphSelection(selectedNodeNames, adjacencyList);
|
|
|
|
if (Array.isArray(selection)) {
|
|
return { valid: false, reason: 'invalid-subgraph', errors: selection };
|
|
}
|
|
|
|
const disconnectedSelectionError = findDisconnectedSelectionError(
|
|
selectedNodeNames,
|
|
adjacencyList,
|
|
);
|
|
if (disconnectedSelectionError) {
|
|
return { valid: false, reason: 'invalid-subgraph', errors: [disconnectedSelectionError] };
|
|
}
|
|
|
|
return { valid: true, subGraph: nodes, subGraphData: selection };
|
|
}
|
|
|
|
function findAlreadyGroupedNodeIds(
|
|
selectionNodeIds: string[],
|
|
existingNodeGroups: IWorkflowGroup[],
|
|
): string[] {
|
|
const groupedNodeIds = new Set(existingNodeGroups.flatMap((group) => group.nodeIds));
|
|
return selectionNodeIds.filter((nodeId) => groupedNodeIds.has(nodeId));
|
|
}
|
|
|
|
function hasSingleMainIO<TNode extends INode>(
|
|
nodeName: string,
|
|
direction: IODirection,
|
|
nodesByName: Map<string, TNode>,
|
|
getNodeType: (node: TNode) => INodeTypeDescription | null | undefined,
|
|
getNodeInputs?: (
|
|
node: TNode,
|
|
nodeType: INodeTypeDescription,
|
|
) => Array<NodeConnectionType | INodeInputConfiguration>,
|
|
getNodeOutputs?: (
|
|
node: TNode,
|
|
nodeType: INodeTypeDescription,
|
|
) => Array<NodeConnectionType | INodeOutputConfiguration>,
|
|
): boolean {
|
|
const node = nodesByName.get(nodeName);
|
|
if (!node) return true;
|
|
const nodeType = getNodeType(node);
|
|
if (!nodeType) return true;
|
|
|
|
const ios =
|
|
direction === 'inputs'
|
|
? (getNodeInputs?.(node, nodeType) ?? nodeType.inputs)
|
|
: (getNodeOutputs?.(node, nodeType) ?? nodeType.outputs);
|
|
if (!Array.isArray(ios)) return true;
|
|
return ios.filter(isMainIo).length <= 1;
|
|
}
|
|
|
|
function isMainIo(io: NodeIo): boolean {
|
|
return typeof io === 'string'
|
|
? io === NodeConnectionTypes.Main
|
|
: io.type === NodeConnectionTypes.Main;
|
|
}
|
|
|
|
function findDisconnectedSelectionError(
|
|
nodeNames: Set<string>,
|
|
currentAdjacencyList: IConnectionAdjacencyList,
|
|
): ExtractableErrorResult | null {
|
|
if (nodeNames.size <= 1) return null;
|
|
|
|
const neighborsByNodeName = new Map<string, Set<string>>();
|
|
for (const nodeName of nodeNames) {
|
|
neighborsByNodeName.set(nodeName, new Set());
|
|
}
|
|
|
|
for (const [sourceNodeName, connections] of currentAdjacencyList.entries()) {
|
|
if (!nodeNames.has(sourceNodeName)) continue;
|
|
|
|
const sourceNeighbors = neighborsByNodeName.get(sourceNodeName);
|
|
if (!sourceNeighbors) continue;
|
|
|
|
for (const connection of connections) {
|
|
const targetNodeName = connection.node;
|
|
if (!nodeNames.has(targetNodeName) || targetNodeName === sourceNodeName) continue;
|
|
|
|
sourceNeighbors.add(targetNodeName);
|
|
neighborsByNodeName.get(targetNodeName)?.add(sourceNodeName);
|
|
}
|
|
}
|
|
|
|
const firstNodeName = nodeNames.values().next().value;
|
|
if (!firstNodeName) return null;
|
|
|
|
const visited = new Set<string>([firstNodeName]);
|
|
const queue = [firstNodeName];
|
|
while (queue.length > 0) {
|
|
const currentNodeName = queue.shift();
|
|
if (!currentNodeName) continue;
|
|
|
|
for (const neighborNodeName of neighborsByNodeName.get(currentNodeName) ?? []) {
|
|
if (visited.has(neighborNodeName)) continue;
|
|
|
|
visited.add(neighborNodeName);
|
|
queue.push(neighborNodeName);
|
|
}
|
|
}
|
|
|
|
if (visited.size === nodeNames.size) return null;
|
|
|
|
const disconnectedNodeName = Array.from(nodeNames).find((nodeName) => !visited.has(nodeName));
|
|
if (!disconnectedNodeName) return null;
|
|
|
|
return {
|
|
errorCode: 'No Continuous Path From Root To Leaf In Selection',
|
|
start: firstNodeName,
|
|
end: disconnectedNodeName,
|
|
};
|
|
}
|
|
|
|
function findNonMainBoundaryConnection(
|
|
nodeNames: Set<string>,
|
|
connectionsBySourceNode: IConnections,
|
|
): { source: string; target: string; type: string } | null {
|
|
for (const [sourceNodeName, sourceConnections] of Object.entries(connectionsBySourceNode)) {
|
|
const sourceInside = nodeNames.has(sourceNodeName);
|
|
|
|
for (const [type, connectionsByOutputIndex] of Object.entries(sourceConnections)) {
|
|
if (type === NodeConnectionTypes.Main) continue;
|
|
|
|
for (const connections of connectionsByOutputIndex) {
|
|
for (const connection of connections ?? []) {
|
|
if (sourceInside === nodeNames.has(connection.node)) continue;
|
|
|
|
return { source: sourceNodeName, target: connection.node, type };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|