mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
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:
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,
|
||||
|
||||
Reference in New Issue
Block a user