diff --git a/packages/@n8n/config/src/configs/runners.config.ts b/packages/@n8n/config/src/configs/runners.config.ts new file mode 100644 index 0000000000..5eb452b2b8 --- /dev/null +++ b/packages/@n8n/config/src/configs/runners.config.ts @@ -0,0 +1,14 @@ +import { Config, Env } from '../decorators'; + +@Config +export class TaskRunnersConfig { + // Defaults to true for now + @Env('N8N_RUNNERS_DISABLED') + disabled: boolean = true; + + @Env('N8N_RUNNERS_PATH') + path: string = '/runners'; + + @Env('N8N_RUNNERS_AUTH_TOKEN') + authToken: string = ''; +} diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index e4cdd93284..3290cac5bb 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -8,6 +8,8 @@ import { ExternalStorageConfig } from './configs/external-storage.config'; import { LoggingConfig } from './configs/logging.config'; import { NodesConfig } from './configs/nodes.config'; import { PublicApiConfig } from './configs/public-api.config'; +import { TaskRunnersConfig } from './configs/runners.config'; +export { TaskRunnersConfig } from './configs/runners.config'; import { ScalingModeConfig } from './configs/scaling-mode.config'; import { SentryConfig } from './configs/sentry.config'; import { TemplatesConfig } from './configs/templates.config'; @@ -85,4 +87,7 @@ export class GlobalConfig { @Nested logging: LoggingConfig; + + @Nested + taskRunners: TaskRunnersConfig; } diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index a2fd191eab..a93f29d5f9 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -221,6 +221,11 @@ describe('GlobalConfig', () => { }, }, }, + taskRunners: { + disabled: true, + path: '/runners', + authToken: '', + }, sentry: { backendDsn: '', frontendDsn: '', diff --git a/packages/@n8n/task-runner-node-js/.eslintrc.js b/packages/@n8n/task-runner-node-js/.eslintrc.js new file mode 100644 index 0000000000..dd79f2157e --- /dev/null +++ b/packages/@n8n/task-runner-node-js/.eslintrc.js @@ -0,0 +1,19 @@ +const sharedOptions = require('@n8n_io/eslint-config/shared'); + +/** + * @type {import('@types/eslint').ESLint.ConfigData} + */ +module.exports = { + extends: ['@n8n_io/eslint-config/node'], + + ...sharedOptions(__dirname), + + ignorePatterns: ['jest.config.js'], + + rules: { + 'unicorn/filename-case': ['error', { case: 'kebabCase' }], + '@typescript-eslint/no-duplicate-imports': 'off', + + complexity: 'error', + }, +}; diff --git a/packages/@n8n/task-runner-node-js/jest.config.js b/packages/@n8n/task-runner-node-js/jest.config.js new file mode 100644 index 0000000000..5c3abe1ef7 --- /dev/null +++ b/packages/@n8n/task-runner-node-js/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('jest').Config} */ +module.exports = { + ...require('../../../jest.config'), + testTimeout: 10_000, +}; diff --git a/packages/@n8n/task-runner-node-js/package.json b/packages/@n8n/task-runner-node-js/package.json new file mode 100644 index 0000000000..ee94e34399 --- /dev/null +++ b/packages/@n8n/task-runner-node-js/package.json @@ -0,0 +1,59 @@ +{ + "name": "@n8n/task-runner", + "private": true, + "version": "0.1.0", + "description": "", + "main": "dist/index.js", + "scripts": { + "start": "node dist/start.js", + "dev": "pnpm build && pnpm start", + "build": "tsc -p ./tsconfig.build.json", + "test": "jest", + "lint": "eslint .", + "lintfix": "eslint . --fix", + "watch": "tsc -w -p ./tsconfig.build.json" + }, + "engines": { + "node": ">=20.15", + "pnpm": ">=9.5" + }, + "files": [ + "src/", + "dist/", + "package.json", + "tsconfig.json" + ], + "main": "dist/index.js", + "module": "src/index.ts", + "types": "dist/index.d.ts", + "packageManager": "pnpm@9.6.0", + "devDependencies": { + "@n8n_io/eslint-config": "^0.0.2", + "@types/jest": "^29.5.0", + "@types/node": "^18.13.0", + "@types/ws": "^8.5.12", + "@typescript-eslint/eslint-plugin": "^6.1.0", + "eslint": "^8.38.0", + "eslint-config-airbnb-typescript": "^17.1.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-n8n-local-rules": "^1.0.0", + "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-unicorn": "^48.0.0", + "eslint-plugin-unused-imports": "^3.0.0", + "jest": "^29.5.0", + "nodemon": "^2.0.20", + "prettier": "^3.0.0", + "ts-jest": "^29.1.0", + "ts-node": "^10.9.1", + "tsc-alias": "^1.8.7", + "typescript": "^5.0.0" + }, + "dependencies": { + "jmespath": "^0.16.0", + "luxon": "^3.5.0", + "n8n-workflow": "workspace:*", + "n8n-core": "workspace:*", + "nanoid": "^3.3.6", + "ws": "^8.18.0" + } +} diff --git a/packages/@n8n/task-runner-node-js/src/authenticator.ts b/packages/@n8n/task-runner-node-js/src/authenticator.ts new file mode 100644 index 0000000000..8b4326f866 --- /dev/null +++ b/packages/@n8n/task-runner-node-js/src/authenticator.ts @@ -0,0 +1,42 @@ +import * as a from 'node:assert/strict'; + +export type AuthOpts = { + n8nUri: string; + authToken: string; +}; + +/** + * Requests a one-time token that can be used to establish a task runner connection + */ +export async function authenticate(opts: AuthOpts) { + try { + const authEndpoint = `http://${opts.n8nUri}/rest/runners/auth`; + const response = await fetch(authEndpoint, { + method: 'POST', + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + token: opts.authToken, + }), + }); + + if (!response.ok) { + throw new Error(`Invalid response status ${response.status}: ${await response.text()}`); + } + + const { data } = (await response.json()) as { data: { token: string } }; + const grantToken = data.token; + a.ok(grantToken); + + return grantToken; + } catch (e) { + console.error(e); + const error = e as Error; + + throw new Error(`Could not connect to n8n message broker ${opts.n8nUri}: ${error.message}`, { + cause: error, + }); + } +} diff --git a/packages/@n8n/task-runner-node-js/src/code.ts b/packages/@n8n/task-runner-node-js/src/code.ts new file mode 100644 index 0000000000..2942166b72 --- /dev/null +++ b/packages/@n8n/task-runner-node-js/src/code.ts @@ -0,0 +1,150 @@ +import { runInNewContext, type Context } from 'node:vm'; +import * as a from 'node:assert'; + +import { + type INode, + type INodeType, + type ITaskDataConnections, + type IWorkflowExecuteAdditionalData, + WorkflowDataProxy, + type WorkflowParameters, +} from 'n8n-workflow'; +import { + type IDataObject, + type IExecuteData, + type INodeExecutionData, + type INodeParameters, + type IRunExecutionData, + // type IWorkflowDataProxyAdditionalKeys, + Workflow, + type WorkflowExecuteMode, +} from 'n8n-workflow'; +import { getAdditionalKeys } from 'n8n-core'; + +import type { TaskResultData } from './runner-types'; +import { type Task, TaskRunner } from './task-runner'; + +interface JSExecSettings { + code: string; + + // For workflow data proxy + mode: WorkflowExecuteMode; +} + +export interface PartialAdditionalData { + executionId?: string; + restartExecutionId?: string; + restApiUrl: string; + instanceBaseUrl: string; + formWaitingBaseUrl: string; + webhookBaseUrl: string; + webhookWaitingBaseUrl: string; + webhookTestBaseUrl: string; + currentNodeParameters?: INodeParameters; + executionTimeoutTimestamp?: number; + userId?: string; + variables: IDataObject; +} + +export interface AllCodeTaskData { + workflow: Omit; + inputData: ITaskDataConnections; + node: INode; + + runExecutionData: IRunExecutionData; + runIndex: number; + itemIndex: number; + activeNodeName: string; + connectionInputData: INodeExecutionData[]; + siblingParameters: INodeParameters; + mode: WorkflowExecuteMode; + executeData?: IExecuteData; + defaultReturnRunIndex: number; + selfData: IDataObject; + contextNodeName: string; + additionalData: PartialAdditionalData; +} + +export class JsTaskRunner extends TaskRunner { + constructor( + taskType: string, + wsUrl: string, + grantToken: string, + maxConcurrency: number, + name?: string, + ) { + super(taskType, wsUrl, grantToken, maxConcurrency, name ?? 'JS Task Runner'); + } + + async executeTask(task: Task): Promise { + const allData = await this.requestData(task.taskId, 'all'); + + const settings = task.settings; + a.ok(settings, 'JS Code not sent to runner'); + + const workflowParams = allData.workflow; + const workflow = new Workflow({ + ...workflowParams, + nodeTypes: { + getByNameAndVersion() { + return undefined as unknown as INodeType; + }, + getByName() { + return undefined as unknown as INodeType; + }, + getKnownTypes() { + return {}; + }, + }, + }); + + const dataProxy = new WorkflowDataProxy( + workflow, + allData.runExecutionData, + allData.runIndex, + allData.itemIndex, + allData.activeNodeName, + allData.connectionInputData, + allData.siblingParameters, + settings.mode, + getAdditionalKeys( + allData.additionalData as IWorkflowExecuteAdditionalData, + allData.mode, + allData.runExecutionData, + ), + allData.executeData, + allData.defaultReturnRunIndex, + allData.selfData, + allData.contextNodeName, + ); + + const customConsole = { + log: (...args: unknown[]) => { + const logOutput = args + .map((arg) => (typeof arg === 'object' && arg !== null ? JSON.stringify(arg) : arg)) + .join(' '); + console.log('[JS Code]', logOutput); + void this.makeRpcCall(task.taskId, 'logNodeOutput', [logOutput]); + }, + }; + + const context: Context = { + require, + module: {}, + console: customConsole, + + ...dataProxy.getDataProxy(), + ...this.buildRpcCallObject(task.taskId), + }; + + const result = (await runInNewContext( + `module.exports = async function() {${settings.code}\n}()`, + context, + )) as TaskResultData['result']; + + return { + result, + customData: allData.runExecutionData.resultData.metadata, + }; + } +} diff --git a/packages/@n8n/task-runner-node-js/src/index.ts b/packages/@n8n/task-runner-node-js/src/index.ts new file mode 100644 index 0000000000..59e6f6d288 --- /dev/null +++ b/packages/@n8n/task-runner-node-js/src/index.ts @@ -0,0 +1,2 @@ +export * from './task-runner'; +export * from './runner-types'; diff --git a/packages/@n8n/task-runner-node-js/src/runner-types.ts b/packages/@n8n/task-runner-node-js/src/runner-types.ts new file mode 100644 index 0000000000..27b4e9a76c --- /dev/null +++ b/packages/@n8n/task-runner-node-js/src/runner-types.ts @@ -0,0 +1,231 @@ +import type { INodeExecutionData } from 'n8n-workflow'; + +export type DataRequestType = 'input' | 'node' | 'all'; + +export interface TaskResultData { + result: INodeExecutionData[]; + customData?: Record; +} + +export namespace N8nMessage { + export namespace ToRunner { + export interface InfoRequest { + type: 'broker:inforequest'; + } + + export interface RunnerRegistered { + type: 'broker:runnerregistered'; + } + + export interface TaskOfferAccept { + type: 'broker:taskofferaccept'; + taskId: string; + offerId: string; + } + + export interface TaskCancel { + type: 'broker:taskcancel'; + taskId: string; + reason: string; + } + + export interface TaskSettings { + type: 'broker:tasksettings'; + taskId: string; + settings: unknown; + } + + export interface RPCResponse { + type: 'broker:rpcresponse'; + callId: string; + taskId: string; + status: 'success' | 'error'; + data: unknown; + } + + export interface TaskDataResponse { + type: 'broker:taskdataresponse'; + taskId: string; + requestId: string; + data: unknown; + } + + export type All = + | InfoRequest + | TaskOfferAccept + | TaskCancel + | TaskSettings + | RunnerRegistered + | RPCResponse + | TaskDataResponse; + } + + export namespace ToRequester { + export interface TaskReady { + type: 'broker:taskready'; + requestId: string; + taskId: string; + } + + export interface TaskDone { + type: 'broker:taskdone'; + taskId: string; + data: TaskResultData; + } + + export interface TaskError { + type: 'broker:taskerror'; + taskId: string; + error: unknown; + } + + export interface TaskDataRequest { + type: 'broker:taskdatarequest'; + taskId: string; + requestId: string; + requestType: DataRequestType; + param?: string; + } + + export interface RPC { + type: 'broker:rpc'; + callId: string; + taskId: string; + name: (typeof RPC_ALLOW_LIST)[number]; + params: unknown[]; + } + + export type All = TaskReady | TaskDone | TaskError | TaskDataRequest | RPC; + } +} + +export namespace RequesterMessage { + export namespace ToN8n { + export interface TaskSettings { + type: 'requester:tasksettings'; + taskId: string; + settings: unknown; + } + + export interface TaskCancel { + type: 'requester:taskcancel'; + taskId: string; + reason: string; + } + + export interface TaskDataResponse { + type: 'requester:taskdataresponse'; + taskId: string; + requestId: string; + data: unknown; + } + + export interface RPCResponse { + type: 'requester:rpcresponse'; + taskId: string; + callId: string; + status: 'success' | 'error'; + data: unknown; + } + + export interface TaskRequest { + type: 'requester:taskrequest'; + requestId: string; + taskType: string; + } + + export type All = TaskSettings | TaskCancel | RPCResponse | TaskDataResponse | TaskRequest; + } +} + +export namespace RunnerMessage { + export namespace ToN8n { + export interface Info { + type: 'runner:info'; + name: string; + types: string[]; + } + + export interface TaskAccepted { + type: 'runner:taskaccepted'; + taskId: string; + } + + export interface TaskRejected { + type: 'runner:taskrejected'; + taskId: string; + reason: string; + } + + export interface TaskDone { + type: 'runner:taskdone'; + taskId: string; + data: TaskResultData; + } + + export interface TaskError { + type: 'runner:taskerror'; + taskId: string; + error: unknown; + } + + export interface TaskOffer { + type: 'runner:taskoffer'; + offerId: string; + taskType: string; + validFor: number; + } + + export interface TaskDataRequest { + type: 'runner:taskdatarequest'; + taskId: string; + requestId: string; + requestType: DataRequestType; + param?: string; + } + + export interface RPC { + type: 'runner:rpc'; + callId: string; + taskId: string; + name: (typeof RPC_ALLOW_LIST)[number]; + params: unknown[]; + } + + export type All = + | Info + | TaskDone + | TaskError + | TaskAccepted + | TaskRejected + | TaskOffer + | RPC + | TaskDataRequest; + } +} + +export const RPC_ALLOW_LIST = [ + 'helpers.httpRequestWithAuthentication', + 'helpers.requestWithAuthenticationPaginated', + // "helpers.normalizeItems" + // "helpers.constructExecutionMetaData" + // "helpers.assertBinaryData" + 'helpers.getBinaryDataBuffer', + // "helpers.copyInputItems" + // "helpers.returnJsonArray" + 'helpers.getSSHClient', + 'helpers.createReadStream', + // "helpers.getStoragePath" + 'helpers.writeContentToFile', + 'helpers.prepareBinaryData', + 'helpers.setBinaryDataBuffer', + 'helpers.copyBinaryFile', + 'helpers.binaryToBuffer', + // "helpers.binaryToString" + // "helpers.getBinaryPath" + 'helpers.getBinaryStream', + 'helpers.getBinaryMetadata', + 'helpers.createDeferredPromise', + 'helpers.httpRequest', + 'logNodeOutput', +] as const; diff --git a/packages/@n8n/task-runner-node-js/src/start.ts b/packages/@n8n/task-runner-node-js/src/start.ts new file mode 100644 index 0000000000..ac402dd1c0 --- /dev/null +++ b/packages/@n8n/task-runner-node-js/src/start.ts @@ -0,0 +1,34 @@ +import * as a from 'node:assert/strict'; + +import { JsTaskRunner } from './code'; +import { authenticate } from './authenticator'; + +let _runner: JsTaskRunner; + +type Config = { + n8nUri: string; + authToken: string; +}; + +function readAndParseConfig(): Config { + const authToken = process.env.N8N_RUNNERS_AUTH_TOKEN; + a.ok(authToken, 'Missing task runner auth token. Use N8N_RUNNERS_AUTH_TOKEN to configure it'); + + return { + n8nUri: process.env.N8N_RUNNERS_N8N_URI ?? 'localhost:5678', + authToken, + }; +} + +void (async function start() { + const config = readAndParseConfig(); + + const grantToken = await authenticate({ + authToken: config.authToken, + n8nUri: config.n8nUri, + }); + + const wsUrl = `ws://${config.n8nUri}/rest/runners/_ws`; + + _runner = new JsTaskRunner('javascript', wsUrl, grantToken, 5); +})(); diff --git a/packages/@n8n/task-runner-node-js/src/task-runner.ts b/packages/@n8n/task-runner-node-js/src/task-runner.ts new file mode 100644 index 0000000000..6a95bf8d7d --- /dev/null +++ b/packages/@n8n/task-runner-node-js/src/task-runner.ts @@ -0,0 +1,362 @@ +import { URL } from 'node:url'; +import { nanoid } from 'nanoid'; +import { type MessageEvent, WebSocket } from 'ws'; +import { ensureError } from 'n8n-workflow'; + +import { + RPC_ALLOW_LIST, + type RunnerMessage, + type N8nMessage, + type TaskResultData, +} from './runner-types'; + +export interface Task { + taskId: string; + settings?: T; + active: boolean; + cancelled: boolean; +} + +export interface TaskOffer { + offerId: string; + validUntil: bigint; +} + +interface DataRequest { + requestId: string; + resolve: (data: unknown) => void; + reject: (error: unknown) => void; +} + +interface RPCCall { + callId: string; + resolve: (data: unknown) => void; + reject: (error: unknown) => void; +} + +export interface RPCCallObject { + [name: string]: ((...args: unknown[]) => Promise) | RPCCallObject; +} + +const VALID_TIME_MS = 1000; +const VALID_EXTRA_MS = 100; + +export abstract class TaskRunner { + id: string = nanoid(); + + ws: WebSocket; + + canSendOffers = false; + + runningTasks: Map = new Map(); + + offerInterval: NodeJS.Timeout | undefined; + + openOffers: Map = new Map(); + + dataRequests: Map = new Map(); + + rpcCalls: Map = new Map(); + + constructor( + public taskType: string, + wsUrl: string, + grantToken: string, + private maxConcurrency: number, + public name?: string, + ) { + const url = new URL(wsUrl); + url.searchParams.append('id', this.id); + this.ws = new WebSocket(url.toString(), { + headers: { + authorization: `Bearer ${grantToken}`, + }, + }); + this.ws.addEventListener('message', this.receiveMessage); + this.ws.addEventListener('close', this.stopTaskOffers); + } + + private receiveMessage = (message: MessageEvent) => { + // eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse + const data = JSON.parse(message.data as string) as N8nMessage.ToRunner.All; + void this.onMessage(data); + }; + + private stopTaskOffers = () => { + this.canSendOffers = false; + if (this.offerInterval) { + clearInterval(this.offerInterval); + this.offerInterval = undefined; + } + }; + + private startTaskOffers() { + this.canSendOffers = true; + if (this.offerInterval) { + clearInterval(this.offerInterval); + } + this.offerInterval = setInterval(() => this.sendOffers(), 250); + } + + deleteStaleOffers() { + this.openOffers.forEach((offer, key) => { + if (offer.validUntil < process.hrtime.bigint()) { + this.openOffers.delete(key); + } + }); + } + + sendOffers() { + this.deleteStaleOffers(); + + const offersToSend = + this.maxConcurrency - + (Object.values(this.openOffers).length + Object.values(this.runningTasks).length); + + for (let i = 0; i < offersToSend; i++) { + const offer: TaskOffer = { + offerId: nanoid(), + validUntil: process.hrtime.bigint() + BigInt((VALID_TIME_MS + VALID_EXTRA_MS) * 1_000_000), // Adding a little extra time to account for latency + }; + this.openOffers.set(offer.offerId, offer); + this.send({ + type: 'runner:taskoffer', + taskType: this.taskType, + offerId: offer.offerId, + validFor: VALID_TIME_MS, + }); + } + } + + send(message: RunnerMessage.ToN8n.All) { + this.ws.send(JSON.stringify(message)); + } + + onMessage(message: N8nMessage.ToRunner.All) { + switch (message.type) { + case 'broker:inforequest': + this.send({ + type: 'runner:info', + name: this.name ?? 'Node.js Task Runner SDK', + types: [this.taskType], + }); + break; + case 'broker:runnerregistered': + this.startTaskOffers(); + break; + case 'broker:taskofferaccept': + this.offerAccepted(message.offerId, message.taskId); + break; + case 'broker:taskcancel': + this.taskCancelled(message.taskId); + break; + case 'broker:tasksettings': + void this.receivedSettings(message.taskId, message.settings); + break; + case 'broker:taskdataresponse': + this.processDataResponse(message.requestId, message.data); + break; + case 'broker:rpcresponse': + this.handleRpcResponse(message.callId, message.status, message.data); + } + } + + processDataResponse(requestId: string, data: unknown) { + const request = this.dataRequests.get(requestId); + if (!request) { + return; + } + // Deleting of the request is handled in `requestData`, using a + // `finally` wrapped around the return + request.resolve(data); + } + + hasOpenTasks() { + return Object.values(this.runningTasks).length < this.maxConcurrency; + } + + offerAccepted(offerId: string, taskId: string) { + if (!this.hasOpenTasks()) { + this.send({ + type: 'runner:taskrejected', + taskId, + reason: 'No open task slots', + }); + return; + } + const offer = this.openOffers.get(offerId); + if (!offer) { + this.send({ + type: 'runner:taskrejected', + taskId, + reason: 'Offer expired and no open task slots', + }); + return; + } else { + this.openOffers.delete(offerId); + } + + this.runningTasks.set(taskId, { + taskId, + active: false, + cancelled: false, + }); + + this.send({ + type: 'runner:taskaccepted', + taskId, + }); + } + + taskCancelled(taskId: string) { + const task = this.runningTasks.get(taskId); + if (!task) { + return; + } + task.cancelled = true; + if (task.active) { + // TODO + } else { + this.runningTasks.delete(taskId); + } + this.sendOffers(); + } + + taskErrored(taskId: string, error: unknown) { + this.send({ + type: 'runner:taskerror', + taskId, + error, + }); + this.runningTasks.delete(taskId); + this.sendOffers(); + } + + taskDone(taskId: string, data: RunnerMessage.ToN8n.TaskDone['data']) { + this.send({ + type: 'runner:taskdone', + taskId, + data, + }); + this.runningTasks.delete(taskId); + this.sendOffers(); + } + + async receivedSettings(taskId: string, settings: unknown) { + const task = this.runningTasks.get(taskId); + if (!task) { + return; + } + if (task.cancelled) { + this.runningTasks.delete(taskId); + return; + } + task.settings = settings; + task.active = true; + try { + const data = await this.executeTask(task); + this.taskDone(taskId, data); + } catch (e) { + if (ensureError(e)) { + this.taskErrored(taskId, (e as Error).message); + } else { + this.taskErrored(taskId, e); + } + } + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + async executeTask(_task: Task): Promise { + throw new Error('Unimplemented'); + } + + async requestData( + taskId: Task['taskId'], + type: RunnerMessage.ToN8n.TaskDataRequest['requestType'], + param?: string, + ): Promise { + const requestId = nanoid(); + + const p = new Promise((resolve, reject) => { + this.dataRequests.set(requestId, { + requestId, + resolve: resolve as (data: unknown) => void, + reject, + }); + }); + + this.send({ + type: 'runner:taskdatarequest', + taskId, + requestId, + requestType: type, + param, + }); + + try { + return await p; + } finally { + this.dataRequests.delete(requestId); + } + } + + async makeRpcCall(taskId: string, name: RunnerMessage.ToN8n.RPC['name'], params: unknown[]) { + const callId = nanoid(); + + const dataPromise = new Promise((resolve, reject) => { + this.rpcCalls.set(callId, { + callId, + resolve, + reject, + }); + }); + + this.send({ + type: 'runner:rpc', + callId, + taskId, + name, + params, + }); + + try { + return await dataPromise; + } finally { + this.rpcCalls.delete(callId); + } + } + + handleRpcResponse( + callId: string, + status: N8nMessage.ToRunner.RPCResponse['status'], + data: unknown, + ) { + const call = this.rpcCalls.get(callId); + if (!call) { + return; + } + if (status === 'success') { + call.resolve(data); + } else { + call.reject(typeof data === 'string' ? new Error(data) : data); + } + } + + buildRpcCallObject(taskId: string) { + const rpcObject: RPCCallObject = {}; + for (const r of RPC_ALLOW_LIST) { + const splitPath = r.split('.'); + let obj = rpcObject; + + splitPath.forEach((s, index) => { + if (index !== splitPath.length - 1) { + obj[s] = {}; + obj = obj[s]; + return; + } + obj[s] = async (...args: unknown[]) => this.makeRpcCall(taskId, r, args); + }); + } + return rpcObject; + } +} diff --git a/packages/@n8n/task-runner-node-js/tsconfig.build.json b/packages/@n8n/task-runner-node-js/tsconfig.build.json new file mode 100644 index 0000000000..928d2f4d41 --- /dev/null +++ b/packages/@n8n/task-runner-node-js/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": ["./tsconfig.json", "../../../tsconfig.build.json"], + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/build.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "exclude": ["test/**", "src/**/__tests__/**"] +} diff --git a/packages/@n8n/task-runner-node-js/tsconfig.json b/packages/@n8n/task-runner-node-js/tsconfig.json new file mode 100644 index 0000000000..06f51b39cf --- /dev/null +++ b/packages/@n8n/task-runner-node-js/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": ["../../../tsconfig.json", "../../../tsconfig.backend.json"], + "compilerOptions": { + "rootDir": ".", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "baseUrl": "src", + "paths": { + "@/*": ["./*"] + }, + "tsBuildInfoFile": "dist/typecheck.tsbuildinfo" + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 1b33ba70a4..187e4da688 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -92,6 +92,7 @@ "@n8n/localtunnel": "3.0.0", "@n8n/n8n-nodes-langchain": "workspace:*", "@n8n/permissions": "workspace:*", + "@n8n/task-runner": "workspace:*", "@n8n/typeorm": "0.3.20-12", "@n8n_io/ai-assistant-sdk": "1.9.4", "@n8n_io/license-sdk": "2.13.1", diff --git a/packages/cli/src/abstract-server.ts b/packages/cli/src/abstract-server.ts index 95ecaccdc5..7f9cac807e 100644 --- a/packages/cli/src/abstract-server.ts +++ b/packages/cli/src/abstract-server.ts @@ -119,6 +119,8 @@ export abstract class AbstractServer { protected setupPushServer() {} + protected setupRunnerServer() {} + private async setupHealthCheck() { // main health check should not care about DB connections this.app.get('/healthz', async (_req, res) => { @@ -182,6 +184,10 @@ export abstract class AbstractServer { if (!inTest) { await this.setupErrorHandlers(); this.setupPushServer(); + + if (!this.globalConfig.taskRunners.disabled) { + this.setupRunnerServer(); + } } this.setupCommonMiddlewares(); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 3c89c6b90e..755eecec57 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -21,6 +21,8 @@ import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus' import { EventService } from '@/events/event.service'; import { ExecutionService } from '@/executions/execution.service'; import { License } from '@/license'; +import { SingleMainTaskManager } from '@/runners/task-managers/single-main-task-manager'; +import { TaskManager } from '@/runners/task-managers/task-manager'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { Server } from '@/server'; import { OrchestrationHandlerMainService } from '@/services/orchestration/main/orchestration.handler.main.service'; @@ -220,6 +222,10 @@ export class Start extends BaseCommand { if (!this.globalConfig.endpoints.disableUi) { await this.generateStaticAssets(); } + + if (!this.globalConfig.taskRunners.disabled) { + Container.set(TaskManager, new SingleMainTaskManager()); + } } async initOrchestration() { diff --git a/packages/cli/src/runners/__tests__/task-broker.test.ts b/packages/cli/src/runners/__tests__/task-broker.test.ts new file mode 100644 index 0000000000..f5b91a3f2c --- /dev/null +++ b/packages/cli/src/runners/__tests__/task-broker.test.ts @@ -0,0 +1,504 @@ +import { mock } from 'jest-mock-extended'; + +import { TaskRejectError } from '../errors'; +import type { RunnerMessage, TaskResultData } from '../runner-types'; +import { TaskBroker } from '../task-broker.service'; +import type { TaskOffer, TaskRequest, TaskRunner } from '../task-broker.service'; + +describe('TaskBroker', () => { + let taskBroker: TaskBroker; + + beforeEach(() => { + taskBroker = new TaskBroker(mock()); + jest.restoreAllMocks(); + }); + + describe('expireTasks', () => { + it('should remove expired task offers and keep valid task offers', () => { + const now = process.hrtime.bigint(); + + const validOffer: TaskOffer = { + offerId: 'valid', + runnerId: 'runner1', + taskType: 'taskType1', + validFor: 1000, + validUntil: now + BigInt(1000 * 1_000_000), // 1 second in the future + }; + + const expiredOffer1: TaskOffer = { + offerId: 'expired1', + runnerId: 'runner2', + taskType: 'taskType1', + validFor: 1000, + validUntil: now - BigInt(1000 * 1_000_000), // 1 second in the past + }; + + const expiredOffer2: TaskOffer = { + offerId: 'expired2', + runnerId: 'runner3', + taskType: 'taskType1', + validFor: 2000, + validUntil: now - BigInt(2000 * 1_000_000), // 2 seconds in the past + }; + + taskBroker.setPendingTaskOffers([validOffer, expiredOffer1, expiredOffer2]); + + taskBroker.expireTasks(); + + const offers = taskBroker.getPendingTaskOffers(); + + expect(offers).toHaveLength(1); + expect(offers[0]).toEqual(validOffer); + }); + }); + + describe('registerRunner', () => { + it('should add a runner to known runners', () => { + const runnerId = 'runner1'; + const runner = mock({ id: runnerId }); + const messageCallback = jest.fn(); + + taskBroker.registerRunner(runner, messageCallback); + + const knownRunners = taskBroker.getKnownRunners(); + const runnerIds = [...knownRunners.keys()]; + + expect(runnerIds).toHaveLength(1); + expect(runnerIds[0]).toEqual(runnerId); + + expect(knownRunners.get(runnerId)?.runner).toEqual(runner); + expect(knownRunners.get(runnerId)?.messageCallback).toEqual(messageCallback); + }); + }); + + describe('registerRequester', () => { + it('should add a requester to known requesters', () => { + const requesterId = 'requester1'; + const messageCallback = jest.fn(); + + taskBroker.registerRequester(requesterId, messageCallback); + + const knownRequesters = taskBroker.getKnownRequesters(); + const requesterIds = [...knownRequesters.keys()]; + + expect(requesterIds).toHaveLength(1); + expect(requesterIds[0]).toEqual(requesterId); + + expect(knownRequesters.get(requesterId)).toEqual(messageCallback); + }); + }); + + describe('deregisterRunner', () => { + it('should remove a runner from known runners', () => { + const runnerId = 'runner1'; + const runner = mock({ id: runnerId }); + const messageCallback = jest.fn(); + + taskBroker.registerRunner(runner, messageCallback); + taskBroker.deregisterRunner(runnerId); + + const knownRunners = taskBroker.getKnownRunners(); + const runnerIds = Object.keys(knownRunners); + + expect(runnerIds).toHaveLength(0); + }); + }); + + describe('deregisterRequester', () => { + it('should remove a requester from known requesters', () => { + const requesterId = 'requester1'; + const messageCallback = jest.fn(); + + taskBroker.registerRequester(requesterId, messageCallback); + taskBroker.deregisterRequester(requesterId); + + const knownRequesters = taskBroker.getKnownRequesters(); + const requesterIds = Object.keys(knownRequesters); + + expect(requesterIds).toHaveLength(0); + }); + }); + + describe('taskRequested', () => { + it('should match a pending offer to an incoming request', async () => { + const now = process.hrtime.bigint(); + + const offer: TaskOffer = { + offerId: 'offer1', + runnerId: 'runner1', + taskType: 'taskType1', + validFor: 1000, + validUntil: now + BigInt(1000 * 1_000_000), + }; + + taskBroker.setPendingTaskOffers([offer]); + + const request: TaskRequest = { + requestId: 'request1', + requesterId: 'requester1', + taskType: 'taskType1', + }; + + jest.spyOn(taskBroker, 'acceptOffer').mockResolvedValue(); // allow Jest to exit cleanly + + taskBroker.taskRequested(request); + + expect(taskBroker.acceptOffer).toHaveBeenCalled(); + expect(taskBroker.getPendingTaskOffers()).toHaveLength(0); + }); + }); + + describe('taskOffered', () => { + it('should match a pending request to an incoming offer', () => { + const now = process.hrtime.bigint(); + + const request: TaskRequest = { + requestId: 'request1', + requesterId: 'requester1', + taskType: 'taskType1', + acceptInProgress: false, + }; + + taskBroker.setPendingTaskRequests([request]); + + const offer: TaskOffer = { + offerId: 'offer1', + runnerId: 'runner1', + taskType: 'taskType1', + validFor: 1000, + validUntil: now + BigInt(1000 * 1_000_000), + }; + + jest.spyOn(taskBroker, 'acceptOffer').mockResolvedValue(); // allow Jest to exit cleanly + + taskBroker.taskOffered(offer); + + expect(taskBroker.acceptOffer).toHaveBeenCalled(); + expect(taskBroker.getPendingTaskOffers()).toHaveLength(0); + }); + }); + + describe('settleTasks', () => { + it('should match task offers with task requests by task type', () => { + const now = process.hrtime.bigint(); + + const offer1: TaskOffer = { + offerId: 'offer1', + runnerId: 'runner1', + taskType: 'taskType1', + validFor: 1000, + validUntil: now + BigInt(1000 * 1_000_000), + }; + + const offer2: TaskOffer = { + offerId: 'offer2', + runnerId: 'runner2', + taskType: 'taskType2', + validFor: 1000, + validUntil: now + BigInt(1000 * 1_000_000), + }; + + const request1: TaskRequest = { + requestId: 'request1', + requesterId: 'requester1', + taskType: 'taskType1', + acceptInProgress: false, + }; + + const request2: TaskRequest = { + requestId: 'request2', + requesterId: 'requester2', + taskType: 'taskType2', + acceptInProgress: false, + }; + + const request3: TaskRequest = { + requestId: 'request3', + requesterId: 'requester3', + taskType: 'taskType3', // will have no match + acceptInProgress: false, + }; + + taskBroker.setPendingTaskOffers([offer1, offer2]); + taskBroker.setPendingTaskRequests([request1, request2, request3]); + + const acceptOfferSpy = jest.spyOn(taskBroker, 'acceptOffer').mockResolvedValue(); + + taskBroker.settleTasks(); + + expect(acceptOfferSpy).toHaveBeenCalledTimes(2); + expect(acceptOfferSpy).toHaveBeenCalledWith(offer1, request1); + expect(acceptOfferSpy).toHaveBeenCalledWith(offer2, request2); + + const remainingOffers = taskBroker.getPendingTaskOffers(); + expect(remainingOffers).toHaveLength(0); + }); + + it('should not match a request whose acceptance is in progress', () => { + const now = process.hrtime.bigint(); + + const offer: TaskOffer = { + offerId: 'offer1', + runnerId: 'runner1', + taskType: 'taskType1', + validFor: 1000, + validUntil: now + BigInt(1000 * 1_000_000), + }; + + const request: TaskRequest = { + requestId: 'request1', + requesterId: 'requester1', + taskType: 'taskType1', + acceptInProgress: true, + }; + + taskBroker.setPendingTaskOffers([offer]); + taskBroker.setPendingTaskRequests([request]); + + const acceptOfferSpy = jest.spyOn(taskBroker, 'acceptOffer').mockResolvedValue(); + + taskBroker.settleTasks(); + + expect(acceptOfferSpy).not.toHaveBeenCalled(); + + const remainingOffers = taskBroker.getPendingTaskOffers(); + expect(remainingOffers).toHaveLength(1); + expect(remainingOffers[0]).toEqual(offer); + + const remainingRequests = taskBroker.getPendingTaskRequests(); + expect(remainingRequests).toHaveLength(1); + expect(remainingRequests[0]).toEqual(request); + }); + + it('should expire tasks before settling', () => { + const now = process.hrtime.bigint(); + + const validOffer: TaskOffer = { + offerId: 'valid', + runnerId: 'runner1', + taskType: 'taskType1', + validFor: 1000, + validUntil: now + BigInt(1000 * 1_000_000), // 1 second in the future + }; + + const expiredOffer: TaskOffer = { + offerId: 'expired', + runnerId: 'runner2', + taskType: 'taskType2', // will be removed before matching + validFor: 1000, + validUntil: now - BigInt(1000 * 1_000_000), // 1 second in the past + }; + + const request1: TaskRequest = { + requestId: 'request1', + requesterId: 'requester1', + taskType: 'taskType1', + acceptInProgress: false, + }; + + const request2: TaskRequest = { + requestId: 'request2', + requesterId: 'requester2', + taskType: 'taskType2', + acceptInProgress: false, + }; + + taskBroker.setPendingTaskOffers([validOffer, expiredOffer]); + taskBroker.setPendingTaskRequests([request1, request2]); + + const acceptOfferSpy = jest.spyOn(taskBroker, 'acceptOffer').mockResolvedValue(); + + taskBroker.settleTasks(); + + expect(acceptOfferSpy).toHaveBeenCalledTimes(1); + expect(acceptOfferSpy).toHaveBeenCalledWith(validOffer, request1); + + const remainingOffers = taskBroker.getPendingTaskOffers(); + expect(remainingOffers).toHaveLength(0); + }); + }); + + describe('onRunnerMessage', () => { + it('should handle `runner:taskaccepted` message', async () => { + const runnerId = 'runner1'; + const taskId = 'task1'; + + const message: RunnerMessage.ToN8n.TaskAccepted = { + type: 'runner:taskaccepted', + taskId, + }; + + const accept = jest.fn(); + const reject = jest.fn(); + + taskBroker.setRunnerAcceptRejects({ [taskId]: { accept, reject } }); + taskBroker.registerRunner(mock({ id: runnerId }), jest.fn()); + + await taskBroker.onRunnerMessage(runnerId, message); + + const runnerAcceptRejects = taskBroker.getRunnerAcceptRejects(); + + expect(accept).toHaveBeenCalled(); + expect(reject).not.toHaveBeenCalled(); + expect(runnerAcceptRejects.get(taskId)).toBeUndefined(); + }); + + it('should handle `runner:taskrejected` message', async () => { + const runnerId = 'runner1'; + const taskId = 'task1'; + const rejectionReason = 'Task execution failed'; + + const message: RunnerMessage.ToN8n.TaskRejected = { + type: 'runner:taskrejected', + taskId, + reason: rejectionReason, + }; + + const accept = jest.fn(); + const reject = jest.fn(); + + taskBroker.setRunnerAcceptRejects({ [taskId]: { accept, reject } }); + taskBroker.registerRunner(mock({ id: runnerId }), jest.fn()); + + await taskBroker.onRunnerMessage(runnerId, message); + + const runnerAcceptRejects = taskBroker.getRunnerAcceptRejects(); + + expect(accept).not.toHaveBeenCalled(); + expect(reject).toHaveBeenCalledWith(new TaskRejectError(rejectionReason)); + expect(runnerAcceptRejects.get(taskId)).toBeUndefined(); + }); + + it('should handle `runner:taskdone` message', async () => { + const runnerId = 'runner1'; + const taskId = 'task1'; + const requesterId = 'requester1'; + const data = mock(); + + const message: RunnerMessage.ToN8n.TaskDone = { + type: 'runner:taskdone', + taskId, + data, + }; + + const requesterMessageCallback = jest.fn(); + + taskBroker.registerRunner(mock({ id: runnerId }), jest.fn()); + taskBroker.setTasks({ + [taskId]: { id: taskId, runnerId, requesterId, taskType: 'test' }, + }); + taskBroker.registerRequester(requesterId, requesterMessageCallback); + + await taskBroker.onRunnerMessage(runnerId, message); + + expect(requesterMessageCallback).toHaveBeenCalledWith({ + type: 'broker:taskdone', + taskId, + data, + }); + + expect(taskBroker.getTasks().get(taskId)).toBeUndefined(); + }); + + it('should handle `runner:taskerror` message', async () => { + const runnerId = 'runner1'; + const taskId = 'task1'; + const requesterId = 'requester1'; + const errorMessage = 'Task execution failed'; + + const message: RunnerMessage.ToN8n.TaskError = { + type: 'runner:taskerror', + taskId, + error: errorMessage, + }; + + const requesterMessageCallback = jest.fn(); + + taskBroker.registerRunner(mock({ id: runnerId }), jest.fn()); + taskBroker.setTasks({ + [taskId]: { id: taskId, runnerId, requesterId, taskType: 'test' }, + }); + taskBroker.registerRequester(requesterId, requesterMessageCallback); + + await taskBroker.onRunnerMessage(runnerId, message); + + expect(requesterMessageCallback).toHaveBeenCalledWith({ + type: 'broker:taskerror', + taskId, + error: errorMessage, + }); + + expect(taskBroker.getTasks().get(taskId)).toBeUndefined(); + }); + + it('should handle `runner:taskdatarequest` message', async () => { + const runnerId = 'runner1'; + const taskId = 'task1'; + const requesterId = 'requester1'; + const requestId = 'request1'; + const requestType = 'input'; + const param = 'test_param'; + + const message: RunnerMessage.ToN8n.TaskDataRequest = { + type: 'runner:taskdatarequest', + taskId, + requestId, + requestType, + param, + }; + + const requesterMessageCallback = jest.fn(); + + taskBroker.registerRunner(mock({ id: runnerId }), jest.fn()); + taskBroker.setTasks({ + [taskId]: { id: taskId, runnerId, requesterId, taskType: 'test' }, + }); + taskBroker.registerRequester(requesterId, requesterMessageCallback); + + await taskBroker.onRunnerMessage(runnerId, message); + + expect(requesterMessageCallback).toHaveBeenCalledWith({ + type: 'broker:taskdatarequest', + taskId, + requestId, + requestType, + param, + }); + }); + + it('should handle `runner:rpc` message', async () => { + const runnerId = 'runner1'; + const taskId = 'task1'; + const requesterId = 'requester1'; + const callId = 'call1'; + const rpcName = 'helpers.httpRequestWithAuthentication'; + const rpcParams = ['param1', 'param2']; + + const message: RunnerMessage.ToN8n.RPC = { + type: 'runner:rpc', + taskId, + callId, + name: rpcName, + params: rpcParams, + }; + + const requesterMessageCallback = jest.fn(); + + taskBroker.registerRunner(mock({ id: runnerId }), jest.fn()); + taskBroker.setTasks({ + [taskId]: { id: taskId, runnerId, requesterId, taskType: 'test' }, + }); + taskBroker.registerRequester(requesterId, requesterMessageCallback); + + await taskBroker.onRunnerMessage(runnerId, message); + + expect(requesterMessageCallback).toHaveBeenCalledWith({ + type: 'broker:rpc', + taskId, + callId, + name: rpcName, + params: rpcParams, + }); + }); + }); +}); diff --git a/packages/cli/src/runners/auth/__tests__/task-runner-auth.controller.test.ts b/packages/cli/src/runners/auth/__tests__/task-runner-auth.controller.test.ts new file mode 100644 index 0000000000..7d43f91458 --- /dev/null +++ b/packages/cli/src/runners/auth/__tests__/task-runner-auth.controller.test.ts @@ -0,0 +1,115 @@ +import { GlobalConfig } from '@n8n/config'; +import type { NextFunction, Response } from 'express'; +import { mock } from 'jest-mock-extended'; + +import { CacheService } from '@/services/cache/cache.service'; +import { mockInstance } from '@test/mocking'; + +import { BadRequestError } from '../../../errors/response-errors/bad-request.error'; +import { ForbiddenError } from '../../../errors/response-errors/forbidden.error'; +import type { AuthlessRequest } from '../../../requests'; +import type { TaskRunnerServerInitRequest } from '../../runner-types'; +import { TaskRunnerAuthController } from '../task-runner-auth.controller'; +import { TaskRunnerAuthService } from '../task-runner-auth.service'; + +describe('TaskRunnerAuthController', () => { + const globalConfig = mockInstance(GlobalConfig, { + cache: { + backend: 'memory', + memory: { + maxSize: 1024, + ttl: 9999, + }, + }, + taskRunners: { + authToken: 'random-secret', + }, + }); + const TTL = 100; + const cacheService = new CacheService(globalConfig); + const authService = new TaskRunnerAuthService(globalConfig, cacheService, TTL); + const authController = new TaskRunnerAuthController(authService); + + const createMockGrantTokenReq = (token?: string) => + ({ + body: { + token, + }, + }) as unknown as AuthlessRequest; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createGrantToken', () => { + it('should throw BadRequestError when auth token is missing', async () => { + const req = createMockGrantTokenReq(); + + // Act + await expect(authController.createGrantToken(req)).rejects.toThrowError(BadRequestError); + }); + + it('should throw ForbiddenError when auth token is invalid', async () => { + const req = createMockGrantTokenReq('invalid'); + + // Act + await expect(authController.createGrantToken(req)).rejects.toThrowError(ForbiddenError); + }); + + it('should return rant token when auth token is valid', async () => { + const req = createMockGrantTokenReq('random-secret'); + + // Act + await expect(authController.createGrantToken(req)).resolves.toStrictEqual({ + token: expect.any(String), + }); + }); + }); + + describe('authMiddleware', () => { + const res = mock(); + const next = jest.fn() as NextFunction; + + const createMockReqWithToken = (token?: string) => + mock({ + headers: { + authorization: `Bearer ${token}`, + }, + }); + + beforeEach(() => { + res.status.mockReturnThis(); + }); + + it('should respond with 401 when grant token is missing', async () => { + const req = mock({}); + + await authController.authMiddleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ code: 401, message: 'Unauthorized' }); + }); + + it('should respond with 403 when grant token is invalid', async () => { + const req = createMockReqWithToken('invalid'); + + await authController.authMiddleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ code: 403, message: 'Forbidden' }); + }); + + it('should call next() when grant token is valid', async () => { + const { token: validToken } = await authController.createGrantToken( + createMockGrantTokenReq('random-secret'), + ); + + await authController.authMiddleware(createMockReqWithToken(validToken), res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/runners/auth/__tests__/task-runner-auth.service.test.ts b/packages/cli/src/runners/auth/__tests__/task-runner-auth.service.test.ts new file mode 100644 index 0000000000..b5b35ace29 --- /dev/null +++ b/packages/cli/src/runners/auth/__tests__/task-runner-auth.service.test.ts @@ -0,0 +1,92 @@ +import { GlobalConfig } from '@n8n/config'; +import { sleep } from 'n8n-workflow'; + +import config from '@/config'; +import { CacheService } from '@/services/cache/cache.service'; + +import { mockInstance } from '../../../../test/shared/mocking'; +import { TaskRunnerAuthService } from '../task-runner-auth.service'; + +describe('TaskRunnerAuthService', () => { + config.set('taskRunners.authToken', 'random-secret'); + + const globalConfig = mockInstance(GlobalConfig, { + cache: { + backend: 'memory', + memory: { + maxSize: 1024, + ttl: 9999, + }, + }, + taskRunners: { + authToken: 'random-secret', + }, + }); + const TTL = 100; + const cacheService = new CacheService(globalConfig); + const authService = new TaskRunnerAuthService(globalConfig, cacheService, TTL); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('isValidAuthToken', () => { + it('should be valid for the configured token', () => { + expect(authService.isValidAuthToken('random-secret')); + }); + + it('should be invalid for anything else', () => { + expect(authService.isValidAuthToken('!random-secret')); + }); + }); + + describe('createGrantToken', () => { + it('should generate a random token', async () => { + expect(typeof (await authService.createGrantToken())).toBe('string'); + }); + + it('should store the generated token in cache', async () => { + // Arrange + const cacheSetSpy = jest.spyOn(cacheService, 'set'); + + // Act + const token = await authService.createGrantToken(); + + // Assert + expect(cacheSetSpy).toHaveBeenCalledWith(`grant-token:${token}`, '1', TTL); + }); + }); + + describe('tryConsumeGrantToken', () => { + it('should return false for an invalid grant token', async () => { + expect(await authService.tryConsumeGrantToken('random-secret')).toBe(false); + }); + + it('should return true for a valid grant token', async () => { + // Arrange + const grantToken = await authService.createGrantToken(); + + // Act + expect(await authService.tryConsumeGrantToken(grantToken)).toBe(true); + }); + + it('should return false for a already used grant token', async () => { + // Arrange + const grantToken = await authService.createGrantToken(); + + // Act + expect(await authService.tryConsumeGrantToken(grantToken)).toBe(true); + expect(await authService.tryConsumeGrantToken(grantToken)).toBe(false); + }); + + it('should return false for an expired grant token', async () => { + // Arrange + const grantToken = await authService.createGrantToken(); + + // Act + await sleep(TTL + 1); + + expect(await authService.tryConsumeGrantToken(grantToken)).toBe(false); + }); + }); +}); diff --git a/packages/cli/src/runners/auth/task-runner-auth.controller.ts b/packages/cli/src/runners/auth/task-runner-auth.controller.ts new file mode 100644 index 0000000000..a117dfca0d --- /dev/null +++ b/packages/cli/src/runners/auth/task-runner-auth.controller.ts @@ -0,0 +1,62 @@ +import type { NextFunction, Response } from 'express'; +import { Service } from 'typedi'; + +import type { AuthlessRequest } from '@/requests'; + +import { taskRunnerAuthRequestBodySchema } from './task-runner-auth.schema'; +import { TaskRunnerAuthService } from './task-runner-auth.service'; +import { BadRequestError } from '../../errors/response-errors/bad-request.error'; +import { ForbiddenError } from '../../errors/response-errors/forbidden.error'; +import type { TaskRunnerServerInitRequest } from '../runner-types'; + +/** + * Controller responsible for authenticating Task Runner connections + */ +@Service() +export class TaskRunnerAuthController { + constructor(private readonly taskRunnerAuthService: TaskRunnerAuthService) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.authMiddleware = this.authMiddleware.bind(this); + } + + /** + * Validates the provided auth token and creates and responds with a grant token, + * which can be used to initiate a task runner connection. + */ + async createGrantToken(req: AuthlessRequest) { + const result = await taskRunnerAuthRequestBodySchema.safeParseAsync(req.body); + if (!result.success) { + throw new BadRequestError(result.error.errors[0].code); + } + + const { token: authToken } = result.data; + if (!this.taskRunnerAuthService.isValidAuthToken(authToken)) { + throw new ForbiddenError(); + } + + const grantToken = await this.taskRunnerAuthService.createGrantToken(); + return { + token: grantToken, + }; + } + + /** + * Middleware to authenticate task runner init requests + */ + async authMiddleware(req: TaskRunnerServerInitRequest, res: Response, next: NextFunction) { + const authHeader = req.headers.authorization; + if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ code: 401, message: 'Unauthorized' }); + return; + } + + const grantToken = authHeader.slice('Bearer '.length); + const isConsumed = await this.taskRunnerAuthService.tryConsumeGrantToken(grantToken); + if (!isConsumed) { + res.status(403).json({ code: 403, message: 'Forbidden' }); + return; + } + + next(); + } +} diff --git a/packages/cli/src/runners/auth/task-runner-auth.schema.ts b/packages/cli/src/runners/auth/task-runner-auth.schema.ts new file mode 100644 index 0000000000..c3ab2c17f2 --- /dev/null +++ b/packages/cli/src/runners/auth/task-runner-auth.schema.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const taskRunnerAuthRequestBodySchema = z.object({ + token: z.string().min(1), +}); diff --git a/packages/cli/src/runners/auth/task-runner-auth.service.ts b/packages/cli/src/runners/auth/task-runner-auth.service.ts new file mode 100644 index 0000000000..5907cf6678 --- /dev/null +++ b/packages/cli/src/runners/auth/task-runner-auth.service.ts @@ -0,0 +1,56 @@ +import { GlobalConfig } from '@n8n/config'; +import { randomBytes } from 'crypto'; +import { Service } from 'typedi'; + +import { Time } from '@/constants'; +import { CacheService } from '@/services/cache/cache.service'; + +const GRANT_TOKEN_TTL = 15 * Time.seconds.toMilliseconds; + +@Service() +export class TaskRunnerAuthService { + constructor( + private readonly globalConfig: GlobalConfig, + private readonly cacheService: CacheService, + // For unit testing purposes + private readonly grantTokenTtl = GRANT_TOKEN_TTL, + ) {} + + isValidAuthToken(token: string) { + return token === this.globalConfig.taskRunners.authToken; + } + + /** + * @returns grant token that can be used to establish a task runner connection + */ + async createGrantToken() { + const grantToken = this.generateGrantToken(); + + const key = this.cacheKeyForGrantToken(grantToken); + await this.cacheService.set(key, '1', this.grantTokenTtl); + + return grantToken; + } + + /** + * Checks if the given `grantToken` is a valid token and marks it as + * used. + */ + async tryConsumeGrantToken(grantToken: string) { + const key = this.cacheKeyForGrantToken(grantToken); + const consumed = await this.cacheService.get(key); + // Not found from cache --> Invalid token + if (consumed === undefined) return false; + + await this.cacheService.delete(key); + return true; + } + + private generateGrantToken() { + return randomBytes(32).toString('hex'); + } + + private cacheKeyForGrantToken(grantToken: string) { + return `grant-token:${grantToken}`; + } +} diff --git a/packages/cli/src/runners/errors.ts b/packages/cli/src/runners/errors.ts new file mode 100644 index 0000000000..cc53e18fd4 --- /dev/null +++ b/packages/cli/src/runners/errors.ts @@ -0,0 +1,9 @@ +import { ApplicationError } from 'n8n-workflow'; + +export class TaskRejectError extends ApplicationError { + constructor(public reason: string) { + super(`Task rejected with reason: ${reason}`, { level: 'info' }); + } +} + +export class TaskError extends ApplicationError {} diff --git a/packages/cli/src/runners/runner-types.ts b/packages/cli/src/runners/runner-types.ts new file mode 100644 index 0000000000..f615754e02 --- /dev/null +++ b/packages/cli/src/runners/runner-types.ts @@ -0,0 +1,243 @@ +import type { Response } from 'express'; +import type { INodeExecutionData } from 'n8n-workflow'; +import type WebSocket from 'ws'; + +import type { TaskRunner } from './task-broker.service'; +import type { AuthlessRequest } from '../requests'; + +export type DataRequestType = 'input' | 'node' | 'all'; + +export interface TaskResultData { + result: INodeExecutionData[]; + customData?: Record; +} + +export interface TaskRunnerServerInitRequest + extends AuthlessRequest<{}, {}, {}, { id: TaskRunner['id']; token?: string }> { + ws: WebSocket; +} + +export type TaskRunnerServerInitResponse = Response & { req: TaskRunnerServerInitRequest }; + +export namespace N8nMessage { + export namespace ToRunner { + export interface InfoRequest { + type: 'broker:inforequest'; + } + + export interface RunnerRegistered { + type: 'broker:runnerregistered'; + } + + export interface TaskOfferAccept { + type: 'broker:taskofferaccept'; + taskId: string; + offerId: string; + } + + export interface TaskCancel { + type: 'broker:taskcancel'; + taskId: string; + reason: string; + } + + export interface TaskSettings { + type: 'broker:tasksettings'; + taskId: string; + settings: unknown; + } + + export interface RPCResponse { + type: 'broker:rpcresponse'; + callId: string; + taskId: string; + status: 'success' | 'error'; + data: unknown; + } + + export interface TaskDataResponse { + type: 'broker:taskdataresponse'; + taskId: string; + requestId: string; + data: unknown; + } + + export type All = + | InfoRequest + | TaskOfferAccept + | TaskCancel + | TaskSettings + | RunnerRegistered + | RPCResponse + | TaskDataResponse; + } + + export namespace ToRequester { + export interface TaskReady { + type: 'broker:taskready'; + requestId: string; + taskId: string; + } + + export interface TaskDone { + type: 'broker:taskdone'; + taskId: string; + data: TaskResultData; + } + + export interface TaskError { + type: 'broker:taskerror'; + taskId: string; + error: unknown; + } + + export interface TaskDataRequest { + type: 'broker:taskdatarequest'; + taskId: string; + requestId: string; + requestType: DataRequestType; + param?: string; + } + + export interface RPC { + type: 'broker:rpc'; + callId: string; + taskId: string; + name: (typeof RPC_ALLOW_LIST)[number]; + params: unknown[]; + } + + export type All = TaskReady | TaskDone | TaskError | TaskDataRequest | RPC; + } +} + +export namespace RequesterMessage { + export namespace ToN8n { + export interface TaskSettings { + type: 'requester:tasksettings'; + taskId: string; + settings: unknown; + } + + export interface TaskCancel { + type: 'requester:taskcancel'; + taskId: string; + reason: string; + } + + export interface TaskDataResponse { + type: 'requester:taskdataresponse'; + taskId: string; + requestId: string; + data: unknown; + } + + export interface RPCResponse { + type: 'requester:rpcresponse'; + taskId: string; + callId: string; + status: 'success' | 'error'; + data: unknown; + } + + export interface TaskRequest { + type: 'requester:taskrequest'; + requestId: string; + taskType: string; + } + + export type All = TaskSettings | TaskCancel | RPCResponse | TaskDataResponse | TaskRequest; + } +} + +export namespace RunnerMessage { + export namespace ToN8n { + export interface Info { + type: 'runner:info'; + name: string; + types: string[]; + } + + export interface TaskAccepted { + type: 'runner:taskaccepted'; + taskId: string; + } + + export interface TaskRejected { + type: 'runner:taskrejected'; + taskId: string; + reason: string; + } + + export interface TaskDone { + type: 'runner:taskdone'; + taskId: string; + data: TaskResultData; + } + + export interface TaskError { + type: 'runner:taskerror'; + taskId: string; + error: unknown; + } + + export interface TaskOffer { + type: 'runner:taskoffer'; + offerId: string; + taskType: string; + validFor: number; + } + + export interface TaskDataRequest { + type: 'runner:taskdatarequest'; + taskId: string; + requestId: string; + requestType: DataRequestType; + param?: string; + } + + export interface RPC { + type: 'runner:rpc'; + callId: string; + taskId: string; + name: (typeof RPC_ALLOW_LIST)[number]; + params: unknown[]; + } + + export type All = + | Info + | TaskDone + | TaskError + | TaskAccepted + | TaskRejected + | TaskOffer + | RPC + | TaskDataRequest; + } +} + +export const RPC_ALLOW_LIST = [ + 'logNodeOutput', + 'helpers.httpRequestWithAuthentication', + 'helpers.requestWithAuthenticationPaginated', + // "helpers.normalizeItems" + // "helpers.constructExecutionMetaData" + // "helpers.assertBinaryData" + 'helpers.getBinaryDataBuffer', + // "helpers.copyInputItems" + // "helpers.returnJsonArray" + 'helpers.getSSHClient', + 'helpers.createReadStream', + // "helpers.getStoragePath" + 'helpers.writeContentToFile', + 'helpers.prepareBinaryData', + 'helpers.setBinaryDataBuffer', + 'helpers.copyBinaryFile', + 'helpers.binaryToBuffer', + // "helpers.binaryToString" + // "helpers.getBinaryPath" + 'helpers.getBinaryStream', + 'helpers.getBinaryMetadata', + 'helpers.createDeferredPromise', + 'helpers.httpRequest', +] as const; diff --git a/packages/cli/src/runners/runner-ws-server.ts b/packages/cli/src/runners/runner-ws-server.ts new file mode 100644 index 0000000000..e9b824499d --- /dev/null +++ b/packages/cli/src/runners/runner-ws-server.ts @@ -0,0 +1,188 @@ +import { GlobalConfig } from '@n8n/config'; +import type { Application } from 'express'; +import { ServerResponse, type Server } from 'http'; +import { ApplicationError } from 'n8n-workflow'; +import type { Socket } from 'net'; +import Container, { Service } from 'typedi'; +import { parse as parseUrl } from 'url'; +import { Server as WSServer } from 'ws'; +import type WebSocket from 'ws'; + +import { Logger } from '@/logging/logger.service'; +import { send } from '@/response-helper'; +import { TaskRunnerAuthController } from '@/runners/auth/task-runner-auth.controller'; + +import type { + RunnerMessage, + N8nMessage, + TaskRunnerServerInitRequest, + TaskRunnerServerInitResponse, +} from './runner-types'; +import { TaskBroker, type MessageCallback, type TaskRunner } from './task-broker.service'; + +function heartbeat(this: WebSocket) { + this.isAlive = true; +} + +function getEndpointBasePath(restEndpoint: string) { + const globalConfig = Container.get(GlobalConfig); + + let path = globalConfig.taskRunners.path; + if (path.startsWith('/')) { + path = path.slice(1); + } + if (path.endsWith('/')) { + path = path.slice(-1); + } + + return `/${restEndpoint}/${path}`; +} + +function getWsEndpoint(restEndpoint: string) { + return `${getEndpointBasePath(restEndpoint)}/_ws`; +} + +@Service() +export class TaskRunnerService { + runnerConnections: Record = {}; + + constructor( + private readonly logger: Logger, + private readonly taskBroker: TaskBroker, + ) {} + + sendMessage(id: TaskRunner['id'], message: N8nMessage.ToRunner.All) { + this.runnerConnections[id]?.send(JSON.stringify(message)); + } + + add(id: TaskRunner['id'], connection: WebSocket) { + connection.isAlive = true; + connection.on('pong', heartbeat); + + let isConnected = false; + + const onMessage = (data: WebSocket.RawData) => { + try { + const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data); + + const message: RunnerMessage.ToN8n.All = JSON.parse( + buffer.toString('utf8'), + ) as RunnerMessage.ToN8n.All; + + if (!isConnected && message.type !== 'runner:info') { + return; + } else if (!isConnected && message.type === 'runner:info') { + this.removeConnection(id); + isConnected = true; + + this.runnerConnections[id] = connection; + + this.taskBroker.registerRunner( + { + id, + taskTypes: message.types, + lastSeen: new Date(), + name: message.name, + }, + this.sendMessage.bind(this, id) as MessageCallback, + ); + + this.sendMessage(id, { type: 'broker:runnerregistered' }); + + this.logger.info(`Runner "${message.name}"(${id}) has been registered`); + return; + } + + void this.taskBroker.onRunnerMessage(id, message); + } catch (error) { + this.logger.error(`Couldn't parse message from runner "${id}"`, { + error: error as unknown, + id, + data, + }); + } + }; + + // Makes sure to remove the session if the connection is closed + connection.once('close', () => { + connection.off('pong', heartbeat); + connection.off('message', onMessage); + this.removeConnection(id); + }); + + connection.on('message', onMessage); + connection.send( + JSON.stringify({ type: 'broker:inforequest' } as N8nMessage.ToRunner.InfoRequest), + ); + } + + removeConnection(id: TaskRunner['id']) { + if (id in this.runnerConnections) { + this.taskBroker.deregisterRunner(id); + this.runnerConnections[id].close(); + delete this.runnerConnections[id]; + } + } + + handleRequest(req: TaskRunnerServerInitRequest, _res: TaskRunnerServerInitResponse) { + this.add(req.query.id, req.ws); + } +} + +// Checks for upgrade requests on the runners path and upgrades the connection +// then, passes the request back to the app to handle the routing +export const setupRunnerServer = (restEndpoint: string, server: Server, app: Application) => { + const globalConfig = Container.get(GlobalConfig); + const { authToken } = globalConfig.taskRunners; + + if (!authToken) { + throw new ApplicationError( + 'Authentication token must be configured when task runners are enabled. Use N8N_RUNNERS_AUTH_TOKEN environment variable to set it.', + ); + } + + const endpoint = getWsEndpoint(restEndpoint); + const wsServer = new WSServer({ noServer: true }); + server.on('upgrade', (request: TaskRunnerServerInitRequest, socket: Socket, head) => { + if (parseUrl(request.url).pathname !== endpoint) { + // We can't close the connection here since the Push connections + // are using the same HTTP server and upgrade requests and this + // gets triggered for both + return; + } + + wsServer.handleUpgrade(request, socket, head, (ws) => { + request.ws = ws; + + const response = new ServerResponse(request); + response.writeHead = (statusCode) => { + if (statusCode > 200) ws.close(); + return response; + }; + + // @ts-expect-error Hidden API? + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + app.handle(request, response); + }); + }); +}; + +export const setupRunnerHandler = (restEndpoint: string, app: Application) => { + const wsEndpoint = getWsEndpoint(restEndpoint); + const authEndpoint = `${getEndpointBasePath(restEndpoint)}/auth`; + + const taskRunnerAuthController = Container.get(TaskRunnerAuthController); + const taskRunnerService = Container.get(TaskRunnerService); + app.use( + wsEndpoint, + // eslint-disable-next-line @typescript-eslint/unbound-method + taskRunnerAuthController.authMiddleware, + (req: TaskRunnerServerInitRequest, res: TaskRunnerServerInitResponse) => + taskRunnerService.handleRequest(req, res), + ); + + app.post( + authEndpoint, + send(async (req) => await taskRunnerAuthController.createGrantToken(req)), + ); +}; diff --git a/packages/cli/src/runners/task-broker.service.ts b/packages/cli/src/runners/task-broker.service.ts new file mode 100644 index 0000000000..829910b468 --- /dev/null +++ b/packages/cli/src/runners/task-broker.service.ts @@ -0,0 +1,553 @@ +import { ApplicationError } from 'n8n-workflow'; +import { nanoid } from 'nanoid'; +import { Service } from 'typedi'; + +import { Logger } from '@/logging/logger.service'; + +import { TaskRejectError } from './errors'; +import type { N8nMessage, RunnerMessage, RequesterMessage, TaskResultData } from './runner-types'; + +export interface TaskRunner { + id: string; + name?: string; + taskTypes: string[]; + lastSeen: Date; +} + +export interface Task { + id: string; + runnerId: TaskRunner['id']; + requesterId: string; + taskType: string; +} + +export interface TaskOffer { + offerId: string; + runnerId: TaskRunner['id']; + taskType: string; + validFor: number; + validUntil: bigint; +} + +export interface TaskRequest { + requestId: string; + requesterId: string; + taskType: string; + + acceptInProgress?: boolean; +} + +export type MessageCallback = (message: N8nMessage.ToRunner.All) => Promise | void; +export type RequesterMessageCallback = ( + message: N8nMessage.ToRequester.All, +) => Promise | void; + +type RunnerAcceptCallback = () => void; +type RequesterAcceptCallback = (settings: RequesterMessage.ToN8n.TaskSettings['settings']) => void; +type TaskRejectCallback = (reason: TaskRejectError) => void; + +@Service() +export class TaskBroker { + private knownRunners: Map< + TaskRunner['id'], + { runner: TaskRunner; messageCallback: MessageCallback } + > = new Map(); + + private requesters: Map = new Map(); + + private tasks: Map = new Map(); + + private runnerAcceptRejects: Map< + Task['id'], + { accept: RunnerAcceptCallback; reject: TaskRejectCallback } + > = new Map(); + + private requesterAcceptRejects: Map< + Task['id'], + { accept: RequesterAcceptCallback; reject: TaskRejectCallback } + > = new Map(); + + private pendingTaskOffers: TaskOffer[] = []; + + private pendingTaskRequests: TaskRequest[] = []; + + constructor(private readonly logger: Logger) {} + + expireTasks() { + const now = process.hrtime.bigint(); + const invalidOffers: number[] = []; + for (let i = 0; i < this.pendingTaskOffers.length; i++) { + if (this.pendingTaskOffers[i].validUntil < now) { + invalidOffers.push(i); + } + } + + // We reverse the list so the later indexes are valid after deleting earlier ones + invalidOffers.reverse().forEach((i) => this.pendingTaskOffers.splice(i, 1)); + } + + registerRunner(runner: TaskRunner, messageCallback: MessageCallback) { + this.knownRunners.set(runner.id, { runner, messageCallback }); + } + + deregisterRunner(runnerId: string) { + this.knownRunners.delete(runnerId); + } + + registerRequester(requesterId: string, messageCallback: RequesterMessageCallback) { + this.requesters.set(requesterId, messageCallback); + } + + deregisterRequester(requesterId: string) { + this.requesters.delete(requesterId); + } + + private async messageRunner(runnerId: TaskRunner['id'], message: N8nMessage.ToRunner.All) { + await this.knownRunners.get(runnerId)?.messageCallback(message); + } + + private async messageRequester(requesterId: string, message: N8nMessage.ToRequester.All) { + await this.requesters.get(requesterId)?.(message); + } + + async onRunnerMessage(runnerId: TaskRunner['id'], message: RunnerMessage.ToN8n.All) { + const runner = this.knownRunners.get(runnerId); + if (!runner) { + return; + } + switch (message.type) { + case 'runner:taskaccepted': + this.handleRunnerAccept(message.taskId); + break; + case 'runner:taskrejected': + this.handleRunnerReject(message.taskId, message.reason); + break; + case 'runner:taskoffer': + this.taskOffered({ + runnerId, + taskType: message.taskType, + offerId: message.offerId, + validFor: message.validFor, + validUntil: process.hrtime.bigint() + BigInt(message.validFor * 1_000_000), + }); + break; + case 'runner:taskdone': + await this.taskDoneHandler(message.taskId, message.data); + break; + case 'runner:taskerror': + await this.taskErrorHandler(message.taskId, message.error); + break; + case 'runner:taskdatarequest': + await this.handleDataRequest( + message.taskId, + message.requestId, + message.requestType, + message.param, + ); + break; + + case 'runner:rpc': + await this.handleRpcRequest(message.taskId, message.callId, message.name, message.params); + break; + // Already handled + case 'runner:info': + break; + } + } + + async handleRpcRequest( + taskId: Task['id'], + callId: string, + name: RunnerMessage.ToN8n.RPC['name'], + params: unknown[], + ) { + const task = this.tasks.get(taskId); + if (!task) { + return; + } + await this.messageRequester(task.requesterId, { + type: 'broker:rpc', + taskId, + callId, + name, + params, + }); + } + + handleRunnerAccept(taskId: Task['id']) { + const acceptReject = this.runnerAcceptRejects.get(taskId); + if (acceptReject) { + acceptReject.accept(); + this.runnerAcceptRejects.delete(taskId); + } + } + + handleRunnerReject(taskId: Task['id'], reason: string) { + const acceptReject = this.runnerAcceptRejects.get(taskId); + if (acceptReject) { + acceptReject.reject(new TaskRejectError(reason)); + this.runnerAcceptRejects.delete(taskId); + } + } + + async handleDataRequest( + taskId: Task['id'], + requestId: RunnerMessage.ToN8n.TaskDataRequest['requestId'], + requestType: RunnerMessage.ToN8n.TaskDataRequest['requestType'], + param?: string, + ) { + const task = this.tasks.get(taskId); + if (!task) { + return; + } + await this.messageRequester(task.requesterId, { + type: 'broker:taskdatarequest', + taskId, + requestId, + requestType, + param, + }); + } + + async handleResponse( + taskId: Task['id'], + requestId: RunnerMessage.ToN8n.TaskDataRequest['requestId'], + data: unknown, + ) { + const task = this.tasks.get(taskId); + if (!task) { + return; + } + await this.messageRunner(task.requesterId, { + type: 'broker:taskdataresponse', + taskId, + requestId, + data, + }); + } + + async onRequesterMessage(requesterId: string, message: RequesterMessage.ToN8n.All) { + switch (message.type) { + case 'requester:tasksettings': + this.handleRequesterAccept(message.taskId, message.settings); + break; + case 'requester:taskcancel': + await this.cancelTask(message.taskId, message.reason); + break; + case 'requester:taskrequest': + this.taskRequested({ + taskType: message.taskType, + requestId: message.requestId, + requesterId, + }); + break; + case 'requester:taskdataresponse': + await this.handleRequesterDataResponse(message.taskId, message.requestId, message.data); + break; + case 'requester:rpcresponse': + await this.handleRequesterRpcResponse( + message.taskId, + message.callId, + message.status, + message.data, + ); + break; + } + } + + async handleRequesterRpcResponse( + taskId: string, + callId: string, + status: RequesterMessage.ToN8n.RPCResponse['status'], + data: unknown, + ) { + const runner = await this.getRunnerOrFailTask(taskId); + await this.messageRunner(runner.id, { + type: 'broker:rpcresponse', + taskId, + callId, + status, + data, + }); + } + + async handleRequesterDataResponse(taskId: Task['id'], requestId: string, data: unknown) { + const runner = await this.getRunnerOrFailTask(taskId); + + await this.messageRunner(runner.id, { + type: 'broker:taskdataresponse', + taskId, + requestId, + data, + }); + } + + handleRequesterAccept( + taskId: Task['id'], + settings: RequesterMessage.ToN8n.TaskSettings['settings'], + ) { + const acceptReject = this.requesterAcceptRejects.get(taskId); + if (acceptReject) { + acceptReject.accept(settings); + this.requesterAcceptRejects.delete(taskId); + } + } + + handleRequesterReject(taskId: Task['id'], reason: string) { + const acceptReject = this.requesterAcceptRejects.get(taskId); + if (acceptReject) { + acceptReject.reject(new TaskRejectError(reason)); + this.requesterAcceptRejects.delete(taskId); + } + } + + private async cancelTask(taskId: Task['id'], reason: string) { + const task = this.tasks.get(taskId); + if (!task) { + return; + } + this.tasks.delete(taskId); + + await this.messageRunner(task.runnerId, { + type: 'broker:taskcancel', + taskId, + reason, + }); + } + + private async failTask(taskId: Task['id'], reason: string) { + const task = this.tasks.get(taskId); + if (!task) { + return; + } + this.tasks.delete(taskId); + // TODO: special message type? + await this.messageRequester(task.requesterId, { + type: 'broker:taskerror', + taskId, + error: reason, + }); + } + + private async getRunnerOrFailTask(taskId: Task['id']): Promise { + const task = this.tasks.get(taskId); + if (!task) { + throw new ApplicationError(`Cannot find runner, failed to find task (${taskId})`, { + level: 'error', + }); + } + const runner = this.knownRunners.get(task.runnerId); + if (!runner) { + const reason = `Cannot find runner, failed to find runner (${task.runnerId})`; + await this.failTask(taskId, reason); + throw new ApplicationError(reason, { + level: 'error', + }); + } + return runner.runner; + } + + async sendTaskSettings(taskId: Task['id'], settings: unknown) { + const runner = await this.getRunnerOrFailTask(taskId); + await this.messageRunner(runner.id, { + type: 'broker:tasksettings', + taskId, + settings, + }); + } + + async taskDoneHandler(taskId: Task['id'], data: TaskResultData) { + const task = this.tasks.get(taskId); + if (!task) { + return; + } + await this.requesters.get(task.requesterId)?.({ + type: 'broker:taskdone', + taskId: task.id, + data, + }); + this.tasks.delete(task.id); + } + + async taskErrorHandler(taskId: Task['id'], error: unknown) { + const task = this.tasks.get(taskId); + if (!task) { + return; + } + await this.requesters.get(task.requesterId)?.({ + type: 'broker:taskerror', + taskId: task.id, + error, + }); + this.tasks.delete(task.id); + } + + async acceptOffer(offer: TaskOffer, request: TaskRequest): Promise { + const taskId = nanoid(8); + + try { + const acceptPromise = new Promise((resolve, reject) => { + this.runnerAcceptRejects.set(taskId, { accept: resolve as () => void, reject }); + + // TODO: customisable timeout + setTimeout(() => { + reject('Runner timed out'); + }, 2000); + }); + + await this.messageRunner(offer.runnerId, { + type: 'broker:taskofferaccept', + offerId: offer.offerId, + taskId, + }); + + await acceptPromise; + } catch (e) { + request.acceptInProgress = false; + if (e instanceof TaskRejectError) { + this.logger.info(`Task (${taskId}) rejected by Runner with reason "${e.reason}"`); + return; + } + throw e; + } + + const task: Task = { + id: taskId, + taskType: offer.taskType, + runnerId: offer.runnerId, + requesterId: request.requesterId, + }; + + this.tasks.set(taskId, task); + const requestIndex = this.pendingTaskRequests.findIndex( + (r) => r.requestId === request.requestId, + ); + if (requestIndex === -1) { + this.logger.error( + `Failed to find task request (${request.requestId}) after a task was accepted. This shouldn't happen, and might be a race condition.`, + ); + return; + } + this.pendingTaskRequests.splice(requestIndex, 1); + + try { + const acceptPromise = new Promise( + (resolve, reject) => { + this.requesterAcceptRejects.set(taskId, { + accept: resolve as (settings: RequesterMessage.ToN8n.TaskSettings['settings']) => void, + reject, + }); + + // TODO: customisable timeout + setTimeout(() => { + reject('Requester timed out'); + }, 2000); + }, + ); + + await this.messageRequester(request.requesterId, { + type: 'broker:taskready', + requestId: request.requestId, + taskId, + }); + + const settings = await acceptPromise; + await this.sendTaskSettings(task.id, settings); + } catch (e) { + if (e instanceof TaskRejectError) { + await this.cancelTask(task.id, e.reason); + this.logger.info(`Task (${taskId}) rejected by Requester with reason "${e.reason}"`); + return; + } + await this.cancelTask(task.id, 'Unknown reason'); + throw e; + } + } + + // Find matching task offers and requests, then let the runner + // know that an offer has been accepted + // + // *DO NOT MAKE THIS FUNCTION ASYNC* + // This function relies on never yielding. + // If you need to make this function async, you'll need to + // implement some kind of locking for the requests and task + // lists + settleTasks() { + this.expireTasks(); + + for (const request of this.pendingTaskRequests) { + if (request.acceptInProgress) { + continue; + } + const offerIndex = this.pendingTaskOffers.findIndex((o) => o.taskType === request.taskType); + if (offerIndex === -1) { + continue; + } + const offer = this.pendingTaskOffers[offerIndex]; + + request.acceptInProgress = true; + this.pendingTaskOffers.splice(offerIndex, 1); + + void this.acceptOffer(offer, request); + } + } + + taskRequested(request: TaskRequest) { + this.pendingTaskRequests.push(request); + this.settleTasks(); + } + + taskOffered(offer: TaskOffer) { + this.pendingTaskOffers.push(offer); + this.settleTasks(); + } + + /** + * For testing only + */ + + getTasks() { + return this.tasks; + } + + getPendingTaskOffers() { + return this.pendingTaskOffers; + } + + getPendingTaskRequests() { + return this.pendingTaskRequests; + } + + getKnownRunners() { + return this.knownRunners; + } + + getKnownRequesters() { + return this.requesters; + } + + getRunnerAcceptRejects() { + return this.runnerAcceptRejects; + } + + setTasks(tasks: Record) { + this.tasks = new Map(Object.entries(tasks)); + } + + setPendingTaskOffers(pendingTaskOffers: TaskOffer[]) { + this.pendingTaskOffers = pendingTaskOffers; + } + + setPendingTaskRequests(pendingTaskRequests: TaskRequest[]) { + this.pendingTaskRequests = pendingTaskRequests; + } + + setRunnerAcceptRejects( + runnerAcceptRejects: Record< + string, + { accept: RunnerAcceptCallback; reject: TaskRejectCallback } + >, + ) { + this.runnerAcceptRejects = new Map(Object.entries(runnerAcceptRejects)); + } +} diff --git a/packages/cli/src/runners/task-managers/single-main-task-manager.ts b/packages/cli/src/runners/task-managers/single-main-task-manager.ts new file mode 100644 index 0000000000..b5b60df72b --- /dev/null +++ b/packages/cli/src/runners/task-managers/single-main-task-manager.ts @@ -0,0 +1,30 @@ +import Container from 'typedi'; + +import { TaskManager } from './task-manager'; +import type { RequesterMessage } from '../runner-types'; +import type { RequesterMessageCallback } from '../task-broker.service'; +import { TaskBroker } from '../task-broker.service'; + +export class SingleMainTaskManager extends TaskManager { + taskBroker: TaskBroker; + + id: string = 'single-main'; + + constructor() { + super(); + this.registerRequester(); + } + + registerRequester() { + this.taskBroker = Container.get(TaskBroker); + + this.taskBroker.registerRequester( + this.id, + this.onMessage.bind(this) as RequesterMessageCallback, + ); + } + + sendMessage(message: RequesterMessage.ToN8n.All) { + void this.taskBroker.onRequesterMessage(this.id, message); + } +} diff --git a/packages/cli/src/runners/task-managers/task-manager.ts b/packages/cli/src/runners/task-managers/task-manager.ts new file mode 100644 index 0000000000..9f7e492fbe --- /dev/null +++ b/packages/cli/src/runners/task-managers/task-manager.ts @@ -0,0 +1,410 @@ +import { + type IExecuteFunctions, + type Workflow, + type IRunExecutionData, + type INodeExecutionData, + type ITaskDataConnections, + type INode, + type WorkflowParameters, + type INodeParameters, + type WorkflowExecuteMode, + type IExecuteData, + type IDataObject, + type IWorkflowExecuteAdditionalData, +} from 'n8n-workflow'; +import { nanoid } from 'nanoid'; + +import { TaskError } from '@/runners/errors'; + +import { + RPC_ALLOW_LIST, + type TaskResultData, + type N8nMessage, + type RequesterMessage, +} from '../runner-types'; + +export type RequestAccept = (jobId: string) => void; +export type RequestReject = (reason: string) => void; + +export type TaskAccept = (data: TaskResultData) => void; +export type TaskReject = (error: unknown) => void; + +export interface TaskData { + executeFunctions: IExecuteFunctions; + inputData: ITaskDataConnections; + node: INode; + + workflow: Workflow; + runExecutionData: IRunExecutionData; + runIndex: number; + itemIndex: number; + activeNodeName: string; + connectionInputData: INodeExecutionData[]; + siblingParameters: INodeParameters; + mode: WorkflowExecuteMode; + executeData?: IExecuteData; + defaultReturnRunIndex: number; + selfData: IDataObject; + contextNodeName: string; + additionalData: IWorkflowExecuteAdditionalData; +} + +export interface PartialAdditionalData { + executionId?: string; + restartExecutionId?: string; + restApiUrl: string; + instanceBaseUrl: string; + formWaitingBaseUrl: string; + webhookBaseUrl: string; + webhookWaitingBaseUrl: string; + webhookTestBaseUrl: string; + currentNodeParameters?: INodeParameters; + executionTimeoutTimestamp?: number; + userId?: string; + variables: IDataObject; +} + +export interface AllCodeTaskData { + workflow: Omit; + inputData: ITaskDataConnections; + node: INode; + + runExecutionData: IRunExecutionData; + runIndex: number; + itemIndex: number; + activeNodeName: string; + connectionInputData: INodeExecutionData[]; + siblingParameters: INodeParameters; + mode: WorkflowExecuteMode; + executeData?: IExecuteData; + defaultReturnRunIndex: number; + selfData: IDataObject; + contextNodeName: string; + additionalData: PartialAdditionalData; +} + +export interface TaskRequest { + requestId: string; + taskType: string; + settings: unknown; + data: TaskData; +} + +export interface Task { + taskId: string; + settings: unknown; + data: TaskData; +} + +interface ExecuteFunctionObject { + [name: string]: ((...args: unknown[]) => unknown) | ExecuteFunctionObject; +} + +const workflowToParameters = (workflow: Workflow): Omit => { + return { + id: workflow.id, + name: workflow.name, + active: workflow.active, + connections: workflow.connectionsBySourceNode, + nodes: Object.values(workflow.nodes), + pinData: workflow.pinData, + settings: workflow.settings, + staticData: workflow.staticData, + }; +}; + +export class TaskManager { + requestAcceptRejects: Map = new Map(); + + taskAcceptRejects: Map = new Map(); + + pendingRequests: Map = new Map(); + + tasks: Map = new Map(); + + async startTask( + additionalData: IWorkflowExecuteAdditionalData, + taskType: string, + settings: unknown, + executeFunctions: IExecuteFunctions, + inputData: ITaskDataConnections, + node: INode, + workflow: Workflow, + runExecutionData: IRunExecutionData, + runIndex: number, + itemIndex: number, + activeNodeName: string, + connectionInputData: INodeExecutionData[], + siblingParameters: INodeParameters, + mode: WorkflowExecuteMode, + executeData?: IExecuteData, + defaultReturnRunIndex = -1, + selfData: IDataObject = {}, + contextNodeName: string = activeNodeName, + ): Promise { + const data: TaskData = { + workflow, + runExecutionData, + runIndex, + connectionInputData, + inputData, + node, + executeFunctions, + itemIndex, + siblingParameters, + mode, + executeData, + defaultReturnRunIndex, + selfData, + contextNodeName, + activeNodeName, + additionalData, + }; + + const request: TaskRequest = { + requestId: nanoid(), + taskType, + settings, + data, + }; + + this.pendingRequests.set(request.requestId, request); + + const taskIdPromise = new Promise((resolve, reject) => { + this.requestAcceptRejects.set(request.requestId, { + accept: resolve, + reject, + }); + }); + + this.sendMessage({ + type: 'requester:taskrequest', + requestId: request.requestId, + taskType, + }); + + const taskId = await taskIdPromise; + + const task: Task = { + taskId, + data, + settings, + }; + this.tasks.set(task.taskId, task); + + try { + const dataPromise = new Promise((resolve, reject) => { + this.taskAcceptRejects.set(task.taskId, { + accept: resolve, + reject, + }); + }); + + this.sendMessage({ + type: 'requester:tasksettings', + taskId, + settings, + }); + + const resultData = await dataPromise; + // Set custom execution data (`$execution.customData`) if sent + if (resultData.customData) { + Object.entries(resultData.customData).forEach(([k, v]) => { + if (!runExecutionData.resultData.metadata) { + runExecutionData.resultData.metadata = {}; + } + runExecutionData.resultData.metadata[k] = v; + }); + } + return resultData.result as T; + } catch (e) { + if (typeof e === 'string') { + throw new TaskError(e, { + level: 'error', + }); + } + throw e; + } finally { + this.tasks.delete(taskId); + } + } + + sendMessage(_message: RequesterMessage.ToN8n.All) {} + + onMessage(message: N8nMessage.ToRequester.All) { + switch (message.type) { + case 'broker:taskready': + this.taskReady(message.requestId, message.taskId); + break; + case 'broker:taskdone': + this.taskDone(message.taskId, message.data); + break; + case 'broker:taskerror': + this.taskError(message.taskId, message.error); + break; + case 'broker:taskdatarequest': + this.sendTaskData(message.taskId, message.requestId, message.requestType); + break; + case 'broker:rpc': + void this.handleRpc(message.taskId, message.callId, message.name, message.params); + break; + } + } + + taskReady(requestId: string, taskId: string) { + const acceptReject = this.requestAcceptRejects.get(requestId); + if (!acceptReject) { + this.rejectTask( + taskId, + 'Request ID not found. In multi-main setup, it is possible for one of the mains to have reported ready state already.', + ); + return; + } + + acceptReject.accept(taskId); + this.requestAcceptRejects.delete(requestId); + } + + rejectTask(jobId: string, reason: string) { + this.sendMessage({ + type: 'requester:taskcancel', + taskId: jobId, + reason, + }); + } + + taskDone(taskId: string, data: TaskResultData) { + const acceptReject = this.taskAcceptRejects.get(taskId); + if (acceptReject) { + acceptReject.accept(data); + this.taskAcceptRejects.delete(taskId); + } + } + + taskError(taskId: string, error: unknown) { + const acceptReject = this.taskAcceptRejects.get(taskId); + if (acceptReject) { + acceptReject.reject(error); + this.taskAcceptRejects.delete(taskId); + } + } + + sendTaskData( + taskId: string, + requestId: string, + requestType: N8nMessage.ToRequester.TaskDataRequest['requestType'], + ) { + const job = this.tasks.get(taskId); + if (!job) { + // TODO: logging + return; + } + if (requestType === 'all') { + const jd = job.data; + const ad = jd.additionalData; + const data: AllCodeTaskData = { + workflow: workflowToParameters(jd.workflow), + connectionInputData: jd.connectionInputData, + inputData: jd.inputData, + itemIndex: jd.itemIndex, + activeNodeName: jd.activeNodeName, + contextNodeName: jd.contextNodeName, + defaultReturnRunIndex: jd.defaultReturnRunIndex, + mode: jd.mode, + node: jd.node, + runExecutionData: jd.runExecutionData, + runIndex: jd.runIndex, + selfData: jd.selfData, + siblingParameters: jd.siblingParameters, + executeData: jd.executeData, + additionalData: { + formWaitingBaseUrl: ad.formWaitingBaseUrl, + instanceBaseUrl: ad.instanceBaseUrl, + restApiUrl: ad.restApiUrl, + variables: ad.variables, + webhookBaseUrl: ad.webhookBaseUrl, + webhookTestBaseUrl: ad.webhookTestBaseUrl, + webhookWaitingBaseUrl: ad.webhookWaitingBaseUrl, + currentNodeParameters: ad.currentNodeParameters, + executionId: ad.executionId, + executionTimeoutTimestamp: ad.executionTimeoutTimestamp, + restartExecutionId: ad.restartExecutionId, + userId: ad.userId, + }, + }; + this.sendMessage({ + type: 'requester:taskdataresponse', + taskId, + requestId, + data, + }); + } + } + + async handleRpc( + taskId: string, + callId: string, + name: N8nMessage.ToRequester.RPC['name'], + params: unknown[], + ) { + const job = this.tasks.get(taskId); + if (!job) { + // TODO: logging + return; + } + + try { + if (!RPC_ALLOW_LIST.includes(name)) { + this.sendMessage({ + type: 'requester:rpcresponse', + taskId, + callId, + status: 'error', + data: 'Method not allowed', + }); + return; + } + const splitPath = name.split('.'); + + const funcs = job.data.executeFunctions; + + let func: ((...args: unknown[]) => Promise) | undefined = undefined; + let funcObj: ExecuteFunctionObject[string] | undefined = + funcs as unknown as ExecuteFunctionObject; + for (const part of splitPath) { + funcObj = (funcObj as ExecuteFunctionObject)[part] ?? undefined; + if (!funcObj) { + break; + } + } + func = funcObj as unknown as (...args: unknown[]) => Promise; + if (!func) { + this.sendMessage({ + type: 'requester:rpcresponse', + taskId, + callId, + status: 'error', + data: 'Could not find method', + }); + return; + } + const data = (await func.call(funcs, ...params)) as unknown; + + this.sendMessage({ + type: 'requester:rpcresponse', + taskId, + callId, + status: 'success', + data, + }); + } catch (e) { + this.sendMessage({ + type: 'requester:rpcresponse', + taskId, + callId, + status: 'error', + data: e, + }); + } + } +} diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index b83e2bdb2a..24f467fc5a 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -31,6 +31,7 @@ import { isApiEnabled, loadPublicApiVersions } from '@/public-api'; import { setupPushServer, setupPushHandler, Push } from '@/push'; import type { APIRequest } from '@/requests'; import * as ResponseHelper from '@/response-helper'; +import { setupRunnerServer, setupRunnerHandler } from '@/runners/runner-ws-server'; import type { FrontendService } from '@/services/frontend.service'; import { OrchestrationService } from '@/services/orchestration.service'; @@ -201,6 +202,10 @@ export class Server extends AbstractServer { const { restEndpoint, app } = this; setupPushHandler(restEndpoint, app); + if (!this.globalConfig.taskRunners.disabled) { + setupRunnerHandler(restEndpoint, app); + } + const push = Container.get(Push); if (push.isBidirectional) { const { CollaborationService } = await import('@/collaboration/collaboration.service'); @@ -400,4 +405,9 @@ export class Server extends AbstractServer { const { restEndpoint, server, app } = this; setupPushServer(restEndpoint, server, app); } + + protected setupRunnerServer(): void { + const { restEndpoint, server, app } = this; + setupRunnerServer(restEndpoint, server, app); + } } diff --git a/packages/cli/src/services/cache/cache.service.ts b/packages/cli/src/services/cache/cache.service.ts index 3eda66ecb8..aefe9310fc 100644 --- a/packages/cli/src/services/cache/cache.service.ts +++ b/packages/cli/src/services/cache/cache.service.ts @@ -89,6 +89,9 @@ export class CacheService extends TypedEmitter { // storing // ---------------------------------- + /** + * @param ttl Time to live in milliseconds + */ async set(key: string, value: unknown, ttl?: number) { if (!this.cache) await this.init(); diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index 6533f18bb9..73e14f2cb7 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -24,6 +24,8 @@ import type { WorkflowExecuteMode, ExecutionStatus, ExecutionError, + IExecuteFunctions, + ITaskDataConnections, ExecuteWorkflowOptions, IWorkflowExecutionDataProcess, } from 'n8n-workflow'; @@ -64,6 +66,7 @@ import { } from './execution-lifecycle-hooks/shared/shared-hook-functions'; import { toSaveSettings } from './execution-lifecycle-hooks/to-save-settings'; import { Logger } from './logging/logger.service'; +import { TaskManager } from './runners/task-managers/task-manager'; import { SecretsHelper } from './secrets-helpers'; import { OwnershipService } from './services/ownership.service'; import { UrlService } from './services/url.service'; @@ -984,6 +987,47 @@ export async function getBase( setExecutionStatus, variables, secretsHelpers: Container.get(SecretsHelper), + async startAgentJob( + additionalData: IWorkflowExecuteAdditionalData, + jobType: string, + settings: unknown, + executeFunctions: IExecuteFunctions, + inputData: ITaskDataConnections, + node: INode, + workflow: Workflow, + runExecutionData: IRunExecutionData, + runIndex: number, + itemIndex: number, + activeNodeName: string, + connectionInputData: INodeExecutionData[], + siblingParameters: INodeParameters, + mode: WorkflowExecuteMode, + executeData?: IExecuteData, + defaultReturnRunIndex?: number, + selfData?: IDataObject, + contextNodeName?: string, + ) { + return await Container.get(TaskManager).startTask( + additionalData, + jobType, + settings, + executeFunctions, + inputData, + node, + workflow, + runExecutionData, + runIndex, + itemIndex, + activeNodeName, + connectionInputData, + siblingParameters, + mode, + executeData, + defaultReturnRunIndex, + selfData, + contextNodeName, + ); + }, logAiEvent: (eventName: keyof AiEventMap, payload: AiEventPayload) => eventService.emit(eventName, payload), }; diff --git a/packages/cli/src/workflow-runner.ts b/packages/cli/src/workflow-runner.ts index 637c94d866..aaed763500 100644 --- a/packages/cli/src/workflow-runner.ts +++ b/packages/cli/src/workflow-runner.ts @@ -245,7 +245,7 @@ export class WorkflowRunner { { executionId }, ); let workflowExecution: PCancelable; - await this.executionRepository.updateStatus(executionId, 'running'); + await this.executionRepository.updateStatus(executionId, 'running'); // write try { additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain(data, executionId); diff --git a/packages/core/src/Agent/index.ts b/packages/core/src/Agent/index.ts new file mode 100644 index 0000000000..75195b6acf --- /dev/null +++ b/packages/core/src/Agent/index.ts @@ -0,0 +1,58 @@ +import type { + IExecuteFunctions, + Workflow, + IRunExecutionData, + INodeExecutionData, + ITaskDataConnections, + INode, + IWorkflowExecuteAdditionalData, + WorkflowExecuteMode, + INodeParameters, + IExecuteData, + IDataObject, +} from 'n8n-workflow'; + +export const createAgentStartJob = ( + additionalData: IWorkflowExecuteAdditionalData, + inputData: ITaskDataConnections, + node: INode, + workflow: Workflow, + runExecutionData: IRunExecutionData, + runIndex: number, + activeNodeName: string, + connectionInputData: INodeExecutionData[], + siblingParameters: INodeParameters, + mode: WorkflowExecuteMode, + executeData?: IExecuteData, + defaultReturnRunIndex?: number, + selfData?: IDataObject, + contextNodeName?: string, +): IExecuteFunctions['startJob'] => { + return async function startJob( + this: IExecuteFunctions, + jobType: string, + settings: unknown, + itemIndex: number, + ): Promise { + return await additionalData.startAgentJob( + additionalData, + jobType, + settings, + this, + inputData, + node, + workflow, + runExecutionData, + runIndex, + itemIndex, + activeNodeName, + connectionInputData, + siblingParameters, + mode, + executeData, + defaultReturnRunIndex, + selfData, + contextNodeName, + ); + }; +}; diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index af428026b7..177040dbb3 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -132,6 +132,7 @@ import { Readable } from 'stream'; import Container from 'typedi'; import url, { URL, URLSearchParams } from 'url'; +import { createAgentStartJob } from './Agent'; import { BinaryDataService } from './BinaryData/BinaryData.service'; import type { BinaryData } from './BinaryData/types'; import { binaryToBuffer } from './BinaryData/utils'; @@ -3788,6 +3789,17 @@ export function getExecuteFunctions( additionalData.setExecutionStatus('waiting'); } }, + logNodeOutput(...args: unknown[]): void { + if (mode === 'manual') { + // @ts-expect-error `args` is spreadable + this.sendMessageToUI(...args); + return; + } + + if (process.env.CODE_ENABLE_STDOUT === 'true') { + console.log(`[Workflow "${this.getWorkflow().id}"][Node "${node.name}"]`, ...args); + } + }, sendMessageToUI(...args: any[]): void { if (mode !== 'manual') { return; @@ -3905,6 +3917,19 @@ export function getExecuteFunctions( }); }, getParentCallbackManager: () => additionalData.parentCallbackManager, + startJob: createAgentStartJob( + additionalData, + inputData, + node, + workflow, + runExecutionData, + runIndex, + node.name, + connectionInputData, + {}, + mode, + executeData, + ), }; })(workflow, runExecutionData, connectionInputData, inputData, node) as IExecuteFunctions; } diff --git a/packages/nodes-base/nodes/Code/Code.node.ts b/packages/nodes-base/nodes/Code/Code.node.ts index cf94916589..a7bc4dc653 100644 --- a/packages/nodes-base/nodes/Code/Code.node.ts +++ b/packages/nodes-base/nodes/Code/Code.node.ts @@ -8,6 +8,9 @@ import { type INodeTypeDescription, } from 'n8n-workflow'; import set from 'lodash/set'; +import Container from 'typedi'; +import { TaskRunnersConfig } from '@n8n/config'; + import { javascriptCodeDescription } from './descriptions/JavascriptCodeDescription'; import { pythonCodeDescription } from './descriptions/PythonCodeDescription'; import { JavaScriptSandbox } from './JavaScriptSandbox'; @@ -92,6 +95,8 @@ export class Code implements INodeType { }; async execute(this: IExecuteFunctions) { + const runnersConfig = Container.get(TaskRunnersConfig); + const nodeMode = this.getNodeParameter('mode', 0) as CodeExecutionMode; const workflowMode = this.getMode(); @@ -102,6 +107,22 @@ export class Code implements INodeType { : 'javaScript'; const codeParameterName = language === 'python' ? 'pythonCode' : 'jsCode'; + if (!runnersConfig.disabled && language === 'javaScript') { + // TODO: once per item + const code = this.getNodeParameter(codeParameterName, 0) as string; + const items = await this.startJob( + { javaScript: 'javascript', python: 'python' }[language] ?? language, + { + code, + nodeMode, + workflowMode, + }, + 0, + ); + + return [items]; + } + const getSandbox = (index = 0) => { const code = this.getNodeParameter(codeParameterName, index) as string; const context = getSandboxContext.call(this, index); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 4f37056d17..9e55a22e8a 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -837,6 +837,7 @@ }, "dependencies": { "@kafkajs/confluent-schema-registry": "1.0.6", + "@n8n/config": "workspace:*", "@n8n/imap": "workspace:*", "@n8n/vm2": "3.9.25", "amqplib": "0.10.3", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 15802fe0b0..f0903dd6ce 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -952,6 +952,8 @@ export type IExecuteFunctions = ExecuteFunctions.GetNodeParameterFn & }; getParentCallbackManager(): CallbackManager | undefined; + + startJob(jobType: string, settings: unknown, itemIndex: number): Promise; }; export interface IExecuteSingleFunctions extends BaseExecutionFunctions { @@ -2239,6 +2241,26 @@ export interface IWorkflowExecuteAdditionalData { secretsHelpers: SecretsHelpersBase; logAiEvent: (eventName: AiEvent, payload: AiEventPayload) => void; parentCallbackManager?: CallbackManager; + startAgentJob( + additionalData: IWorkflowExecuteAdditionalData, + jobType: string, + settings: unknown, + executeFunctions: IExecuteFunctions, + inputData: ITaskDataConnections, + node: INode, + workflow: Workflow, + runExecutionData: IRunExecutionData, + runIndex: number, + itemIndex: number, + activeNodeName: string, + connectionInputData: INodeExecutionData[], + siblingParameters: INodeParameters, + mode: WorkflowExecuteMode, + executeData?: IExecuteData, + defaultReturnRunIndex?: number, + selfData?: IDataObject, + contextNodeName?: string, + ): Promise; } export type WorkflowExecuteMode = diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 036ab9af4b..c01e6b6d5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,7 +154,7 @@ importers: version: 29.6.2 jest-mock-extended: specifier: ^3.0.4 - version: 3.0.4(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.6.2)))(typescript@5.6.2) + version: 3.0.4(jest@29.6.2)(typescript@5.6.2) lefthook: specifier: ^1.7.15 version: 1.7.15 @@ -178,7 +178,7 @@ importers: version: 7.0.0 ts-jest: specifier: ^29.1.1 - version: 29.1.1(@babel/core@7.24.0)(@jest/types@29.6.1)(babel-jest@29.6.2(@babel/core@7.24.0))(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.6.2)))(typescript@5.6.2) + version: 29.1.1(@babel/core@7.24.0)(@jest/types@29.6.1)(babel-jest@29.6.2(@babel/core@7.24.0))(jest@29.6.2)(typescript@5.6.2) tsc-alias: specifier: ^1.8.7 version: 1.8.7 @@ -258,7 +258,7 @@ importers: version: 4.0.7 axios: specifier: 'catalog:' - version: 1.7.4(debug@4.3.6) + version: 1.7.4 dotenv: specifier: 8.6.0 version: 8.6.0 @@ -335,7 +335,7 @@ importers: dependencies: axios: specifier: 'catalog:' - version: 1.7.4(debug@4.3.6) + version: 1.7.4 packages/@n8n/codemirror-lang: dependencies: @@ -463,7 +463,7 @@ importers: version: 0.5.0 '@n8n/typeorm': specifier: 0.3.20-12 - version: 0.3.20-12(@sentry/node@7.87.0)(ioredis@5.3.2)(mssql@10.0.2)(mysql2@3.11.0)(pg@8.12.0)(redis@4.6.12)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.6.2)) + version: 0.3.20-12(@sentry/node@7.87.0)(ioredis@5.3.2)(mssql@10.0.2)(mysql2@3.11.0)(pg@8.12.0)(redis@4.6.12)(sqlite3@5.1.7)(ts-node@10.9.2(typescript@5.6.2)) '@n8n/vm2': specifier: 3.9.25 version: 3.9.25 @@ -621,6 +621,85 @@ importers: specifier: ^8.3.1 version: 8.3.1 + packages/@n8n/task-runner-node-js: + dependencies: + jmespath: + specifier: ^0.16.0 + version: 0.16.0 + luxon: + specifier: ^3.5.0 + version: 3.5.0 + n8n-core: + specifier: workspace:* + version: link:../../core + n8n-workflow: + specifier: workspace:* + version: link:../../workflow + nanoid: + specifier: ^3.3.6 + version: 3.3.7 + ws: + specifier: '>=8.17.1' + version: 8.17.1 + devDependencies: + '@n8n_io/eslint-config': + specifier: ^0.0.2 + version: 0.0.2 + '@types/jest': + specifier: ^29.5.0 + version: 29.5.3 + '@types/node': + specifier: ^18.16.16 + version: 18.16.16 + '@types/ws': + specifier: ^8.5.12 + version: 8.5.12 + '@typescript-eslint/eslint-plugin': + specifier: ^6.1.0 + version: 6.21.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0)(typescript@5.6.2) + eslint: + specifier: ^8.38.0 + version: 8.57.0 + eslint-config-airbnb-typescript: + specifier: ^17.1.0 + version: 17.1.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0)(typescript@5.6.2))(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0) + eslint-config-prettier: + specifier: ^8.8.0 + version: 8.10.0(eslint@8.57.0) + eslint-plugin-n8n-local-rules: + specifier: ^1.0.0 + version: 1.0.0 + eslint-plugin-prettier: + specifier: ^5.0.0 + version: 5.2.1(@types/eslint@8.56.5)(eslint-config-prettier@8.10.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.3.3) + eslint-plugin-unicorn: + specifier: ^48.0.0 + version: 48.0.1(eslint@8.57.0) + eslint-plugin-unused-imports: + specifier: ^3.0.0 + version: 3.1.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0) + jest: + specifier: ^29.5.0 + version: 29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.6.2)) + nodemon: + specifier: ^2.0.20 + version: 2.0.22 + prettier: + specifier: ^3.0.0 + version: 3.3.3 + ts-jest: + specifier: ^29.1.0 + version: 29.1.1(@babel/core@7.24.0)(@jest/types@29.6.1)(babel-jest@29.6.2(@babel/core@7.24.0))(jest@29.6.2)(typescript@5.6.2) + ts-node: + specifier: ^10.9.1 + version: 10.9.2(@types/node@18.16.16)(typescript@5.6.2) + tsc-alias: + specifier: ^1.8.7 + version: 1.8.7 + typescript: + specifier: ^5.6.2 + version: 5.6.2 + packages/@n8n_io/eslint-config: devDependencies: '@types/eslint': @@ -698,9 +777,12 @@ importers: '@n8n/permissions': specifier: workspace:* version: link:../@n8n/permissions + '@n8n/task-runner': + specifier: workspace:* + version: link:../@n8n/task-runner-node-js '@n8n/typeorm': specifier: 0.3.20-12 - version: 0.3.20-12(@sentry/node@7.87.0)(ioredis@5.3.2)(mssql@10.0.2)(mysql2@3.11.0)(pg@8.12.0)(redis@4.6.14)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.6.2)) + version: 0.3.20-12(@sentry/node@7.87.0)(ioredis@5.3.2)(mssql@10.0.2)(mysql2@3.11.0)(pg@8.12.0)(redis@4.6.14)(sqlite3@5.1.7)(ts-node@10.9.2(typescript@5.6.2)) '@n8n_io/ai-assistant-sdk': specifier: 1.9.4 version: 1.9.4 @@ -724,7 +806,7 @@ importers: version: 1.11.0 axios: specifier: 'catalog:' - version: 1.7.4(debug@4.3.6) + version: 1.7.4 bcryptjs: specifier: 2.4.3 version: 2.4.3 @@ -1058,7 +1140,7 @@ importers: version: 1.11.0 axios: specifier: 'catalog:' - version: 1.7.4(debug@4.3.6) + version: 1.7.4 concat-stream: specifier: 2.0.0 version: 2.0.0 @@ -1345,7 +1427,7 @@ importers: version: 10.11.0(vue@3.4.21(typescript@5.6.2)) axios: specifier: 'catalog:' - version: 1.7.4(debug@4.3.6) + version: 1.7.4 bowser: specifier: 2.11.0 version: 2.11.0 @@ -1543,6 +1625,9 @@ importers: '@kafkajs/confluent-schema-registry': specifier: 1.0.6 version: 1.0.6 + '@n8n/config': + specifier: workspace:* + version: link:../@n8n/config '@n8n/imap': specifier: workspace:* version: link:../@n8n/imap @@ -1819,7 +1904,7 @@ importers: version: 0.15.2 axios: specifier: 'catalog:' - version: 1.7.4(debug@4.3.6) + version: 1.7.4 callsites: specifier: 3.1.0 version: 3.1.0 @@ -2437,10 +2522,6 @@ packages: resolution: {integrity: sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==} engines: {node: '>=6.9.0'} - '@babel/generator@7.22.9': - resolution: {integrity: sha512-KtLMbmicyuK2Ak/FTCJVbDnkN1SlT8/kceFTiuDiiRUUSMnHMidxSCdG4ndkTOHHpoomWe/4xkvHkEOncwjYIw==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.23.6': resolution: {integrity: sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==} engines: {node: '>=6.9.0'} @@ -2495,10 +2576,6 @@ packages: resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.24.6': - resolution: {integrity: sha512-4yA7s865JHaqUdRbnaxarZREuPTHrjpDT+pXoAZ1yhyo6uFnIEpS8VMu16siFOHDpZNKYv5BObhsB//ycbICyw==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.24.7': resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} engines: {node: '>=6.9.0'} @@ -3164,14 +3241,6 @@ packages: node-notifier: optional: true - '@jest/schemas@29.4.3': - resolution: {integrity: sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/schemas@29.6.0': - resolution: {integrity: sha512-rxLjXyJBTL4LQeJW3aKo0M/+GkCOXsO+8i9Iu7eDb6KwtP65ayoDsitrdPBtujxQ88k4wI2FNYfa6TOGwSn6cQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3908,6 +3977,9 @@ packages: resolution: {integrity: sha512-jFBT3SNPQuPTiJceCVIO0VB06ALa6Az1nfsRnZYdm+HMIle1ZFZDIjf32tqYoa2VvrqitfR/fs7CiMlVQUkIRg==} engines: {node: '>=20.15', pnpm: '>=8.14'} + '@n8n_io/eslint-config@0.0.2': + resolution: {integrity: sha512-nqVq6k7q0Kk6GMkifVZQ5yEEHob8wbPMq062ds4E/eywiqfaWuGNAQ+ax678iRTlyOS/i4/e23SB0vb0oylr7Q==} + '@n8n_io/license-sdk@2.13.1': resolution: {integrity: sha512-R6min21m3OlZHYMpeT+8uuB5v4FG3rC00fvRNbVKzNEqNAvfF1dPm6i1fliy5sGKCQ6sh71kjQ9JgE7lRRzJDg==} engines: {node: '>=18.12.1'} @@ -3980,6 +4052,10 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@pkgr/core@0.1.1': + resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -4317,9 +4393,6 @@ packages: '@sideway/pinpoint@2.0.0': resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} - '@sinclair/typebox@0.25.21': - resolution: {integrity: sha512-gFukHN4t8K4+wVC+ECqeqwzBDeFeTzBXroBTqE6vcWrQGbEUpHO7LYdG0f4xnvYq4VOEwITSlHlp0JBAIFMS/g==} - '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -5556,8 +5629,8 @@ packages: '@types/whatwg-url@11.0.4': resolution: {integrity: sha512-lXCmTWSHJvf0TRSO58nm978b8HJ/EdsSsEKLd3ODHFjo+3VGAyyTp4v50nWvwtzBxSMQrVOK7tcuN0zGPLICMw==} - '@types/ws@8.5.10': - resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} + '@types/ws@8.5.12': + resolution: {integrity: sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==} '@types/ws@8.5.4': resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==} @@ -5577,6 +5650,17 @@ packages: '@types/yauzl@2.10.0': resolution: {integrity: sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==} + '@typescript-eslint/eslint-plugin@6.21.0': + resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + '@typescript-eslint/eslint-plugin@7.2.0': resolution: {integrity: sha512-mdekAHOqS9UjlmyF/LSs6AIEvfceV749GFxoBAjwAv0nkevfKHWQFDMcBZWUiIC5ft6ePWivXoS36aKQ0Cy3sw==} engines: {node: ^16.0.0 || >=18.0.0} @@ -5606,6 +5690,16 @@ packages: resolution: {integrity: sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==} engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/type-utils@6.21.0': + resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + '@typescript-eslint/type-utils@7.2.0': resolution: {integrity: sha512-xHi51adBHo9O9330J8GQYQwrKBqbIPJGZZVQTHHmy200hvkLZFWJIFtAG/7IYTWUyun6DE6w5InDReePJYJlJA==} engines: {node: ^16.0.0 || >=18.0.0} @@ -7438,6 +7532,14 @@ packages: eslint: ^7.32.0 || ^8.2.0 eslint-plugin-import: ^2.25.2 + eslint-config-airbnb-typescript@17.1.0: + resolution: {integrity: sha512-GPxI5URre6dDpJ0CtcthSZVBAfI+Uw7un5OYNVxP2EYi3H81Jw701yFP7AU+/vCE7xBtFmjge7kfhhk4+RAiig==} + peerDependencies: + '@typescript-eslint/eslint-plugin': ^5.13.0 || ^6.0.0 + '@typescript-eslint/parser': ^5.0.0 || ^6.0.0 + eslint: ^7.32.0 || ^8.2.0 + eslint-plugin-import: ^2.25.3 + eslint-config-airbnb-typescript@18.0.0: resolution: {integrity: sha512-oc+Lxzgzsu8FQyFVa4QFaVKiitTYiiW3frB9KYW5OWdPrqFc7FzxgB20hP4cHMlr+MBzGcLl3jnCOVOydL9mIg==} peerDependencies: @@ -7445,6 +7547,12 @@ packages: '@typescript-eslint/parser': ^7.0.0 eslint: ^8.56.0 + eslint-config-prettier@8.10.0: + resolution: {integrity: sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + eslint-config-prettier@9.1.0: resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} hasBin: true @@ -7516,6 +7624,26 @@ packages: resolution: {integrity: sha512-Qj8S+YgymYkt/5Fr1buwOTjl0jAERJBp3MA5V8M6NR1HYfErKazVjpOPEy5+04c0vAQZO1mPLGAzanxqqNUIng==} engines: {node: '>=20.15', pnpm: '>=9.6'} + eslint-plugin-prettier@5.2.1: + resolution: {integrity: sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '*' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-plugin-unicorn@48.0.1: + resolution: {integrity: sha512-FW+4r20myG/DqFcCSzoumaddKBicIPeFnTrifon2mWIzlfyvzwyqZjqVP7m4Cqr/ZYisS2aiLghkUWaPg6vtCw==} + engines: {node: '>=16'} + peerDependencies: + eslint: '>=8.44.0' + eslint-plugin-unicorn@51.0.1: resolution: {integrity: sha512-MuR/+9VuB0fydoI0nIn2RDA5WISRn4AsJyNSaNKLVwie9/ONvQhxOBbkfSICBPnzKrB77Fh6CZZXjgTt/4Latw==} engines: {node: '>=16'} @@ -7652,10 +7780,6 @@ packages: resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} engines: {node: '>=0.10.0'} - expect@29.5.0: - resolution: {integrity: sha512-yM7xqUrCO2JdpFo4XpM82t+PJBFybdqoQuJLDGeDX2ij8NZzqRHyu3Hp188/JX7SWqud+7t4MUdvcgGBICMHZg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - expect@29.6.2: resolution: {integrity: sha512-iAErsLxJ8C+S02QbLAwgSGSezLQK+XXRDt8IuFXFpwCNw2ECmzZSmjKcCaFVp5VRMk+WAvz6h6jokzEzBFZEuA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7715,6 +7839,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@3.2.12: resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==} engines: {node: '>=8.6.0'} @@ -8671,10 +8798,6 @@ packages: resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} engines: {node: '>=10'} - istanbul-reports@3.1.6: - resolution: {integrity: sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==} - engines: {node: '>=8'} - istanbul-reports@3.1.7: resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} engines: {node: '>=8'} @@ -8721,10 +8844,6 @@ packages: ts-node: optional: true - jest-diff@29.5.0: - resolution: {integrity: sha512-LtxijLLZBduXnHSniy0WMdaHjmQnt3g5sa16W4p0HqukYTTsyTW3GD1q41TyGl5YFXj/5B2U6dlh5FM1LIMgxw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-diff@29.6.2: resolution: {integrity: sha512-t+ST7CB9GX5F2xKwhwCf0TAR17uNDiaPTZnVymP9lw0lssa9vG+AFyDZoeIHStU3WowFFwT+ky+er0WVl2yGhA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8765,18 +8884,10 @@ packages: resolution: {integrity: sha512-aNqYhfp5uYEO3tdWMb2bfWv6f0b4I0LOxVRpnRLAeque2uqOVVMLh6khnTcE2qJ5wAKop0HcreM1btoysD6bPQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-matcher-utils@29.5.0: - resolution: {integrity: sha512-lecRtgm/rjIK0CQ7LPQwzCs2VwW6WAahA55YBuI+xqmhm7LAaxokSB8C97yJeYyT+HvQkH741StzpU41wohhWw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-matcher-utils@29.6.2: resolution: {integrity: sha512-4LiAk3hSSobtomeIAzFTe+N8kL6z0JtF3n6I4fg29iIW7tt99R7ZcIFW34QkX+DuVrf+CUe6wuVOpm7ZKFJzZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-message-util@29.5.0: - resolution: {integrity: sha512-Kijeg9Dag6CKtIDA7O21zNTACqD5MD/8HfIV8pdD94vFyFuer52SigdC3IQMhab3vACxXMiFk+yMHNdbqtyTGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-message-util@29.6.2: resolution: {integrity: sha512-vnIGYEjoPSuRqV8W9t+Wow95SDp6KPX2Uf7EoeG9G99J2OVh7OSwpS4B6J0NfpEIpfkBNHlBZpA2rblEuEFhZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8824,10 +8935,6 @@ packages: resolution: {integrity: sha512-1OdjqvqmRdGNvWXr/YZHuyhh5DeaLp1p/F8Tht/MrMw4Kr1Uu/j4lRG+iKl1DAqUJDWxtQBMk41Lnf/JETYBRA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-util@29.5.0: - resolution: {integrity: sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-util@29.6.2: resolution: {integrity: sha512-3eX1qb6L88lJNCFlEADKOkjpXJQyZRiavX1INZ4tRnrBVr2COd3RgcTLyUiEXMNBlDU/cgYq6taUS0fExrWW4w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -9385,6 +9492,10 @@ packages: resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==} engines: {node: '>=12'} + luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -10001,6 +10112,11 @@ packages: resolution: {integrity: sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA==} engines: {node: '>=6.0.0'} + nodemon@2.0.22: + resolution: {integrity: sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==} + engines: {node: '>=8.10.0'} + hasBin: true + nodemon@3.0.1: resolution: {integrity: sha512-g9AZ7HmkhQkqXkRc20w+ZfQ73cHLbE8hnPbtaFbFtCumZsjyMhKk9LajQ07U5Ux28lvFjZ5X7HvWR1xzU8jHVw==} engines: {node: '>=10'} @@ -10587,6 +10703,10 @@ packages: pretender@3.4.7: resolution: {integrity: sha512-jkPAvt1BfRi0RKamweJdEcnjkeu7Es8yix3bJ+KgBC5VpG/Ln4JE3hYN6vJym4qprm8Xo5adhWpm3HCoft1dOw==} + prettier-linter-helpers@1.0.0: + resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + engines: {node: '>=6.0.0'} + prettier@3.3.3: resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} engines: {node: '>=14'} @@ -10600,10 +10720,6 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - pretty-format@29.5.0: - resolution: {integrity: sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -11293,6 +11409,10 @@ packages: simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + simple-update-notifier@1.1.0: + resolution: {integrity: sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==} + engines: {node: '>=8.10.0'} + simple-update-notifier@2.0.0: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} @@ -11650,6 +11770,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.9.1: + resolution: {integrity: sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==} + engines: {node: ^14.18.0 || >=16.0.0} + syslog-client@1.1.1: resolution: {integrity: sha512-c3qKw8JzCuHt0mwrzKQr8eqOc3RB28HgOpFuwGMO3GLscVpfR+0ECevWLZq/yIJTbx3WTb3QXBFVpTFtKAPDrw==} @@ -13354,7 +13478,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-sdk/client-sts': 3.654.0 '@aws-sdk/core': 3.654.0 - '@aws-sdk/credential-provider-node': 3.654.0(@aws-sdk/client-sso-oidc@3.654.0(@aws-sdk/client-sts@3.654.0))(@aws-sdk/client-sts@3.654.0) + '@aws-sdk/credential-provider-node': 3.654.0(@aws-sdk/client-sso-oidc@3.654.0(@aws-sdk/client-sts@3.654.0))(@aws-sdk/client-sts@3.645.0) '@aws-sdk/middleware-host-header': 3.654.0 '@aws-sdk/middleware-logger': 3.654.0 '@aws-sdk/middleware-recursion-detection': 3.654.0 @@ -13797,25 +13921,6 @@ snapshots: - '@aws-sdk/client-sso-oidc' - aws-crt - '@aws-sdk/credential-provider-ini@3.645.0(@aws-sdk/client-sts@3.645.0)': - dependencies: - '@aws-sdk/client-sts': 3.645.0 - '@aws-sdk/credential-provider-env': 3.620.1 - '@aws-sdk/credential-provider-http': 3.635.0 - '@aws-sdk/credential-provider-process': 3.620.1 - '@aws-sdk/credential-provider-sso': 3.645.0(@aws-sdk/client-sso-oidc@3.654.0(@aws-sdk/client-sts@3.654.0)) - '@aws-sdk/credential-provider-web-identity': 3.621.0(@aws-sdk/client-sts@3.645.0) - '@aws-sdk/types': 3.609.0 - '@smithy/credential-provider-imds': 3.2.0 - '@smithy/property-provider': 3.1.3 - '@smithy/shared-ini-file-loader': 3.1.4 - '@smithy/types': 3.3.0 - tslib: 2.6.2 - transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - - aws-crt - optional: true - '@aws-sdk/credential-provider-ini@3.654.0(@aws-sdk/client-sso-oidc@3.654.0(@aws-sdk/client-sts@3.654.0))(@aws-sdk/client-sts@3.645.0)': dependencies: '@aws-sdk/client-sts': 3.645.0 @@ -13906,26 +14011,6 @@ snapshots: - '@aws-sdk/client-sts' - aws-crt - '@aws-sdk/credential-provider-node@3.645.0(@aws-sdk/client-sts@3.645.0)': - dependencies: - '@aws-sdk/credential-provider-env': 3.620.1 - '@aws-sdk/credential-provider-http': 3.635.0 - '@aws-sdk/credential-provider-ini': 3.645.0(@aws-sdk/client-sts@3.645.0) - '@aws-sdk/credential-provider-process': 3.620.1 - '@aws-sdk/credential-provider-sso': 3.645.0(@aws-sdk/client-sso-oidc@3.654.0(@aws-sdk/client-sts@3.654.0)) - '@aws-sdk/credential-provider-web-identity': 3.621.0(@aws-sdk/client-sts@3.645.0) - '@aws-sdk/types': 3.609.0 - '@smithy/credential-provider-imds': 3.2.0 - '@smithy/property-provider': 3.1.3 - '@smithy/shared-ini-file-loader': 3.1.4 - '@smithy/types': 3.3.0 - tslib: 2.6.2 - transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - - '@aws-sdk/client-sts' - - aws-crt - optional: true - '@aws-sdk/credential-provider-node@3.654.0(@aws-sdk/client-sso-oidc@3.654.0(@aws-sdk/client-sts@3.654.0))(@aws-sdk/client-sts@3.645.0)': dependencies: '@aws-sdk/credential-provider-env': 3.654.0 @@ -14078,10 +14163,10 @@ snapshots: '@aws-sdk/credential-provider-cognito-identity': 3.645.0 '@aws-sdk/credential-provider-env': 3.620.1 '@aws-sdk/credential-provider-http': 3.635.0 - '@aws-sdk/credential-provider-ini': 3.645.0(@aws-sdk/client-sts@3.645.0) - '@aws-sdk/credential-provider-node': 3.645.0(@aws-sdk/client-sts@3.645.0) + '@aws-sdk/credential-provider-ini': 3.645.0(@aws-sdk/client-sso-oidc@3.645.0(@aws-sdk/client-sts@3.645.0))(@aws-sdk/client-sts@3.645.0) + '@aws-sdk/credential-provider-node': 3.645.0(@aws-sdk/client-sso-oidc@3.645.0(@aws-sdk/client-sts@3.645.0))(@aws-sdk/client-sts@3.645.0) '@aws-sdk/credential-provider-process': 3.620.1 - '@aws-sdk/credential-provider-sso': 3.645.0(@aws-sdk/client-sso-oidc@3.654.0(@aws-sdk/client-sts@3.654.0)) + '@aws-sdk/credential-provider-sso': 3.645.0(@aws-sdk/client-sso-oidc@3.645.0(@aws-sdk/client-sts@3.645.0)) '@aws-sdk/credential-provider-web-identity': 3.621.0(@aws-sdk/client-sts@3.645.0) '@aws-sdk/types': 3.609.0 '@smithy/credential-provider-imds': 3.2.3 @@ -14700,13 +14785,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.22.9': - dependencies: - '@babel/types': 7.25.6 - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 - jsesc: 2.5.2 - '@babel/generator@7.23.6': dependencies: '@babel/types': 7.25.6 @@ -14762,8 +14840,6 @@ snapshots: '@babel/helper-validator-identifier@7.22.20': {} - '@babel/helper-validator-identifier@7.24.6': {} - '@babel/helper-validator-identifier@7.24.7': {} '@babel/helper-validator-option@7.23.5': {} @@ -14778,7 +14854,7 @@ snapshots: '@babel/highlight@7.24.6': dependencies: - '@babel/helper-validator-identifier': 7.24.6 + '@babel/helper-validator-identifier': 7.24.7 chalk: 2.4.2 js-tokens: 4.0.0 picocolors: 1.0.1 @@ -14889,7 +14965,7 @@ snapshots: '@babel/types@7.24.6': dependencies: '@babel/helper-string-parser': 7.24.6 - '@babel/helper-validator-identifier': 7.24.6 + '@babel/helper-validator-identifier': 7.24.7 to-fast-properties: 2.0.0 '@babel/types@7.25.6': @@ -15053,7 +15129,6 @@ snapshots: '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 - optional: true '@ctrl/tinycolor@3.6.0': {} @@ -15531,7 +15606,7 @@ snapshots: istanbul-lib-instrument: 5.2.1 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 4.0.1 - istanbul-reports: 3.1.6 + istanbul-reports: 3.1.7 jest-message-util: 29.6.2 jest-util: 29.6.2 jest-worker: 29.6.2 @@ -15542,14 +15617,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@jest/schemas@29.4.3': - dependencies: - '@sinclair/typebox': 0.25.21 - - '@jest/schemas@29.6.0': - dependencies: - '@sinclair/typebox': 0.27.8 - '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.8 @@ -15596,7 +15663,7 @@ snapshots: '@jest/types@29.6.1': dependencies: - '@jest/schemas': 29.6.0 + '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.4 '@types/istanbul-reports': 3.0.1 '@types/node': 18.16.16 @@ -15606,7 +15673,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/trace-mapping': 0.3.25 '@jridgewell/resolve-uri@3.1.0': {} @@ -15626,13 +15693,12 @@ snapshots: '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.0 - '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.0 '@jridgewell/sourcemap-codec': 1.5.0 - optional: true '@js-joda/core@5.6.1': {} @@ -16045,7 +16111,7 @@ snapshots: '@n8n/localtunnel@3.0.0': dependencies: - axios: 1.7.4(debug@4.3.6) + axios: 1.7.7(debug@4.3.6) debug: 4.3.6(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -16063,7 +16129,7 @@ snapshots: esprima-next: 5.8.4 recast: 0.22.0 - '@n8n/typeorm@0.3.20-12(@sentry/node@7.87.0)(ioredis@5.3.2)(mssql@10.0.2)(mysql2@3.11.0)(pg@8.12.0)(redis@4.6.12)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.6.2))': + '@n8n/typeorm@0.3.20-12(@sentry/node@7.87.0)(ioredis@5.3.2)(mssql@10.0.2)(mysql2@3.11.0)(pg@8.12.0)(redis@4.6.12)(sqlite3@5.1.7)(ts-node@10.9.2(typescript@5.6.2))': dependencies: '@n8n/p-retry': 6.2.0-2 '@sqltools/formatter': 1.2.5 @@ -16094,7 +16160,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@n8n/typeorm@0.3.20-12(@sentry/node@7.87.0)(ioredis@5.3.2)(mssql@10.0.2)(mysql2@3.11.0)(pg@8.12.0)(redis@4.6.14)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.6.2))': + '@n8n/typeorm@0.3.20-12(@sentry/node@7.87.0)(ioredis@5.3.2)(mssql@10.0.2)(mysql2@3.11.0)(pg@8.12.0)(redis@4.6.14)(sqlite3@5.1.7)(ts-node@10.9.2(typescript@5.6.2))': dependencies: '@n8n/p-retry': 6.2.0-2 '@sqltools/formatter': 1.2.5 @@ -16134,6 +16200,8 @@ snapshots: dependencies: undici: 6.19.7 + '@n8n_io/eslint-config@0.0.2': {} + '@n8n_io/license-sdk@2.13.1': dependencies: crypto-js: 4.2.0 @@ -16240,6 +16308,8 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@pkgr/core@0.1.1': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -16429,7 +16499,7 @@ snapshots: '@rudderstack/rudder-sdk-node@2.0.9(tslib@2.6.2)': dependencies: - axios: 1.7.4(debug@4.3.6) + axios: 1.7.4 axios-retry: 3.7.0 component-type: 1.2.1 join-component: 1.1.0 @@ -16647,8 +16717,6 @@ snapshots: '@sideway/pinpoint@2.0.0': {} - '@sinclair/typebox@0.25.21': {} - '@sinclair/typebox@0.27.8': {} '@sinonjs/commons@2.0.0': @@ -17726,7 +17794,7 @@ snapshots: express: 4.21.0 find-cache-dir: 3.3.2 fs-extra: 11.1.1 - magic-string: 0.30.10 + magic-string: 0.30.11 storybook: 8.3.1 ts-dedent: 2.2.0 vite: 5.4.6(@types/node@18.16.16)(sass@1.64.1)(terser@5.16.1) @@ -17864,7 +17932,7 @@ snapshots: dependencies: '@supabase/node-fetch': 2.6.15 '@types/phoenix': 1.6.4 - '@types/ws': 8.5.10 + '@types/ws': 8.5.12 ws: 8.17.1 transitivePeerDependencies: - bufferutil @@ -17954,17 +18022,13 @@ snapshots: '@tootallnate/once@2.0.0': {} - '@tsconfig/node10@1.0.11': - optional: true + '@tsconfig/node10@1.0.11': {} - '@tsconfig/node12@1.0.11': - optional: true + '@tsconfig/node12@1.0.11': {} - '@tsconfig/node14@1.0.3': - optional: true + '@tsconfig/node14@1.0.3': {} - '@tsconfig/node16@1.0.4': - optional: true + '@tsconfig/node16@1.0.4': {} '@types/amqplib@0.10.1': dependencies: @@ -18141,8 +18205,8 @@ snapshots: '@types/jest@29.5.3': dependencies: - expect: 29.5.0 - pretty-format: 29.5.0 + expect: 29.6.2 + pretty-format: 29.7.0 '@types/jmespath@0.15.0': {} @@ -18416,7 +18480,7 @@ snapshots: dependencies: '@types/webidl-conversions': 7.0.0 - '@types/ws@8.5.10': + '@types/ws@8.5.12': dependencies: '@types/node': 18.16.16 @@ -18441,6 +18505,26 @@ snapshots: '@types/node': 18.16.16 optional: true + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0)(typescript@5.6.2)': + dependencies: + '@eslint-community/regexpp': 4.6.2 + '@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.6.2) + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.0)(typescript@5.6.2) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.6.2) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.3.7 + eslint: 8.57.0 + graphemer: 1.4.0 + ignore: 5.2.4 + natural-compare: 1.4.0 + semver: 7.6.0 + ts-api-utils: 1.0.1(typescript@5.6.2) + optionalDependencies: + typescript: 5.6.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/eslint-plugin@7.2.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0)(typescript@5.6.2)': dependencies: '@eslint-community/regexpp': 4.6.2 @@ -18484,6 +18568,18 @@ snapshots: '@typescript-eslint/types': 7.2.0 '@typescript-eslint/visitor-keys': 7.2.0 + '@typescript-eslint/type-utils@6.21.0(eslint@8.57.0)(typescript@5.6.2)': + dependencies: + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.6.2) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.6.2) + debug: 4.3.7 + eslint: 8.57.0 + ts-api-utils: 1.0.1(typescript@5.6.2) + optionalDependencies: + typescript: 5.6.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/type-utils@7.2.0(eslint@8.57.0)(typescript@5.6.2)': dependencies: '@typescript-eslint/typescript-estree': 7.2.0(typescript@5.6.2) @@ -18579,7 +18675,7 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 - debug: 4.3.6(supports-color@8.1.1) + debug: 4.3.7 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 @@ -18730,15 +18826,15 @@ snapshots: '@vue/compiler-sfc@3.4.21': dependencies: - '@babel/parser': 7.24.6 + '@babel/parser': 7.25.6 '@vue/compiler-core': 3.4.21 '@vue/compiler-dom': 3.4.21 '@vue/compiler-ssr': 3.4.21 '@vue/shared': 3.4.21 estree-walker: 2.0.2 - magic-string: 0.30.10 - postcss: 8.4.38 - source-map-js: 1.2.0 + magic-string: 0.30.11 + postcss: 8.4.47 + source-map-js: 1.2.1 '@vue/compiler-ssr@3.4.21': dependencies: @@ -19016,8 +19112,7 @@ snapshots: readable-stream: 3.6.0 optional: true - arg@4.1.3: - optional: true + arg@4.1.3: {} arg@5.0.2: {} @@ -19203,7 +19298,7 @@ snapshots: '@babel/runtime': 7.24.7 is-retry-allowed: 2.2.0 - axios@1.7.4(debug@4.3.6): + axios@1.7.4: dependencies: follow-redirects: 1.15.6(debug@4.3.6) form-data: 4.0.0 @@ -19211,14 +19306,13 @@ snapshots: transitivePeerDependencies: - debug - axios@1.7.7: + axios@1.7.7(debug@4.3.6): dependencies: follow-redirects: 1.15.6(debug@4.3.6) form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug - optional: true axios@1.7.7(debug@4.3.7): dependencies: @@ -19921,8 +20015,7 @@ snapshots: nan: 2.20.0 optional: true - create-require@1.1.1: - optional: true + create-require@1.1.1: {} crelt@1.0.5: {} @@ -19934,7 +20027,7 @@ snapshots: cron-parser@4.9.0: dependencies: - luxon: 3.4.4 + luxon: 3.5.0 cron@3.1.7: dependencies: @@ -20292,8 +20385,7 @@ snapshots: diff-sequences@29.6.3: {} - diff@4.0.2: - optional: true + diff@4.0.2: {} dingbat-to-unicode@1.0.1: {} @@ -20723,6 +20815,14 @@ snapshots: object.entries: 1.1.5 semver: 7.6.0 + eslint-config-airbnb-typescript@17.1.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0)(typescript@5.6.2))(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0): + dependencies: + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0)(typescript@5.6.2) + '@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.6.2) + eslint: 8.57.0 + eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-config-airbnb-typescript@18.0.0(@typescript-eslint/eslint-plugin@7.2.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0)(typescript@5.6.2))(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))(eslint@8.57.0): dependencies: '@typescript-eslint/eslint-plugin': 7.2.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0)(typescript@5.6.2) @@ -20732,6 +20832,10 @@ snapshots: transitivePeerDependencies: - eslint-plugin-import + eslint-config-prettier@8.10.0(eslint@8.57.0): + dependencies: + eslint: 8.57.0 + eslint-config-prettier@9.1.0(eslint@8.57.0): dependencies: eslint: 8.57.0 @@ -20830,6 +20934,35 @@ snapshots: - supports-color - typescript + eslint-plugin-prettier@5.2.1(@types/eslint@8.56.5)(eslint-config-prettier@8.10.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.3.3): + dependencies: + eslint: 8.57.0 + prettier: 3.3.3 + prettier-linter-helpers: 1.0.0 + synckit: 0.9.1 + optionalDependencies: + '@types/eslint': 8.56.5 + eslint-config-prettier: 8.10.0(eslint@8.57.0) + + eslint-plugin-unicorn@48.0.1(eslint@8.57.0): + dependencies: + '@babel/helper-validator-identifier': 7.24.7 + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + ci-info: 3.8.0 + clean-regexp: 1.0.0 + eslint: 8.57.0 + esquery: 1.5.0 + indent-string: 4.0.0 + is-builtin-module: 3.2.1 + jsesc: 3.0.2 + lodash: 4.17.21 + pluralize: 8.0.0 + read-pkg-up: 7.0.1 + regexp-tree: 0.1.27 + regjsparser: 0.10.0 + semver: 7.6.0 + strip-indent: 3.0.0 + eslint-plugin-unicorn@51.0.1(eslint@8.57.0): dependencies: '@babel/helper-validator-identifier': 7.22.20 @@ -20852,6 +20985,13 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-unused-imports@3.1.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0): + dependencies: + eslint: 8.57.0 + eslint-rule-composer: 0.3.0 + optionalDependencies: + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0)(typescript@5.6.2) + eslint-plugin-unused-imports@3.1.0(@typescript-eslint/eslint-plugin@7.2.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0): dependencies: eslint: 8.57.0 @@ -20894,7 +21034,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.4 + debug: 4.3.7 doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -21016,14 +21156,6 @@ snapshots: dependencies: homedir-polyfill: 1.0.3 - expect@29.5.0: - dependencies: - '@jest/expect-utils': 29.6.2 - jest-get-type: 29.4.3 - jest-matcher-utils: 29.5.0 - jest-message-util: 29.5.0 - jest-util: 29.6.2 - expect@29.6.2: dependencies: '@jest/expect-utils': 29.6.2 @@ -21132,6 +21264,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-diff@1.3.0: {} + fast-glob@3.2.12: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -21944,7 +22078,7 @@ snapshots: infisical-node@1.3.0: dependencies: - axios: 1.7.4(debug@4.3.6) + axios: 1.7.7(debug@4.3.6) dotenv: 16.3.1 tweetnacl: 1.0.3 tweetnacl-util: 0.15.1 @@ -22232,11 +22366,6 @@ snapshots: transitivePeerDependencies: - supports-color - istanbul-reports@3.1.6: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - istanbul-reports@3.1.7: dependencies: html-escaper: 2.0.2 @@ -22343,13 +22472,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-diff@29.5.0: - dependencies: - chalk: 4.1.2 - diff-sequences: 29.6.3 - jest-get-type: 29.4.3 - pretty-format: 29.7.0 - jest-diff@29.6.2: dependencies: chalk: 4.1.2 @@ -22418,13 +22540,6 @@ snapshots: jest-get-type: 29.4.3 pretty-format: 29.7.0 - jest-matcher-utils@29.5.0: - dependencies: - chalk: 4.1.2 - jest-diff: 29.5.0 - jest-get-type: 29.4.3 - pretty-format: 29.7.0 - jest-matcher-utils@29.6.2: dependencies: chalk: 4.1.2 @@ -22432,18 +22547,6 @@ snapshots: jest-get-type: 29.4.3 pretty-format: 29.7.0 - jest-message-util@29.5.0: - dependencies: - '@babel/code-frame': 7.24.6 - '@jest/types': 29.6.1 - '@types/stack-utils': 2.0.1 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.5 - pretty-format: 29.7.0 - slash: 3.0.0 - stack-utils: 2.0.6 - jest-message-util@29.6.2: dependencies: '@babel/code-frame': 7.24.6 @@ -22456,7 +22559,7 @@ snapshots: slash: 3.0.0 stack-utils: 2.0.6 - jest-mock-extended@3.0.4(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.6.2)))(typescript@5.6.2): + jest-mock-extended@3.0.4(jest@29.6.2)(typescript@5.6.2): dependencies: jest: 29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.6.2)) ts-essentials: 7.0.3(typescript@5.6.2) @@ -22549,10 +22652,10 @@ snapshots: jest-snapshot@29.6.2: dependencies: '@babel/core': 7.24.0 - '@babel/generator': 7.22.9 + '@babel/generator': 7.23.6 '@babel/plugin-syntax-jsx': 7.18.6(@babel/core@7.24.0) '@babel/plugin-syntax-typescript': 7.20.0(@babel/core@7.24.0) - '@babel/types': 7.24.6 + '@babel/types': 7.25.6 '@jest/expect-utils': 29.6.2 '@jest/transform': 29.6.2 '@jest/types': 29.6.1 @@ -22571,15 +22674,6 @@ snapshots: transitivePeerDependencies: - supports-color - jest-util@29.5.0: - dependencies: - '@jest/types': 29.6.1 - '@types/node': 18.16.16 - chalk: 4.1.2 - ci-info: 3.8.0 - graceful-fs: 4.2.11 - picomatch: 2.3.1 - jest-util@29.6.2: dependencies: '@jest/types': 29.6.1 @@ -22903,7 +22997,7 @@ snapshots: '@langchain/groq': 0.1.2(@langchain/core@0.3.3(openai@4.63.0(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13) '@langchain/mistralai': 0.1.1(@langchain/core@0.3.3(openai@4.63.0(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13) '@langchain/ollama': 0.1.0(@langchain/core@0.3.3(openai@4.63.0(encoding@0.1.13)(zod@3.23.8))) - axios: 1.7.7 + axios: 1.7.7(debug@4.3.6) cheerio: 1.0.0-rc.12 handlebars: 4.7.8 transitivePeerDependencies: @@ -23192,6 +23286,8 @@ snapshots: luxon@3.4.4: {} + luxon@3.5.0: {} + lz-string@1.5.0: {} magic-string@0.30.10: @@ -23210,7 +23306,7 @@ snapshots: dependencies: '@babel/parser': 7.25.6 '@babel/types': 7.25.6 - source-map-js: 1.2.0 + source-map-js: 1.2.1 mailparser@3.6.7: dependencies: @@ -23820,7 +23916,7 @@ snapshots: mqtt@5.7.2: dependencies: '@types/readable-stream': 4.0.10 - '@types/ws': 8.5.10 + '@types/ws': 8.5.12 commist: 3.2.0 concat-stream: 2.0.0 debug: 4.3.4 @@ -24038,6 +24134,19 @@ snapshots: nodemailer@6.9.9: {} + nodemon@2.0.22: + dependencies: + chokidar: 3.5.2 + debug: 3.2.7(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 3.1.2 + pstree.remy: 1.1.8 + semver: 7.6.0 + simple-update-notifier: 1.1.0 + supports-color: 5.5.0 + touch: 3.1.0 + undefsafe: 2.0.5 + nodemon@3.0.1: dependencies: chokidar: 3.5.2 @@ -24642,7 +24751,7 @@ snapshots: posthog-node@3.2.1: dependencies: - axios: 1.7.4(debug@4.3.6) + axios: 1.7.7(debug@4.3.6) rusha: 0.8.14 transitivePeerDependencies: - debug @@ -24671,6 +24780,10 @@ snapshots: fake-xml-http-request: 2.1.2 route-recognizer: 0.3.4 + prettier-linter-helpers@1.0.0: + dependencies: + fast-diff: 1.3.0 + prettier@3.3.3: {} pretty-bytes@5.6.0: {} @@ -24681,12 +24794,6 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 - pretty-format@29.5.0: - dependencies: - '@jest/schemas': 29.4.3 - ansi-styles: 5.2.0 - react-is: 18.2.0 - pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -25564,6 +25671,10 @@ snapshots: dependencies: is-arrayish: 0.3.2 + simple-update-notifier@1.1.0: + dependencies: + semver: 7.6.0 + simple-update-notifier@2.0.0: dependencies: semver: 7.6.0 @@ -25618,7 +25729,7 @@ snapshots: asn1.js: 5.4.1 asn1.js-rfc2560: 5.0.1(asn1.js@5.4.1) asn1.js-rfc5280: 3.0.0 - axios: 1.7.4(debug@4.3.6) + axios: 1.7.7(debug@4.3.6) big-integer: 1.6.51 bignumber.js: 9.1.2 binascii: 0.0.2 @@ -26022,6 +26133,11 @@ snapshots: symbol-tree@3.2.4: {} + synckit@0.9.1: + dependencies: + '@pkgr/core': 0.1.1 + tslib: 2.6.2 + syslog-client@1.1.1: {} tailwindcss@3.4.3(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.6.2)): @@ -26268,12 +26384,12 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.1.1(@babel/core@7.24.0)(@jest/types@29.6.1)(babel-jest@29.6.2(@babel/core@7.24.0))(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.6.2)))(typescript@5.6.2): + ts-jest@29.1.1(@babel/core@7.24.0)(@jest/types@29.6.1)(babel-jest@29.6.2(@babel/core@7.24.0))(jest@29.6.2)(typescript@5.6.2): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 jest: 29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.6.2)) - jest-util: 29.5.0 + jest-util: 29.6.2 json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -26304,7 +26420,6 @@ snapshots: typescript: 5.6.2 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - optional: true ts-toolbelt@9.6.0: {} @@ -26656,8 +26771,7 @@ snapshots: v3-infinite-loading@1.2.2: {} - v8-compile-cache-lib@3.0.1: - optional: true + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.1.0: dependencies: @@ -26743,7 +26857,7 @@ snapshots: '@vitest/spy': 2.1.1 '@vitest/utils': 2.1.1 chai: 5.1.1 - debug: 4.3.6(supports-color@8.1.1) + debug: 4.3.7 magic-string: 0.30.11 pathe: 1.1.2 std-env: 3.7.0 @@ -27208,8 +27322,7 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 - yn@3.1.1: - optional: true + yn@3.1.1: {} yocto-queue@0.1.0: {}