Compare commits

...

18 Commits

Author SHA1 Message Date
n8n-assistant[bot]
69d73b9b6a
🚀 Release 2.43.4 (#40855)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-10-12 07:11:46 +00:00
n8n-assistant[bot]
dc770620e5
fix(core): Handle each Chat Hub segment once in queue mode (backport to release-candidate/2.43.x) (#40816)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:34:49 +00:00
n8n-assistant[bot]
c00df8c17f
feat(core): Support minor levels in n8nNodesApiVersion (no-changelog) (backport to release-candidate/2.43.x) (#40807)
Co-authored-by: Garrit Franke <32395585+garritfra@users.noreply.github.com>
Co-authored-by: Benny Neugebauer <mail@bennycode.com>
2026-10-09 15:07:49 +00:00
n8n-assistant[bot]
5639a819f1
feat(core): Accept array literals and single-parameter callbacks in native expression evaluation (no-changelog) (backport to release-candidate/2.43.x) (#40785)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
2026-10-09 12:27:14 +00:00
n8n-assistant[bot]
ea448c6f61
fix(core): Resume tool calls for deferred tools that a skill loaded (backport to release-candidate/2.43.x) (#40746)
Co-authored-by: Raúl Gómez Morales <raul00gm@gmail.com>
Co-authored-by: Mutasem Aldmour <mutasem@n8n.io>
2026-10-09 11:39:32 +00:00
n8n-assistant[bot]
3d47f4bfaa
feat(core): Declare what on main awaits a queued run's outcome (no-changelog) (backport to release-candidate/2.43.x) (#40768)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
2026-10-09 11:26:11 +00:00
n8n-assistant[bot]
3f9a95542b
feat(core): Extend native expression evaluation to node references and more data roots (no-changelog) (backport to release-candidate/2.43.x) (#40772)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
2026-10-09 11:18:36 +00:00
n8n-assistant[bot]
17f9c4a715
🚀 Release 2.43.3 (#40736)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-10-09 07:14:00 +00:00
n8n-assistant[bot]
3c4f6e3393
feat(core): Block create/update of workflows with deprecated nodes (backport to release-candidate/2.43.x) (#40706)
Co-authored-by: mfsiega <93014743+mfsiega@users.noreply.github.com>
Co-authored-by: Emilia <100027345+sovietspaceship@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:54:55 +00:00
n8n-assistant[bot]
1830ccfcda
feat(Google Gemini Node): Show Nano Banana 2.1 and 2 Lite in the image model list (backport to release-candidate/2.43.x) (#40655)
Co-authored-by: Alexander Gekov <40495748+alexander-gekov@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 10:29:03 +00:00
n8n-assistant[bot]
6ab34a4a42
🚀 Release 2.43.2 (#40631)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-10-08 07:10:33 +00:00
n8n-assistant[bot]
3ee8d7aeef
fix(core): Require execution delete scope for assistant execution cleanup (no-changelog) (backport to release-candidate/2.43.x) (#40562)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:41:01 +00:00
n8n-assistant[bot]
509a3f36a8
fix(core): Fix Kimi K3 and other OpenAI-compatible Assistant model providers on self-hosted Assistant (backport to release-candidate/2.43.x) (#40552)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:54:39 +00:00
n8n-assistant[bot]
204e2ddf17
fix(editor): Show the Agent artifact while the builder creates it (backport to release-candidate/2.43.x) (#40550)
Co-authored-by: Anne Aguirre <anne.aguirre@n8n.io>
2026-10-07 13:36:18 +00:00
n8n-assistant[bot]
b553bca31e
🚀 Release 2.43.1 (#40476)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-10-07 07:09:16 +00:00
n8n-assistant[bot]
fda8dac7d8
perf(core): Prepare Instance AI skill sandbox in the background (backport to release-candidate/2.43.x) (#40442)
Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
2026-10-06 16:35:35 +00:00
n8n-assistant[bot]
9ea78350fa
perf(core): Keep agent builder system prompt static for prompt caching (backport to release-candidate/2.43.x) (#40441)
Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
2026-10-06 16:35:17 +00:00
n8n-assistant[bot]
345ae0100c
fix(editor): Polish n8n Assistant and Execute button UI details (backport to release-candidate/2.43.x) (#40438)
Co-authored-by: Tuukka Kantola <Tuukkaa@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:59:57 +00:00
187 changed files with 4288 additions and 426 deletions

View File

@ -1,3 +1,44 @@
## [2.43.4](https://github.com/n8n-io/n8n/compare/n8n@2.43.3...n8n@2.43.4) (2026-10-12)
### Bug Fixes
* **core:** Handle each Chat Hub segment once in queue mode ([#40816](https://github.com/n8n-io/n8n/issues/40816)) ([dc77062](https://github.com/n8n-io/n8n/commit/dc770620e5e3830e7e41b5b840028f937dedad8d))
* **core:** Resume tool calls for deferred tools that a skill loaded ([#40746](https://github.com/n8n-io/n8n/issues/40746)) ([ea448c6](https://github.com/n8n-io/n8n/commit/ea448c6f61fb7cae06cfbeccbd32713a70d112ca))
## [2.43.3](https://github.com/n8n-io/n8n/compare/n8n@2.43.2...n8n@2.43.3) (2026-10-09)
### Features
* **core:** Block create/update of workflows with deprecated nodes ([#40706](https://github.com/n8n-io/n8n/issues/40706)) ([3c4f6e3](https://github.com/n8n-io/n8n/commit/3c4f6e33934adf29d2c76e7723256fa5b69b9ab9))
* **Google Gemini Node:** Show Nano Banana 2.1 and 2 Lite in the image model list ([#40655](https://github.com/n8n-io/n8n/issues/40655)) ([1830ccf](https://github.com/n8n-io/n8n/commit/1830ccfcda55fc948197fbb88ac7438211a39940))
## [2.43.2](https://github.com/n8n-io/n8n/compare/n8n@2.43.1...n8n@2.43.2) (2026-10-08)
### Bug Fixes
* **core:** Fix Kimi K3 and other OpenAI-compatible Assistant model providers on self-hosted Assistant ([#40552](https://github.com/n8n-io/n8n/issues/40552)) ([509a3f3](https://github.com/n8n-io/n8n/commit/509a3f36a8443e544714e3874dc55ba14f2c9463))
* **editor:** Show the Agent artifact while the builder creates it ([#40550](https://github.com/n8n-io/n8n/issues/40550)) ([204e2dd](https://github.com/n8n-io/n8n/commit/204e2ddf1745095f940659d827b6f8ca000ead49))
## [2.43.1](https://github.com/n8n-io/n8n/compare/n8n@2.43.0...n8n@2.43.1) (2026-10-07)
### Bug Fixes
* **editor:** Polish n8n Assistant and Execute button UI details ([#40438](https://github.com/n8n-io/n8n/issues/40438)) ([345ae01](https://github.com/n8n-io/n8n/commit/345ae0100c073de418cab4eaf3f48926b414e6e1))
### Performance Improvements
* **core:** Keep agent builder system prompt static for prompt caching ([#40441](https://github.com/n8n-io/n8n/issues/40441)) ([9ea7835](https://github.com/n8n-io/n8n/commit/9ea78350fa4a3972cbef036ccfd8355b0e127262))
* **core:** Prepare Instance AI skill sandbox in the background ([#40442](https://github.com/n8n-io/n8n/issues/40442)) ([fda8dac](https://github.com/n8n-io/n8n/commit/fda8dac7d8bd7a3fe427285de281911d68f8f63b))
# [2.43.0](https://github.com/n8n-io/n8n/compare/n8n@2.42.0...n8n@2.43.0) (2026-10-06)

View File

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "2.43.0",
"version": "2.43.4",
"private": true,
"engines": {
"node": ">=24.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/agents",
"version": "0.28.0",
"version": "0.28.3",
"description": "AI agent SDK for n8n's code-first execution engine",
"engines": {
"node": ">=24.0.0"

View File

@ -8753,6 +8753,55 @@ describe('AgentRuntime — mid-run observation', () => {
expect(JSON.stringify(capturedCall(2))).not.toContain('Old workflow policy.');
});
it('resumes a deferred tool that a skill dependency loaded', async () => {
const source = createRuntimeSkillSource([
{
id: 'planning',
name: 'planning',
description: 'Plan multi-step work.',
instructions: 'Ask the user to approve the plan.',
dependencies: { tools: ['approve'] },
},
]);
const checkpointStore = makeClaimingCheckpointStore();
const memory = new InMemoryMemory();
const options = {
skillSource: source,
tools: createRuntimeSkillTools(source),
deferredTools: [makeInterruptibleTool()],
checkpointStorage: checkpointStore,
};
const first = buildMidRunRuntime(memory, options);
generateText
.mockResolvedValueOnce(
makeGenerateWithToolCall('load-planning', 'load_skill', { skillId: 'planning' }),
)
.mockResolvedValueOnce(
makeGenerateWithToolCall('confirm', 'approve', { question: 'Run the plan?' }),
);
const result = await first.generate('Plan it', { persistence: PERSISTENCE });
await first.dispose();
const suspension = result.pendingSuspend?.[0];
if (!suspension) throw new Error('Expected a plan confirmation');
const resumed = buildMidRunRuntime(memory, options);
generateText.mockResolvedValueOnce(makeGenerateSuccess('Plan approved.'));
const resumeResult = await resumed.resume(
'generate',
{ approved: true },
{ runId: suspension.runId, toolCallId: suspension.toolCallId },
);
await resumed.dispose();
expect(resumeResult.error).toBeUndefined();
expect(resumeResult.toolCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ tool: 'approve', output: { approved: true } }),
]),
);
expect(capturedCall(2).tools).toHaveProperty('approve');
});
it('merges system messages after compaction for custom OpenAI-compatible endpoints', async () => {
const memory = new InMemoryMemory();
const runtime = buildMidRunRuntime(memory, {

View File

@ -509,9 +509,11 @@ export class AgentRuntime {
const list = await this.restoreCheckpointMessages(state);
const tool = this.context
.getCurrentTools(state.persistence)
.find((t) => t.name === toolCall.toolName);
// A skill dependency loads a deferred tool without a `load_tool` record, and
// skills restore only when the loop starts. Accept any registered deferred tool.
const tool =
this.context.getCurrentTools(state.persistence).find((t) => t.name === toolCall.toolName) ??
this.deferredToolManager?.getTool(toolCall.toolName);
if (!tool) throw new Error(`Tool ${toolCall.toolName} not found`);
const resumeSchema = toolCall.suspended ? toolCall.resumeSchema : tool.resumeSchema;

View File

@ -136,6 +136,11 @@ export class DeferredToolManager {
return [this.searchTool, this.loadTool];
}
/** A registered deferred tool, loaded or not. */
getTool(name: string): BuiltTool | undefined {
return this.toolsByName.get(name);
}
getLoadedTools(): BuiltTool[] {
return Array.from(this.loadedToolNames)
.map((name) => this.toolsByName.get(name))

View File

@ -1099,7 +1099,6 @@ export class Agent implements BuiltAgent, AgentBuilder {
let instructions = this.instructionsText;
if (this.skillSource) {
await this.skillSource.prepare?.();
instructions = appendSkillCatalogToInstructions(instructions, this.skillSource.registry);
}
if (this.workspaceInstance) {

View File

@ -490,25 +490,16 @@ Use the workflow SDK.`,
expect(prepare).toHaveBeenCalledTimes(1);
});
it('prepares the runtime skill source before injecting the agent skill catalog', async () => {
it('injects the agent skill catalog without preparing the runtime skill source', async () => {
const source = createRuntimeSkillSource([
{
id: 'summarize_notes',
name: 'Summarize notes',
description: 'Use for meeting notes.',
instructions: 'Extract decisions.',
instructions: 'Full private skill body: Extract decisions.',
},
]);
const prepare = vi.fn(async () => {
await Promise.resolve();
source.registry = {
...source.registry,
skills: source.registry.skills.map((skill) => ({
...skill,
description: 'Use for materialized meeting notes.',
})),
};
});
const prepare = vi.fn(async () => {});
source.prepare = prepare;
const agent = new Agent('assistant')
@ -521,11 +512,10 @@ Use the workflow SDK.`,
const { instructions } = runtimeConfig;
expect(runtimeConfig.skillSource).toBe(source);
expect(prepare).toHaveBeenCalledTimes(1);
expect(prepare).not.toHaveBeenCalled();
expect(instructions).toContain('name: "Summarize notes"');
expect(instructions).toContain('id: "summarize_notes"');
expect(instructions).toContain('description: "Use for materialized meeting notes."');
expect(instructions).not.toContain('description: "Use for meeting notes."');
expect(instructions).toContain('description: "Use for meeting notes."');
expect(instructions).not.toContain('Full private skill body');
});

View File

@ -162,6 +162,12 @@ export type RuntimeSkillFileLoader = (
export interface RuntimeSkillSource {
registry: RuntimeSkillRegistry;
/**
* Lazy setup that runs before a skill is loaded, not when the agent is built.
* It must not change catalog fields (id, name, description, category,
* recommendedTools), because the catalog is rendered before it runs.
* Only load_skill calls it, so loadSkill and loadFile must not depend on it.
*/
prepare?: () => Promise<void>;
loadSkill: RuntimeSkillLoader;
loadFile?: RuntimeSkillFileLoader;

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-node-sdk",
"version": "0.33.0",
"version": "0.33.2",
"description": "SDK for building AI nodes in n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-utilities",
"version": "0.36.0",
"version": "0.36.2",
"description": "Utilities for building AI nodes in n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-workflow-builder",
"version": "1.43.0",
"version": "1.43.2",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "1.43.0",
"version": "1.43.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-common",
"version": "1.43.0",
"version": "1.43.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-network",
"version": "1.17.0",
"version": "1.17.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-services",
"version": "1.3.0",
"version": "1.3.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-test-utils",
"version": "1.43.0",
"version": "1.43.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/blob-storage",
"version": "1.11.0",
"version": "1.11.2",
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
"scripts": {
"clean": "rimraf dist .turbo",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/chat-hub",
"version": "1.36.0",
"version": "1.36.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/client-oauth2",
"version": "1.25.0",
"version": "1.25.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/config",
"version": "2.41.0",
"version": "2.41.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -49,4 +49,14 @@ export class NodesConfig {
/** Memory limit in MB for the Merge node's SQL sandbox. */
@Env('NODES_MERGE_SQL_SANDBOX_MEMORY_LIMIT_MB', z.coerce.number().int().positive())
mergeSqlSandboxMemoryLimitMb: number = 64;
/**
* Refuse deprecated nodes. Saving or importing a workflow fails if it adds or
* changes one. A sub-workflow from inline JSON, a URL or a file, or a single
* node run by n8n Assistant, fails if it contains one. Saved workflows that
* already contain deprecated nodes keep running. Set to `false` to turn this
* off, for example to import older backups.
*/
@Env('N8N_DEPRECATED_NODES_BLOCK')
blockDeprecated: boolean = true;
}

View File

@ -213,6 +213,7 @@ describe('GlobalConfig', () => {
exclude: ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'],
pythonEnabled: true,
mergeSqlSandboxMemoryLimitMb: 64,
blockDeprecated: true,
},
publicApi: {
disabled: false,

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/constants",
"version": "0.41.0",
"version": "0.41.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,16 +1,7 @@
export const NPM_COMMUNITY_NODE_SEARCH_API_URL = 'https://api.npms.io/v2/';
/**
* Node-authoring API level supported by the runtime that bundles this copy.
*
* Compatibility rule for community packages:
* `package.n8n.n8nNodesApiVersion <= N8N_NODES_API_VERSION`.
*
* The level mirrors the n8n major for the v3 transition: `1` on master
* (which ships as 2.x during the v3 window) and `3` on the `3.x` branch,
* where it also serves as the v3 feature flag for node-authoring APIs.
*
* Lives here, not in `n8n-workflow`, so tooling that cannot depend on the
* runtime packages (e.g. `@n8n/node-cli`) can read the same value.
* Node-authoring API level this runtime supports, and so the highest
* `n8nNodesApiVersion` a node may declare to be installed.
*/
export const N8N_NODES_API_VERSION = 1;
export const N8N_NODES_API_VERSION = '1.0';

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/create-node",
"version": "0.51.0",
"version": "0.51.2",
"description": "Official CLI to create new community nodes for n8n",
"bin": {
"create-node": "bin/create-node.cjs"

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/db",
"version": "1.43.0",
"version": "1.43.2",
"scripts": {
"clean": "rimraf dist .turbo .tmp-schema-docs",
"gen:migration-index": "node scripts/generate-migration-index.mjs",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/decorators",
"version": "1.43.0",
"version": "1.43.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -56,6 +56,11 @@ export type WorkflowExecuteAfterContext = {
retryOf?: string;
/** Who initiated the run. Unset means a regular user-initiated run. */
source?: WorkflowExecutionSource;
/**
* False when this process only enqueued the run and waited for its job. In queue mode,
* the process that ran the job fires its own `workflowExecuteAfter` for the same run.
*/
executedInThisProcess: boolean;
};
export type WorkflowExecuteResumeContext = {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/engine",
"version": "0.23.0",
"version": "0.23.2",
"description": "n8n workflow execution engine (v2)",
"scripts": {
"clean": "rimraf dist .turbo compiled",

View File

@ -17,8 +17,9 @@ register at install time and the failure is opaque to the user.
This rule enforces the structural contract:
- `package.json` must contain an `n8n` object.
- `n8n.n8nNodesApiVersion` must be present and a positive integer. It must live
inside `n8n`, not at the root.
- `n8n.n8nNodesApiVersion` must be present and a positive integer, or a
`"<major>"` or `"<major>.<minor>"` string such as `"3"` or `"3.1"`. It must
live inside `n8n`, not at the root.
- `n8n.nodes` must be a non-empty array of strings, each starting with `dist/`.
- `n8n.credentials`, if present, must be an array of strings, each starting
with `dist/`.
@ -53,7 +54,7 @@ the templates published by `@n8n/node-cli` and `@n8n/create-node`.
{
"name": "n8n-nodes-example",
"n8n": {
"n8nNodesApiVersion": "1",
"n8nNodesApiVersion": "v1",
"nodes": ["nodes/Foo/Foo.node.js"]
}
}

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/eslint-plugin-community-nodes",
"type": "module",
"version": "0.35.0",
"version": "0.35.1",
"main": "./dist/plugin.js",
"types": "./dist/plugin.d.ts",
"exports": {
@ -28,6 +28,7 @@
"watch": "tsc --watch --project tsconfig.build.json"
},
"dependencies": {
"@n8n/utils": "workspace:*",
"@typescript-eslint/typescript-estree": "catalog:",
"@typescript-eslint/utils": "catalog:",
"fastest-levenshtein": "catalog:"

View File

@ -26,6 +26,21 @@ ruleTester.run('n8n-object-validation', N8nObjectValidationRule, {
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 2, "nodes": ["dist/x.js"] } }',
},
{
name: 'major-only string api version is allowed',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3", "nodes": ["dist/x.js"] } }',
},
{
name: 'major.minor string api version is allowed',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.1", "nodes": ["dist/x.js"] } }',
},
{
name: 'two-digit minor string api version is allowed',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.10", "nodes": ["dist/x.js"] } }',
},
{
name: 'non-package.json file is ignored',
filename: 'some-config.json',
@ -77,10 +92,22 @@ ruleTester.run('n8n-object-validation', N8nObjectValidationRule, {
errors: [{ messageId: 'missingNodesApiVersion' }],
},
{
name: 'n8nNodesApiVersion is a string',
name: 'n8nNodesApiVersion is a non-numeric string',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "1", "nodes": ["dist/x.js"] } }',
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '1' } }],
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "v3", "nodes": ["dist/x.js"] } }',
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: 'v3' } }],
},
{
name: 'n8nNodesApiVersion is a string with a patch part',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.1.0", "nodes": ["dist/x.js"] } }',
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '3.1.0' } }],
},
{
name: 'n8nNodesApiVersion is a zero-major string',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "0.1", "nodes": ["dist/x.js"] } }',
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '0.1' } }],
},
{
name: 'n8nNodesApiVersion is zero',

View File

@ -1,3 +1,4 @@
import { parseNodesApiLevel } from '@n8n/utils/nodes-api-level';
import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
@ -34,9 +35,9 @@ export const N8nObjectValidationRule = createRule<[], MessageIds>({
wrongLocationApiVersion:
'"n8nNodesApiVersion" must be inside the "n8n" section, not at the root level of package.json.',
missingNodesApiVersion:
'The "n8n" object must declare "n8nNodesApiVersion" (a positive integer).',
'The "n8n" object must declare "n8nNodesApiVersion" (a positive integer, or a "<major>" or "<major>.<minor>" string).',
invalidNodesApiVersion:
'"n8n.n8nNodesApiVersion" must be a positive integer, got {{ value }}.',
'"n8n.n8nNodesApiVersion" must be a positive integer, or a "<major>" or "<major>.<minor>" string, got {{ value }}.',
missingN8nNodes: 'The "n8n" object must declare "nodes" as an array of "dist/" paths.',
n8nNodesNotArray: '"n8n.nodes" must be an array of "dist/" paths.',
emptyN8nNodes: '"n8n.nodes" must contain at least one path.',
@ -108,7 +109,7 @@ function validateApiVersion(context: Context, n8nObject: TSESTree.ObjectExpressi
}
const valueNode = apiVersionProp.value;
if (valueNode.type !== AST_NODE_TYPES.Literal || !isPositiveInteger(valueNode.value)) {
if (valueNode.type !== AST_NODE_TYPES.Literal || parseNodesApiLevel(valueNode.value) === null) {
context.report({
node: apiVersionProp,
messageId: 'invalidNodesApiVersion',
@ -194,7 +195,3 @@ function validatePathArray(
}
}
}
function isPositiveInteger(value: unknown): boolean {
return typeof value === 'number' && Number.isInteger(value) && value > 0;
}

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/expression-runtime",
"version": "0.34.0",
"version": "0.34.1",
"description": "Secure, isolated expression evaluation runtime for n8n",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/imap",
"version": "0.29.0",
"version": "0.29.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/inbound-auth",
"version": "0.2.0",
"version": "0.2.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/instance-ai",
"version": "1.28.0",
"version": "1.28.3",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",

View File

@ -455,6 +455,63 @@ describe('createInstanceAgent', () => {
);
});
it('starts preparing runtime skills in the background without waiting for them', async () => {
let finishPrepare: () => void = () => {};
const prepare = vi.fn(
async () =>
await new Promise<void>((resolve) => {
finishPrepare = resolve;
}),
);
const runtimeSkills = {
registry: {
schemaVersion: 1,
skillsHash: 'skills-hash',
skills: [{ id: 'data-table-manager', name: 'data-table-manager', description: 'x' }],
},
prepare,
loadSkill: vi.fn(),
};
await createInstanceAgent({
modelId: 'test-model',
context: {},
orchestrationContext: { runId: 'skills-test', runtimeSkills },
memoryConfig: {},
mcpManager: createMcpManagerStub(),
} as never);
expect(prepare).toHaveBeenCalledTimes(1);
finishPrepare();
});
it('logs a background skill preparation failure instead of rejecting', async () => {
const logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
const runtimeSkills = {
registry: {
schemaVersion: 1,
skillsHash: 'skills-hash',
skills: [{ id: 'data-table-manager', name: 'data-table-manager', description: 'x' }],
},
prepare: vi.fn(async () => await Promise.reject(new Error('sandbox unavailable'))),
loadSkill: vi.fn(),
};
await createInstanceAgent({
modelId: 'test-model',
context: {},
orchestrationContext: { runId: 'skills-test', runtimeSkills, logger },
memoryConfig: {},
mcpManager: createMcpManagerStub(),
} as never);
await vi.waitFor(() =>
expect(logger.warn).toHaveBeenCalledWith('Failed to warm runtime skills in the background', {
error: 'sandbox unavailable',
}),
);
});
it('passes the selected catalog to domain tools before workspace materialization', async () => {
const runtimeSkillCatalog = {
registry: { schemaVersion: 1, skillsHash: 'selected-skills', skills: [] },

View File

@ -14,7 +14,7 @@ import {
import { attachRuntimeWorkspaceCapabilities } from './runtime-workspace';
import { listConnectedMcpServices } from '../mcp/connected-mcp-services';
import { getVersionedSystemPrompt, resolvePromptProfile } from '../prompts/prompt-profiles';
import { hasRuntimeSkills } from '../skills/runtime-skills';
import { hasRuntimeSkills, warmRuntimeSkills } from '../skills/runtime-skills';
import { createToolRegistry, mergeToolRegistries, toolRegistryValues } from '../tool-registry';
import {
createOrchestratorDomainTools,
@ -254,6 +254,10 @@ export async function createInstanceAgent(
const runtimeSkills = orchestrationContext?.runtimeSkills;
if (hasRuntimeSkills(runtimeSkills)) {
agent.skills(runtimeSkills);
warmRuntimeSkills(runtimeSkills, {
logger: orchestrationContext?.logger,
tracing: orchestrationContext?.tracing,
});
}
if (telemetry) {
agent.telemetry(telemetry);

View File

@ -7,6 +7,7 @@ import {
INSTANCE_AI_SKILLS_DIR,
loadInstanceAiRuntimeSkillSource,
loadInstanceAiRuntimeSkillSourceForBuildMode,
warmRuntimeSkills,
} from '../runtime-skills';
import { CONFIG_EVALS_SKILL_ID, disabledInstanceAiSkillIds } from '../skill-gates';
@ -614,3 +615,60 @@ async function loadRuntimeSkillSourceWithEnabledModules(enabledModules: string |
const { loadInstanceAiRuntimeSkillSource } = await import('../runtime-skills.js');
return loadInstanceAiRuntimeSkillSource();
}
describe('warmRuntimeSkills', () => {
const registry = {
schemaVersion: 1 as const,
skillsHash: 'skills-hash',
skills: [{ id: 'data-table-manager', name: 'data-table-manager', description: 'x' }],
};
it('runs the background preparation in the actor span of the chat trace', async () => {
let insideSpan = false;
let preparedInsideSpan = false;
const actorRun = { id: 'actor-run' };
const withActiveSpan = vi.fn(async (_run: unknown, fn: () => Promise<unknown>) => {
insideSpan = true;
try {
return await fn();
} finally {
insideSpan = false;
}
});
const prepare = vi.fn(async () => {
preparedInsideSpan = insideSpan;
});
warmRuntimeSkills({ registry, prepare, loadSkill: vi.fn() } as never, {
tracing: { actorRun, withActiveSpan } as never,
});
await vi.waitFor(() => expect(prepare).toHaveBeenCalledTimes(1));
expect(withActiveSpan).toHaveBeenCalledWith(actorRun, expect.any(Function));
expect(preparedInsideSpan).toBe(true);
});
it('keeps the chat trace open until the background preparation settles', () => {
const preparation = Promise.resolve();
const keepOpenUntilSettled = vi.fn();
const tracing = {
actorRun: { id: 'actor-run' },
withActiveSpan: vi.fn(async () => await preparation),
keepOpenUntilSettled,
};
warmRuntimeSkills({ registry, prepare: vi.fn(), loadSkill: vi.fn() } as never, {
tracing: tracing as never,
});
expect(keepOpenUntilSettled).toHaveBeenCalledWith(tracing.withActiveSpan.mock.results[0].value);
});
it('prepares the source without a span when tracing is off', async () => {
const prepare = vi.fn(async () => {});
warmRuntimeSkills({ registry, prepare, loadSkill: vi.fn() } as never);
await vi.waitFor(() => expect(prepare).toHaveBeenCalledTimes(1));
});
});

View File

@ -1,11 +1,14 @@
import { loadRuntimeSkillSourceFromDirectory, type RuntimeSkillSource } from '@n8n/agents';
import type { InstanceAiBuildMode } from '@n8n/api-types';
import { getErrorMessage } from '@n8n/utils/errors/get-error-message';
import { GROUPING_GUIDANCE } from '@n8n/workflow-sdk/prompts/sdk-reference';
import { TOP_LEVEL_ITEM_CEILING } from 'n8n-workflow';
import { resolve } from 'node:path';
import { isAgentFeatureEnabled } from '@/utils/agent-feature-enabled';
import type { Logger } from '../logger';
import type { InstanceAiTraceContext } from '../types';
import {
PROMPT_FRAGMENT_SKILLS,
resolvePromptProfile,
@ -63,3 +66,27 @@ export function hasRuntimeSkills(
): source is RuntimeSkillSource {
return (source?.registry.skills.length ?? 0) > 0;
}
/**
* Prepare the skill source in the background so it overlaps with the first model
* call. load_skill awaits the same promise and retries if this one fails. It runs
* in the actor span, and keeps the trace open until it settles, so the sandbox
* spans stay in the chat trace even when the reply finishes first.
*/
export function warmRuntimeSkills(
source: RuntimeSkillSource,
options: { logger?: Logger; tracing?: InstanceAiTraceContext } = {},
): void {
const { prepare } = source;
if (!prepare) return;
const { logger, tracing } = options;
const preparation = tracing
? tracing.withActiveSpan(tracing.actorRun, async () => await prepare.call(source))
: prepare.call(source);
tracing?.keepOpenUntilSettled?.(preparation);
preparation.catch((error: unknown) => {
logger?.warn('Failed to warm runtime skills in the background', {
error: getErrorMessage(error),
});
});
}

View File

@ -1185,6 +1185,66 @@ describe('createInstanceAiTraceContext', () => {
expect(provider.shutdown).toHaveBeenCalledTimes(1);
});
it('keeps the trace open for a background operation that outlives the root run', async () => {
const tracing = await createInstanceAiTraceContext({
threadId: 'thread-background-op',
messageId: 'message-background-op',
runId: 'run-background-op',
userId: 'user-background-op',
input: { message: 'hi' },
});
expect(tracing).toBeDefined();
await startForegroundActor(tracing!);
const provider = agentsMock.getProvider();
const acquired = createDeferredPromise<undefined>();
const operation = tracing!.withActiveSpan(tracing!.actorRun, async () => {
await withCurrentTraceSpan({ name: 'sandbox: acquire' }, async () => await acquired.promise);
await withCurrentTraceSpan({ name: 'sandbox: sync-skills' }, async () => {});
});
tracing!.keepOpenUntilSettled?.(operation);
await tracing!.finishRun(tracing!.rootRun, { outputs: { status: 'done' } });
expect(provider.shutdown).not.toHaveBeenCalled();
expect(tracing?.isLive?.()).toBe(true);
acquired.resolve(undefined);
await operation;
await vi.waitFor(() => expect(provider.shutdown).toHaveBeenCalledTimes(1));
const spans = agentsMock.getSpans();
const acquireSpan = spans.find((span) => span.name === 'sandbox: acquire');
expect(acquireSpan?.ended).toBe(true);
expect(acquireSpan?.status?.message).toBeUndefined();
expect(spans.find((span) => span.name === 'sandbox: sync-skills')?.ended).toBe(true);
expect(tracing?.isLive?.()).toBe(false);
});
it('releases the trace after a time limit when a background operation does not settle', async () => {
const tracing = await createInstanceAiTraceContext({
threadId: 'thread-stuck-op',
messageId: 'message-stuck-op',
runId: 'run-stuck-op',
userId: 'user-stuck-op',
input: { message: 'hi' },
});
expect(tracing).toBeDefined();
const provider = agentsMock.getProvider();
vi.useFakeTimers();
try {
tracing!.keepOpenUntilSettled?.(new Promise<void>(() => {}));
await tracing!.finishRun(tracing!.rootRun, { outputs: { status: 'done' } });
expect(provider.shutdown).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2 * 60 * 1000);
expect(provider.shutdown).toHaveBeenCalledTimes(1);
expect(tracing?.isLive?.()).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('keeps the provider alive for a queued memory task until it releases, then exports its span and shuts down', async () => {
const tracing = await createInstanceAiTraceContext({
threadId: 'thread-late-memory-task',

View File

@ -185,10 +185,16 @@ interface ProductOtelTraceRuntime {
spans: Map<string, OtelApiSpan>;
contexts: Map<string, OtelContext>;
pendingOperations: Map<string, InstanceAiTraceRun>;
/** Work started in the trace that can outlive the root run. The release waits for it. */
backgroundOperations: Set<Promise<unknown>>;
releaseDeferred: boolean;
shutdown: boolean;
lifetime: ProductTelemetryLifetime;
}
/** Upper limit for how long a background operation can keep a closed trace open. */
const BACKGROUND_OPERATION_RELEASE_TIMEOUT_MS = 2 * 60 * 1000;
interface OTelTracer {
startSpan(
name: string,
@ -488,7 +494,19 @@ async function releaseProductOtelRuntime(
runtime: ProductOtelTraceRuntime,
traceId: string,
): Promise<void> {
if (runtime.shutdown) return;
if (runtime.shutdown || runtime.releaseDeferred) return;
if (runtime.backgroundOperations.size > 0) {
// Do not block the caller. Release after the background work settles, so its
// spans finish normally instead of being cancelled.
runtime.releaseDeferred = true;
void settleBackgroundOperations(runtime).then(async () => {
runtime.releaseDeferred = false;
runtime.backgroundOperations.clear();
await releaseProductOtelRuntime(runtime, traceId);
});
return;
}
runtime.shutdown = true;
for (const run of runtime.pendingOperations.values()) {
@ -505,6 +523,27 @@ async function releaseProductOtelRuntime(
await runtime.lifetime.release();
}
async function settleBackgroundOperations(runtime: ProductOtelTraceRuntime): Promise<void> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, BACKGROUND_OPERATION_RELEASE_TIMEOUT_MS);
timer.unref?.();
});
const allSettled = (async () => {
// Operations can register while earlier ones are in flight.
while (runtime.backgroundOperations.size > 0) {
const operations = [...runtime.backgroundOperations];
await Promise.allSettled(operations);
for (const operation of operations) runtime.backgroundOperations.delete(operation);
}
})();
try {
await Promise.race([allSettled, timeout]);
} finally {
clearTimeout(timer);
}
}
async function withProxyHeaders<T>(
proxyConfig: ServiceProxyConfig | undefined,
fn: () => T | Promise<T>,
@ -1318,6 +1357,13 @@ function createTraceContext(
finishRun,
failRun,
onMemoryTaskEvent,
keepOpenUntilSettled: (operation) => {
if (otelRuntime.shutdown) return;
otelRuntime.backgroundOperations.add(operation);
void operation
.catch(() => undefined)
.finally(() => otelRuntime.backgroundOperations.delete(operation));
},
...(telemetryFactory ? { getTelemetry: telemetryFactory } : {}),
wrapTools: (tools, traceOptions) => {
if (ctx.replayMode === 'replay' && ctx.traceIndex && ctx.idRemapper) {
@ -1767,6 +1813,8 @@ async function createProductOtelRuntime(
spans: new Map(),
contexts: new Map(),
pendingOperations: new Map(),
backgroundOperations: new Set(),
releaseDeferred: false,
shutdown: false,
lifetime: new ProductTelemetryLifetime(telemetry),
};

View File

@ -2139,6 +2139,11 @@ export interface InstanceAiTraceContext {
* for any agent (main or sub-agent) whose spans should join this trace.
*/
onMemoryTaskEvent?: (event: ScopedMemoryTaskEvent) => void;
/**
* Keep the trace open until a background operation settles, so its spans
* finish normally when it outlives the root run.
*/
keepOpenUntilSettled?: (operation: Promise<unknown>) => void;
/** Trace replay mode: 'record' captures tool I/O, 'replay' remaps IDs, 'off' disables. */
replayMode: TraceReplayMode;
/** Shared ID remapper instance — available in 'replay' mode. */

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/mcp-apps",
"version": "0.20.0",
"version": "0.20.3",
"description": "MCP Apps UI resources and server helpers for n8n",
"main": "dist/server/index.js",
"types": "dist/server/index.d.ts",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/node-cli",
"version": "0.52.0",
"version": "0.52.2",
"description": "Official CLI for developing community nodes for n8n",
"bin": {
"n8n-node": "bin/n8n-node.mjs"

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/node-engine-compatibility",
"version": "0.11.0",
"version": "0.11.2",
"description": "Adapts v1 nodes and workflows to the Engine v2 execution model",
"scripts": {
"clean": "rimraf dist .turbo",

View File

@ -1,12 +1,18 @@
import { LOG_LEVELS, CONSOLE_OUTPUT_REDACTED_MESSAGE } from 'n8n-workflow';
import {
Code,
transformLegacyLangchainImport,
createSandboxLogger,
createProductionConsoleLog,
} from './Code.node';
describe('Code.node', () => {
it('is marked deprecated so the backend refuses new instances', () => {
const node = new Code();
expect(node.description.deprecated).toBe(true);
});
describe('createSandboxLogger', () => {
const logLevelKeys = LOG_LEVELS.filter((level) => level !== 'silent');

View File

@ -238,6 +238,7 @@ export class Code implements INodeType {
description: INodeTypeDescription = {
displayName: 'LangChain Code',
name: 'code',
deprecated: true,
icon: 'fa:code',
iconColor: 'black',
group: ['transform'],

View File

@ -49,6 +49,12 @@ const mockResponse = {
{
name: 'models/gemini-3.1-flash-image-preview',
},
{
name: 'models/gemini-nano-banana-2.1',
},
{
name: 'models/gemini-3.1-flash-lite-image',
},
],
};
@ -153,6 +159,16 @@ describe('GoogleGemini -> listSearch', () => {
name: 'models/gemini-3.1-flash-image-preview (Nano Banana 2)',
value: 'models/gemini-3.1-flash-image-preview',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'models/gemini-nano-banana-2.1 (Nano Banana 2.1)',
value: 'models/gemini-nano-banana-2.1',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'models/gemini-3.1-flash-lite-image (Nano Banana 2 Lite)',
value: 'models/gemini-3.1-flash-lite-image',
},
],
});
});
@ -196,6 +212,16 @@ describe('GoogleGemini -> listSearch', () => {
name: 'models/gemini-3.1-flash-image-preview (Nano Banana 2)',
value: 'models/gemini-3.1-flash-image-preview',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'models/gemini-nano-banana-2.1 (Nano Banana 2.1)',
value: 'models/gemini-nano-banana-2.1',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'models/gemini-3.1-flash-lite-image (Nano Banana 2 Lite)',
value: 'models/gemini-3.1-flash-lite-image',
},
],
});
});

View File

@ -2,6 +2,11 @@ import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow'
import { apiRequest } from '../transport';
/** Nano Banana ids do not contain the word "image". */
function isImageModel(model: string): boolean {
return model.includes('image') || model.includes('nano-banana');
}
async function baseModelSearch(
this: ILoadOptionsFunctions,
modelFilter: (model: string) => boolean,
@ -32,9 +37,9 @@ export async function modelSearch(
return await baseModelSearch.call(
this,
(model) =>
!isImageModel(model) &&
!model.includes('embedding') &&
!model.includes('aqa') &&
!model.includes('image') &&
!model.includes('vision') &&
!model.includes('veo') &&
!model.includes('audio') &&
@ -50,9 +55,9 @@ export async function audioModelSearch(
return await baseModelSearch.call(
this,
(model) =>
!isImageModel(model) &&
!model.includes('embedding') &&
!model.includes('aqa') &&
!model.includes('image') &&
!model.includes('vision') &&
!model.includes('veo') &&
!model.includes('tts'), // we don't have a tts operation
@ -64,8 +69,17 @@ export async function imageGenerationModelSearch(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const rawResult = await baseModelSearch.call(this, (model) => model.includes('image'));
const rawResult = await baseModelSearch.call(this, isImageModel);
let results = rawResult.results.map((r) => {
if (r.name.includes('gemini-nano-banana-2.1')) {
return { name: `${r.name} (Nano Banana 2.1)`, value: r.value };
}
// The Lite id also contains "gemini-3.1-flash-image". Match it first.
if (r.name.includes('gemini-3.1-flash-lite-image')) {
return { name: `${r.name} (Nano Banana 2 Lite)`, value: r.value };
}
if (r.name.includes('gemini-3.1-flash-image')) {
return { name: `${r.name} (Nano Banana 2)`, value: r.value };
}

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-nodes-langchain",
"version": "2.43.0",
"version": "2.43.2",
"description": "",
"main": "index.js",
"exports": {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/scan-community-package",
"version": "0.38.0",
"version": "0.38.1",
"description": "Static code analyser for n8n community packages",
"license": "LicenseRef-n8n-sustainable-use",
"bin": "scanner/cli.mjs",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/scheduler",
"version": "1.12.0",
"version": "1.12.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/task-runner",
"version": "2.43.0",
"version": "2.43.2",
"scripts": {
"clean": "rimraf dist .turbo",
"start": "node dist/start.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/telemetry",
"version": "1.11.0",
"version": "1.11.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/utils",
"type": "module",
"version": "1.50.0",
"version": "1.50.1",
"files": [
"dist"
],
@ -41,13 +41,15 @@
},
"dependencies": {
"@n8n/constants": "workspace:*",
"nanoid": "catalog:"
"nanoid": "catalog:",
"semver": "catalog:"
},
"devDependencies": {
"@n8n/oxlint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@testing-library/jest-dom": "catalog:frontend",
"@types/semver": "catalog:",
"fast-check": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",

View File

@ -0,0 +1,58 @@
import { formatNodesApiLevel, parseNodesApiLevel } from './nodes-api-level';
describe('parseNodesApiLevel', () => {
it.each([
['3.1', { major: 3, minor: 1 }],
['3.0', { major: 3, minor: 0 }],
['3', { major: 3, minor: 0 }],
['3.10', { major: 3, minor: 10 }],
])('parses the string %p as %p', (value, expected) => {
expect(parseNodesApiLevel(value)).toMatchObject(expected);
});
it.each([
[1, { major: 1, minor: 0 }],
[3, { major: 3, minor: 0 }],
])('reads the integer %p as %p', (value, expected) => {
expect(parseNodesApiLevel(value)).toMatchObject(expected);
});
it.each([
0,
-1,
2.5,
3.1,
'0.0',
'0.1',
'01.1',
'03',
'3.01',
'3.',
'.1',
' 3.2 ',
'3.1.2',
'v3',
'',
null,
NaN,
Infinity,
true,
{},
])('rejects %p', (value) => {
expect(parseNodesApiLevel(value)).toBeNull();
});
it.each([2 ** 53, '9007199254740992', '1.9007199254740992'])(
'rejects the level %p above the safe integer range',
(value) => {
expect(parseNodesApiLevel(value)).toBeNull();
},
);
});
describe('formatNodesApiLevel', () => {
it('writes major and minor', () => {
expect(formatNodesApiLevel(parseNodesApiLevel('3.0')!)).toBe('3.0');
expect(formatNodesApiLevel(parseNodesApiLevel('3.10')!)).toBe('3.10');
});
});

View File

@ -0,0 +1,19 @@
import { coerce, type SemVer } from 'semver';
// A float is rejected, since an unquoted `3.10` in package.json is read as
// the number `3.1`. The input must print back from its coerced version as
// `<major>` or `<major>.<minor>`.
export function parseNodesApiLevel(value: unknown): SemVer | null {
const text = typeof value === 'number' && Number.isInteger(value) ? String(value) : value;
if (typeof text !== 'string') return null;
const version = coerce(text);
if (version === null || version.major < 1) return null;
if (text !== `${version.major}` && text !== `${version.major}.${version.minor}`) return null;
return version;
}
export function formatNodesApiLevel({ major, minor }: SemVer): string {
return `${major}.${minor}`;
}

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/workflow-sdk",
"version": "0.36.0",
"version": "0.36.2",
"description": "TypeScript SDK for programmatically creating n8n workflows",
"exports": {
".": {

View File

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "2.43.0",
"version": "2.43.4",
"description": "n8n Workflow Automation Tool",
"main": "dist/index",
"types": "dist/index.d.ts",

View File

@ -955,6 +955,7 @@ describe('ActiveWorkflowManager', () => {
mode,
undefined,
undefined,
'none',
);
expect(eventService.emit).toHaveBeenCalledWith('workflow-executed', {
@ -998,6 +999,7 @@ describe('ActiveWorkflowManager', () => {
mode,
undefined,
'wf-1:node-1:1700000000000',
'none',
);
});

View File

@ -214,6 +214,7 @@ describe('WaitTracker', () => {
projectId: project.id,
pushRef: execution.data.pushRef,
userId: undefined,
callerAwaitsOutcome: 'none',
},
false,
false,
@ -373,6 +374,7 @@ describe('WaitTracker', () => {
workflowData: execution.workflowData,
projectId: project.id,
pushRef: execution.data.pushRef,
callerAwaitsOutcome: 'none',
},
false,
false,
@ -393,6 +395,7 @@ describe('WaitTracker', () => {
workflowData: parentExecution.workflowData,
projectId: project.id,
pushRef: parentExecution.data.pushRef,
callerAwaitsOutcome: 'none',
startedAt: parentExecution.startedAt,
},
false,

View File

@ -1,6 +1,7 @@
import { Logger } from '@n8n/backend-common';
import { EventService, UrlService } from '@n8n/backend-services';
import { mockInstance } from '@n8n/backend-test-utils';
import { ExecutionsConfig, GlobalConfig, WorkflowsConfig } from '@n8n/config';
import { ExecutionsConfig, GlobalConfig, type NodesConfig, WorkflowsConfig } from '@n8n/config';
import type { WorkflowEntity, Project, WorkflowHistory } from '@n8n/db';
import {
ExecutionRepository,
@ -59,6 +60,8 @@ import {
} from '@/workflow-execute-additional-data';
import * as WorkflowHelpers from '@/workflow-helpers';
import { WorkflowHookContextService } from '@/workflow-hook-context.service';
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service';
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
@ -138,6 +141,20 @@ describe('WorkflowExecuteAdditionalData', () => {
mockInstance(DataTableProxyService);
mockInstance(WorkflowHookContextService);
const workflowPublishedDataService = mockInstance(WorkflowPublishedDataService);
const deprecatedNodeTypes = mock<NodeTypes>();
deprecatedNodeTypes.getByNameAndVersion.mockImplementation((type) =>
type === 'n8n-nodes-base.function'
? mock<INodeType>({ description: { deprecated: true } })
: mock<INodeType>(),
);
Container.set(
DeprecatedNodesValidationService,
new DeprecatedNodesValidationService(
mock<Logger>(),
mock<NodesConfig>({ blockDeprecated: true }),
deprecatedNodeTypes,
),
);
const workflowsConfig = Container.get(WorkflowsConfig);
afterEach(() => {
// Keep the default (flag off) for every test; flag-on tests opt in.
@ -1193,6 +1210,15 @@ describe('WorkflowExecuteAdditionalData', () => {
expect(result.id).toBe('parent-workflow-id');
},
);
it('refuses inline nodes of a deprecated type', async () => {
const nodes = [mock<INode>({ type: 'n8n-nodes-base.function', typeVersion: 1 })];
const workflowCode = mock<IWorkflowBase>({ nodes, connections: {} });
await expect(loadWorkflow({ code: workflowCode }, 'parent-workflow-id')).rejects.toThrow(
DeprecatedNodesError,
);
});
});
describe('getPublishedWorkflowData', () => {
@ -1414,6 +1440,17 @@ describe('WorkflowExecuteAdditionalData', () => {
workflowRepository.get.mockClear();
});
it('loads a stored workflow that contains a deprecated node', async () => {
const nodes = [mock<INode>({ type: 'n8n-nodes-base.function', typeVersion: 1 })];
workflowRepository.get.mockResolvedValue(
mock<WorkflowEntity>({ id: 'workflow-123', nodes, connections: {} }),
);
const result = await getDraftWorkflowData({ id: 'workflow-123' }, 'parent-workflow-id');
expect(result.nodes).toEqual(nodes);
});
it('should use draft version', async () => {
const activeVersionNodes: INode[] = [
mock<INode>({

View File

@ -1222,6 +1222,7 @@ describe('enqueueExecution', () => {
executionData: undefined,
pushRef: 'push-ref',
streamingEnabled: true,
callerAwaitsOutcome: 'response',
});
const error = new Error('stop for test purposes');
@ -1241,6 +1242,7 @@ describe('enqueueExecution', () => {
workflowId: 'workflow-xyz',
executionId: '1',
restartExecutionId,
callerAwaitsOutcome: 'response',
}),
expect.any(Object),
);

View File

@ -0,0 +1,23 @@
import { ResponseError } from '@n8n/errors';
export type DeprecatedNodeViolation = {
kind: 'added' | 'edited';
nodeName: string;
nodeType: string;
};
/**
* Thrown when a workflow adds or edits a node whose type is marked
* `deprecated: true`, on save or before an unsaved workflow or node runs.
* Lists the offending nodes in `meta.violations` so callers can act on them
* without parsing the error message.
*/
export class DeprecatedNodesError extends ResponseError {
constructor(
message: string,
readonly meta: { violations: DeprecatedNodeViolation[] },
) {
super(message, 400);
this.name = 'DeprecatedNodesError';
}
}

View File

@ -9,9 +9,9 @@ export class IncompatibleNodesApiVersionError extends ResponseError {
message: string,
readonly meta: {
/** API version the package requires, or `null` if the declared value is malformed. */
requiredNodesApiVersion: number | null;
requiredNodesApiVersion: string | null;
/** Node API version this runtime supports. */
supportedNodesApiVersion: number;
supportedNodesApiVersion: string;
},
cause?: unknown,
) {

View File

@ -6,6 +6,7 @@ import { ExecutionsConfig } from '@n8n/config';
import type { Project, User } from '@n8n/db';
import { ExecutionRepository, UserRepository } from '@n8n/db';
import { LifecycleMetadata } from '@n8n/decorators';
import type { WorkflowExecuteAfterContext } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { stringify } from 'flatted';
import {
@ -536,6 +537,67 @@ describe('Execution Lifecycle Hooks', () => {
Container.set(LifecycleMetadata, original);
}
});
describe('executedInThisProcess', () => {
class RecordingHandler {
contexts: WorkflowExecuteAfterContext[] = [];
async onWorkflowExecuteAfter(ctx: WorkflowExecuteAfterContext) {
this.contexts.push(ctx);
}
}
const recorder = new RecordingHandler();
const runModuleHandler = async (hooks: ExecutionLifecycleHooks) => {
await hooks.runHook('workflowExecuteAfter', [successfulRun, {}]);
return recorder.contexts.at(-1);
};
let original: LifecycleMetadata;
beforeEach(() => {
recorder.contexts = [];
const recording = new LifecycleMetadata();
recording.register({
handlerClass: RecordingHandler as unknown as HandlerClass,
methodName: 'onWorkflowExecuteAfter',
eventName: 'workflowExecuteAfter',
});
Container.set(RecordingHandler, recorder);
original = Container.get(LifecycleMetadata);
Container.set(LifecycleMetadata, recording);
});
afterEach(() => {
Container.set(LifecycleMetadata, original);
});
it('should be false for the hooks of a process that only enqueued the run', async () => {
const hooks = getLifecycleHooksForScalingMain(
{ executionMode: 'manual', workflowData },
executionId,
);
expect(await runModuleHandler(hooks)).toMatchObject({ executedInThisProcess: false });
});
it('should be true for the hooks of a process that runs the execution', async () => {
const workerHooks = getLifecycleHooksForScalingWorker(
{ executionMode: 'manual', workflowData },
executionId,
);
const regularHooks = getLifecycleHooksForRegularMain(
{ executionMode: 'manual', workflowData },
executionId,
);
expect(await runModuleHandler(workerHooks)).toMatchObject({ executedInThisProcess: true });
expect(await runModuleHandler(regularHooks)).toMatchObject({
executedInThisProcess: true,
});
});
});
});
describe('getLifecycleHooksForRegularMain', () => {

View File

@ -53,8 +53,13 @@ class ModulesHooksRegistry {
/**
* `source` is not carried on `ExecutionLifecycleHooks`, so it is passed in
* here to let module handlers tell agent-initiated runs from user ones.
* `executedInThisProcess` is false for the hooks of a process that only enqueued the run.
*/
addHooks(hooks: ExecutionLifecycleHooks, source?: IWorkflowExecutionDataProcess['source']) {
addHooks(
hooks: ExecutionLifecycleHooks,
source?: IWorkflowExecutionDataProcess['source'],
{ executedInThisProcess = true }: { executedInThisProcess?: boolean } = {},
) {
const handlers = Container.get(LifecycleMetadata).getHandlers();
for (const { handlerClass, methodName, eventName } of handlers) {
@ -72,6 +77,7 @@ class ModulesHooksRegistry {
executionId: this.executionId,
retryOf: this.retryOf,
source,
executedInThisProcess,
};
return await instance[methodName].call(instance, context);
@ -977,7 +983,7 @@ export function getLifecycleHooksForScalingMain(
hooks.handlers.nodeExecuteBefore = [];
hooks.handlers.nodeExecuteAfter = [];
Container.get(ModulesHooksRegistry).addHooks(hooks, source);
Container.get(ModulesHooksRegistry).addHooks(hooks, source, { executedInThisProcess: false });
return hooks;
}

View File

@ -396,6 +396,19 @@ describe('ExecutionService', () => {
const erroredRun = (error: boolean) =>
[{ error: error ? new Error('boom') : undefined }] as unknown as ITaskData[];
it('should mark the retry as awaited to completion', async () => {
const { service, workflowRunner } = buildRetryService();
executionPersistence.findWithUnflattenedData.mockResolvedValue(
buildCrashedExecution({ lastNodeExecuted: 'Some Node', runData: {} }),
);
await service.retry(retryArgs());
expect(workflowRunner.run).toHaveBeenCalledWith(
expect.objectContaining({ callerAwaitsOutcome: 'completion' }),
);
});
it('should not throw when retrying a crashed execution whose runData is undefined', async () => {
const { service, workflowRunner } = buildRetryService();
executionPersistence.findWithUnflattenedData.mockResolvedValue(

View File

@ -280,6 +280,8 @@ export class ExecutionService {
retryOf: executionId,
workflowData: execution.workflowData,
userId: user.id,
// The retry caller awaits the result below, so a worker must not park it.
callerAwaitsOutcome: 'completion',
};
const { lastNodeExecuted } = data.executionData!.resultData;

View File

@ -1,9 +1,20 @@
import type { ProviderCatalog } from '@n8n/agents';
import { buildModelRecommendationsSection } from '../agents-builder-model-recommendations';
import { buildBuilderPrompt } from '../agents-builder-prompts';
import {
buildModelRecommendationsSection,
getModelRecommendationsSection,
resetModelRecommendationsCacheForTest,
} from '../agents-builder-model-recommendations';
import { buildBuilderPrompt, buildBuilderSessionContext } from '../agents-builder-prompts';
import { getBuilderRuntimeSkills } from '../skills';
const { fetchProviderCatalog } = vi.hoisted(() => ({ fetchProviderCatalog: vi.fn() }));
vi.mock('@n8n/agents/catalog', async (importOriginal) => ({
...(await importOriginal<typeof import('@n8n/agents/catalog')>()),
fetchProviderCatalog,
}));
const catalog: ProviderCatalog = {
anthropic: {
id: 'anthropic',
@ -83,8 +94,8 @@ const catalog: ProviderCatalog = {
},
};
function buildPrompt(modelRecommendationsSection: string | null) {
return buildBuilderPrompt({
function buildSessionContext(modelRecommendationsSection: string | null) {
return buildBuilderSessionContext({
agentPreviewPath: '/projects/project-1/agents/agent-1/preview',
modelRecommendationsSection,
});
@ -107,7 +118,7 @@ describe('builder model recommendations', () => {
});
it('routes distinct target-agent functions into autonomously managed skills', () => {
const prompt = buildPrompt(null);
const prompt = buildBuilderPrompt();
const skill = getBuilderRuntimeSkills().find((s) => s.id === 'agent-builder-target-skills');
expect(prompt).toContain(
@ -145,7 +156,7 @@ describe('builder model recommendations', () => {
});
it('tells the builder to preserve fallback web search on model switches', () => {
const prompt = buildPrompt(null);
const prompt = buildBuilderPrompt();
expect(prompt).toContain(
'When changing models, preserve existing Brave or SearXNG\n `config.webSearch` unchanged',
@ -160,7 +171,7 @@ describe('builder model recommendations', () => {
});
it('defers custom tool builder guidance to the agent-builder-custom-tools skill', () => {
const prompt = buildPrompt(null);
const prompt = buildBuilderPrompt();
const skill = getBuilderRuntimeSkills().find((s) => s.id === 'agent-builder-custom-tools');
expect(prompt).not.toContain("import { Tool } from '@n8n/agents';");
@ -178,13 +189,14 @@ describe('builder model recommendations', () => {
);
});
it('injects the recommendation section only into the LLM selection prompt', () => {
it('injects the recommendation section into the session context, not the static prompt', () => {
const section = buildModelRecommendationsSection(catalog);
expect(buildPrompt(section)).toContain('### Recommended LLM Models');
expect(buildPrompt(section)).toContain('`openai/gpt-5` GPT-5');
expect(buildPrompt(null)).not.toContain('### Recommended LLM Models');
expect(buildPrompt(null)).toContain('do not recommend or name');
expect(buildSessionContext(section)).toContain('### Recommended LLM Models');
expect(buildSessionContext(section)).toContain('`openai/gpt-5` GPT-5');
expect(buildBuilderPrompt()).not.toContain('### Recommended LLM Models');
expect(buildSessionContext(null)).not.toContain('### Recommended LLM Models');
expect(buildSessionContext(null)).toContain('do not recommend or name');
});
it('does not tell the builder to prefer Slack OAuth credentials for chat integrations', () => {
@ -196,3 +208,39 @@ describe('builder model recommendations', () => {
expect(externalServicesSkill?.instructions).not.toContain('prefer the OAuth variant');
});
});
describe('getModelRecommendationsSection', () => {
beforeEach(() => {
resetModelRecommendationsCacheForTest();
fetchProviderCatalog.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('caches a successful catalog fetch', async () => {
fetchProviderCatalog.mockResolvedValue(catalog);
const first = await getModelRecommendationsSection();
const second = await getModelRecommendationsSection();
expect(first).toContain('### Recommended LLM Models');
expect(second).toBe(first);
expect(fetchProviderCatalog).toHaveBeenCalledTimes(1);
});
it('waits before it retries a failed fetch, so turns do not each wait on the timeout', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
fetchProviderCatalog.mockRejectedValueOnce(new Error('offline')).mockResolvedValue(catalog);
expect(await getModelRecommendationsSection()).toBeNull();
expect(await getModelRecommendationsSection()).toBeNull();
expect(fetchProviderCatalog).toHaveBeenCalledTimes(1);
vi.setSystemTime(Date.now() + 10 * 60 * 1000 + 1);
expect(await getModelRecommendationsSection()).toContain('### Recommended LLM Models');
expect(fetchProviderCatalog).toHaveBeenCalledTimes(2);
});
});

View File

@ -1,15 +1,12 @@
import { AGENT_UI_LABELS_SECTION, buildBuilderPrompt } from '../agents-builder-prompts';
function buildPrompt() {
return buildBuilderPrompt({
agentPreviewPath: '/projects/p1/agents/a1?openPreview=true',
modelRecommendationsSection: null,
});
}
import {
AGENT_UI_LABELS_SECTION,
buildBuilderPrompt,
buildBuilderSessionContext,
} from '../agents-builder-prompts';
describe('buildBuilderPrompt', () => {
it('teaches the builder the Sessions tab labels after conversation-mode guidance', () => {
const prompt = buildPrompt();
const prompt = buildBuilderPrompt();
expect(prompt).toContain('## Agent UI labels');
expect(prompt).toContain('Sessions tab');
@ -24,4 +21,41 @@ describe('buildBuilderPrompt', () => {
expect(conversationModeIndex).toBeGreaterThan(-1);
expect(uiLabelsIndex).toBeGreaterThan(conversationModeIndex);
});
it('stays free of per-agent and per-process values so builds share one cached prompt', () => {
const prompt = buildBuilderPrompt();
expect(buildBuilderPrompt()).toBe(prompt);
expect(prompt).not.toContain('/projects/');
expect(prompt).not.toContain('### Recommended LLM Models');
expect(prompt).toContain('Preview link from the Session context');
});
});
describe('buildBuilderSessionContext', () => {
it('carries the agent Preview link', () => {
const context = buildBuilderSessionContext({
agentPreviewPath: '/projects/p1/agents/a1?openPreview=true',
modelRecommendationsSection: null,
});
expect(context).toContain('## Session context');
expect(context).toContain('[Preview](/projects/p1/agents/a1?openPreview=true)');
});
it('includes the model recommendations, or the no-recommendations rule when absent', () => {
const withSection = buildBuilderSessionContext({
agentPreviewPath: '/p',
modelRecommendationsSection: '### Recommended LLM Models\n\n- Anthropic: x',
});
const withoutSection = buildBuilderSessionContext({
agentPreviewPath: '/p',
modelRecommendationsSection: null,
});
expect(withSection).toContain('### Recommended LLM Models');
expect(withSection).not.toContain('do not recommend or name');
expect(withoutSection).not.toContain('### Recommended LLM Models');
expect(withoutSection).toContain('do not recommend or name');
});
});

View File

@ -28,6 +28,7 @@ const agentsSdkMocks = vi.hoisted(() => {
}> = [];
const resumeCalls: Array<{ options: Record<string, unknown> }> = [];
const instructionsCalls: string[] = [];
const volatileInstructionsProviders: Array<() => Promise<string | undefined>> = [];
const registeredToolNames: string[] = [];
const modelCalls: unknown[] = [];
const configurationCalls: Array<{ maxIterations?: number }> = [];
@ -66,6 +67,10 @@ const agentsSdkMocks = vi.hoisted(() => {
instructionsCalls.push(text);
return this;
}
volatileInstructionsProvider(provider: () => Promise<string | undefined>) {
volatileInstructionsProviders.push(provider);
return this;
}
skills(_skills: unknown) {
return this;
}
@ -129,6 +134,7 @@ const agentsSdkMocks = vi.hoisted(() => {
streamCalls,
resumeCalls,
instructionsCalls,
volatileInstructionsProviders,
registeredToolNames,
modelCalls,
configurationCalls,
@ -238,6 +244,7 @@ describe('AgentsBuilderService session isolation', () => {
agentsSdkMocks.streamCalls.length = 0;
agentsSdkMocks.resumeCalls.length = 0;
agentsSdkMocks.instructionsCalls.length = 0;
agentsSdkMocks.volatileInstructionsProviders.length = 0;
agentsSdkMocks.registeredToolNames.length = 0;
agentsSdkMocks.modelCalls.length = 0;
agentsSdkMocks.configurationCalls.length = 0;
@ -524,6 +531,29 @@ describe('AgentsBuilderService session isolation', () => {
expect(agentsSdkMocks.configurationCalls).toEqual([{ maxIterations: 100 }]);
});
it('keeps the per-agent Preview path out of the cached instructions', async () => {
const { service, user, credentialProvider, credentialService } = setup();
await drain(
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
baseSession,
),
);
expect(agentsSdkMocks.instructionsCalls[0]).not.toContain('/projects/project-1/agents/agent-1');
expect(agentsSdkMocks.volatileInstructionsProviders).toHaveLength(1);
const sessionContext = await agentsSdkMocks.volatileInstructionsProviders[0]?.();
expect(sessionContext).toContain(
'[Preview](/projects/project-1/agents/agent-1?openPreview=true)',
);
});
it('enables prompt caching with a 5m Anthropic TTL for the builder agent', async () => {
const { service, user, credentialProvider, credentialService } = setup();

View File

@ -1,6 +1,8 @@
import { fetchProviderCatalog, type ModelInfo, type ProviderCatalog } from '@n8n/agents/catalog';
const MODEL_RECOMMENDATION_FETCH_TIMEOUT_MS = 5000;
/** After a failed fetch, skip retries for this long so each builder turn does not wait on the timeout. */
const MODEL_RECOMMENDATION_RETRY_AFTER_MS = 10 * 60 * 1000;
const MAX_RECOMMENDED_MODELS_PER_PROVIDER = 3;
interface RecommendedProvider {
@ -18,6 +20,7 @@ const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
let modelRecommendationsSection: string | null | undefined;
let modelRecommendationsPromise: Promise<string | null> | undefined;
let modelRecommendationsRetryAt = 0;
function getReleaseTime(model: ModelInfo): number | null {
if (!model.releaseDate) return null;
@ -110,6 +113,7 @@ async function fetchProviderCatalogWithTimeout(): Promise<ProviderCatalog | null
export async function getModelRecommendationsSection(): Promise<string | null> {
if (modelRecommendationsSection !== undefined) return modelRecommendationsSection;
if (!modelRecommendationsPromise && Date.now() < modelRecommendationsRetryAt) return null;
modelRecommendationsPromise ??= fetchProviderCatalogWithTimeout()
.then((catalog) => (catalog ? buildModelRecommendationsSection(catalog) : null))
@ -119,6 +123,17 @@ export async function getModelRecommendationsSection(): Promise<string | null> {
});
const section = await modelRecommendationsPromise;
if (section) modelRecommendationsSection = section;
if (section) {
modelRecommendationsSection = section;
} else {
modelRecommendationsRetryAt = Date.now() + MODEL_RECOMMENDATION_RETRY_AFTER_MS;
}
return section;
}
/** Test-only: clear the module-level cache. */
export function resetModelRecommendationsCacheForTest(): void {
modelRecommendationsSection = undefined;
modelRecommendationsPromise = undefined;
modelRecommendationsRetryAt = 0;
}

View File

@ -1,6 +1,6 @@
import { getConfigMutationPrompt } from './prompts/config-mutation.prompt';
import { INITIAL_BUILD_SECTION } from './prompts/initial-build.prompt';
import { getLlmSelectionPrompt } from './prompts/llm-selection.prompt';
import { LLM_SELECTION_PROMPT } from './prompts/llm-selection.prompt';
import { MEMORY_PROMPT } from './prompts/memory.prompt';
import { TOOLS_PROMPT } from './prompts/tools.prompt';
@ -49,8 +49,7 @@ Teams):
When the user asks to change the target agent's channels, prefer a supported
one from the list; never invent a type.`;
export function getConversationModeSection(agentPreviewPath: string): string {
return `\
export const CONVERSATION_MODE_SECTION = `\
## When To Build vs When To Converse
Not every user message is a build request. Before changing config or creating
@ -75,17 +74,17 @@ message it from the connected platform to verify the channel.
Standard tool approvals pause \`call_agent\` until the user approves or rejects them in this chat.
If it returns \`approval_required\` for an unsupported interaction, explain that it cannot be
completed here and direct the user to [Preview](${agentPreviewPath}) to run it again.
completed here and direct the user to the Preview link from the Session context
section to run it again.
After a successful build or config change that leaves the agent ready to try,
include the same [Preview](${agentPreviewPath}) markdown link in your wrap-up
(it can be part of a longer reply). Keep Preview links as relative app paths
and do not invent a different path.
include that same Preview markdown link in your wrap-up (it can be part of a
longer reply). Keep Preview links as relative app paths and do not invent a
different path.
Never write empty or placeholder \`instructions\`. When the user gave a
concrete goal, write real instructions from it and fill gaps with sensible assumptions
stated in your summary. Only ask first when the overall goal itself is missing.`;
}
export const AGENT_UI_LABELS_SECTION = `\
## Agent UI labels
@ -376,23 +375,30 @@ follow-up for the credential.
4. After a successful publish, confirm the agent is live; do not send the user to the editor
Publish button.`;
export interface BuilderPromptContext {
export interface BuilderSessionContext {
agentPreviewPath: string;
modelRecommendationsSection: string | null;
}
export function buildBuilderPrompt(ctx: BuilderPromptContext): string {
const { agentPreviewPath, modelRecommendationsSection } = ctx;
const NO_MODEL_RECOMMENDATIONS =
'No Recommended LLM models section is available; do not recommend or name current, best, latest, or fallback model IDs from memory. Ask via `ask_questions` when the user needs model guidance or choice.';
/**
* The static builder system prompt. It must not contain any per-agent or
* per-process value: Anthropic caches the prompt by exact prefix, so a
* byte-identical prompt lets every build share one cached copy. Put dynamic
* values in `buildBuilderSessionContext` instead.
*/
export function buildBuilderPrompt(): string {
const sections = [
'You are an expert agent builder. You help users create and configure AI agents by writing raw JSON configuration and building custom tools.',
TARGET_AGENT_SECTION,
PREREQUISITES_SECTION,
SUPPORTED_CHANNELS_SECTION,
getConversationModeSection(agentPreviewPath),
CONVERSATION_MODE_SECTION,
AGENT_UI_LABELS_SECTION,
getConfigMutationPrompt(),
getLlmSelectionPrompt(modelRecommendationsSection),
LLM_SELECTION_PROMPT,
MEMORY_PROMPT,
TOOLS_PROMPT,
INTERACTIVE_TOOLS_SECTION,
@ -405,3 +411,19 @@ export function buildBuilderPrompt(ctx: BuilderPromptContext): string {
return sections.join('\n\n');
}
/**
* Per-session values for the builder. The runtime sends this as a separate
* system block after the cached static prompt, so a change here does not
* invalidate the cached prompt.
*/
export function buildBuilderSessionContext(ctx: BuilderSessionContext): string {
const { agentPreviewPath, modelRecommendationsSection } = ctx;
return `\
## Session context
- Preview link for the target agent: [Preview](${agentPreviewPath})
${modelRecommendationsSection ?? NO_MODEL_RECOMMENDATIONS}`;
}

View File

@ -35,7 +35,7 @@ import { AgentsService } from '../agents.service';
import { modelStreamStallOptions } from '../model-stream-stall-options';
import { buildAgentPreviewPath } from './agent-builder-preview-path';
import { getModelRecommendationsSection } from './agents-builder-model-recommendations';
import { buildBuilderPrompt } from './agents-builder-prompts';
import { buildBuilderPrompt, buildBuilderSessionContext } from './agents-builder-prompts';
import { AgentsBuilderToolsService, type BuilderTools } from './agents-builder-tools.service';
import { BuilderCheckpointUnavailableError } from './errors';
import {
@ -242,7 +242,8 @@ export class AgentsBuilderService {
// always runs on it directly.
const modelConfig = session.modelConfig;
const finalInstructions = await this.createBuilderInstructions(projectId, agentId, session);
const finalInstructions = this.createBuilderInstructions(session);
const sessionContext = await this.createBuilderSessionContext(projectId, agentId);
const runtimeSkills = getBuilderRuntimeSkills();
const tools = this.agentsBuilderToolsService.getTools(
@ -264,6 +265,8 @@ export class AgentsBuilderService {
const builder = new Agent('agent-builder')
.model(modelConfig)
.instructions(finalInstructions)
// Sent after the cached instructions so per-agent values do not break the cache.
.volatileInstructionsProvider(async () => sessionContext)
.skills(runtimeSkills)
.memory(builderMemory)
.checkpoint(this.n8nCheckpointStorage.getStorage(agentId))
@ -336,21 +339,21 @@ export class AgentsBuilderService {
}
}
private async createBuilderInstructions(
projectId: string,
agentId: string,
session: InstanceAiBuilderSessionOptions,
): Promise<string> {
const modelRecommendationsSection = await getModelRecommendationsSection();
const instructions = buildBuilderPrompt({
agentPreviewPath: buildAgentPreviewPath(projectId, agentId),
modelRecommendationsSection,
});
/** Static per addendum, so every build with the same addendum shares one cached prompt. */
private createBuilderInstructions(session: InstanceAiBuilderSessionOptions): string {
const instructions = buildBuilderPrompt();
return session.instructionsAddendum
? `${instructions}\n\n${session.instructionsAddendum}`
: instructions;
}
private async createBuilderSessionContext(projectId: string, agentId: string): Promise<string> {
return buildBuilderSessionContext({
agentPreviewPath: buildAgentPreviewPath(projectId, agentId),
modelRecommendationsSection: await getModelRecommendationsSection(),
});
}
private async createBuilderMemory(
agentId: string,
user: User,

View File

@ -1,9 +1,8 @@
export function getLlmSelectionPrompt(modelRecommendationsSection: string | null): string {
const recommendationGuidance = modelRecommendationsSection
? `\n\n${modelRecommendationsSection}`
: '\n\nNo Recommended LLM models section is available; do not recommend or name current, best, latest, or fallback model IDs from memory. Ask via `ask_questions` when the user needs model guidance or choice.';
return `\
/**
* Static: the live model recommendations are session context (see
* `buildBuilderSessionContext`), so this text stays byte-identical across builds.
*/
export const LLM_SELECTION_PROMPT = `\
## LLM Selection Guidance
### Purpose
@ -43,6 +42,7 @@ Use this to resolve the target agent's main \`model\` and \`credential\`.
- If the user explicitly asks for Brave or SearXNG, keep that provider even
when the selected model also supports native search.
- For "Anthropic via OpenRouter", pass \`provider: "openrouter"\`; if the user names a routed model, pass the routed id without adding another provider prefix.
- Model recommendations are in the "Recommended LLM Models" part of the Session context section.
- Prefer a provider the user already has credentials for when choosing from recommendations.
- Never copy main LLM credential IDs from \`list_credentials\`.
@ -59,5 +59,4 @@ Use this to resolve the target agent's main \`model\` and \`credential\`.
- The persisted \`model\` is in \`provider/model\` form.
- The persisted \`credential\` came from \`resolve_llm\`.
- Existing Brave or SearXNG \`config.webSearch\` is preserved on model changes unless the user explicitly requested a web-search method change.${recommendationGuidance}`;
}
- Existing Brave or SearXNG \`config.webSearch\` is preserved on model changes unless the user explicitly requested a web-search method change.`;

View File

@ -31,7 +31,7 @@ export const INSTANCE_AI_BUILDER_ADDENDUM = `## Instance AI session rules
You are running as a sub-agent inside n8n's instance AI chat; the user sees your questions as chat cards.
Preview links work in this chat. Include a markdown Preview link after a successful build and when \`call_agent\` reports an unsupported interaction as \`approval_required\`, using the exact relative path from "When To Build vs When To Converse" (form: \`[Preview](<path>)\`). Do not invent absolute URLs. Do not omit the link and describe the path in plain text instead.
Preview links work in this chat. Include a markdown Preview link after a successful build and when \`call_agent\` reports an unsupported interaction as \`approval_required\`, using the exact relative path from the "Session context" section (form: \`[Preview](<path>)\`). Do not invent absolute URLs. Do not omit the link and describe the path in plain text instead.
When you mention the agent editor, say Sessions tab for history and Preview for live chat. Never say Runs, Executions, or Activity History for agents.

View File

@ -171,8 +171,12 @@ describe('ChatHubExecutionWatcherService', () => {
...overrides,
}) as IRun;
const createAfterContext = (executionId: string, runData: IRun): WorkflowExecuteAfterContext =>
({ executionId, runData }) as WorkflowExecuteAfterContext;
const createAfterContext = (
executionId: string,
runData: IRun,
{ executedInThisProcess = true }: { executedInThisProcess?: boolean } = {},
): WorkflowExecuteAfterContext =>
({ executionId, runData, executedInThisProcess }) as WorkflowExecuteAfterContext;
it('should skip if context not found', async () => {
executionStore.get.mockResolvedValue(null);
@ -457,9 +461,50 @@ describe('ChatHubExecutionWatcherService', () => {
createMessageOnResume: true,
});
});
it('should handle a segment once when the enqueuing process also reports it', async () => {
let stored = createContext({ responseMode: 'responseNodes' });
executionStore.get.mockImplementation(async () => stored);
executionStore.update.mockImplementation(async (_executionId, updates) => {
stored = { ...stored, ...updates };
});
chatHubExecutionService.extractMessage.mockReturnValue('Found the incident.');
executionPersistence.findSingleExecution.mockResolvedValue(
createExecution('@n8n/n8n-nodes-langchain.chat', { operation: 'send' }),
);
const runData = createRunData({ status: 'waiting' });
await service.handleWorkflowExecuteAfter(createAfterContext(EXECUTION_ID, runData));
await service.handleWorkflowExecuteAfter(
createAfterContext(EXECUTION_ID, runData, { executedInThisProcess: false }),
);
expect(messageRepository.createChatMessage).toHaveBeenCalledTimes(1);
expect(chatStreamService.endExecution).toHaveBeenCalledTimes(1);
expect(executionManager.runWorkflow).toHaveBeenCalledTimes(1);
expect(messageRepository.updateChatMessage).not.toHaveBeenCalledWith(
MOCK_NEW_MESSAGE_ID,
expect.anything(),
);
});
});
});
it('should ignore a call from a process that only enqueued the run', async () => {
executionStore.get.mockResolvedValue(createContext({ responseMode: 'responseNodes' }));
await service.handleWorkflowExecuteAfter(
createAfterContext(EXECUTION_ID, createRunData({ status: 'waiting' }), {
executedInThisProcess: false,
}),
);
expect(executionStore.get).not.toHaveBeenCalled();
expect(messageRepository.updateChatMessage).not.toHaveBeenCalled();
expect(chatStreamService.endExecution).not.toHaveBeenCalled();
expect(executionManager.runWorkflow).not.toHaveBeenCalled();
});
it('should not remove context when finished is false', async () => {
const context = createContext();
executionStore.get.mockResolvedValue(context);

View File

@ -1,4 +1,5 @@
import type { Logger } from '@n8n/backend-common';
import type { NodesConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import type { EntityManager } from '@n8n/typeorm';
import type { INode, INodeType } from 'n8n-workflow';
@ -6,7 +7,9 @@ import { v4 as uuid } from 'uuid';
import { mock } from 'vitest-mock-extended';
import { BadRequestError, NotFoundError } from '@n8n/errors';
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
import type { NodeTypes } from '@/node-types';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import type { ChatHubTool } from '../chat-hub-tool.entity';
import type { ChatHubToolRepository } from '../chat-hub-tool.repository';
@ -73,7 +76,16 @@ describe('ChatHubToolService', () => {
description: { properties: [] },
} as unknown as INodeType);
service = new ChatHubToolService(logger, chatToolRepository, nodeTypes);
service = new ChatHubToolService(
logger,
chatToolRepository,
nodeTypes,
new DeprecatedNodesValidationService(
logger,
mock<NodesConfig>({ blockDeprecated: true }),
nodeTypes,
),
);
});
describe('getToolsByUserId', () => {
@ -124,7 +136,34 @@ describe('ChatHubToolService', () => {
});
});
const deprecatedCodeTool: INode = {
...mockDefinition,
type: '@n8n/n8n-nodes-langchain.code',
parameters: { code: { supplyData: { code: 'return 1;' } } },
};
const markCodeNodeDeprecated = () => {
nodeTypes.getByNameAndVersion.mockImplementation(
(type) =>
({
description: {
properties: [],
deprecated: type === deprecatedCodeTool.type ? true : undefined,
},
}) as unknown as INodeType,
);
};
describe('createTool', () => {
it('rejects a tool of a deprecated node type', async () => {
markCodeNodeDeprecated();
await expect(
service.createTool(mockUser, { definition: deprecatedCodeTool }),
).rejects.toThrow(DeprecatedNodesError);
expect(chatToolRepository.createTool).not.toHaveBeenCalled();
});
it('should create a tool from the definition', async () => {
const created = makeTool();
chatToolRepository.createTool.mockResolvedValue(created);
@ -288,6 +327,40 @@ describe('ChatHubToolService', () => {
});
describe('updateTool', () => {
it('rejects changing a tool of a deprecated node type', async () => {
markCodeNodeDeprecated();
chatToolRepository.getOneById.mockResolvedValue(makeTool({ definition: deprecatedCodeTool }));
await expect(
service.updateTool('tool-1', mockUser, {
definition: { ...deprecatedCodeTool, name: 'Renamed tool' },
}),
).rejects.toThrow(DeprecatedNodesError);
expect(chatToolRepository.updateTool).not.toHaveBeenCalled();
});
it('rejects enabling a stored tool of a deprecated node type', async () => {
markCodeNodeDeprecated();
chatToolRepository.getOneById.mockResolvedValue(
makeTool({ definition: deprecatedCodeTool, enabled: false }),
);
await expect(service.updateTool('tool-1', mockUser, { enabled: true })).rejects.toThrow(
DeprecatedNodesError,
);
expect(chatToolRepository.updateTool).not.toHaveBeenCalled();
});
it('allows disabling a stored tool of a deprecated node type', async () => {
markCodeNodeDeprecated();
chatToolRepository.getOneById.mockResolvedValue(makeTool({ definition: deprecatedCodeTool }));
chatToolRepository.updateTool.mockResolvedValue(makeTool({ enabled: false }));
await service.updateTool('tool-1', mockUser, { enabled: false });
expect(chatToolRepository.updateTool).toHaveBeenCalled();
});
it('should update tool definition and denormalized fields', async () => {
const existingTool = makeTool();
const updatedDef: INode = { ...mockDefinition, name: 'Updated Tool' };

View File

@ -7,7 +7,9 @@ import { type IBinaryData, type INode, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workfl
import type { Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
import type { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { ChatHubAgent } from '../chat-hub-agent.entity';
@ -38,6 +40,7 @@ describe('ChatHubWorkflowService', () => {
const mockCipher = mock<Cipher>();
const policyEnforcementService = mock<PolicyEnforcementService>();
const deprecatedNodesValidationService = mock<DeprecatedNodesValidationService>();
let chatHubAttachmentService: ChatHubAttachmentService;
let service: ChatHubWorkflowService;
@ -72,6 +75,7 @@ describe('ChatHubWorkflowService', () => {
workflowFinderService,
mockCipher,
policyEnforcementService,
deprecatedNodesValidationService,
);
// Mock repository methods
@ -162,6 +166,32 @@ describe('ChatHubWorkflowService', () => {
expect(workflowRepository.createContent).not.toHaveBeenCalled();
});
it('writes nothing when the workflow contains a deprecated node', async () => {
deprecatedNodesValidationService.validateOnCreate.mockImplementationOnce(() => {
throw new DeprecatedNodesError('deprecated', { violations: [] });
});
await expect(
service.createChatWorkflow(
'user-123',
'session-456',
'project-789',
[],
'Hello',
[],
{},
{ provider: 'openai', model: 'gpt-4' },
undefined,
[],
'UTC',
null,
defaultExecutionMetadata,
),
).rejects.toThrow(DeprecatedNodesError);
expect(workflowRepository.createContent).not.toHaveBeenCalled();
});
it('enforces the save for the title-generation workflow', async () => {
await service.createTitleGenerationWorkflow(
'user-123',
@ -1363,6 +1393,7 @@ describe('ChatHubWorkflowService', () => {
workflowFinderService,
mockCipher,
policyEnforcementService,
deprecatedNodesValidationService,
);
const mockTrx = mock<EntityManager>();

View File

@ -1483,6 +1483,7 @@ describe('chatHub', () => {
runData,
newStaticData: {},
executionId,
executedInThisProcess: true,
});
});
@ -1601,6 +1602,7 @@ describe('chatHub', () => {
runData,
newStaticData: {},
executionId,
executedInThisProcess: true,
});
});
@ -1733,6 +1735,7 @@ describe('chatHub', () => {
runData,
newStaticData: {},
executionId,
executedInThisProcess: true,
});
});
@ -1874,6 +1877,7 @@ describe('chatHub', () => {
runData,
newStaticData: {},
executionId,
executedInThisProcess: true,
});
});
@ -1930,6 +1934,7 @@ describe('chatHub', () => {
runData,
newStaticData: {},
executionId: capturedExecutionId,
executedInThisProcess: true,
});
});
});
@ -2064,6 +2069,7 @@ describe('chatHub', () => {
runData,
newStaticData: {},
executionId,
executedInThisProcess: true,
});
});
@ -2205,6 +2211,7 @@ describe('chatHub', () => {
runData,
newStaticData: {},
executionId,
executedInThisProcess: true,
});
});
@ -2298,6 +2305,7 @@ describe('chatHub', () => {
runData,
newStaticData: {},
executionId: capturedExecutionId,
executedInThisProcess: true,
});
});
});
@ -2449,6 +2457,7 @@ describe('chatHub', () => {
runData,
newStaticData: {},
executionId,
executedInThisProcess: true,
});
});
@ -2658,6 +2667,7 @@ describe('chatHub', () => {
runData,
newStaticData: {},
executionId,
executedInThisProcess: true,
});
});
@ -2793,6 +2803,7 @@ describe('chatHub', () => {
runData,
newStaticData: {},
executionId,
executedInThisProcess: true,
});
});
@ -2909,6 +2920,7 @@ describe('chatHub', () => {
runData,
newStaticData: {},
executionId,
executedInThisProcess: true,
});
});
@ -3025,6 +3037,7 @@ describe('chatHub', () => {
runData,
newStaticData: {},
executionId,
executedInThisProcess: true,
});
});

View File

@ -117,6 +117,11 @@ export class ChatHubExecutionWatcherService {
async handleWorkflowExecuteAfter(ctx: WorkflowExecuteAfterContext): Promise<void> {
const { runData, executionId } = ctx;
// An auto-resume enqueues the next segment from the worker, so that worker also waits
// for the job and gets a second call for the same segment. The process that ran the
// segment handles it.
if (!ctx.executedInThisProcess) return;
const context = await this.executionStore.get(executionId);
if (!context) return; // Not a tracked chat hub execution
@ -144,10 +149,7 @@ export class ChatHubExecutionWatcherService {
return;
}
// NOTE: This check is required because on multi-main/queue mode, the resumed execution's
// 'workflowExecuteAfter' hook fires one more time after it should have stopped in 'waiting'
// state on a Chat response node. On this final hook call the runData.status is 'success' even though
// the execution isn't finished. On single-main mode this does not happen.
// A `success` run that has not finished must not end the conversation.
// oxlint-disable-next-line typescript/no-deprecated
if (runData.finished) {
await this.pushFinalResults(context, message);

View File

@ -11,6 +11,7 @@ import { collectExpressionDefaults, findDisallowedChatToolExpressions } from 'n8
import { BadRequestError, NotFoundError } from '@n8n/errors';
import { NodeTypes } from '@/node-types';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import type { ChatHubTool } from './chat-hub-tool.entity';
import { ChatHubToolRepository } from './chat-hub-tool.repository';
@ -21,6 +22,7 @@ export class ChatHubToolService {
private readonly logger: Logger,
private readonly chatToolRepository: ChatHubToolRepository,
private readonly nodeTypes: NodeTypes,
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
) {
this.logger = this.logger.scoped('chat-hub');
}
@ -85,6 +87,7 @@ export class ChatHubToolService {
async createTool(user: User, data: ChatHubCreateToolRequest): Promise<ChatHubTool> {
const definition = data.definition;
this.validateToolExpressions(definition);
this.deprecatedNodesValidationService.validateOnCreate([definition]);
const tool = await this.chatToolRepository.createTool({
id: definition.id,
@ -117,12 +120,21 @@ export class ChatHubToolService {
if (updates.definition !== undefined) {
this.validateToolExpressions(updates.definition);
this.deprecatedNodesValidationService.validateOnUpdate(
[updates.definition],
[existingTool.definition],
);
updateData.definition = updates.definition;
updateData.name = updates.definition.name;
updateData.type = updates.definition.type;
updateData.typeVersion = updates.definition.typeVersion ?? 1;
}
if (updates.enabled !== undefined) {
if (updates.enabled) {
this.deprecatedNodesValidationService.validateOnCreate([
updates.definition ?? existingTool.definition,
]);
}
updateData.enabled = updates.enabled;
}

View File

@ -52,6 +52,7 @@ import { v4 as uuidv4 } from 'uuid';
import { BadRequestError } from '@n8n/errors';
import type { UserLike } from '@/types/user-like.types';
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { ChatHubAgentRepository } from './chat-hub-agent.repository';
@ -96,6 +97,7 @@ export class ChatHubWorkflowService {
private readonly workflowFinderService: WorkflowFinderService,
private readonly cipher: Cipher,
private readonly policyEnforcementService: PolicyEnforcementService,
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
) {
this.logger = this.logger.scoped('chat-hub');
}
@ -196,6 +198,8 @@ export class ChatHubWorkflowService {
projectId: string,
user: UserLike,
) {
this.deprecatedNodesValidationService.validateOnCreate(workflow.nodes, workflow.id);
return await this.policyEnforcementService.enforceWorkflowSave(
{
workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes },

View File

@ -7,7 +7,7 @@ import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { Container } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
import { N8N_NODES_API_VERSION } from 'n8n-workflow';
import { N8N_NODES_API_VERSION, parseNodesApiLevel } from 'n8n-workflow';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'path';
@ -305,9 +305,10 @@ describe('node API compatibility at startup', () => {
});
test('boots with an incompatible package on disk and registers no loader for it', async () => {
const { major, minor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
writePackage('n8n-nodes-future', {
nodes: ['dist/nodes/Future.node.js'],
n8nNodesApiVersion: N8N_NODES_API_VERSION + 1,
n8nNodesApiVersion: `${major}.${minor + 1}`,
});
writePackage('n8n-nodes-good');

View File

@ -310,7 +310,7 @@ describe('CommunityPackagesLifecycleService', () => {
communityPackagesService.installPackage.mockRejectedValue(
new IncompatibleNodesApiVersionError(
'This community node requires n8n node API version 3, but this instance supports up to 1.',
{ requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
{ requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
),
);
@ -318,7 +318,7 @@ describe('CommunityPackagesLifecycleService', () => {
await expect(promise).rejects.toBeInstanceOf(IncompatibleNodesApiVersionError);
await expect(promise).rejects.toMatchObject({
httpStatusCode: 400,
meta: { requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
meta: { requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
message:
'This community node requires n8n node API version 3, but this instance supports up to 1.',
});
@ -666,7 +666,7 @@ describe('CommunityPackagesLifecycleService', () => {
communityPackagesService.updatePackage.mockRejectedValue(
new IncompatibleNodesApiVersionError(
"This community node isn't compatible with your version of n8n. Update n8n to use it.",
{ requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
{ requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
),
);
@ -678,7 +678,7 @@ describe('CommunityPackagesLifecycleService', () => {
await expect(promise).rejects.toBeInstanceOf(IncompatibleNodesApiVersionError);
await expect(promise).rejects.toMatchObject({
httpStatusCode: 400,
meta: { requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
meta: { requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
});
});
@ -691,8 +691,8 @@ describe('CommunityPackagesLifecycleService', () => {
communityPackagesService.findInstalledPackage.mockResolvedValue(mockPackage('1.0.0'));
communityPackagesService.updatePackage.mockRejectedValue(
new IncompatibleNodesApiVersionError('Not compatible', {
requiredNodesApiVersion: 3,
supportedNodesApiVersion: 1,
requiredNodesApiVersion: '3',
supportedNodesApiVersion: '1',
}),
);

View File

@ -3,6 +3,7 @@ import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
import { mockInstance } from '@n8n/backend-test-utils';
import type { InstanceSettings, PackageDirectoryLoader } from 'n8n-core';
import { N8N_NODES_API_VERSION } from '@n8n/constants';
import { parseNodesApiLevel } from 'n8n-workflow';
import { execFile } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
@ -34,6 +35,9 @@ vi.mock('../npm-utils', async () => ({
const PACKAGE_NAME = 'n8n-nodes-test';
const TARBALL_NAME = `${PACKAGE_NAME}-2.0.0.tgz`;
const { major: supportedMajor, minor: supportedMinor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
const oneMinorAboveSupported = `${supportedMajor}.${supportedMinor + 1}`;
/**
* Exercises the install rollback against a real filesystem rather than mocked `fs`
* calls. The bug being guarded here is a directory that is deleted with no backup
@ -264,7 +268,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
};
test('rejects an update and leaves directory, ledger, and database unchanged', async () => {
await expect(updateToIncompatible(N8N_NODES_API_VERSION + 1)).rejects.toThrow(
await expect(updateToIncompatible(oneMinorAboveSupported)).rejects.toThrow(
"isn't compatible with your version of n8n",
);
@ -284,7 +288,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
});
test('rejects an update with a malformed declared version', async () => {
await expect(updateToIncompatible('3')).rejects.toThrow('invalid n8n node API version');
await expect(updateToIncompatible(3.1)).rejects.toThrow('invalid n8n node API version');
expect(existsSync(markerPath())).toBe(true);
expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled();
@ -314,7 +318,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
downloadedPackageJson = {
name: PACKAGE_NAME,
version: '2.0.0',
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 },
n8n: { n8nNodesApiVersion: oneMinorAboveSupported },
};
await expect(communityPackagesService.installPackage(PACKAGE_NAME)).rejects.toThrow(
@ -334,7 +338,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
downloadedPackageJson = {
name: PACKAGE_NAME,
version: '2.0.0',
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 },
n8n: { n8nNodesApiVersion: oneMinorAboveSupported },
};
// The follower resolves the version to install from the leader's database record.
installedPackageRepository.findOne.mockResolvedValue(
@ -359,7 +363,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
packageName: PACKAGE_NAME,
reason: expect.stringContaining("isn't compatible with your version of n8n"),
// The operator log names both versions, unlike the user-facing message.
requiredNodesApiVersion: N8N_NODES_API_VERSION + 1,
requiredNodesApiVersion: oneMinorAboveSupported,
supportedNodesApiVersion: N8N_NODES_API_VERSION,
}),
);

View File

@ -3,7 +3,11 @@ import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
import { mockInstance, randomName } from '@n8n/backend-test-utils';
import { LICENSE_FEATURES } from '@n8n/constants';
import type { InstanceSettings, PackageDirectoryLoader } from 'n8n-core';
import { N8N_NODES_API_VERSION, type PublicInstalledPackage } from 'n8n-workflow';
import {
N8N_NODES_API_VERSION,
parseNodesApiLevel,
type PublicInstalledPackage,
} from 'n8n-workflow';
import { execFile } from 'node:child_process';
import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import path, { join } from 'node:path';
@ -52,6 +56,9 @@ const execMock: typeof execFile = ((...args) => {
vi.mocked(execFile).mockImplementation(execMock);
const { major: supportedMajor, minor: supportedMinor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
const oneMinorAboveSupported = `${supportedMajor}.${supportedMinor + 1}`;
describe('CommunityPackagesService', () => {
const license = mock<License>();
const configDefaults = {
@ -675,7 +682,7 @@ describe('CommunityPackagesService', () => {
test('should reject the update when the package requires a newer node API version', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
await expect(updateToIncompatible(N8N_NODES_API_VERSION + 1)).rejects.toThrow(
await expect(updateToIncompatible(oneMinorAboveSupported)).rejects.toThrow(
"This community node isn't compatible with your version of n8n. Update n8n to use it.",
);
@ -687,7 +694,7 @@ describe('CommunityPackagesService', () => {
test('should reject the update when the declared node API version is malformed', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
await expect(updateToIncompatible('3')).rejects.toThrow('invalid n8n node API version');
await expect(updateToIncompatible(3.1)).rejects.toThrow('invalid n8n node API version');
expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled();
expect(installedPackageRepository.replaceInstalledPackageWithNodes).not.toHaveBeenCalled();
@ -701,6 +708,16 @@ describe('CommunityPackagesService', () => {
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(PACKAGE_NAME);
expect(installedPackageRepository.replaceInstalledPackageWithNodes).toHaveBeenCalled();
});
test('should update to a package that declares the supported level with an explicit minor', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
await expect(updateToIncompatible(`${supportedMajor}.${supportedMinor}`)).resolves.toBe(
packageAfterUpdate,
);
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(PACKAGE_NAME);
});
});
test('should remove the package.json dependency when a fresh install fails', async () => {
@ -1418,7 +1435,7 @@ describe('CommunityPackagesService', () => {
JSON.stringify({
name: 'package-1',
version: '1.0.0',
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 },
n8n: { n8nNodesApiVersion: oneMinorAboveSupported },
}),
);
@ -1438,7 +1455,7 @@ describe('CommunityPackagesService', () => {
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
config.reinstallMissing = true;
vi.mocked(readFile).mockResolvedValue(
JSON.stringify({ name: 'package-1', version: '1.0.0', n8n: { n8nNodesApiVersion: '3' } }),
JSON.stringify({ name: 'package-1', version: '1.0.0', n8n: { n8nNodesApiVersion: 3.1 } }),
);
await communityPackagesService.checkForMissingPackages();

View File

@ -60,7 +60,7 @@ export async function getCommunityNodeTypes(
environment: 'staging' | 'production',
qs: { filters?: StrapiFilters; fields?: string[] } = {},
maxAiNodeSdk: number,
maxN8nNodesApiVersion: number,
maxN8nNodesApiVersion: string,
): Promise<StrapiCommunityNodeType[]> {
const url = getUrl(environment);
const params = {
@ -78,7 +78,7 @@ export async function getCommunityNodeTypes(
export async function getCommunityNodesMetadata(
environment: 'staging' | 'production',
maxAiNodeSdk: number,
maxN8nNodesApiVersion: number,
maxN8nNodesApiVersion: string,
): Promise<CommunityNodesMetadata[]> {
const url = getUrl(environment);
const params = {

View File

@ -40,7 +40,7 @@ export class CommunityPackagesConfig {
readonly aiNodeSdkVersion: number = AI_NODE_SDK_VERSION;
/** Highest community node API version this instance supports, sent to Strapi API */
readonly nodesApiVersion: number = N8N_NODES_API_VERSION;
readonly nodesApiVersion: string = N8N_NODES_API_VERSION;
@Env('N8N_DISABLED_MODULES')
private disabledModules: CommaSeparatedStringArray<string> = [];

View File

@ -304,14 +304,17 @@ export class CommunityPackagesService {
const check = checkNodesApiVersion(packageJson);
if (check.compatible) return;
const isMalformed = check.reason === 'malformed';
if (check.reason === 'malformed') {
throw new IncompatibleNodesApiVersionError(
`This community node declares an invalid n8n node API version (${JSON.stringify(check.declared)}). Install a version of the package with valid metadata or contact the package author.`,
{ requiredNodesApiVersion: null, supportedNodesApiVersion: N8N_NODES_API_VERSION },
);
}
throw new IncompatibleNodesApiVersionError(
isMalformed
? `This community node declares an invalid n8n node API version (${JSON.stringify(check.declared)}). Install a version of the package with valid metadata or contact the package author.`
: "This community node isn't compatible with your version of n8n. Update n8n to use it.",
"This community node isn't compatible with your version of n8n. Update n8n to use it.",
{
requiredNodesApiVersion: isMalformed ? null : Number(check.declared),
requiredNodesApiVersion: check.required,
supportedNodesApiVersion: N8N_NODES_API_VERSION,
},
);
@ -370,7 +373,7 @@ export class CommunityPackagesService {
const requirement =
apiVersionCheck.reason === 'malformed'
? `an invalid n8nNodesApiVersion (${JSON.stringify(apiVersionCheck.declared)})`
: `node API version ${String(apiVersionCheck.declared)}, but this n8n version supports up to ${N8N_NODES_API_VERSION}`;
: `node API version ${apiVersionCheck.required}, but this n8n version supports up to ${N8N_NODES_API_VERSION}`;
this.logger.warn(
`Not reinstalling package "${installedPackage.packageName}": it requires ${requirement}. Upgrade n8n to use this package, or uninstall it in Settings > Community nodes.`,
);

View File

@ -1418,6 +1418,44 @@ describe('InstanceAiSettingsService', () => {
);
});
it.each([
['no URL', '', 'model-x', { id: 'openai/model-x' }],
['the official OpenAI URL', 'https://api.openai.com/v1', 'model-x', { id: 'openai/model-x' }],
['a self-hosted URL', 'https://kimi.example/v1', 'model-x', { id: 'custom/model-x' }],
[
'a self-hosted URL and a known model',
'https://kimi.example/v1',
'moonshotai/Kimi-K3',
{ id: 'custom/moonshotai/Kimi-K3', supportsStructuredOutputs: true },
],
])('builds an OpenAI credential with %s', async (_, url, modelName, expected) => {
const credential = mock<CredentialsEntity>({
id: 'cred-1',
name: 'Admin model',
type: 'openAiApi',
usageScope: 'instance',
});
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: credential.id,
name: credential.name,
type: credential.type,
data: { apiKey: 'admin-key', url },
});
instanceCredentialBroker.assignForUse.mockResolvedValue({
id: credential.id,
name: credential.name,
type: credential.type,
});
await service.updateAdminSettings({ modelCredentialId: credential.id, modelName });
await expect(service.resolveModelConfig(mock<User>())).resolves.toEqual({
...expected,
url,
apiKey: 'admin-key',
});
});
it('uses the environment model connection without resolving stored credentials', async () => {
Object.assign(globalConfig.instanceAi, {
model: 'openai/gpt-5',

View File

@ -36,6 +36,7 @@ import {
import type { GlobalConfig } from '@n8n/config';
import { GLOBAL_MEMBER_ROLE } from '@n8n/db';
import { Container } from '@n8n/di';
import { LessThan } from '@n8n/typeorm';
import type {
AiBuilderTemporaryWorkflowRepository,
CredentialsEntity,
@ -393,7 +394,7 @@ describe('stop execution — workflow:execute scope', () => {
// ---------------------------------------------------------------------------
describe('cleanupTestExecutions — scope and deletion pipeline', () => {
it('rejects when user lacks workflow:execute scope', async () => {
it('rejects when user lacks execution:delete scope', async () => {
workflowFinderService.findWorkflowForUser.mockResolvedValue(null);
const ctx = service.createContext(user);
@ -402,26 +403,60 @@ describe('cleanupTestExecutions — scope and deletion pipeline', () => {
);
expect(workflowFinderService.findWorkflowForUser).toHaveBeenCalledWith('wf-1', user, [
'workflow:execute',
'execution:delete',
]);
});
it('calls hardDeleteBy instead of deleteByIds', async () => {
it('deletes only the counted manual executions by ID', async () => {
workflowFinderService.findWorkflowForUser.mockResolvedValue({ id: 'wf-1' } as never);
executionRepository.find.mockResolvedValue([{ id: 'exec-1' }, { id: 'exec-2' }] as never);
executionPersistence.hardDeleteBy.mockResolvedValue(undefined);
const now = new Date('2026-10-07T12:00:00.000Z');
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(now);
try {
const ctx = service.createContext(user);
const result = await ctx.workspaceService!.cleanupTestExecutions('wf-1');
expect(result.deletedCount).toBe(2);
} finally {
vi.useRealTimers();
}
expect(executionRepository.find).toHaveBeenCalledWith({
select: ['id'],
where: {
workflowId: 'wf-1',
mode: 'manual',
startedAt: LessThan(new Date(now.getTime() - 60 * 60 * 1000)),
},
});
expect(executionPersistence.hardDeleteBy).toHaveBeenCalledWith({
filters: undefined,
accessibleWorkflowIds: ['wf-1'],
deleteConditions: { ids: ['exec-1', 'exec-2'] },
});
// Verify deleteByIds is NOT called
expect(executionRepository.deleteByIds).not.toHaveBeenCalled();
});
it('deletes a large set of executions in chunks', async () => {
workflowFinderService.findWorkflowForUser.mockResolvedValue({ id: 'wf-1' } as never);
const ids = Array.from({ length: 1201 }, (_, i) => `exec-${i}`);
executionRepository.find.mockResolvedValue(ids.map((id) => ({ id })) as never);
executionPersistence.hardDeleteBy.mockResolvedValue(undefined);
const ctx = service.createContext(user);
const result = await ctx.workspaceService!.cleanupTestExecutions('wf-1');
expect(result.deletedCount).toBe(2);
expect(executionPersistence.hardDeleteBy).toHaveBeenCalledWith({
filters: { workflowId: 'wf-1', mode: 'manual' },
accessibleWorkflowIds: ['wf-1'],
deleteConditions: { deleteBefore: expect.any(Date) },
});
// Verify deleteByIds is NOT called
expect(executionRepository.deleteByIds).not.toHaveBeenCalled();
expect(result.deletedCount).toBe(1201);
const chunks = executionPersistence.hardDeleteBy.mock.calls.map(
([criteria]) => criteria.deleteConditions.ids,
);
expect(chunks.map((chunk) => chunk?.length)).toEqual([500, 500, 201]);
expect(chunks.flat()).toEqual(ids);
});
it('emits execution-deleted audit event', async () => {

View File

@ -5191,6 +5191,28 @@ describe('createExecutionAdapter run()', () => {
});
});
it('marks a trigger-mode run as awaited so a worker never parks it at shutdown', async () => {
const { adapter, mockWorkflowRunner } = createRunAdapterForTests({
id: 'wf-1',
nodes: [
{
id: 'n1',
name: 'Schedule',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1,
position: [0, 0],
},
],
connections: {},
});
await adapter.run('wf-1');
const runData = mockWorkflowRunner.run.mock.calls[0][0];
expect(runData.executionMode).toBe('trigger');
expect(runData.callerAwaitsOutcome).toBe('completion');
});
it('still applies overrides when the workflow has no settings', async () => {
const { adapter, mockWorkflowRunner } = createRunAdapterForTests({
id: 'wf-1',

View File

@ -1,3 +1,4 @@
import { isOpenAiCustomEndpoint } from '@n8n/ai-utilities/model-discovery';
import { EventService, CredentialsFinderService } from '@n8n/backend-services';
import { isDeepStrictEqual } from 'node:util';
@ -1478,8 +1479,8 @@ export class InstanceAiSettingsService {
data: Record<string, unknown>,
modelName: string,
): ModelConfig | null {
const provider = CREDENTIAL_TO_MODEL_PROVIDER[credentialType];
if (!provider) {
const credentialProvider = CREDENTIAL_TO_MODEL_PROVIDER[credentialType];
if (!credentialProvider) {
return null;
}
@ -1487,10 +1488,22 @@ export class InstanceAiSettingsService {
const urlField = URL_FIELD_MAP[credentialType];
const rawUrl = urlField ? data[urlField] : undefined;
const baseUrl = typeof rawUrl === 'string' ? rawUrl : '';
// The UI saves its self-hosted option as an OpenAI credential with a custom URL.
// These servers speak /chat/completions, so build them like an env `custom/` model.
const provider =
credentialType === 'openAiApi' && baseUrl && isOpenAiCustomEndpoint(baseUrl)
? 'custom'
: credentialProvider;
const id: `${string}/${string}` = `${provider}/${modelName}`;
if (!baseUrl && !apiKey) return null;
const headers = modelCredentialHeaders(credentialType, data);
return { id, url: baseUrl, ...(apiKey ? { apiKey } : {}), ...(headers ? { headers } : {}) };
return {
id,
url: baseUrl,
...(apiKey ? { apiKey } : {}),
...(headers ? { headers } : {}),
...this.customModelOptionsFor(id),
};
}
// ── Private helpers ───────────────────────────────────────────────────

View File

@ -2149,6 +2149,9 @@ export class InstanceAiAdapterService {
},
userId: user.id,
pushRef,
// The agent awaits this run's result and prunes its pins afterwards, so
// a worker must not park a trigger-mode run at shutdown.
callerAwaitsOutcome: 'completion',
// A verification run picks a production execution mode above so the
// trigger behaves realistically. Without this opt-out, a failed build
// attempt would dispatch the user's error workflow as if production
@ -4237,9 +4240,8 @@ export class InstanceAiAdapterService {
options?: { olderThanHours?: number },
): Promise<{ deletedCount: number }> {
assertNotReadOnly('executions');
// Access-check the workflow with execute scope (matches controller behavior)
const workflow = await workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:execute',
'execution:delete',
]);
if (!workflow) {
throw new WorkflowNotFoundError(workflowId);
@ -4264,12 +4266,15 @@ export class InstanceAiAdapterService {
const ids = executions.map((e) => e.id);
// Use the canonical deletion pipeline (handles binary data and fs blobs)
await executionPersistence.hardDeleteBy({
filters: { workflowId, mode: 'manual' },
accessibleWorkflowIds: [workflowId],
deleteConditions: { deleteBefore: cutoff },
});
// Use the canonical deletion pipeline (handles binary data and fs blobs).
// Delete by ID so only the counted manual executions are removed.
for (let start = 0; start < ids.length; start += EXECUTION_DELETE_CHUNK_SIZE) {
await executionPersistence.hardDeleteBy({
filters: undefined,
accessibleWorkflowIds: [workflowId],
deleteConditions: { ids: ids.slice(start, start + EXECUTION_DELETE_CHUNK_SIZE) },
});
}
// Emit audit event (matches controller behavior)
eventService.emit('execution-deleted', {
@ -5349,6 +5354,10 @@ function readParentFolder(workflow: WorkflowEntity): { id: string; name: string
return parent ? { id: parent.id, name: parent.name } : undefined;
}
/** Execution ids per deletion query. The query binds one parameter per id
* and SQLite allows 999 of them, so the ids are deleted in chunks. */
const EXECUTION_DELETE_CHUNK_SIZE = 500;
/** Folder ids per path query. `getFolderPathsToRoot` binds one parameter per id
* and SQLite allows 999 of them, so the ids are read in chunks. */
const FOLDER_PATH_CHUNK_SIZE = 500;

View File

@ -36,6 +36,7 @@ import { mock } from 'vitest-mock-extended';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { VariablesService } from '@/environments.ee/variables/variables.service.ee';
import type { ExecutionPersistence } from '@/executions/execution-persistence';
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
import { WorkflowPublishBlockedError } from '@/errors/response-errors/workflow-publish-blocked.error';
import type { DataTableColumnRepository } from '@/modules/data-table/data-table-column.repository';
import type { DataTableDDLService } from '@/modules/data-table/data-table-ddl.service';
@ -44,6 +45,7 @@ import type { DataTableRepository } from '@/modules/data-table/data-table.reposi
import type { RedactionEnforcementService } from '@/modules/redaction/redaction-enforcement.service';
import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
import { PolicyViolationError } from '@/policy/policy-violation.error';
import type { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import type { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
import type { WorkflowMutationHooksProxy } from '@/workflows/workflow-mutation-hooks-proxy.service';
import type { WorkflowPublishGuardProxy } from '@/workflows/workflow-publish-guard-proxy.service';
@ -86,6 +88,7 @@ describe('SourceControlImportService', () => {
const dataTableColumnRepository = mock<DataTableColumnRepository>();
const dataTableDDLService = mock<DataTableDDLService>();
const redactionEnforcementService = mock<RedactionEnforcementService>();
const deprecatedNodesValidationService = mock<DeprecatedNodesValidationService>();
const policyEnforcementService = mock<PolicyEnforcementService>();
policyEnforcementService.hasChecksFor.mockReturnValue(true);
policyEnforcementService.enforceContentImport.mockResolvedValue(mock());
@ -140,6 +143,7 @@ describe('SourceControlImportService', () => {
workflowPublishGuard,
workflowMutationHooks,
workflowFinderService,
deprecatedNodesValidationService,
);
const globMock = fastGlob.default as unknown as Mock<(...args: string[]) => Promise<string[]>>;
@ -1618,6 +1622,82 @@ describe('SourceControlImportService', () => {
});
});
describe('deprecated node enforcement', () => {
const mockUserId = 'user-id-123';
const deprecatedNode = {
id: 'node-1',
name: 'Function',
type: 'n8n-nodes-base.function',
typeVersion: 1,
position: [0, 0],
parameters: { functionCode: 'return items;' },
};
const workflowFile = (id: string) =>
JSON.stringify({
id,
name: `Workflow ${id}`,
active: false,
nodes: [deprecatedNode],
connections: {},
versionId: 'v1',
parentFolderId: null,
});
beforeEach(() => {
projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue(
Object.assign(new Project(), {
id: 'personal-project-id-123',
name: 'Personal Project',
type: 'personal',
createdAt: new Date(),
updatedAt: new Date(),
}),
);
folderRepository.find.mockResolvedValue([]);
sharedWorkflowRepository.findWithFields.mockResolvedValue([]);
workflowRepository.upsertImportedContent.mockResolvedValue('1');
});
it('rejects a new workflow that fails the deprecated-node check', async () => {
workflowRepository.findByIds.mockResolvedValue([]);
fsReadFile.mockResolvedValueOnce(workflowFile('2'));
deprecatedNodesValidationService.validateOnCreate.mockImplementationOnce(() => {
throw new DeprecatedNodesError('blocked', { violations: [] });
});
const candidates = [mock<SourceControlledFile>({ file: '/mock/workflow2.json', id: '2' })];
await expect(service.importWorkflowFromWorkFolder(candidates, mockUserId)).rejects.toThrow(
DeprecatedNodesError,
);
expect(deprecatedNodesValidationService.validateOnCreate).toHaveBeenCalledWith(
[deprecatedNode],
'2',
);
expect(workflowRepository.upsertImportedContent).not.toHaveBeenCalled();
});
it('checks an existing workflow against its stored nodes', async () => {
workflowRepository.findByIds.mockResolvedValue([
Object.assign(new WorkflowEntity(), { id: '1', nodes: [deprecatedNode] }),
]);
fsReadFile.mockResolvedValueOnce(workflowFile('1'));
const candidates = [mock<SourceControlledFile>({ file: '/mock/workflow1.json', id: '1' })];
await service.importWorkflowFromWorkFolder(candidates, mockUserId);
expect(deprecatedNodesValidationService.validateOnUpdate).toHaveBeenCalledWith(
[deprecatedNode],
[deprecatedNode],
'1',
);
expect(deprecatedNodesValidationService.validateOnCreate).not.toHaveBeenCalled();
expect(workflowRepository.upsertImportedContent).toHaveBeenCalled();
});
});
describe('content-import policy enforcement', () => {
const mockUserId = 'user-id-123';
const mockWorkflowFile = '/mock/workflow1.json';

Some files were not shown because too many files have changed in this diff Show More