fix: correct operator names, connection types, and implement __patch_find_replace (#665, #659, #642) (#672)

Three critical fixes in n8n_update_partial_workflow:

- **#665**: Replace incorrect `isNotEmpty`/`isEmpty` operator names with `notEmpty`/`empty`
  across validators, sanitizer, docs, and error messages. Add auto-correction in sanitizer.
  Unknown operators silently returned false in n8n's execution engine.

- **#659**: Remap numeric `targetInput` values (e.g., "0") to "main" in addConnection.
  Relax sourceOutput remapping guard for redundant sourceOutput+sourceIndex combinations.
  Also resolves #653 (dangling connections caused by malformed type:"0" connections).

- **#642**: Implement __patch_find_replace for surgical string edits in updateNode.
  Previously stored patch objects literally as jsCode, producing [object Object].
  Now reads current value, applies find/replace sequentially, writes back the string.

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Romuald Członkowski
2026-03-27 16:26:21 +01:00
committed by GitHub
parent de2abaf89d
commit 6be9ffa53e
28 changed files with 522 additions and 96 deletions

View File

@@ -351,6 +351,28 @@ class WorkflowDiffEngine {
}
}
}
for (const [path, value] of Object.entries(operation.updates)) {
if (value !== null && typeof value === 'object' && !Array.isArray(value)
&& '__patch_find_replace' in value) {
const patches = value.__patch_find_replace;
if (!Array.isArray(patches)) {
return `Invalid __patch_find_replace at "${path}": must be an array of {find, replace} objects`;
}
for (let i = 0; i < patches.length; i++) {
const patch = patches[i];
if (!patch || typeof patch.find !== 'string' || typeof patch.replace !== 'string') {
return `Invalid __patch_find_replace entry at "${path}[${i}]": each entry must have "find" (string) and "replace" (string)`;
}
}
const currentValue = this.getNestedProperty(node, path);
if (currentValue === undefined) {
return `Cannot apply __patch_find_replace to "${path}": property does not exist on node`;
}
if (typeof currentValue !== 'string') {
return `Cannot apply __patch_find_replace to "${path}": current value is ${typeof currentValue}, expected string`;
}
}
}
return null;
}
validateMoveNode(workflow, operation) {
@@ -541,7 +563,25 @@ class WorkflowDiffEngine {
logger.debug(`Tracking rename: "${oldName}" → "${newName}"`);
}
Object.entries(operation.updates).forEach(([path, value]) => {
this.setNestedProperty(node, path, value);
if (value !== null && typeof value === 'object' && !Array.isArray(value)
&& '__patch_find_replace' in value) {
const patches = value.__patch_find_replace;
let current = this.getNestedProperty(node, path);
for (const patch of patches) {
if (!current.includes(patch.find)) {
this.warnings.push({
operation: -1,
message: `__patch_find_replace: "${patch.find.substring(0, 50)}" not found in "${path}". Skipped.`
});
continue;
}
current = current.replace(patch.find, patch.replace);
}
this.setNestedProperty(node, path, current);
}
else {
this.setNestedProperty(node, path, value);
}
});
const sanitized = (0, node_sanitizer_1.sanitizeNode)(node);
Object.assign(node, sanitized);
@@ -568,9 +608,11 @@ class WorkflowDiffEngine {
const sourceNode = this.findNode(workflow, operation.source, operation.source);
let sourceOutput = String(operation.sourceOutput ?? 'main');
let sourceIndex = operation.sourceIndex ?? 0;
if (/^\d+$/.test(sourceOutput) && operation.sourceIndex === undefined
const numericOutput = /^\d+$/.test(sourceOutput) ? parseInt(sourceOutput, 10) : null;
if (numericOutput !== null
&& (operation.sourceIndex === undefined || operation.sourceIndex === numericOutput)
&& operation.branch === undefined && operation.case === undefined) {
sourceIndex = parseInt(sourceOutput, 10);
sourceIndex = numericOutput;
sourceOutput = 'main';
}
if (operation.branch !== undefined && operation.sourceIndex === undefined) {
@@ -606,7 +648,10 @@ class WorkflowDiffEngine {
if (!sourceNode || !targetNode)
return;
const { sourceOutput, sourceIndex } = this.resolveSmartParameters(workflow, operation);
const targetInput = String(operation.targetInput ?? sourceOutput);
let targetInput = String(operation.targetInput ?? sourceOutput);
if (/^\d+$/.test(targetInput)) {
targetInput = 'main';
}
const targetIndex = operation.targetIndex ?? 0;
if (!workflow.connections[sourceNode.name]) {
workflow.connections[sourceNode.name] = {};
@@ -875,6 +920,16 @@ class WorkflowDiffEngine {
.join(', ');
return `Node not found for ${operationType}: "${nodeIdentifier}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters (apostrophes, quotes).`;
}
getNestedProperty(obj, path) {
const keys = path.split('.');
let current = obj;
for (const key of keys) {
if (current == null || typeof current !== 'object')
return undefined;
current = current[key];
}
return current;
}
setNestedProperty(obj, path, value) {
const keys = path.split('.');
let current = obj;