diff --git a/packages/@n8n/backend-test-utils/src/db/workflows.ts b/packages/@n8n/backend-test-utils/src/db/workflows.ts index 3682404a47ea4..d9f03b3f29580 100644 --- a/packages/@n8n/backend-test-utils/src/db/workflows.ts +++ b/packages/@n8n/backend-test-utils/src/db/workflows.ts @@ -89,6 +89,18 @@ export async function createManyWorkflows( return await Promise.all(workflowRequests); } +export async function createManyActiveWorkflows( + amount: number, + attributes: Partial = {}, + userOrProject?: User | Project, +) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const workflowRequests = [...Array(amount)].map( + async (_) => await createActiveWorkflow(attributes, userOrProject), + ); + return await Promise.all(workflowRequests); +} + export async function shareWorkflowWithUsers(workflow: IWorkflowBase, users: User[]) { const sharedWorkflows: Array> = await Promise.all( users.map(async (user) => { @@ -135,7 +147,7 @@ export async function getWorkflowSharing(workflow: IWorkflowBase) { */ export async function createWorkflowWithTrigger( attributes: Partial = {}, - user?: User, + userOrProject?: User | Project, ) { const workflow = await createWorkflow( { @@ -170,7 +182,7 @@ export async function createWorkflowWithTrigger( }, ...attributes, }, - user, + userOrProject, ); return workflow; @@ -201,12 +213,12 @@ export async function createWorkflowWithHistory( */ export async function createWorkflowWithTriggerAndHistory( attributes: Partial = {}, - user?: User, + userOrProject?: User | Project, ) { - const workflow = await createWorkflowWithTrigger(attributes, user); + const workflow = await createWorkflowWithTrigger(attributes, userOrProject); // Create workflow history for the initial version - await createWorkflowHistory(workflow, user); + await createWorkflowHistory(workflow, userOrProject); return workflow; } @@ -227,12 +239,78 @@ export const getWorkflowById = async (id: string) => * @param workflow workflow to create history for * @param user user who created the version (optional) */ -export async function createWorkflowHistory(workflow: IWorkflowDb, user?: User): Promise { +export async function createWorkflowHistory( + workflow: IWorkflowDb, + userOrProject?: User | Project, +): Promise { await Container.get(WorkflowHistoryRepository).insert({ workflowId: workflow.id, versionId: workflow.versionId, nodes: workflow.nodes, connections: workflow.connections, + authors: userOrProject instanceof User ? userOrProject.email : 'test@example.com', + }); +} + +/** + * Set the active version for a workflow + * @param workflowId workflow ID + * @param versionId version ID to set as active + */ +export async function setActiveVersion(workflowId: string, versionId: string): Promise { + await Container.get(WorkflowRepository) + .createQueryBuilder() + .update() + .set({ activeVersionId: versionId }) + .where('id = :workflowId', { workflowId }) + .execute(); +} + +/** + * Create an active workflow with trigger, history, and activeVersionId set to the current version. + * This simulates a workflow that has been activated and is running. + * @param attributes workflow attributes + * @param user user to assign the workflow to + */ +export async function createActiveWorkflow( + attributes: Partial = {}, + userOrProject?: User | Project, +) { + const workflow = await createWorkflowWithTriggerAndHistory( + { active: true, ...attributes }, + userOrProject, + ); + + await setActiveVersion(workflow.id, workflow.versionId); + + workflow.activeVersionId = workflow.versionId; + return workflow; +} + +/** + * Create a workflow with a specific active version. + * This simulates a workflow where the active version differs from the current version. + * @param activeVersionId the version ID to set as active + * @param attributes workflow attributes + * @param user user to assign the workflow to + */ +export async function createWorkflowWithActiveVersion( + activeVersionId: string, + attributes: Partial = {}, + user?: User, +) { + const workflow = await createWorkflowWithTriggerAndHistory({ active: true, ...attributes }, user); + + await Container.get(WorkflowHistoryRepository).insert({ + workflowId: workflow.id, + versionId: activeVersionId, + nodes: workflow.nodes, + connections: workflow.connections, authors: user?.email ?? 'test@example.com', }); + + await setActiveVersion(workflow.id, activeVersionId); + + workflow.activeVersionId = activeVersionId; + return workflow; } diff --git a/packages/@n8n/backend-test-utils/src/test-db.ts b/packages/@n8n/backend-test-utils/src/test-db.ts index 3a1a657ce79bf..c6fee2a8daa16 100644 --- a/packages/@n8n/backend-test-utils/src/test-db.ts +++ b/packages/@n8n/backend-test-utils/src/test-db.ts @@ -94,8 +94,44 @@ type EntityName = */ export async function truncate(entities: EntityName[]) { const connection = Container.get(Connection); + const dbType = connection.options.type; - for (const name of entities) { - await connection.getRepository(name).delete({}); + // Disable FK checks for MySQL/MariaDB to handle circular dependencies + if (dbType === 'mysql' || dbType === 'mariadb') { + await connection.query('SET FOREIGN_KEY_CHECKS=0'); + } + + try { + // Collect junction tables to clean + const junctionTablesToClean = new Set(); + + // Find all junction tables associated with the entities being truncated + for (const name of entities) { + try { + const metadata = connection.getMetadata(name); + for (const relation of metadata.manyToManyRelations) { + if (relation.junctionEntityMetadata) { + const junctionTableName = relation.junctionEntityMetadata.tablePath; + junctionTablesToClean.add(junctionTableName); + } + } + } catch (error) { + // Skip + } + } + + // Clean junction tables first (since they reference the entities) + for (const tableName of junctionTablesToClean) { + await connection.query(`DELETE FROM ${tableName}`); + } + + for (const name of entities) { + await connection.getRepository(name).delete({}); + } + } finally { + // Re-enable FK checks + if (dbType === 'mysql' || dbType === 'mariadb') { + await connection.query('SET FOREIGN_KEY_CHECKS=1'); + } } } diff --git a/packages/@n8n/db/src/entities/types-db.ts b/packages/@n8n/db/src/entities/types-db.ts index b9782f38394d9..82f3dba355edc 100644 --- a/packages/@n8n/db/src/entities/types-db.ts +++ b/packages/@n8n/db/src/entities/types-db.ts @@ -26,6 +26,7 @@ import type { SharedWorkflow } from './shared-workflow'; import type { TagEntity } from './tag-entity'; import type { User } from './user'; import type { WorkflowEntity } from './workflow-entity'; +import type { WorkflowHistory } from './workflow-history'; export type UsageCount = { usageCount: number; @@ -79,6 +80,7 @@ export interface IWorkflowDb extends IWorkflowBase { triggerCount: number; tags?: TagEntity[]; parentFolder?: Folder | null; + activeVersion?: WorkflowHistory | null; } export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted { @@ -221,6 +223,7 @@ export namespace ListQueryDb { | 'name' | 'active' | 'versionId' + | 'activeVersionId' | 'createdAt' | 'updatedAt' | 'tags' diff --git a/packages/@n8n/db/src/entities/workflow-entity.ts b/packages/@n8n/db/src/entities/workflow-entity.ts index 026a0efeb1274..3392a9ceac8f8 100644 --- a/packages/@n8n/db/src/entities/workflow-entity.ts +++ b/packages/@n8n/db/src/entities/workflow-entity.ts @@ -18,6 +18,7 @@ import type { SharedWorkflow } from './shared-workflow'; import type { TagEntity } from './tag-entity'; import type { TestRun } from './test-run.ee'; import type { ISimplifiedPinData, IWorkflowDb } from './types-db'; +import type { WorkflowHistory } from './workflow-history'; import type { WorkflowStatistics } from './workflow-statistics'; import type { WorkflowTagMapping } from './workflow-tag-mapping'; import { objectRetriever, sqlite } from '../utils/transformers'; @@ -103,6 +104,13 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl @Column({ length: 36 }) versionId: string; + @Column({ name: 'activeVersionId', length: 36, nullable: true }) + activeVersionId: string | null; + + @ManyToOne('WorkflowHistory', { nullable: true }) + @JoinColumn({ name: 'activeVersionId', referencedColumnName: 'versionId' }) + activeVersion: WorkflowHistory | null; + @Column({ default: 1 }) versionCounter: number; diff --git a/packages/@n8n/db/src/migrations/common/1763047800000-AddActiveVersionIdColumn.ts b/packages/@n8n/db/src/migrations/common/1763047800000-AddActiveVersionIdColumn.ts new file mode 100644 index 0000000000000..c4c44513b619a --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1763047800000-AddActiveVersionIdColumn.ts @@ -0,0 +1,43 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const WORKFLOWS_TABLE_NAME = 'workflow_entity'; +const WORKFLOW_HISTORY_TABLE_NAME = 'workflow_history'; + +export class AddActiveVersionIdColumn1763047800000 implements ReversibleMigration { + async up({ + schemaBuilder: { addColumns, column, addForeignKey }, + queryRunner, + escape, + }: MigrationContext) { + const workflowsTableName = escape.tableName(WORKFLOWS_TABLE_NAME); + + await addColumns(WORKFLOWS_TABLE_NAME, [column('activeVersionId').varchar(36)]); + + await addForeignKey( + WORKFLOWS_TABLE_NAME, + 'activeVersionId', + [WORKFLOW_HISTORY_TABLE_NAME, 'versionId'], + undefined, + 'RESTRICT', + ); + + // For existing ACTIVE workflows, set activeVersionId = versionId + const versionIdColumn = escape.columnName('versionId'); + const activeColumn = escape.columnName('active'); + const activeVersionIdColumn = escape.columnName('activeVersionId'); + + await queryRunner.query( + `UPDATE ${workflowsTableName} + SET ${activeVersionIdColumn} = ${versionIdColumn} + WHERE ${activeColumn} = true`, + ); + } + + async down({ schemaBuilder: { dropColumns, dropForeignKey } }: MigrationContext) { + await dropForeignKey(WORKFLOWS_TABLE_NAME, 'activeVersionId', [ + WORKFLOW_HISTORY_TABLE_NAME, + 'versionId', + ]); + await dropColumns(WORKFLOWS_TABLE_NAME, ['activeVersionId']); + } +} diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index b93ba09ecf3f9..78388f1f7afc1 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -1,5 +1,3 @@ -import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns'; -import { AddWorkflowHistoryAutoSaveFields1762847206508 } from './../common/1762847206508-AddWorkflowHistoryAutoSaveFields'; import { InitialMigration1588157391238 } from './1588157391238-InitialMigration'; import { WebhookModel1592447867632 } from './1592447867632-WebhookModel'; import { CreateIndexStoppedAt1594902918301 } from './1594902918301-CreateIndexStoppedAt'; @@ -55,6 +53,7 @@ import { AddToolsColumnToChatHubTables1761830340990 } from './1761830340990-AddT import { CreateLdapEntities1674509946020 } from '../common/1674509946020-CreateLdapEntities'; import { PurgeInvalidWorkflowConnections1675940580449 } from '../common/1675940580449-PurgeInvalidWorkflowConnections'; import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns'; +import { AddMfaColumns1690000000030 } from '../common/1690000000040-AddMfaColumns'; import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex'; import { CreateWorkflowHistoryTable1692967111175 } from '../common/1692967111175-CreateWorkflowHistoryTable'; import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-ExecutionSoftDelete'; @@ -114,6 +113,8 @@ import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000 import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages'; import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; +import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields'; +import { AddActiveVersionIdColumn1763047800000 } from '../common/1763047800000-AddActiveVersionIdColumn'; import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar'; import type { Migration } from '../migration-types'; @@ -235,4 +236,5 @@ export const mysqlMigrations: Migration[] = [ AddToolsColumnToChatHubTables1761830340990, ChangeOAuthStateColumnToUnboundedVarchar1763572724000, AddAttachmentsToChatHubMessages1761773155024, + AddActiveVersionIdColumn1763047800000, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 234ca067ccc70..90a69d8a4c214 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -114,6 +114,7 @@ import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340 import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields'; +import { AddActiveVersionIdColumn1763047800000 } from '../common/1763047800000-AddActiveVersionIdColumn'; import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar'; import type { Migration } from '../migration-types'; @@ -235,4 +236,5 @@ export const postgresMigrations: Migration[] = [ AddToolsColumnToChatHubTables1761830340990, ChangeOAuthStateColumnToUnboundedVarchar1763572724000, AddAttachmentsToChatHubMessages1761773155024, + AddActiveVersionIdColumn1763047800000, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 55ea03ee6bef0..27fde9d26d03b 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -110,6 +110,7 @@ import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340 import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields'; +import { AddActiveVersionIdColumn1763047800000 } from '../common/1763047800000-AddActiveVersionIdColumn'; import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar'; import type { Migration } from '../migration-types'; @@ -227,6 +228,7 @@ const sqliteMigrations: Migration[] = [ AddToolsColumnToChatHubTables1761830340990, ChangeOAuthStateColumnToUnboundedVarchar1763572724000, AddAttachmentsToChatHubMessages1761773155024, + AddActiveVersionIdColumn1763047800000, ]; export { sqliteMigrations }; diff --git a/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts index ea8afb8ba6a42..d6f3691e2ceac 100644 --- a/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts @@ -249,9 +249,7 @@ describe('WorkflowRepository', () => { }), ); - expect(queryBuilder.andWhere).toHaveBeenCalledWith('workflow.active = :active', { - active: true, - }); + expect(queryBuilder.andWhere).toHaveBeenCalledWith('workflow.activeVersionId IS NOT NULL'); expect(queryBuilder.innerJoin).toHaveBeenCalledWith('workflow.shared', 'shared'); expect(queryBuilder.andWhere).toHaveBeenCalledWith('shared.projectId = :projectId', { diff --git a/packages/@n8n/db/src/repositories/license-metrics.repository.ts b/packages/@n8n/db/src/repositories/license-metrics.repository.ts index c20c0c1091d55..3ccf80680f854 100644 --- a/packages/@n8n/db/src/repositories/license-metrics.repository.ts +++ b/packages/@n8n/db/src/repositories/license-metrics.repository.ts @@ -58,7 +58,7 @@ export class LicenseMetricsRepository extends Repository { SELECT (SELECT COUNT(*) FROM ${userTable} WHERE disabled = false) AS enabled_user_count, (SELECT COUNT(*) FROM ${userTable}) AS total_user_count, - (SELECT COUNT(*) FROM ${workflowTable} WHERE active = true) AS active_workflow_count, + (SELECT COUNT(*) FROM ${workflowTable} WHERE ${this.toColumnName('activeVersionId')} IS NOT NULL) AS active_workflow_count, (SELECT COUNT(*) FROM ${workflowTable}) AS total_workflow_count, (SELECT COUNT(*) FROM ${credentialTable}) AS total_credentials_count, (SELECT SUM(count) FROM ${workflowStatsTable} WHERE name IN ('production_success', 'production_error')) AS production_executions_count, diff --git a/packages/@n8n/db/src/repositories/shared-workflow.repository.ts b/packages/@n8n/db/src/repositories/shared-workflow.repository.ts index 3311ef9aa4e5a..ebe905aa9a98c 100644 --- a/packages/@n8n/db/src/repositories/shared-workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/shared-workflow.repository.ts @@ -149,6 +149,7 @@ export class SharedWorkflowRepository extends Repository { where?: FindOptionsWhere; includeTags?: boolean; includeParentFolder?: boolean; + includeActiveVersion?: boolean; em?: EntityManager; } = {}, ) { @@ -156,6 +157,7 @@ export class SharedWorkflowRepository extends Repository { where = {}, includeTags = false, includeParentFolder = false, + includeActiveVersion = false, em = this.manager, } = options; @@ -169,6 +171,7 @@ export class SharedWorkflowRepository extends Repository { shared: { project: { projectRelations: { user: true } } }, tags: includeTags, parentFolder: includeParentFolder, + activeVersion: includeActiveVersion, }, }, }); diff --git a/packages/@n8n/db/src/repositories/workflow-history.repository.ts b/packages/@n8n/db/src/repositories/workflow-history.repository.ts index 9127e271fdc84..d552813321ac2 100644 --- a/packages/@n8n/db/src/repositories/workflow-history.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow-history.repository.ts @@ -14,9 +14,9 @@ export class WorkflowHistoryRepository extends Repository { } /** - * Delete workflow history records earlier than a given date, except for current workflow versions. + * Delete workflow history records earlier than a given date, except for current and active workflow versions. */ - async deleteEarlierThanExceptCurrent(date: Date) { + async deleteEarlierThanExceptCurrentAndActive(date: Date) { const currentVersionIdsSubquery = this.manager .createQueryBuilder() .subQuery() @@ -24,12 +24,21 @@ export class WorkflowHistoryRepository extends Repository { .from(WorkflowEntity, 'w') .getQuery(); + const activeVersionIdsSubquery = this.manager + .createQueryBuilder() + .subQuery() + .select('w.activeVersionId') + .from(WorkflowEntity, 'w') + .where('w.activeVersionId IS NOT NULL') + .getQuery(); + return await this.manager .createQueryBuilder() .delete() .from(WorkflowHistory) .where('createdAt < :date', { date }) .andWhere(`versionId NOT IN (${currentVersionIdsSubquery})`) + .andWhere(`versionId NOT IN (${activeVersionIdsSubquery})`) .execute(); } } diff --git a/packages/@n8n/db/src/repositories/workflow-statistics.repository.ts b/packages/@n8n/db/src/repositories/workflow-statistics.repository.ts index 26b7e1c5c1dfe..be470904c513c 100644 --- a/packages/@n8n/db/src/repositories/workflow-statistics.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow-statistics.repository.ts @@ -1,7 +1,14 @@ import { GlobalConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; -import { DataSource, MoreThanOrEqual, QueryFailedError, Repository } from '@n8n/typeorm'; +import { + DataSource, + IsNull, + MoreThanOrEqual, + Not, + QueryFailedError, + Repository, +} from '@n8n/typeorm'; import { WorkflowStatistics } from '../entities'; import type { User } from '../entities'; @@ -125,7 +132,7 @@ export class WorkflowStatisticsRepository extends Repository role: 'workflow:owner', project: { projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } } }, }, - active: true, + activeVersionId: Not(IsNull()), }, name: StatisticsNames.productionSuccess, count: MoreThanOrEqual(5), diff --git a/packages/@n8n/db/src/repositories/workflow.repository.ts b/packages/@n8n/db/src/repositories/workflow.repository.ts index 1d34d5c562688..752d06583cba4 100644 --- a/packages/@n8n/db/src/repositories/workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow.repository.ts @@ -1,6 +1,6 @@ import { GlobalConfig } from '@n8n/config'; import { Service } from '@n8n/di'; -import { DataSource, Repository, In, Like } from '@n8n/typeorm'; +import { DataSource, Repository, In, Like, Not, IsNull } from '@n8n/typeorm'; import type { SelectQueryBuilder, UpdateResult, @@ -71,7 +71,7 @@ export class WorkflowRepository extends Repository { async getAllActiveIds() { const result = await this.find({ select: { id: true }, - where: { active: true }, + where: { activeVersionId: Not(IsNull()) }, relations: { shared: { project: { projectRelations: true } } }, }); @@ -81,7 +81,7 @@ export class WorkflowRepository extends Repository { async getActiveIds({ maxResults }: { maxResults?: number } = {}) { const activeWorkflows = await this.find({ select: ['id'], - where: { active: true }, + where: { activeVersionId: Not(IsNull()) }, // 'take' and 'order' are only needed when maxResults is provided: ...(maxResults ? { take: maxResults, order: { createdAt: 'ASC' } } : {}), }); @@ -90,14 +90,14 @@ export class WorkflowRepository extends Repository { async getActiveCount() { return await this.count({ - where: { active: true }, + where: { activeVersionId: Not(IsNull()) }, }); } async findById(workflowId: string) { return await this.findOne({ where: { id: workflowId }, - relations: { shared: { project: { projectRelations: true } } }, + relations: { shared: { project: { projectRelations: true } }, activeVersion: true }, }); } @@ -113,7 +113,7 @@ export class WorkflowRepository extends Repository { async getActiveTriggerCount() { const totalTriggerCount = await this.sum('triggerCount', { - active: true, + activeVersionId: Not(IsNull()), }); return totalTriggerCount ?? 0; } @@ -585,7 +585,11 @@ export class WorkflowRepository extends Repository { filter: ListQuery.Options['filter'], ): void { if (typeof filter?.active === 'boolean') { - qb.andWhere('workflow.active = :active', { active: filter.active }); + if (filter.active) { + qb.andWhere('workflow.activeVersionId IS NOT NULL'); + } else { + qb.andWhere('workflow.activeVersionId IS NULL'); + } } } @@ -686,6 +690,7 @@ export class WorkflowRepository extends Repository { 'workflow.createdAt', 'workflow.updatedAt', 'workflow.versionId', + 'workflow.activeVersionId', 'workflow.settings', 'workflow.description', ]); @@ -806,19 +811,42 @@ export class WorkflowRepository extends Repository { } async updateActiveState(workflowId: string, newState: boolean) { - return await this.update({ id: workflowId }, { active: newState }); + if (newState) { + return await this.createQueryBuilder() + .update(WorkflowEntity) + .set({ + activeVersionId: () => 'versionId', + active: true, + }) + .where('id = :workflowId', { workflowId }) + .execute(); + } else { + return await this.update({ id: workflowId }, { active: false, activeVersionId: null }); + } } async deactivateAll() { - return await this.update({ active: true }, { active: false }); + return await this.update( + { activeVersionId: Not(IsNull()) }, + { active: false, activeVersionId: null }, + ); } + // We're planning to remove this command in V2, so for now set activeVersion to the current version async activateAll() { - return await this.update({ active: false }, { active: true }); + await this.manager + .createQueryBuilder() + .update(WorkflowEntity) + .set({ + active: true, + activeVersionId: () => 'versionId', + }) + .where('activeVersionId IS NULL') + .execute(); } async findByActiveState(activeState: boolean) { - return await this.findBy({ active: activeState }); + return await this.findBy({ activeVersionId: activeState ? Not(IsNull()) : IsNull() }); } async moveAllToFolder(fromFolderId: string, toFolderId: string, tx: EntityManager) { @@ -854,12 +882,14 @@ export class WorkflowRepository extends Repository { ); const workflows: Array< - Pick & Partial> + Pick & + Partial> > = await qb .select([ 'workflow.id', 'workflow.name', 'workflow.active', + 'workflow.activeVersionId', ...(includeNodes ? ['workflow.nodes'] : []), ]) .where(whereClause, parameters) diff --git a/packages/cli/src/__tests__/active-executions.test.ts b/packages/cli/src/__tests__/active-executions.test.ts index 1d9dad8fd870c..a0f85004e1a4b 100644 --- a/packages/cli/src/__tests__/active-executions.test.ts +++ b/packages/cli/src/__tests__/active-executions.test.ts @@ -65,6 +65,7 @@ describe('ActiveExecutions', () => { id: '123', name: 'Test workflow 1', active: false, + activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), diff --git a/packages/cli/src/__tests__/active-workflow-manager.test.ts b/packages/cli/src/__tests__/active-workflow-manager.test.ts index 734d9c9563563..1bb3bce99433b 100644 --- a/packages/cli/src/__tests__/active-workflow-manager.test.ts +++ b/packages/cli/src/__tests__/active-workflow-manager.test.ts @@ -1,5 +1,5 @@ import { mockLogger } from '@n8n/backend-test-utils'; -import type { WorkflowEntity, WorkflowRepository } from '@n8n/db'; +import type { WorkflowEntity, WorkflowHistory, WorkflowRepository } from '@n8n/db'; import { mock } from 'jest-mock-extended'; import type { InstanceSettings } from 'n8n-core'; import type { @@ -136,7 +136,9 @@ describe('ActiveWorkflowManager', () => { activeWorkflowManager, 'addTriggersAndPollers', ); - workflowRepository.findById.mockResolvedValue(mock({ active: false })); + workflowRepository.findById.mockResolvedValue( + mock({ active: false, activeVersionId: null, activeVersion: null }), + ); const added = await activeWorkflowManager.add('some-id', mode); @@ -165,4 +167,76 @@ describe('ActiveWorkflowManager', () => { expect(getAllActiveIds).toHaveBeenCalledTimes(1); }); }); + + describe('activateWorkflow', () => { + beforeEach(() => { + // Set up as leader to allow workflow activation + Object.assign(instanceSettings, { isLeader: true }); + }); + + test('should use active version when calling executeErrorWorkflow on activation failure', async () => { + // Create different nodes for draft vs active version + const draftNodes = [ + { + id: 'draft-node-1', + name: 'Draft Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + position: [0, 0] as [number, number], + parameters: {}, + }, + ]; + + const activeNodes = [ + { + id: 'active-node-1', + name: 'Active Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + position: [0, 0] as [number, number], + parameters: {}, + }, + ]; + + const activeVersion = mock({ + versionId: 'v1', + workflowId: 'workflow-1', + nodes: activeNodes, + connections: {}, + authors: 'test-user', + createdAt: new Date(), + updatedAt: new Date(), + }); + + const workflowEntity = mock({ + id: 'workflow-1', + name: 'Test Workflow', + active: true, + activeVersionId: activeVersion.versionId, + nodes: draftNodes, + connections: {}, + activeVersion, + }); + + workflowRepository.findById.mockResolvedValue(workflowEntity); + + // Mock the add method to throw an error (simulating activation failure) + jest.spyOn(activeWorkflowManager, 'add').mockRejectedValue(new Error('Authorization failed')); + + const executeErrorWorkflowSpy = jest + .spyOn(activeWorkflowManager, 'executeErrorWorkflow') + .mockImplementation(() => {}); + + await activeWorkflowManager['activateWorkflow']('workflow-1', 'init'); + + expect(executeErrorWorkflowSpy).toHaveBeenCalled(); + + // Get the workflow data that was passed to executeErrorWorkflow + const callArgs = executeErrorWorkflowSpy.mock.calls[0]; + const workflowData = callArgs[1]; + + expect(workflowData.nodes).toEqual(activeNodes); + expect(workflowData.nodes[0].name).toBe('Active Webhook'); + }); + }); }); diff --git a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts index 44730676f9c91..1c23973a24dfb 100644 --- a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts +++ b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts @@ -12,6 +12,7 @@ import type { ExecuteWorkflowOptions, IRun, INodeExecutionData, + INode, } from 'n8n-workflow'; import type PCancelable from 'p-cancelable'; @@ -28,7 +29,12 @@ import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.ser import { UrlService } from '@/services/url.service'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { Telemetry } from '@/telemetry'; -import { executeWorkflow, getBase, getRunData } from '@/workflow-execute-additional-data'; +import { + executeWorkflow, + getBase, + getRunData, + getWorkflowData, +} from '@/workflow-execute-additional-data'; import * as WorkflowHelpers from '@/workflow-helpers'; const EXECUTION_ID = '123'; @@ -130,7 +136,15 @@ describe('WorkflowExecuteAdditionalData', () => { beforeEach(() => { workflowRepository.get.mockResolvedValue( - mock({ id: EXECUTION_ID, nodes: [] }), + mock({ + id: EXECUTION_ID, + name: 'Test Workflow', + active: false, + activeVersionId: null, + activeVersion: null, + nodes: [], + connections: {}, + }), ); activeExecutions.add.mockResolvedValue(EXECUTION_ID); processRunExecutionData.mockReturnValue(getCancelablePromise(runWithData)); @@ -279,6 +293,174 @@ describe('WorkflowExecuteAdditionalData', () => { }); }); + describe('getWorkflowData', () => { + beforeEach(() => { + workflowRepository.get.mockClear(); + }); + + it('should load and use active version when workflow is active', async () => { + const activeVersionNodes: INode[] = [ + mock({ + id: 'active-node', + type: 'n8n-nodes-base.set', + name: 'Active Node', + typeVersion: 1, + parameters: {}, + position: [250, 300], + }), + ]; + const activeVersionConnections = { 'Active Node': {} }; + const currentNodes: INode[] = [ + mock({ + id: 'current-node', + type: 'n8n-nodes-base.set', + name: 'Current Node', + typeVersion: 1, + parameters: {}, + position: [250, 300], + }), + ]; + const currentConnections = { 'Current Node': {} }; + + workflowRepository.get.mockResolvedValue( + mock({ + id: 'workflow-123', + name: 'Test Workflow', + active: true, + activeVersionId: 'version-456', + nodes: currentNodes, + connections: currentConnections, + activeVersion: mock({ + versionId: 'version-456', + workflowId: 'workflow-123', + nodes: activeVersionNodes, + connections: activeVersionConnections, + authors: 'user1', + createdAt: new Date(), + updatedAt: new Date(), + }), + }), + ); + + const result = await getWorkflowData({ id: 'workflow-123' }, 'parent-workflow-id'); + + expect(result.nodes).toEqual(activeVersionNodes); + expect(result.connections).toEqual(activeVersionConnections); + expect(workflowRepository.get).toHaveBeenCalledWith( + { id: 'workflow-123' }, + { relations: ['activeVersion', 'tags'] }, + ); + }); + + it('should use current version when workflow has no active version', async () => { + const currentNodes: INode[] = [ + mock({ + id: 'current-node', + type: 'n8n-nodes-base.set', + name: 'Current Node', + typeVersion: 1, + parameters: {}, + position: [250, 300], + }), + ]; + const currentConnections = { 'Current Node': {} }; + + workflowRepository.get.mockResolvedValue( + mock({ + id: 'workflow-123', + name: 'Test Workflow', + active: false, + activeVersionId: null, + nodes: currentNodes, + connections: currentConnections, + activeVersion: null, + }), + ); + + const result = await getWorkflowData({ id: 'workflow-123' }, 'parent-workflow-id'); + + expect(result.nodes).toEqual(currentNodes); + expect(result.connections).toEqual(currentConnections); + }); + + it('should load activeVersion relation when tags are disabled', async () => { + const globalConfig = Container.get(GlobalConfig); + globalConfig.tags.disabled = true; + + workflowRepository.get.mockResolvedValue( + mock({ + id: 'workflow-123', + active: false, + activeVersionId: null, + nodes: [], + connections: {}, + activeVersion: null, + }), + ); + + await getWorkflowData({ id: 'workflow-123' }, 'parent-workflow-id'); + + expect(workflowRepository.get).toHaveBeenCalledWith( + { id: 'workflow-123' }, + { relations: ['activeVersion'] }, + ); + + globalConfig.tags.disabled = false; + }); + + it('should throw error when workflow does not exist', async () => { + workflowRepository.get.mockResolvedValue(null); + + await expect(getWorkflowData({ id: 'non-existent' }, 'parent-workflow-id')).rejects.toThrow( + 'Workflow does not exist', + ); + }); + + it('should use provided workflow code when id is not provided', async () => { + const workflowCode = mock({ + id: 'code-workflow', + name: 'Code Workflow', + active: false, + nodes: [ + mock({ + id: 'node1', + type: 'n8n-nodes-base.set', + name: 'Node 1', + typeVersion: 1, + parameters: {}, + position: [250, 300], + }), + ], + connections: {}, + }); + + const result = await getWorkflowData({ code: workflowCode }, 'parent-workflow-id'); + + expect(result).toEqual(workflowCode); + expect(workflowRepository.get).not.toHaveBeenCalled(); + }); + + it('should set parent workflow settings when not provided in code', async () => { + const workflowCode = mock({ + id: 'code-workflow', + name: 'Code Workflow', + active: false, + nodes: [], + connections: {}, + settings: undefined, + }); + const parentSettings = { executionOrder: 'v1' as const }; + + const result = await getWorkflowData( + { code: workflowCode }, + 'parent-workflow-id', + parentSettings, + ); + + expect(result.settings).toEqual(parentSettings); + }); + }); + describe('getBase', () => { const mockWebhookBaseUrl = 'webhook-base-url.com'; jest.spyOn(urlService, 'getWebhookBaseUrl').mockReturnValue(mockWebhookBaseUrl); diff --git a/packages/cli/src/active-workflow-manager.ts b/packages/cli/src/active-workflow-manager.ts index 3f137b956e6b9..7d952adfbc615 100644 --- a/packages/cli/src/active-workflow-manager.ts +++ b/packages/cli/src/active-workflow-manager.ts @@ -144,11 +144,11 @@ export class ActiveWorkflowManager { */ async isActive(workflowId: WorkflowId) { const workflow = await this.workflowRepository.findOne({ - select: ['active'], + select: ['activeVersionId'], where: { id: workflowId }, }); - return !!workflow?.active; + return !!workflow?.activeVersionId; } /** @@ -248,18 +248,27 @@ export class ActiveWorkflowManager { async clearWebhooks(workflowId: WorkflowId) { const workflowData = await this.workflowRepository.findOne({ where: { id: workflowId }, + relations: { activeVersion: true }, }); if (workflowData === null) { throw new UnexpectedError('Could not find workflow', { extra: { workflowId } }); } + if (!workflowData.activeVersion) { + throw new UnexpectedError('Active version not found for workflow', { + extra: { workflowId }, + }); + } + + const { nodes, connections } = workflowData.activeVersion; + const workflow = new Workflow({ id: workflowId, name: workflowData.name, - nodes: workflowData.nodes, - connections: workflowData.connections, - active: workflowData.active, + nodes, + connections, + active: true, nodeTypes: this.nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings, @@ -488,8 +497,17 @@ export class ActiveWorkflowManager { }, ); + if (!dbWorkflow.activeVersion) { + throw new UnexpectedError('Active version not found for workflow', { + extra: { workflowId: dbWorkflow.id }, + }); + } + + const { nodes, connections } = dbWorkflow.activeVersion; + const workflowForError = { ...dbWorkflow, nodes, connections }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - this.executeErrorWorkflow(error, dbWorkflow, 'internal'); + this.executeErrorWorkflow(error, workflowForError, 'internal'); // do not keep trying to activate on authorization error // eslint-disable-next-line @typescript-eslint/no-unsafe-call @@ -568,7 +586,7 @@ export class ActiveWorkflowManager { }); } - if (['init', 'leadershipChange'].includes(activationMode) && !dbWorkflow.active) { + if (['init', 'leadershipChange'].includes(activationMode) && !dbWorkflow.activeVersion) { this.logger.debug( `Skipping workflow ${formatWorkflow(dbWorkflow)} as it is no longer active`, { workflowId: dbWorkflow.id }, @@ -577,12 +595,23 @@ export class ActiveWorkflowManager { return added; } + // Get workflow data from the active version + if (!dbWorkflow.activeVersion) { + throw new UnexpectedError('Active version not found for workflow', { + extra: { workflowId: dbWorkflow.id }, + }); + } + + const { nodes, connections } = dbWorkflow.activeVersion; + dbWorkflow.nodes = nodes; + dbWorkflow.connections = connections; + workflow = new Workflow({ id: dbWorkflow.id, name: dbWorkflow.name, - nodes: dbWorkflow.nodes, - connections: dbWorkflow.connections, - active: dbWorkflow.active, + nodes, + connections, + active: true, nodeTypes: this.nodeTypes, staticData: dbWorkflow.staticData, settings: dbWorkflow.settings, @@ -683,7 +712,7 @@ export class ActiveWorkflowManager { const error = ensureError(e); const { message } = error; - await this.workflowRepository.update(workflowId, { active: false }); + await this.workflowRepository.update(workflowId, { active: false, activeVersionId: null }); this.push.broadcast({ type: 'workflowFailedToActivate', diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts index 164362c94d431..986d8b796e7e0 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts @@ -27,6 +27,8 @@ import type { ExportableFolder } from '../types/exportable-folders'; import type { ExportableProject } from '../types/exportable-project'; import { SourceControlContext } from '../types/source-control-context'; +import type { ActiveWorkflowManager } from '@/active-workflow-manager'; + jest.mock('fast-glob'); const globalAdminContext = new SourceControlContext( @@ -50,11 +52,12 @@ describe('SourceControlImportService', () => { const sourceControlScopedService = mock(); const variableService = mock(); const variablesRepository = mock(); + const activeWorkflowManager = mock(); const service = new SourceControlImportService( mockLogger, mock(), variableService, - mock(), + activeWorkflowManager, mock(), projectRepository, mock(), @@ -259,6 +262,232 @@ describe('SourceControlImportService', () => { expect.any(Object), ); }); + + it('should set new workflows as inactive with null activeVersionId', async () => { + const mockUserId = 'user-id-123'; + const mockWorkflowFile = '/mock/workflow1.json'; + const mockWorkflowData = { + id: 'workflow1', + name: 'New Workflow', + nodes: [], + parentFolderId: null, + }; + const candidates = [mock({ file: mockWorkflowFile, id: 'workflow1' })]; + + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue( + Object.assign(new Project(), { id: 'project1', type: 'personal' }), + ); + workflowRepository.findByIds.mockResolvedValue([]); + folderRepository.find.mockResolvedValue([]); + sharedWorkflowRepository.findWithFields.mockResolvedValue([]); + workflowRepository.upsert.mockResolvedValue({ + identifiers: [{ id: 'workflow1' }], + generatedMaps: [], + raw: [], + }); + + fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData)); + + await service.importWorkflowFromWorkFolder(candidates, mockUserId); + + expect(workflowRepository.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'workflow1', + active: false, + activeVersionId: null, + }), + ['id'], + ); + expect(activeWorkflowManager.remove).not.toHaveBeenCalled(); + expect(activeWorkflowManager.add).not.toHaveBeenCalled(); + }); + + it('should keep existing inactive workflows inactive', async () => { + const mockUserId = 'user-id-123'; + const mockWorkflowFile = '/mock/workflow1.json'; + const mockWorkflowData = { + id: 'workflow1', + name: 'Existing Workflow', + nodes: [], + parentFolderId: null, + }; + const candidates = [mock({ file: mockWorkflowFile, id: 'workflow1' })]; + + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue( + Object.assign(new Project(), { id: 'project1', type: 'personal' }), + ); + workflowRepository.findByIds.mockResolvedValue([ + Object.assign(new WorkflowEntity(), { + id: 'workflow1', + name: 'Existing Workflow', + active: false, + activeVersionId: null, + }), + ]); + folderRepository.find.mockResolvedValue([]); + sharedWorkflowRepository.findWithFields.mockResolvedValue([]); + workflowRepository.upsert.mockResolvedValue({ + identifiers: [{ id: 'workflow1' }], + generatedMaps: [], + raw: [], + }); + + fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData)); + + await service.importWorkflowFromWorkFolder(candidates, mockUserId); + + expect(workflowRepository.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'workflow1', + active: false, + activeVersionId: null, + }), + ['id'], + ); + expect(activeWorkflowManager.remove).not.toHaveBeenCalled(); + expect(activeWorkflowManager.add).not.toHaveBeenCalled(); + }); + + it('should reactivate existing active workflows', async () => { + const mockUserId = 'user-id-123'; + const mockWorkflowFile = '/mock/workflow1.json'; + const mockWorkflowData = { + id: 'workflow1', + name: 'Active Workflow', + nodes: [], + parentFolderId: null, + }; + const candidates = [mock({ file: mockWorkflowFile, id: 'workflow1' })]; + + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue( + Object.assign(new Project(), { id: 'project1', type: 'personal' }), + ); + workflowRepository.findByIds.mockResolvedValue([ + Object.assign(new WorkflowEntity(), { + id: 'workflow1', + name: 'Active Workflow', + active: true, + activeVersionId: 'version-123', + }), + ]); + folderRepository.find.mockResolvedValue([]); + sharedWorkflowRepository.findWithFields.mockResolvedValue([]); + workflowRepository.upsert.mockResolvedValue({ + identifiers: [{ id: 'workflow1' }], + generatedMaps: [], + raw: [], + }); + + fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData)); + + await service.importWorkflowFromWorkFolder(candidates, mockUserId); + + expect(workflowRepository.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'workflow1', + active: true, + activeVersionId: 'version-123', + }), + ['id'], + ); + expect(activeWorkflowManager.remove).toHaveBeenCalledWith('workflow1'); + expect(activeWorkflowManager.add).toHaveBeenCalledWith('workflow1', 'activate'); + }); + + it('should deactivate archived workflows even if they were previously active', async () => { + const mockUserId = 'user-id-123'; + const mockWorkflowFile = '/mock/workflow1.json'; + const mockWorkflowData = { + id: 'workflow1', + name: 'Archived Workflow', + nodes: [], + parentFolderId: null, + isArchived: true, + }; + const candidates = [mock({ file: mockWorkflowFile, id: 'workflow1' })]; + + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue( + Object.assign(new Project(), { id: 'project1', type: 'personal' }), + ); + workflowRepository.findByIds.mockResolvedValue([ + Object.assign(new WorkflowEntity(), { + id: 'workflow1', + name: 'Archived Workflow', + active: true, + activeVersionId: 'version-123', + }), + ]); + folderRepository.find.mockResolvedValue([]); + sharedWorkflowRepository.findWithFields.mockResolvedValue([]); + workflowRepository.upsert.mockResolvedValue({ + identifiers: [{ id: 'workflow1' }], + generatedMaps: [], + raw: [], + }); + + fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData)); + + await service.importWorkflowFromWorkFolder(candidates, mockUserId); + + expect(workflowRepository.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'workflow1', + active: false, + activeVersionId: null, + }), + ['id'], + ); + expect(activeWorkflowManager.remove).toHaveBeenCalledWith('workflow1'); + expect(activeWorkflowManager.add).not.toHaveBeenCalled(); + }); + + it('should handle activation errors gracefully', async () => { + const mockUserId = 'user-id-123'; + const mockWorkflowFile = '/mock/workflow1.json'; + const mockWorkflowData = { + id: 'workflow1', + name: 'Workflow with activation error', + nodes: [], + parentFolderId: null, + }; + const candidates = [mock({ file: mockWorkflowFile, id: 'workflow1' })]; + + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue( + Object.assign(new Project(), { id: 'project1', type: 'personal' }), + ); + workflowRepository.findByIds.mockResolvedValue([ + Object.assign(new WorkflowEntity(), { + id: 'workflow1', + name: 'Workflow with activation error', + active: true, + activeVersionId: 'version-123', + }), + ]); + folderRepository.find.mockResolvedValue([]); + sharedWorkflowRepository.findWithFields.mockResolvedValue([]); + workflowRepository.upsert.mockResolvedValue({ + identifiers: [{ id: 'workflow1' }], + generatedMaps: [], + raw: [], + }); + workflowRepository.update.mockResolvedValue({ + generatedMaps: [], + raw: [], + affected: 1, + }); + activeWorkflowManager.add.mockRejectedValue(new Error('Activation failed')); + + fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData)); + + const result = await service.importWorkflowFromWorkFolder(candidates, mockUserId); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to activate workflow workflow1', + expect.any(Object), + ); + expect(workflowRepository.update).toHaveBeenCalled(); + expect(result).toEqual([{ id: 'workflow1', name: mockWorkflowFile }]); + }); }); describe('getRemoteCredentialsFromFiles', () => { diff --git a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts index 240f2f92f169f..11d8df771c02c 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts @@ -634,7 +634,7 @@ export class SourceControlImportService { const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(userId); const candidateIds = candidates.map((c) => c.id); const existingWorkflows = await this.workflowRepository.findByIds(candidateIds, { - fields: ['id', 'name', 'versionId', 'active'], + fields: ['id', 'name', 'versionId', 'active', 'activeVersionId'], }); const folders = await this.folderRepository.find({ select: ['id'] }); @@ -662,9 +662,18 @@ export class SourceControlImportService { // IWorkflowToImport having it typed as boolean. Imported workflows are always inactive if they are new, // and existing workflows use the existing workflow's active status unless they have been archived on the remote. // In that case, we deactivate the existing workflow on pull and turn it archived. - importedWorkflow.active = existingWorkflow - ? existingWorkflow.active && !importedWorkflow.isArchived - : false; + if (existingWorkflow) { + if (importedWorkflow.isArchived) { + importedWorkflow.active = false; + importedWorkflow.activeVersionId = null; + } else { + importedWorkflow.active = !!existingWorkflow.activeVersionId; + importedWorkflow.activeVersionId = existingWorkflow.activeVersionId; + } + } else { + importedWorkflow.active = false; + importedWorkflow.activeVersionId = null; + } const parentFolderId = importedWorkflow.parentFolderId ?? ''; @@ -695,7 +704,7 @@ export class SourceControlImportService { repository: this.sharedWorkflowRepository, }); - if (existingWorkflow?.active) { + if (existingWorkflow?.activeVersionId) { await this.activateImportedWorkflow({ existingWorkflow, importedWorkflow }); } @@ -733,7 +742,7 @@ export class SourceControlImportService { this.logger.debug(`Deactivating workflow id ${existingWorkflow.id}`); await this.activeWorkflowManager.remove(existingWorkflow.id); - if (importedWorkflow.active) { + if (importedWorkflow.activeVersionId) { // try activating the imported workflow this.logger.debug(`Reactivating workflow id ${existingWorkflow.id}`); await this.activeWorkflowManager.add(existingWorkflow.id, 'activate'); diff --git a/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts b/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts index 96b30e7ae1166..7ba3fdcc81bd8 100644 --- a/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts +++ b/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts @@ -6,7 +6,7 @@ import { Service } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { DeleteResult } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import -import { In } from '@n8n/typeorm'; +import { In, IsNull, Not } from '@n8n/typeorm'; import EventEmitter from 'events'; import uniqby from 'lodash/uniqBy'; import type { MessageEventBusDestinationOptions } from 'n8n-workflow'; @@ -165,7 +165,7 @@ export class MessageEventBus extends EventEmitter { if (unfinishedExecutionIds.length > 0) { const activeWorkflows = await this.workflowRepository.find({ - where: { active: true }, + where: { activeVersionId: Not(IsNull()) }, select: ['id', 'name'], }); if (activeWorkflows.length > 0) { diff --git a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts index 576b1fc24cb4c..9eb4f2f2a70c0 100644 --- a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts @@ -172,6 +172,7 @@ describe('LogStreamingEventRelay', () => { id: 'wf202', name: 'Test Workflow', active: true, + activeVersionId: 'some-version-id', nodes: [], connections: {}, staticData: undefined, @@ -608,6 +609,7 @@ describe('LogStreamingEventRelay', () => { id: 'wf303', name: 'Test Workflow with Nodes', active: true, + activeVersionId: 'some-version-id', nodes: [ { id: 'node1', @@ -656,6 +658,7 @@ describe('LogStreamingEventRelay', () => { id: 'wf404', name: 'Test Workflow with Completed Node', active: true, + activeVersionId: 'some-version-id', nodes: [ { id: 'node1', diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index bce4deb370ae0..2566a1cd923ef 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -1215,6 +1215,7 @@ describe('TelemetryEventRelay', () => { id: 'workflow123', name: 'Test Workflow', active: true, + activeVersionId: 'some-version-id', nodes: [ { id: 'node1', diff --git a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts index 5149f7a05c0a5..8fae0d993c745 100644 --- a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts @@ -62,6 +62,7 @@ describe('Execution Lifecycle Hooks', () => { id: workflowId, name: 'Test Workflow', active: true, + activeVersionId: 'some-version-id', isArchived: false, connections: {}, nodes: [ diff --git a/packages/cli/src/execution-lifecycle/shared/shared-hook-functions.ts b/packages/cli/src/execution-lifecycle/shared/shared-hook-functions.ts index c1a10d01238e9..bc8e27f77631f 100644 --- a/packages/cli/src/execution-lifecycle/shared/shared-hook-functions.ts +++ b/packages/cli/src/execution-lifecycle/shared/shared-hook-functions.ts @@ -38,6 +38,7 @@ export function prepareExecutionDataForDbUpdate(parameters: { 'id', 'name', 'active', + 'activeVersionId', 'isArchived', 'createdAt', 'updatedAt', diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index cb6b31633863a..341e01de252ad 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -183,6 +183,7 @@ export class ExecutionService { const executionMode = 'retry'; execution.workflowData.active = false; + execution.workflowData.activeVersionId = null; // Start the workflow const data: IWorkflowExecutionDataProcess = { diff --git a/packages/cli/src/executions/execution.utils.ts b/packages/cli/src/executions/execution.utils.ts new file mode 100644 index 0000000000000..56409374035e0 --- /dev/null +++ b/packages/cli/src/executions/execution.utils.ts @@ -0,0 +1,15 @@ +import type { IWorkflowBase } from 'n8n-workflow'; + +/** + * Determines the active status of a workflow from workflow data. + * + * This function handles backward compatibility: + * - Newer workflow data uses `activeVersionId` (string = active, null/undefined = inactive) + * - Older workflow data (before activeVersionId was introduced) falls back to the `active` boolean field + * + * @param workflowData - Workflow data + * @returns true if the workflow should be considered active, false otherwise + */ +export function getWorkflowActiveStatusFromWorkflowData(workflowData: IWorkflowBase): boolean { + return !!workflowData.activeVersionId || workflowData.active; +} diff --git a/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts b/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts index e827e5903cbee..c2d353822bdf9 100644 --- a/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts +++ b/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts @@ -6,6 +6,7 @@ export class WorkflowSelect extends BaseSelect { 'id', // always included downstream 'name', 'active', + 'activeVersionId', 'tags', 'createdAt', 'updatedAt', diff --git a/packages/cli/src/modules/breaking-changes/__tests__/test-helpers.ts b/packages/cli/src/modules/breaking-changes/__tests__/test-helpers.ts index 86872fbc90634..40a363d80b00d 100644 --- a/packages/cli/src/modules/breaking-changes/__tests__/test-helpers.ts +++ b/packages/cli/src/modules/breaking-changes/__tests__/test-helpers.ts @@ -6,6 +6,8 @@ export const createWorkflow = (id: string, name: string, nodes: INode[], active id, name, active, + activeVersionId: active ? 'v1' : null, + versionId: 'v1', nodes, statistics: [ { 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 d7f01743a2953..4828390cd8b4b 100644 --- a/packages/cli/src/modules/breaking-changes/breaking-changes.service.ts +++ b/packages/cli/src/modules/breaking-changes/breaking-changes.service.ts @@ -87,7 +87,7 @@ export class BreakingChangeService { // Process workflows in batches for (let skip = 0; skip < totalWorkflows; skip += this.batchSize) { const workflows = await this.workflowRepository.find({ - select: ['id', 'name', 'active', 'nodes', 'updatedAt', 'statistics'], + select: ['id', 'name', 'active', 'activeVersionId', 'nodes', 'updatedAt', 'statistics'], skip, take: this.batchSize, order: { id: 'ASC' }, @@ -115,7 +115,7 @@ export class BreakingChangeService { const affectedWorkflow: BreakingChangeAffectedWorkflow = { id: workflow.id, name: workflow.name, - active: workflow.active, + active: !!workflow.activeVersionId, issues: workflowDetectionResult.issues, numberOfExecutions: workflow.statistics.reduce( (acc, cur) => acc + (cur.count || 0), diff --git a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts index ab5fb8041ad82..81fe2e2840e89 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts @@ -80,6 +80,7 @@ export class ChatHubWorkflowService { newWorkflow.versionId = uuidv4(); newWorkflow.name = `Chat ${sessionId}`; newWorkflow.active = false; + newWorkflow.activeVersionId = null; newWorkflow.nodes = nodes; newWorkflow.connections = connections; newWorkflow.settings = { @@ -129,6 +130,7 @@ export class ChatHubWorkflowService { newWorkflow.versionId = uuidv4(); newWorkflow.name = `Chat ${sessionId} (Title Generation)`; newWorkflow.active = false; + newWorkflow.activeVersionId = null; newWorkflow.nodes = nodes; newWorkflow.connections = connections; newWorkflow.settings = { diff --git a/packages/cli/src/modules/chat-hub/chat-hub.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.service.ts index 7d2fcb9d2733d..c8b78b445cb35 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.service.ts @@ -546,7 +546,7 @@ export class ChatHubService { models: workflows // Ensure the user has at least read access to the workflow .filter((workflow) => workflow.scopes.includes('workflow:read')) - .filter((workflow) => workflow.active) + .filter((workflow) => !!workflow.activeVersionId) .flatMap((workflow) => { const chatTrigger = workflow.nodes?.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE); if (!chatTrigger) { diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts index 25ce535430771..bdd8dc26d90b8 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts @@ -203,6 +203,7 @@ describe('McpSettingsController', () => { const entity = new WorkflowEntity(); entity.id = workflowId; entity.active = true; + entity.activeVersionId = overrides.active === false ? null : 'current-version-id'; entity.nodes = [createWebhookNode()]; entity.settings = { saveManualExecutions: true }; entity.versionId = 'current-version-id'; diff --git a/packages/cli/src/modules/mcp/__tests__/mock.utils.ts b/packages/cli/src/modules/mcp/__tests__/mock.utils.ts index cd2eb05d8c8a2..7364e8cb54c16 100644 --- a/packages/cli/src/modules/mcp/__tests__/mock.utils.ts +++ b/packages/cli/src/modules/mcp/__tests__/mock.utils.ts @@ -27,6 +27,8 @@ export const createWorkflow = (overrides: Partial = {}) => ({ }, ], active: overrides.active ?? false, + versionId: 'some-version-id', + activeVersionId: overrides.active ? 'some-version-id' : null, isArchived: overrides.isArchived ?? false, createdAt: overrides.createdAt ?? new Date('2024-01-01T00:00:00.000Z'), updatedAt: overrides.updatedAt ?? new Date('2024-01-02T00:00:00.000Z'), diff --git a/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts index 8a2cfb29f11aa..ab34340c70876 100644 --- a/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts @@ -73,6 +73,7 @@ describe('search-workflows MCP tool', () => { id: 'a', name: 'Alpha', active: false, + activeVersionId: null, createdAt: new Date('2024-01-01T00:00:00.000Z').toISOString(), updatedAt: new Date('2024-01-02T00:00:00.000Z').toISOString(), triggerCount: 1, @@ -82,6 +83,7 @@ describe('search-workflows MCP tool', () => { id: 'b', name: 'Beta', active: true, + activeVersionId: workflows[1].versionId, createdAt: new Date('2024-01-01T00:00:00.000Z').toISOString(), updatedAt: new Date('2024-01-02T00:00:00.000Z').toISOString(), triggerCount: 1, diff --git a/packages/cli/src/modules/mcp/mcp.settings.controller.ts b/packages/cli/src/modules/mcp/mcp.settings.controller.ts index 8d5288f4e1d1b..72018814d10f4 100644 --- a/packages/cli/src/modules/mcp/mcp.settings.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.settings.controller.ts @@ -88,7 +88,7 @@ export class McpSettingsController { ); } - if (!workflow.active && dto.availableInMCP) { + if (!workflow.activeVersionId && dto.availableInMCP) { throw new BadRequestError('MCP access can only be set for active workflows'); } diff --git a/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts b/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts index eeb1e3ce5bed0..7703cb34a4f46 100644 --- a/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts +++ b/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts @@ -126,7 +126,7 @@ export async function getWorkflowDetails( const sanitizedWorkflow: WorkflowDetailsResult['workflow'] = { id: workflow.id, name: workflow.name, - active: workflow.active, + active: workflow.activeVersionId !== null, isArchived: workflow.isArchived, versionId: workflow.versionId, triggerCount: workflow.triggerCount, diff --git a/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts b/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts index 05dfd7bc2530a..39cd87fb28fbd 100644 --- a/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts +++ b/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts @@ -165,11 +165,22 @@ export async function searchWorkflows( ); const formattedWorkflows: SearchWorkflowsItem[] = (workflows as WorkflowEntity[]).map( - ({ id, name, description, active, createdAt, updatedAt, triggerCount, nodes }) => ({ + ({ id, name, description, active, + activeVersionId, + createdAt, + updatedAt, + triggerCount, + nodes, + }) => ({ + id, + name, + description, + active, + activeVersionId, createdAt: createdAt.toISOString(), updatedAt: updatedAt.toISOString(), triggerCount, diff --git a/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts b/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts index e0c2cd4a9c52d..4d53d469adaa2 100644 --- a/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts +++ b/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts @@ -44,6 +44,7 @@ describe('WorkflowIndexService', () => { id: 'workflow-123', name: 'Test Workflow', active: true, + activeVersionId: 'some-version-id', isArchived: false, createdAt: new Date(), updatedAt: new Date(), diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index f511d660cddfb..d665e42929205 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -82,7 +82,7 @@ export declare namespace WorkflowRequest { type Get = AuthenticatedRequest<{ id: string }, {}, {}, { excludePinnedData?: boolean }>; type Delete = Get; type Update = AuthenticatedRequest<{ id: string }, {}, WorkflowEntity, {}>; - type Activate = Get; + type Activate = AuthenticatedRequest<{ id: string }, {}, {}, {}>; type GetTags = Get; type UpdateTags = AuthenticatedRequest<{ id: string }, {}, TagEntity[]>; type Transfer = AuthenticatedRequest<{ id: string }, {}, { destinationProjectId: string }>; diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/activeVersion.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/activeVersion.yml new file mode 100644 index 0000000000000..2afb5e1da715c --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/activeVersion.yml @@ -0,0 +1,37 @@ +type: object +readOnly: true +nullable: true +additionalProperties: false +properties: + versionId: + type: string + readOnly: true + description: Unique identifier for this workflow version + example: 7c6b9e3f-8d4a-4b2c-9f1e-6a5d3b8c7e4f + workflowId: + type: string + readOnly: true + description: The workflow this version belongs to + example: 2tUt1wbLX592XDdX + nodes: + type: array + readOnly: true + items: + $ref: './node.yml' + connections: + type: object + readOnly: true + example: { Jira: { main: [[{ node: 'Jira', type: 'main', index: 0 }]] } } + authors: + type: string + readOnly: true + description: Comma-separated list of author IDs who contributed to this version + example: 1,2,3 + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflow.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflow.yml index 822bdc5c7adc1..7f36ea82d077d 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflow.yml +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflow.yml @@ -50,3 +50,5 @@ properties: type: array items: $ref: './sharedWorkflow.yml' + activeVersion: + $ref: './activeVersion.yml' diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts index ef126b29d362d..719aa8a259a8f 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts @@ -2,7 +2,7 @@ import { GlobalConfig } from '@n8n/config'; import { WorkflowEntity, ProjectRepository, TagRepository, WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import -import { In, Like, QueryFailedError } from '@n8n/typeorm'; +import { In, IsNull, Like, Not, QueryFailedError } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { FindOptionsWhere } from '@n8n/typeorm'; import type express from 'express'; @@ -12,7 +12,11 @@ import { z } from 'zod'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; -import { addNodeIds, replaceInvalidCredentials } from '@/workflow-helpers'; +import { + addNodeIds, + getActiveVersionUpdateValue, + replaceInvalidCredentials, +} from '@/workflow-helpers'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import { WorkflowService } from '@/workflows/workflow.service'; @@ -116,7 +120,10 @@ export = { id, req.user, ['workflow:read'], - { includeTags: !Container.get(GlobalConfig).tags.disabled }, + { + includeTags: !Container.get(GlobalConfig).tags.disabled, + includeActiveVersion: true, + }, ); if (!workflow) { @@ -153,10 +160,18 @@ export = { } = req.query; const where: FindOptionsWhere = { - ...(active !== undefined && { active }), ...(name !== undefined && { name: Like('%' + name.trim() + '%') }), }; + // Filter by active status based on activeVersionId + if (active !== undefined) { + if (active) { + where.activeVersionId = Not(IsNull()); + } else { + where.activeVersionId = IsNull(); + } + } + if (['global:owner', 'global:admin'].includes(req.user.role.slug)) { if (tags) { const workflowIds = await Container.get(TagRepository).getWorkflowIdsViaTags( @@ -211,10 +226,11 @@ export = { where.id = In(workflowsIds); } - const selectFields: (keyof WorkflowEntity)[] = [ + const selectFields: Array = [ 'id', 'name', 'active', + 'activeVersionId', 'createdAt', 'updatedAt', 'isArchived', @@ -232,7 +248,7 @@ export = { selectFields.push('pinData'); } - const relations = ['shared']; + const relations = ['shared', 'activeVersion']; if (!Container.get(GlobalConfig).tags.disabled) { relations.push('tags'); } @@ -279,6 +295,7 @@ export = { id, req.user, ['workflow:update'], + { includeActiveVersion: true }, ); if (!workflow) { @@ -292,13 +309,28 @@ export = { const workflowManager = Container.get(ActiveWorkflowManager); - if (workflow.active) { + if (workflow.activeVersionId !== null) { // When workflow gets saved always remove it as the triggers could have been // changed and so the changes would not take effect await workflowManager.remove(id); } try { + // First add a record to workflow history to be able to get the full version object during the update + await Container.get(WorkflowHistoryService).saveVersion(req.user, updateData, workflow.id); + + const updatedVersion = await Container.get(WorkflowHistoryService).getVersion( + req.user, + id, + updateData.versionId, + ); + + updateData.activeVersion = getActiveVersionUpdateValue( + workflow, + updatedVersion, + undefined, // active is read-only + ); + await updateWorkflow(workflow, updateData); } catch (error) { if (error instanceof Error) { @@ -306,7 +338,7 @@ export = { } } - if (workflow.active) { + if (workflow.activeVersionId !== null) { try { await workflowManager.add(workflow.id, 'update'); } catch (error) { @@ -318,14 +350,6 @@ export = { const updatedWorkflow = await getWorkflowById(workflow.id); - if (updatedWorkflow) { - await Container.get(WorkflowHistoryService).saveVersion( - req.user, - updatedWorkflow, - workflow.id, - ); - } - await Container.get(ExternalHooks).run('workflow.afterUpdate', [updateData]); Container.get(EventService).emit('workflow-saved', { user: req.user, @@ -346,6 +370,7 @@ export = { id, req.user, ['workflow:update'], + { includeActiveVersion: true }, ); if (!workflow) { @@ -354,20 +379,35 @@ export = { return res.status(404).json({ message: 'Not Found' }); } - if (!workflow.active) { + const activeVersionId = workflow.versionId; + + const newVersionIsBeingActivated = + activeVersionId && activeVersionId !== workflow.activeVersion?.versionId; + + if (!workflow.activeVersionId || newVersionIsBeingActivated) { try { + // change the status to active in the DB + const activeVersion = await setWorkflowAsActive(req.user, workflow.id, activeVersionId); + await Container.get(ActiveWorkflowManager).add(workflow.id, 'activate'); + + // Update the workflow object for response + workflow.active = true; + workflow.activeVersionId = activeVersionId; + workflow.activeVersion = activeVersion; } catch (error) { + // Rollback: restore previous state + await Container.get(WorkflowRepository).update(workflow.id, { + active: workflow.active, + activeVersion: workflow.activeVersion, + updatedAt: new Date(), + }); + if (error instanceof Error) { return res.status(400).json({ message: error.message }); } } - // change the status to active in the DB - await setWorkflowAsActive(workflow.id); - - workflow.active = true; - Container.get(EventService).emit('workflow-activated', { user: req.user, workflowId: workflow.id, @@ -378,7 +418,7 @@ export = { return res.json(workflow); } - // nothing to do as the workflow is already active + // nothing to do as this version is already active return res.json(workflow); }, ], @@ -402,12 +442,15 @@ export = { const activeWorkflowManager = Container.get(ActiveWorkflowManager); - if (workflow.active) { + if (workflow.activeVersionId) { await activeWorkflowManager.remove(workflow.id); await setWorkflowAsInactive(workflow.id); + // Update the workflow object for response workflow.active = false; + workflow.activeVersionId = null; + workflow.activeVersion = null; Container.get(EventService).emit('workflow-deactivated', { user: req.user, diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts index 83dd1c00af7cf..9fe88b980324a 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts @@ -13,6 +13,7 @@ import { PROJECT_OWNER_ROLE_SLUG, type Scope, type WorkflowSharingRole } from '@ import type { WorkflowId } from 'n8n-workflow'; import { License } from '@/license'; +import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; function insertIf(condition: boolean, elements: string[]): string[] { @@ -85,16 +86,26 @@ export async function createWorkflow( }); } -export async function setWorkflowAsActive(workflowId: WorkflowId) { +export async function setWorkflowAsActive(user: User, workflowId: WorkflowId, versionId: string) { + const activeVersion = await Container.get(WorkflowHistoryService).getVersion( + user, + workflowId, + versionId, + ); + await Container.get(WorkflowRepository).update(workflowId, { active: true, + activeVersion, updatedAt: new Date(), }); + + return activeVersion; } export async function setWorkflowAsInactive(workflowId: WorkflowId) { return await Container.get(WorkflowRepository).update(workflowId, { active: false, + activeVersion: null, updatedAt: new Date(), }); } diff --git a/packages/cli/src/scaling/job-processor.ts b/packages/cli/src/scaling/job-processor.ts index e54dc498d957f..0699f0aca2f74 100644 --- a/packages/cli/src/scaling/job-processor.ts +++ b/packages/cli/src/scaling/job-processor.ts @@ -26,6 +26,7 @@ import type { import { EventService } from '@/events/event.service'; import { getLifecycleHooksForScalingWorker } from '@/execution-lifecycle/execution-lifecycle-hooks'; +import { getWorkflowActiveStatusFromWorkflowData } from '@/executions/execution.utils'; import { ManualExecutionService } from '@/manual-execution.service'; import { NodeTypes } from '@/node-types'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; @@ -114,7 +115,7 @@ export class JobProcessor { name: execution.workflowData.name, nodes: execution.workflowData.nodes, connections: execution.workflowData.connections, - active: execution.workflowData.active, + active: getWorkflowActiveStatusFromWorkflowData(execution.workflowData), nodeTypes: this.nodeTypes, staticData, settings: execution.workflowData.settings, diff --git a/packages/cli/src/security-audit/risk-reporters/credentials-risk-reporter.ts b/packages/cli/src/security-audit/risk-reporters/credentials-risk-reporter.ts index 7724d07467af9..27818a24661eb 100644 --- a/packages/cli/src/security-audit/risk-reporters/credentials-risk-reporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/credentials-risk-reporter.ts @@ -19,7 +19,7 @@ export class CredentialsRiskReporter implements RiskReporter { const days = this.securityConfig.daysAbandonedWorkflow; const allExistingCreds = await this.getAllExistingCreds(); - const { credsInAnyUse, credsInActiveUse } = await this.getAllCredsInUse(workflows); + const { credsInAnyUse, credsInActiveUse } = this.getAllCredsInUse(workflows); const recentlyExecutedCreds = await this.getCredsInRecentlyExecutedWorkflows(days); const credsNotInAnyUse = allExistingCreds.filter((c) => !credsInAnyUse.has(c.id)); @@ -81,7 +81,7 @@ export class CredentialsRiskReporter implements RiskReporter { return report; } - private async getAllCredsInUse(workflows: IWorkflowBase[]) { + private getAllCredsInUse(workflows: IWorkflowBase[]) { const credsInAnyUse = new Set(); const credsInActiveUse = new Set(); @@ -94,7 +94,9 @@ export class CredentialsRiskReporter implements RiskReporter { credsInAnyUse.add(cred.id); - if (workflow.active) credsInActiveUse.add(cred.id); + if (workflow.activeVersionId !== null) { + credsInActiveUse.add(cred.id); + } }); }); }); diff --git a/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts b/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts index b396ec072786d..89f94bab12423 100644 --- a/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts @@ -130,7 +130,7 @@ export class InstanceRiskReporter implements RiskReporter { private getUnprotectedWebhookNodes(workflows: IWorkflowBase[]) { return workflows.reduce((acc, workflow) => { - if (!workflow.active) return acc; + if (!workflow.activeVersionId) return acc; workflow.nodes.forEach((node) => { if ( diff --git a/packages/cli/src/security-audit/security-audit.service.ts b/packages/cli/src/security-audit/security-audit.service.ts index ea91d55c67496..4b712333de6a3 100644 --- a/packages/cli/src/security-audit/security-audit.service.ts +++ b/packages/cli/src/security-audit/security-audit.service.ts @@ -30,7 +30,7 @@ export class SecurityAuditService { } const workflows = await this.workflowRepository.find({ - select: ['id', 'name', 'active', 'nodes', 'connections'], + select: ['id', 'name', 'active', 'activeVersionId', 'nodes', 'connections'], }); const promises = categories.map(async (c) => await this.reporters[c].report(workflows)); diff --git a/packages/cli/src/services/__tests__/hooks.service.test.ts b/packages/cli/src/services/__tests__/hooks.service.test.ts index f781ab32e4f8b..aa9fc2c39bb9c 100644 --- a/packages/cli/src/services/__tests__/hooks.service.test.ts +++ b/packages/cli/src/services/__tests__/hooks.service.test.ts @@ -6,6 +6,7 @@ import type { WorkflowRepository, UserRepository, } from '@n8n/db'; +import { IsNull, Not } from '@n8n/typeorm'; import RudderStack from '@rudderstack/rudder-sdk-node'; import type { Response } from 'express'; import { mock } from 'jest-mock-extended'; @@ -100,7 +101,7 @@ describe('HooksService', () => { it('hooksService.workflowsCount should call workflowRepository.count', async () => { // ARRANGE - const filter = { where: { active: true } }; + const filter = { where: { activeVersionId: Not(IsNull()) } }; // ACT await hooksService.workflowsCount(filter); diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts index b24d310819980..cad342ce182e7 100644 --- a/packages/cli/src/services/import.service.ts +++ b/packages/cli/src/services/import.service.ts @@ -87,8 +87,9 @@ export class ImportService { const { manager: dbManager } = this.credentialsRepository; await dbManager.transaction(async (tx) => { for (const workflow of workflows) { - if (workflow.active) { + if (workflow.active || workflow.activeVersionId) { workflow.active = false; + workflow.activeVersionId = null; this.logger.info(`Deactivating workflow "${workflow.name}". Remember to activate later.`); } diff --git a/packages/cli/src/webhooks/__tests__/live-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/live-webhooks.test.ts new file mode 100644 index 0000000000000..00adbc5cd3669 --- /dev/null +++ b/packages/cli/src/webhooks/__tests__/live-webhooks.test.ts @@ -0,0 +1,135 @@ +import { mockLogger } from '@n8n/backend-test-utils'; +import type { WebhookEntity, WorkflowEntity, WorkflowHistory, WorkflowRepository } from '@n8n/db'; +import type { Response } from 'express'; +import { mock } from 'jest-mock-extended'; +import type { + IHttpRequestMethods, + INode, + INodeType, + IWebhookData, + IWorkflowExecuteAdditionalData, + Workflow, +} from 'n8n-workflow'; + +import type { NodeTypes } from '@/node-types'; +import { LiveWebhooks } from '@/webhooks/live-webhooks'; +import * as WebhookHelpers from '@/webhooks/webhook-helpers'; +import type { WebhookService } from '@/webhooks/webhook.service'; +import type { WebhookRequest } from '@/webhooks/webhook.types'; +import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; +import type { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; + +jest.mock('@/webhooks/webhook-helpers'); +jest.mock('@/workflow-execute-additional-data'); + +describe('LiveWebhooks', () => { + const workflowRepository = mock(); + const webhookService = mock(); + const nodeTypes = mock(); + const workflowStaticDataService = mock(); + + let liveWebhooks: LiveWebhooks; + + beforeEach(() => { + jest.clearAllMocks(); + liveWebhooks = new LiveWebhooks( + mockLogger(), + nodeTypes, + webhookService, + workflowRepository, + workflowStaticDataService, + ); + + // Mock WorkflowExecuteAdditionalData.getBase to avoid DI issues + (WorkflowExecuteAdditionalData.getBase as jest.Mock).mockResolvedValue( + mock(), + ); + }); + + describe('executeWebhook', () => { + it('should use active version nodes when executing webhook', async () => { + const workflowId = 'workflow-1'; + const nodeName = 'Webhook'; + const webhookPath = 'test-webhook'; + const httpMethod: IHttpRequestMethods = 'GET'; + + const createWebhookNode = (id: string, position: [number, number]): INode => ({ + id, + name: nodeName, + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + position, + parameters: { path: webhookPath, httpMethod }, + }); + + const draftNodes = [createWebhookNode('webhook-node-draft', [0, 0])]; + const activeNodes = [createWebhookNode('webhook-node-active', [100, 200])]; + + const activeVersion = mock({ + versionId: 'v1', + workflowId, + nodes: activeNodes, + connections: {}, + authors: 'test-user', + createdAt: new Date(), + updatedAt: new Date(), + }); + + const workflowEntity = mock({ + id: workflowId, + name: 'Test Workflow', + active: true, + activeVersionId: activeVersion.versionId, + nodes: draftNodes, + connections: {}, + activeVersion, + shared: [{ role: 'workflow:owner', project: { id: 'project-1', projectRelations: [] } }], + }); + + const webhookEntity = mock({ + workflowId, + node: nodeName, + webhookPath, + method: httpMethod, + isDynamic: false, + }); + + const webhookNodeType = mock({ + description: { name: nodeName, properties: [] }, + webhook: jest.fn(), + }); + + const webhookData = mock({ + httpMethod, + path: webhookPath, + node: nodeName, + webhookDescription: {}, + workflowId, + }); + + webhookService.findWebhook.mockResolvedValue(webhookEntity); + webhookService.getWebhookMethods.mockResolvedValue([httpMethod]); + workflowRepository.findOne.mockResolvedValue(workflowEntity); + nodeTypes.getByNameAndVersion.mockReturnValue(webhookNodeType); + webhookService.getNodeWebhooks.mockReturnValue([webhookData]); + + let capturedNodes: INode[] = []; + (WebhookHelpers.executeWebhook as jest.Mock).mockImplementation( + (workflow: Workflow, ...args: unknown[]) => { + capturedNodes = Object.values(workflow.nodes); + const webhookCallback = args[args.length - 1] as ( + error: Error | null, + data: object, + ) => void; + void webhookCallback(null, {}); + }, + ); + + const request = mock({ method: httpMethod, params: { path: webhookPath } }); + + await liveWebhooks.executeWebhook(request, mock()); + + expect(capturedNodes[0].id).toBe('webhook-node-active'); + }); + }); +}); diff --git a/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts b/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts index 522a6cb292ec5..a85a5b7fae656 100644 --- a/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts +++ b/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts @@ -3,13 +3,19 @@ import type express from 'express'; import { mock } from 'jest-mock-extended'; import { FORM_NODE_TYPE, WAITING_FORMS_EXECUTION_STATUS, type Workflow } from 'n8n-workflow'; +import type { WaitingWebhookRequest } from '../webhook.types'; + import { WaitingForms } from '@/webhooks/waiting-forms'; -import type { WaitingWebhookRequest } from '../webhook.types'; +class TestWaitingForms extends WaitingForms { + exposeGetWorkflow(execution: IExecutionResponse): Workflow { + return this.getWorkflow(execution); + } +} describe('WaitingForms', () => { const executionRepository = mock(); - const waitingForms = new WaitingForms(mock(), mock(), executionRepository, mock(), mock()); + const waitingForms = new TestWaitingForms(mock(), mock(), executionRepository, mock(), mock()); beforeEach(() => { jest.restoreAllMocks(); @@ -220,5 +226,93 @@ describe('WaitingForms', () => { expect(result).toEqual({ noWebhookResponse: true }); expect(res.send).toHaveBeenCalledWith(execution.status); }); + + it('should handle old executions with missing activeVersionId field when active=true', () => { + const execution = mock({ + workflowData: { + id: 'workflow1', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: true, + activeVersionId: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted + settings: {}, + staticData: {}, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const workflow = waitingForms.exposeGetWorkflow(execution); + + expect(workflow.active).toBe(true); + }); + + it('should handle old executions with missing activeVersionId field when active=false', () => { + const execution = mock({ + workflowData: { + id: 'workflow1', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: false, + activeVersionId: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted + settings: {}, + staticData: {}, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const workflow = waitingForms.exposeGetWorkflow(execution); + + expect(workflow.active).toBe(false); + }); + + it('should set active to true when activeVersionId exists', () => { + const execution = mock({ + workflowData: { + id: 'workflow1', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted + activeVersionId: 'version-123', + settings: {}, + staticData: {}, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const workflow = waitingForms.exposeGetWorkflow(execution); + + expect(workflow.active).toBe(true); + }); + + it('should set active to false when activeVersionId is null', () => { + const execution = mock({ + workflowData: { + id: 'workflow1', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted + activeVersionId: null, + settings: {}, + staticData: {}, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const workflow = waitingForms.exposeGetWorkflow(execution); + + expect(workflow.active).toBe(false); + }); }); }); diff --git a/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts index e8f3c7115153c..24d18d8cb9a6b 100644 --- a/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts @@ -3,19 +3,26 @@ import type express from 'express'; import { mock } from 'jest-mock-extended'; import type { InstanceSettings } from 'n8n-core'; import { generateUrlSignature, prepareUrlForSigning, WAITING_TOKEN_QUERY_PARAM } from 'n8n-core'; +import type { IWorkflowBase, Workflow } from 'n8n-workflow'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { WaitingWebhooks } from '@/webhooks/waiting-webhooks'; import type { WaitingWebhookRequest } from '@/webhooks/webhook.types'; +class TestWaitingWebhooks extends WaitingWebhooks { + exposeCreateWorkflow(workflowData: IWorkflowBase): Workflow { + return this.createWorkflow(workflowData); + } +} + describe('WaitingWebhooks', () => { const SIGNING_SECRET = 'test-secret'; const executionRepository = mock(); const mockInstanceSettings = mock({ hmacSignatureSecret: SIGNING_SECRET, }); - const waitingWebhooks = new WaitingWebhooks( + const waitingWebhooks = new TestWaitingWebhooks( mock(), mock(), executionRepository, @@ -197,4 +204,88 @@ describe('WaitingWebhooks', () => { expect(result).toBe(false); }); }); + + describe('createWorkflow', () => { + it('should handle old executions with missing activeVersionId field when active=true', () => { + const workflowData = { + id: 'workflow1', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: true, + activeVersionId: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted + settings: {}, + staticData: {}, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + // @ts-expect-error: createWorkflow typing is incorrect, will be fixed later + const workflow = waitingWebhooks.exposeCreateWorkflow(workflowData); + + expect(workflow.active).toBe(true); + }); + + it('should handle old executions with missing activeVersionId field when active=false', () => { + const workflowData = { + id: 'workflow1', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: false, + activeVersionId: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted + settings: {}, + staticData: {}, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + // @ts-expect-error: createWorkflow typing is incorrect, will be fixed later + const workflow = waitingWebhooks.exposeCreateWorkflow(workflowData); + + expect(workflow.active).toBe(false); + }); + + it('should set active to true when activeVersionId exists', () => { + const workflowData: IWorkflowBase = { + id: 'workflow1', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: true, + activeVersionId: 'version-123', + settings: {}, + staticData: {}, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const workflow = waitingWebhooks.exposeCreateWorkflow(workflowData); + + expect(workflow.active).toBe(true); + }); + + it('should set active to false when activeVersionId is null', () => { + const workflowData: IWorkflowBase = { + id: 'workflow1', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: false, + activeVersionId: null, + settings: {}, + staticData: {}, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const workflow = waitingWebhooks.exposeCreateWorkflow(workflowData); + + expect(workflow.active).toBe(false); + }); + }); }); diff --git a/packages/cli/src/webhooks/live-webhooks.ts b/packages/cli/src/webhooks/live-webhooks.ts index 96b09d4dcf97a..a94fa853d2a09 100644 --- a/packages/cli/src/webhooks/live-webhooks.ts +++ b/packages/cli/src/webhooks/live-webhooks.ts @@ -96,19 +96,30 @@ export class LiveWebhooks implements IWebhookManager { const workflowData = await this.workflowRepository.findOne({ where: { id: webhook.workflowId }, - relations: { shared: { project: { projectRelations: true } } }, + relations: { + activeVersion: true, + shared: { project: { projectRelations: true } }, + }, }); if (workflowData === null) { throw new NotFoundError(`Could not find workflow with id "${webhook.workflowId}"`); } + if (!workflowData.activeVersion) { + throw new NotFoundError( + `Active version not found for workflow with id "${webhook.workflowId}"`, + ); + } + + const { nodes, connections } = workflowData.activeVersion; + const workflow = new Workflow({ id: webhook.workflowId, name: workflowData.name, - nodes: workflowData.nodes, - connections: workflowData.connections, - active: workflowData.active, + nodes, + connections, + active: workflowData.activeVersionId !== null, nodeTypes: this.nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings, diff --git a/packages/cli/src/webhooks/waiting-forms.ts b/packages/cli/src/webhooks/waiting-forms.ts index 8a97fa27bf3e9..0d51ce69421ee 100644 --- a/packages/cli/src/webhooks/waiting-forms.ts +++ b/packages/cli/src/webhooks/waiting-forms.ts @@ -10,6 +10,7 @@ import { } from 'n8n-workflow'; import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { getWorkflowActiveStatusFromWorkflowData } from '@/executions/execution.utils'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { WaitingWebhooks } from '@/webhooks/waiting-webhooks'; @@ -30,14 +31,14 @@ export class WaitingForms extends WaitingWebhooks { } } - private getWorkflow(execution: IExecutionResponse) { + protected getWorkflow(execution: IExecutionResponse) { const { workflowData } = execution; return new Workflow({ id: workflowData.id, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, - active: workflowData.active, + active: getWorkflowActiveStatusFromWorkflowData(workflowData), nodeTypes: this.nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings, diff --git a/packages/cli/src/webhooks/waiting-webhooks.ts b/packages/cli/src/webhooks/waiting-webhooks.ts index abfe55b4ff57e..f05a3f88165fb 100644 --- a/packages/cli/src/webhooks/waiting-webhooks.ts +++ b/packages/cli/src/webhooks/waiting-webhooks.ts @@ -29,6 +29,7 @@ import type { import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { getWorkflowActiveStatusFromWorkflowData } from '@/executions/execution.utils'; import { NodeTypes } from '@/node-types'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; @@ -70,13 +71,14 @@ export class WaitingWebhooks implements IWebhookManager { ); } - private createWorkflow(workflowData: IWorkflowBase) { + // TODO: fix the type here - it should be execution workflowData + protected createWorkflow(workflowData: IWorkflowBase) { return new Workflow({ id: workflowData.id, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, - active: workflowData.active, + active: getWorkflowActiveStatusFromWorkflowData(workflowData), nodeTypes: this.nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings, diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index fe9238988dcfd..414934b2e295e 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -92,6 +92,10 @@ export function getRunData( }; } +/** + * Loads workflow data for sub-workflow execution. + * Uses the active version when available. + */ export async function getWorkflowData( workflowInfo: IExecuteWorkflowInfo, parentWorkflowId: string, @@ -105,27 +109,35 @@ export async function getWorkflowData( let workflowData: IWorkflowBase | null; if (workflowInfo.id !== undefined) { - const relations = Container.get(GlobalConfig).tags.disabled ? [] : ['tags']; + const baseRelations = ['activeVersion']; + const relations = Container.get(GlobalConfig).tags.disabled + ? [...baseRelations] + : [...baseRelations, 'tags']; - workflowData = await Container.get(WorkflowRepository).get( + const workflowFromDb = await Container.get(WorkflowRepository).get( { id: workflowInfo.id }, { relations }, ); - if (workflowData === undefined || workflowData === null) { + if (workflowFromDb === undefined || workflowFromDb === null) { throw new UnexpectedError('Workflow does not exist.', { extra: { workflowId: workflowInfo.id }, }); } + + if (workflowFromDb.activeVersion) { + workflowFromDb.nodes = workflowFromDb.activeVersion.nodes; + workflowFromDb.connections = workflowFromDb.activeVersion.connections; + } + + workflowData = workflowFromDb; } else { workflowData = workflowInfo.code ?? null; if (workflowData) { if (!workflowData.id) { workflowData.id = parentWorkflowId; } - if (!workflowData.settings) { - workflowData.settings = parentWorkflowSettings; - } + workflowData.settings ??= parentWorkflowSettings; } } @@ -183,7 +195,7 @@ async function startExecution( name: workflowName, nodes: workflowData.nodes, connections: workflowData.connections, - active: workflowData.active, + active: workflowData.activeVersionId !== null, nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings, diff --git a/packages/cli/src/workflow-helpers.ts b/packages/cli/src/workflow-helpers.ts index e1d52e03740f0..814a0d3f1d8d0 100644 --- a/packages/cli/src/workflow-helpers.ts +++ b/packages/cli/src/workflow-helpers.ts @@ -1,4 +1,5 @@ import { CredentialsRepository } from '@n8n/db'; +import type { WorkflowEntity, WorkflowHistory } from '@n8n/db'; import { Container } from '@n8n/di'; import type { IDataObject, @@ -215,3 +216,28 @@ export function shouldRestartParentExecution( } return parentExecution.shouldResume; } + +/** + * Determines the value to set for a workflow's active version based on the provided parameters. + * Always updates the active version to the current version for active workflows, clears it when deactivating. + * + * @param dbWorkflow - The current workflow entity from the database, before the update + * @param updatedVersion - The workflow history version of the updated workflow + * @param updatedActive - Optional boolean indicating if the workflow's active status is being updated + * @returns The workflow history version to set as active, null if deactivating, or the existing active version if unchanged + */ +export function getActiveVersionUpdateValue( + dbWorkflow: WorkflowEntity, + updatedVersion: WorkflowHistory, + updatedActive?: boolean, +) { + if (updatedActive) { + return updatedVersion; + } + + if (updatedActive === false) { + return null; + } + + return dbWorkflow.activeVersionId ? updatedVersion : null; +} diff --git a/packages/cli/src/workflow-runner.ts b/packages/cli/src/workflow-runner.ts index d35e1dfd02472..2d1db28472c31 100644 --- a/packages/cli/src/workflow-runner.ts +++ b/packages/cli/src/workflow-runner.ts @@ -235,7 +235,7 @@ export class WorkflowRunner { name: data.workflowData.name, nodes: data.workflowData.nodes, connections: data.workflowData.connections, - active: data.workflowData.active, + active: data.workflowData.activeVersionId !== null, nodeTypes: this.nodeTypes, staticData: data.workflowData.staticData, settings: workflowSettings, diff --git a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts index a7d58372baea0..c5a49199afc3c 100644 --- a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts @@ -93,7 +93,11 @@ describe('WorkflowExecutionService', () => { describe('runWorkflow()', () => { test('should call `WorkflowRunner.run()`', async () => { const node = mock(); - const workflow = mock({ active: true, nodes: [node] }); + const workflow = mock({ + active: true, + activeVersionId: 'some-version-id', + nodes: [node], + }); workflowRunner.run.mockResolvedValue('fake-execution-id'); @@ -104,6 +108,10 @@ describe('WorkflowExecutionService', () => { }); describe('executeManually()', () => { + beforeEach(() => { + workflowRunner.run.mockClear(); + }); + test('should call `WorkflowRunner.run()` with correct parameters with default partial execution logic', async () => { const executionId = 'fake-execution-id'; const userId = 'user-id'; @@ -253,6 +261,7 @@ describe('WorkflowExecutionService', () => { id: 'abc', name: 'test', active: false, + activeVersionId: null, isArchived: false, pinData: { [pinnedTrigger.name]: [{ json: {} }], @@ -320,6 +329,7 @@ describe('WorkflowExecutionService', () => { id: 'abc', name: 'test', active: false, + activeVersionId: null, isArchived: false, pinData: { [pinnedTrigger.name]: [{ json: {} }], @@ -355,6 +365,38 @@ describe('WorkflowExecutionService', () => { }); expect(result).toEqual({ executionId }); }); + + test('should force current version for manual execution even if workflow has active version', async () => { + const executionId = 'fake-execution-id'; + const userId = 'user-id'; + const user = mock({ id: userId }); + const runPayload: WorkflowRequest.ManualRunPayload = { + workflowData: { + id: 'workflow-id', + name: 'Test Workflow', + active: true, + activeVersionId: 'version-123', + isArchived: false, + nodes: [], + connections: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + startNodes: [], + destinationNode: undefined, + }; + + workflowRunner.run.mockResolvedValue(executionId); + + const result = await workflowExecutionService.executeManually(runPayload, user); + + expect(workflowRunner.run).toHaveBeenCalledTimes(1); + const callArgs = workflowRunner.run.mock.calls[0][0]; + expect(callArgs.workflowData.active).toBe(false); + expect(callArgs.workflowData.activeVersionId).toBe(null); + expect(callArgs.executionMode).toBe('manual'); + expect(result).toEqual({ executionId }); + }); }); describe('selectPinnedActivatorStarter()', () => { @@ -625,6 +667,7 @@ describe('WorkflowExecutionService', () => { id: 'error-workflow-id', name: 'Error Workflow', active: false, + activeVersionId: null, isArchived: false, pinData: {}, nodes: [errorTriggerNode], diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index 5fe4f116a0e38..1b1f624bb8c33 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -165,6 +165,7 @@ export class WorkflowExecutionService { // For manual testing always set to not active workflowData.active = false; + workflowData.activeVersionId = null; // Start the workflow const data: IWorkflowExecutionDataProcess = { @@ -283,7 +284,7 @@ export class WorkflowExecutionService { nodeTypes: this.nodeTypes, nodes: workflowData.nodes, connections: workflowData.connections, - active: workflowData.active, + active: workflowData.activeVersion !== null, staticData: workflowData.staticData, settings: workflowData.settings, }); @@ -438,7 +439,7 @@ export class WorkflowExecutionService { new Workflow({ nodes: workflow.nodes, connections: workflow.connections, - active: workflow.active, + active: workflow.activeVersionId !== null, nodeTypes: this.nodeTypes, }).getParentNodes(destinationNode), ); @@ -466,7 +467,7 @@ export class WorkflowExecutionService { const parentNodeNames = new Workflow({ nodes: workflow.nodes, connections: workflow.connections, - active: workflow.active, + active: workflow.activeVersionId !== null, nodeTypes: this.nodeTypes, }).getParentNodes(firstStartNodeName); diff --git a/packages/cli/src/workflows/workflow-finder.service.ts b/packages/cli/src/workflows/workflow-finder.service.ts index 5679dbeec1444..4647aa5bf7fda 100644 --- a/packages/cli/src/workflows/workflow-finder.service.ts +++ b/packages/cli/src/workflows/workflow-finder.service.ts @@ -24,6 +24,7 @@ export class WorkflowFinderService { options: { includeTags?: boolean; includeParentFolder?: boolean; + includeActiveVersion?: boolean; em?: EntityManager; } = {}, ) { @@ -50,6 +51,7 @@ export class WorkflowFinderService { where, includeTags: options.includeTags, includeParentFolder: options.includeParentFolder, + includeActiveVersion: options.includeActiveVersion, em: options.em, }); diff --git a/packages/cli/src/workflows/workflow-history/workflow-history-manager.ts b/packages/cli/src/workflows/workflow-history/workflow-history-manager.ts index 85e2e9669f29e..7051f8f0f9d7d 100644 --- a/packages/cli/src/workflows/workflow-history/workflow-history-manager.ts +++ b/packages/cli/src/workflows/workflow-history/workflow-history-manager.ts @@ -34,6 +34,6 @@ export class WorkflowHistoryManager { } const pruneDateTime = DateTime.now().minus({ hours: pruneHours }).toJSDate(); - await this.workflowHistoryRepo.deleteEarlierThanExceptCurrent(pruneDateTime); + await this.workflowHistoryRepo.deleteEarlierThanExceptCurrentAndActive(pruneDateTime); } } diff --git a/packages/cli/src/workflows/workflow-history/workflow-history.service.ts b/packages/cli/src/workflows/workflow-history/workflow-history.service.ts index 59b8692b35505..fb2578b7b5f33 100644 --- a/packages/cli/src/workflows/workflow-history/workflow-history.service.ts +++ b/packages/cli/src/workflows/workflow-history/workflow-history.service.ts @@ -1,7 +1,9 @@ import { Logger } from '@n8n/backend-common'; -import type { User, WorkflowHistory } from '@n8n/db'; -import { WorkflowHistoryRepository } from '@n8n/db'; +import type { User } from '@n8n/db'; +import { WorkflowHistory, WorkflowHistoryRepository } from '@n8n/db'; import { Service } from '@n8n/di'; +// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import +import type { EntityManager } from '@n8n/typeorm'; import type { IWorkflowBase } from 'n8n-workflow'; import { ensureError, UnexpectedError } from 'n8n-workflow'; @@ -64,15 +66,24 @@ export class WorkflowHistoryService { return hist; } - async saveVersion(user: User, workflow: IWorkflowBase, workflowId: string) { + async saveVersion( + user: User, + workflow: IWorkflowBase, + workflowId: string, + transactionManager?: EntityManager, + ) { if (!workflow.nodes || !workflow.connections) { throw new UnexpectedError( `Cannot save workflow history: nodes and connections are required for workflow ${workflowId}`, ); } + const repository = transactionManager + ? transactionManager.getRepository(WorkflowHistory) + : this.workflowHistoryRepository; + try { - await this.workflowHistoryRepository.insert({ + await repository.insert({ authors: user.firstName + ' ' + user.lastName, connections: workflow.connections, nodes: workflow.nodes, diff --git a/packages/cli/src/workflows/workflow.service.ee.ts b/packages/cli/src/workflows/workflow.service.ee.ts index 1c9114bad6c41..3ca96c0bf9c21 100644 --- a/packages/cli/src/workflows/workflow.service.ee.ts +++ b/packages/cli/src/workflows/workflow.service.ee.ts @@ -329,7 +329,7 @@ export class EnterpriseWorkflowService { } // 6. deactivate workflow if necessary - const wasActive = workflow.active; + const wasActive = workflow.activeVersionId !== null; if (wasActive) { await this.activeWorkflowManager.remove(workflowId); } @@ -395,14 +395,14 @@ export class EnterpriseWorkflowService { // 2. Get all workflows in the nested folders const workflows = await this.workflowRepository.find({ - select: ['id', 'active', 'shared'], + select: ['id', 'activeVersionId', 'shared'], relations: ['shared', 'shared.project'], where: { parentFolder: { id: In([...childrenFolderIds, sourceFolderId]) }, }, }); - const activeWorkflows = workflows.filter((w) => w.active).map((w) => w.id); + const activeWorkflows = workflows.filter((w) => w.activeVersionId !== null).map((w) => w.id); // 3. get destination project const destinationProject = await this.projectService.getProjectWithScope( diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 1fb6a3d1515ce..59d58d427407d 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -205,9 +205,12 @@ export class WorkflowService { parentFolderId?: string, forceSave?: boolean, ): Promise { - const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [ - 'workflow:update', - ]); + const workflow = await this.workflowFinderService.findWorkflowForUser( + workflowId, + user, + ['workflow:update'], + { includeActiveVersion: true }, + ); if (!workflow) { this.logger.warn('User attempted to update a workflow without permissions', { @@ -230,7 +233,10 @@ export class WorkflowService { ); } - if (Object.keys(omit(workflowUpdateData, ['id', 'versionId', 'active'])).length > 0) { + if ( + Object.keys(omit(workflowUpdateData, ['id', 'versionId', 'active', 'activeVersionId'])) + .length > 0 + ) { // Update the workflow's version when changing properties such as // `name`, `pinData`, `nodes`, `connections`, `settings` or `tags` // This is necessary for collaboration to work properly - even when only name or settings @@ -246,8 +252,22 @@ export class WorkflowService { ); } + // Convert 'active' boolean from frontend to 'activeVersionId' for backend + if ('active' in workflowUpdateData) { + if (workflowUpdateData.active) { + workflowUpdateData.activeVersionId = workflowUpdateData.versionId ?? workflow.versionId; + } else { + workflowUpdateData.activeVersionId = null; + } + } + const versionChanged = workflowUpdateData.versionId && workflowUpdateData.versionId !== workflow.versionId; + const wasActive = workflow.activeVersionId !== null; + const isNowActive = workflowUpdateData.active ?? wasActive; + const activationStatusChanged = isNowActive !== wasActive; + const needsActiveVersionUpdate = activationStatusChanged || (versionChanged && isNowActive); + if (versionChanged) { // To save a version, we need both nodes and connections workflowUpdateData.nodes = workflowUpdateData.nodes ?? workflow.nodes; @@ -268,7 +288,7 @@ export class WorkflowService { * If a trigger or poller in the workflow was updated, the new value * will take effect only on removing and re-adding. */ - if (workflow.active) { + if (wasActive) { await this.activeWorkflowManager.remove(workflowId); } @@ -308,9 +328,30 @@ export class WorkflowService { 'staticData', 'pinData', 'versionId', + 'activeVersionId', 'description', ]); + // Save the workflow to history first, so we can retrieve the complete version object for the update + if (versionChanged) { + await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId); + } + + if (needsActiveVersionUpdate) { + const versionIdToFetch = versionChanged ? workflowUpdateData.versionId : workflow.versionId; + const version = await this.workflowHistoryService.getVersion( + user, + workflowId, + versionIdToFetch, + ); + + updatePayload.activeVersion = WorkflowHelpers.getActiveVersionUpdateValue( + workflow, + version, + isNowActive, + ); + } + if (parentFolderId) { const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(workflow.id); if (parentFolderId !== PROJECT_ROOT) { @@ -334,10 +375,6 @@ export class WorkflowService { await this.workflowTagMappingRepository.overwriteTaggings(workflowId, tagIds); } - if (versionChanged) { - await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId); - } - const relations = tagsDisabled ? [] : ['tags']; // We sadly get nothing back from "update". Neither if it updated a record @@ -366,11 +403,7 @@ export class WorkflowService { publicApi: false, }); - // Check if workflow activation status changed - const wasActive = workflow.active; - const isNowActive = updatedWorkflow.active; - - if (isNowActive && !wasActive) { + if (activationStatusChanged && isNowActive) { // Workflow is being activated this.eventService.emit('workflow-activated', { user, @@ -378,7 +411,7 @@ export class WorkflowService { workflow: updatedWorkflow, publicApi: false, }); - } else if (!isNowActive && wasActive) { + } else if (activationStatusChanged && !isNowActive) { // Workflow is being deactivated this.eventService.emit('workflow-deactivated', { user, @@ -388,21 +421,24 @@ export class WorkflowService { }); } - if (updatedWorkflow.active) { + if (isNowActive) { // When the workflow is supposed to be active add it again try { await this.externalHooks.run('workflow.activate', [updatedWorkflow]); - await this.activeWorkflowManager.add(workflowId, workflow.active ? 'update' : 'activate'); + await this.activeWorkflowManager.add(workflowId, wasActive ? 'update' : 'activate'); } catch (error) { // If workflow could not be activated set it again to inactive - // and revert the versionId change so UI remains consistent + // and revert the versionId and activeVersionId change so UI remains consistent await this.workflowRepository.update(workflowId, { active: false, + activeVersion: null, versionId: workflow.versionId, }); // Also set it in the returned data updatedWorkflow.active = false; + updatedWorkflow.activeVersionId = null; + updatedWorkflow.activeVersion = null; // Emit deactivation event since activation failed this.eventService.emit('workflow-deactivated', { @@ -490,7 +526,7 @@ export class WorkflowService { throw new BadRequestError('Workflow is already archived.'); } - if (workflow.active) { + if (workflow.activeVersionId !== null) { await this.activeWorkflowManager.remove(workflowId); } @@ -498,10 +534,13 @@ export class WorkflowService { workflow.versionId = versionId; workflow.isArchived = true; workflow.active = false; + workflow.activeVersionId = null; + workflow.activeVersion = null; await this.workflowRepository.update(workflowId, { isArchived: true, active: false, + activeVersion: null, versionId, }); diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 1193cedf1965b..bd4095b5d4239 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -181,11 +181,29 @@ export class WorkflowsController { await transactionManager.save(newSharedWorkflow); + await this.workflowHistoryService.saveVersion( + req.user, + workflow, + workflow.id, + transactionManager, + ); + + const shouldActivate = req.body.active === true; + if (shouldActivate) { + workflow.activeVersionId = workflow.versionId; + await transactionManager.save(workflow); + } + return await this.workflowFinderService.findWorkflowForUser( workflow.id, req.user, ['workflow:read'], - { em: transactionManager, includeTags: true, includeParentFolder: true }, + { + em: transactionManager, + includeTags: true, + includeParentFolder: true, + includeActiveVersion: true, + }, ); }); @@ -194,8 +212,6 @@ export class WorkflowsController { throw new InternalServerError('Failed to save workflow'); } - await this.workflowHistoryService.saveVersion(req.user, savedWorkflow, savedWorkflow.id); - if (tagIds && !this.globalConfig.tags.disabled && savedWorkflow.tags) { savedWorkflow.tags = this.tagService.sortByRequestOrder(savedWorkflow.tags, { requestOrder: tagIds, diff --git a/packages/cli/test/integration/access-control/cross-project-access.test.ts b/packages/cli/test/integration/access-control/cross-project-access.test.ts index dabf6227a4a60..29b11466999f3 100644 --- a/packages/cli/test/integration/access-control/cross-project-access.test.ts +++ b/packages/cli/test/integration/access-control/cross-project-access.test.ts @@ -127,7 +127,7 @@ describe('Cross-Project Access Control Tests', () => { }); afterAll(async () => { - await testDb.truncate(['User']); + await testDb.truncate(['User', 'ProjectRelation']); await cleanupRolesAndScopes(); }); diff --git a/packages/cli/test/integration/access-control/custom-roles-functionality.test.ts b/packages/cli/test/integration/access-control/custom-roles-functionality.test.ts index a68a17640722d..8741f64edb852 100644 --- a/packages/cli/test/integration/access-control/custom-roles-functionality.test.ts +++ b/packages/cli/test/integration/access-control/custom-roles-functionality.test.ts @@ -166,7 +166,7 @@ describe('Custom Role Functionality Tests', () => { }); afterAll(async () => { - await testDb.truncate(['User']); + await testDb.truncate(['User', 'ProjectRelation']); await cleanupRolesAndScopes(); }); diff --git a/packages/cli/test/integration/access-control/resource-access-matrix.test.ts b/packages/cli/test/integration/access-control/resource-access-matrix.test.ts index 94df9574ad78f..91adc896065eb 100644 --- a/packages/cli/test/integration/access-control/resource-access-matrix.test.ts +++ b/packages/cli/test/integration/access-control/resource-access-matrix.test.ts @@ -135,7 +135,7 @@ describe('Resource Access Control Matrix Tests', () => { }); afterAll(async () => { - await testDb.truncate(['User']); + await testDb.truncate(['User', 'ProjectRelation']); await cleanupRolesAndScopes(); }); diff --git a/packages/cli/test/integration/active-workflow-manager.test.ts b/packages/cli/test/integration/active-workflow-manager.test.ts index 250b492dad774..f65b7c736391f 100644 --- a/packages/cli/test/integration/active-workflow-manager.test.ts +++ b/packages/cli/test/integration/active-workflow-manager.test.ts @@ -1,5 +1,10 @@ -import { createWorkflowWithHistory, testDb, mockInstance } from '@n8n/backend-test-utils'; -import type { Project, WebhookEntity } from '@n8n/db'; +import { + createWorkflowWithHistory, + setActiveVersion, + testDb, + mockInstance, +} from '@n8n/backend-test-utils'; +import type { IWorkflowDb, Project, User, WebhookEntity } from '@n8n/db'; import { WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; @@ -43,8 +48,11 @@ const externalHooks = mockInstance(ExternalHooks); let activeWorkflowManager: ActiveWorkflowManager; -let createActiveWorkflow: () => Promise; +let createActiveWorkflow: ( + workflowOptions?: Parameters[0], +) => Promise; let createInactiveWorkflow: () => Promise; +let owner: User; beforeAll(async () => { await testDb.init(); @@ -64,15 +72,19 @@ beforeAll(async () => { await utils.initNodeTypes(nodes); - const owner = await createOwner(); - createActiveWorkflow = async () => await createWorkflowWithHistory({ active: true }, owner); + owner = await createOwner(); + createActiveWorkflow = async (workflowOptions: Partial = {}) => { + const workflow = await createWorkflowWithHistory({ active: true, ...workflowOptions }, owner); + await setActiveVersion(workflow.id, workflow.versionId); + return workflow; + }; createInactiveWorkflow = async () => await createWorkflowWithHistory({ active: false }, owner); Container.get(InstanceSettings).markAsLeader(); }); afterEach(async () => { await activeWorkflowManager.removeAll(); - await testDb.truncate(['WorkflowEntity', 'WebhookEntity']); + await testDb.truncate(['WorkflowEntity', 'WebhookEntity', 'WorkflowHistory']); jest.clearAllMocks(); }); @@ -176,7 +188,7 @@ describe('add()', () => { ); // Create a workflow which has a form trigger - const dbWorkflow = await createWorkflowWithHistory({ + const dbWorkflow = await createActiveWorkflow({ nodes: [ { id: 'uuid-1', @@ -194,7 +206,7 @@ describe('add()', () => { expect(updateWorkflowTriggerCountSpy).toHaveBeenCalledWith(dbWorkflow.id, 1); }); - test('should activate an initially inactive workflow in memory', async () => { + test('should activate a workflow after its active status changes from false to true', async () => { await activeWorkflowManager.init(); const dbWorkflow = await createInactiveWorkflow(); @@ -203,6 +215,10 @@ describe('add()', () => { // Verify it's not active in memory yet expect(activeWorkflowManager.allActiveInMemory()).toHaveLength(0); + // Simulate the workflow being activated + await setActiveVersion(dbWorkflow.id, dbWorkflow.versionId!); + await Container.get(WorkflowRepository).update(dbWorkflow.id, { active: true }); + await activeWorkflowManager.add(dbWorkflow.id, 'activate'); expect(activeWorkflowManager.allActiveInMemory()).toHaveLength(1); diff --git a/packages/cli/test/integration/commands/import.cmd.test.ts b/packages/cli/test/integration/commands/import.cmd.test.ts index d1e7e8d49064b..f28aa83eaf637 100644 --- a/packages/cli/test/integration/commands/import.cmd.test.ts +++ b/packages/cli/test/integration/commands/import.cmd.test.ts @@ -48,8 +48,8 @@ test('import:workflow should import active workflow and deactivate it', async () }; expect(after).toMatchObject({ workflows: [ - expect.objectContaining({ name: 'active-workflow', active: false }), - expect.objectContaining({ name: 'inactive-workflow', active: false }), + expect.objectContaining({ name: 'active-workflow', active: false, activeVersionId: null }), + expect.objectContaining({ name: 'inactive-workflow', active: false, activeVersionId: null }), ], sharings: [ expect.objectContaining({ @@ -89,8 +89,8 @@ test('import:workflow should import active workflow from combined file and deact }; expect(after).toMatchObject({ workflows: [ - expect.objectContaining({ name: 'active-workflow', active: false }), - expect.objectContaining({ name: 'inactive-workflow', active: false }), + expect.objectContaining({ name: 'active-workflow', active: false, activeVersionId: null }), + expect.objectContaining({ name: 'inactive-workflow', active: false, activeVersionId: null }), ], sharings: [ expect.objectContaining({ @@ -127,7 +127,9 @@ test('import:workflow can import a single workflow object', async () => { sharings: await getAllSharedWorkflows(), }; expect(after).toMatchObject({ - workflows: [expect.objectContaining({ name: 'active-workflow', active: false })], + workflows: [ + expect.objectContaining({ name: 'active-workflow', active: false, activeVersionId: null }), + ], sharings: [ expect.objectContaining({ workflowId: '998', diff --git a/packages/cli/test/integration/commands/update/workflow.test.ts b/packages/cli/test/integration/commands/update/workflow.test.ts index 04c5d2102ee68..ee6fab23760d1 100644 --- a/packages/cli/test/integration/commands/update/workflow.test.ts +++ b/packages/cli/test/integration/commands/update/workflow.test.ts @@ -1,9 +1,12 @@ import { mockInstance, testDb, - createWorkflowWithTrigger, + createWorkflowWithTriggerAndHistory, + createManyActiveWorkflows, getAllWorkflows, } from '@n8n/backend-test-utils'; +import { WorkflowRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; import { UpdateWorkflowCommand } from '@/commands/update/workflow'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; @@ -13,7 +16,7 @@ mockInstance(LoadNodesAndCredentials); const command = setupTestCommand(UpdateWorkflowCommand); beforeEach(async () => { - await testDb.truncate(['WorkflowEntity']); + await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']); }); test('update:workflow can activate all workflows', async () => { @@ -21,10 +24,11 @@ test('update:workflow can activate all workflows', async () => { // ARRANGE // const workflows = await Promise.all([ - createWorkflowWithTrigger({}), - createWorkflowWithTrigger({}), + createWorkflowWithTriggerAndHistory({}), + createWorkflowWithTriggerAndHistory({}), ]); - expect(workflows).toMatchObject([{ active: false }, { active: false }]); + expect(workflows[0].activeVersionId).toBeNull(); + expect(workflows[1].activeVersionId).toBeNull(); // // ACT @@ -34,19 +38,35 @@ test('update:workflow can activate all workflows', async () => { // // ASSERT // - const after = await getAllWorkflows(); - expect(after).toMatchObject([{ active: true }, { active: true }]); + // Verify activeVersionId is now set to the current versionId + const workflowRepo = Container.get(WorkflowRepository); + const workflow1 = await workflowRepo.findOne({ + where: { id: workflows[0].id }, + relations: ['activeVersion'], + }); + const workflow2 = await workflowRepo.findOne({ + where: { id: workflows[1].id }, + relations: ['activeVersion'], + }); + + expect(workflow1?.activeVersionId).toBe(workflows[0].versionId); + expect(workflow1?.activeVersion?.versionId).toBe(workflows[0].versionId); + expect(workflow2?.activeVersionId).toBe(workflows[1].versionId); + expect(workflow2?.activeVersion?.versionId).toBe(workflows[1].versionId); }); test('update:workflow can deactivate all workflows', async () => { // // ARRANGE // - const workflows = await Promise.all([ - createWorkflowWithTrigger({ active: true }), - createWorkflowWithTrigger({ active: true }), - ]); - expect(workflows).toMatchObject([{ active: true }, { active: true }]); + const workflows = await createManyActiveWorkflows(2); + + // Verify activeVersionId is set + const workflowRepo = Container.get(WorkflowRepository); + let workflow1 = await workflowRepo.findOneBy({ id: workflows[0].id }); + let workflow2 = await workflowRepo.findOneBy({ id: workflows[1].id }); + expect(workflow1?.activeVersionId).toBe(workflows[0].versionId); + expect(workflow2?.activeVersionId).toBe(workflows[1].versionId); // // ACT @@ -56,8 +76,20 @@ test('update:workflow can deactivate all workflows', async () => { // // ASSERT // - const after = await getAllWorkflows(); - expect(after).toMatchObject([{ active: false }, { active: false }]); + // Verify activeVersionId is cleared + workflow1 = await workflowRepo.findOne({ + where: { id: workflows[0].id }, + relations: ['activeVersion'], + }); + workflow2 = await workflowRepo.findOne({ + where: { id: workflows[1].id }, + relations: ['activeVersion'], + }); + + expect(workflow1?.activeVersionId).toBeNull(); + expect(workflow1?.activeVersion).toBeNull(); + expect(workflow2?.activeVersionId).toBeNull(); + expect(workflow2?.activeVersion).toBeNull(); }); test('update:workflow can activate a specific workflow', async () => { @@ -66,11 +98,10 @@ test('update:workflow can activate a specific workflow', async () => { // const workflows = ( await Promise.all([ - createWorkflowWithTrigger({ active: false }), - createWorkflowWithTrigger({ active: false }), + createWorkflowWithTriggerAndHistory(), + createWorkflowWithTriggerAndHistory(), ]) ).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id)); - expect(workflows).toMatchObject([{ active: false }, { active: false }]); // // ACT @@ -81,20 +112,19 @@ test('update:workflow can activate a specific workflow', async () => { // ASSERT // const after = (await getAllWorkflows()).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id)); - expect(after).toMatchObject([{ active: true }, { active: false }]); + expect(after).toMatchObject([ + { activeVersionId: workflows[0].versionId }, + { activeVersionId: null }, + ]); }); test('update:workflow can deactivate a specific workflow', async () => { // // ARRANGE // - const workflows = ( - await Promise.all([ - createWorkflowWithTrigger({ active: true }), - createWorkflowWithTrigger({ active: true }), - ]) - ).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id)); - expect(workflows).toMatchObject([{ active: true }, { active: true }]); + const workflows = (await createManyActiveWorkflows(2)).sort((wf1, wf2) => + wf1.id.localeCompare(wf2.id), + ); // // ACT @@ -105,5 +135,8 @@ test('update:workflow can deactivate a specific workflow', async () => { // ASSERT // const after = (await getAllWorkflows()).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id)); - expect(after).toMatchObject([{ active: false }, { active: true }]); + expect(after).toMatchObject([ + { activeVersionId: null }, + { activeVersionId: workflows[1].versionId }, + ]); }); diff --git a/packages/cli/test/integration/cta.service.test.ts b/packages/cli/test/integration/cta.service.test.ts index 645d2f85ac1a6..6068c78866b36 100644 --- a/packages/cli/test/integration/cta.service.test.ts +++ b/packages/cli/test/integration/cta.service.test.ts @@ -1,4 +1,4 @@ -import { createManyWorkflows, testDb } from '@n8n/backend-test-utils'; +import { createManyActiveWorkflows, testDb } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; import { StatisticsNames } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -36,7 +36,7 @@ describe('CtaService', () => { ])( 'should return %p if user has %d active workflows with %d successful production executions', async (expected, numWorkflows, numExecutions) => { - const workflows = await createManyWorkflows(numWorkflows, { active: true }, user); + const workflows = await createManyActiveWorkflows(numWorkflows, {}, user); await Promise.all( workflows.map( diff --git a/packages/cli/test/integration/database/repositories/workflow.repository.test.ts b/packages/cli/test/integration/database/repositories/workflow.repository.test.ts index 510ab834bf887..56404c0020089 100644 --- a/packages/cli/test/integration/database/repositories/workflow.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/workflow.repository.test.ts @@ -1,14 +1,17 @@ import { - createWorkflowWithTrigger, + createWorkflowWithTriggerAndHistory, + createWorkflowWithHistory, + createActiveWorkflow, + createManyActiveWorkflows, + createWorkflowWithActiveVersion, createWorkflow, - getAllWorkflows, testDb, } from '@n8n/backend-test-utils'; +import { GlobalConfig } from '@n8n/config'; import { WorkflowRepository, WorkflowDependencyRepository, WorkflowDependencies } from '@n8n/db'; import { Container } from '@n8n/di'; import { createTestRun } from '../../shared/db/evaluation'; -import { GlobalConfig } from '@n8n/config'; describe('WorkflowRepository', () => { beforeAll(async () => { @@ -16,7 +19,7 @@ describe('WorkflowRepository', () => { }); beforeEach(async () => { - await testDb.truncate(['WorkflowDependency', 'WorkflowEntity']); + await testDb.truncate(['WorkflowDependency', 'WorkflowEntity', 'WorkflowHistory']); }); afterAll(async () => { @@ -30,10 +33,11 @@ describe('WorkflowRepository', () => { // const workflowRepository = Container.get(WorkflowRepository); const workflows = await Promise.all([ - createWorkflowWithTrigger(), - createWorkflowWithTrigger(), + createWorkflowWithTriggerAndHistory(), + createWorkflowWithTriggerAndHistory(), ]); - expect(workflows).toMatchObject([{ active: false }, { active: false }]); + expect(workflows[0].activeVersionId).toBeNull(); + expect(workflows[1].activeVersionId).toBeNull(); // // ACT @@ -43,33 +47,79 @@ describe('WorkflowRepository', () => { // // ASSERT // - const after = await getAllWorkflows(); - expect(after).toMatchObject([{ active: true }, { active: true }]); + const workflow1 = await workflowRepository.findOne({ + where: { id: workflows[0].id }, + }); + const workflow2 = await workflowRepository.findOne({ + where: { id: workflows[1].id }, + }); + + expect(workflow1?.activeVersionId).toBe(workflows[0].versionId); + expect(workflow2?.activeVersionId).toBe(workflows[1].versionId); + }); + + it('should not change activeVersionId for already-active workflows', async () => { + // + // ARRANGE + // + const workflowRepository = Container.get(WorkflowRepository); + const activeVersionId = 'old-active-version-id'; + + // Create workflow with different active and current versions + const workflow = await createWorkflowWithActiveVersion(activeVersionId, {}); + const currentVersionId = workflow.versionId; + + expect(workflow.active).toBe(true); + expect(workflow.activeVersionId).toBe(activeVersionId); + expect(workflow.versionId).toBe(currentVersionId); + + // + // ACT + // + await workflowRepository.activateAll(); + + // + // ASSERT + // + // activeVersionId should remain unchanged + const after = await workflowRepository.findOne({ + where: { id: workflow.id }, + }); + + expect(after?.activeVersionId).toBe(activeVersionId); // Unchanged + expect(after?.versionId).toBe(currentVersionId); }); }); describe('deactivateAll', () => { - it('should deactivate all workflows', async () => { + it('should deactivate all workflows and clear activeVersionId', async () => { // // ARRANGE // const workflowRepository = Container.get(WorkflowRepository); - const workflows = await Promise.all([ - createWorkflowWithTrigger({ active: true }), - createWorkflowWithTrigger({ active: true }), - ]); - expect(workflows).toMatchObject([{ active: true }, { active: true }]); + const workflows = await createManyActiveWorkflows(2); + + // Verify activeVersionId is initially set + expect(workflows[0].activeVersionId).not.toBeNull(); + expect(workflows[1].activeVersionId).not.toBeNull(); // // ACT // await workflowRepository.deactivateAll(); - // // ASSERT // - const after = await getAllWorkflows(); - expect(after).toMatchObject([{ active: false }, { active: false }]); + // Verify activeVersionId is cleared + const workflow1 = await workflowRepository.findOne({ + where: { id: workflows[0].id }, + }); + const workflow2 = await workflowRepository.findOne({ + where: { id: workflows[1].id }, + }); + + expect(workflow1?.activeVersionId).toBeNull(); + expect(workflow2?.activeVersionId).toBeNull(); }); }); @@ -79,9 +129,9 @@ describe('WorkflowRepository', () => { // ARRANGE // const workflows = await Promise.all([ - createWorkflow({ active: true }), - createWorkflow({ active: false }), - createWorkflow({ active: false }), + createActiveWorkflow(), + createWorkflowWithHistory(), + createWorkflowWithHistory(), ]); // @@ -101,9 +151,9 @@ describe('WorkflowRepository', () => { // ARRANGE // await Promise.all([ - createWorkflow({ active: true }), - createWorkflow({ active: false }), - createWorkflow({ active: true }), + createActiveWorkflow(), + createWorkflowWithHistory(), + createActiveWorkflow(), ]); // diff --git a/packages/cli/test/integration/folder/folder.controller.test.ts b/packages/cli/test/integration/folder/folder.controller.test.ts index 100bfd092324a..d64d15c5b7933 100644 --- a/packages/cli/test/integration/folder/folder.controller.test.ts +++ b/packages/cli/test/integration/folder/folder.controller.test.ts @@ -8,6 +8,7 @@ import { linkUserToProject, testDb, mockInstance, + createActiveWorkflow, } from '@n8n/backend-test-utils'; import type { Project, User } from '@n8n/db'; import { FolderRepository, ProjectRepository, WorkflowRepository } from '@n8n/db'; @@ -388,7 +389,6 @@ describe('GET /projects/:projectId/folders/:folderId/credentials', () => { { name: 'Test Workflow', parentFolder: folder, - active: false, nodes: [ { parameters: {}, @@ -480,7 +480,6 @@ describe('GET /projects/:projectId/folders/:folderId/credentials', () => { { name: 'Test Workflow', parentFolder: folder, - active: false, nodes: [ { parameters: {}, @@ -1093,8 +1092,8 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => { }); // Create workflows in the folders - const workflow1 = await createWorkflow({ parentFolder: rootFolder, active: false }, owner); - const workflow2 = await createWorkflow({ parentFolder: childFolder, active: true }, owner); + const workflow1 = await createWorkflow({ parentFolder: rootFolder }, owner); + const workflow2 = await createActiveWorkflow({ parentFolder: childFolder }, owner); await authOwnerAgent.delete(`/projects/${project.id}/folders/${rootFolder.id}`); @@ -1115,6 +1114,7 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => { expect(workflow1InDb?.isArchived).toBe(true); expect(workflow1InDb?.parentFolder).toBe(null); expect(workflow1InDb?.active).toBe(false); + expect(workflow1InDb?.activeVersionId).toBeNull(); const workflow2InDb = await workflowRepository.findOne({ where: { id: workflow2.id }, @@ -1124,6 +1124,7 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => { expect(workflow2InDb?.isArchived).toBe(true); expect(workflow2InDb?.parentFolder).toBe(null); expect(workflow2InDb?.active).toBe(false); + expect(workflow2InDb?.activeVersionId).toBeNull(); }); test('should transfer folder contents when transferToFolderId is specified', async () => { @@ -1823,7 +1824,7 @@ describe('PUT /projects/:projectId/folders/:folderId/transfer', () => { const sourceFolder1 = await createFolder(sourceProject, { name: 'Source Folder 1' }); - await createWorkflow({ active: true, parentFolder: sourceFolder1 }, destinationProject); + await createActiveWorkflow({ parentFolder: sourceFolder1 }, destinationProject); await testServer .authAgentFor(member) @@ -1856,7 +1857,7 @@ describe('PUT /projects/:projectId/folders/:folderId/transfer', () => { const sourceFolder1 = await createFolder(sourceProject, { name: 'Source Folder 1' }); - await createWorkflow({ active: true }, destinationProject); + await createActiveWorkflow({}, destinationProject); await testServer .authAgentFor(member) @@ -2599,7 +2600,7 @@ describe('PUT /projects/:projectId/folders/:folderId/transfer', () => { parentFolder: sourceFolder1, }); - await createWorkflow({ active: true, parentFolder: sourceFolder1 }, sourceProject); + await createActiveWorkflow({ parentFolder: sourceFolder1 }, sourceProject); await createWorkflow({ parentFolder: sourceFolder2 }, sourceProject); activeWorkflowManager.add.mockRejectedValue(new ApplicationError('Oh no!')); diff --git a/packages/cli/test/integration/import.service.test.ts b/packages/cli/test/integration/import.service.test.ts index a7c9b346eabec..77a43c2b23b98 100644 --- a/packages/cli/test/integration/import.service.test.ts +++ b/packages/cli/test/integration/import.service.test.ts @@ -5,6 +5,7 @@ import { getWorkflowById, newWorkflow, testDb, + createActiveWorkflow, } from '@n8n/backend-test-utils'; import { DatabaseConfig } from '@n8n/config'; import type { Project, User } from '@n8n/db'; @@ -120,7 +121,7 @@ describe('ImportService', () => { }); test('should deactivate imported workflow if active', async () => { - const workflowToImport = await createWorkflow({ active: true }); + const workflowToImport = await createActiveWorkflow(); await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); @@ -129,6 +130,7 @@ describe('ImportService', () => { if (!dbWorkflow) fail('Expected to find workflow'); expect(dbWorkflow.active).toBe(false); + expect(dbWorkflow.activeVersionId).toBeNull(); }); test('should leave intact new-format credentials', async () => { @@ -227,7 +229,7 @@ describe('ImportService', () => { }); test('should remove workflow from ActiveWorkflowManager when workflow has ID', async () => { - const workflowWithId = await createWorkflow({ active: true }); + const workflowWithId = await createActiveWorkflow(); await importService.importWorkflows([workflowWithId], ownerPersonalProject.id); expect(mockActiveWorkflowManager.remove).toHaveBeenCalledWith(workflowWithId.id); diff --git a/packages/cli/test/integration/license-metrics.repository.test.ts b/packages/cli/test/integration/license-metrics.repository.test.ts index 7cb84f9c23fa6..4472e3c825ef7 100644 --- a/packages/cli/test/integration/license-metrics.repository.test.ts +++ b/packages/cli/test/integration/license-metrics.repository.test.ts @@ -1,4 +1,4 @@ -import { createManyWorkflows, testDb } from '@n8n/backend-test-utils'; +import { createManyActiveWorkflows, createManyWorkflows, testDb } from '@n8n/backend-test-utils'; import { StatisticsNames, LicenseMetricsRepository, WorkflowStatisticsRepository } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -33,7 +33,7 @@ describe('LicenseMetricsRepository', () => { describe('getLicenseRenewalMetrics', () => { test('should return license renewal metrics', async () => { - const [firstWorkflow, secondWorkflow] = await createManyWorkflows(2, { active: false }); + const [firstWorkflow, secondWorkflow] = await createManyWorkflows(2); await Promise.all([ createOwner(), @@ -42,7 +42,7 @@ describe('LicenseMetricsRepository', () => { createMember(), createUser({ disabled: true }), createManyCredentials(2), - createManyWorkflows(3, { active: true }), + createManyActiveWorkflows(3), ]); await Promise.all([ @@ -85,7 +85,8 @@ describe('LicenseMetricsRepository', () => { }); test('should handle zero execution statistics correctly', async () => { - await Promise.all([createOwner(), createManyWorkflows(3, { active: true })]); + const owner = await createOwner(); + await createManyActiveWorkflows(3, {}, owner); const metrics = await licenseMetricsRepository.getLicenseRenewalMetrics(); diff --git a/packages/cli/test/integration/prometheus-metrics.test.ts b/packages/cli/test/integration/prometheus-metrics.test.ts index 34fe2b2e29088..7c0d56e8d7d4a 100644 --- a/packages/cli/test/integration/prometheus-metrics.test.ts +++ b/packages/cli/test/integration/prometheus-metrics.test.ts @@ -1,4 +1,4 @@ -import { createWorkflow, newWorkflow } from '@n8n/backend-test-utils'; +import { createActiveWorkflow } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -361,8 +361,7 @@ describe('PrometheusMetricsService', () => { expect(lines).toContain('n8n_test_active_workflow_count 0'); - const workflow = newWorkflow({ active: true }); - await createWorkflow(workflow); + await createActiveWorkflow({}); const workflowRepository = Container.get(WorkflowRepository); const activeWorkflowCount = await workflowRepository.getActiveCount(); diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index 920d8e8f3e569..b4c0f115f26fa 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -4,6 +4,7 @@ import { createWorkflowWithTriggerAndHistory, testDb, mockInstance, + createActiveWorkflow, } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import type { Project, TagEntity, User } from '@n8n/db'; @@ -110,6 +111,7 @@ describe('GET /workflows', () => { id, connections, active, + activeVersionId, staticData, nodes, settings, @@ -123,6 +125,7 @@ describe('GET /workflows', () => { expect(name).toBeDefined(); expect(connections).toBeDefined(); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toBeDefined(); expect(nodes).toBeDefined(); expect(tags).toBeDefined(); @@ -161,6 +164,7 @@ describe('GET /workflows', () => { id, connections, active, + activeVersionId, staticData, nodes, settings, @@ -174,6 +178,7 @@ describe('GET /workflows', () => { expect(name).toBeDefined(); expect(connections).toBeDefined(); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toBeDefined(); expect(nodes).toBeDefined(); expect(tags).toBeDefined(); @@ -203,6 +208,7 @@ describe('GET /workflows', () => { id, connections, active, + activeVersionId, staticData, nodes, settings, @@ -216,6 +222,7 @@ describe('GET /workflows', () => { expect(name).toBeDefined(); expect(connections).toBeDefined(); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toBeDefined(); expect(nodes).toBeDefined(); expect(settings).toBeDefined(); @@ -244,8 +251,18 @@ describe('GET /workflows', () => { expect(response.body.data.length).toBe(2); for (const workflow of response.body.data) { - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - workflow; + const { + id, + connections, + active, + activeVersionId, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + } = workflow; expect(id).toBeDefined(); expect([workflow1.id, workflow2.id].includes(id)).toBe(true); @@ -253,6 +270,7 @@ describe('GET /workflows', () => { expect(name).toBeDefined(); expect(connections).toBeDefined(); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toBeDefined(); expect(nodes).toBeDefined(); expect(settings).toBeDefined(); @@ -324,6 +342,7 @@ describe('GET /workflows', () => { id, connections, active, + activeVersionId, staticData, nodes, settings, @@ -337,6 +356,7 @@ describe('GET /workflows', () => { expect(name).toBe(workflowName); expect(connections).toBeDefined(); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toBeDefined(); expect(nodes).toBeDefined(); expect(settings).toBeDefined(); @@ -365,6 +385,7 @@ describe('GET /workflows', () => { id, connections, active, + activeVersionId, staticData, nodes, settings, @@ -378,6 +399,7 @@ describe('GET /workflows', () => { expect(name).toBeDefined(); expect(connections).toBeDefined(); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toBeDefined(); expect(nodes).toBeDefined(); expect(tags).toBeDefined(); @@ -427,6 +449,57 @@ describe('GET /workflows', () => { expect(pinData).not.toBeDefined(); } }); + + test('should return activeVersion for all workflows', async () => { + const inactiveWorkflow = await createWorkflow({}, member); + const activeWorkflow = await createWorkflowWithTriggerAndHistory({}, member); + + await authMemberAgent.post(`/workflows/${activeWorkflow.id}/activate`); + + const response = await authMemberAgent.get('/workflows'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + const inactiveInResponse = response.body.data.find( + (w: { id: string }) => w.id === inactiveWorkflow.id, + ); + const activeInResponse = response.body.data.find( + (w: { id: string }) => w.id === activeWorkflow.id, + ); + + // Inactive workflow should have null activeVersion + expect(inactiveInResponse).toBeDefined(); + expect(inactiveInResponse.activeVersionId).toBeNull(); + + // Active workflow should have populated activeVersion + expect(activeInResponse).toBeDefined(); + expect(activeInResponse.active).toBe(true); + expect(activeInResponse.activeVersion).toBeDefined(); + expect(activeInResponse.activeVersion).not.toBeNull(); + expect(activeInResponse.activeVersion.versionId).toBe(activeWorkflow.versionId); + expect(activeInResponse.activeVersion.nodes).toEqual(activeWorkflow.nodes); + expect(activeInResponse.activeVersion.connections).toEqual(activeWorkflow.connections); + }); + + test('should return activeVersion when filtering by active=true', async () => { + await createWorkflow({}, member); + const activeWorkflow = await createWorkflowWithTriggerAndHistory({}, member); + + await authMemberAgent.post(`/workflows/${activeWorkflow.id}/activate`); + + const response = await authMemberAgent.get('/workflows?active=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); + + const workflow = response.body.data[0]; + expect(workflow.id).toBe(activeWorkflow.id); + expect(workflow.active).toBe(true); + expect(workflow.activeVersion).toBeDefined(); + expect(workflow.activeVersion).not.toBeNull(); + expect(workflow.activeVersion.versionId).toBe(activeWorkflow.versionId); + }); }); describe('GET /workflows/:id', () => { @@ -451,6 +524,7 @@ describe('GET /workflows/:id', () => { id, connections, active, + activeVersionId, staticData, nodes, settings, @@ -464,6 +538,7 @@ describe('GET /workflows/:id', () => { expect(name).toEqual(workflow.name); expect(connections).toEqual(workflow.connections); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toEqual(workflow.staticData); expect(nodes).toEqual(workflow.nodes); expect(tags).toEqual([]); @@ -480,13 +555,24 @@ describe('GET /workflows/:id', () => { expect(response.statusCode).toBe(200); - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - response.body; + const { + id, + connections, + active, + activeVersionId, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + } = response.body; expect(id).toEqual(workflow.id); expect(name).toEqual(workflow.name); expect(connections).toEqual(workflow.connections); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toEqual(workflow.staticData); expect(nodes).toEqual(workflow.nodes); expect(settings).toEqual(workflow.settings); @@ -513,6 +599,34 @@ describe('GET /workflows/:id', () => { expect(pinData).not.toBeDefined(); }); + + test('should return activeVersion as null for inactive workflow', async () => { + const workflow = await createWorkflow({}, member); + + const response = await authMemberAgent.get(`/workflows/${workflow.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body.active).toBe(false); + expect(response.body.activeVersionId).toBe(null); + expect(response.body.activeVersion).toBeNull(); + }); + + test('should return activeVersion for active workflow', async () => { + const workflow = await createWorkflowWithTriggerAndHistory({}, member); + + await authMemberAgent.post(`/workflows/${workflow.id}/activate`); + + const response = await authMemberAgent.get(`/workflows/${workflow.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body.active).toBe(true); + expect(response.body.activeVersionId).toBe(workflow.versionId); + expect(response.body.activeVersion).toBeDefined(); + expect(response.body.activeVersion).not.toBeNull(); + expect(response.body.activeVersion.versionId).toBe(workflow.versionId); + expect(response.body.activeVersion.nodes).toEqual(workflow.nodes); + expect(response.body.activeVersion.connections).toEqual(workflow.connections); + }); }); describe('DELETE /workflows/:id', () => { @@ -533,13 +647,24 @@ describe('DELETE /workflows/:id', () => { expect(response.statusCode).toBe(200); - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - response.body; + const { + id, + connections, + active, + activeVersionId, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + } = response.body; expect(id).toEqual(workflow.id); expect(name).toEqual(workflow.name); expect(connections).toEqual(workflow.connections); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toEqual(workflow.staticData); expect(nodes).toEqual(workflow.nodes); expect(settings).toEqual(workflow.settings); @@ -562,13 +687,24 @@ describe('DELETE /workflows/:id', () => { expect(response.statusCode).toBe(200); - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - response.body; + const { + id, + connections, + active, + activeVersionId, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + } = response.body; expect(id).toEqual(workflow.id); expect(name).toEqual(workflow.name); expect(connections).toEqual(workflow.connections); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toEqual(workflow.staticData); expect(nodes).toEqual(workflow.nodes); expect(settings).toEqual(workflow.settings); @@ -630,13 +766,24 @@ describe('POST /workflows/:id/activate', () => { expect(response.statusCode).toBe(200); - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - response.body; + const { + id, + connections, + active, + activeVersionId, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + } = response.body; expect(id).toEqual(workflow.id); expect(name).toEqual(workflow.name); expect(connections).toEqual(workflow.connections); expect(active).toBe(true); + expect(activeVersionId).toBe(workflow.versionId); expect(staticData).toEqual(workflow.staticData); expect(nodes).toEqual(workflow.nodes); expect(settings).toEqual(workflow.settings); @@ -652,24 +799,58 @@ describe('POST /workflows/:id/activate', () => { relations: ['workflow'], }); - expect(sharedWorkflow?.workflow.active).toBe(true); + expect(sharedWorkflow?.workflow.activeVersionId).toBe(workflow.versionId); // check whether the workflow is on the active workflow runner expect(await activeWorkflowManager.isActive(workflow.id)).toBe(true); }); + test('should set activeVersionId when activating workflow', async () => { + const workflow = await createWorkflowWithTriggerAndHistory({}, member); + + const response = await authMemberAgent.post(`/workflows/${workflow.id}/activate`); + + expect(response.statusCode).toBe(200); + expect(response.body.active).toBe(true); + expect(response.body.activeVersionId).toBe(workflow.versionId); + + const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + projectId: memberPersonalProject.id, + workflowId: workflow.id, + }, + relations: ['workflow', 'workflow.activeVersion'], + }); + + expect(sharedWorkflow?.workflow.activeVersionId).toBe(workflow.versionId); + expect(sharedWorkflow?.workflow.activeVersion?.versionId).toBe(workflow.versionId); + expect(sharedWorkflow?.workflow.activeVersion?.nodes).toEqual(workflow.nodes); + expect(sharedWorkflow?.workflow.activeVersion?.connections).toEqual(workflow.connections); + }); + test('should set non-owned workflow as active when owner', async () => { const workflow = await createWorkflowWithTriggerAndHistory({}, member); const response = await authMemberAgent.post(`/workflows/${workflow.id}/activate`).expect(200); - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - response.body; + const { + id, + connections, + active, + activeVersionId, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + } = response.body; expect(id).toEqual(workflow.id); expect(name).toEqual(workflow.name); expect(connections).toEqual(workflow.connections); expect(active).toBe(true); + expect(activeVersionId).toBe(workflow.versionId); expect(staticData).toEqual(workflow.staticData); expect(nodes).toEqual(workflow.nodes); expect(settings).toEqual(workflow.settings); @@ -694,7 +875,7 @@ describe('POST /workflows/:id/activate', () => { relations: ['workflow'], }); - expect(sharedWorkflow?.workflow.active).toBe(true); + expect(sharedWorkflow?.workflow.activeVersionId).toBe(workflow.versionId); // check whether the workflow is on the active workflow runner expect(await activeWorkflowManager.isActive(workflow.id)).toBe(true); @@ -726,13 +907,24 @@ describe('POST /workflows/:id/deactivate', () => { `/workflows/${workflow.id}/deactivate`, ); - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - workflowDeactivationResponse.body; + const { + id, + connections, + active, + activeVersionId, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + } = workflowDeactivationResponse.body; expect(id).toEqual(workflow.id); expect(name).toEqual(workflow.name); expect(connections).toEqual(workflow.connections); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toEqual(workflow.staticData); expect(nodes).toEqual(workflow.nodes); expect(settings).toEqual(workflow.settings); @@ -749,11 +941,42 @@ describe('POST /workflows/:id/deactivate', () => { }); // check whether the workflow is deactivated in the database - expect(sharedWorkflow?.workflow.active).toBe(false); + expect(sharedWorkflow?.workflow.activeVersionId).toBeNull(); expect(await activeWorkflowManager.isActive(workflow.id)).toBe(false); }); + test('should clear activeVersionId when deactivating workflow', async () => { + const workflow = await createWorkflowWithTriggerAndHistory({}, member); + + await authMemberAgent.post(`/workflows/${workflow.id}/activate`); + let sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + projectId: memberPersonalProject.id, + workflowId: workflow.id, + }, + relations: ['workflow'], + }); + + expect(sharedWorkflow?.workflow.activeVersionId).toBe(workflow.versionId); + + const deactivateResponse = await authMemberAgent.post(`/workflows/${workflow.id}/deactivate`); + + expect(deactivateResponse.statusCode).toBe(200); + expect(deactivateResponse.body.active).toBe(false); + expect(deactivateResponse.body.activeVersionId).toBe(null); + + sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + projectId: memberPersonalProject.id, + workflowId: workflow.id, + }, + relations: ['workflow'], + }); + + expect(sharedWorkflow?.workflow.activeVersionId).toBeNull(); + }); + test('should deactivate non-owned workflow when owner', async () => { const workflow = await createWorkflowWithTriggerAndHistory({}, member); @@ -763,13 +986,24 @@ describe('POST /workflows/:id/deactivate', () => { `/workflows/${workflow.id}/deactivate`, ); - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - workflowDeactivationResponse.body; + const { + id, + connections, + active, + activeVersionId, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + } = workflowDeactivationResponse.body; expect(id).toEqual(workflow.id); expect(name).toEqual(workflow.name); expect(connections).toEqual(workflow.connections); expect(active).toBe(false); + expect(activeVersionId).toBe(null); expect(staticData).toEqual(workflow.staticData); expect(nodes).toEqual(workflow.nodes); expect(settings).toEqual(workflow.settings); @@ -794,7 +1028,7 @@ describe('POST /workflows/:id/deactivate', () => { relations: ['workflow'], }); - expect(sharedWorkflow?.workflow.active).toBe(false); + expect(sharedWorkflow?.workflow.activeVersionId).toBeNull(); expect(await activeWorkflowManager.isActive(workflow.id)).toBe(false); }); @@ -842,16 +1076,27 @@ describe('POST /workflows', () => { expect(response.statusCode).toBe(200); - const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } = - response.body; + const { + id, + name, + nodes, + connections, + staticData, + active, + activeVersionId, + settings, + createdAt, + updatedAt, + } = response.body; expect(id).toBeDefined(); expect(name).toBe(payload.name); expect(connections).toEqual(payload.connections); expect(settings).toEqual(payload.settings); + expect(active).toBe(false); expect(staticData).toEqual(payload.staticData); expect(nodes).toEqual(payload.nodes); - expect(active).toBe(false); + expect(activeVersionId).toBe(null); expect(createdAt).toBeDefined(); expect(updatedAt).toEqual(createdAt); @@ -1042,8 +1287,18 @@ describe('PUT /workflows/:id', () => { const response = await authMemberAgent.put(`/workflows/${workflow.id}`).send(payload); - const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } = - response.body; + const { + id, + name, + nodes, + connections, + staticData, + active, + activeVersionId, + settings, + createdAt, + updatedAt, + } = response.body; expect(response.statusCode).toBe(200); @@ -1051,9 +1306,10 @@ describe('PUT /workflows/:id', () => { expect(name).toBe(payload.name); expect(connections).toEqual(payload.connections); expect(settings).toEqual(payload.settings); + expect(active).toBe(false); expect(staticData).toMatchObject(JSON.parse(payload.staticData)); expect(nodes).toEqual(payload.nodes); - expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(createdAt).toBe(workflow.createdAt.toISOString()); expect(updatedAt).not.toBe(workflow.updatedAt.toISOString()); @@ -1126,6 +1382,119 @@ describe('PUT /workflows/:id', () => { expect(historyVersion!.nodes).toEqual(payload.nodes); }); + test('should update activeVersionId when updating an active workflow', async () => { + const workflow = await createActiveWorkflow({}, member); + + const updatedPayload = { + name: 'Updated active workflow', + nodes: [ + { + id: 'uuid-updated', + parameters: { triggerTimes: { item: [{ mode: 'everyMinute' }] } }, + name: 'Updated Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [300, 400], + }, + ], + connections: {}, + staticData: workflow.staticData, + settings: workflow.settings, + }; + + const updateResponse = await authMemberAgent + .put(`/workflows/${workflow.id}`) + .send(updatedPayload); + + expect(updateResponse.statusCode).toBe(200); + + await authMemberAgent.post(`/workflows/${workflow.id}/activate`); + + const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + projectId: memberPersonalProject.id, + workflowId: workflow.id, + }, + relations: ['workflow', 'workflow.activeVersion'], + }); + + expect(sharedWorkflow?.workflow.activeVersionId).toBe(sharedWorkflow?.workflow.versionId); + expect(sharedWorkflow?.workflow.activeVersionId).not.toBe(workflow.activeVersionId); + expect(sharedWorkflow?.workflow.activeVersion?.nodes).toEqual(updatedPayload.nodes); + }); + + test('should not update activeVersionId when updating an inactive workflow', async () => { + const workflow = await createWorkflow({}, member); + + // Update workflow without activating it + const updatedPayload = { + name: 'Updated inactive workflow', + nodes: [ + { + id: 'uuid-inactive', + parameters: {}, + name: 'Start Node', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [200, 300], + }, + ], + connections: {}, + staticData: workflow.staticData, + settings: workflow.settings, + }; + + const updateResponse = await authMemberAgent + .put(`/workflows/${workflow.id}`) + .send(updatedPayload); + + expect(updateResponse.statusCode).toBe(200); + expect(updateResponse.body.active).toBe(false); + expect(updateResponse.body.activeVersionId).toBeNull(); + + // Verify activeVersion is still null + const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + projectId: memberPersonalProject.id, + workflowId: workflow.id, + }, + relations: ['workflow'], + }); + + expect(sharedWorkflow?.workflow.activeVersionId).toBeNull(); + }); + + test('should not allow setting active field via PUT request', async () => { + const workflow = await createWorkflowWithTriggerAndHistory({}, member); + + const updatePayload = { + name: 'Try to activate via update', + nodes: workflow.nodes, + connections: workflow.connections, + staticData: workflow.staticData, + settings: workflow.settings, + active: true, + }; + + const updateResponse = await authMemberAgent + .put(`/workflows/${workflow.id}`) + .send(updatePayload); + + expect(updateResponse.statusCode).toBe(400); + expect(updateResponse.body.message).toContain('active'); + expect(updateResponse.body.message).toContain('read-only'); + + const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + projectId: memberPersonalProject.id, + workflowId: workflow.id, + }, + relations: ['workflow'], + }); + + expect(sharedWorkflow?.workflow.activeVersionId).toBeNull(); + }); + test('should update non-owned workflow if owner', async () => { const workflow = await createWorkflow({}, member); @@ -1165,8 +1534,18 @@ describe('PUT /workflows/:id', () => { const response = await authMemberAgent.put(`/workflows/${workflow.id}`).send(payload); - const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } = - response.body; + const { + id, + name, + nodes, + connections, + staticData, + active, + activeVersionId, + settings, + createdAt, + updatedAt, + } = response.body; expect(response.statusCode).toBe(200); @@ -1174,9 +1553,10 @@ describe('PUT /workflows/:id', () => { expect(name).toBe(payload.name); expect(connections).toEqual(payload.connections); expect(settings).toEqual(payload.settings); + expect(active).toBe(false); expect(staticData).toMatchObject(JSON.parse(payload.staticData)); expect(nodes).toEqual(payload.nodes); - expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(createdAt).toBe(workflow.createdAt.toISOString()); expect(updatedAt).not.toBe(workflow.updatedAt.toISOString()); diff --git a/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts index e48418133cd99..7bbfd0eb09443 100644 --- a/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts @@ -1,4 +1,4 @@ -import { testDb } from '@n8n/backend-test-utils'; +import { createActiveWorkflow, createWorkflowWithHistory, testDb } from '@n8n/backend-test-utils'; import type { SecurityConfig } from '@n8n/config'; import { generateNanoId, @@ -46,12 +46,9 @@ test('should report credentials not in any use', async () => { }; const workflowDetails = { - id: generateNanoId(), name: 'My Test Workflow', - active: false, connections: {}, nodeTypes: {}, - versionId: uuid(), nodes: [ { id: uuid(), @@ -59,13 +56,14 @@ test('should report credentials not in any use', async () => { type: 'n8n-nodes-base.slack', typeVersion: 1, position: [0, 0] as [number, number], + parameters: {}, }, ], }; await Promise.all([ Container.get(CredentialsRepository).save(credentialDetails), - Container.get(WorkflowRepository).save(workflowDetails), + createWorkflowWithHistory(workflowDetails), ]); const testAudit = await securityAuditService.run(['credentials']); @@ -94,12 +92,9 @@ test('should report credentials not in active use', async () => { const credential = await Container.get(CredentialsRepository).save(credentialDetails); const workflowDetails = { - id: generateNanoId(), name: 'My Test Workflow', - active: false, connections: {}, nodeTypes: {}, - versionId: uuid(), nodes: [ { id: uuid(), @@ -107,11 +102,12 @@ test('should report credentials not in active use', async () => { type: 'n8n-nodes-base.slack', typeVersion: 1, position: [0, 0] as [number, number], + parameters: {}, }, ], }; - await Container.get(WorkflowRepository).save(workflowDetails); + await createWorkflowWithHistory(workflowDetails); const testAudit = await securityAuditService.run(['credentials']); @@ -139,12 +135,9 @@ test('should report credential in not recently executed workflow', async () => { const credential = await Container.get(CredentialsRepository).save(credentialDetails); const workflowDetails = { - id: generateNanoId(), name: 'My Test Workflow', - active: false, connections: {}, nodeTypes: {}, - versionId: uuid(), nodes: [ { id: uuid(), @@ -158,11 +151,12 @@ test('should report credential in not recently executed workflow', async () => { name: credential.name, }, }, + parameters: {}, }, ], }; - const workflow = await Container.get(WorkflowRepository).save(workflowDetails); + const workflow = await createWorkflowWithHistory(workflowDetails); const date = new Date(); date.setDate(date.getDate() - securityConfig.daysAbandonedWorkflow - 1); @@ -209,12 +203,9 @@ test('should not report credentials in recently executed workflow', async () => const credential = await Container.get(CredentialsRepository).save(credentialDetails); const workflowDetails = { - id: generateNanoId(), name: 'My Test Workflow', - active: true, connections: {}, nodeTypes: {}, - versionId: uuid(), nodes: [ { id: uuid(), @@ -228,11 +219,12 @@ test('should not report credentials in recently executed workflow', async () => name: credential.name, }, }, + parameters: {}, }, ], }; - const workflow = await Container.get(WorkflowRepository).save(workflowDetails); + const workflow = await createActiveWorkflow(workflowDetails); const date = new Date(); date.setDate(date.getDate() - securityConfig.daysAbandonedWorkflow + 1); diff --git a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts index 5569243d4041d..110a4d41bd59e 100644 --- a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts @@ -1,6 +1,6 @@ -import { testDb } from '@n8n/backend-test-utils'; +import { createActiveWorkflow, testDb } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; -import { generateNanoId, WorkflowRepository } from '@n8n/db'; +import { WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { NodeConnectionTypes } from 'n8n-workflow'; @@ -39,13 +39,9 @@ afterAll(async () => { test('should report webhook lacking authentication', async () => { const targetNodeId = uuid(); - const details = { - id: generateNanoId(), + await createActiveWorkflow({ name: 'My Test Workflow', - active: true, - nodeTypes: {}, connections: {}, - versionId: uuid(), nodes: [ { parameters: { @@ -60,9 +56,7 @@ test('should report webhook lacking authentication', async () => { webhookId: uuid(), }, ], - }; - - await Container.get(WorkflowRepository).save(details); + }); const testAudit = await securityAuditService.run(['instance']); @@ -83,13 +77,9 @@ test('should report webhook lacking authentication', async () => { test('should not report webhooks having basic or header auth', async () => { const promises = ['basicAuth', 'headerAuth'].map(async (authType) => { - const details = { - id: generateNanoId(), + return await createActiveWorkflow({ name: 'My Test Workflow', - active: true, - nodeTypes: {}, connections: {}, - versionId: uuid(), nodes: [ { parameters: { @@ -105,9 +95,7 @@ test('should not report webhooks having basic or header auth', async () => { webhookId: uuid(), }, ], - }; - - return await Container.get(WorkflowRepository).save(details); + }); }); await Promise.all(promises); @@ -129,12 +117,8 @@ test('should not report webhooks having basic or header auth', async () => { test('should not report webhooks validated by direct children', async () => { const promises = [...WEBHOOK_VALIDATOR_NODE_TYPES].map(async (nodeType) => { - const details = { - id: generateNanoId(), + return await createActiveWorkflow({ name: 'My Test Workflow', - active: true, - nodeTypes: {}, - versionId: uuid(), nodes: [ { parameters: { @@ -154,6 +138,7 @@ test('should not report webhooks validated by direct children', async () => { type: nodeType, typeVersion: 1, position: [0, 0] as [number, number], + parameters: {}, }, ], connections: { @@ -169,9 +154,7 @@ test('should not report webhooks validated by direct children', async () => { ], }, }, - }; - - return await Container.get(WorkflowRepository).save(details); + }); }); await Promise.all(promises); diff --git a/packages/cli/test/integration/services/role.service.test.ts b/packages/cli/test/integration/services/role.service.test.ts index 71f3d85629f87..5896a45ca67a4 100644 --- a/packages/cli/test/integration/services/role.service.test.ts +++ b/packages/cli/test/integration/services/role.service.test.ts @@ -53,7 +53,7 @@ afterAll(async () => { }); afterEach(async () => { - await testDb.truncate(['User']); + await testDb.truncate(['User', 'ProjectRelation']); await cleanupRolesAndScopes(); }); diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index 7667cb5f97515..a6fbc08f8e965 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -196,6 +196,7 @@ export function makeWorkflow(options?: { workflow.name = 'My Workflow'; workflow.active = false; + workflow.activeVersionId = null; workflow.connections = {}; workflow.nodes = [node]; diff --git a/packages/cli/test/integration/webhooks.api.test.ts b/packages/cli/test/integration/webhooks.api.test.ts index dff68754a8a68..dceb9bdceb1c2 100644 --- a/packages/cli/test/integration/webhooks.api.test.ts +++ b/packages/cli/test/integration/webhooks.api.test.ts @@ -1,7 +1,6 @@ -import { createWorkflow, testDb, mockInstance } from '@n8n/backend-test-utils'; +import { testDb, mockInstance, createActiveWorkflow } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; import { readFileSync } from 'fs'; -import { mock } from 'jest-mock-extended'; import { type INode, type IWorkflowBase, @@ -66,11 +65,15 @@ class WebhookTestingNode implements INodeType { describe('Webhook API', () => { const nodeInstance = new WebhookTestingNode(); - const node = mock({ + const node: INode = { + id: 'webhook-node-1', name: 'Webhook', type: nodeInstance.description.name, + typeVersion: 1, + position: [0, 0], + parameters: {}, webhookId: '5ccef736-be16-4d10-b7fb-feed7a61ff22', - }); + }; const workflowData = { active: true, nodes: [node] } as IWorkflowBase; const nodeTypes = mockInstance(NodeTypes); @@ -91,7 +94,7 @@ describe('Webhook API', () => { beforeEach(async () => { await testDb.truncate(['WorkflowEntity']); - await createWorkflow(workflowData, user); + await createActiveWorkflow(workflowData, user); await initActiveWorkflowManager(); }); diff --git a/packages/cli/test/integration/workflow-history-manager.test.ts b/packages/cli/test/integration/workflow-history-manager.test.ts index 8005bb6ca0bb8..79e48454d8397 100644 --- a/packages/cli/test/integration/workflow-history-manager.test.ts +++ b/packages/cli/test/integration/workflow-history-manager.test.ts @@ -1,4 +1,9 @@ -import { createWorkflow, testDb, mockInstance } from '@n8n/backend-test-utils'; +import { + createWorkflow, + testDb, + mockInstance, + createActiveWorkflow, +} from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { WorkflowHistoryRepository, WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -24,7 +29,7 @@ describe('Workflow History Manager', () => { }); beforeEach(async () => { - await testDb.truncate(['WorkflowEntity']); + await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']); jest.clearAllMocks(); globalConfig.workflowHistory.pruneTime = -1; @@ -92,8 +97,8 @@ describe('Workflow History Manager', () => { test('should not prune current versions', async () => { globalConfig.workflowHistory.pruneTime = 24; - const activeWorkflow = await createWorkflow({ active: true }); - const inactiveWorkflow = await createWorkflow({ active: false }); + const activeWorkflow = await createActiveWorkflow(); + const inactiveWorkflow = await createWorkflow(); // Create old history versions for the active workflow const activeWorkflowVersions = await createManyWorkflowHistoryItems( @@ -130,6 +135,36 @@ describe('Workflow History Manager', () => { expect(await repo.count({ where: { versionId: In(otherVersionIds) } })).toBe(0); }); + test('should not prune current or active versions when they differ', async () => { + globalConfig.workflowHistory.pruneTime = 24; + + const workflow = await createActiveWorkflow(); + + // Create old history versions + const workflowVersions = await createManyWorkflowHistoryItems( + workflow.id, + 5, + DateTime.now().minus({ days: 2 }).toJSDate(), + ); + + // Set current version to one version and active version to a different version + workflow.versionId = workflowVersions[0].versionId; + workflow.activeVersionId = workflowVersions[1].versionId; + + const workflowRepo = Container.get(WorkflowRepository); + await workflowRepo.save(workflow); + + await manager.prune(); + + // Both current and active versions should still exist even though they are old + expect(await repo.count({ where: { versionId: workflow.versionId } })).toBe(1); + expect(await repo.count({ where: { versionId: workflow.activeVersionId } })).toBe(1); + + // Other old versions should be deleted + const otherVersionIds = workflowVersions.slice(2).map((i) => i.versionId); + expect(await repo.count({ where: { versionId: In(otherVersionIds) } })).toBe(0); + }); + const createWorkflowHistory = async (ageInDays = 2) => { const workflow = await createWorkflow(); const time = DateTime.now().minus({ days: ageInDays }).toJSDate(); @@ -139,7 +174,7 @@ describe('Workflow History Manager', () => { const pruneAndAssertCount = async (finalCount = 10, initialCount = 10) => { expect(await repo.count()).toBe(initialCount); - const deleteSpy = jest.spyOn(repo, 'deleteEarlierThanExceptCurrent'); + const deleteSpy = jest.spyOn(repo, 'deleteEarlierThanExceptCurrentAndActive'); await manager.prune(); if (initialCount === finalCount) { diff --git a/packages/cli/test/integration/workflows/workflow-index.test.ts b/packages/cli/test/integration/workflows/workflow-index.test.ts index 5c54ebb2e2ede..5d7f67a7fad07 100644 --- a/packages/cli/test/integration/workflows/workflow-index.test.ts +++ b/packages/cli/test/integration/workflows/workflow-index.test.ts @@ -65,6 +65,7 @@ describe('WorkflowIndexService Integration', () => { id: workflowId, name: 'Test Workflow', active: false, + activeVersionId: null, versionCounter: 1, versionId, nodes: [ diff --git a/packages/cli/test/integration/workflows/workflow.service.test.ts b/packages/cli/test/integration/workflows/workflow.service.test.ts index 93091f1de1196..ae822fce4bdec 100644 --- a/packages/cli/test/integration/workflows/workflow.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.test.ts @@ -1,4 +1,9 @@ -import { createWorkflowWithHistory, testDb, mockInstance } from '@n8n/backend-test-utils'; +import { + createWorkflowWithHistory, + createActiveWorkflow, + testDb, + mockInstance, +} from '@n8n/backend-test-utils'; import { SharedWorkflowRepository, type WorkflowEntity, WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; @@ -44,19 +49,24 @@ beforeAll(async () => { }); afterEach(async () => { - await testDb.truncate(['WorkflowEntity']); + await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']); jest.restoreAllMocks(); }); describe('update()', () => { test('should remove and re-add to active workflows on `active: true` payload', async () => { const owner = await createOwner(); - const workflow = await createWorkflowWithHistory({ active: true }, owner); + const workflow = await createActiveWorkflow({}, owner); const removeSpy = jest.spyOn(activeWorkflowManager, 'remove'); const addSpy = jest.spyOn(activeWorkflowManager, 'add'); - await workflowService.update(owner, workflow, workflow.id); + const updateData = { + active: true, + versionId: workflow.versionId, + }; + + await workflowService.update(owner, updateData as WorkflowEntity, workflow.id); expect(removeSpy).toHaveBeenCalledTimes(1); const [removedWorkflowId] = removeSpy.mock.calls[0]; @@ -70,13 +80,17 @@ describe('update()', () => { test('should remove from active workflows on `active: false` payload', async () => { const owner = await createOwner(); - const workflow = await createWorkflowWithHistory({ active: true }, owner); + const workflow = await createActiveWorkflow({}, owner); const removeSpy = jest.spyOn(activeWorkflowManager, 'remove'); const addSpy = jest.spyOn(activeWorkflowManager, 'add'); - workflow.active = false; - await workflowService.update(owner, workflow, workflow.id); + const updateData = { + active: false, + versionId: workflow.versionId, + }; + + await workflowService.update(owner, updateData as WorkflowEntity, workflow.id); expect(removeSpy).toHaveBeenCalledTimes(1); const [removedWorkflowId] = removeSpy.mock.calls[0]; @@ -89,7 +103,7 @@ describe('update()', () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); - const updateData: Partial = { + const updateData = { nodes: [ { id: 'new-node', @@ -116,11 +130,11 @@ describe('update()', () => { test('should not save workflow history version when updating only active status', async () => { const owner = await createOwner(); - const workflow = await createWorkflowWithHistory({ active: false }, owner); + const workflow = await createWorkflowWithHistory({}, owner); const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion'); - const updateData: Partial = { + const updateData = { active: true, versionId: workflow.versionId, }; @@ -132,13 +146,12 @@ describe('update()', () => { test('should save workflow history version with backfilled data when versionId changes', async () => { const owner = await createOwner(); - const workflow = await createWorkflowWithHistory({ active: false }, owner); + const workflow = await createWorkflowWithHistory({}, owner); const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion'); const newVersionId = 'new-version-id-123'; - const updateData: Partial = { - active: true, + const updateData = { versionId: newVersionId, }; diff --git a/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts index a9971c3a78c52..6b8e5bcc47908 100644 --- a/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts @@ -1,8 +1,8 @@ import { createTeamProject, - createWorkflowWithTriggerAndHistory, testDb, mockInstance, + createActiveWorkflow, } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; @@ -39,7 +39,7 @@ describe('PUT /:workflowId/transfer', () => { // const destinationProject = await createTeamProject('Team Project', member); - const workflow = await createWorkflowWithTriggerAndHistory({ active: true }, member); + const workflow = await createActiveWorkflow({}, member); // // ACT diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index dc846c67c77e6..abd98d05dd46a 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -3,6 +3,7 @@ import { getPersonalProject, linkUserToProject, createWorkflow, + createActiveWorkflow, createWorkflowWithHistory, getWorkflowSharing, shareWorkflowWithProjects, @@ -1421,7 +1422,7 @@ describe('PATCH /workflows/:workflowId', () => { describe('activate workflow', () => { test('should activate workflow without changing version ID', async () => { - const workflow = await createWorkflow({}, owner); + const workflow = await createWorkflowWithHistory({}, owner); const payload = { versionId: workflow.versionId, active: true, @@ -1433,16 +1434,17 @@ describe('PATCH /workflows/:workflowId', () => { expect(activeWorkflowManager.add).toBeCalled(); const { - data: { id, versionId, active }, + data: { id, versionId, active, activeVersionId }, } = response.body; expect(id).toBe(workflow.id); expect(versionId).toBe(workflow.versionId); expect(active).toBe(true); + expect(activeVersionId).toBe(workflow.versionId); }); test('should deactivate workflow without changing version ID', async () => { - const workflow = await createWorkflowWithHistory({ active: true }, owner); + const workflow = await createActiveWorkflow({}, owner); const payload = { versionId: workflow.versionId, active: false, @@ -1455,12 +1457,13 @@ describe('PATCH /workflows/:workflowId', () => { expect(activeWorkflowManager.remove).toBeCalled(); const { - data: { id, versionId, active }, + data: { id, versionId, active, activeVersionId }, } = response.body; expect(id).toBe(workflow.id); expect(versionId).toBe(workflow.versionId); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); }); }); }); @@ -1636,7 +1639,7 @@ describe('PUT /:workflowId/transfer', () => { // const destinationProject = await createTeamProject('Team Project', member); - const workflow = await createWorkflowWithHistory({ active: true }, member); + const workflow = await createActiveWorkflow({}, member); // // ACT @@ -1664,10 +1667,7 @@ describe('PUT /:workflowId/transfer', () => { const folder = await createFolder(destinationProject, { name: 'Test Folder' }); - const workflow = await createWorkflowWithHistory( - { active: true, parentFolder: folder }, - member, - ); + const workflow = await createActiveWorkflow({ parentFolder: folder }, member); // // ACT @@ -1699,10 +1699,7 @@ describe('PUT /:workflowId/transfer', () => { const folder = await createFolder(destinationProject, { name: 'Test Folder' }); - const workflow = await createWorkflowWithHistory( - { active: true, parentFolder: folder }, - member, - ); + const workflow = await createActiveWorkflow({ parentFolder: folder }, member); // // ACT @@ -1742,10 +1739,7 @@ describe('PUT /:workflowId/transfer', () => { name: 'Another Test Folder', }); - const workflow = await createWorkflow( - { active: true, parentFolder: folderInDestinationProject }, - member, - ); + const workflow = await createWorkflow({ parentFolder: folderInDestinationProject }, member); // // ACT @@ -1766,7 +1760,7 @@ describe('PUT /:workflowId/transfer', () => { // const destinationProject = await createTeamProject('Team Project', member); - const workflow = await createWorkflowWithHistory({ active: true }, member); + const workflow = await createActiveWorkflow({}, member); activeWorkflowManager.add.mockRejectedValue(new WorkflowActivationError('Failed')); @@ -1795,7 +1789,8 @@ describe('PUT /:workflowId/transfer', () => { expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update'); const workflowFromDB = await workflowRepository.findOneByOrFail({ id: workflow.id }); - expect(workflowFromDB).toMatchObject({ active: false }); + expect(workflowFromDB.active).toBe(false); + expect(workflowFromDB.activeVersionId).toBeNull(); }); test('owner transfers workflow from project they are not part of, e.g. test global cred sharing scope', async () => { @@ -2136,7 +2131,7 @@ describe('PUT /:workflowId/transfer', () => { // const destinationProject = await createTeamProject('Team Project', member); - const workflow = await createWorkflowWithHistory({ active: true }, member); + const workflow = await createActiveWorkflow({}, member); activeWorkflowManager.add.mockRejectedValue(new ApplicationError('Oh no!')); diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 9e25ba9664490..87868a91728e7 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -3,7 +3,10 @@ import { getPersonalProject, linkUserToProject, createWorkflow, + createActiveWorkflow, + setActiveVersion, createWorkflowWithHistory, + createWorkflowWithTriggerAndHistory, shareWorkflowWithProjects, shareWorkflowWithUsers, randomCredentialPayload, @@ -62,10 +65,10 @@ let folderListMissingRole: Role; beforeEach(async () => { await testDb.truncate([ 'SharedWorkflow', - 'WorkflowHistory', 'ProjectRelation', 'Folder', 'WorkflowEntity', + 'WorkflowHistory', 'TagEntity', 'Project', 'User', @@ -131,6 +134,7 @@ describe('POST /workflows', () => { timezone: 'America/New_York', }, active: false, + activeVersionId: null, }; const response = await authMemberAgent.post('/workflows').send(payload); @@ -171,6 +175,7 @@ describe('POST /workflows', () => { staticData: null, settings: {}, active: false, + activeVersionId: null, uiContext: 'workflow_list', }; @@ -210,6 +215,7 @@ describe('POST /workflows', () => { timezone: 'America/New_York', }, active: false, + activeVersionId: null, }; const response = await authOwnerAgent.post('/workflows').send(payload); @@ -234,6 +240,52 @@ describe('POST /workflows', () => { expect(historyVersion!.nodes).toEqual(payload.nodes); }); + test('should create workflow as active when active: true is provided in POST body', async () => { + const payload = { + name: 'active workflow', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: {}, + active: true, + }; + + const response = await authOwnerAgent.post('/workflows').send(payload); + + expect(response.statusCode).toBe(200); + + const { + data: { id, versionId, activeVersionId, active }, + } = response.body; + + expect(id).toBeDefined(); + expect(versionId).toBeDefined(); + expect(activeVersionId).toBe(versionId); // Should be set to current version + expect(active).toBe(true); + + // Verify in database + const workflow = await Container.get(WorkflowRepository).findOneBy({ id }); + expect(workflow?.activeVersionId).toBe(versionId); + + // Verify history was created + const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ + where: { + workflowId: id, + versionId, + }, + }); + expect(historyVersion).not.toBeNull(); + }); + test('create workflow in personal project by default', async () => { // // ARRANGE @@ -261,6 +313,7 @@ describe('POST /workflows', () => { }); expect(response.body.data).toMatchObject({ active: false, + activeVersionId: null, id: expect.any(String), name: workflow.name, sharedWithProjects: [], @@ -311,6 +364,7 @@ describe('POST /workflows', () => { }); expect(response.body.data).toMatchObject({ active: false, + activeVersionId: null, id: expect.any(String), name: workflow.name, sharedWithProjects: [], @@ -407,6 +461,7 @@ describe('POST /workflows', () => { expect(response.body.data).toMatchObject({ active: false, + activeVersionId: null, id: expect.any(String), name: workflow.name, sharedWithProjects: [], @@ -445,6 +500,7 @@ describe('POST /workflows', () => { expect(response.body.data).toMatchObject({ active: false, + activeVersionId: null, id: expect.any(String), name: workflow.name, sharedWithProjects: [], @@ -480,6 +536,7 @@ describe('POST /workflows', () => { expect(response.body.data).toMatchObject({ active: false, + activeVersionId: null, id: expect.any(String), name: workflow.name, sharedWithProjects: [], @@ -613,7 +670,8 @@ describe('GET /workflows', () => { objectContaining({ id: any(String), name: 'First', - active: any(Boolean), + active: false, + activeVersionId: null, createdAt: any(String), updatedAt: any(String), tags: [{ id: any(String), name: 'A' }], @@ -629,7 +687,8 @@ describe('GET /workflows', () => { objectContaining({ id: any(String), name: 'Second', - active: any(Boolean), + active: false, + activeVersionId: null, createdAt: any(String), updatedAt: any(String), tags: [], @@ -813,8 +872,8 @@ describe('GET /workflows', () => { }); test('should filter workflows by field: active', async () => { - await createWorkflow({ active: true }, owner); - await createWorkflow({ active: false }, owner); + await createActiveWorkflow({}, owner); + await createWorkflow({}, owner); const response = await authOwnerAgent .get('/workflows') @@ -823,7 +882,7 @@ describe('GET /workflows', () => { expect(response.body).toEqual({ count: 1, - data: [objectContaining({ active: true })], + data: [objectContaining({ active: true, activeVersionId: expect.any(String) })], }); }); @@ -1121,8 +1180,8 @@ describe('GET /workflows', () => { }); test('should select workflow field: active', async () => { - await createWorkflow({ active: true }, owner); - await createWorkflow({ active: false }, owner); + await createActiveWorkflow({}, owner); + await createWorkflow({}, owner); const response = await authOwnerAgent .get('/workflows') @@ -1138,6 +1197,24 @@ describe('GET /workflows', () => { }); }); + test('should select workflow field: activeVersionId', async () => { + const activeWorkflow = await createActiveWorkflow({}, owner); + await createWorkflow({}, owner); + + const response = await authOwnerAgent + .get('/workflows') + .query('select=["activeVersionId"]') + .expect(200); + + expect(response.body).toEqual({ + count: 2, + data: arrayContaining([ + { id: any(String), activeVersionId: activeWorkflow.versionId }, + { id: any(String), activeVersionId: null }, + ]), + }); + }); + test('should select workflow field: tags', async () => { const firstWorkflow = await createWorkflow({ name: 'First' }, owner); const secondWorkflow = await createWorkflow({ name: 'Second' }, owner); @@ -1477,7 +1554,8 @@ describe('GET /workflows?onlySharedWithMe=true', () => { objectContaining({ id: any(String), name: 'Third', - active: any(Boolean), + active: false, + activeVersionId: null, createdAt: any(String), updatedAt: any(String), versionId: any(String), @@ -1549,7 +1627,8 @@ describe('GET /workflows?includeFolders=true', () => { resource: 'workflow', id: any(String), name: 'First', - active: any(Boolean), + active: false, + activeVersionId: null, createdAt: any(String), updatedAt: any(String), tags: [{ id: any(String), name: 'A' }], @@ -1565,7 +1644,7 @@ describe('GET /workflows?includeFolders=true', () => { objectContaining({ id: any(String), name: 'Second', - active: any(Boolean), + activeVersionId: null, createdAt: any(String), updatedAt: any(String), tags: [], @@ -1846,8 +1925,8 @@ describe('GET /workflows?includeFolders=true', () => { }); test('should filter workflows and folders by field: active', async () => { - const workflow1 = await createWorkflow({ active: true }, owner); - await createWorkflow({ active: false }, owner); + const workflow1 = await createActiveWorkflow({}, owner); + await createWorkflow({}, owner); const response = await authOwnerAgent .get('/workflows') @@ -1856,7 +1935,9 @@ describe('GET /workflows?includeFolders=true', () => { expect(response.body).toEqual({ count: 1, - data: [objectContaining({ id: workflow1.id, active: true })], + data: [ + objectContaining({ id: workflow1.id, active: true, versionId: workflow1.versionId }), + ], }); }); @@ -2408,16 +2489,17 @@ describe('PATCH /workflows/:workflowId', () => { expect(activeWorkflowManagerLike.add).toBeCalled(); const { - data: { id, versionId, active }, + data: { id, versionId, active, activeVersionId }, } = response.body; expect(id).toBe(workflow.id); expect(versionId).toBe(workflow.versionId); expect(active).toBe(true); + expect(activeVersionId).toBe(workflow.versionId); }); test('should deactivate workflow without changing version ID', async () => { - const workflow = await createWorkflowWithHistory({ active: true }, owner); + const workflow = await createActiveWorkflow({}, owner); const payload = { versionId: workflow.versionId, active: false, @@ -2430,12 +2512,129 @@ describe('PATCH /workflows/:workflowId', () => { expect(activeWorkflowManagerLike.remove).toBeCalled(); const { - data: { id, versionId, active }, + data: { id, versionId, active, activeVersionId }, } = response.body; expect(id).toBe(workflow.id); expect(versionId).toBe(workflow.versionId); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); + }); + + test('should set activeVersionId when activating via PATCH', async () => { + const workflow = await createWorkflowWithTriggerAndHistory({}, owner); + + const payload = { + versionId: workflow.versionId, + active: true, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + expect(response.statusCode).toBe(200); + expect(activeWorkflowManagerLike.add).toBeCalled(); + + const { + data: { id, activeVersionId }, + } = response.body; + + expect(id).toBe(workflow.id); + expect(activeVersionId).toBe(workflow.versionId); + + // Verify activeVersion is set + const updatedWorkflow = await Container.get(WorkflowRepository).findOne({ + where: { id: workflow.id }, + relations: ['activeVersion'], + }); + + expect(updatedWorkflow?.activeVersionId).toBe(workflow.versionId); + expect(updatedWorkflow?.activeVersion).not.toBeNull(); + expect(updatedWorkflow?.activeVersion?.versionId).toBe(workflow.versionId); + expect(updatedWorkflow?.activeVersion?.nodes).toEqual(workflow.nodes); + expect(updatedWorkflow?.activeVersion?.connections).toEqual(workflow.connections); + }); + + test('should clear activeVersionId when deactivating via PATCH', async () => { + const workflow = await createActiveWorkflow({}, owner); + + await setActiveVersion(workflow.id, workflow.versionId); + + const payload = { + versionId: workflow.versionId, + active: false, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + expect(response.statusCode).toBe(200); + expect(activeWorkflowManagerLike.remove).toBeCalled(); + + const { + data: { id, activeVersionId }, + } = response.body; + + expect(id).toBe(workflow.id); + expect(activeVersionId).toBeNull(); + + // Verify activeVersion is cleared + const updatedWorkflow = await Container.get(WorkflowRepository).findOne({ + where: { id: workflow.id }, + }); + + expect(updatedWorkflow?.activeVersionId).toBeNull(); + }); + + test('should update activeVersionId when updating an active workflow', async () => { + const workflow = await createActiveWorkflow({}, owner); + + await setActiveVersion(workflow.id, workflow.versionId); + + // Verify initial state + const initialWorkflow = await Container.get(WorkflowRepository).findOne({ + where: { id: workflow.id }, + relations: ['activeVersion'], + }); + expect(initialWorkflow?.activeVersion?.versionId).toBe(workflow.versionId); + + // Update workflow nodes + const updatedNodes: INode[] = [ + { + id: 'uuid-updated', + parameters: { triggerTimes: { item: [{ mode: 'everyHour' }] } }, + name: 'Cron Updated', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [500, 400], + }, + ]; + + const payload = { + versionId: workflow.versionId, + nodes: updatedNodes, + connections: {}, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + expect(response.statusCode).toBe(200); + + const { + data: { id, versionId: newVersionId }, + } = response.body; + + expect(id).toBe(workflow.id); + expect(newVersionId).not.toBe(workflow.versionId); + + // Verify activeVersion points to the new version + const updatedWorkflow = await Container.get(WorkflowRepository).findOne({ + where: { id: workflow.id }, + relations: ['activeVersion'], + }); + + expect(updatedWorkflow?.active).toBe(true); + expect(updatedWorkflow?.activeVersionId).not.toBeNull(); + expect(updatedWorkflow?.activeVersion?.versionId).toBe(newVersionId); + expect(updatedWorkflow?.activeVersion?.nodes).toEqual(updatedNodes); }); test('should update workflow meta', async () => { @@ -2559,11 +2758,12 @@ describe('POST /workflows/:workflowId/archive', () => { .expect(200); const { - data: { isArchived, versionId }, + data: { isArchived, versionId, active }, } = response.body; expect(isArchived).toBe(true); expect(versionId).not.toBe(workflow.versionId); + expect(active).toBe(false); const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id); expect(updatedWorkflow).not.toBeNull(); @@ -2571,17 +2771,18 @@ describe('POST /workflows/:workflowId/archive', () => { }); test('should deactivate active workflow on archive', async () => { - const workflow = await createWorkflow({ active: true }, owner); + const workflow = await createActiveWorkflow({}, owner); const response = await authOwnerAgent .post(`/workflows/${workflow.id}/archive`) .send() .expect(200); const { - data: { isArchived, versionId, active }, + data: { isArchived, versionId, activeVersionId, active }, } = response.body; expect(isArchived).toBe(true); + expect(activeVersionId).toBeNull(); expect(active).toBe(false); expect(versionId).not.toBe(workflow.versionId); expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id); @@ -2824,6 +3025,7 @@ describe('POST /workflows/:workflowId/unarchive', () => { .expect(200); expect(activateResponse.body.data.active).toBe(true); + expect(activateResponse.body.data.activeVersionId).toBeDefined(); }); }); diff --git a/packages/frontend/@n8n/rest-api-client/src/api/workflows.ts b/packages/frontend/@n8n/rest-api-client/src/api/workflows.ts index 44b1d6985dc0e..264f3e9b7f768 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/workflows.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/workflows.ts @@ -20,6 +20,7 @@ export interface WorkflowData { tags?: string[]; pinData?: IPinData; versionId?: string; + activeVersionId?: string | null; meta?: WorkflowMetadata; } diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 2653ced152b8f..1fcb8042fcf6f 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -249,6 +249,7 @@ export interface IWorkflowDb { homeProject?: ProjectSharingData; scopes?: Scope[]; versionId: string; + activeVersionId: string | null; usedCredentials?: IUsedCredential[]; meta?: WorkflowMetadata; parentFolder?: { @@ -276,6 +277,7 @@ export type WorkflowResource = BaseResource & { updatedAt: string; createdAt: string; active: boolean; + activeVersionId: string | null; isArchived: boolean; homeProject?: ProjectSharingData; scopes?: Scope[]; @@ -346,6 +348,7 @@ export interface IWorkflowShortResponse { id: string; name: string; active: boolean; + activeVersionId: string | null; createdAt: number | string; updatedAt: number | string; tags: ITag[]; diff --git a/packages/frontend/editor-ui/src/__tests__/mocks.ts b/packages/frontend/editor-ui/src/__tests__/mocks.ts index a0c7bf47d2433..e841a47670cd0 100644 --- a/packages/frontend/editor-ui/src/__tests__/mocks.ts +++ b/packages/frontend/editor-ui/src/__tests__/mocks.ts @@ -200,7 +200,8 @@ export function createTestWorkflow({ active, isArchived, settings, - versionId: '1', + versionId: 'v1', + activeVersionId: active ? 'v1' : null, meta: {}, pinData, ...rest, diff --git a/packages/frontend/editor-ui/src/__tests__/server/factories/workflow.ts b/packages/frontend/editor-ui/src/__tests__/server/factories/workflow.ts index 555b03e06c752..cc1a60b64853d 100644 --- a/packages/frontend/editor-ui/src/__tests__/server/factories/workflow.ts +++ b/packages/frontend/editor-ui/src/__tests__/server/factories/workflow.ts @@ -12,6 +12,9 @@ export const workflowFactory = Factory.extend({ active() { return faker.datatype.boolean(); }, + activeVersionId(i: number) { + return this.active ? i.toString() : null; + }, isArchived() { return faker.datatype.boolean(); }, diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts index 93d531d4eaf70..14039b7e09b7e 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts @@ -66,6 +66,7 @@ describe('WorkflowDescriptionPopover', () => { id: 'test-workflow-id', name: 'Test Workflow', active: false, + activeVersionId: null, isArchived: false, createdAt: Date.now(), updatedAt: Date.now(), @@ -596,6 +597,7 @@ describe('WorkflowDescriptionPopover', () => { id: 'test-workflow-id', name: 'Test Workflow', active: false, + activeVersionId: null, isArchived: false, createdAt: Date.now(), updatedAt: Date.now(), @@ -642,6 +644,7 @@ describe('WorkflowDescriptionPopover', () => { id: 'test-workflow-id', name: 'Test Workflow', active: false, + activeVersionId: null, isArchived: false, createdAt: Date.now(), updatedAt: Date.now(), diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts index 751af0a0524a8..9db00936c3e34 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts +++ b/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts @@ -61,6 +61,7 @@ const createWorkflow = (overrides = {}): WorkflowResource => ({ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), active: true, + activeVersionId: 'v1', isArchived: false, readOnly: false, ...overrides, diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.test.ts index 4a0cd9a3f56af..f1d41cb3112a8 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.test.ts +++ b/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.test.ts @@ -64,6 +64,7 @@ const mockWorkflow: IWorkflowDb = { id: 'test-workflow-id', name: 'Test Workflow', active: true, + activeVersionId: 'v1', nodes: [], settings: { executionOrder: 'v1', @@ -173,6 +174,7 @@ describe('WorkflowProductionChecklist', () => { workflow: { ...mockWorkflow, active: false, + activeVersionId: null, }, }, pinia: createTestingPinia(), @@ -537,6 +539,7 @@ describe('WorkflowProductionChecklist', () => { workflow: { ...mockWorkflow, active: false, + activeVersionId: null, }, }, pinia: createTestingPinia(), @@ -545,7 +548,6 @@ describe('WorkflowProductionChecklist', () => { await rerender({ workflow: { ...mockWorkflow, - active: true, }, }); @@ -570,6 +572,7 @@ describe('WorkflowProductionChecklist', () => { workflow: { ...mockWorkflow, active: false, + activeVersionId: null, }, }, pinia: createTestingPinia(), @@ -578,7 +581,6 @@ describe('WorkflowProductionChecklist', () => { await rerender({ workflow: { ...mockWorkflow, - active: true, }, }); @@ -608,6 +610,7 @@ describe('WorkflowProductionChecklist', () => { workflow: { ...mockWorkflow, active: false, + activeVersionId: null, }, }, pinia, @@ -616,7 +619,6 @@ describe('WorkflowProductionChecklist', () => { await rerender({ workflow: { ...mockWorkflow, - active: true, }, }); diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.vue b/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.vue index 4b1d90c0f1c2e..c4674d54e63b8 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.vue @@ -85,7 +85,7 @@ const isMcpAvailable = computed(() => { }); const availableActions = computed(() => { - if (!props.workflow.active || workflowsCache.isCacheLoading.value) { + if (props.workflow.activeVersionId === null || workflowsCache.isCacheLoading.value) { return []; } @@ -228,7 +228,7 @@ function handlePopoverOpenChange(open: boolean) { // Watch for workflow activation watch( - () => props.workflow.active, + () => !!props.workflow.activeVersionId, async (isActive, wasActive) => { if (isActive && !wasActive) { // Check if this is the first activation diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts index acc74d5ba146e..70309ff87380b 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts +++ b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts @@ -60,6 +60,7 @@ describe('WorkflowSettingsVue', () => { id: '1', name: 'Test Workflow', active: true, + activeVersionId: 'v1', isArchived: false, nodes: [], connections: {}, @@ -72,6 +73,7 @@ describe('WorkflowSettingsVue', () => { id: '1', name: 'Test Workflow', active: true, + activeVersionId: 'v1', isArchived: false, nodes: [], connections: {}, @@ -279,6 +281,7 @@ describe('WorkflowSettingsVue', () => { id: '1', name: 'Test Workflow', active: true, + activeVersionId: 'v1', isArchived: false, nodes: [], connections: {}, diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowShareModal.ee.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowShareModal.ee.test.ts index 50476addab484..2c5c1a8142be1 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowShareModal.ee.test.ts +++ b/packages/frontend/editor-ui/src/app/components/WorkflowShareModal.ee.test.ts @@ -116,6 +116,7 @@ describe('WorkflowShareModal.ee.vue', () => { id: PLACEHOLDER_EMPTY_WORKFLOW_ID, name: 'My workflow', active: false, + activeVersionId: null, isArchived: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), diff --git a/packages/frontend/editor-ui/src/app/components/layouts/ResourcesListLayout.test.ts b/packages/frontend/editor-ui/src/app/components/layouts/ResourcesListLayout.test.ts index fa6b35eeb8fad..c84a55f785a75 100644 --- a/packages/frontend/editor-ui/src/app/components/layouts/ResourcesListLayout.test.ts +++ b/packages/frontend/editor-ui/src/app/components/layouts/ResourcesListLayout.test.ts @@ -32,6 +32,7 @@ const TEST_WORKFLOWS: Resource[] = vi.hoisted(() => [ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), active: true, + activeVersionId: 'v1', isArchived: false, readOnly: false, homeProject: TEST_HOME_PROJECT, @@ -43,6 +44,7 @@ const TEST_WORKFLOWS: Resource[] = vi.hoisted(() => [ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), active: true, + activeVersionId: 'v1', isArchived: false, readOnly: false, homeProject: TEST_HOME_PROJECT, diff --git a/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/workflowFailedToActivate.ts b/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/workflowFailedToActivate.ts index 58de3b12f9258..7e7f1e20b7b47 100644 --- a/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/workflowFailedToActivate.ts +++ b/packages/frontend/editor-ui/src/app/composables/usePushConnection/handlers/workflowFailedToActivate.ts @@ -15,7 +15,7 @@ export async function workflowFailedToActivate( } workflowsStore.setWorkflowInactive(data.workflowId); - options.workflowState.setActive(false); + options.workflowState.setActive(null); const toast = useToast(); const i18n = useI18n(); diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowHelpers.test.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowHelpers.test.ts index 4a6e8aadf5a0b..2e9a85f98b773 100644 --- a/packages/frontend/editor-ui/src/app/composables/useWorkflowHelpers.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowHelpers.test.ts @@ -247,7 +247,7 @@ describe('useWorkflowHelpers', () => { initState(workflowData); expect(addWorkflowSpy).toHaveBeenCalledWith(workflowData); - expect(setActiveSpy).toHaveBeenCalledWith(true); + expect(setActiveSpy).toHaveBeenCalledWith('v1'); expect(setWorkflowIdSpy).toHaveBeenCalledWith('1'); expect(setWorkflowNameSpy).toHaveBeenCalledWith({ newName: 'Test Workflow', @@ -258,7 +258,7 @@ describe('useWorkflowHelpers', () => { timezone: 'DEFAULT', }); expect(setWorkflowPinDataSpy).toHaveBeenCalledWith({}); - expect(setWorkflowVersionIdSpy).toHaveBeenCalledWith('1'); + expect(setWorkflowVersionIdSpy).toHaveBeenCalledWith('v1'); expect(setWorkflowMetadataSpy).toHaveBeenCalledWith({}); expect(setWorkflowScopesSpy).toHaveBeenCalledWith(['workflow:create']); expect(setUsedCredentialsSpy).toHaveBeenCalledWith([]); diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowHelpers.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowHelpers.ts index 446157d58a0e7..db845cbe98bd0 100644 --- a/packages/frontend/editor-ui/src/app/composables/useWorkflowHelpers.ts +++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowHelpers.ts @@ -850,11 +850,11 @@ export function useWorkflowHelpers() { workflowsStore.setWorkflowVersionId(workflow.versionId); if (isCurrentWorkflow) { - workflowState.setActive(!!workflow.active); + workflowState.setActive(workflow.activeVersionId); uiStore.stateIsDirty = false; } - if (workflow.active) { + if (workflow.activeVersionId !== null) { workflowsStore.setWorkflowActive(workflowId); } else { workflowsStore.setWorkflowInactive(workflowId); @@ -940,7 +940,7 @@ export function useWorkflowHelpers() { function initState(workflowData: IWorkflowDb) { workflowsStore.addWorkflow(workflowData); - workflowState.setActive(workflowData.active || false); + workflowState.setActive(workflowData.activeVersionId); workflowsStore.setIsArchived(workflowData.isArchived); workflowsStore.setDescription(workflowData.description); workflowState.setWorkflowId(workflowData.id); diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.ts index 1777d14e67263..cccbd425e917f 100644 --- a/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.ts +++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.ts @@ -382,10 +382,11 @@ export function useWorkflowSaving({ } // workflow should not be active if there is live webhook with the same path - if (workflowData.active) { + if (workflowData.activeVersionId !== null) { const conflict = await checkConflictingWebhooks(workflowData.id); if (conflict) { workflowData.active = false; + workflowData.activeVersionId = null; toast.showMessage({ title: 'Conflicting Webhook Path', @@ -395,7 +396,7 @@ export function useWorkflowSaving({ } } - workflowState.setActive(workflowData.active || false); + workflowState.setActive(workflowData.activeVersionId); workflowState.setWorkflowId(workflowData.id); workflowsStore.setWorkflowVersionId(workflowData.versionId); workflowState.setWorkflowName({ newName: workflowData.name, setStateDirty: false }); diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowState.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowState.ts index aa1e53a9b1ec1..fe30b66bdf204 100644 --- a/packages/frontend/editor-ui/src/app/composables/useWorkflowState.ts +++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowState.ts @@ -111,8 +111,9 @@ export function useWorkflowState() { return true; } - function setActive(active: boolean) { - ws.workflow.active = active; + function setActive(activeVersionId: string | null) { + ws.workflow.active = activeVersionId !== null; + ws.workflow.activeVersionId = activeVersionId; } function setWorkflowId(id?: string) { @@ -223,7 +224,7 @@ export function useWorkflowState() { setWorkflowExecutionData(null); resetAllNodesIssues(); - setActive(ws.defaults.active); + setActive(ws.defaults.activeVersionId); setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID); setWorkflowName({ newName: '', setStateDirty: false }); setWorkflowSettings({ ...ws.defaults.settings }); diff --git a/packages/frontend/editor-ui/src/app/stores/workflows.store.test.ts b/packages/frontend/editor-ui/src/app/stores/workflows.store.test.ts index 3d5675d381d62..e49d35c49f6d7 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflows.store.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflows.store.test.ts @@ -2264,6 +2264,7 @@ function generateMockExecutionEvents() { nodes: [], connections: {}, active: false, + activeVersionId: null, isArchived: false, versionId: '1', }, diff --git a/packages/frontend/editor-ui/src/app/stores/workflows.store.ts b/packages/frontend/editor-ui/src/app/stores/workflows.store.ts index 307017c84f484..c7df47a670590 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflows.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflows.store.ts @@ -98,6 +98,7 @@ const defaults: Omit & { settings: NonNullable { const isNewWorkflow = computed(() => workflow.value.id === PLACEHOLDER_EMPTY_WORKFLOW_ID); - const isWorkflowActive = computed(() => workflow.value.active); + const isWorkflowActive = computed(() => workflow.value.activeVersionId !== null); const workflowTriggerNodes = computed(() => workflow.value.nodes.filter((node: INodeUi) => { @@ -878,12 +879,15 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { if (index === -1) { activeWorkflows.value.push(targetWorkflowId); } - if (workflowsById.value[targetWorkflowId]) { - workflowsById.value[targetWorkflowId].active = true; + const targetWorkflow = workflowsById.value[targetWorkflowId]; + if (targetWorkflow) { + targetWorkflow.active = true; + targetWorkflow.activeVersionId = targetWorkflow.versionId; } if (targetWorkflowId === workflow.value.id) { uiStore.stateIsDirty = false; workflow.value.active = true; + workflow.value.activeVersionId = workflow.value.versionId; } } @@ -892,8 +896,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { if (index !== -1) { activeWorkflows.value.splice(index, 1); } - if (workflowsById.value[targetWorkflowId]) { - workflowsById.value[targetWorkflowId].active = false; + const targetWorkflow = workflowsById.value[targetWorkflowId]; + if (targetWorkflow) { + targetWorkflow.active = false; + targetWorkflow.activeVersionId = null; } if (targetWorkflowId === workflow.value.id) { workflow.value.active = false; diff --git a/packages/frontend/editor-ui/src/app/utils/workflowUtils.test.ts b/packages/frontend/editor-ui/src/app/utils/workflowUtils.test.ts index 387fc47ef1c83..34276d9934326 100644 --- a/packages/frontend/editor-ui/src/app/utils/workflowUtils.test.ts +++ b/packages/frontend/editor-ui/src/app/utils/workflowUtils.test.ts @@ -13,6 +13,7 @@ describe('workflowUtils', () => { id: 'test-workflow', name: 'Test Workflow', active: false, + activeVersionId: null, isArchived: false, createdAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z', @@ -83,6 +84,7 @@ describe('workflowUtils', () => { id: 'test-workflow', name: 'Test Workflow', active: false, + activeVersionId: null, isArchived: false, createdAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z', diff --git a/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts b/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts index 4f29c18436b6d..82aca07180d57 100644 --- a/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts +++ b/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts @@ -458,6 +458,7 @@ describe('Folders', () => { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), active: true, + activeVersionId: 'v1', isArchived: false, versionId: '1', homeProject: { diff --git a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue index 1cb3a1f5f0cf0..2b0a974791ad6 100644 --- a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue +++ b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue @@ -354,6 +354,7 @@ const workflowListResources = computed(() => { name: resource.name, description: resource.description, active: resource.active ?? false, + activeVersionId: resource.activeVersionId, isArchived: resource.isArchived, updatedAt: resource.updatedAt.toString(), createdAt: resource.createdAt.toString(), @@ -1074,6 +1075,7 @@ const onWorkflowActiveToggle = async (data: { id: string; active: boolean }) => ); if (!workflow) return; workflow.active = data.active; + workflow.activeVersionId = data.active ? workflow.versionId : null; // Fetch the updated workflow to get the latest settings try { diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/composables/useAIAssistantHelpers.test.ts b/packages/frontend/editor-ui/src/features/ai/assistant/composables/useAIAssistantHelpers.test.ts index 617f132fe99fc..1184dbcf2b5a9 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/composables/useAIAssistantHelpers.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/composables/useAIAssistantHelpers.test.ts @@ -417,6 +417,7 @@ const testWorkflow: IWorkflowDb = { id: 'MokOcBHON6KkPq6Y', name: 'My Sub-Workflow 3', active: false, + activeVersionId: null, isArchived: false, createdAt: -1, updatedAt: -1, diff --git a/packages/frontend/editor-ui/src/features/ai/evaluation.ee/views/EvaluationsRootView.test.ts b/packages/frontend/editor-ui/src/features/ai/evaluation.ee/views/EvaluationsRootView.test.ts index 528fe5dd06070..2a262029e1b6c 100644 --- a/packages/frontend/editor-ui/src/features/ai/evaluation.ee/views/EvaluationsRootView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/evaluation.ee/views/EvaluationsRootView.test.ts @@ -50,6 +50,7 @@ describe('EvaluationsRootView', () => { id: 'different-id', name: 'Test Workflow', active: false, + activeVersionId: null, isArchived: false, createdAt: Date.now(), updatedAt: Date.now(), diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.constants.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.constants.ts index 42bbb66028e5a..346e44c7c8b7c 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.constants.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.constants.ts @@ -8,6 +8,7 @@ export const MCP_WORKFLOWS: WorkflowListItem[] = [ updatedAt: '2025-09-23T08:13:45.000Z', name: 'MCP Test 1', active: true, + activeVersionId: 'v1', isArchived: false, settings: { availableInMCP: true, @@ -35,6 +36,7 @@ export const MCP_WORKFLOWS: WorkflowListItem[] = [ updatedAt: '2025-09-23T08:13:45.000Z', name: 'MCP Test 2', active: false, + activeVersionId: null, isArchived: false, settings: { availableInMCP: true, diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.test.ts index 4f5ae334ec1b8..24d77fa22b357 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/WorkflowsTable.test.ts @@ -24,6 +24,7 @@ const mockWorkflow = (id: string, overrides?: Partial): Workfl id, name: `Workflow ${id}`, active: true, + activeVersionId: 'v1', isArchived: false, createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-02T00:00:00.000Z', diff --git a/packages/frontend/editor-ui/src/features/execution/executions/components/ExecutionsFilter.test.ts b/packages/frontend/editor-ui/src/features/execution/executions/components/ExecutionsFilter.test.ts index 4270a800433c6..37a31f1037d41 100644 --- a/packages/frontend/editor-ui/src/features/execution/executions/components/ExecutionsFilter.test.ts +++ b/packages/frontend/editor-ui/src/features/execution/executions/components/ExecutionsFilter.test.ts @@ -56,7 +56,8 @@ const workflowDataFactory = (): IWorkflowShortResponse => ({ updatedAt: faker.date.past().toDateString(), id: faker.string.uuid(), name: faker.string.sample(), - active: faker.datatype.boolean(), + active: false, + activeVersionId: null, tags: [], }); diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue index a6ccf32384521..02409b98ffa1d 100644 --- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue +++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue @@ -654,6 +654,7 @@ function castProject(project: ProjectListItem): WorkflowResource { id: '', name: '', active: false, + activeVersionId: null, createdAt: '', updatedAt: '', isArchived: false, diff --git a/packages/frontend/editor-ui/src/features/ndv/panel/components/InputPanel.test.ts b/packages/frontend/editor-ui/src/features/ndv/panel/components/InputPanel.test.ts index 732370e5e346a..40213912bb104 100644 --- a/packages/frontend/editor-ui/src/features/ndv/panel/components/InputPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/ndv/panel/components/InputPanel.test.ts @@ -70,6 +70,7 @@ const render = (props: Partial = {}, pinData?: INodeExecutionData[], runD id: '', name: '', active: false, + activeVersionId: null, isArchived: false, createdAt: '', updatedAt: '', diff --git a/packages/frontend/editor-ui/src/features/settings/communityNodes/components/NodesInWorkflowTable.test.ts b/packages/frontend/editor-ui/src/features/settings/communityNodes/components/NodesInWorkflowTable.test.ts index 26447ef616067..93370ffb1e7ee 100644 --- a/packages/frontend/editor-ui/src/features/settings/communityNodes/components/NodesInWorkflowTable.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/communityNodes/components/NodesInWorkflowTable.test.ts @@ -11,6 +11,7 @@ const mockWorkflows: WorkflowResource[] = [ name: 'Test Workflow 1', resourceType: 'workflow', active: true, + activeVersionId: 'version-1', createdAt: '2023-01-01T00:00:00.000Z', updatedAt: '2023-01-01T00:00:00.000Z', homeProject: { @@ -31,6 +32,7 @@ const mockWorkflows: WorkflowResource[] = [ name: 'Test Workflow 2', resourceType: 'workflow', active: false, + activeVersionId: null, createdAt: '2023-01-01T00:00:00.000Z', updatedAt: '2023-01-01T00:00:00.000Z', homeProject: { diff --git a/packages/frontend/editor-ui/src/features/shared/commandBar/composables/useWorkflowCommands.test.ts b/packages/frontend/editor-ui/src/features/shared/commandBar/composables/useWorkflowCommands.test.ts index fedda5e90425d..fd50f1ab49722 100644 --- a/packages/frontend/editor-ui/src/features/shared/commandBar/composables/useWorkflowCommands.test.ts +++ b/packages/frontend/editor-ui/src/features/shared/commandBar/composables/useWorkflowCommands.test.ts @@ -310,6 +310,7 @@ describe('useWorkflowCommands', () => { it('should show deactivate command when workflow is active', () => { mockWorkflowsStore.workflow.active = true; + mockWorkflowsStore.workflow.activeVersionId = '1'; const { commands } = useWorkflowCommands(); const activateCommand = commands.value.find((cmd) => cmd.id === 'activate-workflow'); diff --git a/packages/frontend/editor-ui/src/features/workflows/templates/components/SetupWorkflowCredentialsButton.test.ts b/packages/frontend/editor-ui/src/features/workflows/templates/components/SetupWorkflowCredentialsButton.test.ts index 1f97a016a58df..dbcd6864df63f 100644 --- a/packages/frontend/editor-ui/src/features/workflows/templates/components/SetupWorkflowCredentialsButton.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/templates/components/SetupWorkflowCredentialsButton.test.ts @@ -31,6 +31,7 @@ const EMPTY_WORKFLOW = { versionId: '1', name: 'Email Summary Agent ', active: false, + activeVersionId: null, isArchived: false, connections: {}, nodes: [], diff --git a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/useWorkflowDiff.test.ts b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/useWorkflowDiff.test.ts index e3e54bd333bf4..180e47e304b60 100644 --- a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/useWorkflowDiff.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/useWorkflowDiff.test.ts @@ -156,6 +156,7 @@ describe('useWorkflowDiff', () => { nodes, connections, active: false, + activeVersionId: null, createdAt: '2023-01-01T00:00:00.000Z', updatedAt: '2023-01-01T00:00:00.000Z', tags: [], diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 4d5cc3f450403..b0e2213bba9d6 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -2557,6 +2557,7 @@ export interface IWorkflowBase { staticData?: IDataObject; pinData?: IPinData; versionId?: string; + activeVersionId: string | null; versionCounter?: number; meta?: WorkflowFEMeta; } diff --git a/packages/workflow/test/telemetry-helpers.test.ts b/packages/workflow/test/telemetry-helpers.test.ts index 306b08b8a2163..d6e51ad271500 100644 --- a/packages/workflow/test/telemetry-helpers.test.ts +++ b/packages/workflow/test/telemetry-helpers.test.ts @@ -104,6 +104,7 @@ describe('generateNodesGraph', () => { id: 'NfV4GV9aQTifSLc2', name: 'My workflow 26', active: false, + activeVersionId: null, isArchived: false, nodes: [ { @@ -169,6 +170,7 @@ describe('generateNodesGraph', () => { id: 'NfV4GV9aQTifSLc2', name: 'My workflow 26', active: false, + activeVersionId: null, isArchived: false, nodes: [], connections: {}, @@ -213,6 +215,7 @@ describe('generateNodesGraph', () => { id: 'NfV4GV9aQTifSLc2', name: 'My workflow 26', active: false, + activeVersionId: null, isArchived: false, nodes: [ { @@ -280,6 +283,7 @@ describe('generateNodesGraph', () => { id: 'NfV4GV9aQTifSLc2', name: 'My workflow 26', active: false, + activeVersionId: null, isArchived: false, nodes: [ { @@ -736,6 +740,7 @@ describe('generateNodesGraph', () => { id: 'NfV4GV9aQTifSLc2', name: 'My workflow 26', active: false, + activeVersionId: null, isArchived: false, nodes: [ { @@ -2821,6 +2826,7 @@ describe('extractLastExecutedNodeStructuredOutputErrorInfo', () => { id: 'test-workflow', name: 'Test Workflow', active: false, + activeVersionId: null, isArchived: false, nodes, connections: connections || {}, diff --git a/packages/workflow/test/workflow-data-proxy.test.ts b/packages/workflow/test/workflow-data-proxy.test.ts index daf4525fd00ca..de43d4e58c1ff 100644 --- a/packages/workflow/test/workflow-data-proxy.test.ts +++ b/packages/workflow/test/workflow-data-proxy.test.ts @@ -418,7 +418,7 @@ describe('WorkflowDataProxy', () => { test.each([{ methodName: 'itemMatching' }, { methodName: 'pairedItem' }])( '$methodName should throw when it cannot find a paired item', - async ({ methodName }) => { + ({ methodName }) => { try { proxy.$('DebugHelper')[methodName](0); throw new Error('should throw'); @@ -444,7 +444,7 @@ describe('WorkflowDataProxy', () => { }, ); - test('item should throw when it cannot find a paired item', async () => { + test('item should throw when it cannot find a paired item', () => { try { proxy.$('DebugHelper').item; throw new Error('should throw'); @@ -560,6 +560,7 @@ describe('WorkflowDataProxy', () => { ], connections: {}, active: false, + activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), @@ -622,6 +623,7 @@ describe('WorkflowDataProxy', () => { ], connections: {}, active: false, + activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), @@ -1013,6 +1015,7 @@ describe('WorkflowDataProxy', () => { }, }, active: false, + activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), @@ -1079,6 +1082,7 @@ describe('WorkflowDataProxy', () => { }, }, active: false, + activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), @@ -1140,6 +1144,7 @@ describe('WorkflowDataProxy', () => { }, }, active: false, + activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), @@ -1214,6 +1219,7 @@ describe('WorkflowDataProxy', () => { ], connections: {}, active: false, + activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(),