refactor(core): Dispatch MCP workflow operations via a handler table (no-changelog) (#34926)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel Moreno
2026-07-25 17:16:23 +02:00
committed by GitHub
parent 7d8426f055
commit 0dabb99ce5
@@ -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<string, INode>;
addedNodeNames: Set<string>;
// 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<string> | 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<K extends PartialUpdateOperation['type']> = (
op: Extract<PartialUpdateOperation, { type: K }>,
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<string, INode>,
nodeNames: string[],
groupName: string,
): { nodeIds: string[] } | { error: string } => {
const nodeIds = new Set<string>();
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<string, unknown>;
const merged = op.replace
? sanitized
: deepMerge((node.parameters ?? {}) as Record<string, unknown>, 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<string, unknown>;
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<K> } = {
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<string>();
// 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<string> | 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<string>();
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<string>(),
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<typeof op.type>;
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<string, unknown>;
const merged = op.replace
? sanitized
: deepMerge((node.parameters ?? {}) as Record<string, unknown>, 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<string, unknown>;
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,