From 97d8b393261eb8306d8cc33bb4883fc7be3ee1b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 28 Nov 2025 12:35:11 +0100 Subject: [PATCH] fix(core): Defer requirements check for Python runner in internal mode (#22448) --- .../task-managers/task-requester.ts | 13 ++++++ .../src/task-runners/task-runner-module.ts | 11 +++++ .../task-runners/task-runner-process-py.ts | 40 ++++++++++++------- .../src/workflow-execute-additional-data.ts | 1 + .../base-execute-context.ts | 4 ++ packages/nodes-base/nodes/Code/Code.node.ts | 14 ++++++- .../Code/python-runner-unavailable.error.ts | 21 ++++++++++ packages/workflow/src/interfaces.ts | 3 ++ 8 files changed, 91 insertions(+), 16 deletions(-) create mode 100644 packages/nodes-base/nodes/Code/python-runner-unavailable.error.ts diff --git a/packages/cli/src/task-runners/task-managers/task-requester.ts b/packages/cli/src/task-runners/task-managers/task-requester.ts index 630c460a448..a38dbae9c38 100644 --- a/packages/cli/src/task-runners/task-managers/task-requester.ts +++ b/packages/cli/src/task-runners/task-managers/task-requester.ts @@ -51,6 +51,8 @@ interface ExecuteFunctionObject { [name: string]: ((...args: unknown[]) => unknown) | ExecuteFunctionObject; } +export type RunnerStatus = { available: true } | { available: false; reason?: string }; + @Service() export abstract class TaskRequester { requestAcceptRejects: Map = new Map(); @@ -63,6 +65,8 @@ export abstract class TaskRequester { private readonly executionIdsToTaskIds: Map> = new Map(); + private readonly unavailableRunners: Map = new Map(); + constructor( private readonly nodeTypes: NodeTypes, private readonly eventService: EventService, @@ -71,6 +75,15 @@ export abstract class TaskRequester { private readonly errorReporter: ErrorReporter, ) {} + setRunnerUnavailable(taskType: string, reason: string) { + this.unavailableRunners.set(taskType, reason); + } + + getRunnerStatus(taskType: string): RunnerStatus { + const reason = this.unavailableRunners.get(taskType); + return reason ? { available: false, reason } : { available: true }; + } + async startTask( additionalData: IWorkflowExecuteAdditionalData, taskType: string, diff --git a/packages/cli/src/task-runners/task-runner-module.ts b/packages/cli/src/task-runners/task-runner-module.ts index 2f2ed7a2432..7100a881114 100644 --- a/packages/cli/src/task-runners/task-runner-module.ts +++ b/packages/cli/src/task-runners/task-runner-module.ts @@ -14,8 +14,10 @@ import type { PyTaskRunnerProcess } from '@/task-runners/task-runner-process-py' import { TaskRunnerProcessRestartLoopDetector } from '@/task-runners/task-runner-process-restart-loop-detector'; import { MissingAuthTokenError } from './errors/missing-auth-token.error'; +import { MissingRequirementsError } from './errors/missing-requirements.error'; import type { TaskBrokerServer } from './task-broker/task-broker-server'; import type { LocalTaskRequester } from './task-managers/local-task-requester'; +import { TaskRequester } from './task-managers/task-requester'; /** * Module responsible for loading and starting task runner. Task runner can be @@ -126,6 +128,15 @@ export class TaskRunnerModule { if (this.runnerConfig.isNativePythonRunnerEnabled) { const { PyTaskRunnerProcess } = await import('@/task-runners/task-runner-process-py'); + + const failureReason = await PyTaskRunnerProcess.checkRequirements(); + if (failureReason) { + Container.get(TaskRequester).setRunnerUnavailable('python', failureReason); + const error = new MissingRequirementsError(failureReason); + this.logger.warn(error.message); + return; // allow bootup, will fail at execution time + } + this.pyRunnerProcess = Container.get(PyTaskRunnerProcess); this.pyRunnerProcessRestartLoopDetector = new TaskRunnerProcessRestartLoopDetector( this.pyRunnerProcess, diff --git a/packages/cli/src/task-runners/task-runner-process-py.ts b/packages/cli/src/task-runners/task-runner-process-py.ts index a7ae3ff5cd0..c43cb200996 100644 --- a/packages/cli/src/task-runners/task-runner-process-py.ts +++ b/packages/cli/src/task-runners/task-runner-process-py.ts @@ -6,7 +6,6 @@ import { access } from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; -import { MissingRequirementsError } from './errors/missing-requirements.error'; import { TaskBrokerAuthService } from './task-broker/auth/task-broker-auth.service'; import { TaskRunnerLifecycleEvents } from './task-runner-lifecycle-events'; import { TaskRunnerProcessBase } from './task-runner-process-base'; @@ -33,20 +32,8 @@ export class PyTaskRunnerProcess extends TaskRunnerProcessBase { } async startProcess(grantToken: string, taskBrokerUri: string) { - try { - await asyncExec('python3 --version', { timeout: 5000 }); - } catch { - throw new MissingRequirementsError('python'); - } - const pythonDir = path.join(__dirname, '../../../@n8n/task-runner-python'); - const venvPath = path.join(pythonDir, '.venv/bin/python'); - - try { - await access(venvPath); - } catch { - throw new MissingRequirementsError('venv'); - } + const venvPath = PyTaskRunnerProcess.getVenvPath(); return spawn(venvPath, ['-m', 'src.main'], { cwd: pythonDir, @@ -65,4 +52,29 @@ export class PyTaskRunnerProcess extends TaskRunnerProcessBase { }, }); } + + /** + * Check if Python requirements are met for internal mode. + * Returns the failure reason if requirements are missing, or `null` if all requirements are met. + */ + static async checkRequirements(): Promise<'python' | 'venv' | null> { + try { + await asyncExec('python3 --version', { timeout: 5000 }); + } catch { + return 'python'; + } + + try { + await access(PyTaskRunnerProcess.getVenvPath()); + } catch { + return 'venv'; + } + + return null; + } + + private static getVenvPath() { + const pythonDir = path.join(__dirname, '../../../@n8n/task-runner-python'); + return path.join(pythonDir, '.venv/bin/python'); + } } diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index f1c4c50ae92..7ed962e80fb 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -459,6 +459,7 @@ export async function getBase({ }, logAiEvent: (eventName: keyof AiEventMap, payload: AiEventPayload) => eventService.emit(eventName, payload), + getRunnerStatus: (taskType: string) => Container.get(TaskRequester).getRunnerStatus(taskType), }; for (const [moduleName, moduleContext] of Container.get(ModuleRegistry).context.entries()) { 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 354a798145e..63b58815885 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 @@ -274,4 +274,8 @@ export class BaseExecuteContext extends NodeExecutionContext { this.executeData, ); } + + getRunnerStatus(taskType: string): { available: true } | { available: false; reason?: string } { + return this.additionalData.getRunnerStatus?.(taskType) ?? { available: true }; + } } diff --git a/packages/nodes-base/nodes/Code/Code.node.ts b/packages/nodes-base/nodes/Code/Code.node.ts index a9eb3d2f28e..0e6ada2612e 100644 --- a/packages/nodes-base/nodes/Code/Code.node.ts +++ b/packages/nodes-base/nodes/Code/Code.node.ts @@ -20,6 +20,7 @@ import { pythonCodeDescription } from './descriptions/PythonCodeDescription'; import { JavaScriptSandbox } from './JavaScriptSandbox'; import { JsTaskRunnerSandbox } from './JsTaskRunnerSandbox'; import { NativePythonWithoutRunnerError } from './native-python-without-runner.error'; +import { PythonRunnerUnavailableError } from './python-runner-unavailable.error'; import { PythonSandbox } from './PythonSandbox'; import { PythonTaskRunnerSandbox } from './PythonTaskRunnerSandbox'; import { getSandboxContext } from './Sandbox'; @@ -148,8 +149,17 @@ export class Code implements INodeType { : [await sandbox.runCodeForEachItem(numInputItems)]; } - if (language === 'pythonNative' && !isPyRunner) { - throw new NativePythonWithoutRunnerError(); + if (language === 'pythonNative') { + if (!isPyRunner) { + throw new NativePythonWithoutRunnerError(); + } + + const runnerStatus = this.getRunnerStatus('python'); + if (!runnerStatus.available) { + throw new PythonRunnerUnavailableError( + runnerStatus.reason as 'python' | 'venv' | undefined, + ); + } } if (isPyLang && isPyRunner) { diff --git a/packages/nodes-base/nodes/Code/python-runner-unavailable.error.ts b/packages/nodes-base/nodes/Code/python-runner-unavailable.error.ts new file mode 100644 index 00000000000..1ff7db021f9 --- /dev/null +++ b/packages/nodes-base/nodes/Code/python-runner-unavailable.error.ts @@ -0,0 +1,21 @@ +import { UserError } from 'n8n-workflow'; + +type FailureReason = 'python' | 'venv'; + +const REASONS: Record = { + python: 'Python 3 is missing from this system', + venv: 'Virtual environment is missing from this system', +}; + +export class PythonRunnerUnavailableError extends UserError { + constructor(reason?: FailureReason) { + const message = reason + ? `Python runner unavailable: ${REASONS[reason]}` + : 'Python runner unavailable'; + + super(message, { + description: + 'Internal mode is intended only for debugging. For production, deploy in external mode: https://docs.n8n.io/hosting/configuration/task-runners/#setting-up-external-mode', + }); + } +} diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index ac71fbdc3b2..5415f80d462 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -1052,6 +1052,8 @@ export type IExecuteFunctions = ExecuteFunctions.GetNodeParameterFn & settings: unknown, itemIndex: number, ): Promise>; + + getRunnerStatus(taskType: string): { available: true } | { available: false; reason?: string }; }; export interface IExecuteSingleFunctions extends BaseExecutionFunctions { @@ -2712,6 +2714,7 @@ export interface IWorkflowExecuteAdditionalData { envProviderState: EnvProviderState, executeData?: IExecuteData, ): Promise>; + getRunnerStatus?(taskType: string): { available: true } | { available: false; reason?: string }; } export type WorkflowActivateMode =