diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index eddc320efe5..4de07c5498c 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/schemas/breaking-changes.schema.ts b/packages/@n8n/api-types/src/schemas/breaking-changes.schema.ts index a323ac47a4e..b208d706201 100644 --- a/packages/@n8n/api-types/src/schemas/breaking-changes.schema.ts +++ b/packages/@n8n/api-types/src/schemas/breaking-changes.schema.ts @@ -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; diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index e0f71e7a598..84ef85b2257 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -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", diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 00a11ce7f82..241c7b9cefa 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -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, diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index 0bed16809a5..57ae913bd07 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -140,6 +140,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'chatHubAgent:delete', 'chatHubAgent:list', 'breakingChanges:list', + 'breakingChanges:migrate', 'execution:reveal', 'apiKey:manage', 'apiKey:list', diff --git a/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.migration-registry.service.test.ts b/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.migration-registry.service.test.ts new file mode 100644 index 00000000000..740842ef5ae --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.migration-registry.service.test.ts @@ -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(); + const logger = mock({ 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); + }); +}); diff --git a/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.migration.service.test.ts b/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.migration.service.test.ts new file mode 100644 index 00000000000..c572501e79e --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.migration.service.test.ts @@ -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({ id: 'user-1' }); + const RULE_ID = 'ai-transform-deprecated'; + + let ruleRegistry: RuleRegistry; + let migrationRegistry: MigrationRegistry; + let workflowFinderService: Mocked; + let workflowService: Mocked; + let workflowValidationService: Mocked; + let nodeTypes: Mocked; + let service: BreakingChangeMigrationService; + + const buildWorkflow = (nodes: INode[], overrides: Partial = {}) => + mock({ 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(); + workflowService = mock(); + workflowValidationService = mock(); + nodeTypes = mock(); + + 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({ 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({ 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({ 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({ 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({ 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); + }); +}); diff --git a/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.service.test.ts b/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.service.test.ts index aaf0e8113d9..627b367ca6a 100644 --- a/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.service.test.ts +++ b/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.service.test.ts @@ -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, diff --git a/packages/cli/src/modules/breaking-changes/__tests__/group-nodes-by-type.test.ts b/packages/cli/src/modules/breaking-changes/__tests__/group-nodes-by-type.test.ts new file mode 100644 index 00000000000..6ca3d664bd4 --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/__tests__/group-nodes-by-type.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/modules/breaking-changes/breaking-changes.controller.ts b/packages/cli/src/modules/breaking-changes/breaking-changes.controller.ts index 37bce2ada93..7332b2b5eb3 100644 --- a/packages/cli/src/modules/breaking-changes/breaking-changes.controller.ts +++ b/packages/cli/src/modules/breaking-changes/breaking-changes.controller.ts @@ -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 { + return await this.migrationService.migrateWorkflow(ruleId, workflowId, req.user); + } } diff --git a/packages/cli/src/modules/breaking-changes/breaking-changes.migration-registry.service.ts b/packages/cli/src/modules/breaking-changes/breaking-changes.migration-registry.service.ts new file mode 100644 index 00000000000..85ed5700547 --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/breaking-changes.migration-registry.service.ts @@ -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(); + + 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); + } +} diff --git a/packages/cli/src/modules/breaking-changes/breaking-changes.migration.service.ts b/packages/cli/src/modules/breaking-changes/breaking-changes.migration.service.ts new file mode 100644 index 00000000000..8d5ad1e528e --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/breaking-changes.migration.service.ts @@ -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 { + 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, + }; + } +} diff --git a/packages/cli/src/modules/breaking-changes/breaking-changes.module.ts b/packages/cli/src/modules/breaking-changes/breaking-changes.module.ts index 5dd67126ac7..6e03eee0290 100644 --- a/packages/cli/src/modules/breaking-changes/breaking-changes.module.ts +++ b/packages/cli/src/modules/breaking-changes/breaking-changes.module.ts @@ -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'); } } diff --git a/packages/cli/src/modules/breaking-changes/breaking-changes.service.ts b/packages/cli/src/modules/breaking-changes/breaking-changes.service.ts index aa93e7a7351..5236f7debda 100644 --- a/packages/cli/src/modules/breaking-changes/breaking-changes.service.ts +++ b/packages/cli/src/modules/breaking-changes/breaking-changes.service.ts @@ -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 { - const nodesGroupedByType: Map = 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, @@ -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 = { diff --git a/packages/cli/src/modules/breaking-changes/group-nodes-by-type.ts b/packages/cli/src/modules/breaking-changes/group-nodes-by-type.ts new file mode 100644 index 00000000000..e3d3236c8d7 --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/group-nodes-by-type.ts @@ -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 { + const grouped = new Map(); + for (const node of nodes) { + const existing = grouped.get(node.type); + if (existing) existing.push(node); + else grouped.set(node.type, [node]); + } + return grouped; +} diff --git a/packages/cli/src/modules/breaking-changes/migrations/__tests__/ai-transform-to-code.migration.test.ts b/packages/cli/src/modules/breaking-changes/migrations/__tests__/ai-transform-to-code.migration.test.ts new file mode 100644 index 00000000000..19170401179 --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/migrations/__tests__/ai-transform-to-code.migration.test.ts @@ -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'); + }); +}); diff --git a/packages/cli/src/modules/breaking-changes/migrations/ai-transform-to-code.migration.ts b/packages/cli/src/modules/breaking-changes/migrations/ai-transform-to-code.migration.ts new file mode 100644 index 00000000000..6728e18bf25 --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/migrations/ai-transform-to-code.migration.ts @@ -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, + }, + }, + }; + }, +}; diff --git a/packages/cli/src/modules/breaking-changes/migrations/index.ts b/packages/cli/src/modules/breaking-changes/migrations/index.ts new file mode 100644 index 00000000000..2d1771d61a4 --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/migrations/index.ts @@ -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]; diff --git a/packages/cli/src/modules/breaking-changes/migrations/node-migration.ts b/packages/cli/src/modules/breaking-changes/migrations/node-migration.ts new file mode 100644 index 00000000000..5d93a419857 --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/migrations/node-migration.ts @@ -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; + // 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; +} diff --git a/packages/cli/src/modules/breaking-changes/rules/index.ts b/packages/cli/src/modules/breaking-changes/rules/index.ts index d03d151ae77..9c51af3397c 100644 --- a/packages/cli/src/modules/breaking-changes/rules/index.ts +++ b/packages/cli/src/modules/breaking-changes/rules/index.ts @@ -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'; diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/ai-transform-deprecated.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/ai-transform-deprecated.rule.test.ts new file mode 100644 index 00000000000..a1920869b32 --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/ai-transform-deprecated.rule.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/ai-transform-deprecated.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/ai-transform-deprecated.rule.ts new file mode 100644 index 00000000000..0f776c4afe4 --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/rules/v3/ai-transform-deprecated.rule.ts @@ -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 { + 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, + ): Promise { + 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, + })), + }; + } +} diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 33eccca786d..78d6a87b8f0 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/@n8n/rest-api-client/src/api/breaking-changes.ts b/packages/frontend/@n8n/rest-api-client/src/api/breaking-changes.ts index d7eec7af87b..7e206b39ad8 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/breaking-changes.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/breaking-changes.ts @@ -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 { return (await get(context.baseUrl, `/breaking-changes/report/${ruleId}`)).data; } + +export async function migrateWorkflowForRule( + context: IRestApiContext, + ruleId: string, + workflowId: string, +): Promise { + return await makeRestApiRequest( + context, + 'POST', + `/breaking-changes/report/${ruleId}/workflows/${workflowId}/migrate`, + ); +} diff --git a/packages/frontend/editor-ui/src/app/components/Modals.vue b/packages/frontend/editor-ui/src/app/components/Modals.vue index fe3847f8c26..2c750aabf7e 100644 --- a/packages/frontend/editor-ui/src/app/components/Modals.vue +++ b/packages/frontend/editor-ui/src/app/components/Modals.vue @@ -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 + + + + diff --git a/packages/frontend/editor-ui/src/features/settings/migrationReport/MigrationRules.test.ts b/packages/frontend/editor-ui/src/features/settings/migrationReport/MigrationRules.test.ts index aa8e9a7cd84..7a30010aa22 100644 --- a/packages/frontend/editor-ui/src/features/settings/migrationReport/MigrationRules.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/migrationReport/MigrationRules.test.ts @@ -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', diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/__snapshots__/viewsData.test.ts.snap b/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/__snapshots__/viewsData.test.ts.snap index 57398bdc608..ff108b78903 100644 --- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/__snapshots__/viewsData.test.ts.snap +++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/__snapshots__/viewsData.test.ts.snap @@ -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": [ { diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.test.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.test.ts index cebdb0881f5..971658da94a 100644 --- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.test.ts +++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.test.ts @@ -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', () => { diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.ts index f6a392848d6..aecbab3e041 100644 --- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.ts +++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.ts @@ -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, diff --git a/packages/nodes-base/nodes/AiTransform/AiTransform.node.ts b/packages/nodes-base/nodes/AiTransform/AiTransform.node.ts index 3d321fe66ba..a8bd92bf428 100644 --- a/packages/nodes-base/nodes/AiTransform/AiTransform.node.ts +++ b/packages/nodes-base/nodes/AiTransform/AiTransform.node.ts @@ -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', },