This commit is contained in:
Daria 2025-11-20 16:03:26 +00:00 committed by GitHub
commit bad6e4603c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 557 additions and 53 deletions

View File

@ -22,4 +22,8 @@ export class WorkflowsConfig {
/** Whether to enable workflow dependency indexing */
@Env('N8N_WORKFLOWS_INDEXING_ENABLED')
indexingEnabled: boolean = false;
/** DO NOT USE - Enable draft/publish workflow feature */
@Env('N8N_WORKFLOWS_DRAFT_PUBLISH_ENABLED')
draftPublishEnabled: boolean = false;
}

View File

@ -410,3 +410,8 @@ export interface ISimplifiedPinData {
pairedItem?: IPairedItemData | IPairedItemData[] | number;
}>;
}
export type WorkflowHistoryUpdate = Omit<
Partial<WorkflowHistory>,
'versionId' | 'workflowId' | 'createdAt' | 'updatedAt'
>;

View File

@ -22,6 +22,12 @@ export class WorkflowHistory extends WithTimestamps {
@Column()
authors: string;
@Column({ nullable: true })
name: string;
@Column({ nullable: true })
description: string;
@ManyToOne('WorkflowEntity', {
onDelete: 'CASCADE',
})

View File

@ -1,5 +1,5 @@
import { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import type { User, WorkflowHistoryUpdate } 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
@ -7,11 +7,11 @@ import type { EntityManager } from '@n8n/typeorm';
import type { IWorkflowBase } from 'n8n-workflow';
import { ensureError, UnexpectedError } from 'n8n-workflow';
import { WorkflowFinderService } from '../workflow-finder.service';
import { SharedWorkflowNotFoundError } from '@/errors/shared-workflow-not-found.error';
import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error';
import { WorkflowFinderService } from '../workflow-finder.service';
@Service()
export class WorkflowHistoryService {
constructor(
@ -97,4 +97,8 @@ export class WorkflowHistoryService {
});
}
}
async updateVersion(versionId: string, workflowId: string, updateData: WorkflowHistoryUpdate) {
await this.workflowHistoryRepository.update({ versionId, workflowId }, { ...updateData });
}
}

View File

@ -72,4 +72,12 @@ export declare namespace WorkflowRequest {
type ManualRun = AuthenticatedRequest<{ workflowId: string }, {}, ManualRunPayload, {}>;
type Share = AuthenticatedRequest<{ workflowId: string }, {}, { shareWithIds: string[] }>;
type Activate = AuthenticatedRequest<
{ workflowId: string },
{},
{ versionId: string; name?: string; description?: string }
>;
type Deactivate = AuthenticatedRequest<{ workflowId: string }>;
}

View File

@ -1,6 +1,12 @@
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import type { User, WorkflowEntity, ListQueryDb, WorkflowFolderUnionFull } from '@n8n/db';
import type {
User,
WorkflowEntity,
ListQueryDb,
WorkflowFolderUnionFull,
WorkflowHistoryUpdate,
} from '@n8n/db';
import {
SharedWorkflow,
ExecutionRepository,
@ -233,6 +239,8 @@ export class WorkflowService {
);
}
const isDraftPublishDisabled = !this.globalConfig.workflows.draftPublishEnabled;
if (
Object.keys(omit(workflowUpdateData, ['id', 'versionId', 'active', 'activeVersionId']))
.length > 0
@ -253,7 +261,8 @@ export class WorkflowService {
}
// Convert 'active' boolean from frontend to 'activeVersionId' for backend
if ('active' in workflowUpdateData) {
// Forbid updating active fields with FF on
if (isDraftPublishDisabled && 'active' in workflowUpdateData) {
if (workflowUpdateData.active) {
workflowUpdateData.activeVersionId = workflowUpdateData.versionId ?? workflow.versionId;
} else {
@ -288,7 +297,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 (wasActive) {
if (isDraftPublishDisabled && wasActive) {
await this.activeWorkflowManager.remove(workflowId);
}
@ -318,7 +327,7 @@ export class WorkflowService {
await validateEntity(workflowUpdateData);
}
const updatePayload: QueryDeepPartialEntity<WorkflowEntity> = pick(workflowUpdateData, [
const fieldsToUpdate = [
'name',
'active',
'nodes',
@ -328,16 +337,25 @@ export class WorkflowService {
'staticData',
'pinData',
'versionId',
'activeVersionId',
'description',
]);
];
// Forbid updating active fields with FF on
if (isDraftPublishDisabled) {
fieldsToUpdate.push('activeVersionId');
}
const updatePayload: QueryDeepPartialEntity<WorkflowEntity> = pick(
workflowUpdateData,
fieldsToUpdate,
);
// 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) {
if (isDraftPublishDisabled && needsActiveVersionUpdate) {
const versionIdToFetch = versionChanged ? workflowUpdateData.versionId : workflow.versionId;
const version = await this.workflowHistoryService.getVersion(
user,
@ -403,63 +421,209 @@ export class WorkflowService {
publicApi: false,
});
if (activationStatusChanged && isNowActive) {
// Workflow is being activated
this.eventService.emit('workflow-activated', {
user,
workflowId,
workflow: updatedWorkflow,
publicApi: false,
});
} else if (activationStatusChanged && !isNowActive) {
// Workflow is being deactivated
this.eventService.emit('workflow-deactivated', {
user,
workflowId,
workflow: updatedWorkflow,
publicApi: false,
});
}
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, wasActive ? 'update' : 'activate');
} catch (error) {
// If workflow could not be activated set it again to inactive
// and revert the versionId and activeVersionId change so UI remains consistent
await this.workflowRepository.update(workflowId, {
active: false,
activeVersion: null,
versionId: workflow.versionId,
// Skip activation/deactivation logic if draft/publish feature flag is enabled
if (isDraftPublishDisabled) {
if (activationStatusChanged && isNowActive) {
// Workflow is being activated
this.eventService.emit('workflow-activated', {
user,
workflowId,
workflow: updatedWorkflow,
publicApi: false,
});
// Also set it in the returned data
updatedWorkflow.active = false;
updatedWorkflow.activeVersionId = null;
updatedWorkflow.activeVersion = null;
// Emit deactivation event since activation failed
} else if (activationStatusChanged && !isNowActive) {
// Workflow is being deactivated
this.eventService.emit('workflow-deactivated', {
user,
workflowId,
workflow: updatedWorkflow,
publicApi: false,
});
}
let message;
if (error instanceof NodeApiError) message = error.description;
message = message ?? (error as Error).message;
// Now return the original error for UI to display
throw new BadRequestError(message);
if (isNowActive) {
// When the workflow is supposed to be active add it again
await this._addToActiveWorkflowManager(
user,
workflowId,
updatedWorkflow,
wasActive ? 'update' : 'activate',
workflow.versionId,
);
}
}
return updatedWorkflow;
}
/**
* Private helper to add a workflow to the active workflow manager
* @param originalVersionId - Optional versionId to roll back to if activation fails
*/
private async _addToActiveWorkflowManager(
user: User,
workflowId: string,
workflow: WorkflowEntity,
mode: 'activate' | 'update',
originalVersionId?: string,
): Promise<void> {
try {
await this.externalHooks.run('workflow.activate', [workflow]);
await this.activeWorkflowManager.add(workflowId, mode);
} catch (error) {
// If workflow could not be activated, set it again to inactive
// and revert the versionId and activeVersionId change so UI remains consistent
const rollbackPayload: QueryDeepPartialEntity<WorkflowEntity> = {
active: false,
activeVersion: null,
};
// Roll back versionId if provided (used in update flow)
if (originalVersionId !== undefined) {
rollbackPayload.versionId = originalVersionId;
}
await this.workflowRepository.update(workflowId, rollbackPayload);
// Also set it in the returned data
workflow.active = false;
workflow.activeVersionId = null;
workflow.activeVersion = null;
// Emit deactivation event since activation failed
this.eventService.emit('workflow-deactivated', {
user,
workflowId,
workflow,
publicApi: false,
});
let message;
if (error instanceof NodeApiError) message = error.description;
message = message ?? (error as Error).message;
// Now return the original error for UI to display
throw new BadRequestError(message);
}
}
/**
* Activates a workflow by setting its activeVersionId and adding it to the active workflow manager.
* @param user - The user activating the workflow
* @param workflowId - The ID of the workflow to activate
* @param versionId - The version ID to activate
* @returns The activated workflow
*/
async activateWorkflow(
user: User,
workflowId: string,
versionId: string,
options?: { name?: string; description?: string },
): Promise<WorkflowEntity> {
const workflow = await this.workflowFinderService.findWorkflowForUser(
workflowId,
user,
['workflow:update'],
{ includeActiveVersion: true },
);
if (!workflow) {
this.logger.warn('User attempted to activate a workflow without permissions', {
workflowId,
userId: user.id,
});
throw new NotFoundError(
'You do not have permission to activate this workflow. Ask the owner to share it with you.',
);
}
if (workflow.activeVersionId === versionId) {
return workflow;
}
await this.workflowRepository.update(workflowId, {
activeVersionId: versionId,
active: true,
});
if (options) {
const updateFields: WorkflowHistoryUpdate = {};
if (options.name !== undefined) updateFields.name = options.name;
if (options.description !== undefined) updateFields.description = options.description;
await this.workflowHistoryService.updateVersion(versionId, workflowId, updateFields);
}
const updatedWorkflow = await this.workflowRepository.findOne({
where: { id: workflowId },
});
if (!updatedWorkflow) {
throw new NotFoundError(`Workflow with ID "${workflowId}" could not be found.`);
}
this.eventService.emit('workflow-activated', {
user,
workflowId,
workflow: updatedWorkflow,
publicApi: false,
});
await this._addToActiveWorkflowManager(user, workflowId, updatedWorkflow, 'activate');
return updatedWorkflow;
}
/**
* Deactivates a workflow by removing it from the active workflow manager and setting activeVersionId to null.
* @param user - The user deactivating the workflow
* @param workflowId - The ID of the workflow to deactivate
* @returns The deactivated workflow
*/
async deactivateWorkflow(user: User, workflowId: string): Promise<WorkflowEntity> {
const workflow = await this.workflowFinderService.findWorkflowForUser(
workflowId,
user,
['workflow:update'],
{ includeActiveVersion: true },
);
if (!workflow) {
this.logger.warn('User attempted to deactivate a workflow without permissions', {
workflowId,
userId: user.id,
});
throw new NotFoundError(
'You do not have permission to deactivate this workflow. Ask the owner to share it with you.',
);
}
if (workflow.activeVersionId === null) {
return workflow;
}
// Remove from active workflow manager
await this.activeWorkflowManager.remove(workflowId);
await this.workflowRepository.update(workflowId, {
active: false,
activeVersionId: null,
});
// Update the workflow object for response
workflow.active = false;
workflow.activeVersionId = null;
workflow.activeVersion = null;
this.eventService.emit('workflow-deactivated', {
user,
workflowId,
workflow,
publicApi: false,
});
return workflow;
}
/**
* Deletes a workflow and returns it.
*

View File

@ -479,6 +479,44 @@ export class WorkflowsController {
return workflow;
}
@Post('/:workflowId/activate')
@ProjectScope('workflow:update')
async activate(req: WorkflowRequest.Activate) {
const { workflowId } = req.params;
const { versionId, name, description } = req.body;
if (!versionId) {
throw new BadRequestError('versionId is required');
}
const options: { name?: string; description?: string } = {};
if (name !== undefined) options.name = name;
if (description !== undefined) options.description = description;
const workflow = await this.workflowService.activateWorkflow(
req.user,
workflowId,
versionId,
Object.keys(options).length > 0 ? options : undefined,
);
const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId);
return { ...workflow, scopes };
}
@Post('/:workflowId/deactivate')
@ProjectScope('workflow:update')
async deactivate(req: WorkflowRequest.Deactivate) {
const { workflowId } = req.params;
const workflow = await this.workflowService.deactivateWorkflow(req.user, workflowId);
const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId);
return { ...workflow, scopes };
}
@Post('/:workflowId/run')
@ProjectScope('workflow:execute')
async runManually(req: WorkflowRequest.ManualRun, _res: unknown) {

View File

@ -13,6 +13,7 @@ import {
testDb,
mockInstance,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { User, ListQueryDb, WorkflowFolderUnionFull, Role } from '@n8n/db';
import {
ProjectRepository,
@ -60,6 +61,7 @@ const { objectContaining, arrayContaining, any } = expect;
const activeWorkflowManagerLike = mockInstance(ActiveWorkflowManager);
let projectRepository: ProjectRepository;
let globalConfig: GlobalConfig;
let folderListMissingRole: Role;
beforeEach(async () => {
@ -75,6 +77,7 @@ beforeEach(async () => {
]);
await cleanupRolesAndScopes();
projectRepository = Container.get(ProjectRepository);
globalConfig = Container.get(GlobalConfig);
owner = await createOwner();
authOwnerAgent = testServer.authAgentFor(owner);
member = await createMember();
@ -86,6 +89,9 @@ beforeEach(async () => {
displayName: 'Workflow Read-Only',
description: 'Can only read and list workflows',
});
// Default: draft/publish feature disabled
globalConfig.workflows.draftPublishEnabled = false;
});
afterEach(() => {
@ -2716,6 +2722,275 @@ describe('PATCH /workflows/:workflowId', () => {
expect(response.statusCode).toBe(500);
});
describe('with draft/publish feature enabled', () => {
beforeEach(() => {
globalConfig.workflows.draftPublishEnabled = true;
});
afterEach(() => {
globalConfig.workflows.draftPublishEnabled = false;
});
test('should not update activeVersionId when updating with active: true', async () => {
const workflow = await createWorkflow({}, 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).not.toBeCalled();
const { data } = response.body;
expect(data.activeVersionId).toBeNull();
});
test('should not deactivate workflow when updating with active: false', 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).not.toBeCalled();
const { data } = response.body;
expect(data.activeVersionId).toBe(workflow.versionId);
});
test('should not modify activeVersionId when explicitly provided', async () => {
const workflow = await createWorkflow({}, owner);
const payload = {
versionId: workflow.versionId,
activeVersionId: workflow.versionId,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).not.toBeCalled();
const { data } = response.body;
expect(data.activeVersionId).toBeNull(); // Should not be activated
});
test('should allow updating active workflow without updating its active version', async () => {
const workflow = await createActiveWorkflow({}, owner);
await setActiveVersion(workflow.id, workflow.versionId);
const payload = {
name: 'Updated Active Workflow',
versionId: workflow.versionId,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.remove).not.toBeCalled();
expect(activeWorkflowManagerLike.add).not.toBeCalled();
const { data } = response.body;
expect(data.name).toBe('Updated Active Workflow');
expect(data.versionId).not.toBe(workflow.versionId); // New version created
expect(data.activeVersionId).toBe(workflow.versionId); // Should remain active
});
});
});
describe('POST /workflows/:workflowId/activate', () => {
test('should activate workflow with provided versionId', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: workflow.versionId });
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
const { data } = response.body;
expect(data.id).toBe(workflow.id);
expect(data.activeVersionId).toBe(workflow.versionId);
});
test('should return 400 when versionId is missing', async () => {
const workflow = await createWorkflow({}, owner);
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({});
expect(response.statusCode).toBe(400);
expect(response.body.message).toContain('versionId is required');
});
test('should return 404 when workflow does not exist', async () => {
const response = await authOwnerAgent
.post('/workflows/non-existent-id/activate')
.send({ versionId: uuid() });
expect(response.statusCode).toBe(404);
});
test('should return 403 when user does not have update permission', async () => {
const workflow = await createWorkflow({}, owner);
const response = await authMemberAgent
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: workflow.versionId });
expect(response.statusCode).toBe(403);
});
test('should set activeVersion relation when activating', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
await authOwnerAgent
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: workflow.versionId });
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);
});
test('should update version name when provided during activation', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const newVersionName = 'Production Version';
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: workflow.versionId, name: newVersionName });
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
const { data } = response.body;
expect(data.activeVersionId).toBe(workflow.versionId);
const workflowHistoryRepository = Container.get(WorkflowHistoryRepository);
const historyVersion = await workflowHistoryRepository.findOne({
where: { workflowId: workflow.id, versionId: workflow.versionId },
});
expect(historyVersion?.name).toBe(newVersionName);
});
test('should update version description when provided during activation', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const newDescription = 'This is the stable production release';
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: workflow.versionId, description: newDescription });
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
const { data } = response.body;
expect(data.activeVersionId).toBe(workflow.versionId);
const workflowHistoryRepository = Container.get(WorkflowHistoryRepository);
const historyVersion = await workflowHistoryRepository.findOne({
where: { workflowId: workflow.id, versionId: workflow.versionId },
});
expect(historyVersion?.description).toBe(newDescription);
});
test('should update both version name and description when provided during activation', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const newVersionName = 'Production Version';
const newDescription = 'Major update with new features';
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({
versionId: workflow.versionId,
name: newVersionName,
description: newDescription,
});
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
const { data } = response.body;
expect(data.activeVersionId).toBe(workflow.versionId);
const workflowHistoryRepository = Container.get(WorkflowHistoryRepository);
const historyVersion = await workflowHistoryRepository.findOne({
where: { workflowId: workflow.id, versionId: workflow.versionId },
});
expect(historyVersion?.name).toBe(newVersionName);
expect(historyVersion?.description).toBe(newDescription);
});
});
describe('POST /workflows/:workflowId/deactivate', () => {
test('should deactivate active workflow', async () => {
const workflow = await createActiveWorkflow({}, owner);
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/deactivate`);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id);
const { data } = response.body;
expect(data.id).toBe(workflow.id);
expect(data.active).toBe(false);
expect(data.activeVersionId).toBeNull();
});
test('should handle deactivating already inactive workflow', async () => {
const workflow = await createWorkflow({}, owner);
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/deactivate`);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.remove).not.toBeCalled();
const { data } = response.body;
expect(data.activeVersionId).toBeNull();
});
test('should return 404 when workflow does not exist', async () => {
const response = await authOwnerAgent.post('/workflows/non-existent-id/deactivate');
expect(response.statusCode).toBe(404);
});
test('should return 403 when user does not have update permission', async () => {
const workflow = await createActiveWorkflow({}, owner);
const response = await authMemberAgent.post(`/workflows/${workflow.id}/deactivate`);
expect(response.statusCode).toBe(403);
});
test('should clear activeVersion relation when deactivating', async () => {
const workflow = await createActiveWorkflow({}, owner);
await setActiveVersion(workflow.id, workflow.versionId);
await authOwnerAgent.post(`/workflows/${workflow.id}/deactivate`);
const updatedWorkflow = await Container.get(WorkflowRepository).findOne({
where: { id: workflow.id },
relations: ['activeVersion'],
});
expect(updatedWorkflow?.activeVersionId).toBeNull();
expect(updatedWorkflow?.activeVersion).toBeNull();
});
});
describe('POST /workflows/:workflowId/run', () => {