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

@@ -259,9 +259,9 @@ export async function handleUpdatePartialWorkflow(
// Build recovery guidance based on error types
const recoverySteps = [];
if (errorTypes.has('operator_issues')) {
recoverySteps.push('Operator structure issue detected. Use validate_node_operation to check specific nodes.');
recoverySteps.push('Operator structure issue detected. Use validate_node to check specific nodes.');
recoverySteps.push('Binary operators (equals, contains, greaterThan, etc.) must NOT have singleValue:true');
recoverySteps.push('Unary operators (isEmpty, isNotEmpty, true, false) REQUIRE singleValue:true');
recoverySteps.push('Unary operators (empty, notEmpty, true, false) REQUIRE singleValue:true');
}
if (errorTypes.has('connection_issues')) {
recoverySteps.push('Connection validation failed. Check all node connections reference existing nodes.');

View File

@@ -119,8 +119,8 @@ When ANY workflow update is made, ALL nodes in the workflow are automatically sa
1. **Operator Structure Fixes**:
- Binary operators (equals, contains, greaterThan, etc.) automatically have \`singleValue\` removed
- Unary operators (isEmpty, isNotEmpty, true, false) automatically get \`singleValue: true\` added
- Invalid operator structures (e.g., \`{type: "isNotEmpty"}\`) are corrected to \`{type: "boolean", operation: "isNotEmpty"}\`
- Unary operators (empty, notEmpty, true, false) automatically get \`singleValue: true\` added
- Invalid operator structures (e.g., \`{type: "notEmpty"}\`) are corrected to \`{type: "object", operation: "notEmpty"}\`
2. **Missing Metadata Added**:
- IF nodes with conditions get complete \`conditions.options\` structure if missing
@@ -333,6 +333,8 @@ n8n_update_partial_workflow({
'// Best-effort mode: apply what works, report what fails\nn8n_update_partial_workflow({id: "vwx", operations: [\n {type: "updateName", name: "Fixed Workflow"},\n {type: "removeConnection", source: "Broken", target: "Node"},\n {type: "cleanStaleConnections"}\n], continueOnError: true})',
'// Update node parameter\nn8n_update_partial_workflow({id: "yza", operations: [{type: "updateNode", nodeName: "HTTP Request", updates: {"parameters.url": "https://api.example.com"}}]})',
'// Validate before applying\nn8n_update_partial_workflow({id: "bcd", operations: [{type: "removeNode", nodeName: "Old Process"}], validateOnly: true})',
'// Surgically edit code using __patch_find_replace (avoids replacing entire code block)\nn8n_update_partial_workflow({id: "pfr1", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "const limit = 10;", "replace": "const limit = 50;"}]}}}]})',
'// Multiple sequential patches on the same property\nn8n_update_partial_workflow({id: "pfr2", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "api.old-domain.com", "replace": "api.new-domain.com"}, {"find": "Authorization: Bearer old_token", "replace": "Authorization: Bearer new_token"}]}}}]})',
'\n// ============ AI CONNECTION EXAMPLES ============',
'// Connect language model to AI Agent\nn8n_update_partial_workflow({id: "ai1", operations: [{type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel"}]})',
'// Connect tool to AI Agent\nn8n_update_partial_workflow({id: "ai2", operations: [{type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"}]})',
@@ -411,10 +413,12 @@ n8n_update_partial_workflow({
'**CRITICAL**: For Switch nodes, ALWAYS use case=N instead of sourceIndex. Using same sourceIndex for multiple connections will put them on the same case output.',
'cleanStaleConnections removes ALL broken connections - cannot be selective',
'replaceConnections overwrites entire connections object - all previous connections lost',
'**Auto-sanitization behavior**: Binary operators (equals, contains) automatically have singleValue removed; unary operators (isEmpty, isNotEmpty) automatically get singleValue:true added',
'**Auto-sanitization behavior**: Binary operators (equals, contains) automatically have singleValue removed; unary operators (empty, notEmpty) automatically get singleValue:true added',
'**Auto-sanitization runs on ALL nodes**: When ANY update is made, ALL nodes in the workflow are sanitized (not just modified ones)',
'**Auto-sanitization cannot fix everything**: It fixes operator structures and missing metadata, but cannot fix broken connections or branch mismatches',
'**Corrupted workflows beyond repair**: Workflows in paradoxical states (API returns corrupt, API rejects updates) cannot be fixed via API - must be recreated',
'**__patch_find_replace for code edits**: Instead of replacing entire code blocks, use `{"parameters.jsCode": {"__patch_find_replace": [{"find": "old text", "replace": "new text"}]}}` to surgically edit string properties',
'__patch_find_replace replaces the FIRST occurrence of each find string. Patches are applied sequentially — order matters',
'To remove a property, set it to null in the updates object',
'When properties are mutually exclusive (e.g., continueOnFail and onError), setting only the new property will fail - you must remove the old one with null',
'Removing a required property may cause validation errors - check node documentation first',

View File

@@ -1209,30 +1209,30 @@ export class EnhancedConfigValidator extends ConfigValidator {
'empty', 'notEmpty', 'equals', 'notEquals',
'contains', 'notContains', 'startsWith', 'notStartsWith',
'endsWith', 'notEndsWith', 'regex', 'notRegex',
'exists', 'notExists', 'isNotEmpty' // exists checks field presence, isNotEmpty alias for notEmpty
'exists', 'notExists'
],
number: [
'empty', 'notEmpty', 'equals', 'notEquals', 'gt', 'lt', 'gte', 'lte',
'exists', 'notExists', 'isNotEmpty'
'exists', 'notExists'
],
dateTime: [
'empty', 'notEmpty', 'equals', 'notEquals', 'after', 'before', 'afterOrEquals', 'beforeOrEquals',
'exists', 'notExists', 'isNotEmpty'
'exists', 'notExists'
],
boolean: [
'empty', 'notEmpty', 'true', 'false', 'equals', 'notEquals',
'exists', 'notExists', 'isNotEmpty'
'exists', 'notExists'
],
array: [
'contains', 'notContains', 'lengthEquals', 'lengthNotEquals',
'lengthGt', 'lengthLt', 'lengthGte', 'lengthLte', 'empty', 'notEmpty',
'exists', 'notExists', 'isNotEmpty'
'exists', 'notExists'
],
object: [
'empty', 'notEmpty',
'exists', 'notExists', 'isNotEmpty'
'exists', 'notExists'
],
any: ['exists', 'notExists', 'isNotEmpty']
any: ['exists', 'notExists']
};
for (let i = 0; i < conditions.length; i++) {

View File

@@ -621,13 +621,13 @@ export function validateOperatorStructure(operator: any, path: string): string[]
if (!operator.operation) {
errors.push(
`${path}: missing required field "operation". ` +
'Operation specifies the comparison type (e.g., "equals", "contains", "isNotEmpty")'
'Operation specifies the comparison type (e.g., "equals", "contains", "notEmpty")'
);
}
// Check singleValue based on operator type
if (operator.operation) {
const unaryOperators = ['isEmpty', 'isNotEmpty', 'true', 'false', 'isNumeric'];
const unaryOperators = ['empty', 'notEmpty', 'true', 'false', 'isNumeric', 'exists', 'notExists'];
const isUnary = unaryOperators.includes(operator.operation);
if (isUnary) {
@@ -643,7 +643,7 @@ export function validateOperatorStructure(operator: any, path: string): string[]
if (operator.singleValue === true) {
errors.push(
`${path}: binary operator "${operator.operation}" should not have "singleValue: true". ` +
'Only unary operators (isEmpty, isNotEmpty, true, false, isNumeric) need this property.'
'Only unary operators (empty, notEmpty, true, false, isNumeric, exists, notExists) need this property.'
);
}
}

View File

@@ -13,6 +13,23 @@ import { INodeParameters } from 'n8n-workflow';
import { logger } from '../utils/logger';
import { WorkflowNode } from '../types/n8n-api';
/** Legacy operator names that n8n no longer recognizes, mapped to their correct names. */
const OPERATOR_CORRECTIONS: Record<string, string> = {
'isEmpty': 'empty',
'isNotEmpty': 'notEmpty',
};
/** Operators that take no right-hand value and require singleValue: true. */
const UNARY_OPERATORS = new Set([
'true',
'false',
'isNumeric',
'empty',
'notEmpty',
'exists',
'notExists',
]);
/**
* Sanitize a single node by adding required metadata
*/
@@ -162,29 +179,28 @@ function sanitizeOperator(operator: any): any {
const sanitized = { ...operator };
// Fix common mistake: type field used for operation name
// WRONG: {type: "isNotEmpty"}
// RIGHT: {type: "string", operation: "isNotEmpty"}
// WRONG: {type: "notEmpty"}
// RIGHT: {type: "string", operation: "notEmpty"}
if (sanitized.type && !sanitized.operation) {
// Check if type value looks like an operation (lowercase, no dots)
const typeValue = sanitized.type as string;
if (isOperationName(typeValue)) {
logger.debug(`Fixing operator structure: converting type="${typeValue}" to operation`);
// Infer data type from operation
const dataType = inferDataType(typeValue);
sanitized.type = dataType;
sanitized.type = inferDataType(typeValue);
sanitized.operation = typeValue;
}
}
// Auto-correct legacy operator names to n8n-recognized names
if (sanitized.operation && OPERATOR_CORRECTIONS[sanitized.operation]) {
sanitized.operation = OPERATOR_CORRECTIONS[sanitized.operation];
}
// Set singleValue based on operator type
if (sanitized.operation) {
if (isUnaryOperator(sanitized.operation)) {
// Unary operators require singleValue: true
sanitized.singleValue = true;
} else {
// Binary operators should NOT have singleValue (or it should be false/undefined)
// Remove it to prevent UI errors
// Binary operators should NOT have singleValue — remove it to prevent UI errors
delete sanitized.singleValue;
}
}
@@ -207,7 +223,7 @@ function isOperationName(value: string): boolean {
*/
function inferDataType(operation: string): string {
// Boolean operations
const booleanOps = ['true', 'false', 'isEmpty', 'isNotEmpty'];
const booleanOps = ['true', 'false'];
if (booleanOps.includes(operation)) {
return 'boolean';
}
@@ -225,7 +241,6 @@ function inferDataType(operation: string): string {
}
// Object operations: empty/notEmpty/exists/notExists are generic object-level checks
// (distinct from isEmpty/isNotEmpty which are boolean-typed operations)
const objectOps = ['empty', 'notEmpty', 'exists', 'notExists'];
if (objectOps.includes(operation)) {
return 'object';
@@ -239,18 +254,7 @@ function inferDataType(operation: string): string {
* Check if operator is unary (requires singleValue: true)
*/
function isUnaryOperator(operation: string): boolean {
const unaryOps = [
'isEmpty',
'isNotEmpty',
'true',
'false',
'isNumeric',
'empty',
'notEmpty',
'exists',
'notExists'
];
return unaryOps.includes(operation);
return UNARY_OPERATORS.has(operation);
}
/**

View File

@@ -470,6 +470,31 @@ export class WorkflowDiffEngine {
}
}
// Validate __patch_find_replace syntax (#642)
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)`;
}
}
// node was already found above — reuse it
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;
}
@@ -721,7 +746,26 @@ export class WorkflowDiffEngine {
// Apply updates using dot notation
Object.entries(operation.updates).forEach(([path, value]) => {
this.setNestedProperty(node, path, value);
// Handle __patch_find_replace for surgical string edits (#642)
// Format and type validation already passed in validateUpdateNode()
if (value !== null && typeof value === 'object' && !Array.isArray(value)
&& '__patch_find_replace' in value) {
const patches = value.__patch_find_replace as Array<{ find: string; replace: string }>;
let current = this.getNestedProperty(node, path) as string;
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);
}
});
// Sanitize node after updates to ensure metadata is complete
@@ -766,11 +810,13 @@ export class WorkflowDiffEngine {
let sourceOutput = String(operation.sourceOutput ?? 'main');
let sourceIndex = operation.sourceIndex ?? 0;
// Remap numeric sourceOutput (e.g., "0", "1") to "main" with sourceIndex (#537)
// Remap numeric sourceOutput (e.g., "0", "1") to "main" with sourceIndex (#537, #659)
// Skip when smart parameters (branch, case) are present — they take precedence
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';
}
@@ -823,7 +869,11 @@ export class WorkflowDiffEngine {
// Use nullish coalescing to properly handle explicit 0 values
// Default targetInput to sourceOutput to preserve connection type for AI connections (ai_tool, ai_memory, etc.)
// Coerce to string to handle numeric values passed as sourceOutput/targetInput
const targetInput = String(operation.targetInput ?? sourceOutput);
let targetInput = String(operation.targetInput ?? sourceOutput);
// Remap numeric targetInput (e.g., "0") to "main" — connection types are named strings (#659)
if (/^\d+$/.test(targetInput)) {
targetInput = 'main';
}
const targetIndex = operation.targetIndex ?? 0;
// Initialize source node connections object
@@ -1266,6 +1316,16 @@ export class WorkflowDiffEngine {
return `Node not found for ${operationType}: "${nodeIdentifier}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters (apostrophes, quotes).`;
}
private getNestedProperty(obj: any, path: string): any {
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;
}
private setNestedProperty(obj: any, path: string, value: any): void {
const keys = path.split('.');
let current = obj;