Skip to content

Commit 2b5d271

Browse files
czlonkowskibloufedward-stjohn-CoTPitchfork-and-Torchfix2015
authored
fix: harden the workflow write path (node allowlist, settings retry, rollback verification) (v2.81.0) (#1059)
* fix(partial-workflow): strip unknown node properties echoed by n8n GET n8n's GET /workflows/{id} returns node properties that its PUT/PATCH schema rejects (e.g. server-managed fields like `issues`, `runIndex`, and previously-missing fields like `onError` and `webhookId`). - Add `onError` and `webhookId` to `workflowNodeSchema` - Introduce `cleanNodeForApi()` to filter each node through an allow-list of API-safe properties - Apply filtering in both `cleanWorkflowForCreate` and `cleanWorkflowForUpdate` - Add tests covering the stripping behaviour and the new function Conceived by Romuald Członkowski - www.aiadvisors.pl/en (cherry picked from commit 3638beb) * Verify rollback against the server before reporting it as failed A rollback PUT can persist and then throw. n8n's public API commits workflow content before it checks publish permission, so a caller allowed to edit but not to publish receives an error on a write that landed. The catch block then recorded rollbackPerformed: false and warned that the workflow may be in a broken state, when the content had in fact been restored. That invites a riskier recovery action than doing nothing. After the rollback PUT throws, re-read the workflow and compare the fields an update actually sends, via cleanWorkflowForUpdate. Version identity cannot settle it: a successful rollback writes a new version, so compareVersions() reports 'changed' either way. (cherry picked from commit eb41626) * Use deep equality on the update allowlist and ignore generated webhook ids cleanWorkflowForUpdate() assigns a random webhookId to webhook nodes that lack one, and does so in place. Comparing its output directly mutated both reads and gave each a different id, so any workflow containing a webhook node could never compare equal to itself and the verification silently never succeeded. Clone before cleaning, drop the generated field, and compare with isDeepStrictEqual instead of a hand-written serialiser. Tests now model an actual reverted change rather than workflows that were identical throughout, and cover the unrestored and webhook cases. (cherry picked from commit 8e0b2a9) * Compare webhook ids the workflow already carried Only the ids the update allowlist generates for nodes that lack one are unstable between reads, so drop just those. A webhookId already present is real content, and a change to one means the prior state was not restored. (cherry picked from commit d9ba578) * test: cover GET→UPDATE workflow round-trips and n8n API quirks (#433) Add unit and live-integration coverage for the common spread-from-GET update pattern that let description/read-only fields slip through (#431). - Unit: full GET-shaped payload cleaning, minimal payload, empty/unknown settings - Integration: GET→UPDATE, spread rename, nested settings, description strip, missing settings defaults, read-only field echo, minimal updates, settings filter - Document n8n read/write asymmetry in tests/integration/n8n-api/README.md (cherry picked from commit 46cd1c8) * fix: stale hardcoded version in health check, deprecated API, typos - healthCheck() was returning hardcoded version '2.24.1' instead of the actual package version. imported PROJECT_VERSION and used that instead. - res.finished was deprecated in Node 13, changed to res.writableEnded - fixed "paralel" → "parallel" and "Concieved" → "Conceived" in CLAUDE.md - replaced dead issue link (issues/XXX) with inline description - removed unnecessary (this as any) cast in workflow-validator since currentWorkflow is already properly typed on the class (cherry picked from commit 63f3542) * fix: retry a workflow write without the settings an n8n instance rejects as unknown Settings are forwarded on purpose (the table trails n8n's releases), and n8n answers an unknown key with a 400 that names the path but not the key. Instead of failing the whole write, the client now retries without candidates in order: keys absent from the settings table together, then known keys newest first, and reports what it left out through onWarning. A rejected key is remembered for the client's lifetime. This replaces marking timeSavedMode as derived (#1017): n8n 2.36 accepts and echoes it on PUT, so stripping it would drop a real setting on current instances. Also gives the round-trip tests from #925 their own describe block and rewrites the one assertion that encoded the pre-2.70 settings allowlist. Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RQmLW2QcubiwyuemrLs62d * refactor: derive the node allowlist from the schema and tidy the settings ladder Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RQmLW2QcubiwyuemrLs62d * fix: remember only single-key settings rejections; guard node allowlist drift Review round on the write-path branch: - a batch of unknown settings keys dropped together is no longer remembered, since only one of them may be the culprit; single-key steps still are - the rollback comparison ignores generated webhook ids on both reads, so it no longer depends on cleanWorkflowForUpdate mutating the snapshot in place; id-less nodes guarded - customTelemetryTags added to the node schema (n8n 2.36's node schema lists it, so the allowlist would have stripped it), and check:settings-drift now compares the node schema against WRITABLE_NODE_PROPERTIES as well - notes on rejectedSettings lifetime and the retry bound; small doc and test-name fixes Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RQmLW2QcubiwyuemrLs62d * fix: let a settings rejection on the group ladder's retry reach the settings ladder Codex found that the confirmation retry without nodeGroups swallowed a settings-level 400 and rethrew the original groups error, so the settings ladder never ran. Also a health-check version test and a README correction. Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RQmLW2QcubiwyuemrLs62d * test: pin that a batch of unknown settings keys is probed again; attribute readOnly to the right property Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RQmLW2QcubiwyuemrLs62d * chore: release 2.81.0 Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RQmLW2QcubiwyuemrLs62d --------- Co-authored-by: blouf <blouf@blouf.org> Co-authored-by: Ed St John <edward.stjohn@capitalontap.com> Co-authored-by: Pitchfork-and-Torch <297513015+Pitchfork-and-Torch@users.noreply.github.com> Co-authored-by: vitalii.semianchuk <fix20152@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 29e3f25 commit 2b5d271

23 files changed

Lines changed: 1365 additions & 34 deletions

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.81.0] - 2026-09-03
11+
12+
### Added
13+
14+
- **Workflow writes retry without the settings an n8n instance rejects as unknown.** Settings are forwarded untouched on purpose, because the bundled settings table trails n8n's releases and a property dropped up front is dropped silently. The cost was that a setting the instance's write schema does not accept failed the whole write with `request/body/settings must NOT have additional properties`, a message that never names the key, which is what users hit when a GET echoes a property such as `timeSavedMode` that their n8n version stores but does not accept on PUT ([#1017](https://github.com/czlonkowski/n8n-mcp/pull/1017)). The API client now retries without candidates in a fixed order: keys absent from the settings table first, together, then known keys from newest to oldest; keys that predate n8n 1.119.0 are never dropped, since instances that old still report their version and are filtered precisely. Each dropped key is reported in the tool response `warnings` and remembered for the client's lifetime so later writes skip the probe. `timeSavedMode` itself stays writable: n8n 2.36 accepts and echoes it, so marking it derived would have dropped a real setting on current instances.
15+
16+
### Fixed
17+
18+
- **Node properties that n8n echoes on GET but rejects on PUT are stripped before a write** ([#983](https://github.com/czlonkowski/n8n-mcp/pull/983)). n8n's node schema is `additionalProperties: false`, and a GET can carry `issues`, `runIndex` or `data`, so a round trip failed with `request/body/nodes/0 must NOT have additional properties`. `cleanWorkflowForCreate` and `cleanWorkflowForUpdate` now keep only the properties of the node schema; the allowlist is derived from that schema, so it cannot drift from it. `onError` and `webhookId` were missing from the schema and are accepted.
19+
- **A rollback that n8n persisted before rejecting is no longer reported as failed** ([#979](https://github.com/czlonkowski/n8n-mcp/pull/979)). n8n's public API can commit workflow content and then throw on a later check, so `n8n_update_partial_workflow` could warn that a workflow was left broken when it had in fact been restored. After a rollback PUT throws, the handler re-reads the workflow and compares the writable fields with the pre-update snapshot; a match is reported as `rollbackVerifiedAfterError: true`. Webhook ids generated for the comparison are ignored; ids the workflow already carried are compared.
20+
- **Engine health check reported a hard-coded version** ([#908](https://github.com/czlonkowski/n8n-mcp/pull/908)). `N8NMCPEngine.healthCheck()` returned `2.24.1` regardless of the installed version; it now reports the package version. The HTTP server's response logging reads `writableEnded` instead of the deprecated `finished`.
21+
22+
### Changed
23+
24+
- Round-trip tests for GET→UPDATE workflow writes ([#925](https://github.com/czlonkowski/n8n-mcp/pull/925), [#433](https://github.com/czlonkowski/n8n-mcp/issues/433)): unit tests for the cleaners and integration tests against a live instance for the spread-a-GET-into-an-update patterns the n8n API is particular about.
25+
1026
## [2.80.1] - 2026-09-03
1127

1228
### Fixed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "n8n-mcp",
3-
"version": "2.80.1",
3+
"version": "2.81.0",
44
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

package.runtime.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "n8n-mcp-runtime",
3-
"version": "2.80.1",
3+
"version": "2.81.0",
44
"description": "n8n MCP Server Runtime Dependencies Only",
55
"private": true,
66
"dependencies": {

scripts/check-settings-drift.ts

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ import {
2121
WORKFLOW_SETTINGS_PROPERTIES,
2222
type SettingsVersion,
2323
} from '../src/constants/workflow-settings';
24+
import { WRITABLE_NODE_PROPERTIES } from '../src/services/n8n-validation';
2425

2526
const SCHEMA_PATH = 'dist/public-api/v1/openapi.yml';
2627
const SCHEMA_NAME = 'workflowSettings';
28+
/** The node schema is `additionalProperties: false` too; cleanNodeForApi strips to WRITABLE_NODE_PROPERTIES. */
29+
const NODE_SCHEMA_NAME = 'node';
2730
const ENTITY_INTERFACE = 'IWorkflowSettings';
2831

2932
function resolveVersion(): string {
@@ -70,13 +73,17 @@ const indentOf = (line: string): number => line.length - line.trimStart().length
7073
* this cannot find throws, which is the point - a silently empty result would read as "no
7174
* drift".
7275
*/
73-
export function parseSchemaProperties(yaml: string): Set<string> {
76+
export function parseSchemaProperties(
77+
yaml: string,
78+
schemaName = SCHEMA_NAME,
79+
readOnly?: Set<string>
80+
): Set<string> {
7481
const lines = yaml.split('\n');
7582

76-
const schemaIndex = lines.findIndex(line => new RegExp(`^\\s+${SCHEMA_NAME}:\\s*$`).test(line));
83+
const schemaIndex = lines.findIndex(line => new RegExp(`^\\s+${schemaName}:\\s*$`).test(line));
7784
if (schemaIndex === -1) {
7885
throw new Error(
79-
`No "${SCHEMA_NAME}:" schema in ${SCHEMA_PATH}. n8n may have renamed it - check the spec.`
86+
`No "${schemaName}:" schema in ${SCHEMA_PATH}. n8n may have renamed it - check the spec.`
8087
);
8188
}
8289
const schemaIndent = indentOf(lines[schemaIndex]);
@@ -94,12 +101,14 @@ export function parseSchemaProperties(yaml: string): Set<string> {
94101
}
95102
}
96103
if (propertiesIndex === -1) {
97-
throw new Error(`"${SCHEMA_NAME}" has no properties block in ${SCHEMA_PATH}`);
104+
throw new Error(`"${schemaName}" has no properties block in ${SCHEMA_PATH}`);
98105
}
99106

100107
const propertiesIndent = indentOf(lines[propertiesIndex]);
101108
const names = new Set<string>();
102109
let keyIndent: number | null = null;
110+
let current: string | null = null;
111+
let readOnlyIndent: number | null = null;
103112

104113
for (let i = propertiesIndex + 1; i < lines.length; i++) {
105114
const line = lines[i];
@@ -108,18 +117,42 @@ export function parseSchemaProperties(yaml: string): Set<string> {
108117
if (indent <= propertiesIndent) break;
109118

110119
if (keyIndent === null) keyIndent = indent;
111-
if (indent !== keyIndent) continue; // nested schema of the property above
120+
if (indent !== keyIndent) {
121+
// Directly under the property above, `readOnly: true` marks a GET-only property. Deeper
122+
// lines belong to a sub-schema and say nothing about the property itself.
123+
if (current && indent > keyIndent && line.trim() === 'readOnly: true' && readOnlyIndent === indent) {
124+
readOnly?.add(current);
125+
}
126+
continue;
127+
}
112128

113129
const match = line.trim().match(/^([A-Za-z][A-Za-z0-9_]*):/);
130+
current = match ? match[1] : null;
114131
if (match) names.add(match[1]);
132+
// The property's own attributes sit one level in; measured from the next line, not assumed.
133+
readOnlyIndent = lines.slice(i + 1).find(next => next.trim() !== '')?.match(/^\s*/)?.[0].length ?? null;
115134
}
116135

117136
if (names.size === 0) {
118-
throw new Error(`Parsed zero properties from "${SCHEMA_NAME}" - the spec format changed`);
137+
throw new Error(`Parsed zero properties from "${schemaName}" - the spec format changed`);
119138
}
120139
return names;
121140
}
122141

142+
/**
143+
* Node-level drift: properties the node write schema accepts that cleanNodeForApi would strip,
144+
* and properties we send that the schema no longer lists. Read-only ones (createdAt, updatedAt)
145+
* are rejected on write, so stripping them is correct and they are not reported.
146+
*/
147+
export function diffNodeProperties(yaml: string): { missing: string[]; removed: string[] } {
148+
const readOnly = new Set<string>();
149+
const schema = parseSchemaProperties(yaml, NODE_SCHEMA_NAME, readOnly);
150+
return {
151+
missing: [...schema].filter(name => !readOnly.has(name) && !WRITABLE_NODE_PROPERTIES.has(name)),
152+
removed: [...WRITABLE_NODE_PROPERTIES].filter(name => !schema.has(name)),
153+
};
154+
}
155+
123156
/**
124157
* Pull the property names out of n8n-workflow's `IWorkflowSettings` declaration - the workflow
125158
* entity's settings type, which the Public API schema is supposed to mirror but has trailed
@@ -426,7 +459,9 @@ async function main(): Promise<void> {
426459
}
427460
}
428461

429-
const schemaProperties = parseSchemaProperties(await fetchSchemaFile(version));
462+
const schemaYaml = await fetchSchemaFile(version);
463+
const schemaProperties = parseSchemaProperties(schemaYaml);
464+
const nodeDrift = diffNodeProperties(schemaYaml);
430465

431466
const { missing, removed, ahead, entityOnly, unhandledEntityOnly, publishedEntityOnly } =
432467
diffSettingsProperties(schemaProperties, entityProperties, parseVersion(version));
@@ -443,13 +478,26 @@ async function main(): Promise<void> {
443478
console.log(`ℹ️ ${entityOnly.length} entity-only, stripped on write (expected): ${entityOnly.join(', ')}\n`);
444479
}
445480

481+
if (nodeDrift.missing.length > 0) {
482+
console.error(`❌ ${nodeDrift.missing.length} node property/properties in n8n's write schema that cleanNodeForApi strips:`);
483+
for (const name of nodeDrift.missing) console.error(` + ${name}`);
484+
console.error('\n Add each to workflowNodeObjectSchema in src/services/n8n-validation.ts.\n');
485+
}
486+
if (nodeDrift.removed.length > 0) {
487+
console.error(`❌ ${nodeDrift.removed.length} node property/properties we send that n8n's write schema no longer lists:`);
488+
for (const name of nodeDrift.removed) console.error(` - ${name}`);
489+
console.error('\n Remove them from workflowNodeObjectSchema once no supported version accepts them.\n');
490+
}
491+
446492
if (
447493
missing.length === 0 &&
448494
removed.length === 0 &&
449495
unhandledEntityOnly.length === 0 &&
450-
publishedEntityOnly.length === 0
496+
publishedEntityOnly.length === 0 &&
497+
nodeDrift.missing.length === 0 &&
498+
nodeDrift.removed.length === 0
451499
) {
452-
console.log('✅ No drift - src/constants/workflow-settings.ts matches n8n.');
500+
console.log('✅ No drift - src/constants/workflow-settings.ts and the node schema match n8n.');
453501
return;
454502
}
455503

src/database/shared-database.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
*
88
* Memory impact: Reduces per-session memory from ~900MB to near-zero by sharing
99
* a single ~68MB database connection across all sessions.
10-
*
11-
* Issue: https://github.com/czlonkowski/n8n-mcp/issues/XXX
1210
*/
1311

1412
import path from 'path';

src/http-server-single-session.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1630,7 +1630,7 @@ export class SingleSessionHTTPServer {
16301630
logger.info('POST /mcp request completed - checking response status', {
16311631
responseHeadersSent: res.headersSent,
16321632
responseStatusCode: res.statusCode,
1633-
responseFinished: res.finished
1633+
responseFinished: res.writableEnded
16341634
});
16351635
});
16361636

src/mcp-engine.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { Request, Response } from 'express';
99
import { SingleSessionHTTPServer } from './http-server-single-session';
1010
import { logger } from './utils/logger';
11+
import { PROJECT_VERSION } from './utils/version';
1112
import { InstanceContext } from './types/instance-context';
1213
import { SessionState } from './types/session-state';
1314
import type { AdditionalTool } from './types/additional-tools';
@@ -105,7 +106,7 @@ export class N8NMCPEngine {
105106
total: Math.round(memoryUsage.heapTotal / 1024 / 1024),
106107
unit: 'MB'
107108
},
108-
version: '2.24.1'
109+
version: PROJECT_VERSION
109110
};
110111
} catch (error) {
111112
logger.error('Health check failed:', error);
@@ -114,7 +115,7 @@ export class N8NMCPEngine {
114115
uptime: 0,
115116
sessionActive: false,
116117
memoryUsage: { used: 0, total: 0, unit: 'MB' },
117-
version: '2.24.1'
118+
version: PROJECT_VERSION
118119
};
119120
}
120121
}

src/mcp/handlers-workflow-diff.ts

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55

66
import { z } from 'zod';
77
import { randomUUID } from 'crypto';
8-
import { McpToolResponse } from '../types/n8n-api';
8+
import { isDeepStrictEqual } from 'node:util';
9+
import { McpToolResponse, Workflow } from '../types/n8n-api';
910
import { WorkflowDiffRequest, WorkflowDiffOperation, WorkflowDiffValidationError } from '../types/workflow-diff';
1011
import { WorkflowDiffEngine } from '../services/workflow-diff-engine';
1112
import { getN8nApiClient } from './handlers-n8n-manager';
1213
import { N8nApiError, getUserFriendlyErrorMessage } from '../utils/n8n-errors';
1314
import { logger } from '../utils/logger';
1415
import { InstanceContext, getInstanceScopeId } from '../types/instance-context';
15-
import { validateWorkflowStructure } from '../services/n8n-validation';
16+
import { validateWorkflowStructure, cleanWorkflowForUpdate } from '../services/n8n-validation';
1617
import { NodeRepository } from '../database/node-repository';
1718
import { WorkflowVersioningService } from '../services/workflow-versioning-service';
1819
import { WorkflowValidator } from '../services/workflow-validator';
@@ -48,6 +49,38 @@ function compareVersions(
4849
return 'unknown';
4950
}
5051

52+
// Ids of nodes that lack a webhookId in the given read. cleanWorkflowForUpdate() assigns a random
53+
// one to such nodes, and the server persists it, so the snapshot taken before a write and the read
54+
// taken after it legitimately differ there. A webhookId both reads carry is real content.
55+
function nodesWithoutWebhookId(workflow: Workflow): string[] {
56+
return (workflow.nodes ?? []).filter(node => node.id && !node.webhookId).map(node => node.id);
57+
}
58+
59+
// The shape an update would send, for comparing two reads of the same workflow. Cloned because
60+
// cleanWorkflowForUpdate() mutates its input.
61+
function writableShape(workflow: Workflow, ignoreWebhookIdOf: Set<string>): Record<string, unknown> {
62+
const cleaned = cleanWorkflowForUpdate(structuredClone(workflow)) as Record<string, unknown>;
63+
if (Array.isArray(cleaned.nodes)) {
64+
for (const node of cleaned.nodes) {
65+
if (ignoreWebhookIdOf.has(node.id)) delete node.webhookId;
66+
}
67+
}
68+
return cleaned;
69+
}
70+
71+
// Compare only the fields the update allowlist accepts. Version identity cannot verify a rollback:
72+
// a successful rollback writes a new version, so compareVersions() reports 'changed' regardless.
73+
// Generated webhook ids are ignored on both sides whichever read lacks them, so the outcome does
74+
// not depend on whether an earlier write mutated the snapshot in place.
75+
function sameWritableContent(a: Workflow, b: Workflow): boolean {
76+
try {
77+
const generated = new Set([...nodesWithoutWebhookId(a), ...nodesWithoutWebhookId(b)]);
78+
return isDeepStrictEqual(writableShape(a, generated), writableShape(b, generated));
79+
} catch {
80+
return false;
81+
}
82+
}
83+
5184
/**
5285
* Get or create cached workflow validator instance
5386
* Reuses the same validator to avoid redundant NodeSimilarityService initialization
@@ -422,6 +455,7 @@ export async function handleUpdatePartialWorkflow(
422455

423456
// Either persist-then-fail OR couldn't determine — attempt rollback.
424457
let rollbackPerformed = false;
458+
let rollbackVerifiedAfterError = false;
425459
let rollbackErrorMessage: string | undefined;
426460
try {
427461
// No authoredGroups here: restoring the graph matters, frames do not. If the snapshot's
@@ -436,11 +470,32 @@ export async function handleUpdatePartialWorkflow(
436470
});
437471
} catch (rollbackErr) {
438472
rollbackErrorMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
439-
logger.error('updateWorkflow failed AND rollback failed', {
440-
workflowId: input.id,
441-
originalError: updateError instanceof Error ? updateError.message : String(updateError),
442-
rollbackError: rollbackErrorMessage,
443-
});
473+
474+
// n8n can persist a rollback PUT and then reject it: the public API commits workflow
475+
// content before it checks publish permission. Verify against the server rather than
476+
// trusting the throw, or we warn of a broken workflow that was in fact restored.
477+
try {
478+
const afterRollback = await client.getWorkflow(input.id);
479+
if (sameWritableContent(afterRollback, workflowBefore)) {
480+
rollbackPerformed = true;
481+
rollbackVerifiedAfterError = true;
482+
logger.warn('rollback PUT errored but content matches the prior state; treating as rolled back', {
483+
workflowId: input.id,
484+
rollbackError: rollbackErrorMessage,
485+
});
486+
rollbackErrorMessage = undefined;
487+
}
488+
} catch (verifyErr) {
489+
logger.debug('post-rollback verification GET failed', verifyErr);
490+
}
491+
492+
if (!rollbackPerformed) {
493+
logger.error('updateWorkflow failed AND rollback failed', {
494+
workflowId: input.id,
495+
originalError: updateError instanceof Error ? updateError.message : String(updateError),
496+
rollbackError: rollbackErrorMessage,
497+
});
498+
}
444499
}
445500

446501
// Re-throw with rollback context attached so the outer N8nApiError
@@ -454,6 +509,7 @@ export async function handleUpdatePartialWorkflow(
454509
const augmentedDetails: Record<string, unknown> = {
455510
...((updateError.details as Record<string, unknown>) ?? {}),
456511
rollbackPerformed,
512+
...(rollbackVerifiedAfterError ? { rollbackVerifiedAfterError: true } : {}),
457513
...(folderMoveInPayload && rollbackPerformed ? { folderMoveMayHavePersisted: true } : {}),
458514
...(rollbackErrorMessage ? { rollbackError: rollbackErrorMessage } : {}),
459515
...(workflowBefore.versionId ? { priorVersionId: workflowBefore.versionId } : {}),

0 commit comments

Comments
 (0)