mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-29 21:53:07 +00:00
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:
committed by
GitHub
parent
de2abaf89d
commit
88ee6eeccc
18
CHANGELOG.md
18
CHANGELOG.md
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.41.1] - 2026-03-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **If node operators silently fail at runtime** (Issue #665): Replaced incorrect operator names `isNotEmpty`/`isEmpty` with `notEmpty`/`empty` across all validators, sanitizer, documentation, and error messages. n8n's execution engine does not recognize `isNotEmpty`/`isEmpty` — unknown operators silently return `false`, causing If/Switch conditions to always take the wrong branch. Added auto-correction in the sanitizer so existing workflows using legacy names are fixed on update.
|
||||||
|
|
||||||
|
- **`addConnection` creates broken connections with `type: "0"`** (Issue #659): Fixed two edge cases where numeric `targetInput` or `sourceOutput` values leaked into connection objects as `"type": "0"` instead of `"type": "main"`. Numeric `targetInput` values are now remapped to `"main"`, and the `sourceOutput` remapping guard was relaxed to handle redundant `sourceOutput: 0` + `sourceIndex: 0` combinations. Also resolves Issue #653 (dangling connections after `removeNode`) which was caused by malformed connections from this bug.
|
||||||
|
|
||||||
|
- **`__patch_find_replace` corrupts Code node jsCode** (Issue #642): Implemented the `__patch_find_replace` feature for surgical string edits in `updateNode` operations. Previously, passing `{"parameters.jsCode": {"__patch_find_replace": [...]}}` stored the patch object literally as jsCode, producing `[object Object]` at runtime. The feature now reads the current string value, applies each `{find, replace}` entry sequentially, and writes back the modified string. Includes validation for patch format, target property existence, and string type.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Extracted `OPERATOR_CORRECTIONS` and `UNARY_OPERATORS` to module-level constants for better performance and single source of truth
|
||||||
|
- Added `exists`/`notExists` to unary operator lists for consistency across sanitizer and validator
|
||||||
|
- Fixed recovery guidance referencing non-existent `validate_node_operation` tool (now `validate_node`)
|
||||||
|
|
||||||
|
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||||
|
|
||||||
## [2.41.0] - 2026-03-25
|
## [2.41.0] - 2026-03-25
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
4
dist/mcp/handlers-workflow-diff.js
vendored
4
dist/mcp/handlers-workflow-diff.js
vendored
@@ -231,9 +231,9 @@ async function handleUpdatePartialWorkflow(args, repository, context) {
|
|||||||
});
|
});
|
||||||
const recoverySteps = [];
|
const recoverySteps = [];
|
||||||
if (errorTypes.has('operator_issues')) {
|
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('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')) {
|
if (errorTypes.has('connection_issues')) {
|
||||||
recoverySteps.push('Connection validation failed. Check all node connections reference existing nodes.');
|
recoverySteps.push('Connection validation failed. Check all node connections reference existing nodes.');
|
||||||
|
|||||||
2
dist/mcp/handlers-workflow-diff.js.map
vendored
2
dist/mcp/handlers-workflow-diff.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"n8n-update-partial-workflow.d.ts","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAE7C,eAAO,MAAM,2BAA2B,EAAE,iBAuazC,CAAC"}
|
{"version":3,"file":"n8n-update-partial-workflow.d.ts","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAE7C,eAAO,MAAM,2BAA2B,EAAE,iBA2azC,CAAC"}
|
||||||
@@ -120,8 +120,8 @@ When ANY workflow update is made, ALL nodes in the workflow are automatically sa
|
|||||||
|
|
||||||
1. **Operator Structure Fixes**:
|
1. **Operator Structure Fixes**:
|
||||||
- Binary operators (equals, contains, greaterThan, etc.) automatically have \`singleValue\` removed
|
- Binary operators (equals, contains, greaterThan, etc.) automatically have \`singleValue\` removed
|
||||||
- Unary operators (isEmpty, isNotEmpty, true, false) automatically get \`singleValue: true\` added
|
- Unary operators (empty, notEmpty, true, false) automatically get \`singleValue: true\` added
|
||||||
- Invalid operator structures (e.g., \`{type: "isNotEmpty"}\`) are corrected to \`{type: "boolean", operation: "isNotEmpty"}\`
|
- Invalid operator structures (e.g., \`{type: "notEmpty"}\`) are corrected to \`{type: "object", operation: "notEmpty"}\`
|
||||||
|
|
||||||
2. **Missing Metadata Added**:
|
2. **Missing Metadata Added**:
|
||||||
- IF nodes with conditions get complete \`conditions.options\` structure if missing
|
- IF nodes with conditions get complete \`conditions.options\` structure if missing
|
||||||
@@ -334,6 +334,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})',
|
'// 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"}}]})',
|
'// 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})',
|
'// 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 ============',
|
'\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 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"}]})',
|
'// Connect tool to AI Agent\nn8n_update_partial_workflow({id: "ai2", operations: [{type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"}]})',
|
||||||
@@ -412,10 +414,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.',
|
'**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',
|
'cleanStaleConnections removes ALL broken connections - cannot be selective',
|
||||||
'replaceConnections overwrites entire connections object - all previous connections lost',
|
'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 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',
|
'**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',
|
'**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',
|
'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',
|
'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',
|
'Removing a required property may cause validation errors - check node documentation first',
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"n8n-update-partial-workflow.js","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts"],"names":[],"mappings":"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"}
|
{"version":3,"file":"n8n-update-partial-workflow.js","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts"],"names":[],"mappings":"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"}
|
||||||
14
dist/services/enhanced-config-validator.js
vendored
14
dist/services/enhanced-config-validator.js
vendored
@@ -730,30 +730,30 @@ class EnhancedConfigValidator extends config_validator_1.ConfigValidator {
|
|||||||
'empty', 'notEmpty', 'equals', 'notEquals',
|
'empty', 'notEmpty', 'equals', 'notEquals',
|
||||||
'contains', 'notContains', 'startsWith', 'notStartsWith',
|
'contains', 'notContains', 'startsWith', 'notStartsWith',
|
||||||
'endsWith', 'notEndsWith', 'regex', 'notRegex',
|
'endsWith', 'notEndsWith', 'regex', 'notRegex',
|
||||||
'exists', 'notExists', 'isNotEmpty'
|
'exists', 'notExists'
|
||||||
],
|
],
|
||||||
number: [
|
number: [
|
||||||
'empty', 'notEmpty', 'equals', 'notEquals', 'gt', 'lt', 'gte', 'lte',
|
'empty', 'notEmpty', 'equals', 'notEquals', 'gt', 'lt', 'gte', 'lte',
|
||||||
'exists', 'notExists', 'isNotEmpty'
|
'exists', 'notExists'
|
||||||
],
|
],
|
||||||
dateTime: [
|
dateTime: [
|
||||||
'empty', 'notEmpty', 'equals', 'notEquals', 'after', 'before', 'afterOrEquals', 'beforeOrEquals',
|
'empty', 'notEmpty', 'equals', 'notEquals', 'after', 'before', 'afterOrEquals', 'beforeOrEquals',
|
||||||
'exists', 'notExists', 'isNotEmpty'
|
'exists', 'notExists'
|
||||||
],
|
],
|
||||||
boolean: [
|
boolean: [
|
||||||
'empty', 'notEmpty', 'true', 'false', 'equals', 'notEquals',
|
'empty', 'notEmpty', 'true', 'false', 'equals', 'notEquals',
|
||||||
'exists', 'notExists', 'isNotEmpty'
|
'exists', 'notExists'
|
||||||
],
|
],
|
||||||
array: [
|
array: [
|
||||||
'contains', 'notContains', 'lengthEquals', 'lengthNotEquals',
|
'contains', 'notContains', 'lengthEquals', 'lengthNotEquals',
|
||||||
'lengthGt', 'lengthLt', 'lengthGte', 'lengthLte', 'empty', 'notEmpty',
|
'lengthGt', 'lengthLt', 'lengthGte', 'lengthLte', 'empty', 'notEmpty',
|
||||||
'exists', 'notExists', 'isNotEmpty'
|
'exists', 'notExists'
|
||||||
],
|
],
|
||||||
object: [
|
object: [
|
||||||
'empty', 'notEmpty',
|
'empty', 'notEmpty',
|
||||||
'exists', 'notExists', 'isNotEmpty'
|
'exists', 'notExists'
|
||||||
],
|
],
|
||||||
any: ['exists', 'notExists', 'isNotEmpty']
|
any: ['exists', 'notExists']
|
||||||
};
|
};
|
||||||
for (let i = 0; i < conditions.length; i++) {
|
for (let i = 0; i < conditions.length; i++) {
|
||||||
const condition = conditions[i];
|
const condition = conditions[i];
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
6
dist/services/n8n-validation.js
vendored
6
dist/services/n8n-validation.js
vendored
@@ -419,10 +419,10 @@ function validateOperatorStructure(operator, path) {
|
|||||||
}
|
}
|
||||||
if (!operator.operation) {
|
if (!operator.operation) {
|
||||||
errors.push(`${path}: missing required field "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")');
|
||||||
}
|
}
|
||||||
if (operator.operation) {
|
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);
|
const isUnary = unaryOperators.includes(operator.operation);
|
||||||
if (isUnary) {
|
if (isUnary) {
|
||||||
if (operator.singleValue !== true) {
|
if (operator.singleValue !== true) {
|
||||||
@@ -433,7 +433,7 @@ function validateOperatorStructure(operator, path) {
|
|||||||
else {
|
else {
|
||||||
if (operator.singleValue === true) {
|
if (operator.singleValue === true) {
|
||||||
errors.push(`${path}: binary operator "${operator.operation}" should not have "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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
dist/services/n8n-validation.js.map
vendored
2
dist/services/n8n-validation.js.map
vendored
File diff suppressed because one or more lines are too long
2
dist/services/node-sanitizer.d.ts.map
vendored
2
dist/services/node-sanitizer.d.ts.map
vendored
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"node-sanitizer.d.ts","sourceRoot":"","sources":["../../src/services/node-sanitizer.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAKhD,wBAAgB,YAAY,CAAC,IAAI,EAAE,YAAY,GAAG,YAAY,CAa7D;AAKD,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,GAAG,GAAG,GAAG,CASxD;AA6ND,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,YAAY,GAAG,MAAM,EAAE,CAgEjE"}
|
{"version":3,"file":"node-sanitizer.d.ts","sourceRoot":"","sources":["../../src/services/node-sanitizer.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAsBhD,wBAAgB,YAAY,CAAC,IAAI,EAAE,YAAY,GAAG,YAAY,CAa7D;AAKD,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,GAAG,GAAG,GAAG,CASxD;AAgND,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,YAAY,GAAG,MAAM,EAAE,CAgEjE"}
|
||||||
34
dist/services/node-sanitizer.js
vendored
34
dist/services/node-sanitizer.js
vendored
@@ -4,6 +4,19 @@ exports.sanitizeNode = sanitizeNode;
|
|||||||
exports.sanitizeWorkflowNodes = sanitizeWorkflowNodes;
|
exports.sanitizeWorkflowNodes = sanitizeWorkflowNodes;
|
||||||
exports.validateNodeMetadata = validateNodeMetadata;
|
exports.validateNodeMetadata = validateNodeMetadata;
|
||||||
const logger_1 = require("../utils/logger");
|
const logger_1 = require("../utils/logger");
|
||||||
|
const OPERATOR_CORRECTIONS = {
|
||||||
|
'isEmpty': 'empty',
|
||||||
|
'isNotEmpty': 'notEmpty',
|
||||||
|
};
|
||||||
|
const UNARY_OPERATORS = new Set([
|
||||||
|
'true',
|
||||||
|
'false',
|
||||||
|
'isNumeric',
|
||||||
|
'empty',
|
||||||
|
'notEmpty',
|
||||||
|
'exists',
|
||||||
|
'notExists',
|
||||||
|
]);
|
||||||
function sanitizeNode(node) {
|
function sanitizeNode(node) {
|
||||||
const sanitized = { ...node };
|
const sanitized = { ...node };
|
||||||
if (isFilterBasedNode(node.type, node.typeVersion)) {
|
if (isFilterBasedNode(node.type, node.typeVersion)) {
|
||||||
@@ -92,11 +105,13 @@ function sanitizeOperator(operator) {
|
|||||||
const typeValue = sanitized.type;
|
const typeValue = sanitized.type;
|
||||||
if (isOperationName(typeValue)) {
|
if (isOperationName(typeValue)) {
|
||||||
logger_1.logger.debug(`Fixing operator structure: converting type="${typeValue}" to operation`);
|
logger_1.logger.debug(`Fixing operator structure: converting type="${typeValue}" to operation`);
|
||||||
const dataType = inferDataType(typeValue);
|
sanitized.type = inferDataType(typeValue);
|
||||||
sanitized.type = dataType;
|
|
||||||
sanitized.operation = typeValue;
|
sanitized.operation = typeValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (sanitized.operation && OPERATOR_CORRECTIONS[sanitized.operation]) {
|
||||||
|
sanitized.operation = OPERATOR_CORRECTIONS[sanitized.operation];
|
||||||
|
}
|
||||||
if (sanitized.operation) {
|
if (sanitized.operation) {
|
||||||
if (isUnaryOperator(sanitized.operation)) {
|
if (isUnaryOperator(sanitized.operation)) {
|
||||||
sanitized.singleValue = true;
|
sanitized.singleValue = true;
|
||||||
@@ -112,7 +127,7 @@ function isOperationName(value) {
|
|||||||
return !dataTypes.includes(value) && /^[a-z][a-zA-Z]*$/.test(value);
|
return !dataTypes.includes(value) && /^[a-z][a-zA-Z]*$/.test(value);
|
||||||
}
|
}
|
||||||
function inferDataType(operation) {
|
function inferDataType(operation) {
|
||||||
const booleanOps = ['true', 'false', 'isEmpty', 'isNotEmpty'];
|
const booleanOps = ['true', 'false'];
|
||||||
if (booleanOps.includes(operation)) {
|
if (booleanOps.includes(operation)) {
|
||||||
return 'boolean';
|
return 'boolean';
|
||||||
}
|
}
|
||||||
@@ -131,18 +146,7 @@ function inferDataType(operation) {
|
|||||||
return 'string';
|
return 'string';
|
||||||
}
|
}
|
||||||
function isUnaryOperator(operation) {
|
function isUnaryOperator(operation) {
|
||||||
const unaryOps = [
|
return UNARY_OPERATORS.has(operation);
|
||||||
'isEmpty',
|
|
||||||
'isNotEmpty',
|
|
||||||
'true',
|
|
||||||
'false',
|
|
||||||
'isNumeric',
|
|
||||||
'empty',
|
|
||||||
'notEmpty',
|
|
||||||
'exists',
|
|
||||||
'notExists'
|
|
||||||
];
|
|
||||||
return unaryOps.includes(operation);
|
|
||||||
}
|
}
|
||||||
function generateConditionId() {
|
function generateConditionId() {
|
||||||
return `condition-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
return `condition-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|||||||
2
dist/services/node-sanitizer.js.map
vendored
2
dist/services/node-sanitizer.js.map
vendored
File diff suppressed because one or more lines are too long
1
dist/services/workflow-diff-engine.d.ts
vendored
1
dist/services/workflow-diff-engine.d.ts
vendored
@@ -47,6 +47,7 @@ export declare class WorkflowDiffEngine {
|
|||||||
private normalizeNodeName;
|
private normalizeNodeName;
|
||||||
private findNode;
|
private findNode;
|
||||||
private formatNodeNotFoundError;
|
private formatNodeNotFoundError;
|
||||||
|
private getNestedProperty;
|
||||||
private setNestedProperty;
|
private setNestedProperty;
|
||||||
}
|
}
|
||||||
//# sourceMappingURL=workflow-diff-engine.d.ts.map
|
//# sourceMappingURL=workflow-diff-engine.d.ts.map
|
||||||
2
dist/services/workflow-diff-engine.d.ts.map
vendored
2
dist/services/workflow-diff-engine.d.ts.map
vendored
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"workflow-diff-engine.d.ts","sourceRoot":"","sources":["../../src/services/workflow-diff-engine.ts"],"names":[],"mappings":"AAMA,OAAO,EAEL,mBAAmB,EACnB,kBAAkB,EAuBnB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,QAAQ,EAAoC,MAAM,kBAAkB,CAAC;AAY9E,qBAAa,kBAAkB;IAE7B,OAAO,CAAC,SAAS,CAAkC;IAEnD,OAAO,CAAC,QAAQ,CAAqC;IAErD,OAAO,CAAC,eAAe,CAAqB;IAE5C,OAAO,CAAC,gBAAgB,CAAqB;IAE7C,OAAO,CAAC,SAAS,CAAgB;IACjC,OAAO,CAAC,YAAY,CAAgB;IAEpC,OAAO,CAAC,mBAAmB,CAAqB;IAK1C,SAAS,CACb,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,kBAAkB,CAAC;IAgO9B,OAAO,CAAC,iBAAiB;IA0CzB,OAAO,CAAC,cAAc;IA4DtB,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,kBAAkB;IAuB1B,OAAO,CAAC,kBAAkB;IAoC1B,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,kBAAkB;IAU1B,OAAO,CAAC,qBAAqB;IAkD7B,OAAO,CAAC,wBAAwB;IA6ChC,OAAO,CAAC,wBAAwB;IAmDhC,OAAO,CAAC,YAAY;IA4BpB,OAAO,CAAC,eAAe;IAwCvB,OAAO,CAAC,eAAe;IA0BvB,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,eAAe;IAOvB,OAAO,CAAC,gBAAgB;IAWxB,OAAO,CAAC,sBAAsB;IAwD9B,OAAO,CAAC,kBAAkB;IA6C1B,OAAO,CAAC,qBAAqB;IAuC7B,OAAO,CAAC,qBAAqB;IA0B7B,OAAO,CAAC,mBAAmB;IAW3B,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,WAAW;IAYnB,OAAO,CAAC,cAAc;IAatB,OAAO,CAAC,wBAAwB;IAchC,OAAO,CAAC,0BAA0B;IAMlC,OAAO,CAAC,qBAAqB;IAM7B,OAAO,CAAC,uBAAuB;IAO/B,OAAO,CAAC,wBAAwB;IAOhC,OAAO,CAAC,qBAAqB;IAK7B,OAAO,CAAC,6BAA6B;IAKrC,OAAO,CAAC,0BAA0B;IA0BlC,OAAO,CAAC,0BAA0B;IA+ElC,OAAO,CAAC,uBAAuB;IAe/B,OAAO,CAAC,0BAA0B;IAmElC,OAAO,CAAC,iBAAiB;IAkBzB,OAAO,CAAC,QAAQ;IAsChB,OAAO,CAAC,uBAAuB;IAW/B,OAAO,CAAC,iBAAiB;CAoB1B"}
|
{"version":3,"file":"workflow-diff-engine.d.ts","sourceRoot":"","sources":["../../src/services/workflow-diff-engine.ts"],"names":[],"mappings":"AAMA,OAAO,EAEL,mBAAmB,EACnB,kBAAkB,EAuBnB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,QAAQ,EAAoC,MAAM,kBAAkB,CAAC;AAY9E,qBAAa,kBAAkB;IAE7B,OAAO,CAAC,SAAS,CAAkC;IAEnD,OAAO,CAAC,QAAQ,CAAqC;IAErD,OAAO,CAAC,eAAe,CAAqB;IAE5C,OAAO,CAAC,gBAAgB,CAAqB;IAE7C,OAAO,CAAC,SAAS,CAAgB;IACjC,OAAO,CAAC,YAAY,CAAgB;IAEpC,OAAO,CAAC,mBAAmB,CAAqB;IAK1C,SAAS,CACb,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,kBAAkB,CAAC;IAgO9B,OAAO,CAAC,iBAAiB;IA0CzB,OAAO,CAAC,cAAc;IA4DtB,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,kBAAkB;IAuB1B,OAAO,CAAC,kBAAkB;IA6D1B,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,kBAAkB;IAU1B,OAAO,CAAC,qBAAqB;IAkD7B,OAAO,CAAC,wBAAwB;IA6ChC,OAAO,CAAC,wBAAwB;IAmDhC,OAAO,CAAC,YAAY;IA4BpB,OAAO,CAAC,eAAe;IAwCvB,OAAO,CAAC,eAAe;IA6CvB,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,eAAe;IAOvB,OAAO,CAAC,gBAAgB;IAWxB,OAAO,CAAC,sBAAsB;IA0D9B,OAAO,CAAC,kBAAkB;IAiD1B,OAAO,CAAC,qBAAqB;IAuC7B,OAAO,CAAC,qBAAqB;IA0B7B,OAAO,CAAC,mBAAmB;IAW3B,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,WAAW;IAYnB,OAAO,CAAC,cAAc;IAatB,OAAO,CAAC,wBAAwB;IAchC,OAAO,CAAC,0BAA0B;IAMlC,OAAO,CAAC,qBAAqB;IAM7B,OAAO,CAAC,uBAAuB;IAO/B,OAAO,CAAC,wBAAwB;IAOhC,OAAO,CAAC,qBAAqB;IAK7B,OAAO,CAAC,6BAA6B;IAKrC,OAAO,CAAC,0BAA0B;IA0BlC,OAAO,CAAC,0BAA0B;IA+ElC,OAAO,CAAC,uBAAuB;IAe/B,OAAO,CAAC,0BAA0B;IAmElC,OAAO,CAAC,iBAAiB;IAkBzB,OAAO,CAAC,QAAQ;IAsChB,OAAO,CAAC,uBAAuB;IAW/B,OAAO,CAAC,iBAAiB;IAUzB,OAAO,CAAC,iBAAiB;CAoB1B"}
|
||||||
63
dist/services/workflow-diff-engine.js
vendored
63
dist/services/workflow-diff-engine.js
vendored
@@ -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;
|
return null;
|
||||||
}
|
}
|
||||||
validateMoveNode(workflow, operation) {
|
validateMoveNode(workflow, operation) {
|
||||||
@@ -541,7 +563,25 @@ class WorkflowDiffEngine {
|
|||||||
logger.debug(`Tracking rename: "${oldName}" → "${newName}"`);
|
logger.debug(`Tracking rename: "${oldName}" → "${newName}"`);
|
||||||
}
|
}
|
||||||
Object.entries(operation.updates).forEach(([path, value]) => {
|
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);
|
const sanitized = (0, node_sanitizer_1.sanitizeNode)(node);
|
||||||
Object.assign(node, sanitized);
|
Object.assign(node, sanitized);
|
||||||
@@ -568,9 +608,11 @@ class WorkflowDiffEngine {
|
|||||||
const sourceNode = this.findNode(workflow, operation.source, operation.source);
|
const sourceNode = this.findNode(workflow, operation.source, operation.source);
|
||||||
let sourceOutput = String(operation.sourceOutput ?? 'main');
|
let sourceOutput = String(operation.sourceOutput ?? 'main');
|
||||||
let sourceIndex = operation.sourceIndex ?? 0;
|
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) {
|
&& operation.branch === undefined && operation.case === undefined) {
|
||||||
sourceIndex = parseInt(sourceOutput, 10);
|
sourceIndex = numericOutput;
|
||||||
sourceOutput = 'main';
|
sourceOutput = 'main';
|
||||||
}
|
}
|
||||||
if (operation.branch !== undefined && operation.sourceIndex === undefined) {
|
if (operation.branch !== undefined && operation.sourceIndex === undefined) {
|
||||||
@@ -606,7 +648,10 @@ class WorkflowDiffEngine {
|
|||||||
if (!sourceNode || !targetNode)
|
if (!sourceNode || !targetNode)
|
||||||
return;
|
return;
|
||||||
const { sourceOutput, sourceIndex } = this.resolveSmartParameters(workflow, operation);
|
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;
|
const targetIndex = operation.targetIndex ?? 0;
|
||||||
if (!workflow.connections[sourceNode.name]) {
|
if (!workflow.connections[sourceNode.name]) {
|
||||||
workflow.connections[sourceNode.name] = {};
|
workflow.connections[sourceNode.name] = {};
|
||||||
@@ -875,6 +920,16 @@ class WorkflowDiffEngine {
|
|||||||
.join(', ');
|
.join(', ');
|
||||||
return `Node not found for ${operationType}: "${nodeIdentifier}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters (apostrophes, quotes).`;
|
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) {
|
setNestedProperty(obj, path, value) {
|
||||||
const keys = path.split('.');
|
const keys = path.split('.');
|
||||||
let current = obj;
|
let current = obj;
|
||||||
|
|||||||
2
dist/services/workflow-diff-engine.js.map
vendored
2
dist/services/workflow-diff-engine.js.map
vendored
File diff suppressed because one or more lines are too long
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.41.0",
|
"version": "2.41.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.41.0",
|
"version": "2.41.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "1.28.0",
|
"@modelcontextprotocol/sdk": "1.28.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.41.0",
|
"version": "2.41.1",
|
||||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -259,9 +259,9 @@ export async function handleUpdatePartialWorkflow(
|
|||||||
// Build recovery guidance based on error types
|
// Build recovery guidance based on error types
|
||||||
const recoverySteps = [];
|
const recoverySteps = [];
|
||||||
if (errorTypes.has('operator_issues')) {
|
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('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')) {
|
if (errorTypes.has('connection_issues')) {
|
||||||
recoverySteps.push('Connection validation failed. Check all node connections reference existing nodes.');
|
recoverySteps.push('Connection validation failed. Check all node connections reference existing nodes.');
|
||||||
|
|||||||
@@ -119,8 +119,8 @@ When ANY workflow update is made, ALL nodes in the workflow are automatically sa
|
|||||||
|
|
||||||
1. **Operator Structure Fixes**:
|
1. **Operator Structure Fixes**:
|
||||||
- Binary operators (equals, contains, greaterThan, etc.) automatically have \`singleValue\` removed
|
- Binary operators (equals, contains, greaterThan, etc.) automatically have \`singleValue\` removed
|
||||||
- Unary operators (isEmpty, isNotEmpty, true, false) automatically get \`singleValue: true\` added
|
- Unary operators (empty, notEmpty, true, false) automatically get \`singleValue: true\` added
|
||||||
- Invalid operator structures (e.g., \`{type: "isNotEmpty"}\`) are corrected to \`{type: "boolean", operation: "isNotEmpty"}\`
|
- Invalid operator structures (e.g., \`{type: "notEmpty"}\`) are corrected to \`{type: "object", operation: "notEmpty"}\`
|
||||||
|
|
||||||
2. **Missing Metadata Added**:
|
2. **Missing Metadata Added**:
|
||||||
- IF nodes with conditions get complete \`conditions.options\` structure if missing
|
- 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})',
|
'// 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"}}]})',
|
'// 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})',
|
'// 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 ============',
|
'\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 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"}]})',
|
'// 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.',
|
'**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',
|
'cleanStaleConnections removes ALL broken connections - cannot be selective',
|
||||||
'replaceConnections overwrites entire connections object - all previous connections lost',
|
'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 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',
|
'**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',
|
'**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',
|
'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',
|
'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',
|
'Removing a required property may cause validation errors - check node documentation first',
|
||||||
|
|||||||
@@ -1209,30 +1209,30 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
|||||||
'empty', 'notEmpty', 'equals', 'notEquals',
|
'empty', 'notEmpty', 'equals', 'notEquals',
|
||||||
'contains', 'notContains', 'startsWith', 'notStartsWith',
|
'contains', 'notContains', 'startsWith', 'notStartsWith',
|
||||||
'endsWith', 'notEndsWith', 'regex', 'notRegex',
|
'endsWith', 'notEndsWith', 'regex', 'notRegex',
|
||||||
'exists', 'notExists', 'isNotEmpty' // exists checks field presence, isNotEmpty alias for notEmpty
|
'exists', 'notExists'
|
||||||
],
|
],
|
||||||
number: [
|
number: [
|
||||||
'empty', 'notEmpty', 'equals', 'notEquals', 'gt', 'lt', 'gte', 'lte',
|
'empty', 'notEmpty', 'equals', 'notEquals', 'gt', 'lt', 'gte', 'lte',
|
||||||
'exists', 'notExists', 'isNotEmpty'
|
'exists', 'notExists'
|
||||||
],
|
],
|
||||||
dateTime: [
|
dateTime: [
|
||||||
'empty', 'notEmpty', 'equals', 'notEquals', 'after', 'before', 'afterOrEquals', 'beforeOrEquals',
|
'empty', 'notEmpty', 'equals', 'notEquals', 'after', 'before', 'afterOrEquals', 'beforeOrEquals',
|
||||||
'exists', 'notExists', 'isNotEmpty'
|
'exists', 'notExists'
|
||||||
],
|
],
|
||||||
boolean: [
|
boolean: [
|
||||||
'empty', 'notEmpty', 'true', 'false', 'equals', 'notEquals',
|
'empty', 'notEmpty', 'true', 'false', 'equals', 'notEquals',
|
||||||
'exists', 'notExists', 'isNotEmpty'
|
'exists', 'notExists'
|
||||||
],
|
],
|
||||||
array: [
|
array: [
|
||||||
'contains', 'notContains', 'lengthEquals', 'lengthNotEquals',
|
'contains', 'notContains', 'lengthEquals', 'lengthNotEquals',
|
||||||
'lengthGt', 'lengthLt', 'lengthGte', 'lengthLte', 'empty', 'notEmpty',
|
'lengthGt', 'lengthLt', 'lengthGte', 'lengthLte', 'empty', 'notEmpty',
|
||||||
'exists', 'notExists', 'isNotEmpty'
|
'exists', 'notExists'
|
||||||
],
|
],
|
||||||
object: [
|
object: [
|
||||||
'empty', 'notEmpty',
|
'empty', 'notEmpty',
|
||||||
'exists', 'notExists', 'isNotEmpty'
|
'exists', 'notExists'
|
||||||
],
|
],
|
||||||
any: ['exists', 'notExists', 'isNotEmpty']
|
any: ['exists', 'notExists']
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < conditions.length; i++) {
|
for (let i = 0; i < conditions.length; i++) {
|
||||||
|
|||||||
@@ -621,13 +621,13 @@ export function validateOperatorStructure(operator: any, path: string): string[]
|
|||||||
if (!operator.operation) {
|
if (!operator.operation) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`${path}: missing required field "operation". ` +
|
`${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
|
// Check singleValue based on operator type
|
||||||
if (operator.operation) {
|
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);
|
const isUnary = unaryOperators.includes(operator.operation);
|
||||||
|
|
||||||
if (isUnary) {
|
if (isUnary) {
|
||||||
@@ -643,7 +643,7 @@ export function validateOperatorStructure(operator: any, path: string): string[]
|
|||||||
if (operator.singleValue === true) {
|
if (operator.singleValue === true) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`${path}: binary operator "${operator.operation}" should not have "singleValue: true". ` +
|
`${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.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,23 @@ import { INodeParameters } from 'n8n-workflow';
|
|||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { WorkflowNode } from '../types/n8n-api';
|
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
|
* Sanitize a single node by adding required metadata
|
||||||
*/
|
*/
|
||||||
@@ -162,29 +179,28 @@ function sanitizeOperator(operator: any): any {
|
|||||||
const sanitized = { ...operator };
|
const sanitized = { ...operator };
|
||||||
|
|
||||||
// Fix common mistake: type field used for operation name
|
// Fix common mistake: type field used for operation name
|
||||||
// WRONG: {type: "isNotEmpty"}
|
// WRONG: {type: "notEmpty"}
|
||||||
// RIGHT: {type: "string", operation: "isNotEmpty"}
|
// RIGHT: {type: "string", operation: "notEmpty"}
|
||||||
if (sanitized.type && !sanitized.operation) {
|
if (sanitized.type && !sanitized.operation) {
|
||||||
// Check if type value looks like an operation (lowercase, no dots)
|
|
||||||
const typeValue = sanitized.type as string;
|
const typeValue = sanitized.type as string;
|
||||||
if (isOperationName(typeValue)) {
|
if (isOperationName(typeValue)) {
|
||||||
logger.debug(`Fixing operator structure: converting type="${typeValue}" to operation`);
|
logger.debug(`Fixing operator structure: converting type="${typeValue}" to operation`);
|
||||||
|
sanitized.type = inferDataType(typeValue);
|
||||||
// Infer data type from operation
|
|
||||||
const dataType = inferDataType(typeValue);
|
|
||||||
sanitized.type = dataType;
|
|
||||||
sanitized.operation = 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
|
// Set singleValue based on operator type
|
||||||
if (sanitized.operation) {
|
if (sanitized.operation) {
|
||||||
if (isUnaryOperator(sanitized.operation)) {
|
if (isUnaryOperator(sanitized.operation)) {
|
||||||
// Unary operators require singleValue: true
|
|
||||||
sanitized.singleValue = true;
|
sanitized.singleValue = true;
|
||||||
} else {
|
} else {
|
||||||
// Binary operators should NOT have singleValue (or it should be false/undefined)
|
// Binary operators should NOT have singleValue — remove it to prevent UI errors
|
||||||
// Remove it to prevent UI errors
|
|
||||||
delete sanitized.singleValue;
|
delete sanitized.singleValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,7 +223,7 @@ function isOperationName(value: string): boolean {
|
|||||||
*/
|
*/
|
||||||
function inferDataType(operation: string): string {
|
function inferDataType(operation: string): string {
|
||||||
// Boolean operations
|
// Boolean operations
|
||||||
const booleanOps = ['true', 'false', 'isEmpty', 'isNotEmpty'];
|
const booleanOps = ['true', 'false'];
|
||||||
if (booleanOps.includes(operation)) {
|
if (booleanOps.includes(operation)) {
|
||||||
return 'boolean';
|
return 'boolean';
|
||||||
}
|
}
|
||||||
@@ -225,7 +241,6 @@ function inferDataType(operation: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Object operations: empty/notEmpty/exists/notExists are generic object-level checks
|
// 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'];
|
const objectOps = ['empty', 'notEmpty', 'exists', 'notExists'];
|
||||||
if (objectOps.includes(operation)) {
|
if (objectOps.includes(operation)) {
|
||||||
return 'object';
|
return 'object';
|
||||||
@@ -239,18 +254,7 @@ function inferDataType(operation: string): string {
|
|||||||
* Check if operator is unary (requires singleValue: true)
|
* Check if operator is unary (requires singleValue: true)
|
||||||
*/
|
*/
|
||||||
function isUnaryOperator(operation: string): boolean {
|
function isUnaryOperator(operation: string): boolean {
|
||||||
const unaryOps = [
|
return UNARY_OPERATORS.has(operation);
|
||||||
'isEmpty',
|
|
||||||
'isNotEmpty',
|
|
||||||
'true',
|
|
||||||
'false',
|
|
||||||
'isNumeric',
|
|
||||||
'empty',
|
|
||||||
'notEmpty',
|
|
||||||
'exists',
|
|
||||||
'notExists'
|
|
||||||
];
|
|
||||||
return unaryOps.includes(operation);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,7 +746,26 @@ export class WorkflowDiffEngine {
|
|||||||
|
|
||||||
// Apply updates using dot notation
|
// Apply updates using dot notation
|
||||||
Object.entries(operation.updates).forEach(([path, value]) => {
|
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
|
// Sanitize node after updates to ensure metadata is complete
|
||||||
@@ -766,11 +810,13 @@ export class WorkflowDiffEngine {
|
|||||||
let sourceOutput = String(operation.sourceOutput ?? 'main');
|
let sourceOutput = String(operation.sourceOutput ?? 'main');
|
||||||
let sourceIndex = operation.sourceIndex ?? 0;
|
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
|
// 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) {
|
&& operation.branch === undefined && operation.case === undefined) {
|
||||||
sourceIndex = parseInt(sourceOutput, 10);
|
sourceIndex = numericOutput;
|
||||||
sourceOutput = 'main';
|
sourceOutput = 'main';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,7 +869,11 @@ export class WorkflowDiffEngine {
|
|||||||
// Use nullish coalescing to properly handle explicit 0 values
|
// 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.)
|
// 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
|
// 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;
|
const targetIndex = operation.targetIndex ?? 0;
|
||||||
|
|
||||||
// Initialize source node connections object
|
// 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).`;
|
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 {
|
private setNestedProperty(obj: any, path: string, value: any): void {
|
||||||
const keys = path.split('.');
|
const keys = path.split('.');
|
||||||
let current = obj;
|
let current = obj;
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ describe('Integration: Real-World Type Structure Validation', () => {
|
|||||||
expect(sheetIdErrors).toBe(0);
|
expect(sheetIdErrors).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should validate all filter operations including exists/notExists/isNotEmpty', async () => {
|
it('should validate all filter operations including exists/notExists/notEmpty', async () => {
|
||||||
const templates = db.prepare(`
|
const templates = db.prepare(`
|
||||||
SELECT id, name, workflow_json_compressed
|
SELECT id, name, workflow_json_compressed
|
||||||
FROM templates
|
FROM templates
|
||||||
|
|||||||
@@ -102,9 +102,9 @@ describe('Node Sanitizer', () => {
|
|||||||
const sanitized = sanitizeNode(node);
|
const sanitized = sanitizeNode(node);
|
||||||
const condition = (sanitized.parameters.conditions as any).conditions[0];
|
const condition = (sanitized.parameters.conditions as any).conditions[0];
|
||||||
|
|
||||||
// Should fix operator structure
|
// Should fix operator structure and auto-correct isNotEmpty to notEmpty
|
||||||
expect(condition.operator.type).toBe('boolean'); // Inferred data type (isEmpty/isNotEmpty are boolean ops)
|
expect(condition.operator.type).toBe('string'); // Inferred data type (default)
|
||||||
expect(condition.operator.operation).toBe('isNotEmpty'); // Moved to operation field
|
expect(condition.operator.operation).toBe('notEmpty'); // Moved to operation field and auto-corrected
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add singleValue for unary operators', () => {
|
it('should add singleValue for unary operators', () => {
|
||||||
@@ -253,6 +253,39 @@ describe('Node Sanitizer', () => {
|
|||||||
expect(condition.operator.type).toBe('string');
|
expect(condition.operator.type).toBe('string');
|
||||||
expect(condition.operator.operation).toBe('equals');
|
expect(condition.operator.operation).toBe('equals');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should auto-correct isNotEmpty to notEmpty', () => {
|
||||||
|
const node: WorkflowNode = {
|
||||||
|
id: 'test-if-autocorrect',
|
||||||
|
name: 'IF AutoCorrect',
|
||||||
|
type: 'n8n-nodes-base.if',
|
||||||
|
typeVersion: 2.2,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {
|
||||||
|
conditions: {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
id: 'condition1',
|
||||||
|
leftValue: '={{ $json.value }}',
|
||||||
|
rightValue: '',
|
||||||
|
operator: {
|
||||||
|
type: 'string',
|
||||||
|
operation: 'isNotEmpty' // Legacy operator name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = sanitizeNode(node);
|
||||||
|
const condition = (sanitized.parameters.conditions as any).conditions[0];
|
||||||
|
|
||||||
|
// Should auto-correct isNotEmpty to notEmpty
|
||||||
|
expect(condition.operator.operation).toBe('notEmpty');
|
||||||
|
expect(condition.operator.type).toBe('string');
|
||||||
|
expect(condition.operator.singleValue).toBe(true); // notEmpty is unary
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validateNodeMetadata', () => {
|
describe('validateNodeMetadata', () => {
|
||||||
@@ -370,7 +403,7 @@ describe('Node Sanitizer', () => {
|
|||||||
rightValue: '',
|
rightValue: '',
|
||||||
operator: {
|
operator: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
operation: 'isNotEmpty'
|
operation: 'notEmpty'
|
||||||
// Missing singleValue: true
|
// Missing singleValue: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,7 +477,7 @@ describe('Node Sanitizer', () => {
|
|||||||
rightValue: '',
|
rightValue: '',
|
||||||
operator: {
|
operator: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
operation: 'isNotEmpty',
|
operation: 'notEmpty',
|
||||||
singleValue: true
|
singleValue: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -427,6 +427,158 @@ describe('WorkflowDiffEngine', () => {
|
|||||||
expect(result.errors![0].message).toContain('Missing required parameter \'updates\'');
|
expect(result.errors![0].message).toContain('Missing required parameter \'updates\'');
|
||||||
expect(result.errors![0].message).toContain('Correct structure:');
|
expect(result.errors![0].message).toContain('Correct structure:');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should apply __patch_find_replace to string properties (#642)', async () => {
|
||||||
|
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
|
||||||
|
workflow.nodes.push({
|
||||||
|
id: 'code-1',
|
||||||
|
name: 'Code',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [900, 300],
|
||||||
|
parameters: { jsCode: 'const x = 1;\nreturn x + 2;' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflow, {
|
||||||
|
id: 'test',
|
||||||
|
operations: [{
|
||||||
|
type: 'updateNode' as const,
|
||||||
|
nodeName: 'Code',
|
||||||
|
updates: {
|
||||||
|
'parameters.jsCode': {
|
||||||
|
__patch_find_replace: [
|
||||||
|
{ find: 'x + 2', replace: 'x + 3' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const codeNode = result.workflow.nodes.find((n: any) => n.name === 'Code');
|
||||||
|
expect(codeNode?.parameters.jsCode).toBe('const x = 1;\nreturn x + 3;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply multiple sequential __patch_find_replace patches', async () => {
|
||||||
|
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
|
||||||
|
workflow.nodes.push({
|
||||||
|
id: 'code-1',
|
||||||
|
name: 'Code',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [900, 300],
|
||||||
|
parameters: { jsCode: 'const a = 1;\nconst b = 2;\nreturn a + b;' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflow, {
|
||||||
|
id: 'test',
|
||||||
|
operations: [{
|
||||||
|
type: 'updateNode' as const,
|
||||||
|
nodeName: 'Code',
|
||||||
|
updates: {
|
||||||
|
'parameters.jsCode': {
|
||||||
|
__patch_find_replace: [
|
||||||
|
{ find: 'const a = 1', replace: 'const a = 10' },
|
||||||
|
{ find: 'const b = 2', replace: 'const b = 20' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const codeNode = result.workflow.nodes.find((n: any) => n.name === 'Code');
|
||||||
|
expect(codeNode?.parameters.jsCode).toBe('const a = 10;\nconst b = 20;\nreturn a + b;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject __patch_find_replace on non-string properties', async () => {
|
||||||
|
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
|
||||||
|
workflow.nodes.push({
|
||||||
|
id: 'code-1',
|
||||||
|
name: 'Code',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [900, 300],
|
||||||
|
parameters: { retryCount: 3 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflow, {
|
||||||
|
id: 'test',
|
||||||
|
operations: [{
|
||||||
|
type: 'updateNode' as const,
|
||||||
|
nodeName: 'Code',
|
||||||
|
updates: {
|
||||||
|
'parameters.retryCount': {
|
||||||
|
__patch_find_replace: [
|
||||||
|
{ find: '3', replace: '5' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errors?.[0]?.message).toContain('__patch_find_replace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject __patch_find_replace with invalid format', async () => {
|
||||||
|
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
|
||||||
|
workflow.nodes.push({
|
||||||
|
id: 'code-1',
|
||||||
|
name: 'Code',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [900, 300],
|
||||||
|
parameters: { jsCode: 'const x = 1;' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflow, {
|
||||||
|
id: 'test',
|
||||||
|
operations: [{
|
||||||
|
type: 'updateNode' as const,
|
||||||
|
nodeName: 'Code',
|
||||||
|
updates: {
|
||||||
|
'parameters.jsCode': {
|
||||||
|
__patch_find_replace: 'not an array'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errors?.[0]?.message).toContain('must be an array');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn when __patch_find_replace find string not found', async () => {
|
||||||
|
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
|
||||||
|
workflow.nodes.push({
|
||||||
|
id: 'code-1',
|
||||||
|
name: 'Code',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [900, 300],
|
||||||
|
parameters: { jsCode: 'const x = 1;' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflow, {
|
||||||
|
id: 'test',
|
||||||
|
operations: [{
|
||||||
|
type: 'updateNode' as const,
|
||||||
|
nodeName: 'Code',
|
||||||
|
updates: {
|
||||||
|
'parameters.jsCode': {
|
||||||
|
__patch_find_replace: [
|
||||||
|
{ find: 'nonexistent text', replace: 'something' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.warnings).toBeDefined();
|
||||||
|
expect(result.warnings!.some(w => w.message.includes('not found'))).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('MoveNode Operation', () => {
|
describe('MoveNode Operation', () => {
|
||||||
@@ -766,6 +918,97 @@ describe('WorkflowDiffEngine', () => {
|
|||||||
expect(result.errors![0].message).toContain('HTTP Request');
|
expect(result.errors![0].message).toContain('HTTP Request');
|
||||||
expect(result.errors![0].message).toContain('Slack');
|
expect(result.errors![0].message).toContain('Slack');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should remap numeric targetInput to main (#659)', async () => {
|
||||||
|
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
|
||||||
|
workflow.nodes.push({
|
||||||
|
id: 'code-1',
|
||||||
|
name: 'Code',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [900, 300],
|
||||||
|
parameters: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflow, {
|
||||||
|
id: 'test',
|
||||||
|
operations: [{
|
||||||
|
type: 'addConnection' as const,
|
||||||
|
source: 'Slack',
|
||||||
|
target: 'Code',
|
||||||
|
sourceOutput: 'main',
|
||||||
|
targetInput: '0',
|
||||||
|
sourceIndex: 0,
|
||||||
|
targetIndex: 0
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.workflow.connections['Slack']['main'][0][0].type).toBe('main');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remap sourceOutput 0 with explicit sourceIndex 0 (#659)', async () => {
|
||||||
|
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
|
||||||
|
workflow.nodes.push({
|
||||||
|
id: 'code-1',
|
||||||
|
name: 'Code',
|
||||||
|
type: 'n8n-nodes-base.code',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [900, 300],
|
||||||
|
parameters: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflow, {
|
||||||
|
id: 'test',
|
||||||
|
operations: [{
|
||||||
|
type: 'addConnection' as const,
|
||||||
|
source: 'Slack',
|
||||||
|
target: 'Code',
|
||||||
|
sourceOutput: '0',
|
||||||
|
sourceIndex: 0,
|
||||||
|
targetIndex: 0
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.workflow.connections['Slack']['main']).toBeDefined();
|
||||||
|
expect(result.workflow.connections['Slack']['0']).toBeUndefined();
|
||||||
|
expect(result.workflow.connections['Slack']['main'][0][0].type).toBe('main');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve named targetInput like ai_tool', async () => {
|
||||||
|
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
|
||||||
|
workflow.nodes.push({
|
||||||
|
id: 'agent-1',
|
||||||
|
name: 'AI Agent',
|
||||||
|
type: '@n8n/n8n-nodes-langchain.agent',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [900, 300],
|
||||||
|
parameters: {}
|
||||||
|
});
|
||||||
|
workflow.nodes.push({
|
||||||
|
id: 'tool-1',
|
||||||
|
name: 'Calculator',
|
||||||
|
type: '@n8n/n8n-nodes-langchain.toolCalculator',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [1100, 300],
|
||||||
|
parameters: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflow, {
|
||||||
|
id: 'test',
|
||||||
|
operations: [{
|
||||||
|
type: 'addConnection' as const,
|
||||||
|
source: 'Calculator',
|
||||||
|
target: 'AI Agent',
|
||||||
|
sourceOutput: 'ai_tool',
|
||||||
|
targetInput: 'ai_tool'
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.workflow.connections['Calculator']['ai_tool'][0][0].type).toBe('ai_tool');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('RemoveConnection Operation', () => {
|
describe('RemoveConnection Operation', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user