feat: Reintroduce collaboration feature (#10602)

This commit is contained in:
Tomi Turtiainen
2024-09-03 17:52:12 +03:00
committed by GitHub
parent 35e6a87cba
commit 2ea2bfe762
22 changed files with 1046 additions and 23 deletions

View File

@@ -0,0 +1,96 @@
import { CollaborationState } from '../collaboration.state';
import type { CacheService } from '@/services/cache/cache.service';
import { mock } from 'jest-mock-extended';
const origDate = global.Date;
const mockDateFactory = (currentDate: string) => {
return class CustomDate extends origDate {
constructor() {
super(currentDate);
}
} as DateConstructor;
};
describe('CollaborationState', () => {
let collaborationState: CollaborationState;
let mockCacheService: jest.Mocked<CacheService>;
beforeEach(() => {
mockCacheService = mock<CacheService>();
collaborationState = new CollaborationState(mockCacheService);
});
afterEach(() => {
global.Date = origDate;
});
const workflowId = 'workflow';
describe('addActiveWorkflowUser', () => {
it('should add workflow user with correct cache key and value', async () => {
// Arrange
global.Date = mockDateFactory('2023-01-01T00:00:00.000Z');
// Act
await collaborationState.addActiveWorkflowUser(workflowId, 'userId');
// Assert
expect(mockCacheService.setHash).toHaveBeenCalledWith('collaboration:workflow', {
userId: '2023-01-01T00:00:00.000Z',
});
});
});
describe('removeActiveWorkflowUser', () => {
it('should remove workflow user with correct cache key', async () => {
// Act
await collaborationState.removeActiveWorkflowUser(workflowId, 'userId');
// Assert
expect(mockCacheService.deleteFromHash).toHaveBeenCalledWith(
'collaboration:workflow',
'userId',
);
});
});
describe('getActiveWorkflowUsers', () => {
it('should get workflows with correct cache key', async () => {
// Act
const users = await collaborationState.getActiveWorkflowUsers(workflowId);
// Assert
expect(mockCacheService.getHash).toHaveBeenCalledWith('collaboration:workflow');
expect(users).toBeEmptyArray();
});
it('should get workflow users that are not expired', async () => {
// Arrange
const nowMinus16Minutes = new Date();
nowMinus16Minutes.setMinutes(nowMinus16Minutes.getMinutes() - 16);
const now = new Date().toISOString();
mockCacheService.getHash.mockResolvedValueOnce({
expiredUserId: nowMinus16Minutes.toISOString(),
notExpiredUserId: now,
});
// Act
const users = await collaborationState.getActiveWorkflowUsers(workflowId);
// Assert
expect(users).toEqual([
{
lastSeen: now,
userId: 'notExpiredUserId',
},
]);
// removes expired users from the cache
expect(mockCacheService.deleteFromHash).toHaveBeenCalledWith(
'collaboration:workflow',
'expiredUserId',
);
});
});
});

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
export type CollaborationMessage = WorkflowOpenedMessage | WorkflowClosedMessage;
export const workflowOpenedMessageSchema = z
.object({
type: z.literal('workflowOpened'),
workflowId: z.string().min(1),
})
.strict();
export const workflowClosedMessageSchema = z
.object({
type: z.literal('workflowClosed'),
workflowId: z.string().min(1),
})
.strict();
export const workflowMessageSchema = z.discriminatedUnion('type', [
workflowOpenedMessageSchema,
workflowClosedMessageSchema,
]);
export type WorkflowOpenedMessage = z.infer<typeof workflowOpenedMessageSchema>;
export type WorkflowClosedMessage = z.infer<typeof workflowClosedMessageSchema>;
export type WorkflowMessage = z.infer<typeof workflowMessageSchema>;
/**
* Parses the given message and ensure it's of type WorkflowMessage
*/
export const parseWorkflowMessage = async (msg: unknown) => {
return await workflowMessageSchema.parseAsync(msg);
};

View File

@@ -0,0 +1,120 @@
import type { Workflow } from 'n8n-workflow';
import { Service } from 'typedi';
import { Push } from '../push';
import type { WorkflowClosedMessage, WorkflowOpenedMessage } from './collaboration.message';
import { parseWorkflowMessage } from './collaboration.message';
import type { IActiveWorkflowUsersChanged } from '../interfaces';
import type { OnPushMessage } from '@/push/types';
import { UserRepository } from '@/databases/repositories/user.repository';
import type { User } from '@/databases/entities/user';
import { CollaborationState } from '@/collaboration/collaboration.state';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { UserService } from '@/services/user.service';
import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow';
/**
* Service for managing collaboration feature between users. E.g. keeping
* track of active users for a workflow.
*/
@Service()
export class CollaborationService {
constructor(
private readonly push: Push,
private readonly state: CollaborationState,
private readonly userRepository: UserRepository,
private readonly userService: UserService,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
) {}
init() {
this.push.on('message', async (event: OnPushMessage) => {
try {
await this.handleUserMessage(event.userId, event.msg);
} catch (error) {
ErrorReporterProxy.error(
new ApplicationError('Error handling CollaborationService push message', {
extra: {
msg: event.msg,
userId: event.userId,
},
cause: error,
}),
);
}
});
}
async handleUserMessage(userId: User['id'], msg: unknown) {
const workflowMessage = await parseWorkflowMessage(msg);
if (workflowMessage.type === 'workflowOpened') {
await this.handleWorkflowOpened(userId, workflowMessage);
} else if (workflowMessage.type === 'workflowClosed') {
await this.handleWorkflowClosed(userId, workflowMessage);
}
}
private async handleWorkflowOpened(userId: User['id'], msg: WorkflowOpenedMessage) {
const { workflowId } = msg;
if (!(await this.hasUserAccessToWorkflow(userId, workflowId))) {
return;
}
await this.state.addActiveWorkflowUser(workflowId, userId);
await this.sendWorkflowUsersChangedMessage(workflowId);
}
private async handleWorkflowClosed(userId: User['id'], msg: WorkflowClosedMessage) {
const { workflowId } = msg;
if (!(await this.hasUserAccessToWorkflow(userId, workflowId))) {
return;
}
await this.state.removeActiveWorkflowUser(workflowId, userId);
await this.sendWorkflowUsersChangedMessage(workflowId);
}
private async sendWorkflowUsersChangedMessage(workflowId: Workflow['id']) {
// We have already validated that all active workflow users
// have proper access to the workflow, so we don't need to validate it again
const activeWorkflowUsers = await this.state.getActiveWorkflowUsers(workflowId);
const workflowUserIds = activeWorkflowUsers.map((user) => user.userId);
if (workflowUserIds.length === 0) {
return;
}
const users = await this.userRepository.getByIds(this.userRepository.manager, workflowUserIds);
const msgData: IActiveWorkflowUsersChanged = {
workflowId,
activeUsers: await Promise.all(
users.map(async (user) => ({
user: await this.userService.toPublic(user),
lastSeen: activeWorkflowUsers.find((activeUser) => activeUser.userId === user.id)!
.lastSeen,
})),
),
};
this.push.sendToUsers('activeWorkflowUsersChanged', msgData, workflowUserIds);
}
private async hasUserAccessToWorkflow(userId: User['id'], workflowId: Workflow['id']) {
const user = await this.userRepository.findOneBy({
id: userId,
});
if (!user) {
return false;
}
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [
'workflow:read',
]);
return !!workflow;
}
}

View File

@@ -0,0 +1,110 @@
import type { ActiveWorkflowUser } from '@/collaboration/collaboration.types';
import { Time } from '@/constants';
import type { Iso8601DateTimeString } from '@/interfaces';
import { CacheService } from '@/services/cache/cache.service';
import type { User } from '@/databases/entities/user';
import { type Workflow } from 'n8n-workflow';
import { Service } from 'typedi';
type WorkflowCacheHash = Record<User['id'], Iso8601DateTimeString>;
/**
* State management for the collaboration service. Workflow active
* users are stored in a hash in the following format:
* {
* [workflowId] -> {
* [userId] -> lastSeenAsIso8601String
* }
* }
*/
@Service()
export class CollaborationState {
/**
* After how many minutes of inactivity a user should be removed
* as being an active user of a workflow.
*/
public readonly inactivityCleanUpTime = 15 * Time.minutes.toMilliseconds;
constructor(private readonly cache: CacheService) {}
/**
* Mark user active for given workflow
*/
async addActiveWorkflowUser(workflowId: Workflow['id'], userId: User['id']) {
const cacheKey = this.formWorkflowCacheKey(workflowId);
const cacheEntry: WorkflowCacheHash = {
[userId]: new Date().toISOString(),
};
await this.cache.setHash(cacheKey, cacheEntry);
}
/**
* Remove user from workflow's active users
*/
async removeActiveWorkflowUser(workflowId: Workflow['id'], userId: User['id']) {
const cacheKey = this.formWorkflowCacheKey(workflowId);
await this.cache.deleteFromHash(cacheKey, userId);
}
async getActiveWorkflowUsers(workflowId: Workflow['id']): Promise<ActiveWorkflowUser[]> {
const cacheKey = this.formWorkflowCacheKey(workflowId);
const cacheValue = await this.cache.getHash<Iso8601DateTimeString>(cacheKey);
if (!cacheValue) {
return [];
}
const workflowActiveUsers = this.cacheHashToWorkflowActiveUsers(cacheValue);
const [expired, stillActive] = this.splitToExpiredAndStillActive(workflowActiveUsers);
if (expired.length > 0) {
void this.removeExpiredUsersForWorkflow(workflowId, expired);
}
return stillActive;
}
private formWorkflowCacheKey(workflowId: Workflow['id']) {
return `collaboration:${workflowId}`;
}
private splitToExpiredAndStillActive(workflowUsers: ActiveWorkflowUser[]) {
const expired: ActiveWorkflowUser[] = [];
const stillActive: ActiveWorkflowUser[] = [];
for (const user of workflowUsers) {
if (this.hasUserExpired(user.lastSeen)) {
expired.push(user);
} else {
stillActive.push(user);
}
}
return [expired, stillActive];
}
private async removeExpiredUsersForWorkflow(
workflowId: Workflow['id'],
expiredUsers: ActiveWorkflowUser[],
) {
const cacheKey = this.formWorkflowCacheKey(workflowId);
await Promise.all(
expiredUsers.map(async (user) => await this.cache.deleteFromHash(cacheKey, user.userId)),
);
}
private cacheHashToWorkflowActiveUsers(workflowCacheEntry: WorkflowCacheHash) {
return Object.entries(workflowCacheEntry).map(([userId, lastSeen]) => ({
userId,
lastSeen,
}));
}
private hasUserExpired(lastSeenString: Iso8601DateTimeString) {
const expiryTime = new Date(lastSeenString).getTime() + this.inactivityCleanUpTime;
return Date.now() > expiryTime;
}
}

View File

@@ -0,0 +1,7 @@
import type { Iso8601DateTimeString } from '@/interfaces';
import type { User } from '@/databases/entities/user';
export type ActiveWorkflowUser = {
userId: User['id'];
lastSeen: Iso8601DateTimeString;
};

View File

@@ -290,7 +290,13 @@ export type IPushData =
| PushDataWorkerStatusMessage
| PushDataWorkflowActivated
| PushDataWorkflowDeactivated
| PushDataWorkflowFailedToActivate;
| PushDataWorkflowFailedToActivate
| PushDataActiveWorkflowUsersChanged;
type PushDataActiveWorkflowUsersChanged = {
data: IActiveWorkflowUsersChanged;
type: 'activeWorkflowUsersChanged';
};
type PushDataWorkflowFailedToActivate = {
data: IWorkflowFailedToActivate;
@@ -362,6 +368,19 @@ export type PushDataNodeDescriptionUpdated = {
type: 'nodeDescriptionUpdated';
};
/** DateTime in the Iso8601 format, e.g. 2024-10-31T00:00:00.123Z */
export type Iso8601DateTimeString = string;
export interface IActiveWorkflowUser {
user: PublicUser;
lastSeen: Iso8601DateTimeString;
}
export interface IActiveWorkflowUsersChanged {
workflowId: Workflow['id'];
activeUsers: IActiveWorkflowUser[];
}
export interface IActiveWorkflowAdded {
workflowId: Workflow['id'];
}

View File

@@ -7,6 +7,7 @@ import { Logger } from '@/logger';
import type { PushDataExecutionRecovered } from '@/interfaces';
import { mockInstance } from '@test/mocking';
import type { User } from '@/databases/entities/user';
jest.useFakeTimers();
@@ -27,6 +28,7 @@ const createMockWebSocket = () => new MockWebSocket() as unknown as jest.Mocked<
describe('WebSocketPush', () => {
const pushRef1 = 'test-session1';
const pushRef2 = 'test-session2';
const userId: User['id'] = 'test-user';
mockInstance(Logger);
const webSocketPush = Container.get(WebSocketPush);
@@ -35,27 +37,31 @@ describe('WebSocketPush', () => {
beforeEach(() => {
jest.resetAllMocks();
mockWebSocket1.removeAllListeners();
mockWebSocket2.removeAllListeners();
});
it('can add a connection', () => {
webSocketPush.add(pushRef1, mockWebSocket1);
webSocketPush.add(pushRef1, userId, mockWebSocket1);
expect(mockWebSocket1.listenerCount('close')).toBe(1);
expect(mockWebSocket1.listenerCount('pong')).toBe(1);
expect(mockWebSocket1.listenerCount('message')).toBe(1);
});
it('closes a connection', () => {
webSocketPush.add(pushRef1, mockWebSocket1);
webSocketPush.add(pushRef1, userId, mockWebSocket1);
mockWebSocket1.emit('close');
expect(mockWebSocket1.listenerCount('message')).toBe(0);
expect(mockWebSocket1.listenerCount('close')).toBe(0);
expect(mockWebSocket1.listenerCount('pong')).toBe(0);
});
it('sends data to one connection', () => {
webSocketPush.add(pushRef1, mockWebSocket1);
webSocketPush.add(pushRef2, mockWebSocket2);
webSocketPush.add(pushRef1, userId, mockWebSocket1);
webSocketPush.add(pushRef2, userId, mockWebSocket2);
const data: PushDataExecutionRecovered = {
type: 'executionRecovered',
data: {
@@ -80,8 +86,8 @@ describe('WebSocketPush', () => {
});
it('sends data to all connections', () => {
webSocketPush.add(pushRef1, mockWebSocket1);
webSocketPush.add(pushRef2, mockWebSocket2);
webSocketPush.add(pushRef1, userId, mockWebSocket1);
webSocketPush.add(pushRef2, userId, mockWebSocket2);
const data: PushDataExecutionRecovered = {
type: 'executionRecovered',
data: {
@@ -105,12 +111,55 @@ describe('WebSocketPush', () => {
});
it('pings all connections', () => {
webSocketPush.add(pushRef1, mockWebSocket1);
webSocketPush.add(pushRef2, mockWebSocket2);
webSocketPush.add(pushRef1, userId, mockWebSocket1);
webSocketPush.add(pushRef2, userId, mockWebSocket2);
jest.runOnlyPendingTimers();
expect(mockWebSocket1.ping).toHaveBeenCalled();
expect(mockWebSocket2.ping).toHaveBeenCalled();
});
it('sends data to all users connections', () => {
webSocketPush.add(pushRef1, userId, mockWebSocket1);
webSocketPush.add(pushRef2, userId, mockWebSocket2);
const data: PushDataExecutionRecovered = {
type: 'executionRecovered',
data: {
executionId: 'test-execution-id',
},
};
webSocketPush.sendToUsers('executionRecovered', data, [userId]);
const expectedMsg = JSON.stringify({
type: 'executionRecovered',
data: {
type: 'executionRecovered',
data: {
executionId: 'test-execution-id',
},
},
});
expect(mockWebSocket1.send).toHaveBeenCalledWith(expectedMsg);
expect(mockWebSocket2.send).toHaveBeenCalledWith(expectedMsg);
});
it('emits message event when connection receives data', () => {
const mockOnMessageReceived = jest.fn();
webSocketPush.on('message', mockOnMessageReceived);
webSocketPush.add(pushRef1, userId, mockWebSocket1);
webSocketPush.add(pushRef2, userId, mockWebSocket2);
const data = { test: 'data' };
const buffer = Buffer.from(JSON.stringify(data));
mockWebSocket1.emit('message', buffer);
expect(mockOnMessageReceived).toHaveBeenCalledWith({
msg: data,
pushRef: pushRef1,
userId,
});
});
});

View File

@@ -1,6 +1,13 @@
import { assert, jsonStringify } from 'n8n-workflow';
import type { IPushDataType } from '@/interfaces';
import type { Logger } from '@/logger';
import type { User } from '@/databases/entities/user';
import { TypedEmitter } from '@/typed-emitter';
import type { OnPushMessage } from '@/push/types';
export interface AbstractPushEvents {
message: OnPushMessage;
}
/**
* Abstract class for two-way push communication.
@@ -8,16 +15,20 @@ import type { Logger } from '@/logger';
*
* @emits message when a message is received from a client
*/
export abstract class AbstractPush<T> {
export abstract class AbstractPush<T> extends TypedEmitter<AbstractPushEvents> {
protected connections: Record<string, T> = {};
protected userIdByPushRef: Record<string, string> = {};
protected abstract close(connection: T): void;
protected abstract sendToOneConnection(connection: T, data: string): void;
constructor(protected readonly logger: Logger) {}
constructor(protected readonly logger: Logger) {
super();
}
protected add(pushRef: string, connection: T) {
const { connections } = this;
protected add(pushRef: string, userId: User['id'], connection: T) {
const { connections, userIdByPushRef } = this;
this.logger.debug('Add editor-UI session', { pushRef });
const existingConnection = connections[pushRef];
@@ -28,6 +39,15 @@ export abstract class AbstractPush<T> {
}
connections[pushRef] = connection;
userIdByPushRef[pushRef] = userId;
}
protected onMessageReceived(pushRef: string, msg: unknown) {
this.logger.debug('Received message from editor-UI', { pushRef, msg });
const userId = this.userIdByPushRef[pushRef];
this.emit('message', { pushRef, userId, msg });
}
protected remove(pushRef?: string) {
@@ -36,6 +56,7 @@ export abstract class AbstractPush<T> {
this.logger.debug('Removed editor-UI session', { pushRef });
delete this.connections[pushRef];
delete this.userIdByPushRef[pushRef];
}
private sendTo(type: IPushDataType, data: unknown, pushRefs: string[]) {
@@ -66,6 +87,15 @@ export abstract class AbstractPush<T> {
this.sendTo(type, data, [pushRef]);
}
sendToUsers(type: IPushDataType, data: unknown, userIds: Array<User['id']>) {
const { connections } = this;
const userPushRefs = Object.keys(connections).filter((pushRef) =>
userIds.includes(this.userIdByPushRef[pushRef]),
);
this.sendTo(type, data, userPushRefs);
}
closeAllConnections() {
for (const pushRef in this.connections) {
// Signal the connection that we want to close it.

View File

@@ -15,11 +15,13 @@ import { OrchestrationService } from '@/services/orchestration.service';
import { SSEPush } from './sse.push';
import { WebSocketPush } from './websocket.push';
import type { PushResponse, SSEPushRequest, WebSocketPushRequest } from './types';
import type { OnPushMessage, PushResponse, SSEPushRequest, WebSocketPushRequest } from './types';
import { TypedEmitter } from '@/typed-emitter';
import type { User } from '@/databases/entities/user';
type PushEvents = {
editorUiConnected: string;
message: OnPushMessage;
};
const useWebSockets = config.getEnv('push.backend') === 'websocket';
@@ -33,16 +35,21 @@ const useWebSockets = config.getEnv('push.backend') === 'websocket';
*/
@Service()
export class Push extends TypedEmitter<PushEvents> {
public isBidirectional = useWebSockets;
private backend = useWebSockets ? Container.get(WebSocketPush) : Container.get(SSEPush);
constructor(private readonly orchestrationService: OrchestrationService) {
super();
if (useWebSockets) this.backend.on('message', (msg) => this.emit('message', msg));
}
handleRequest(req: SSEPushRequest | WebSocketPushRequest, res: PushResponse) {
const {
ws,
query: { pushRef },
user,
} = req;
if (!pushRef) {
@@ -55,9 +62,9 @@ export class Push extends TypedEmitter<PushEvents> {
}
if (req.ws) {
(this.backend as WebSocketPush).add(pushRef, req.ws);
(this.backend as WebSocketPush).add(pushRef, user.id, req.ws);
} else if (!useWebSockets) {
(this.backend as SSEPush).add(pushRef, { req, res });
(this.backend as SSEPush).add(pushRef, user.id, { req, res });
} else {
res.status(401).send('Unauthorized');
return;
@@ -90,6 +97,10 @@ export class Push extends TypedEmitter<PushEvents> {
return this.backend;
}
sendToUsers(type: IPushDataType, data: unknown, userIds: Array<User['id']>) {
this.backend.sendToUsers(type, data, userIds);
}
@OnShutdown()
onShutdown() {
this.backend.closeAllConnections();

View File

@@ -5,6 +5,7 @@ import { Logger } from '@/logger';
import { AbstractPush } from './abstract.push';
import type { PushRequest, PushResponse } from './types';
import type { User } from '@/databases/entities/user';
type Connection = { req: PushRequest; res: PushResponse };
@@ -22,8 +23,8 @@ export class SSEPush extends AbstractPush<Connection> {
});
}
add(pushRef: string, connection: Connection) {
super.add(pushRef, connection);
add(pushRef: string, userId: User['id'], connection: Connection) {
super.add(pushRef, userId, connection);
this.channel.addClient(connection.req, connection.res);
}

View File

@@ -2,6 +2,7 @@ import type { Response } from 'express';
import type { WebSocket } from 'ws';
import type { AuthenticatedRequest } from '@/requests';
import type { User } from '@/databases/entities/user';
// TODO: move all push related types here
@@ -11,3 +12,9 @@ export type SSEPushRequest = PushRequest & { ws: undefined };
export type WebSocketPushRequest = PushRequest & { ws: WebSocket };
export type PushResponse = Response & { req: PushRequest };
export interface OnPushMessage {
pushRef: string;
userId: User['id'];
msg: unknown;
}

View File

@@ -2,6 +2,8 @@ import type WebSocket from 'ws';
import { Service } from 'typedi';
import { Logger } from '@/logger';
import { AbstractPush } from './abstract.push';
import type { User } from '@/databases/entities/user';
import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow';
function heartbeat(this: WebSocket) {
this.isAlive = true;
@@ -16,17 +18,43 @@ export class WebSocketPush extends AbstractPush<WebSocket> {
setInterval(() => this.pingAll(), 60 * 1000);
}
add(pushRef: string, connection: WebSocket) {
add(pushRef: string, userId: User['id'], connection: WebSocket) {
connection.isAlive = true;
connection.on('pong', heartbeat);
super.add(pushRef, connection);
super.add(pushRef, userId, connection);
const onMessage = (data: WebSocket.RawData) => {
try {
const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
this.onMessageReceived(pushRef, JSON.parse(buffer.toString('utf8')));
} catch (error) {
ErrorReporterProxy.error(
new ApplicationError('Error parsing push message', {
extra: {
userId,
data,
},
cause: error,
}),
);
this.logger.error("Couldn't parse message from editor-UI", {
error: error as unknown,
pushRef,
data,
});
}
};
// Makes sure to remove the session if the connection is closed
connection.once('close', () => {
connection.off('pong', heartbeat);
connection.off('message', onMessage);
this.remove(pushRef);
});
connection.on('message', onMessage);
}
protected close(connection: WebSocket): void {

View File

@@ -27,7 +27,7 @@ import type { ICredentialsOverwrite } from '@/interfaces';
import { CredentialsOverwrites } from '@/credentials-overwrites';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import * as ResponseHelper from '@/response-helper';
import { setupPushServer, setupPushHandler } from '@/push';
import { setupPushServer, setupPushHandler, Push } from '@/push';
import { isLdapEnabled } from '@/ldap/helpers.ee';
import { AbstractServer } from '@/abstract-server';
import { PostHogClient } from '@/posthog';
@@ -212,6 +212,18 @@ export class Server extends AbstractServer {
const { restEndpoint, app } = this;
setupPushHandler(restEndpoint, app);
const push = Container.get(Push);
if (push.isBidirectional) {
const { CollaborationService } = await import('@/collaboration/collaboration.service');
const collaborationService = Container.get(CollaborationService);
collaborationService.init();
} else {
this.logger.warn(
'Collaboration features are disabled because push is configured unidirectional. Use N8N_PUSH_BACKEND=websocket environment variable to enable them.',
);
}
if (config.getEnv('executions.mode') === 'queue') {
const { ScalingService } = await import('@/scaling/scaling.service');
await Container.get(ScalingService).setupQueue();