From 68e4c528b923ba5d96642a3d998cabdd08d4ab0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel=20Moreno?= <1411774+miguelsaddress@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:07:12 +0200 Subject: [PATCH] feat(core): Add node group descriptions to the workflow SDK (no-changelog) (#35254) Co-authored-by: Claude Opus 5 (1M context) --- .../src/ast-interpreter/interpreter.test.ts | 16 ++ .../src/codegen/code-generator.ts | 9 +- .../@n8n/workflow-sdk/src/node-groups.test.ts | 145 +++++++++++++++++- .../sdk-reference/sdk-language.test.ts | 13 +- .../src/prompts/sdk-reference/sdk-language.ts | 15 +- packages/@n8n/workflow-sdk/src/types/base.ts | 28 +++- .../@n8n/workflow-sdk/src/workflow-builder.ts | 23 +-- .../plugins/serializers/json-serializer.ts | 20 ++- .../src/workflow-builder/plugins/types.ts | 7 +- .../src/workflow-builder/workflow-import.ts | 19 ++- 10 files changed, 248 insertions(+), 47 deletions(-) diff --git a/packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.test.ts b/packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.test.ts index 80d00fb3fe2..f8e12bf2cfe 100644 --- a/packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.test.ts +++ b/packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.test.ts @@ -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)', () => { diff --git a/packages/@n8n/workflow-sdk/src/codegen/code-generator.ts b/packages/@n8n/workflow-sdk/src/codegen/code-generator.ts index f7113ff61c3..2855cada388 100644 --- a/packages/@n8n/workflow-sdk/src/codegen/code-generator.ts +++ b/packages/@n8n/workflow-sdk/src/codegen/code-generator.ts @@ -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})`, + ); } } diff --git a/packages/@n8n/workflow-sdk/src/node-groups.test.ts b/packages/@n8n/workflow-sdk/src/node-groups.test.ts index 0d893e2c361..883b312b491 100644 --- a/packages/@n8n/workflow-sdk/src/node-groups.test.ts +++ b/packages/@n8n/workflow-sdk/src/node-groups.test.ts @@ -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:'); + }); }); }); diff --git a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.test.ts b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.test.ts index b91e3ddd62c..bf77a6afe72 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.test.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.test.ts @@ -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*). diff --git a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.ts b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.ts index 09ec815f9a2..4c67f43c3ca 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.ts @@ -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. diff --git a/packages/@n8n/workflow-sdk/src/types/base.ts b/packages/@n8n/workflow-sdk/src/types/base.ts index c83b0585ee6..a989f26a7f4 100644 --- a/packages/@n8n/workflow-sdk/src/types/base.ts +++ b/packages/@n8n/workflow-sdk/src/types/base.ts @@ -1024,6 +1024,26 @@ export interface ToJSONOptions { */ export type GroupMember = NodeInstance; +/** 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. diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder.ts b/packages/@n8n/workflow-sdk/src/workflow-builder.ts index 4417686e322..3137ddd2ab4 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder.ts @@ -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(); 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, 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 }; }); } diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/serializers/json-serializer.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/serializers/json-serializer.ts index ecfda8a8e4b..c2588d12bbe 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/serializers/json-serializer.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/serializers/json-serializer.ts @@ -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 = { 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; diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/types.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/types.ts index 81d417e6802..8b856cc94c9 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/types.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/plugins/types.ts @@ -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 & { 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 diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder/workflow-import.ts b/packages/@n8n/workflow-sdk/src/workflow-builder/workflow-import.ts index 06ad4a56846..e7025c790bd 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder/workflow-import.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder/workflow-import.ts @@ -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; 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>; - }>; + 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(); @@ -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] : [];