feat(core): Add node group descriptions to the workflow SDK (no-changelog) (#35254)

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Miguel Ángel Moreno
2026-07-31 15:07:12 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent 6f44922d00
commit 68e4c528b9
10 changed files with 248 additions and 47 deletions
@@ -840,6 +840,22 @@ describe('AST Interpreter', () => {
interpretSDKCode(code, sdkFunctions);
expect(connectMock).toHaveBeenCalledWith('source', 0, 'target', 0);
});
it("should forward group()'s options object to the workflow builder", () => {
const groupMock = vi.fn();
sdkFunctions.workflow = vi.fn(() => ({
group: groupMock,
}));
// Members are irrelevant here — this pins the third argument surviving evaluation.
const code = `
const wf = workflow('id', 'name');
export default wf.group('Ingestion', [], { description: 'Pulls the CRM contacts' });
`;
interpretSDKCode(code, sdkFunctions);
expect(groupMock).toHaveBeenCalledWith('Ingestion', [], {
description: 'Pulls the CRM contacts',
});
});
});
describe('Security - dangerous globals (defense-in-depth)', () => {
@@ -1458,7 +1458,14 @@ export function generateCode(
const nodeName = idToNodeName.get(id);
return nodeName !== undefined ? [getVarName(nodeName, ctx)] : [];
});
workflowCalls.push(` .group('${escapeString(group.name)}', [${memberVars.join(', ')}])`);
const options = group.description
? `, { description: '${escapeString(group.description)}' }`
: '';
workflowCalls.push(
` .group('${escapeString(group.name)}', [${memberVars.join(', ')}]${options})`,
);
}
}
@@ -1,15 +1,17 @@
import { GROUP_DESCRIPTION_MAX_LENGTH, MANUAL_TRIGGER_NODE_TYPE } from 'n8n-workflow';
import { generateWorkflowCode } from './codegen';
import { parseWorkflowCodeToBuilder } from './codegen/parse-workflow-code';
import type { WorkflowJSON } from './types/base';
import type { GroupOptions, WorkflowJSON } from './types/base';
import { workflow } from './workflow-builder';
import { node, trigger } from './workflow-builder/node-builders/node-builder';
import { generateDeterministicGroupId } from './workflow-builder/string-utils';
const WF_ID = 'wf-groups-1';
function buildGroupedWorkflow() {
function buildGroupedWorkflow(options?: GroupOptions) {
const start = trigger({
type: 'n8n-nodes-base.manualTrigger',
type: MANUAL_TRIGGER_NODE_TYPE,
version: 1,
config: { name: 'Start' },
});
@@ -28,7 +30,7 @@ function buildGroupedWorkflow() {
.add(start)
.to(fetchNode)
.to(transform)
.group('Ingestion', [fetchNode, transform]);
.group('Ingestion', [fetchNode, transform], options);
}
describe('SDK node groups', () => {
@@ -47,7 +49,7 @@ describe('SDK node groups', () => {
it('drops members that do not resolve to a node in the workflow', () => {
const start = trigger({
type: 'n8n-nodes-base.manualTrigger',
type: MANUAL_TRIGGER_NODE_TYPE,
version: 1,
config: { name: 'Start' },
});
@@ -63,7 +65,7 @@ describe('SDK node groups', () => {
it('omits nodeGroups entirely when no group was declared', () => {
const start = trigger({
type: 'n8n-nodes-base.manualTrigger',
type: MANUAL_TRIGGER_NODE_TYPE,
version: 1,
config: { name: 'Start' },
});
@@ -72,6 +74,99 @@ describe('SDK node groups', () => {
});
});
describe('group description', () => {
it('emits the description alongside the group name and members', () => {
const json = buildGroupedWorkflow({ description: 'Pulls the CRM contacts' }).toJSON();
const idByName = new Map(json.nodes.map((n) => [n.name, n.id]));
expect(json.nodeGroups).toHaveLength(1);
expect(json.nodeGroups![0].name).toBe('Ingestion');
expect(json.nodeGroups![0].nodeIds).toEqual([
idByName.get('Fetch data'),
idByName.get('Transform'),
]);
expect(json.nodeGroups![0].description).toBe('Pulls the CRM contacts');
});
it('caps the description at the canvas limit', () => {
const description = 'x'.repeat(GROUP_DESCRIPTION_MAX_LENGTH + 50);
const json = buildGroupedWorkflow({ description }).toJSON();
expect(json.nodeGroups![0].description).toBe('x'.repeat(GROUP_DESCRIPTION_MAX_LENGTH));
});
it('leaves the description key off a group declared without options', () => {
const json = buildGroupedWorkflow().toJSON();
expect(json.nodeGroups![0]).not.toHaveProperty('description');
});
it('leaves the description key off when the authored description is blank', () => {
const json = buildGroupedWorkflow({ description: '' }).toJSON();
expect(json.nodeGroups![0]).not.toHaveProperty('description');
});
it('keeps a description authored in code through the build', () => {
const code = `
const start = trigger({ type: '${MANUAL_TRIGGER_NODE_TYPE}', version: 1, config: { name: 'Start' } });
const a = node({ type: 'n8n-nodes-base.set', version: 3, config: { name: 'A' } });
export default workflow('${WF_ID}', 'wf')
.add(start)
.to(a)
.group('G', [a], { description: 'Pulls the CRM contacts' });
`;
const json = parseWorkflowCodeToBuilder(code).toJSON();
expect(json.nodeGroups![0].description).toBe('Pulls the CRM contacts');
});
function describedWorkflowJSON(description: unknown): WorkflowJSON {
return {
id: WF_ID,
name: 'wf',
nodes: [
{
id: 'id-a',
name: 'A',
type: 'n8n-nodes-base.set',
typeVersion: 3,
position: [0, 0],
parameters: {},
},
],
connections: {},
nodeGroups: [
{ id: 'random-uuid', name: 'G', nodeIds: ['id-a'], description: description as string },
],
};
}
it('preserves the description across a fromJSON round-trip', () => {
const json = workflow.fromJSON(describedWorkflowJSON('Pulls the CRM contacts')).toJSON();
expect(json.nodeGroups![0].description).toBe('Pulls the CRM contacts');
});
it('keeps the description when the imported workflow is edited', () => {
const builder = workflow.fromJSON(describedWorkflowJSON('Pulls the CRM contacts'));
builder.add(node({ type: 'n8n-nodes-base.set', version: 3, config: { name: 'B' } }));
const json = builder.toJSON();
expect(json.nodes.map((n) => n.name)).toContain('B');
expect(json.nodeGroups![0].description).toBe('Pulls the CRM contacts');
});
it('drops a description that the source JSON carries as a non-string', () => {
const json = workflow.fromJSON(describedWorkflowJSON(42)).toJSON();
expect(json.nodeGroups![0]).not.toHaveProperty('description');
});
});
describe('regenerateNodeIds()', () => {
it('keeps group member ids consistent with the regenerated node ids (never dangling)', () => {
const builder = buildGroupedWorkflow();
@@ -87,7 +182,7 @@ describe('SDK node groups', () => {
it('resolves a grouped auto-renamed duplicate handle to the right node after regeneration', () => {
const start = trigger({
type: 'n8n-nodes-base.manualTrigger',
type: MANUAL_TRIGGER_NODE_TYPE,
version: 1,
config: { name: 'Start' },
});
@@ -210,7 +305,7 @@ describe('SDK node groups', () => {
describe('existingGroupIdsByName (toJSON option)', () => {
it('reuses an existing group id matched by name and derives one for a new group', () => {
const start = trigger({
type: 'n8n-nodes-base.manualTrigger',
type: MANUAL_TRIGGER_NODE_TYPE,
version: 1,
config: { name: 'Start' },
});
@@ -283,5 +378,39 @@ describe('SDK node groups', () => {
expect(json2.nodeGroups![0].id).toBe(json1.nodeGroups![0].id);
expect(json2.nodeGroups![0].nodeIds).toEqual(json1.nodeGroups![0].nodeIds);
});
it('emits the description as the third argument and preserves it through a re-parse', () => {
const builder = buildGroupedWorkflow({ description: 'Pulls the CRM contacts' });
builder.regenerateNodeIds();
const code = generateWorkflowCode(builder.toJSON());
expect(code).toContain(
".group('Ingestion', [fetch_data, transform], { description: 'Pulls the CRM contacts' })",
);
const rebuilt = parseWorkflowCodeToBuilder(code);
rebuilt.regenerateNodeIds();
expect(rebuilt.toJSON().nodeGroups![0].description).toBe('Pulls the CRM contacts');
});
it('escapes a quote in the description so the emitted code re-parses', () => {
const builder = buildGroupedWorkflow({ description: "Bob's contacts" });
builder.regenerateNodeIds();
const code = generateWorkflowCode(builder.toJSON());
expect(code).toContain("{ description: 'Bob\\'s contacts' }");
const rebuilt = parseWorkflowCodeToBuilder(code);
expect(rebuilt.toJSON().nodeGroups![0].description).toBe("Bob's contacts");
});
it('emits a two-argument .group() call when the group has no description', () => {
const code = generateWorkflowCode(buildGroupedWorkflow().toJSON());
expect(code).toContain(".group('Ingestion', [fetch_data, transform])");
expect(code).not.toContain('description:');
});
});
});
@@ -1,3 +1,5 @@
import { GROUP_DESCRIPTION_MAX_LENGTH } from 'n8n-workflow';
import { NODE_GROUPS_REFERENCE, SDK_LANGUAGE_REFERENCE } from './sdk-language';
import {
SDK_METHODS,
@@ -78,10 +80,19 @@ describe('NODE_GROUPS_REFERENCE', () => {
});
it('explains how to declare a group', () => {
// The worked example: .group(name, [members]) declared on the workflow.
// The worked example: .group(name, [members], options?) declared on the workflow.
expect(NODE_GROUPS_REFERENCE).toMatch(/\.group\('[^']+', \[/);
});
it('documents the optional description and its length limit', () => {
expect(NODE_GROUPS_REFERENCE).toContain('description:');
expect(NODE_GROUPS_REFERENCE).toContain(`${GROUP_DESCRIPTION_MAX_LENGTH} characters`);
});
it('tells an editing agent to keep existing descriptions', () => {
expect(NODE_GROUPS_REFERENCE).toMatch(/keep the .+ and their descriptions\s+intact/is);
});
it('states the single entry/exit boundary rule that grouping enforces', () => {
// reason: 'invalid-subgraph' — grouping rejects a group with more than one
// incoming or outgoing main connection (single entry/exit *boundary*).
@@ -2,7 +2,7 @@
* Builder-facing SDK language reference, rendered from the interpreter's own
* tables so guidance cannot drift from what the parser accepts.
*/
import { NODE_GROUPING_RULES } from 'n8n-workflow';
import { GROUP_DESCRIPTION_MAX_LENGTH, NODE_GROUPING_RULES } from 'n8n-workflow';
import {
SDK_METHODS,
@@ -86,7 +86,7 @@ export const NODE_GROUPS_REFERENCE = `## Node groups
A node group is a named, visual grouping of nodes (a frame on the canvas). It is
purely organisational — nothing about execution depends on it. Declare one with
\`.group(name, members)\` on the workflow. Members are the node handles (the
\`.group(name, members, options?)\` on the workflow. Members are the node handles (the
\`const\` from \`node(...)\`) — the same way connections reference nodes:
\`\`\`typescript
@@ -95,11 +95,16 @@ const transform = node({ /* ... name: 'Transform' */ });
export default workflow('id', 'My workflow')
.add(fetch)
.to(transform)
.group('Ingestion', [fetch, transform]);
.group('Ingestion', [fetch, transform], {
description: 'Pulls the CRM contacts and normalizes them',
});
\`\`\`
When editing an existing workflow, **keep the \`.group(...)\` calls intact** unless
the change is specifically about grouping.
\`description\` is optional and shown when the group is collapsed. Keep it to one
sentence — anything past ${GROUP_DESCRIPTION_MAX_LENGTH} characters is cut off.
When editing an existing workflow, **keep the \`.group(...)\` calls and their descriptions
intact** unless the change is specifically about grouping.
An invalid group is rejected on save, so these following rules MUST be followed when
creating or editing groups.
+26 -2
View File
@@ -1024,6 +1024,26 @@ export interface ToJSONOptions {
*/
export type GroupMember = NodeInstance<string, string, unknown>;
/** Optional group settings passed as `.group()`'s third argument. */
export type GroupOptions = {
/**
* Description shown when the group is collapsed on the canvas. Capped to
* `GROUP_DESCRIPTION_MAX_LENGTH` characters on serialization; blank is
* treated as no description.
*/
description?: string;
};
/**
* A node group as authored: members are node handles, resolved to the emitted node
* IDs only at serialization. `id` is present for a group carried in from JSON.
*/
export type AuthoredNodeGroup = GroupOptions & {
id?: string;
name: string;
members: GroupMember[];
};
export interface WorkflowBuilder {
readonly id: string;
readonly name: string;
@@ -1103,6 +1123,8 @@ export interface WorkflowBuilder {
* Members resolve to the emitted node IDs in `toJSON()`, so groups survive
* `regenerateNodeIds()` like connections do. Chainable.
*
* Pass `{ description }` to add the text shown when the group is collapsed.
*
* @example
* ```typescript
* const fetch = node({ ... });
@@ -1110,10 +1132,12 @@ export interface WorkflowBuilder {
* workflow('id', 'Name')
* .add(fetch)
* .to(transform)
* .group('Data ingestion', [fetch, transform]);
* .group('Data ingestion', [fetch, transform], {
* description: 'Pulls the CRM contacts and normalizes them',
* });
* ```
*/
group(name: string, members: GroupMember[]): WorkflowBuilder;
group(name: string, members: GroupMember[], options?: GroupOptions): WorkflowBuilder;
/**
* Validate the workflow graph structure.
@@ -7,7 +7,9 @@ import type {
NodeInstance,
ConnectionTarget,
GraphNode,
AuthoredNodeGroup,
GroupMember,
GroupOptions,
IDataObject,
NodeChain,
GeneratePinDataOptions,
@@ -30,6 +32,7 @@ import { jsonSerializer } from './workflow-builder/plugins/serializers';
import type {
PluginContext,
MutablePluginContext,
ResolvedNodeGroup,
ValidationIssue,
SerializerContext,
} from './workflow-builder/plugins/types';
@@ -58,7 +61,7 @@ class WorkflowBuilderImpl implements WorkflowBuilder {
private _dispatchedComposites = new WeakSet<object>();
private static readonly MAX_BRANCH_DEPTH = 500;
/** Node groups, carried by member node handle and resolved to IDs in toJSON(). */
private _nodeGroups: Array<{ id?: string; name: string; members: GroupMember[] }>;
private _nodeGroups: AuthoredNodeGroup[];
constructor(
id: string,
@@ -69,7 +72,7 @@ class WorkflowBuilderImpl implements WorkflowBuilder {
pinData?: Record<string, IDataObject[]>,
meta?: { templateId?: string; instanceId?: string; [key: string]: unknown },
registry?: PluginRegistry,
nodeGroups?: Array<{ id?: string; name: string; members: GroupMember[] }>,
nodeGroups?: AuthoredNodeGroup[],
) {
this.id = id;
this.name = name;
@@ -80,9 +83,7 @@ class WorkflowBuilderImpl implements WorkflowBuilder {
this._pinData = pinData;
this._meta = meta;
this._registry = registry;
this._nodeGroups = nodeGroups
? nodeGroups.map((g) => ({ id: g.id, name: g.name, members: [...g.members] }))
: [];
this._nodeGroups = nodeGroups ? nodeGroups.map((g) => ({ ...g, members: [...g.members] })) : [];
}
/**
@@ -499,8 +500,8 @@ class WorkflowBuilderImpl implements WorkflowBuilder {
return this;
}
group(name: string, members: GroupMember[]): WorkflowBuilder {
this._nodeGroups.push({ name, members: [...members] });
group(name: string, members: GroupMember[], options?: GroupOptions): WorkflowBuilder {
this._nodeGroups.push({ ...options, name, members: [...members] });
return this;
}
@@ -510,17 +511,17 @@ class WorkflowBuilderImpl implements WorkflowBuilder {
* _staleIdToKeyMap, exactly as connection targets do); the live instance under that
* key holds the ID the serializer emits. Unresolvable members are dropped.
*/
private resolveNodeGroups(): Array<{ id?: string; name: string; memberIds: string[] }> {
return this._nodeGroups.map((group) => {
private resolveNodeGroups(): ResolvedNodeGroup[] {
return this._nodeGroups.map(({ members, ...group }) => {
const memberIds: string[] = [];
for (const member of group.members) {
for (const member of members) {
const key = this.resolveTargetNodeName(member, this._staleIdToKeyMap);
const id = key ? this._nodes.get(key)?.instance.id : undefined;
if (id && !memberIds.includes(id)) {
memberIds.push(id);
}
}
return { id: group.id, name: group.name, memberIds };
return { ...group, memberIds };
});
}
@@ -4,7 +4,7 @@
* Serializes workflows to n8n's standard JSON format.
*/
import { deepCopy, normalizeNodeShape } from 'n8n-workflow';
import { deepCopy, normalizeGroupDescription, normalizeNodeShape } from 'n8n-workflow';
import { randomUUID } from 'node:crypto';
import { foldLegacyErrorConnections } from '../../../types/base';
@@ -280,14 +280,20 @@ export const jsonSerializer: SerializerPlugin<WorkflowJSON> = {
if (ctx.nodeGroups && ctx.nodeGroups.length > 0) {
const emittedIds = new Set(nodes.map((node) => node.id));
json.nodeGroups = ctx.nodeGroups.map((group) => ({
id:
json.nodeGroups = ctx.nodeGroups.map((group) => {
const description = normalizeGroupDescription(group.description);
const id =
group.id ??
ctx.existingGroupIdsByName?.get(group.name) ??
generateDeterministicGroupId(ctx.workflowId, group.name),
name: group.name,
nodeIds: group.memberIds.filter((id) => emittedIds.has(id)),
}));
generateDeterministicGroupId(ctx.workflowId, group.name);
return {
id,
name: group.name,
nodeIds: group.memberIds.filter((memberId) => emittedIds.has(memberId)),
...(description ? { description } : {}),
};
});
}
return json;
@@ -5,7 +5,10 @@
* WorkflowBuilder with custom validators, composite handlers, and serializers.
*/
import type { GraphNode, NodeInstance, IDataObject } from '../../types/base';
import type { AuthoredNodeGroup, GraphNode, NodeInstance, IDataObject } from '../../types/base';
/** An authored group with its members resolved to the node IDs the serializer emits. */
export type ResolvedNodeGroup = Omit<AuthoredNodeGroup, 'members'> & { memberIds: string[] };
// =============================================================================
// Utility Functions for Validators
@@ -362,7 +365,7 @@ export interface SerializerContext extends PluginContext {
* nodes carry. `id`, when present, is the source group ID (from fromJSON); the serializer
* reuses it, otherwise assigns one.
*/
readonly nodeGroups?: ReadonlyArray<{ id?: string; name: string; memberIds: string[] }>;
readonly nodeGroups?: readonly ResolvedNodeGroup[];
/**
* Existing group IDs keyed by group name. When a group name matches, the serializer
@@ -10,6 +10,7 @@ import {
foldLegacyErrorConnections,
normalizeConnections,
generateUniqueName,
type AuthoredNodeGroup,
type WorkflowJSON,
type NodeInstance,
type GraphNode,
@@ -31,12 +32,7 @@ export interface ParsedWorkflow {
readonly pinData?: Record<string, IDataObject[]>;
readonly meta?: { templateId?: string; instanceId?: string; [key: string]: unknown };
/** Node groups reconstructed by mapping the JSON's member IDs back to node handles. */
readonly nodeGroups?: Array<{
/** Source group ID from the JSON. */
id?: string;
name: string;
members: Array<NodeInstance<string, string, unknown>>;
}>;
readonly nodeGroups?: AuthoredNodeGroup[];
}
/**
@@ -141,10 +137,14 @@ export function parseWorkflowJSON(json: WorkflowJSON): ParsedWorkflow {
for (const [sourceName, nodeConns] of Object.entries(connections)) {
const mapKey = nameToKey.get(sourceName);
const graphNode = mapKey ? nodes.get(mapKey) : undefined;
if (!graphNode) continue;
if (!graphNode) {
continue;
}
for (const [connType, outputs] of Object.entries(nodeConns)) {
if (!outputs || !Array.isArray(outputs)) continue;
if (!outputs || !Array.isArray(outputs)) {
continue;
}
const typeMap =
graphNode.connections.get(connType) ?? new Map<number, ConnectionTarget[]>();
@@ -175,12 +175,11 @@ export function parseWorkflowJSON(json: WorkflowJSON): ParsedWorkflow {
lastNode = name;
}
// Rebuild groups by mapping each member ID back to its node handle (unresolvable IDs
// are dropped). The group's `id` is carried through so a round-trip preserves it.
const nodeGroups = json.nodeGroups?.length
? json.nodeGroups.map((group) => ({
id: group.id,
name: group.name,
description: group.description,
members: group.nodeIds.flatMap((id) => {
const instance = idToInstance.get(id);
return instance !== undefined ? [instance] : [];