From 0dabb99ce511196a36333ff7cddc60f28d95d977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel=20Moreno?= <1411774+miguelsaddress@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:16:23 +0200 Subject: [PATCH] refactor(core): Dispatch MCP workflow operations via a handler table (no-changelog) (#34926) Co-authored-by: Claude Opus 4.8 (1M context) --- .../workflow-builder/workflow-operations.ts | 843 +++++++++++------- 1 file changed, 503 insertions(+), 340 deletions(-) diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/workflow-operations.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/workflow-operations.ts index c14377ce9bb..4b2df76270c 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/workflow-operations.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/workflow-operations.ts @@ -561,7 +561,7 @@ const pruneConnectionShape = ( const byType = connections[source]; if (!byType) return; const outputs = byType[connectionType]; - if (outputs && outputs.every((o) => !o || o.length === 0)) { + if (outputs?.every((o) => !o || o.length === 0)) { delete byType[connectionType]; } if (Object.keys(byType).length === 0) { @@ -575,6 +575,488 @@ const fail = (opIndex: number, message: string): ApplyOperationsFailure => ({ opIndex, }); +/** + * Mutable state threaded through every operation handler. Handlers mutate the + * workflow/maps in place and reassign `tagSet`/`nodeGroupsChanged` as needed; + * `applyOperations` reads the final values after the batch. + */ +interface ApplyContext { + workflow: WorkflowSlice; + nodeByName: Map; + addedNodeNames: Set; + // Null until the first tag op runs; keeps "no tag ops" distinguishable from + // "tag ops applied to an empty set" at return time. + tagSet: Set | null; + nodeGroupsChanged: boolean; +} + +/** + * Handler for a single operation type. Returns null on success, or a raw error + * message (without the `Operation N failed:` prefix, which `fail` adds). + */ +type OpHandler = ( + op: Extract, + ctx: ApplyContext, +) => string | null; + +/** + * Groups are persisted with node IDs, but ops reference nodes by name like every + * other operation; resolve against the current batch state so nodes added/renamed + * earlier in the same call are found. Dedupes repeats. + */ +const resolveGroupNodeIds = ( + nodeByName: Map, + nodeNames: string[], + groupName: string, +): { nodeIds: string[] } | { error: string } => { + const nodeIds = new Set(); + for (const nodeName of nodeNames) { + const node = nodeByName.get(nodeName); + if (!node) { + return { error: `node '${nodeName}' in group '${groupName}' not found` }; + } + nodeIds.add(node.id); + } + return { nodeIds: [...nodeIds] }; +}; + +const handleUpdateNodeParameters: OpHandler<'updateNodeParameters'> = (op, ctx) => { + const node = ctx.nodeByName.get(op.nodeName); + if (!node) { + return `node '${op.nodeName}' not found`; + } + const sanitized = sanitizeUnsafeKeys(op.parameters) as Record; + const merged = op.replace + ? sanitized + : deepMerge((node.parameters ?? {}) as Record, sanitized); + node.parameters = merged as INodeParameters; + return null; +}; + +const handleSetNodeParameter: OpHandler<'setNodeParameter'> = (op, ctx) => { + const node = ctx.nodeByName.get(op.nodeName); + if (!node) { + return `node '${op.nodeName}' not found`; + } + + const segments = parseJsonPointer(op.path); + if (!segments) { + return `path '${op.path}' is invalid or contains unsafe segments`; + } + + const params = (node.parameters ?? {}) as Record; + const setError = setAtPointer(params, segments, op.value); + if (setError) { + return setError; + } + + node.parameters = params as INodeParameters; + return null; +}; + +const handleAddNode: OpHandler<'addNode'> = (op, ctx) => { + if (!isSafeObjectProperty(op.node.name)) { + return `node name '${op.node.name}' is not allowed`; + } + + if (ctx.nodeByName.has(op.node.name)) { + return `a node named '${op.node.name}' already exists`; + } + + const node: INode = { + id: op.node.id ?? uuid(), + name: op.node.name, + type: op.node.type, + typeVersion: op.node.typeVersion, + position: op.node.position ?? [0, 0], + parameters: (sanitizeUnsafeKeys(op.node.parameters ?? {}) ?? {}) as INodeParameters, + }; + + if (op.node.credentials) { + const credentialEntries: Array<[string, { id: string | null; name: string }]> = []; + for (const [key, cred] of Object.entries(op.node.credentials)) { + if (!isSafeObjectProperty(key)) { + return `credential key '${key}' is not allowed`; + } + credentialEntries.push([key, { id: cred.id ?? null, name: cred.name }]); + } + node.credentials = Object.fromEntries(credentialEntries); + } + + if (op.node.disabled !== undefined) { + node.disabled = op.node.disabled; + } + + if (op.node.notes !== undefined) { + node.notes = op.node.notes; + } + + ctx.workflow.nodes.push(node); + ctx.nodeByName.set(node.name, node); + ctx.addedNodeNames.add(node.name); + return null; +}; + +const handleRemoveNode: OpHandler<'removeNode'> = (op, ctx) => { + const node = ctx.nodeByName.get(op.nodeName); + if (!node) { + return `node '${op.nodeName}' not found`; + } + + ctx.workflow.nodes.splice(ctx.workflow.nodes.indexOf(node), 1); + ctx.nodeByName.delete(op.nodeName); + removeConnectionsFor(ctx.workflow.connections, op.nodeName); + ctx.addedNodeNames.delete(op.nodeName); + // Prune the removed node from any group, dropping a group that empties out — + // mirrors the editor's delete behavior and keeps the save-path group + // validation (all member ids must exist) from rejecting the batch. + if (ctx.workflow.nodeGroups?.length) { + const prunedGroups: IWorkflowGroup[] = []; + for (const group of ctx.workflow.nodeGroups) { + if (!group.nodeIds.includes(node.id)) { + prunedGroups.push(group); + continue; + } + ctx.nodeGroupsChanged = true; + const remaining = group.nodeIds.filter((id) => id !== node.id); + if (remaining.length > 0) prunedGroups.push({ ...group, nodeIds: remaining }); + } + ctx.workflow.nodeGroups = prunedGroups; + } + return null; +}; + +const handleRenameNode: OpHandler<'renameNode'> = (op, ctx) => { + if (op.oldName === op.newName) { + return null; + } + + if (!isSafeObjectProperty(op.newName)) { + return `node name '${op.newName}' is not allowed`; + } + + const node = ctx.nodeByName.get(op.oldName); + if (!node) { + return `node '${op.oldName}' not found`; + } + + if (ctx.nodeByName.has(op.newName)) { + return `a node named '${op.newName}' already exists`; + } + + node.name = op.newName; + ctx.nodeByName.delete(op.oldName); + ctx.nodeByName.set(op.newName, node); + + renameInConnections(ctx.workflow.connections, op.oldName, op.newName); + + if (ctx.addedNodeNames.delete(op.oldName)) { + ctx.addedNodeNames.add(op.newName); + } + + return null; +}; + +const handleAddConnection: OpHandler<'addConnection'> = (op, ctx) => { + if (!ctx.nodeByName.has(op.source)) { + return `source node '${op.source}' not found`; + } + + if (!ctx.nodeByName.has(op.target)) { + return `target node '${op.target}' not found`; + } + + const connectionType = (op.connectionType ?? NodeConnectionTypes.Main) as NodeConnectionType; + if (!isSafeObjectProperty(op.source) || !isSafeObjectProperty(connectionType)) { + return 'connection name is not allowed'; + } + + const sourceIndex = op.sourceIndex ?? 0; + const targetIndex = op.targetIndex ?? 0; + const slot = ensureOutputSlot(ctx.workflow.connections, op.source, connectionType, sourceIndex); + const exists = slot.some( + (c) => c.node === op.target && c.type === connectionType && c.index === targetIndex, + ); + if (!exists) slot.push({ node: op.target, type: connectionType, index: targetIndex }); + return null; +}; + +const handleRemoveConnection: OpHandler<'removeConnection'> = (op, ctx) => { + const connectionType = (op.connectionType ?? NodeConnectionTypes.Main) as NodeConnectionType; + const sourceIndex = op.sourceIndex ?? 0; + const targetIndex = op.targetIndex ?? 0; + const byType = ctx.workflow.connections[op.source]; + const outputs = byType?.[connectionType]; + const slot = outputs?.[sourceIndex]; + + if (!slot) { + return `no '${connectionType}' connection from '${op.source}'`; + } + + const filtered = slot.filter( + (c) => !(c.node === op.target && c.type === connectionType && c.index === targetIndex), + ); + + if (filtered.length === slot.length) { + return `connection from '${op.source}'[${sourceIndex}] to '${op.target}'[${targetIndex}] does not exist`; + } + + outputs[sourceIndex] = filtered; + + pruneConnectionShape(ctx.workflow.connections, op.source, connectionType); + + return null; +}; + +const handleSetNodeCredential: OpHandler<'setNodeCredential'> = (op, ctx) => { + const node = ctx.nodeByName.get(op.nodeName); + if (!node) { + return `node '${op.nodeName}' not found`; + } + + if (!isSafeObjectProperty(op.credentialKey)) { + return `credential key '${op.credentialKey}' is not allowed`; + } + + node.credentials = { + ...(node.credentials ?? {}), + [op.credentialKey]: { id: op.credentialId, name: op.credentialName }, + }; + + return null; +}; + +const handleSetNodePosition: OpHandler<'setNodePosition'> = (op, ctx) => { + const node = ctx.nodeByName.get(op.nodeName); + if (!node) { + return `node '${op.nodeName}' not found`; + } + + node.position = op.position; + return null; +}; + +const handleSetNodeDisabled: OpHandler<'setNodeDisabled'> = (op, ctx) => { + const node = ctx.nodeByName.get(op.nodeName); + if (!node) { + return `node '${op.nodeName}' not found`; + } + + node.disabled = op.disabled; + return null; +}; + +const handleSetNodeSettings: OpHandler<'setNodeSettings'> = (op, ctx) => { + const node = ctx.nodeByName.get(op.nodeName); + if (!node) { + return `node '${op.nodeName}' not found`; + } + const s = op.settings; + if (s.onError !== undefined) { + node.onError = s.onError; + } + + if (s.retryOnFail !== undefined) { + node.retryOnFail = s.retryOnFail; + } + + if (s.maxTries !== undefined) { + node.maxTries = s.maxTries; + } + + if (s.waitBetweenTries !== undefined) { + node.waitBetweenTries = s.waitBetweenTries; + } + + if (s.alwaysOutputData !== undefined) { + node.alwaysOutputData = s.alwaysOutputData; + } + + if (s.executeOnce !== undefined) { + node.executeOnce = s.executeOnce; + } + + return null; +}; + +const handleSetWorkflowMetadata: OpHandler<'setWorkflowMetadata'> = (op, ctx) => { + if (op.name !== undefined) { + ctx.workflow.name = op.name; + } + + if (op.description !== undefined) { + ctx.workflow.description = op.description; + } + return null; +}; + +const handleSetWorkflowSettings: OpHandler<'setWorkflowSettings'> = (op, ctx) => { + // Shallow merge: only the provided keys overwrite, others are kept. + // `WorkflowService.update` later strips 'DEFAULT'/default values. + ctx.workflow.settings = { ...(ctx.workflow.settings ?? {}), ...op.settings }; + return null; +}; + +const handleSetNodeGroups: OpHandler<'setNodeGroups'> = (op, ctx) => { + const nodeGroups: IWorkflowGroup[] = []; + for (const group of op.nodeGroups) { + const resolved = resolveGroupNodeIds(ctx.nodeByName, group.nodeNames, group.name); + if ('error' in resolved) { + return resolved.error; + } + + // Omit blank descriptions so groups without one stay unset, matching the editor. + const description = group.description?.trim(); + nodeGroups.push({ + id: group.id ?? uuid(), + name: group.name, + nodeIds: resolved.nodeIds, + ...(description ? { description } : {}), + }); + } + ctx.workflow.nodeGroups = nodeGroups; + ctx.nodeGroupsChanged = true; + return null; +}; + +const handleAddNodeGroup: OpHandler<'addNodeGroup'> = (op, ctx) => { + const groups = ctx.workflow.nodeGroups ?? []; + if (groups.some((g) => g.name === op.name)) { + return `a node group named '${op.name}' already exists`; + } + + if (op.id !== undefined && groups.some((g) => g.id === op.id)) { + return `a node group with id '${op.id}' already exists`; + } + + const resolved = resolveGroupNodeIds(ctx.nodeByName, op.nodeNames, op.name); + if ('error' in resolved) { + return resolved.error; + } + + const description = op.description?.trim(); + groups.push({ + id: op.id ?? uuid(), + name: op.name, + nodeIds: resolved.nodeIds, + ...(description ? { description } : {}), + }); + + ctx.workflow.nodeGroups = groups; + ctx.nodeGroupsChanged = true; + + return null; +}; + +const handleRemoveNodeGroup: OpHandler<'removeNodeGroup'> = (op, ctx) => { + const groups = ctx.workflow.nodeGroups ?? []; + + const index = groups.findIndex((g) => g.name === op.groupName); + if (index === -1) { + return `node group '${op.groupName}' not found`; + } + + groups.splice(index, 1); + ctx.workflow.nodeGroups = groups; + ctx.nodeGroupsChanged = true; + + return null; +}; + +const handleUpdateNodeGroup: OpHandler<'updateNodeGroup'> = (op, ctx) => { + // Cross-field "at least one change" lives here because zod v3 discriminated + // unions cannot carry a `.refine()` on their members. + if (op.newName === undefined && op.nodeNames === undefined && op.description === undefined) { + return 'updateNodeGroup must specify at least one of newName, nodeNames, or description'; + } + + const groups = ctx.workflow.nodeGroups ?? []; + const group = groups.find((g) => g.name === op.groupName); + + if (!group) { + return `node group '${op.groupName}' not found`; + } + + if (op.nodeNames !== undefined) { + const resolved = resolveGroupNodeIds(ctx.nodeByName, op.nodeNames, op.groupName); + if ('error' in resolved) { + return resolved.error; + } + group.nodeIds = resolved.nodeIds; + } + + if (op.newName !== undefined && op.newName !== group.name) { + if (groups.some((g) => g !== group && g.name === op.newName)) { + return `a node group named '${op.newName}' already exists`; + } + group.name = op.newName; + } + + if (op.description !== undefined) { + // A blank description clears it, matching setNodeGroups / addNodeGroup. + const description = op.description.trim(); + if (description) { + group.description = description; + } else { + delete group.description; + } + } + + ctx.nodeGroupsChanged = true; + + return null; +}; + +const handleTagOp: OpHandler<'addTags' | 'removeTags'> = (op, ctx) => { + if (ctx.workflow.tagNames === undefined) { + return 'tag operations require existing tags to be loaded'; + } + + ctx.tagSet ??= new Set(ctx.workflow.tagNames); + if (op.type === 'addTags') { + for (const name of op.names) { + ctx.tagSet.add(name); + } + + return null; + } + + for (const name of op.names) { + ctx.tagSet.delete(name); + } + + return null; +}; + +/** + * Dispatch table keyed by operation type. Being fully typed, TS requires an entry + * for every member of the discriminated union — this is what enforces + * exhaustiveness (replacing the old switch `default: op satisfies never`). + */ +const OPERATION_HANDLERS: { [K in PartialUpdateOperation['type']]: OpHandler } = { + updateNodeParameters: handleUpdateNodeParameters, + setNodeParameter: handleSetNodeParameter, + addNode: handleAddNode, + removeNode: handleRemoveNode, + renameNode: handleRenameNode, + addConnection: handleAddConnection, + removeConnection: handleRemoveConnection, + setNodeCredential: handleSetNodeCredential, + setNodePosition: handleSetNodePosition, + setNodeDisabled: handleSetNodeDisabled, + setNodeSettings: handleSetNodeSettings, + setWorkflowMetadata: handleSetWorkflowMetadata, + setWorkflowSettings: handleSetWorkflowSettings, + setNodeGroups: handleSetNodeGroups, + addNodeGroup: handleAddNodeGroup, + removeNodeGroup: handleRemoveNodeGroup, + updateNodeGroup: handleUpdateNodeGroup, + // Thin wrappers narrow the shared handler to each slot's exact op type; a + // direct `handleTagOp` assignment trips a variance check on the `Extract`. + addTags: (op, ctx) => handleTagOp(op, ctx), + removeTags: (op, ctx) => handleTagOp(op, ctx), +}; + /** * Apply a sequence of partial-update operations to a workflow slice atomically. * Returns the mutated clone on success, or the first failure with the offending op index. @@ -586,357 +1068,37 @@ export function applyOperations( operations: PartialUpdateOperation[], ): ApplyOperationsResult { const workflow = cloneWorkflow(input); - const nodeByName = new Map(workflow.nodes.map((n) => [n.name, n])); - const addedNodeNames = new Set(); - // Tag set is null until the first tag op runs; that keeps "no tag ops" - // distinguishable from "tag ops applied to an empty set" at return time. - let tagSet: Set | null = null; - let nodeGroupsChanged = false; - - // Groups are persisted with node IDs, but ops reference nodes by name like - // every other operation; resolve against the current batch state so nodes - // added/renamed earlier in the same call are found. Dedupes repeats. - const resolveGroupNodeIds = ( - nodeNames: string[], - groupName: string, - ): { nodeIds: string[] } | { error: string } => { - const nodeIds = new Set(); - for (const nodeName of nodeNames) { - const node = nodeByName.get(nodeName); - if (!node) return { error: `node '${nodeName}' in group '${groupName}' not found` }; - nodeIds.add(node.id); - } - return { nodeIds: [...nodeIds] }; + const ctx: ApplyContext = { + workflow, + nodeByName: new Map(workflow.nodes.map((n) => [n.name, n])), + addedNodeNames: new Set(), + tagSet: null, + nodeGroupsChanged: false, }; for (let i = 0; i < operations.length; i++) { const op = operations[i]; + // The table is exhaustively typed, but indexing it with the union-typed + // `op.type` yields a union of handlers whose parameter TS narrows to + // `never`; the cast reconnects each op to its own handler. + const handler = OPERATION_HANDLERS[op.type] as OpHandler; - switch (op.type) { - case 'updateNodeParameters': { - const node = nodeByName.get(op.nodeName); - if (!node) return fail(i, `node '${op.nodeName}' not found`); - const sanitized = sanitizeUnsafeKeys(op.parameters) as Record; - const merged = op.replace - ? sanitized - : deepMerge((node.parameters ?? {}) as Record, sanitized); - node.parameters = merged as INodeParameters; - break; - } - - case 'setNodeParameter': { - const node = nodeByName.get(op.nodeName); - if (!node) return fail(i, `node '${op.nodeName}' not found`); - const segments = parseJsonPointer(op.path); - if (!segments) { - return fail(i, `path '${op.path}' is invalid or contains unsafe segments`); - } - const params = (node.parameters ?? {}) as Record; - const setError = setAtPointer(params, segments, op.value); - if (setError) return fail(i, setError); - node.parameters = params as INodeParameters; - break; - } - - case 'addNode': { - if (!isSafeObjectProperty(op.node.name)) { - return fail(i, `node name '${op.node.name}' is not allowed`); - } - if (nodeByName.has(op.node.name)) { - return fail(i, `a node named '${op.node.name}' already exists`); - } - const node: INode = { - id: op.node.id ?? uuid(), - name: op.node.name, - type: op.node.type, - typeVersion: op.node.typeVersion, - position: op.node.position ?? [0, 0], - parameters: (sanitizeUnsafeKeys(op.node.parameters ?? {}) ?? {}) as INodeParameters, - }; - if (op.node.credentials) { - const credentialEntries: Array<[string, { id: string | null; name: string }]> = []; - for (const [key, cred] of Object.entries(op.node.credentials)) { - if (!isSafeObjectProperty(key)) { - return fail(i, `credential key '${key}' is not allowed`); - } - credentialEntries.push([key, { id: cred.id ?? null, name: cred.name }]); - } - node.credentials = Object.fromEntries(credentialEntries); - } - if (op.node.disabled !== undefined) node.disabled = op.node.disabled; - if (op.node.notes !== undefined) node.notes = op.node.notes; - workflow.nodes.push(node); - nodeByName.set(node.name, node); - addedNodeNames.add(node.name); - break; - } - - case 'removeNode': { - const node = nodeByName.get(op.nodeName); - if (!node) return fail(i, `node '${op.nodeName}' not found`); - workflow.nodes.splice(workflow.nodes.indexOf(node), 1); - nodeByName.delete(op.nodeName); - removeConnectionsFor(workflow.connections, op.nodeName); - addedNodeNames.delete(op.nodeName); - // Prune the removed node from any group, dropping a group that empties - // out — mirrors the editor's delete behavior and keeps the save-path - // group validation (all member ids must exist) from rejecting the batch. - if (workflow.nodeGroups?.length) { - const prunedGroups: IWorkflowGroup[] = []; - for (const group of workflow.nodeGroups) { - if (!group.nodeIds.includes(node.id)) { - prunedGroups.push(group); - continue; - } - nodeGroupsChanged = true; - const remaining = group.nodeIds.filter((id) => id !== node.id); - if (remaining.length > 0) prunedGroups.push({ ...group, nodeIds: remaining }); - } - workflow.nodeGroups = prunedGroups; - } - break; - } - - case 'renameNode': { - if (op.oldName === op.newName) break; - if (!isSafeObjectProperty(op.newName)) { - return fail(i, `node name '${op.newName}' is not allowed`); - } - const node = nodeByName.get(op.oldName); - if (!node) return fail(i, `node '${op.oldName}' not found`); - if (nodeByName.has(op.newName)) { - return fail(i, `a node named '${op.newName}' already exists`); - } - node.name = op.newName; - nodeByName.delete(op.oldName); - nodeByName.set(op.newName, node); - renameInConnections(workflow.connections, op.oldName, op.newName); - if (addedNodeNames.delete(op.oldName)) addedNodeNames.add(op.newName); - break; - } - - case 'addConnection': { - if (!nodeByName.has(op.source)) { - return fail(i, `source node '${op.source}' not found`); - } - if (!nodeByName.has(op.target)) { - return fail(i, `target node '${op.target}' not found`); - } - const connectionType = (op.connectionType ?? - NodeConnectionTypes.Main) as NodeConnectionType; - if (!isSafeObjectProperty(op.source) || !isSafeObjectProperty(connectionType)) { - return fail(i, 'connection name is not allowed'); - } - const sourceIndex = op.sourceIndex ?? 0; - const targetIndex = op.targetIndex ?? 0; - const slot = ensureOutputSlot(workflow.connections, op.source, connectionType, sourceIndex); - const exists = slot.some( - (c) => c.node === op.target && c.type === connectionType && c.index === targetIndex, - ); - if (!exists) { - slot.push({ node: op.target, type: connectionType, index: targetIndex }); - } - break; - } - - case 'removeConnection': { - const connectionType = (op.connectionType ?? - NodeConnectionTypes.Main) as NodeConnectionType; - const sourceIndex = op.sourceIndex ?? 0; - const targetIndex = op.targetIndex ?? 0; - const byType = workflow.connections[op.source]; - const outputs = byType?.[connectionType]; - const slot = outputs?.[sourceIndex]; - if (!slot) { - return fail(i, `no '${connectionType}' connection from '${op.source}'`); - } - const filtered = slot.filter( - (c) => !(c.node === op.target && c.type === connectionType && c.index === targetIndex), - ); - if (filtered.length === slot.length) { - return fail( - i, - `connection from '${op.source}'[${sourceIndex}] to '${op.target}'[${targetIndex}] does not exist`, - ); - } - outputs[sourceIndex] = filtered; - pruneConnectionShape(workflow.connections, op.source, connectionType); - break; - } - - case 'setNodeCredential': { - const node = nodeByName.get(op.nodeName); - if (!node) return fail(i, `node '${op.nodeName}' not found`); - if (!isSafeObjectProperty(op.credentialKey)) { - return fail(i, `credential key '${op.credentialKey}' is not allowed`); - } - node.credentials = { - ...(node.credentials ?? {}), - [op.credentialKey]: { id: op.credentialId, name: op.credentialName }, - }; - break; - } - - case 'setNodePosition': { - const node = nodeByName.get(op.nodeName); - if (!node) return fail(i, `node '${op.nodeName}' not found`); - node.position = op.position; - break; - } - - case 'setNodeDisabled': { - const node = nodeByName.get(op.nodeName); - if (!node) return fail(i, `node '${op.nodeName}' not found`); - node.disabled = op.disabled; - break; - } - - case 'setNodeSettings': { - const node = nodeByName.get(op.nodeName); - if (!node) return fail(i, `node '${op.nodeName}' not found`); - const s = op.settings; - if (s.onError !== undefined) node.onError = s.onError; - if (s.retryOnFail !== undefined) node.retryOnFail = s.retryOnFail; - if (s.maxTries !== undefined) node.maxTries = s.maxTries; - if (s.waitBetweenTries !== undefined) node.waitBetweenTries = s.waitBetweenTries; - if (s.alwaysOutputData !== undefined) node.alwaysOutputData = s.alwaysOutputData; - if (s.executeOnce !== undefined) node.executeOnce = s.executeOnce; - break; - } - - case 'setWorkflowMetadata': { - if (op.name !== undefined) workflow.name = op.name; - if (op.description !== undefined) workflow.description = op.description; - break; - } - - case 'setWorkflowSettings': { - // Shallow merge: only the provided keys overwrite, others are kept. - // `WorkflowService.update` later strips 'DEFAULT'/default values. - workflow.settings = { ...(workflow.settings ?? {}), ...op.settings }; - break; - } - - case 'setNodeGroups': { - const nodeGroups: IWorkflowGroup[] = []; - for (const group of op.nodeGroups) { - const resolved = resolveGroupNodeIds(group.nodeNames, group.name); - if ('error' in resolved) return fail(i, resolved.error); - // Omit blank descriptions so groups without one stay unset, matching the editor. - const description = group.description?.trim(); - nodeGroups.push({ - id: group.id ?? uuid(), - name: group.name, - nodeIds: resolved.nodeIds, - ...(description ? { description } : {}), - }); - } - workflow.nodeGroups = nodeGroups; - nodeGroupsChanged = true; - break; - } - - case 'addNodeGroup': { - const groups = workflow.nodeGroups ?? []; - if (groups.some((g) => g.name === op.name)) { - return fail(i, `a node group named '${op.name}' already exists`); - } - if (op.id !== undefined && groups.some((g) => g.id === op.id)) { - return fail(i, `a node group with id '${op.id}' already exists`); - } - const resolved = resolveGroupNodeIds(op.nodeNames, op.name); - if ('error' in resolved) return fail(i, resolved.error); - const description = op.description?.trim(); - groups.push({ - id: op.id ?? uuid(), - name: op.name, - nodeIds: resolved.nodeIds, - ...(description ? { description } : {}), - }); - workflow.nodeGroups = groups; - nodeGroupsChanged = true; - break; - } - - case 'removeNodeGroup': { - const groups = workflow.nodeGroups ?? []; - const index = groups.findIndex((g) => g.name === op.groupName); - if (index === -1) return fail(i, `node group '${op.groupName}' not found`); - groups.splice(index, 1); - workflow.nodeGroups = groups; - nodeGroupsChanged = true; - break; - } - - case 'updateNodeGroup': { - // Cross-field "at least one change" lives here because zod v3 - // discriminated unions cannot carry a `.refine()` on their members. - if ( - op.newName === undefined && - op.nodeNames === undefined && - op.description === undefined - ) { - return fail( - i, - 'updateNodeGroup must specify at least one of newName, nodeNames, or description', - ); - } - const groups = workflow.nodeGroups ?? []; - const group = groups.find((g) => g.name === op.groupName); - if (!group) return fail(i, `node group '${op.groupName}' not found`); - if (op.nodeNames !== undefined) { - const resolved = resolveGroupNodeIds(op.nodeNames, op.groupName); - if ('error' in resolved) return fail(i, resolved.error); - group.nodeIds = resolved.nodeIds; - } - if (op.newName !== undefined && op.newName !== group.name) { - if (groups.some((g) => g !== group && g.name === op.newName)) { - return fail(i, `a node group named '${op.newName}' already exists`); - } - group.name = op.newName; - } - if (op.description !== undefined) { - // A blank description clears it, matching the blank-description - // handling of setNodeGroups / addNodeGroup. - const description = op.description.trim(); - if (description) group.description = description; - else delete group.description; - } - nodeGroupsChanged = true; - break; - } - - case 'addTags': - case 'removeTags': { - if (workflow.tagNames === undefined) { - return fail(i, 'tag operations require existing tags to be loaded'); - } - if (tagSet === null) tagSet = new Set(workflow.tagNames); - if (op.type === 'addTags') { - for (const name of op.names) tagSet.add(name); - } else { - for (const name of op.names) tagSet.delete(name); - } - break; - } - - default: { - op satisfies never; - return fail(i, 'unknown operation type'); - } + const error = handler(op, ctx); + if (error) { + return fail(i, error); } } - if (tagSet !== null) { - workflow.tagNames = [...tagSet]; + if (ctx.tagSet !== null) { + ctx.workflow.tagNames = [...ctx.tagSet]; } return { success: true, - workflow, - addedNodeNames: [...addedNodeNames], - tagNames: tagSet !== null ? [...tagSet] : undefined, - nodeGroupsChanged, + workflow: ctx.workflow, + addedNodeNames: [...ctx.addedNodeNames], + tagNames: ctx.tagSet !== null ? [...ctx.tagSet] : undefined, + nodeGroupsChanged: ctx.nodeGroupsChanged, }; } @@ -956,6 +1118,7 @@ export function toWorkflowSlice( } tagNames = tags.map((t) => t.name); } + return { name: workflow.name ?? '', description: (workflow as { description?: string }).description,