feat(core): Add automated node migrations to the v3 migration report (no-changelog) (#34803)

This commit is contained in:
Bernhard Wittmann
2026-07-28 10:09:48 +02:00
committed by GitHub
parent acc62367c2
commit 81538fe23e
36 changed files with 1472 additions and 207 deletions
+1
View File
@@ -247,6 +247,7 @@ export type {
BreakingChangeReportResult,
BreakingChangeLightReportResult,
BreakingChangeVersion,
WorkflowMigrationResult,
} from './schemas/breaking-changes.schema';
export { MIGRATION_REPORT_TARGET_VERSION } from './schemas/breaking-changes.schema';
@@ -59,6 +59,9 @@ const ruleResultBaseSchema = z.object({
ruleSeverity: breakingChangeRuleSeveritySchema,
ruleDocumentationUrl: z.string().optional(),
recommendations: z.array(recommendationSchema),
// True when an automated migration is registered for this rule, so the UI
// can offer a "Migrate" action instead of prose-only advice.
migratable: z.boolean(),
});
const instanceRuleResultsSchema = ruleResultBaseSchema.extend({
@@ -110,3 +113,21 @@ const breakingChangeLightReportResultDataSchema = z.object({
export type BreakingChangeLightReportResult = z.infer<
typeof breakingChangeLightReportResultDataSchema
>;
// Result of applying an automated node migration to a single workflow.
const workflowMigrationResultSchema = z.object({
workflowId: z.string(),
// versionId of the new workflow version created by the migration.
newVersionId: z.string(),
// Ids of the nodes that were rewritten.
migratedNodeIds: z.array(z.string()),
// Parameters that could not be carried over (empty for lossless migrations).
unmapped: z.array(z.string()),
// Behavior/output changes the user should be aware of.
notes: z.array(z.string()),
// True when the migration can be published in one click: it was lossless (no
// unmapped/notes), the migrated version was the one currently published, and
// the result still validates for activation (real trigger, no node errors).
republishable: z.boolean(),
});
export type WorkflowMigrationResult = z.infer<typeof workflowMigrationResultSchema>;
@@ -156,6 +156,7 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = `
"chatHubAgent:delete",
"chatHubAgent:list",
"breakingChanges:list",
"breakingChanges:migrate",
"apiKey:manage",
"apiKey:list",
"apiKey:create",
@@ -77,7 +77,7 @@ export const RESOURCES = {
mcpApiKey: ['create', 'rotate'] as const,
chatHub: ['manage', 'message'] as const,
chatHubAgent: [...DEFAULT_OPERATIONS] as const,
breakingChanges: ['list'] as const,
breakingChanges: ['list', 'migrate'] as const,
apiKey: ['manage', 'list', 'create', 'delete', 'update'] as const,
encryptionKey: ['manage'] as const,
credentialResolver: [...DEFAULT_OPERATIONS] as const,
@@ -140,6 +140,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'chatHubAgent:delete',
'chatHubAgent:list',
'breakingChanges:list',
'breakingChanges:migrate',
'execution:reveal',
'apiKey:manage',
'apiKey:list',
@@ -0,0 +1,49 @@
import type { Logger } from '@n8n/backend-common';
import { mock } from 'vitest-mock-extended';
import type { NodeMigration } from '../migrations/node-migration';
// Control the migration list the registry loads so the tests don't depend on
// which real migrations happen to be registered.
const { migA, migB, migADuplicate } = vi.hoisted(() => ({
migA: { ruleId: 'rule-a', migrate: vi.fn() },
migB: { ruleId: 'rule-b', migrate: vi.fn() },
// Same ruleId as migA, to exercise the duplicate-registration branch.
migADuplicate: { ruleId: 'rule-a', migrate: vi.fn() },
}));
vi.mock('../migrations', () => ({ nodeMigrations: [migA, migB, migADuplicate] }));
import { MigrationRegistry } from '../breaking-changes.migration-registry.service';
describe('MigrationRegistry', () => {
const scopedLogger = mock<Logger>();
const logger = mock<Logger>({ scoped: vi.fn().mockReturnValue(scopedLogger) });
let registry: MigrationRegistry;
beforeEach(() => {
vi.clearAllMocks();
registry = new MigrationRegistry(logger);
});
it('registers migrations keyed by their ruleId', () => {
registry.registerAll();
expect(registry.has('rule-b')).toBe(true);
expect(registry.get('rule-b')).toBe(migB as unknown as NodeMigration);
});
it('reports unknown rules as unregistered', () => {
registry.registerAll();
expect(registry.has('unknown-rule')).toBe(false);
expect(registry.get('unknown-rule')).toBeUndefined();
});
it('warns and keeps the last migration when a ruleId is registered twice', () => {
registry.registerAll();
expect(scopedLogger.warn).toHaveBeenCalledWith(expect.stringContaining('rule-a'));
expect(registry.get('rule-a')).toBe(migADuplicate as unknown as NodeMigration);
});
});
@@ -0,0 +1,237 @@
import { mockLogger } from '@n8n/backend-test-utils';
import type { User, WorkflowEntity } from '@n8n/db';
import type { INode } from 'n8n-workflow';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { NodeTypes } from '@/node-types';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { WorkflowValidationService } from '@/workflows/workflow-validation.service';
import type { WorkflowService } from '@/workflows/workflow.service';
// Stub the heavy module so importing the service under test doesn't pull the
// task-runner import chain; the service receives a typed mock instance anyway.
vi.mock('@/workflows/workflow.service', () => ({ WorkflowService: vi.fn() }));
import { MigrationRegistry } from '../breaking-changes.migration-registry.service';
import { BreakingChangeMigrationService } from '../breaking-changes.migration.service';
import { RuleRegistry } from '../breaking-changes.rule-registry.service';
import {
AiTransformDeprecatedRule,
AI_TRANSFORM_NODE_TYPE,
} from '../rules/v3/ai-transform-deprecated.rule';
import { createNode } from './test-helpers';
describe('BreakingChangeMigrationService', () => {
const logger = mockLogger();
const user = mock<User>({ id: 'user-1' });
const RULE_ID = 'ai-transform-deprecated';
let ruleRegistry: RuleRegistry;
let migrationRegistry: MigrationRegistry;
let workflowFinderService: Mocked<WorkflowFinderService>;
let workflowService: Mocked<WorkflowService>;
let workflowValidationService: Mocked<WorkflowValidationService>;
let nodeTypes: Mocked<NodeTypes>;
let service: BreakingChangeMigrationService;
const buildWorkflow = (nodes: INode[], overrides: Partial<WorkflowEntity> = {}) =>
mock<WorkflowEntity>({ id: 'wf-1', name: 'My WF', nodes, ...overrides });
beforeEach(() => {
vi.clearAllMocks();
ruleRegistry = new RuleRegistry(logger);
ruleRegistry.registerAll([new AiTransformDeprecatedRule()]);
migrationRegistry = new MigrationRegistry(logger);
migrationRegistry.registerAll();
workflowFinderService = mock<WorkflowFinderService>();
workflowService = mock<WorkflowService>();
workflowValidationService = mock<WorkflowValidationService>();
nodeTypes = mock<NodeTypes>();
service = new BreakingChangeMigrationService(
ruleRegistry,
migrationRegistry,
workflowFinderService,
workflowService,
workflowValidationService,
nodeTypes,
logger,
);
});
it('throws when no migration is registered for the rule', async () => {
await expect(service.migrateWorkflow('unknown-rule', 'wf-1', user)).rejects.toThrow(
"No automated migration is available for rule 'unknown-rule'.",
);
});
it('throws when the workflow is not accessible', async () => {
workflowFinderService.findWorkflowForUser.mockResolvedValue(null);
await expect(service.migrateWorkflow(RULE_ID, 'wf-1', user)).rejects.toThrow(
'You do not have permission to update this workflow',
);
expect(workflowService.update).not.toHaveBeenCalled();
});
it('throws when the workflow has no affected nodes', async () => {
const workflow = buildWorkflow([createNode('Set', 'n8n-nodes-base.set', {})]);
workflowFinderService.findWorkflowForUser.mockResolvedValue(workflow);
await expect(service.migrateWorkflow(RULE_ID, 'wf-1', user)).rejects.toThrow(
'no nodes affected',
);
expect(workflowService.update).not.toHaveBeenCalled();
});
it('aborts without saving when an affected node has no generated code', async () => {
const aiNode = createNode('Transform', AI_TRANSFORM_NODE_TYPE, {
instructions: 'x',
jsCode: '',
});
const workflow = buildWorkflow([aiNode]);
workflowFinderService.findWorkflowForUser.mockResolvedValue(workflow);
const error = await service.migrateWorkflow(RULE_ID, 'wf-1', user).catch((e: unknown) => e);
// Carries the offending node so the UI can link to it.
expect(error).toMatchObject({
message: expect.stringContaining('no generated code yet'),
meta: { nodeId: aiNode.id, nodeName: 'Transform' },
});
expect(workflowService.update).not.toHaveBeenCalled();
});
it('aborts the whole workflow (no save) when one of several affected nodes refuses', async () => {
const okNode = createNode('Ok', AI_TRANSFORM_NODE_TYPE, { jsCode: 'return items;' });
const badNode = createNode('Bad', AI_TRANSFORM_NODE_TYPE, { jsCode: '' });
workflowFinderService.findWorkflowForUser.mockResolvedValue(buildWorkflow([okNode, badNode]));
const error = await service.migrateWorkflow(RULE_ID, 'wf-1', user).catch((e: unknown) => e);
expect(error).toMatchObject({ meta: { nodeId: badNode.id, nodeName: 'Bad' } });
// The migratable node is not saved either — the migration is all-or-nothing.
expect(workflowService.update).not.toHaveBeenCalled();
});
it('rewrites the affected node in place and saves a new version', async () => {
const aiNode = createNode('Transform', AI_TRANSFORM_NODE_TYPE, {
jsCode: 'return items;',
instructions: 'x',
});
const otherNode = createNode('Set', 'n8n-nodes-base.set', { value: 1 });
const workflow = buildWorkflow([aiNode, otherNode]);
workflowFinderService.findWorkflowForUser.mockResolvedValue(workflow);
workflowService.update.mockResolvedValue(mock<WorkflowEntity>({ versionId: 'new-version' }));
const result = await service.migrateWorkflow(RULE_ID, 'wf-1', user);
expect(result).toEqual({
workflowId: 'wf-1',
newVersionId: 'new-version',
migratedNodeIds: [aiNode.id],
unmapped: [],
notes: [],
// Not published on this version, so no one-click re-publish is offered.
republishable: false,
});
// A checksum of the fetched workflow is passed so a concurrent edit is
// rejected as a conflict rather than silently overwritten.
const [, updateData, , updateOptions] = workflowService.update.mock.calls[0];
expect(updateOptions).toEqual(
expect.objectContaining({ expectedChecksum: expect.any(String) }),
);
const migrated = updateData.nodes.find((n) => n.id === aiNode.id)!;
// Identity preserved so connections (keyed by node name) stay intact.
expect(migrated.id).toBe(aiNode.id);
expect(migrated.name).toBe('Transform');
expect(migrated.position).toEqual(aiNode.position);
// Type/params rewritten to a Code node.
expect(migrated.type).toBe('n8n-nodes-base.code');
expect(migrated.typeVersion).toBe(2);
expect(migrated.parameters).toEqual({
mode: 'runOnceForAllItems',
language: 'javaScript',
jsCode: 'return items;',
});
// Untouched node stays as-is.
expect(updateData.nodes.find((n) => n.id === otherNode.id)).toEqual(otherNode);
});
it('marks a clean migration republishable when the published version was migrated and it validates', async () => {
const aiNode = createNode('Transform', AI_TRANSFORM_NODE_TYPE, { jsCode: 'return items;' });
// Published version == the version being migrated.
const workflow = buildWorkflow([aiNode], { versionId: 'v1', activeVersionId: 'v1' });
workflowFinderService.findWorkflowForUser.mockResolvedValue(workflow);
workflowService.update.mockResolvedValue(mock<WorkflowEntity>({ versionId: 'new-version' }));
workflowValidationService.validateForActivation.mockReturnValue({ isValid: true });
const result = await service.migrateWorkflow(RULE_ID, 'wf-1', user);
expect(result.republishable).toBe(true);
// The MIGRATED node set (Code, not the original AI Transform) is what gets
// validated, keyed by node name, with the workflow's connections and node types.
expect(workflowValidationService.validateForActivation).toHaveBeenCalledWith(
expect.objectContaining({
Transform: expect.objectContaining({ type: 'n8n-nodes-base.code' }),
}),
workflow.connections,
nodeTypes,
);
});
it('is not republishable when the migration reports warnings, even on the published version', async () => {
const aiNode = createNode('Transform', AI_TRANSFORM_NODE_TYPE, { jsCode: 'return items;' });
const workflow = buildWorkflow([aiNode], { versionId: 'v1', activeVersionId: 'v1' });
workflowFinderService.findWorkflowForUser.mockResolvedValue(workflow);
workflowService.update.mockResolvedValue(mock<WorkflowEntity>({ versionId: 'new-version' }));
// A migration that carries a behavior-change note (not lossless).
vi.spyOn(migrationRegistry, 'get').mockReturnValue({
ruleId: RULE_ID,
migrate: () => ({
node: { type: 'n8n-nodes-base.code', typeVersion: 2, parameters: {} },
notes: ['behavior changed'],
}),
});
const result = await service.migrateWorkflow(RULE_ID, 'wf-1', user);
expect(result.republishable).toBe(false);
// A lossy migration short-circuits before the activation check.
expect(workflowValidationService.validateForActivation).not.toHaveBeenCalled();
});
it('is not republishable when the migrated version was not the published one', async () => {
const aiNode = createNode('Transform', AI_TRANSFORM_NODE_TYPE, { jsCode: 'return items;' });
// A draft (v2) sits ahead of the published version (v1).
const workflow = buildWorkflow([aiNode], { versionId: 'v2', activeVersionId: 'v1' });
workflowFinderService.findWorkflowForUser.mockResolvedValue(workflow);
workflowService.update.mockResolvedValue(mock<WorkflowEntity>({ versionId: 'new-version' }));
workflowValidationService.validateForActivation.mockReturnValue({ isValid: true });
const result = await service.migrateWorkflow(RULE_ID, 'wf-1', user);
expect(result.republishable).toBe(false);
// No point validating a workflow we won't offer to publish.
expect(workflowValidationService.validateForActivation).not.toHaveBeenCalled();
});
it('is not republishable when the migrated workflow cannot be activated', async () => {
const aiNode = createNode('Transform', AI_TRANSFORM_NODE_TYPE, { jsCode: 'return items;' });
const workflow = buildWorkflow([aiNode], { versionId: 'v1', activeVersionId: 'v1' });
workflowFinderService.findWorkflowForUser.mockResolvedValue(workflow);
workflowService.update.mockResolvedValue(mock<WorkflowEntity>({ versionId: 'new-version' }));
// e.g. only a manual trigger, so activation validation fails.
workflowValidationService.validateForActivation.mockReturnValue({
isValid: false,
error: 'no trigger',
});
const result = await service.migrateWorkflow(RULE_ID, 'wf-1', user);
expect(result.republishable).toBe(false);
});
});
@@ -8,6 +8,7 @@ import { mock } from 'vitest-mock-extended';
import type { CacheService } from '@/services/cache/cache.service';
import { N8N_VERSION } from '../../../constants';
import { MigrationRegistry } from '../breaking-changes.migration-registry.service';
import { RuleRegistry } from '../breaking-changes.rule-registry.service';
import { BreakingChangeService } from '../breaking-changes.service';
import { createNode, createWorkflow } from './test-helpers';
@@ -46,6 +47,7 @@ describe('BreakingChangeService', () => {
service = new BreakingChangeService(
ruleRegistry,
new MigrationRegistry(logger),
workflowRepository,
workflowStatisticsRepository,
cacheService,
@@ -0,0 +1,20 @@
import { groupNodesByType } from '../group-nodes-by-type';
import { createNode } from './test-helpers';
describe('groupNodesByType', () => {
it('groups nodes by their type, preserving order within a type', () => {
const set1 = createNode('Set1', 'n8n-nodes-base.set');
const code = createNode('Code', 'n8n-nodes-base.code');
const set2 = createNode('Set2', 'n8n-nodes-base.set');
const grouped = groupNodesByType([set1, code, set2]);
expect(grouped.size).toBe(2);
expect(grouped.get('n8n-nodes-base.set')).toEqual([set1, set2]);
expect(grouped.get('n8n-nodes-base.code')).toEqual([code]);
});
it('returns an empty map for no nodes', () => {
expect(groupNodesByType([]).size).toBe(0);
});
});
@@ -4,6 +4,7 @@ import {
BreakingChangeReportQueryDto,
BreakingChangeReportResult,
BreakingChangeWorkflowRuleResult,
WorkflowMigrationResult,
} from '@n8n/api-types';
import { AuthenticatedRequest } from '@n8n/db';
import { Get, RestController, GlobalScope, Query, Post, Param } from '@n8n/decorators';
@@ -11,11 +12,15 @@ import { Response } from 'express';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { BreakingChangeMigrationService } from './breaking-changes.migration.service';
import { BreakingChangeService } from './breaking-changes.service';
@RestController('/breaking-changes')
export class BreakingChangesController {
constructor(private readonly service: BreakingChangeService) {}
constructor(
private readonly service: BreakingChangeService,
private readonly migrationService: BreakingChangeMigrationService,
) {}
private getLightDetectionResults(
report: BreakingChangeReportResult['report'],
@@ -76,4 +81,19 @@ export class BreakingChangesController {
}
return result;
}
/**
* Apply the rule's automated migration to a single workflow, saving the
* rewritten workflow as a new version.
*/
@Post('/report/:ruleId/workflows/:workflowId/migrate')
@GlobalScope('breakingChanges:migrate')
async migrateWorkflow(
req: AuthenticatedRequest,
_res: Response,
@Param('ruleId') ruleId: string,
@Param('workflowId') workflowId: string,
): Promise<WorkflowMigrationResult> {
return await this.migrationService.migrateWorkflow(ruleId, workflowId, req.user);
}
}
@@ -0,0 +1,39 @@
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { nodeMigrations } from './migrations';
import type { NodeMigration } from './migrations/node-migration';
/**
* Holds the node migrations, keyed by the breaking-change rule id that detects
* the deprecated node. Detection (rules) and transformation (migrations) stay
* decoupled: a rule can exist with no migration, in which case the report falls
* back to prose recommendations.
*/
@Service()
export class MigrationRegistry {
private readonly migrations = new Map<string, NodeMigration>();
constructor(private readonly logger: Logger) {
this.logger = logger.scoped('breaking-changes');
}
registerAll(): void {
for (const migration of nodeMigrations) {
if (this.migrations.has(migration.ruleId)) {
this.logger.warn(
`Migration for rule ${migration.ruleId} is already registered. Overwriting.`,
);
}
this.migrations.set(migration.ruleId, migration);
}
}
get(ruleId: string): NodeMigration | undefined {
return this.migrations.get(ruleId);
}
has(ruleId: string): boolean {
return this.migrations.has(ruleId);
}
}
@@ -0,0 +1,158 @@
import type { WorkflowMigrationResult } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { calculateWorkflowChecksum } from 'n8n-workflow';
import type { INode } from 'n8n-workflow';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { NodeTypes } from '@/node-types';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowValidationService } from '@/workflows/workflow-validation.service';
import { WorkflowService } from '@/workflows/workflow.service';
import { MigrationRegistry } from './breaking-changes.migration-registry.service';
import { RuleRegistry } from './breaking-changes.rule-registry.service';
import { groupNodesByType } from './group-nodes-by-type';
/**
* A migration refused a specific node. Carries the node identity in `meta` so the
* UI can link straight to it.
*/
export class WorkflowMigrationNodeError extends BadRequestError {
constructor(
message: string,
readonly meta: { nodeId: string; nodeName: string },
) {
super(message);
this.name = 'WorkflowMigrationNodeError';
}
}
@Service()
export class BreakingChangeMigrationService {
constructor(
private readonly ruleRegistry: RuleRegistry,
private readonly migrationRegistry: MigrationRegistry,
private readonly workflowFinderService: WorkflowFinderService,
private readonly workflowService: WorkflowService,
private readonly workflowValidationService: WorkflowValidationService,
private readonly nodeTypes: NodeTypes,
private readonly logger: Logger,
) {
this.logger = logger.scoped('breaking-changes');
}
/**
* Rewrites a single workflow to swap the nodes a rule flags as deprecated for
* their replacement, then saves the result as a new workflow version.
*/
async migrateWorkflow(
ruleId: string,
workflowId: string,
user: User,
): Promise<WorkflowMigrationResult> {
const migration = this.migrationRegistry.get(ruleId);
if (!migration) {
throw new BadRequestError(`No automated migration is available for rule '${ruleId}'.`);
}
const rule = this.ruleRegistry.getRule(ruleId);
if (!rule || !('detectWorkflow' in rule)) {
throw new BadRequestError(`Rule '${ruleId}' does not support automated migration.`);
}
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:update',
]);
if (!workflow) {
throw new NotFoundError(
'You do not have permission to update this workflow. Ask the owner to share it with you.',
);
}
// Whether the version we're about to migrate is the one currently published.
// Only then may we offer a one-click re-publish: a draft ahead of the
// published version must not be published without the user reviewing it.
const wasPublishedVersion =
!!workflow.activeVersionId && workflow.activeVersionId === workflow.versionId;
// Detection decides which nodes to migrate, so the transform stays aligned with the report.
const detection = await rule.detectWorkflow(workflow, groupNodesByType(workflow.nodes));
const affectedNodeIds = new Set(
detection.issues.map((issue) => issue.nodeId).filter((id): id is string => Boolean(id)),
);
if (affectedNodeIds.size === 0) {
throw new BadRequestError('This workflow has no nodes affected by the selected rule.');
}
const unmapped: string[] = [];
const notes: string[] = [];
const migratedNodeIds: string[] = [];
const nodes = workflow.nodes.map((node) => {
if (!affectedNodeIds.has(node.id)) return node;
// A node the migration refuses aborts the whole workflow before any save.
let result;
try {
result = migration.migrate(node);
} catch (error) {
throw new WorkflowMigrationNodeError(
error instanceof Error ? error.message : 'Migration failed.',
{ nodeId: node.id, nodeName: node.name },
);
}
migratedNodeIds.push(node.id);
if (result.unmapped?.length) unmapped.push(...result.unmapped);
if (result.notes?.length) notes.push(...result.notes);
// Keep id/name/position so connections (keyed by node name) stay intact.
return {
...node,
type: result.node.type,
typeVersion: result.node.typeVersion,
parameters: result.node.parameters,
} satisfies INode;
});
// Best-effort hint for a one-click re-publish: clean migration of the published
// version that still validates for activation. It's a subset of the full activation
// gate, so publishing can still fail (handled gracefully in the UI).
const republishable =
unmapped.length === 0 &&
notes.length === 0 &&
wasPublishedVersion &&
this.workflowValidationService.validateForActivation(
Object.fromEntries(nodes.map((node) => [node.name, node])),
workflow.connections,
this.nodeTypes,
).isValid;
// Checksum of the workflow as fetched, so a concurrent edit landing between
// this read and the write below is rejected as a conflict rather than clobbered.
const expectedChecksum = await calculateWorkflowChecksum(workflow);
workflow.nodes = nodes;
const updated = await this.workflowService.update(user, workflow, workflowId, {
versionName: 'Automated node migration',
expectedChecksum,
});
this.logger.info('Applied automated node migration', {
ruleId,
workflowId,
migratedNodeIds,
});
return {
workflowId,
newVersionId: updated.versionId,
migratedNodeIds,
unmapped,
notes,
republishable,
};
}
}
@@ -15,6 +15,10 @@ export class BreakingChangesModule implements ModuleInterface {
const { BreakingChangeService } = await import('./breaking-changes.service.js');
Container.get(BreakingChangeService).registerRules();
// Register the node migrations keyed by rule id
const { MigrationRegistry } = await import('./breaking-changes.migration-registry.service.js');
Container.get(MigrationRegistry).registerAll();
await import('./breaking-changes.controller.js');
}
}
@@ -12,11 +12,12 @@ import { BreakingChangeRuleMetadata } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import { In } from '@n8n/typeorm';
import { ErrorReporter } from 'n8n-core';
import type { INode } from 'n8n-workflow';
import { CacheService } from '@/services/cache/cache.service';
import { MigrationRegistry } from './breaking-changes.migration-registry.service';
import { RuleRegistry } from './breaking-changes.rule-registry.service';
import { groupNodesByType } from './group-nodes-by-type';
import type {
IBreakingChangeBatchWorkflowRule,
IBreakingChangeInstanceRule,
@@ -45,6 +46,7 @@ export class BreakingChangeService {
constructor(
private readonly ruleRegistry: RuleRegistry,
private readonly migrationRegistry: MigrationRegistry,
private readonly workflowRepository: WorkflowRepository,
private readonly workflowStatisticsRepository: WorkflowStatisticsRepository,
private readonly cacheService: CacheService,
@@ -78,6 +80,7 @@ export class BreakingChangeService {
ruleDocumentationUrl: rule.getMetadata().documentationUrl,
instanceIssues: ruleResult.instanceIssues,
recommendations: ruleResult.recommendations,
migratable: this.migrationRegistry.has(rule.id),
});
}
} catch (error) {
@@ -88,17 +91,6 @@ export class BreakingChangeService {
return instanceLevelResults;
}
private groupNodesByType(nodes: INode[]): Map<string, INode[]> {
const nodesGroupedByType: Map<string, INode[]> = new Map();
for (const node of nodes) {
if (!nodesGroupedByType.has(node.type)) {
nodesGroupedByType.set(node.type, []);
}
nodesGroupedByType.get(node.type)!.push(node);
}
return nodesGroupedByType;
}
private async aggregateRegularRuleResults(
workflowLevelRules: IBreakingChangeWorkflowRule[],
allAffectedWorkflowsByRule: Map<string, BreakingChangeAffectedWorkflow[]>,
@@ -117,6 +109,7 @@ export class BreakingChangeService {
ruleDocumentationUrl: rule.getMetadata().documentationUrl,
affectedWorkflows: workflowResults,
recommendations: await rule.getRecommendations(workflowResults),
migratable: this.migrationRegistry.has(rule.id),
});
}
}
@@ -165,6 +158,7 @@ export class BreakingChangeService {
ruleDocumentationUrl: rule.getMetadata().documentationUrl,
affectedWorkflows,
recommendations: await rule.getRecommendations(affectedWorkflows),
migratable: this.migrationRegistry.has(rule.id),
});
}
}
@@ -218,7 +212,7 @@ export class BreakingChangeService {
}
for (const workflow of workflows) {
const nodesGroupedByType = this.groupNodesByType(workflow.nodes);
const nodesGroupedByType = groupNodesByType(workflow.nodes);
const statistics = statisticsByWorkflowId.get(workflow.id) ?? [];
const workflowMetadata: WorkflowMetadata = {
@@ -0,0 +1,12 @@
import type { INode } from 'n8n-workflow';
/** Group a workflow's nodes by their `type`, as the workflow rules expect. */
export function groupNodesByType(nodes: INode[]): Map<string, INode[]> {
const grouped = new Map<string, INode[]>();
for (const node of nodes) {
const existing = grouped.get(node.type);
if (existing) existing.push(node);
else grouped.set(node.type, [node]);
}
return grouped;
}
@@ -0,0 +1,42 @@
import { createNode } from '../../__tests__/test-helpers';
import { AI_TRANSFORM_NODE_TYPE } from '../../rules/v3/ai-transform-deprecated.rule';
import { aiTransformToCode } from '../ai-transform-to-code.migration';
describe('aiTransformToCode migration', () => {
it('is keyed by the AI Transform rule id', () => {
expect(aiTransformToCode.ruleId).toBe('ai-transform-deprecated');
});
it('rewrites AI Transform to a Code node carrying jsCode verbatim', () => {
const node = createNode('Transform', AI_TRANSFORM_NODE_TYPE, {
jsCode: 'return [{ json: { ok: true } }];',
instructions: 'do a thing',
codeGeneratedForPrompt: 'do a thing',
});
const result = aiTransformToCode.migrate(node);
expect(result.node).toEqual({
type: 'n8n-nodes-base.code',
typeVersion: 2,
parameters: {
mode: 'runOnceForAllItems',
language: 'javaScript',
jsCode: 'return [{ json: { ok: true } }];',
},
});
// Lossless: nothing dropped, nothing to warn about.
expect(result.unmapped).toBeUndefined();
expect(result.notes).toBeUndefined();
});
it('refuses to migrate a node whose prompt was never turned into code', () => {
// Prompt entered but "Generate code" never clicked → jsCode is empty.
const node = createNode('Transform', AI_TRANSFORM_NODE_TYPE, {
instructions: 'Double the value',
jsCode: '',
});
expect(() => aiTransformToCode.migrate(node)).toThrow('no generated code yet');
});
});
@@ -0,0 +1,36 @@
import type { NodeMigration } from './node-migration';
/**
* AI Transform → Code. Lossless when the node has generated code: both nodes run
* the same JsTaskRunnerSandbox and store it under `jsCode`, and AI Transform always
* runs once for all items. The AI-authoring-only params (`instructions`,
* `codeGeneratedForPrompt`) are dropped because they have no runtime effect on the
* Code node.
*
* A node whose prompt was never turned into code (`jsCode` empty) has nothing to
* carry over and already errors at runtime, so we refuse rather than silently
* produce an empty Code node and lose the prompt.
*/
export const aiTransformToCode: NodeMigration = {
ruleId: 'ai-transform-deprecated',
migrate: (node) => {
const jsCode = node.parameters.jsCode;
if (!jsCode) {
throw new Error(
'This AI Transform node has no generated code yet. Open it and click "Generate code" (or replace it with a Code node manually) before migrating.',
);
}
return {
node: {
type: 'n8n-nodes-base.code',
typeVersion: 2,
parameters: {
mode: 'runOnceForAllItems',
language: 'javaScript',
jsCode,
},
},
};
},
};
@@ -0,0 +1,5 @@
import { aiTransformToCode } from './ai-transform-to-code.migration';
import type { NodeMigration } from './node-migration';
// All registered node migrations. A rule is auto-migratable only if it appears here.
export const nodeMigrations: NodeMigration[] = [aiTransformToCode];
@@ -0,0 +1,21 @@
import type { INode } from 'n8n-workflow';
export interface NodeMigrationResult {
// The replacement node's type/version/parameters. The rewrite engine keeps
// the original node's id, name, and position, so connections are preserved.
node: Pick<INode, 'type' | 'typeVersion' | 'parameters'>;
// Parameters that could not be carried over (engine warns, keeps original).
unmapped?: string[];
// Behavior/output changes to surface to the user.
notes?: string[];
}
/**
* A per-node transform that swaps a deprecated node for its replacement.
* Keyed by the breaking-change rule id that detects the deprecated node.
*/
export interface NodeMigration {
ruleId: string;
// Pure, per node. Return the replacement node; throw to abort with an error.
migrate(node: INode): NodeMigrationResult;
}
@@ -20,6 +20,7 @@ import './v2/wait-node-subworkflow.rule';
import './v2/workflow-hooks-deprecated.rule';
// v3 rules
import './v3/ai-transform-deprecated.rule';
import './v3/always-output-data-multi-output.rule';
import './v3/chat-trigger-embedded-json.rule';
import './v3/compression-node-limits.rule';
@@ -0,0 +1,39 @@
import { createNode, createWorkflow } from '../../../__tests__/test-helpers';
import { AI_TRANSFORM_NODE_TYPE, AiTransformDeprecatedRule } from '../ai-transform-deprecated.rule';
describe('AiTransformDeprecatedRule', () => {
let rule: AiTransformDeprecatedRule;
beforeEach(() => {
rule = new AiTransformDeprecatedRule();
});
describe('detectWorkflow()', () => {
it('should not be affected when no AI Transform node is present', async () => {
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
createNode('Code', 'n8n-nodes-base.code', { jsCode: 'return items;' }),
]);
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
expect(result.isAffected).toBe(false);
expect(result.issues).toHaveLength(0);
});
it('should detect each AI Transform node', async () => {
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
createNode('Transform A', AI_TRANSFORM_NODE_TYPE, { jsCode: 'return items;' }),
createNode('Transform B', AI_TRANSFORM_NODE_TYPE, { jsCode: 'return [];' }),
createNode('Set', 'n8n-nodes-base.set', {}),
]);
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
expect(result.isAffected).toBe(true);
expect(result.issues).toHaveLength(2);
expect(result.issues.map((i) => i.nodeName)).toEqual(['Transform A', 'Transform B']);
expect(result.issues[0].level).toBe('warning');
expect(result.issues[0].nodeId).toBe('node-Transform A');
});
});
});
@@ -0,0 +1,63 @@
import type { BreakingChangeAffectedWorkflow, BreakingChangeRecommendation } from '@n8n/api-types';
import type { WorkflowEntity } from '@n8n/db';
import { BreakingChangeRule } from '@n8n/decorators';
import type { INode } from 'n8n-workflow';
import type {
BreakingChangeRuleMetadata,
IBreakingChangeWorkflowRule,
WorkflowDetectionReport,
} from '../../types';
import { BreakingChangeCategory } from '../../types';
export const AI_TRANSFORM_NODE_TYPE = 'n8n-nodes-base.aiTransform';
@BreakingChangeRule({ version: 'v3' })
export class AiTransformDeprecatedRule implements IBreakingChangeWorkflowRule {
id: string = 'ai-transform-deprecated';
getMetadata(): BreakingChangeRuleMetadata {
return {
version: 'v3',
title: 'AI Transform node is deprecated',
description:
'The AI Transform node is deprecated. Its generated code runs as a Code node instead.',
category: BreakingChangeCategory.workflow,
severity: 'medium',
};
}
// eslint-disable-next-line @typescript-eslint/require-await
async getRecommendations(
_workflowResults: BreakingChangeAffectedWorkflow[],
): Promise<BreakingChangeRecommendation[]> {
return [
{
action: 'Replace AI Transform with a Code node',
description:
'The AI Transform node runs its generated JavaScript in the same sandbox as the Code node. Migrate it to a Code node to keep it working.',
},
];
}
// eslint-disable-next-line @typescript-eslint/require-await
async detectWorkflow(
_workflow: WorkflowEntity,
nodesGroupedByType: Map<string, INode[]>,
): Promise<WorkflowDetectionReport> {
const affectedNodes = nodesGroupedByType.get(AI_TRANSFORM_NODE_TYPE) ?? [];
if (affectedNodes.length === 0) return { isAffected: false, issues: [] };
return {
isAffected: true,
issues: affectedNodes.map((node) => ({
title: `Node '${node.name}' uses the deprecated AI Transform node`,
description:
'The AI Transform node is deprecated. Migrate it to a Code node to keep it working.',
level: 'warning',
nodeId: node.id,
nodeName: node.name,
})),
};
}
}
@@ -4063,6 +4063,23 @@
"settings.migrationReport.detail.filter.status.all": "All",
"settings.migrationReport.detail.filter.status.active": "Active",
"settings.migrationReport.detail.filter.status.deactivated": "Deactivated",
"settings.migrationReport.detail.migrate.button": "Migrate",
"settings.migrationReport.detail.migrate.migrated": "Migrated",
"settings.migrationReport.detail.migrate.error.title": "Could not migrate workflow",
"settings.migrationReport.detail.migrate.modal.title": "Migrate \"{name}\"",
"settings.migrationReport.detail.migrate.modal.confirmLead": "What will change:",
"settings.migrationReport.detail.migrate.modal.stepMigrate": "Migrate saves the changes as a new version. Your published version keeps running unchanged.",
"settings.migrationReport.detail.migrate.modal.stepPublish": "Publish makes the migrated version live — a separate step, after you migrate.",
"settings.migrationReport.detail.migrate.modal.confirmButton": "Migrate",
"settings.migrationReport.detail.migrate.modal.successBody": "\"{name}\" was migrated and saved as a new version.",
"settings.migrationReport.detail.migrate.modal.openWorkflow": "Open workflow",
"settings.migrationReport.detail.migrate.modal.reviewTitle": "Review these changes",
"settings.migrationReport.detail.migrate.modal.reviewNodes": "Affected nodes:",
"settings.migrationReport.detail.migrate.modal.done": "Done",
"settings.migrationReport.detail.migrate.publish.button": "Publish",
"settings.migrationReport.detail.migrate.publish.skip": "Skip publishing",
"settings.migrationReport.detail.migrate.publish.published": "Published to the migrated version",
"settings.migrationReport.detail.migrate.publish.error.title": "Could not publish workflow",
"showMessage.cancel": "@:_reusableBaseText.cancel",
"showMessage.ok": "OK",
"showMessage.showDetails": "Show Details",
@@ -2,6 +2,7 @@ import type {
BreakingChangeLightReportResult,
BreakingChangeWorkflowRuleResult,
BreakingChangeVersion,
WorkflowMigrationResult,
} from '@n8n/api-types';
import type { IRestApiContext } from '../types';
@@ -35,3 +36,15 @@ export async function getReportForRule(
): Promise<BreakingChangeWorkflowRuleResult> {
return (await get(context.baseUrl, `/breaking-changes/report/${ruleId}`)).data;
}
export async function migrateWorkflowForRule(
context: IRestApiContext,
ruleId: string,
workflowId: string,
): Promise<WorkflowMigrationResult> {
return await makeRestApiRequest(
context,
'POST',
`/breaking-changes/report/${ruleId}/workflows/${workflowId}/migrate`,
);
}
@@ -33,6 +33,7 @@ import {
BINARY_DATA_VIEW_MODAL_KEY,
STOP_MANY_EXECUTIONS_MODAL_KEY,
ADD_EXECUTION_TO_DATASET_MODAL_KEY,
MIGRATE_WORKFLOW_MODAL_KEY,
WORKFLOW_DESCRIPTION_MODAL_KEY,
WORKFLOW_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_PUBLISH_MODAL_KEY,
@@ -135,6 +136,7 @@ import StopManyExecutionsModal from './StopManyExecutionsModal.vue';
import AddExecutionToDatasetModal from '@/features/ai/evaluation.ee/components/AddExecutionToDataset/AddExecutionToDatasetModal.vue';
import WorkflowDescriptionModal from '@/app/components/WorkflowDescriptionModal.vue';
import WorkflowPublishModal from '@/app/components/MainHeader/WorkflowPublishModal.vue';
import MigrateWorkflowModal from '@/features/settings/migrationReport/MigrateWorkflowModal.vue';
import UpdatesPanel from './UpdatesPanel.vue';
import CredentialResolverEditModal from '@/app/components/CredentialResolverEditModal.vue';
import AIBuilderDiffModal from '@/features/ai/assistant/components/Agent/AIBuilderDiffModal.vue';
@@ -516,6 +518,12 @@ import InstanceAiToolsConnectionModalWrapper from '@/features/ai/instanceAi/comp
</template>
</ModalRoot>
<ModalRoot :name="MIGRATE_WORKFLOW_MODAL_KEY">
<template #default="{ modalName, data }">
<MigrateWorkflowModal :modal-name="modalName" :data="data" />
</template>
</ModalRoot>
<ModalRoot :name="WORKFLOW_HISTORY_PUBLISH_MODAL_KEY">
<template #default="{ modalName, data }">
<WorkflowVersionFormModal
@@ -49,3 +49,4 @@ export const INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY = 'instanceAiBrowserUseSetu
export const INSTANCE_AI_TOOLS_CONNECTION_MODAL_KEY = 'instanceAiToolsConnection';
export const AGENT_CONFIRMATION_MODAL_KEY = 'agentConfirmation';
export const ADD_EXECUTION_TO_DATASET_MODAL_KEY = 'addExecutionToDataset';
export const MIGRATE_WORKFLOW_MODAL_KEY = 'migrateWorkflow';
@@ -45,6 +45,7 @@ import {
AI_GATEWAY_TOP_UP_MODAL_KEY,
AGENT_CONFIRMATION_MODAL_KEY,
ADD_EXECUTION_TO_DATASET_MODAL_KEY,
MIGRATE_WORKFLOW_MODAL_KEY,
} from '@/app/constants';
import {
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
@@ -177,6 +178,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
INSTANCE_AI_TOOLS_CONNECTION_MODAL_KEY,
AI_GATEWAY_TOP_UP_MODAL_KEY,
AGENT_CONFIRMATION_MODAL_KEY,
MIGRATE_WORKFLOW_MODAL_KEY,
].map((modalKey) => [modalKey, { open: false }]),
),
[DELETE_USER_MODAL_KEY]: {
@@ -0,0 +1,185 @@
import { createTestingPinia } from '@pinia/testing';
import { screen, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { createEventBus, type EventBus } from '@n8n/utils/event-bus';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { ResponseError } from '@n8n/rest-api-client';
import { MIGRATE_WORKFLOW_MODAL_KEY } from '@/app/constants';
import * as breakingChangesApi from '@n8n/rest-api-client/api/breaking-changes';
import MigrateWorkflowModal from './MigrateWorkflowModal.vue';
vi.mock('@n8n/rest-api-client/api/breaking-changes', () => ({
migrateWorkflowForRule: vi.fn(),
}));
const workflow = {
id: 'workflow-1',
name: 'Test Workflow',
active: false,
numberOfExecutions: 0,
lastUpdatedAt: new Date('2024-01-15'),
issues: [
{
nodeId: 'node-1',
nodeName: 'AI Transform',
title: 'Deprecated',
description: '',
level: 'error' as const,
},
],
};
const migrationResult = {
workflowId: 'workflow-1',
newVersionId: 'new-version-1234',
migratedNodeIds: ['node-1'],
unmapped: [],
notes: [],
republishable: false,
};
const recommendations = [
{ action: 'Replace AI Transform with a Code node', description: 'Runs in the same sandbox.' },
];
let rootStore: ReturnType<typeof mockedStore<typeof useRootStore>>;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let eventBus: EventBus;
const renderComponent = createComponentRenderer(MigrateWorkflowModal, {
pinia: createTestingPinia(),
global: {
stubs: {
// Render all slots directly so the modal body/footer are queryable
// without the open-state + teleport machinery of the real Modal.
Modal: {
template:
'<div role="dialog"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
},
},
},
});
function render() {
eventBus = createEventBus();
return renderComponent({
props: {
modalName: MIGRATE_WORKFLOW_MODAL_KEY,
data: { ruleId: 'rule-1', workflow, recommendations, eventBus },
},
});
}
describe('MigrateWorkflowModal', () => {
beforeEach(() => {
vi.clearAllMocks();
rootStore = mockedStore(useRootStore);
rootStore.restApiContext = { baseUrl: 'http://localhost:5678', pushRef: 'test' };
workflowsStore = mockedStore(useWorkflowsStore);
vi.mocked(breakingChangesApi.migrateWorkflowForRule).mockResolvedValue(migrationResult);
});
it('closes without migrating when cancelled', async () => {
const onClose = vi.fn();
render();
eventBus.on('close', onClose);
await userEvent.click(screen.getByTestId('migrate-modal-cancel-button'));
expect(onClose).toHaveBeenCalled();
expect(breakingChangesApi.migrateWorkflowForRule).not.toHaveBeenCalled();
});
it('shows the concrete change (rule recommendation) before confirming', () => {
render();
expect(screen.getByText('Replace AI Transform with a Code node')).toBeInTheDocument();
expect(screen.getByText('Runs in the same sandbox.')).toBeInTheDocument();
});
it('migrates on confirm, shows success and reports back on the bus', async () => {
const onMigrated = vi.fn();
render();
eventBus.on('migrated', onMigrated);
await userEvent.click(screen.getByTestId('migrate-modal-confirm-button'));
await waitFor(() =>
expect(breakingChangesApi.migrateWorkflowForRule).toHaveBeenCalledWith(
rootStore.restApiContext,
'rule-1',
'workflow-1',
),
);
expect(onMigrated).toHaveBeenCalledWith({ workflowId: 'workflow-1' });
// Success state: confirmation copy, an Open workflow link (resolving to a real
// href, not "[object Object]"), and a Done action.
expect(await screen.findByText(/was migrated and saved as a new version/)).toBeInTheDocument();
expect(screen.getByText('Open workflow').closest('a')).toHaveAttribute(
'href',
'/workflow/workflow-1',
);
expect(screen.getByTestId('migrate-modal-done-button')).toBeInTheDocument();
});
it('offers Publish for a republishable migration and publishes on click', async () => {
vi.mocked(breakingChangesApi.migrateWorkflowForRule).mockResolvedValue({
...migrationResult,
republishable: true,
});
render();
await userEvent.click(screen.getByTestId('migrate-modal-confirm-button'));
// Both CTAs make the two-step (migrate → publish) explicit.
expect(await screen.findByTestId('migrate-modal-skip-publish-button')).toBeInTheDocument();
await userEvent.click(screen.getByTestId('migrate-modal-publish-button'));
await waitFor(() =>
expect(workflowsStore.publishWorkflow).toHaveBeenCalledWith('workflow-1', {
versionId: 'new-version-1234',
}),
);
});
it('does not offer Publish when the migration is not republishable', async () => {
render();
await userEvent.click(screen.getByTestId('migrate-modal-confirm-button'));
await screen.findByTestId('migrate-modal-done-button');
expect(screen.queryByTestId('migrate-modal-publish-button')).not.toBeInTheDocument();
});
it('surfaces migration warnings for review', async () => {
vi.mocked(breakingChangesApi.migrateWorkflowForRule).mockResolvedValue({
...migrationResult,
notes: ['Include Binary File was carried over — review it manually.'],
});
render();
await userEvent.click(screen.getByTestId('migrate-modal-confirm-button'));
expect(
await screen.findByText('Include Binary File was carried over — review it manually.'),
).toBeInTheDocument();
});
it('shows the error (with a node link) when the migration is refused', async () => {
vi.mocked(breakingChangesApi.migrateWorkflowForRule).mockRejectedValue(
new ResponseError('This node has no generated code yet.', {
httpStatusCode: 400,
meta: { nodeId: 'node-1', nodeName: 'AI Transform' },
}),
);
render();
await userEvent.click(screen.getByTestId('migrate-modal-confirm-button'));
expect(await screen.findByText('This node has no generated code yet.')).toBeInTheDocument();
expect(screen.getByTestId('migrate-modal-close-button')).toBeInTheDocument();
});
});
@@ -0,0 +1,310 @@
<script lang="ts" setup>
import Modal from '@/app/components/Modal.vue';
import { MIGRATE_WORKFLOW_MODAL_KEY } from '@/app/constants';
import type {
BreakingChangeRecommendation,
BreakingChangeWorkflowRuleResult,
WorkflowMigrationResult,
} from '@n8n/api-types';
import { N8nButton, N8nCallout, N8nHeading, N8nLink, N8nText } from '@n8n/design-system';
import * as breakingChangesApi from '@n8n/rest-api-client/api/breaking-changes';
import { ResponseError } from '@n8n/rest-api-client';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useToast } from '@/app/composables/useToast';
import type { EventBus } from '@n8n/utils/event-bus';
import { computed, ref } from 'vue';
type AffectedWorkflow = BreakingChangeWorkflowRuleResult['affectedWorkflows'][number];
const props = defineProps<{
modalName: string;
data: {
ruleId: string;
workflow: AffectedWorkflow;
// The rule's recommendations, shown as the concrete "what will change" detail.
recommendations: BreakingChangeRecommendation[];
// Emitted back to the report so the row can reflect the migrated state.
eventBus: EventBus;
};
}>();
const i18n = useI18n();
const toast = useToast();
const workflowsStore = useWorkflowsStore();
const workflow = computed(() => props.data.workflow);
type Phase = 'confirm' | 'success' | 'error';
const phase = ref<Phase>('confirm');
const migrating = ref(false);
const result = ref<WorkflowMigrationResult | null>(null);
// The failure message, plus the node a migration refused (so the error state can link to it).
const error = ref<{ message: string; node?: { id: string; name: string } } | null>(null);
const publishing = ref(false);
const published = ref(false);
// Names of the migrated nodes, resolved from the report's issues, for review links.
const migratedNodes = computed(() =>
(result.value?.migratedNodeIds ?? []).map((id) => ({
id,
name: workflow.value.issues.find((issue) => issue.nodeId === id)?.nodeName ?? id,
})),
);
const warnings = computed(() => [
...(result.value?.notes ?? []),
...(result.value?.unmapped ?? []),
]);
async function handleMigrate() {
migrating.value = true;
try {
result.value = await breakingChangesApi.migrateWorkflowForRule(
useRootStore().restApiContext,
props.data.ruleId,
workflow.value.id,
);
props.data.eventBus.emit('migrated', { workflowId: workflow.value.id });
phase.value = 'success';
} catch (e) {
// A migration that refuses a specific node tells us which one — surface it so
// the user can jump straight there.
const meta = e instanceof ResponseError ? e.meta : undefined;
error.value = {
message: e instanceof Error ? e.message : String(e),
node:
typeof meta?.nodeId === 'string'
? {
id: meta.nodeId,
name: typeof meta.nodeName === 'string' ? meta.nodeName : meta.nodeId,
}
: undefined,
};
phase.value = 'error';
} finally {
migrating.value = false;
}
}
async function handlePublish() {
if (!result.value) return;
publishing.value = true;
try {
await workflowsStore.publishWorkflow(workflow.value.id, {
versionId: result.value.newVersionId,
});
published.value = true;
} catch (e) {
toast.showError(
e,
i18n.baseText('settings.migrationReport.detail.migrate.publish.error.title'),
);
} finally {
publishing.value = false;
}
}
function close() {
props.data.eventBus.emit('close');
}
</script>
<template>
<Modal
:name="MIGRATE_WORKFLOW_MODAL_KEY"
:event-bus="data.eventBus"
:center="true"
:close-on-click-modal="false"
width="540px"
>
<template #header>
<N8nHeading size="xlarge">
{{
i18n.baseText('settings.migrationReport.detail.migrate.modal.title', {
interpolate: { name: workflow.name },
})
}}
</N8nHeading>
</template>
<template #content>
<div :class="$style.content">
<!-- Confirmation -->
<template v-if="phase === 'confirm'">
<div v-if="data.recommendations.length" :class="$style.section">
<N8nText color="text-dark">
{{ i18n.baseText('settings.migrationReport.detail.migrate.modal.confirmLead') }}
</N8nText>
<ul :class="$style.changeList">
<li v-for="(rec, index) in data.recommendations" :key="index" :class="$style.change">
<N8nText size="small" color="text-dark">{{ rec.action }}</N8nText>
<N8nText size="small" color="text-base">{{ rec.description }}</N8nText>
</li>
</ul>
</div>
<!-- Migrate and publish are two separate steps; make that explicit so users
don't assume migrating changes the live version. -->
<N8nCallout theme="info">
<div>
{{ i18n.baseText('settings.migrationReport.detail.migrate.modal.stepMigrate') }}
</div>
<div>
{{ i18n.baseText('settings.migrationReport.detail.migrate.modal.stepPublish') }}
</div>
</N8nCallout>
</template>
<!-- Success -->
<template v-else-if="phase === 'success' && result">
<N8nCallout theme="success" icon="circle-check">
{{
i18n.baseText('settings.migrationReport.detail.migrate.modal.successBody', {
interpolate: { name: workflow.name },
})
}}
<N8nLink :to="`/workflow/${workflow.id}`" new-window size="small">
{{ i18n.baseText('settings.migrationReport.detail.migrate.modal.openWorkflow') }}
</N8nLink>
</N8nCallout>
<!-- Behaviour changes the user should review -->
<N8nCallout v-if="warnings.length" theme="warning">
<div :class="$style.reviewTitle">
{{ i18n.baseText('settings.migrationReport.detail.migrate.modal.reviewTitle') }}
</div>
<ul :class="$style.warningList">
<li v-for="(warning, index) in warnings" :key="index">{{ warning }}</li>
</ul>
<div :class="$style.reviewNodes">
{{ i18n.baseText('settings.migrationReport.detail.migrate.modal.reviewNodes') }}
<template v-for="(node, index) in migratedNodes" :key="node.id">
<N8nLink :to="`/workflow/${workflow.id}/${node.id}`" new-window size="small">{{
node.name
}}</N8nLink
><template v-if="index < migratedNodes.length - 1">, </template>
</template>
</div>
</N8nCallout>
<N8nText v-if="published" size="small" color="success">
{{ i18n.baseText('settings.migrationReport.detail.migrate.publish.published') }}
</N8nText>
</template>
<!-- Error -->
<template v-else-if="phase === 'error' && error">
<N8nCallout theme="danger" icon="status-error">
{{ error.message }}
<template v-if="error.node">
<br />
<N8nLink :to="`/workflow/${workflow.id}/${error.node.id}`" new-window size="small">{{
error.node.name
}}</N8nLink>
</template>
</N8nCallout>
</template>
</div>
</template>
<template #footer>
<div :class="$style.actions">
<template v-if="phase === 'confirm'">
<N8nButton
variant="subtle"
:disabled="migrating"
:label="i18n.baseText('generic.cancel')"
data-test-id="migrate-modal-cancel-button"
@click="close"
/>
<N8nButton
:loading="migrating"
:label="i18n.baseText('settings.migrationReport.detail.migrate.modal.confirmButton')"
data-test-id="migrate-modal-confirm-button"
@click="handleMigrate"
/>
</template>
<template v-else-if="phase === 'success'">
<template v-if="result?.republishable && !published">
<N8nButton
variant="subtle"
:disabled="publishing"
:label="i18n.baseText('settings.migrationReport.detail.migrate.publish.skip')"
data-test-id="migrate-modal-skip-publish-button"
@click="close"
/>
<N8nButton
:loading="publishing"
:label="i18n.baseText('settings.migrationReport.detail.migrate.publish.button')"
data-test-id="migrate-modal-publish-button"
@click="handlePublish"
/>
</template>
<N8nButton
v-else
variant="subtle"
:label="i18n.baseText('settings.migrationReport.detail.migrate.modal.done')"
data-test-id="migrate-modal-done-button"
@click="close"
/>
</template>
<template v-else>
<N8nButton
variant="subtle"
:label="i18n.baseText('generic.close')"
data-test-id="migrate-modal-close-button"
@click="close"
/>
</template>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
}
.section {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
}
.changeList {
list-style-type: disc;
padding-left: var(--spacing--sm);
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.change {
display: flex;
flex-direction: column;
}
.reviewTitle {
font-weight: var(--font-weight--bold);
margin-bottom: var(--spacing--3xs);
}
.warningList {
list-style-type: disc;
padding-left: var(--spacing--sm);
margin-bottom: var(--spacing--3xs);
}
.reviewNodes {
margin-top: var(--spacing--3xs);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing--xs);
}
</style>
@@ -2,18 +2,23 @@ import { createTestingPinia } from '@pinia/testing';
import { screen, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import type { EventBus } from '@n8n/utils/event-bus';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useUIStore } from '@/app/stores/ui.store';
import { MIGRATE_WORKFLOW_MODAL_KEY } from '@/app/constants';
import MigrationRuleDetail from './MigrationRuleDetail.vue';
import * as breakingChangesApi from '@n8n/rest-api-client/api/breaking-changes';
import type { BreakingChangeWorkflowRuleResult } from '@n8n/api-types';
vi.mock('@n8n/rest-api-client/api/breaking-changes', () => ({
getReportForRule: vi.fn(),
migrateWorkflowForRule: vi.fn(),
}));
let rootStore: ReturnType<typeof mockedStore<typeof useRootStore>>;
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
let renderComponent: ReturnType<typeof createComponentRenderer>;
const mockWorkflowWithIssue = {
@@ -70,6 +75,7 @@ const mockRuleResult: BreakingChangeWorkflowRuleResult = {
description: 'Please update to the latest version',
},
],
migratable: false,
affectedWorkflows: [mockWorkflowWithIssue, mockWorkflowWithMultipleNodes],
};
@@ -83,6 +89,7 @@ const createMockRuleResult = (
ruleSeverity: 'critical',
ruleDocumentationUrl: 'https://docs.example.com/rule-1',
recommendations: [],
migratable: false,
affectedWorkflows: [],
...overrides,
};
@@ -99,6 +106,7 @@ describe('MigrationRuleDetail', () => {
baseUrl: 'http://localhost:5678',
pushRef: 'test-push-ref',
};
uiStore = mockedStore(useUIStore);
vi.mocked(breakingChangesApi.getReportForRule).mockResolvedValue(mockRuleResult);
});
@@ -164,6 +172,54 @@ describe('MigrationRuleDetail', () => {
});
});
describe('migration', () => {
it('should not render a Migrate button when the rule is not migratable', async () => {
vi.mocked(breakingChangesApi.getReportForRule).mockResolvedValue(mockRuleResult);
renderComponent({ props: { migrationRuleId: 'rule-1' } });
await waitFor(() => expect(screen.getByText('Test Rule')).toBeInTheDocument());
expect(screen.queryByTestId('migrate-workflow-button')).not.toBeInTheDocument();
});
it('opens the migrate modal with the rule and workflow when Migrate is clicked', async () => {
vi.mocked(breakingChangesApi.getReportForRule).mockResolvedValue(
createMockRuleResult({ migratable: true, affectedWorkflows: [mockWorkflowWithIssue] }),
);
renderComponent({ props: { migrationRuleId: 'rule-1' } });
await userEvent.click(await screen.findByTestId('migrate-workflow-button'));
expect(uiStore.openModalWithData).toHaveBeenCalledWith(
expect.objectContaining({
name: MIGRATE_WORKFLOW_MODAL_KEY,
data: expect.objectContaining({
ruleId: 'rule-1',
workflow: expect.objectContaining({ id: mockWorkflowWithIssue.id }),
}),
}),
);
// Nothing is migrated yet — the modal drives that.
expect(screen.getByTestId('migrate-workflow-button')).toBeInTheDocument();
});
it('marks the row migrated when the modal reports a successful migration', async () => {
vi.mocked(breakingChangesApi.getReportForRule).mockResolvedValue(
createMockRuleResult({ migratable: true, affectedWorkflows: [mockWorkflowWithIssue] }),
);
renderComponent({ props: { migrationRuleId: 'rule-1' } });
await userEvent.click(await screen.findByTestId('migrate-workflow-button'));
// Emit on the same bus the detail passed into the modal.
const { data } = vi.mocked(uiStore.openModalWithData).mock.calls[0][0];
(data.eventBus as EventBus).emit('migrated', { workflowId: mockWorkflowWithIssue.id });
await waitFor(() =>
expect(screen.queryByTestId('migrate-workflow-button')).not.toBeInTheDocument(),
);
});
});
describe('data table', () => {
it('should display affected workflows in table', async () => {
renderComponent({
@@ -2,10 +2,12 @@
import TimeAgo from '@/app/components/TimeAgo.vue';
import ResourceFiltersDropdown from '@/app/components/forms/ResourceFiltersDropdown.vue';
import { getDebounceTime } from '@n8n/composables/useDebounce';
import { DEBOUNCE_TIME, VIEWS } from '@/app/constants';
import { DEBOUNCE_TIME, MIGRATE_WORKFLOW_MODAL_KEY, VIEWS } from '@/app/constants';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import type { BreakingChangeWorkflowRuleResult } from '@n8n/api-types';
import { useUIStore } from '@/app/stores/ui.store';
import {
N8nButton,
N8nDataTableServer,
N8nIcon,
N8nInput,
@@ -22,6 +24,7 @@ import type { TableHeader } from '@n8n/design-system/components/N8nDataTableServ
import * as breakingChangesApi from '@n8n/rest-api-client/api/breaking-changes';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { createEventBus } from '@n8n/utils/event-bus';
import { useAsyncState, useDebounceFn } from '@vueuse/core';
import orderBy from 'lodash/orderBy';
import { computed, ref } from 'vue';
@@ -29,6 +32,7 @@ import { useRouter } from 'vue-router';
import SeverityTag from './components/SeverityTag.vue';
const i18n = useI18n();
const uiStore = useUIStore();
useDocumentTitle().set(i18n.baseText('settings.migrationReport'));
@@ -52,46 +56,83 @@ const { state, isLoading } = useAsyncState(
ruleSeverity: 'low',
affectedWorkflows: [],
recommendations: [],
migratable: false,
},
);
type AffectedWorkflow = BreakingChangeWorkflowRuleResult['affectedWorkflows'][number];
const tableHeaders = ref<Array<TableHeader<AffectedWorkflow>>>([
{
title: i18n.baseText('settings.migrationReport.detail.table.name'),
key: 'name',
width: 200,
},
{
title: i18n.baseText('settings.migrationReport.detail.table.status'),
key: 'active',
value: (row: AffectedWorkflow) =>
row.active
? i18n.baseText('settings.migrationReport.detail.table.active')
: i18n.baseText('settings.migrationReport.detail.table.deactivated'),
width: 40,
},
{
title: i18n.baseText('settings.migrationReport.detail.table.nodesAffected'),
key: 'issues',
},
{
title: i18n.baseText('settings.migrationReport.detail.table.numberOfExecutions'),
key: 'numberOfExecutions',
width: 40,
},
{
title: i18n.baseText('settings.migrationReport.detail.table.lastExecuted'),
key: 'lastExecutedAt',
width: 40,
},
{
title: i18n.baseText('settings.migrationReport.detail.table.lastUpdated'),
key: 'lastUpdatedAt',
width: 40,
},
]);
const tableHeaders = computed<Array<TableHeader<AffectedWorkflow>>>(() => {
const headers: Array<TableHeader<AffectedWorkflow>> = [
{
title: i18n.baseText('settings.migrationReport.detail.table.name'),
key: 'name',
width: 200,
},
{
title: i18n.baseText('settings.migrationReport.detail.table.status'),
key: 'active',
value: (row: AffectedWorkflow) =>
row.active
? i18n.baseText('settings.migrationReport.detail.table.active')
: i18n.baseText('settings.migrationReport.detail.table.deactivated'),
width: 40,
},
{
title: i18n.baseText('settings.migrationReport.detail.table.nodesAffected'),
key: 'issues',
},
{
title: i18n.baseText('settings.migrationReport.detail.table.numberOfExecutions'),
key: 'numberOfExecutions',
width: 40,
},
{
title: i18n.baseText('settings.migrationReport.detail.table.lastExecuted'),
key: 'lastExecutedAt',
width: 40,
},
{
title: i18n.baseText('settings.migrationReport.detail.table.lastUpdated'),
key: 'lastUpdatedAt',
width: 40,
},
];
if (state.value.migratable) {
headers.push({
title: '',
key: 'actions',
value: () => '',
width: 40,
disableSort: true,
});
}
return headers;
});
// Workflows successfully migrated this session (the row shows a "Migrated" state).
const migratedWorkflowIds = ref<Set<string>>(new Set());
// The modal runs the migration (confirm → progress → result) and emits back when a
// workflow was migrated so the row can reflect it.
const migrateModalBus = createEventBus();
migrateModalBus.on('migrated', ({ workflowId }: { workflowId: string }) => {
migratedWorkflowIds.value = new Set(migratedWorkflowIds.value).add(workflowId);
});
function openMigrateModal(workflow: AffectedWorkflow) {
uiStore.openModalWithData({
name: MIGRATE_WORKFLOW_MODAL_KEY,
data: {
ruleId: props.migrationRuleId,
workflow,
recommendations: state.value.recommendations,
eventBus: migrateModalBus,
},
});
}
function handleRowClick(_event: MouseEvent, { item }: { item: AffectedWorkflow }) {
window.open(
@@ -292,6 +333,7 @@ const sortedWorkflows = computed(() => {
</div>
<N8nDataTableServer
:key="String(state.migratable)"
v-model:sort-by="sortBy"
:items-per-page="sortedWorkflows.length + 1"
:items="sortedWorkflows"
@@ -323,6 +365,19 @@ const sortedWorkflows = computed(() => {
<template #[`item.lastUpdatedAt`]="{ item }">
<TimeAgo :date="item.lastUpdatedAt.toString()" />
</template>
<template #[`item.actions`]="{ item }">
<N8nText v-if="migratedWorkflowIds.has(item.id)" color="text-light" size="small">
{{ i18n.baseText('settings.migrationReport.detail.migrate.migrated') }}
</N8nText>
<N8nButton
v-else
size="small"
type="secondary"
:label="i18n.baseText('settings.migrationReport.detail.migrate.button')"
data-test-id="migrate-workflow-button"
@click.stop="openMigrateModal(item)"
/>
</template>
</N8nDataTableServer>
</N8nSettingsLayout>
</template>
@@ -29,6 +29,7 @@ const mockWorkflowIssue = {
description: 'Please update to the latest version',
},
],
migratable: false,
nbAffectedWorkflows: 5,
};
@@ -44,6 +45,7 @@ const mockInstanceIssue = {
description: 'Update your instance configuration',
},
],
migratable: false,
instanceIssues: [
{
title: 'Configuration issue',
@@ -1,141 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`viewsData > AIView > should return ai view with ai transform node 1`] = `
{
"items": [
{
"key": "ai_templates_root",
"properties": {
"description": "See what's possible and get started 5x faster",
"icon": "box-open",
"key": "ai_templates_root",
"tag": {
"text": "Recommended",
"type": "info",
},
"title": "AI Templates",
"url": "template-repository-url.n8n.io?test=value&utm_user_role=AdvancedAI",
},
"type": "link",
"uuid": "ai_templates_root",
},
{
"key": "agent",
"properties": {
"description": "example mock agent node",
"displayName": "agent",
"group": [],
"icon": "fa:pen",
"iconUrl": "nodes/test-node/icon.svg",
"name": "agent",
"title": "agent",
},
"type": "node",
},
{
"key": "chain",
"properties": {
"description": "example mock chain node",
"displayName": "chain",
"group": [],
"icon": "fa:pen",
"iconUrl": "nodes/test-node/icon.svg",
"name": "chain",
"title": "chain",
},
"type": "node",
},
{
"key": "n8n-nodes-base.aiTransform",
"properties": {
"description": "",
"displayName": "n8n-nodes-base.aiTransform",
"group": [],
"icon": "fa:pen",
"iconUrl": "nodes/test-node/icon.svg",
"name": "n8n-nodes-base.aiTransform",
"title": "n8n-nodes-base.aiTransform",
},
"type": "node",
},
{
"key": "AI Other",
"properties": {
"description": "Embeddings, Vector Stores, LLMs and other AI nodes",
"icon": "robot",
"title": "Other AI Nodes",
},
"type": "view",
},
],
"subtitle": "Select an AI Node to add to your workflow",
"title": "AI Nodes",
"value": "AI",
}
`;
exports[`viewsData > AIView > should return ai view without ai transform node if ask ai is not enabled 1`] = `
{
"items": [
{
"key": "ai_templates_root",
"properties": {
"description": "See what's possible and get started 5x faster",
"icon": "box-open",
"key": "ai_templates_root",
"tag": {
"text": "Recommended",
"type": "info",
},
"title": "AI Templates",
"url": "template-repository-url.n8n.io?test=value&utm_user_role=AdvancedAI",
},
"type": "link",
"uuid": "ai_templates_root",
},
{
"key": "agent",
"properties": {
"description": "example mock agent node",
"displayName": "agent",
"group": [],
"icon": "fa:pen",
"iconUrl": "nodes/test-node/icon.svg",
"name": "agent",
"title": "agent",
},
"type": "node",
},
{
"key": "chain",
"properties": {
"description": "example mock chain node",
"displayName": "chain",
"group": [],
"icon": "fa:pen",
"iconUrl": "nodes/test-node/icon.svg",
"name": "chain",
"title": "chain",
},
"type": "node",
},
{
"key": "AI Other",
"properties": {
"description": "Embeddings, Vector Stores, LLMs and other AI nodes",
"icon": "robot",
"title": "Other AI Nodes",
},
"type": "view",
},
],
"subtitle": "Select an AI Node to add to your workflow",
"title": "AI Nodes",
"value": "AI",
}
`;
exports[`viewsData > AIView > should return ai view without ai transform node if ask ai is not enabled and node is not in the list 1`] = `
exports[`viewsData > AIView > should return the AI view 1`] = `
{
"items": [
{
@@ -93,25 +93,14 @@ describe('viewsData', () => {
});
describe('AIView', () => {
test('should return ai view with ai transform node', () => {
const settingsStore = useSettingsStore();
vi.spyOn(settingsStore, 'isAskAiEnabled', 'get').mockReturnValue(true);
test('should return the AI view', () => {
expect(AIView([])).toMatchSnapshot();
});
test('should return ai view without ai transform node if ask ai is not enabled', () => {
const settingsStore = useSettingsStore();
vi.spyOn(settingsStore, 'isAskAiEnabled', 'get').mockReturnValue(false);
test('should not include the deprecated AI Transform node', () => {
const result = AIView([]);
expect(AIView([])).toMatchSnapshot();
});
test('should return ai view without ai transform node if ask ai is not enabled and node is not in the list', () => {
const settingsStore = useSettingsStore();
vi.spyOn(settingsStore, 'isAskAiEnabled', 'get').mockReturnValue(false);
expect(AIView([])).toMatchSnapshot();
expect(result.items.some((item) => item.key === AI_TRANSFORM_NODE_TYPE)).toBe(false);
});
test('should include Message an Agent node before the agent node when agents module is active', () => {
@@ -207,10 +207,6 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
TEMPLATE_CATEGORY_AI,
);
const askAiEnabled = settingsStore.isAskAiEnabled;
const aiTransformNode = nodeTypesStore.getNodeType(AI_TRANSFORM_NODE_TYPE);
const transformNode = askAiEnabled && aiTransformNode ? [getNodeView(aiTransformNode)] : [];
const callouts: NodeViewItem[] = [getAiTemplatesCallout(aiTemplatesURL)];
return {
@@ -224,7 +220,6 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
...messageAnAgentNode,
...agentNodes,
...chainNodes,
...transformNode,
...evaluationNode,
{
key: AI_OTHERS_NODE_CREATOR_VIEW,
@@ -21,6 +21,7 @@ export class AiTransform implements INodeType {
group: ['transform'],
version: 1,
description: 'Modify data based on instructions written in plain english',
hidden: true,
defaults: {
name: 'AI Transform',
},