diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts index 48ce3dbb75..c8461778cd 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts @@ -1,24 +1,27 @@ import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; +import { TaskRunnersConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import type { JSONSchema7 } from 'json-schema'; import { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox'; +import { JsTaskRunnerSandbox } from 'n8n-nodes-base/dist/nodes/Code/JsTaskRunnerSandbox'; import { PythonSandbox } from 'n8n-nodes-base/dist/nodes/Code/PythonSandbox'; import type { Sandbox } from 'n8n-nodes-base/dist/nodes/Code/Sandbox'; import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox'; import type { + ExecutionError, + IDataObject, INodeType, INodeTypeDescription, ISupplyDataFunctions, SupplyData, - ExecutionError, - IDataObject, } from 'n8n-workflow'; + import { jsonParse, NodeConnectionTypes, - NodeOperationError, nodeNameToToolName, + NodeOperationError, } from 'n8n-workflow'; - import { buildInputSchemaField, buildJsonSchemaExampleField, @@ -200,6 +203,9 @@ export class ToolCode implements INodeType { const node = this.getNode(); const workflowMode = this.getMode(); + const runnersConfig = Container.get(TaskRunnersConfig); + const isRunnerEnabled = runnersConfig.enabled; + const { typeVersion } = node; const name = typeVersion <= 1.1 @@ -218,6 +224,7 @@ export class ToolCode implements INodeType { code = this.getNodeParameter('pythonCode', itemIndex) as string; } + // @deprecated - TODO: Remove this after a new python runner is implemented const getSandbox = (query: string | IDataObject, index = 0) => { const context = getSandboxContext.call(this, index); context.query = query; @@ -239,15 +246,31 @@ export class ToolCode implements INodeType { return sandbox; }; - const runFunction = async (query: string | IDataObject): Promise => { - const sandbox = getSandbox(query, itemIndex); - return await sandbox.runCode(); + const runFunction = async (query: string | IDataObject): Promise => { + if (language === 'javaScript' && isRunnerEnabled) { + const sandbox = new JsTaskRunnerSandbox( + code, + 'runOnceForAllItems', + workflowMode, + this, + undefined, + { + query, + }, + ); + const executionData = await sandbox.runCodeForTool(); + return executionData; + } else { + // use old vm2-based sandbox for python or when without runner enabled + const sandbox = getSandbox(query, itemIndex); + return await sandbox.runCode(); + } }; const toolHandler = async (query: string | IDataObject): Promise => { const { index } = this.addInputData(NodeConnectionTypes.AiTool, [[{ json: { query } }]]); - let response: string = ''; + let response: any = ''; let executionError: ExecutionError | undefined; try { response = await runFunction(query); diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index cc12434989..9d5286de5b 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -192,6 +192,8 @@ "@modelcontextprotocol/sdk": "1.12.0", "@mozilla/readability": "0.6.0", "@n8n/client-oauth2": "workspace:*", + "@n8n/config": "workspace:*", + "@n8n/di": "workspace:*", "@n8n/errors": "workspace:^", "@n8n/json-schema-to-zod": "workspace:*", "@n8n/typeorm": "0.3.20-12", diff --git a/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts b/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts index a15ac5727b..46957bdfba 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts @@ -56,6 +56,8 @@ export interface RpcCallObject { export interface JSExecSettings { code: string; + // Additional properties to add to the context + additionalProperties?: Record; nodeMode: CodeExecutionMode; workflowMode: WorkflowExecuteMode; continueOnFail: boolean; @@ -245,6 +247,7 @@ export class JsTaskRunner extends TaskRunner { const context = this.buildContext(taskId, workflow, data.node, dataProxy, { items: inputItems, + ...settings.additionalProperties, }); try { @@ -309,7 +312,13 @@ export class JsTaskRunner extends TaskRunner { ? settings.chunk.startIndex + settings.chunk.count : inputItems.length; - const context = this.buildContext(taskId, workflow, data.node); + const context = this.buildContext( + taskId, + workflow, + data.node, + undefined, + settings.additionalProperties, + ); for (let index = chunkStartIdx; index < chunkEndIdx; index++) { const dataProxy = this.createDataProxy(data, workflow, index); diff --git a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts index 56188152f7..4151806ef3 100644 --- a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts @@ -21,6 +21,8 @@ import type { ISourceData, AiEvent, NodeConnectionType, + Result, + IExecuteFunctions, } from 'n8n-workflow'; import { ApplicationError, @@ -28,6 +30,7 @@ import { NodeConnectionTypes, WAIT_INDEFINITELY, WorkflowDataProxy, + createEnvProviderState, } from 'n8n-workflow'; import { BinaryDataService } from '@/binary-data/binary-data.service'; @@ -229,4 +232,29 @@ export class BaseExecuteContext extends NodeExecutionContext { msg, }); } + + async startJob( + jobType: string, + settings: unknown, + itemIndex: number, + ): Promise> { + return await this.additionalData.startRunnerTask( + this.additionalData, + jobType, + settings, + this as IExecuteFunctions, + this.inputData, + this.node, + this.workflow, + this.runExecutionData, + this.runIndex, + itemIndex, + this.node.name, + this.connectionInputData, + {}, + this.mode, + createEnvProviderState(), + this.executeData, + ); + } } diff --git a/packages/core/src/execution-engine/node-execution-context/execute-context.ts b/packages/core/src/execution-engine/node-execution-context/execute-context.ts index 7b0df62381..f594b38de5 100644 --- a/packages/core/src/execution-engine/node-execution-context/execute-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/execute-context.ts @@ -14,7 +14,6 @@ import type { ITaskDataConnections, IWorkflowExecuteAdditionalData, NodeExecutionHint, - Result, StructuredChunk, Workflow, WorkflowExecuteMode, @@ -22,7 +21,6 @@ import type { import { ApplicationError, createDeferredPromise, - createEnvProviderState, jsonParse, NodeConnectionTypes, } from 'n8n-workflow'; @@ -173,31 +171,6 @@ export class ExecuteContext extends BaseExecuteContext implements IExecuteFuncti await this.additionalData.hooks?.runHook('sendChunk', [message]); } - async startJob( - jobType: string, - settings: unknown, - itemIndex: number, - ): Promise> { - return await this.additionalData.startRunnerTask( - this.additionalData, - jobType, - settings, - this, - this.inputData, - this.node, - this.workflow, - this.runExecutionData, - this.runIndex, - itemIndex, - this.node.name, - this.connectionInputData, - {}, - this.mode, - createEnvProviderState(), - this.executeData, - ); - } - async getInputConnectionData( connectionType: AINodeConnectionType, itemIndex: number, diff --git a/packages/nodes-base/nodes/Code/JsTaskRunnerSandbox.ts b/packages/nodes-base/nodes/Code/JsTaskRunnerSandbox.ts index ca6acb582a..007092d6da 100644 --- a/packages/nodes-base/nodes/Code/JsTaskRunnerSandbox.ts +++ b/packages/nodes-base/nodes/Code/JsTaskRunnerSandbox.ts @@ -22,8 +22,12 @@ export class JsTaskRunnerSandbox { private readonly jsCode: string, private readonly nodeMode: CodeExecutionMode, private readonly workflowMode: WorkflowExecuteMode, - private readonly executeFunctions: IExecuteFunctions, + private readonly executeFunctions: Pick< + IExecuteFunctions, + 'startJob' | 'continueOnFail' | 'helpers' + >, private readonly chunkSize = 1000, + private readonly additionalProperties: Record = {}, ) {} async runCodeAllItems(): Promise { @@ -36,6 +40,7 @@ export class JsTaskRunnerSandbox { nodeMode: this.nodeMode, workflowMode: this.workflowMode, continueOnFail: this.executeFunctions.continueOnFail(), + additionalProperties: this.additionalProperties, }, itemIndex, ); @@ -51,6 +56,28 @@ export class JsTaskRunnerSandbox { ); } + async runCodeForTool(): Promise { + const itemIndex = 0; + + const executionResult = await this.executeFunctions.startJob( + 'javascript', + { + code: this.jsCode, + nodeMode: this.nodeMode, + workflowMode: this.workflowMode, + continueOnFail: this.executeFunctions.continueOnFail(), + additionalProperties: this.additionalProperties, + }, + itemIndex, + ); + + if (!executionResult.ok) { + throwExecutionError('error' in executionResult ? executionResult.error : {}); + } + + return executionResult.result; + } + async runCodeForEachItem(numInputItems: number): Promise { validateNoDisallowedMethodsInRunForEach(this.jsCode, 0); @@ -70,6 +97,7 @@ export class JsTaskRunnerSandbox { startIndex: chunk.startIdx, count: chunk.count, }, + additionalProperties: this.additionalProperties, }, itemIndex, ); diff --git a/packages/nodes-base/nodes/Code/test/JsTaskRunnerSandbox.test.ts b/packages/nodes-base/nodes/Code/test/JsTaskRunnerSandbox.test.ts index 741fbfb464..94dbb58936 100644 --- a/packages/nodes-base/nodes/Code/test/JsTaskRunnerSandbox.test.ts +++ b/packages/nodes-base/nodes/Code/test/JsTaskRunnerSandbox.test.ts @@ -1,6 +1,6 @@ import { mock } from 'jest-mock-extended'; import type { IExecuteFunctions } from 'n8n-workflow'; -import { createResultOk } from 'n8n-workflow'; +import { createResultOk, createResultError } from 'n8n-workflow'; import { JsTaskRunnerSandbox } from '../JsTaskRunnerSandbox'; @@ -15,7 +15,8 @@ describe('JsTaskRunnerSandbox', () => { ...executeFunctions.helpers, normalizeItems: jest .fn() - .mockImplementation((items) => (Array.isArray(items) ? items : [items])), + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return + .mockImplementation((items: any) => (Array.isArray(items) ? items : [items])), }; const sandbox = new JsTaskRunnerSandbox(jsCode, nodeMode, workflowMode, executeFunctions, 2); @@ -37,6 +38,7 @@ describe('JsTaskRunnerSandbox', () => { workflowMode, continueOnFail: executeFunctions.continueOnFail(), chunk: { startIndex: 0, count: 2 }, + additionalProperties: {}, }, 0, ], @@ -48,6 +50,7 @@ describe('JsTaskRunnerSandbox', () => { workflowMode, continueOnFail: executeFunctions.continueOnFail(), chunk: { startIndex: 2, count: 2 }, + additionalProperties: {}, }, 0, ], @@ -59,10 +62,80 @@ describe('JsTaskRunnerSandbox', () => { workflowMode, continueOnFail: executeFunctions.continueOnFail(), chunk: { startIndex: 4, count: 1 }, + additionalProperties: {}, }, 0, ], ]); }); }); + + describe('runCodeForTool', () => { + it('should execute code and return string result', async () => { + const jsCode = 'return "Hello World";'; + const nodeMode = 'runOnceForAllItems'; + const workflowMode = 'manual'; + const executeFunctions = mock(); + executeFunctions.helpers = { + ...executeFunctions.helpers, + normalizeItems: jest + .fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return + .mockImplementation((items: any) => (Array.isArray(items) ? items : [items])), + }; + + const sandbox = new JsTaskRunnerSandbox(jsCode, nodeMode, workflowMode, executeFunctions); + + const expectedResult = 'Hello World'; + executeFunctions.startJob.mockResolvedValue(createResultOk(expectedResult)); + + const result = await sandbox.runCodeForTool(); + + expect(result).toBe(expectedResult); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(executeFunctions.startJob).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(executeFunctions.startJob).toHaveBeenCalledWith( + 'javascript', + { + code: jsCode, + nodeMode, + workflowMode, + continueOnFail: executeFunctions.continueOnFail(), + additionalProperties: {}, + }, + 0, + ); + }); + + it('should handle execution errors by calling throwExecutionError', async () => { + const jsCode = 'throw new Error("execution failed");'; + const nodeMode = 'runOnceForAllItems'; + const workflowMode = 'manual'; + const executeFunctions = mock(); + executeFunctions.helpers = { + ...executeFunctions.helpers, + normalizeItems: jest + .fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return + .mockImplementation((items: any) => (Array.isArray(items) ? items : [items])), + }; + + const sandbox = new JsTaskRunnerSandbox(jsCode, nodeMode, workflowMode, executeFunctions); + + const executionError = { message: 'execution failed', stack: 'error stack' }; + executeFunctions.startJob.mockResolvedValue(createResultError(executionError)); + + // Mock throwExecutionError to throw an error for testing + const throwExecutionErrorModule = await import('../throw-execution-error'); + const throwExecutionErrorSpy = jest + .spyOn(throwExecutionErrorModule, 'throwExecutionError') + .mockImplementation(() => { + throw new Error('Execution failed'); + }); + + await expect(sandbox.runCodeForTool()).rejects.toThrow('Execution failed'); + expect(throwExecutionErrorSpy).toHaveBeenCalledWith(executionError); + }); + }); }); diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 01dbe37896..35568b4837 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -1053,6 +1053,7 @@ export type ISupplyDataFunctions = ExecuteFunctions.GetNodeParameterFn & | 'getNodeOutputs' | 'executeWorkflow' | 'sendMessageToUI' + | 'startJob' | 'helpers' > & { getNextRunIndex(): number; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60c82a45dd..0fb75a7070 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1081,6 +1081,12 @@ importers: '@n8n/client-oauth2': specifier: workspace:* version: link:../client-oauth2 + '@n8n/config': + specifier: workspace:* + version: link:../config + '@n8n/di': + specifier: workspace:* + version: link:../di '@n8n/errors': specifier: workspace:^ version: link:../errors