mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
Compare commits
11 Commits
master
...
n8n@2.43.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17f9c4a715 | ||
|
|
3c4f6e3393 | ||
|
|
1830ccfcda | ||
|
|
6ab34a4a42 | ||
|
|
3ee8d7aeef | ||
|
|
509a3f36a8 | ||
|
|
204e2ddf17 | ||
|
|
b553bca31e | ||
|
|
fda8dac7d8 | ||
|
|
9ea78350fa | ||
|
|
345ae0100c |
32
CHANGELOG.md
32
CHANGELOG.md
|
|
@ -1,3 +1,35 @@
|
||||||
|
## [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)
|
# [2.43.0](https://github.com/n8n-io/n8n/compare/n8n@2.42.0...n8n@2.43.0) (2026-10-06)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-monorepo",
|
"name": "n8n-monorepo",
|
||||||
"version": "2.43.0",
|
"version": "2.43.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0.0",
|
"node": ">=24.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/agents",
|
"name": "@n8n/agents",
|
||||||
"version": "0.28.0",
|
"version": "0.28.2",
|
||||||
"description": "AI agent SDK for n8n's code-first execution engine",
|
"description": "AI agent SDK for n8n's code-first execution engine",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0.0"
|
"node": ">=24.0.0"
|
||||||
|
|
|
||||||
|
|
@ -1099,7 +1099,6 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
||||||
|
|
||||||
let instructions = this.instructionsText;
|
let instructions = this.instructionsText;
|
||||||
if (this.skillSource) {
|
if (this.skillSource) {
|
||||||
await this.skillSource.prepare?.();
|
|
||||||
instructions = appendSkillCatalogToInstructions(instructions, this.skillSource.registry);
|
instructions = appendSkillCatalogToInstructions(instructions, this.skillSource.registry);
|
||||||
}
|
}
|
||||||
if (this.workspaceInstance) {
|
if (this.workspaceInstance) {
|
||||||
|
|
|
||||||
|
|
@ -490,25 +490,16 @@ Use the workflow SDK.`,
|
||||||
expect(prepare).toHaveBeenCalledTimes(1);
|
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([
|
const source = createRuntimeSkillSource([
|
||||||
{
|
{
|
||||||
id: 'summarize_notes',
|
id: 'summarize_notes',
|
||||||
name: 'Summarize notes',
|
name: 'Summarize notes',
|
||||||
description: 'Use for meeting notes.',
|
description: 'Use for meeting notes.',
|
||||||
instructions: 'Extract decisions.',
|
instructions: 'Full private skill body: Extract decisions.',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const prepare = vi.fn(async () => {
|
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.',
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
source.prepare = prepare;
|
source.prepare = prepare;
|
||||||
|
|
||||||
const agent = new Agent('assistant')
|
const agent = new Agent('assistant')
|
||||||
|
|
@ -521,11 +512,10 @@ Use the workflow SDK.`,
|
||||||
const { instructions } = runtimeConfig;
|
const { instructions } = runtimeConfig;
|
||||||
expect(runtimeConfig.skillSource).toBe(source);
|
expect(runtimeConfig.skillSource).toBe(source);
|
||||||
|
|
||||||
expect(prepare).toHaveBeenCalledTimes(1);
|
expect(prepare).not.toHaveBeenCalled();
|
||||||
expect(instructions).toContain('name: "Summarize notes"');
|
expect(instructions).toContain('name: "Summarize notes"');
|
||||||
expect(instructions).toContain('id: "summarize_notes"');
|
expect(instructions).toContain('id: "summarize_notes"');
|
||||||
expect(instructions).toContain('description: "Use for materialized meeting notes."');
|
expect(instructions).toContain('description: "Use for meeting notes."');
|
||||||
expect(instructions).not.toContain('description: "Use for meeting notes."');
|
|
||||||
expect(instructions).not.toContain('Full private skill body');
|
expect(instructions).not.toContain('Full private skill body');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,12 @@ export type RuntimeSkillFileLoader = (
|
||||||
|
|
||||||
export interface RuntimeSkillSource {
|
export interface RuntimeSkillSource {
|
||||||
registry: RuntimeSkillRegistry;
|
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>;
|
prepare?: () => Promise<void>;
|
||||||
loadSkill: RuntimeSkillLoader;
|
loadSkill: RuntimeSkillLoader;
|
||||||
loadFile?: RuntimeSkillFileLoader;
|
loadFile?: RuntimeSkillFileLoader;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-node-sdk",
|
"name": "@n8n/ai-node-sdk",
|
||||||
"version": "0.33.0",
|
"version": "0.33.1",
|
||||||
"description": "SDK for building AI nodes in n8n",
|
"description": "SDK for building AI nodes in n8n",
|
||||||
"types": "dist/esm/index.d.ts",
|
"types": "dist/esm/index.d.ts",
|
||||||
"module": "dist/esm/index.js",
|
"module": "dist/esm/index.js",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-utilities",
|
"name": "@n8n/ai-utilities",
|
||||||
"version": "0.36.0",
|
"version": "0.36.1",
|
||||||
"description": "Utilities for building AI nodes in n8n",
|
"description": "Utilities for building AI nodes in n8n",
|
||||||
"types": "dist/esm/index.d.ts",
|
"types": "dist/esm/index.d.ts",
|
||||||
"module": "dist/esm/index.js",
|
"module": "dist/esm/index.js",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-workflow-builder",
|
"name": "@n8n/ai-workflow-builder",
|
||||||
"version": "1.43.0",
|
"version": "1.43.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/api-types",
|
"name": "@n8n/api-types",
|
||||||
"version": "1.43.0",
|
"version": "1.43.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-common",
|
"name": "@n8n/backend-common",
|
||||||
"version": "1.43.0",
|
"version": "1.43.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-network",
|
"name": "@n8n/backend-network",
|
||||||
"version": "1.17.0",
|
"version": "1.17.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-services",
|
"name": "@n8n/backend-services",
|
||||||
"version": "1.3.0",
|
"version": "1.3.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-test-utils",
|
"name": "@n8n/backend-test-utils",
|
||||||
"version": "1.43.0",
|
"version": "1.43.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/blob-storage",
|
"name": "@n8n/blob-storage",
|
||||||
"version": "1.11.0",
|
"version": "1.11.1",
|
||||||
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
|
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/chat-hub",
|
"name": "@n8n/chat-hub",
|
||||||
"version": "1.36.0",
|
"version": "1.36.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/client-oauth2",
|
"name": "@n8n/client-oauth2",
|
||||||
"version": "1.25.0",
|
"version": "1.25.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/config",
|
"name": "@n8n/config",
|
||||||
"version": "2.41.0",
|
"version": "2.41.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -49,4 +49,14 @@ export class NodesConfig {
|
||||||
/** Memory limit in MB for the Merge node's SQL sandbox. */
|
/** Memory limit in MB for the Merge node's SQL sandbox. */
|
||||||
@Env('NODES_MERGE_SQL_SANDBOX_MEMORY_LIMIT_MB', z.coerce.number().int().positive())
|
@Env('NODES_MERGE_SQL_SANDBOX_MEMORY_LIMIT_MB', z.coerce.number().int().positive())
|
||||||
mergeSqlSandboxMemoryLimitMb: number = 64;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,7 @@ describe('GlobalConfig', () => {
|
||||||
exclude: ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'],
|
exclude: ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'],
|
||||||
pythonEnabled: true,
|
pythonEnabled: true,
|
||||||
mergeSqlSandboxMemoryLimitMb: 64,
|
mergeSqlSandboxMemoryLimitMb: 64,
|
||||||
|
blockDeprecated: true,
|
||||||
},
|
},
|
||||||
publicApi: {
|
publicApi: {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/create-node",
|
"name": "@n8n/create-node",
|
||||||
"version": "0.51.0",
|
"version": "0.51.1",
|
||||||
"description": "Official CLI to create new community nodes for n8n",
|
"description": "Official CLI to create new community nodes for n8n",
|
||||||
"bin": {
|
"bin": {
|
||||||
"create-node": "bin/create-node.cjs"
|
"create-node": "bin/create-node.cjs"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/db",
|
"name": "@n8n/db",
|
||||||
"version": "1.43.0",
|
"version": "1.43.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
||||||
"gen:migration-index": "node scripts/generate-migration-index.mjs",
|
"gen:migration-index": "node scripts/generate-migration-index.mjs",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/decorators",
|
"name": "@n8n/decorators",
|
||||||
"version": "1.43.0",
|
"version": "1.43.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/engine",
|
"name": "@n8n/engine",
|
||||||
"version": "0.23.0",
|
"version": "0.23.1",
|
||||||
"description": "n8n workflow execution engine (v2)",
|
"description": "n8n workflow execution engine (v2)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo compiled",
|
"clean": "rimraf dist .turbo compiled",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/inbound-auth",
|
"name": "@n8n/inbound-auth",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/instance-ai",
|
"name": "@n8n/instance-ai",
|
||||||
"version": "1.28.0",
|
"version": "1.28.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",
|
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
it('passes the selected catalog to domain tools before workspace materialization', async () => {
|
||||||
const runtimeSkillCatalog = {
|
const runtimeSkillCatalog = {
|
||||||
registry: { schemaVersion: 1, skillsHash: 'selected-skills', skills: [] },
|
registry: { schemaVersion: 1, skillsHash: 'selected-skills', skills: [] },
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
import { attachRuntimeWorkspaceCapabilities } from './runtime-workspace';
|
import { attachRuntimeWorkspaceCapabilities } from './runtime-workspace';
|
||||||
import { listConnectedMcpServices } from '../mcp/connected-mcp-services';
|
import { listConnectedMcpServices } from '../mcp/connected-mcp-services';
|
||||||
import { getVersionedSystemPrompt, resolvePromptProfile } from '../prompts/prompt-profiles';
|
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 { createToolRegistry, mergeToolRegistries, toolRegistryValues } from '../tool-registry';
|
||||||
import {
|
import {
|
||||||
createOrchestratorDomainTools,
|
createOrchestratorDomainTools,
|
||||||
|
|
@ -254,6 +254,10 @@ export async function createInstanceAgent(
|
||||||
const runtimeSkills = orchestrationContext?.runtimeSkills;
|
const runtimeSkills = orchestrationContext?.runtimeSkills;
|
||||||
if (hasRuntimeSkills(runtimeSkills)) {
|
if (hasRuntimeSkills(runtimeSkills)) {
|
||||||
agent.skills(runtimeSkills);
|
agent.skills(runtimeSkills);
|
||||||
|
warmRuntimeSkills(runtimeSkills, {
|
||||||
|
logger: orchestrationContext?.logger,
|
||||||
|
tracing: orchestrationContext?.tracing,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (telemetry) {
|
if (telemetry) {
|
||||||
agent.telemetry(telemetry);
|
agent.telemetry(telemetry);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
INSTANCE_AI_SKILLS_DIR,
|
INSTANCE_AI_SKILLS_DIR,
|
||||||
loadInstanceAiRuntimeSkillSource,
|
loadInstanceAiRuntimeSkillSource,
|
||||||
loadInstanceAiRuntimeSkillSourceForBuildMode,
|
loadInstanceAiRuntimeSkillSourceForBuildMode,
|
||||||
|
warmRuntimeSkills,
|
||||||
} from '../runtime-skills';
|
} from '../runtime-skills';
|
||||||
import { CONFIG_EVALS_SKILL_ID, disabledInstanceAiSkillIds } from '../skill-gates';
|
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');
|
const { loadInstanceAiRuntimeSkillSource } = await import('../runtime-skills.js');
|
||||||
return loadInstanceAiRuntimeSkillSource();
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import { loadRuntimeSkillSourceFromDirectory, type RuntimeSkillSource } from '@n8n/agents';
|
import { loadRuntimeSkillSourceFromDirectory, type RuntimeSkillSource } from '@n8n/agents';
|
||||||
import type { InstanceAiBuildMode } from '@n8n/api-types';
|
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 { GROUPING_GUIDANCE } from '@n8n/workflow-sdk/prompts/sdk-reference';
|
||||||
import { TOP_LEVEL_ITEM_CEILING } from 'n8n-workflow';
|
import { TOP_LEVEL_ITEM_CEILING } from 'n8n-workflow';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
import { isAgentFeatureEnabled } from '@/utils/agent-feature-enabled';
|
import { isAgentFeatureEnabled } from '@/utils/agent-feature-enabled';
|
||||||
|
|
||||||
|
import type { Logger } from '../logger';
|
||||||
|
import type { InstanceAiTraceContext } from '../types';
|
||||||
import {
|
import {
|
||||||
PROMPT_FRAGMENT_SKILLS,
|
PROMPT_FRAGMENT_SKILLS,
|
||||||
resolvePromptProfile,
|
resolvePromptProfile,
|
||||||
|
|
@ -63,3 +66,27 @@ export function hasRuntimeSkills(
|
||||||
): source is RuntimeSkillSource {
|
): source is RuntimeSkillSource {
|
||||||
return (source?.registry.skills.length ?? 0) > 0;
|
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),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1185,6 +1185,66 @@ describe('createInstanceAiTraceContext', () => {
|
||||||
expect(provider.shutdown).toHaveBeenCalledTimes(1);
|
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 () => {
|
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({
|
const tracing = await createInstanceAiTraceContext({
|
||||||
threadId: 'thread-late-memory-task',
|
threadId: 'thread-late-memory-task',
|
||||||
|
|
|
||||||
|
|
@ -185,10 +185,16 @@ interface ProductOtelTraceRuntime {
|
||||||
spans: Map<string, OtelApiSpan>;
|
spans: Map<string, OtelApiSpan>;
|
||||||
contexts: Map<string, OtelContext>;
|
contexts: Map<string, OtelContext>;
|
||||||
pendingOperations: Map<string, InstanceAiTraceRun>;
|
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;
|
shutdown: boolean;
|
||||||
lifetime: ProductTelemetryLifetime;
|
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 {
|
interface OTelTracer {
|
||||||
startSpan(
|
startSpan(
|
||||||
name: string,
|
name: string,
|
||||||
|
|
@ -488,7 +494,19 @@ async function releaseProductOtelRuntime(
|
||||||
runtime: ProductOtelTraceRuntime,
|
runtime: ProductOtelTraceRuntime,
|
||||||
traceId: string,
|
traceId: string,
|
||||||
): Promise<void> {
|
): 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;
|
runtime.shutdown = true;
|
||||||
for (const run of runtime.pendingOperations.values()) {
|
for (const run of runtime.pendingOperations.values()) {
|
||||||
|
|
@ -505,6 +523,27 @@ async function releaseProductOtelRuntime(
|
||||||
await runtime.lifetime.release();
|
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>(
|
async function withProxyHeaders<T>(
|
||||||
proxyConfig: ServiceProxyConfig | undefined,
|
proxyConfig: ServiceProxyConfig | undefined,
|
||||||
fn: () => T | Promise<T>,
|
fn: () => T | Promise<T>,
|
||||||
|
|
@ -1318,6 +1357,13 @@ function createTraceContext(
|
||||||
finishRun,
|
finishRun,
|
||||||
failRun,
|
failRun,
|
||||||
onMemoryTaskEvent,
|
onMemoryTaskEvent,
|
||||||
|
keepOpenUntilSettled: (operation) => {
|
||||||
|
if (otelRuntime.shutdown) return;
|
||||||
|
otelRuntime.backgroundOperations.add(operation);
|
||||||
|
void operation
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => otelRuntime.backgroundOperations.delete(operation));
|
||||||
|
},
|
||||||
...(telemetryFactory ? { getTelemetry: telemetryFactory } : {}),
|
...(telemetryFactory ? { getTelemetry: telemetryFactory } : {}),
|
||||||
wrapTools: (tools, traceOptions) => {
|
wrapTools: (tools, traceOptions) => {
|
||||||
if (ctx.replayMode === 'replay' && ctx.traceIndex && ctx.idRemapper) {
|
if (ctx.replayMode === 'replay' && ctx.traceIndex && ctx.idRemapper) {
|
||||||
|
|
@ -1767,6 +1813,8 @@ async function createProductOtelRuntime(
|
||||||
spans: new Map(),
|
spans: new Map(),
|
||||||
contexts: new Map(),
|
contexts: new Map(),
|
||||||
pendingOperations: new Map(),
|
pendingOperations: new Map(),
|
||||||
|
backgroundOperations: new Set(),
|
||||||
|
releaseDeferred: false,
|
||||||
shutdown: false,
|
shutdown: false,
|
||||||
lifetime: new ProductTelemetryLifetime(telemetry),
|
lifetime: new ProductTelemetryLifetime(telemetry),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2139,6 +2139,11 @@ export interface InstanceAiTraceContext {
|
||||||
* for any agent (main or sub-agent) whose spans should join this trace.
|
* for any agent (main or sub-agent) whose spans should join this trace.
|
||||||
*/
|
*/
|
||||||
onMemoryTaskEvent?: (event: ScopedMemoryTaskEvent) => void;
|
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. */
|
/** Trace replay mode: 'record' captures tool I/O, 'replay' remaps IDs, 'off' disables. */
|
||||||
replayMode: TraceReplayMode;
|
replayMode: TraceReplayMode;
|
||||||
/** Shared ID remapper instance — available in 'replay' mode. */
|
/** Shared ID remapper instance — available in 'replay' mode. */
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/mcp-apps",
|
"name": "@n8n/mcp-apps",
|
||||||
"version": "0.20.0",
|
"version": "0.20.2",
|
||||||
"description": "MCP Apps UI resources and server helpers for n8n",
|
"description": "MCP Apps UI resources and server helpers for n8n",
|
||||||
"main": "dist/server/index.js",
|
"main": "dist/server/index.js",
|
||||||
"types": "dist/server/index.d.ts",
|
"types": "dist/server/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/node-cli",
|
"name": "@n8n/node-cli",
|
||||||
"version": "0.52.0",
|
"version": "0.52.1",
|
||||||
"description": "Official CLI for developing community nodes for n8n",
|
"description": "Official CLI for developing community nodes for n8n",
|
||||||
"bin": {
|
"bin": {
|
||||||
"n8n-node": "bin/n8n-node.mjs"
|
"n8n-node": "bin/n8n-node.mjs"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/node-engine-compatibility",
|
"name": "@n8n/node-engine-compatibility",
|
||||||
"version": "0.11.0",
|
"version": "0.11.1",
|
||||||
"description": "Adapts v1 nodes and workflows to the Engine v2 execution model",
|
"description": "Adapts v1 nodes and workflows to the Engine v2 execution model",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
import { LOG_LEVELS, CONSOLE_OUTPUT_REDACTED_MESSAGE } from 'n8n-workflow';
|
import { LOG_LEVELS, CONSOLE_OUTPUT_REDACTED_MESSAGE } from 'n8n-workflow';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Code,
|
||||||
transformLegacyLangchainImport,
|
transformLegacyLangchainImport,
|
||||||
createSandboxLogger,
|
createSandboxLogger,
|
||||||
createProductionConsoleLog,
|
createProductionConsoleLog,
|
||||||
} from './Code.node';
|
} from './Code.node';
|
||||||
|
|
||||||
describe('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', () => {
|
describe('createSandboxLogger', () => {
|
||||||
const logLevelKeys = LOG_LEVELS.filter((level) => level !== 'silent');
|
const logLevelKeys = LOG_LEVELS.filter((level) => level !== 'silent');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,7 @@ export class Code implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
displayName: 'LangChain Code',
|
displayName: 'LangChain Code',
|
||||||
name: 'code',
|
name: 'code',
|
||||||
|
deprecated: true,
|
||||||
icon: 'fa:code',
|
icon: 'fa:code',
|
||||||
iconColor: 'black',
|
iconColor: 'black',
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,12 @@ const mockResponse = {
|
||||||
{
|
{
|
||||||
name: 'models/gemini-3.1-flash-image-preview',
|
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)',
|
name: 'models/gemini-3.1-flash-image-preview (Nano Banana 2)',
|
||||||
value: 'models/gemini-3.1-flash-image-preview',
|
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)',
|
name: 'models/gemini-3.1-flash-image-preview (Nano Banana 2)',
|
||||||
value: 'models/gemini-3.1-flash-image-preview',
|
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',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@ import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow'
|
||||||
|
|
||||||
import { apiRequest } from '../transport';
|
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(
|
async function baseModelSearch(
|
||||||
this: ILoadOptionsFunctions,
|
this: ILoadOptionsFunctions,
|
||||||
modelFilter: (model: string) => boolean,
|
modelFilter: (model: string) => boolean,
|
||||||
|
|
@ -32,9 +37,9 @@ export async function modelSearch(
|
||||||
return await baseModelSearch.call(
|
return await baseModelSearch.call(
|
||||||
this,
|
this,
|
||||||
(model) =>
|
(model) =>
|
||||||
|
!isImageModel(model) &&
|
||||||
!model.includes('embedding') &&
|
!model.includes('embedding') &&
|
||||||
!model.includes('aqa') &&
|
!model.includes('aqa') &&
|
||||||
!model.includes('image') &&
|
|
||||||
!model.includes('vision') &&
|
!model.includes('vision') &&
|
||||||
!model.includes('veo') &&
|
!model.includes('veo') &&
|
||||||
!model.includes('audio') &&
|
!model.includes('audio') &&
|
||||||
|
|
@ -50,9 +55,9 @@ export async function audioModelSearch(
|
||||||
return await baseModelSearch.call(
|
return await baseModelSearch.call(
|
||||||
this,
|
this,
|
||||||
(model) =>
|
(model) =>
|
||||||
|
!isImageModel(model) &&
|
||||||
!model.includes('embedding') &&
|
!model.includes('embedding') &&
|
||||||
!model.includes('aqa') &&
|
!model.includes('aqa') &&
|
||||||
!model.includes('image') &&
|
|
||||||
!model.includes('vision') &&
|
!model.includes('vision') &&
|
||||||
!model.includes('veo') &&
|
!model.includes('veo') &&
|
||||||
!model.includes('tts'), // we don't have a tts operation
|
!model.includes('tts'), // we don't have a tts operation
|
||||||
|
|
@ -64,8 +69,17 @@ export async function imageGenerationModelSearch(
|
||||||
this: ILoadOptionsFunctions,
|
this: ILoadOptionsFunctions,
|
||||||
filter?: string,
|
filter?: string,
|
||||||
): Promise<INodeListSearchResult> {
|
): 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) => {
|
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')) {
|
if (r.name.includes('gemini-3.1-flash-image')) {
|
||||||
return { name: `${r.name} (Nano Banana 2)`, value: r.value };
|
return { name: `${r.name} (Nano Banana 2)`, value: r.value };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/n8n-nodes-langchain",
|
"name": "@n8n/n8n-nodes-langchain",
|
||||||
"version": "2.43.0",
|
"version": "2.43.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/scheduler",
|
"name": "@n8n/scheduler",
|
||||||
"version": "1.12.0",
|
"version": "1.12.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/task-runner",
|
"name": "@n8n/task-runner",
|
||||||
"version": "2.43.0",
|
"version": "2.43.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"start": "node dist/start.js",
|
"start": "node dist/start.js",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/telemetry",
|
"name": "@n8n/telemetry",
|
||||||
"version": "1.11.0",
|
"version": "1.11.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/workflow-sdk",
|
"name": "@n8n/workflow-sdk",
|
||||||
"version": "0.36.0",
|
"version": "0.36.1",
|
||||||
"description": "TypeScript SDK for programmatically creating n8n workflows",
|
"description": "TypeScript SDK for programmatically creating n8n workflows",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n",
|
"name": "n8n",
|
||||||
"version": "2.43.0",
|
"version": "2.43.3",
|
||||||
"description": "n8n Workflow Automation Tool",
|
"description": "n8n Workflow Automation Tool",
|
||||||
"main": "dist/index",
|
"main": "dist/index",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
import { Logger } from '@n8n/backend-common';
|
||||||
import { EventService, UrlService } from '@n8n/backend-services';
|
import { EventService, UrlService } from '@n8n/backend-services';
|
||||||
import { mockInstance } from '@n8n/backend-test-utils';
|
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 type { WorkflowEntity, Project, WorkflowHistory } from '@n8n/db';
|
||||||
import {
|
import {
|
||||||
ExecutionRepository,
|
ExecutionRepository,
|
||||||
|
|
@ -59,6 +60,8 @@ import {
|
||||||
} from '@/workflow-execute-additional-data';
|
} from '@/workflow-execute-additional-data';
|
||||||
import * as WorkflowHelpers from '@/workflow-helpers';
|
import * as WorkflowHelpers from '@/workflow-helpers';
|
||||||
import { WorkflowHookContextService } from '@/workflow-hook-context.service';
|
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 { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service';
|
||||||
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
|
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
|
||||||
|
|
||||||
|
|
@ -138,6 +141,20 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||||
mockInstance(DataTableProxyService);
|
mockInstance(DataTableProxyService);
|
||||||
mockInstance(WorkflowHookContextService);
|
mockInstance(WorkflowHookContextService);
|
||||||
const workflowPublishedDataService = mockInstance(WorkflowPublishedDataService);
|
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);
|
const workflowsConfig = Container.get(WorkflowsConfig);
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// Keep the default (flag off) for every test; flag-on tests opt in.
|
// 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');
|
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', () => {
|
describe('getPublishedWorkflowData', () => {
|
||||||
|
|
@ -1414,6 +1440,17 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||||
workflowRepository.get.mockClear();
|
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 () => {
|
it('should use draft version', async () => {
|
||||||
const activeVersionNodes: INode[] = [
|
const activeVersionNodes: INode[] = [
|
||||||
mock<INode>({
|
mock<INode>({
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,20 @@
|
||||||
import type { ProviderCatalog } from '@n8n/agents';
|
import type { ProviderCatalog } from '@n8n/agents';
|
||||||
|
|
||||||
import { buildModelRecommendationsSection } from '../agents-builder-model-recommendations';
|
import {
|
||||||
import { buildBuilderPrompt } from '../agents-builder-prompts';
|
buildModelRecommendationsSection,
|
||||||
|
getModelRecommendationsSection,
|
||||||
|
resetModelRecommendationsCacheForTest,
|
||||||
|
} from '../agents-builder-model-recommendations';
|
||||||
|
import { buildBuilderPrompt, buildBuilderSessionContext } from '../agents-builder-prompts';
|
||||||
import { getBuilderRuntimeSkills } from '../skills';
|
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 = {
|
const catalog: ProviderCatalog = {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
id: 'anthropic',
|
id: 'anthropic',
|
||||||
|
|
@ -83,8 +94,8 @@ const catalog: ProviderCatalog = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildPrompt(modelRecommendationsSection: string | null) {
|
function buildSessionContext(modelRecommendationsSection: string | null) {
|
||||||
return buildBuilderPrompt({
|
return buildBuilderSessionContext({
|
||||||
agentPreviewPath: '/projects/project-1/agents/agent-1/preview',
|
agentPreviewPath: '/projects/project-1/agents/agent-1/preview',
|
||||||
modelRecommendationsSection,
|
modelRecommendationsSection,
|
||||||
});
|
});
|
||||||
|
|
@ -107,7 +118,7 @@ describe('builder model recommendations', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('routes distinct target-agent functions into autonomously managed skills', () => {
|
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');
|
const skill = getBuilderRuntimeSkills().find((s) => s.id === 'agent-builder-target-skills');
|
||||||
|
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
|
|
@ -145,7 +156,7 @@ describe('builder model recommendations', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tells the builder to preserve fallback web search on model switches', () => {
|
it('tells the builder to preserve fallback web search on model switches', () => {
|
||||||
const prompt = buildPrompt(null);
|
const prompt = buildBuilderPrompt();
|
||||||
|
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
'When changing models, preserve existing Brave or SearXNG\n `config.webSearch` unchanged',
|
'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', () => {
|
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');
|
const skill = getBuilderRuntimeSkills().find((s) => s.id === 'agent-builder-custom-tools');
|
||||||
|
|
||||||
expect(prompt).not.toContain("import { Tool } from '@n8n/agents';");
|
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);
|
const section = buildModelRecommendationsSection(catalog);
|
||||||
|
|
||||||
expect(buildPrompt(section)).toContain('### Recommended LLM Models');
|
expect(buildSessionContext(section)).toContain('### Recommended LLM Models');
|
||||||
expect(buildPrompt(section)).toContain('`openai/gpt-5` GPT-5');
|
expect(buildSessionContext(section)).toContain('`openai/gpt-5` GPT-5');
|
||||||
expect(buildPrompt(null)).not.toContain('### Recommended LLM Models');
|
expect(buildBuilderPrompt()).not.toContain('### Recommended LLM Models');
|
||||||
expect(buildPrompt(null)).toContain('do not recommend or name');
|
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', () => {
|
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');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import { AGENT_UI_LABELS_SECTION, buildBuilderPrompt } from '../agents-builder-prompts';
|
import {
|
||||||
|
AGENT_UI_LABELS_SECTION,
|
||||||
function buildPrompt() {
|
buildBuilderPrompt,
|
||||||
return buildBuilderPrompt({
|
buildBuilderSessionContext,
|
||||||
agentPreviewPath: '/projects/p1/agents/a1?openPreview=true',
|
} from '../agents-builder-prompts';
|
||||||
modelRecommendationsSection: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('buildBuilderPrompt', () => {
|
describe('buildBuilderPrompt', () => {
|
||||||
it('teaches the builder the Sessions tab labels after conversation-mode guidance', () => {
|
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('## Agent UI labels');
|
||||||
expect(prompt).toContain('Sessions tab');
|
expect(prompt).toContain('Sessions tab');
|
||||||
|
|
@ -24,4 +21,41 @@ describe('buildBuilderPrompt', () => {
|
||||||
expect(conversationModeIndex).toBeGreaterThan(-1);
|
expect(conversationModeIndex).toBeGreaterThan(-1);
|
||||||
expect(uiLabelsIndex).toBeGreaterThan(conversationModeIndex);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ const agentsSdkMocks = vi.hoisted(() => {
|
||||||
}> = [];
|
}> = [];
|
||||||
const resumeCalls: Array<{ options: Record<string, unknown> }> = [];
|
const resumeCalls: Array<{ options: Record<string, unknown> }> = [];
|
||||||
const instructionsCalls: string[] = [];
|
const instructionsCalls: string[] = [];
|
||||||
|
const volatileInstructionsProviders: Array<() => Promise<string | undefined>> = [];
|
||||||
const registeredToolNames: string[] = [];
|
const registeredToolNames: string[] = [];
|
||||||
const modelCalls: unknown[] = [];
|
const modelCalls: unknown[] = [];
|
||||||
const configurationCalls: Array<{ maxIterations?: number }> = [];
|
const configurationCalls: Array<{ maxIterations?: number }> = [];
|
||||||
|
|
@ -66,6 +67,10 @@ const agentsSdkMocks = vi.hoisted(() => {
|
||||||
instructionsCalls.push(text);
|
instructionsCalls.push(text);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
volatileInstructionsProvider(provider: () => Promise<string | undefined>) {
|
||||||
|
volatileInstructionsProviders.push(provider);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
skills(_skills: unknown) {
|
skills(_skills: unknown) {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
@ -129,6 +134,7 @@ const agentsSdkMocks = vi.hoisted(() => {
|
||||||
streamCalls,
|
streamCalls,
|
||||||
resumeCalls,
|
resumeCalls,
|
||||||
instructionsCalls,
|
instructionsCalls,
|
||||||
|
volatileInstructionsProviders,
|
||||||
registeredToolNames,
|
registeredToolNames,
|
||||||
modelCalls,
|
modelCalls,
|
||||||
configurationCalls,
|
configurationCalls,
|
||||||
|
|
@ -238,6 +244,7 @@ describe('AgentsBuilderService session isolation', () => {
|
||||||
agentsSdkMocks.streamCalls.length = 0;
|
agentsSdkMocks.streamCalls.length = 0;
|
||||||
agentsSdkMocks.resumeCalls.length = 0;
|
agentsSdkMocks.resumeCalls.length = 0;
|
||||||
agentsSdkMocks.instructionsCalls.length = 0;
|
agentsSdkMocks.instructionsCalls.length = 0;
|
||||||
|
agentsSdkMocks.volatileInstructionsProviders.length = 0;
|
||||||
agentsSdkMocks.registeredToolNames.length = 0;
|
agentsSdkMocks.registeredToolNames.length = 0;
|
||||||
agentsSdkMocks.modelCalls.length = 0;
|
agentsSdkMocks.modelCalls.length = 0;
|
||||||
agentsSdkMocks.configurationCalls.length = 0;
|
agentsSdkMocks.configurationCalls.length = 0;
|
||||||
|
|
@ -524,6 +531,29 @@ describe('AgentsBuilderService session isolation', () => {
|
||||||
expect(agentsSdkMocks.configurationCalls).toEqual([{ maxIterations: 100 }]);
|
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 () => {
|
it('enables prompt caching with a 5m Anthropic TTL for the builder agent', async () => {
|
||||||
const { service, user, credentialProvider, credentialService } = setup();
|
const { service, user, credentialProvider, credentialService } = setup();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { fetchProviderCatalog, type ModelInfo, type ProviderCatalog } from '@n8n/agents/catalog';
|
import { fetchProviderCatalog, type ModelInfo, type ProviderCatalog } from '@n8n/agents/catalog';
|
||||||
|
|
||||||
const MODEL_RECOMMENDATION_FETCH_TIMEOUT_MS = 5000;
|
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;
|
const MAX_RECOMMENDED_MODELS_PER_PROVIDER = 3;
|
||||||
|
|
||||||
interface RecommendedProvider {
|
interface RecommendedProvider {
|
||||||
|
|
@ -18,6 +20,7 @@ const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
|
||||||
|
|
||||||
let modelRecommendationsSection: string | null | undefined;
|
let modelRecommendationsSection: string | null | undefined;
|
||||||
let modelRecommendationsPromise: Promise<string | null> | undefined;
|
let modelRecommendationsPromise: Promise<string | null> | undefined;
|
||||||
|
let modelRecommendationsRetryAt = 0;
|
||||||
|
|
||||||
function getReleaseTime(model: ModelInfo): number | null {
|
function getReleaseTime(model: ModelInfo): number | null {
|
||||||
if (!model.releaseDate) return null;
|
if (!model.releaseDate) return null;
|
||||||
|
|
@ -110,6 +113,7 @@ async function fetchProviderCatalogWithTimeout(): Promise<ProviderCatalog | null
|
||||||
|
|
||||||
export async function getModelRecommendationsSection(): Promise<string | null> {
|
export async function getModelRecommendationsSection(): Promise<string | null> {
|
||||||
if (modelRecommendationsSection !== undefined) return modelRecommendationsSection;
|
if (modelRecommendationsSection !== undefined) return modelRecommendationsSection;
|
||||||
|
if (!modelRecommendationsPromise && Date.now() < modelRecommendationsRetryAt) return null;
|
||||||
|
|
||||||
modelRecommendationsPromise ??= fetchProviderCatalogWithTimeout()
|
modelRecommendationsPromise ??= fetchProviderCatalogWithTimeout()
|
||||||
.then((catalog) => (catalog ? buildModelRecommendationsSection(catalog) : null))
|
.then((catalog) => (catalog ? buildModelRecommendationsSection(catalog) : null))
|
||||||
|
|
@ -119,6 +123,17 @@ export async function getModelRecommendationsSection(): Promise<string | null> {
|
||||||
});
|
});
|
||||||
|
|
||||||
const section = await modelRecommendationsPromise;
|
const section = await modelRecommendationsPromise;
|
||||||
if (section) modelRecommendationsSection = section;
|
if (section) {
|
||||||
|
modelRecommendationsSection = section;
|
||||||
|
} else {
|
||||||
|
modelRecommendationsRetryAt = Date.now() + MODEL_RECOMMENDATION_RETRY_AFTER_MS;
|
||||||
|
}
|
||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Test-only: clear the module-level cache. */
|
||||||
|
export function resetModelRecommendationsCacheForTest(): void {
|
||||||
|
modelRecommendationsSection = undefined;
|
||||||
|
modelRecommendationsPromise = undefined;
|
||||||
|
modelRecommendationsRetryAt = 0;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { getConfigMutationPrompt } from './prompts/config-mutation.prompt';
|
import { getConfigMutationPrompt } from './prompts/config-mutation.prompt';
|
||||||
import { INITIAL_BUILD_SECTION } from './prompts/initial-build.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 { MEMORY_PROMPT } from './prompts/memory.prompt';
|
||||||
import { TOOLS_PROMPT } from './prompts/tools.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
|
When the user asks to change the target agent's channels, prefer a supported
|
||||||
one from the list; never invent a type.`;
|
one from the list; never invent a type.`;
|
||||||
|
|
||||||
export function getConversationModeSection(agentPreviewPath: string): string {
|
export const CONVERSATION_MODE_SECTION = `\
|
||||||
return `\
|
|
||||||
## When To Build vs When To Converse
|
## When To Build vs When To Converse
|
||||||
|
|
||||||
Not every user message is a build request. Before changing config or creating
|
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.
|
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
|
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,
|
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
|
include that same Preview markdown link in your wrap-up (it can be part of a
|
||||||
(it can be part of a longer reply). Keep Preview links as relative app paths
|
longer reply). Keep Preview links as relative app paths and do not invent a
|
||||||
and do not invent a different path.
|
different path.
|
||||||
|
|
||||||
Never write empty or placeholder \`instructions\`. When the user gave a
|
Never write empty or placeholder \`instructions\`. When the user gave a
|
||||||
concrete goal, write real instructions from it and fill gaps with sensible assumptions
|
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.`;
|
stated in your summary. Only ask first when the overall goal itself is missing.`;
|
||||||
}
|
|
||||||
|
|
||||||
export const AGENT_UI_LABELS_SECTION = `\
|
export const AGENT_UI_LABELS_SECTION = `\
|
||||||
## Agent UI labels
|
## 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
|
4. After a successful publish, confirm the agent is live; do not send the user to the editor
|
||||||
Publish button.`;
|
Publish button.`;
|
||||||
|
|
||||||
export interface BuilderPromptContext {
|
export interface BuilderSessionContext {
|
||||||
agentPreviewPath: string;
|
agentPreviewPath: string;
|
||||||
modelRecommendationsSection: string | null;
|
modelRecommendationsSection: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildBuilderPrompt(ctx: BuilderPromptContext): string {
|
const NO_MODEL_RECOMMENDATIONS =
|
||||||
const { agentPreviewPath, modelRecommendationsSection } = ctx;
|
'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 = [
|
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.',
|
'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,
|
TARGET_AGENT_SECTION,
|
||||||
PREREQUISITES_SECTION,
|
PREREQUISITES_SECTION,
|
||||||
SUPPORTED_CHANNELS_SECTION,
|
SUPPORTED_CHANNELS_SECTION,
|
||||||
getConversationModeSection(agentPreviewPath),
|
CONVERSATION_MODE_SECTION,
|
||||||
AGENT_UI_LABELS_SECTION,
|
AGENT_UI_LABELS_SECTION,
|
||||||
getConfigMutationPrompt(),
|
getConfigMutationPrompt(),
|
||||||
getLlmSelectionPrompt(modelRecommendationsSection),
|
LLM_SELECTION_PROMPT,
|
||||||
MEMORY_PROMPT,
|
MEMORY_PROMPT,
|
||||||
TOOLS_PROMPT,
|
TOOLS_PROMPT,
|
||||||
INTERACTIVE_TOOLS_SECTION,
|
INTERACTIVE_TOOLS_SECTION,
|
||||||
|
|
@ -405,3 +411,19 @@ export function buildBuilderPrompt(ctx: BuilderPromptContext): string {
|
||||||
|
|
||||||
return sections.join('\n\n');
|
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}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ import { AgentsService } from '../agents.service';
|
||||||
import { modelStreamStallOptions } from '../model-stream-stall-options';
|
import { modelStreamStallOptions } from '../model-stream-stall-options';
|
||||||
import { buildAgentPreviewPath } from './agent-builder-preview-path';
|
import { buildAgentPreviewPath } from './agent-builder-preview-path';
|
||||||
import { getModelRecommendationsSection } from './agents-builder-model-recommendations';
|
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 { AgentsBuilderToolsService, type BuilderTools } from './agents-builder-tools.service';
|
||||||
import { BuilderCheckpointUnavailableError } from './errors';
|
import { BuilderCheckpointUnavailableError } from './errors';
|
||||||
import {
|
import {
|
||||||
|
|
@ -242,7 +242,8 @@ export class AgentsBuilderService {
|
||||||
// always runs on it directly.
|
// always runs on it directly.
|
||||||
const modelConfig = session.modelConfig;
|
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 runtimeSkills = getBuilderRuntimeSkills();
|
||||||
|
|
||||||
const tools = this.agentsBuilderToolsService.getTools(
|
const tools = this.agentsBuilderToolsService.getTools(
|
||||||
|
|
@ -264,6 +265,8 @@ export class AgentsBuilderService {
|
||||||
const builder = new Agent('agent-builder')
|
const builder = new Agent('agent-builder')
|
||||||
.model(modelConfig)
|
.model(modelConfig)
|
||||||
.instructions(finalInstructions)
|
.instructions(finalInstructions)
|
||||||
|
// Sent after the cached instructions so per-agent values do not break the cache.
|
||||||
|
.volatileInstructionsProvider(async () => sessionContext)
|
||||||
.skills(runtimeSkills)
|
.skills(runtimeSkills)
|
||||||
.memory(builderMemory)
|
.memory(builderMemory)
|
||||||
.checkpoint(this.n8nCheckpointStorage.getStorage(agentId))
|
.checkpoint(this.n8nCheckpointStorage.getStorage(agentId))
|
||||||
|
|
@ -336,21 +339,21 @@ export class AgentsBuilderService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createBuilderInstructions(
|
/** Static per addendum, so every build with the same addendum shares one cached prompt. */
|
||||||
projectId: string,
|
private createBuilderInstructions(session: InstanceAiBuilderSessionOptions): string {
|
||||||
agentId: string,
|
const instructions = buildBuilderPrompt();
|
||||||
session: InstanceAiBuilderSessionOptions,
|
|
||||||
): Promise<string> {
|
|
||||||
const modelRecommendationsSection = await getModelRecommendationsSection();
|
|
||||||
const instructions = buildBuilderPrompt({
|
|
||||||
agentPreviewPath: buildAgentPreviewPath(projectId, agentId),
|
|
||||||
modelRecommendationsSection,
|
|
||||||
});
|
|
||||||
return session.instructionsAddendum
|
return session.instructionsAddendum
|
||||||
? `${instructions}\n\n${session.instructionsAddendum}`
|
? `${instructions}\n\n${session.instructionsAddendum}`
|
||||||
: instructions;
|
: instructions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async createBuilderSessionContext(projectId: string, agentId: string): Promise<string> {
|
||||||
|
return buildBuilderSessionContext({
|
||||||
|
agentPreviewPath: buildAgentPreviewPath(projectId, agentId),
|
||||||
|
modelRecommendationsSection: await getModelRecommendationsSection(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async createBuilderMemory(
|
private async createBuilderMemory(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
user: User,
|
user: User,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
export function getLlmSelectionPrompt(modelRecommendationsSection: string | null): string {
|
/**
|
||||||
const recommendationGuidance = modelRecommendationsSection
|
* Static: the live model recommendations are session context (see
|
||||||
? `\n\n${modelRecommendationsSection}`
|
* `buildBuilderSessionContext`), so this text stays byte-identical across builds.
|
||||||
: '\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.';
|
*/
|
||||||
|
export const LLM_SELECTION_PROMPT = `\
|
||||||
return `\
|
|
||||||
## LLM Selection Guidance
|
## LLM Selection Guidance
|
||||||
|
|
||||||
### Purpose
|
### 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
|
- If the user explicitly asks for Brave or SearXNG, keep that provider even
|
||||||
when the selected model also supports native search.
|
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.
|
- 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.
|
- Prefer a provider the user already has credentials for when choosing from recommendations.
|
||||||
- Never copy main LLM credential IDs from \`list_credentials\`.
|
- 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 \`model\` is in \`provider/model\` form.
|
||||||
- The persisted \`credential\` came from \`resolve_llm\`.
|
- 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.`;
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Logger } from '@n8n/backend-common';
|
import type { Logger } from '@n8n/backend-common';
|
||||||
|
import type { NodesConfig } from '@n8n/config';
|
||||||
import type { User } from '@n8n/db';
|
import type { User } from '@n8n/db';
|
||||||
import type { EntityManager } from '@n8n/typeorm';
|
import type { EntityManager } from '@n8n/typeorm';
|
||||||
import type { INode, INodeType } from 'n8n-workflow';
|
import type { INode, INodeType } from 'n8n-workflow';
|
||||||
|
|
@ -6,7 +7,9 @@ import { v4 as uuid } from 'uuid';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
import { BadRequestError, NotFoundError } from '@n8n/errors';
|
import { BadRequestError, NotFoundError } from '@n8n/errors';
|
||||||
|
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
|
||||||
import type { NodeTypes } from '@/node-types';
|
import type { NodeTypes } from '@/node-types';
|
||||||
|
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||||
|
|
||||||
import type { ChatHubTool } from '../chat-hub-tool.entity';
|
import type { ChatHubTool } from '../chat-hub-tool.entity';
|
||||||
import type { ChatHubToolRepository } from '../chat-hub-tool.repository';
|
import type { ChatHubToolRepository } from '../chat-hub-tool.repository';
|
||||||
|
|
@ -73,7 +76,16 @@ describe('ChatHubToolService', () => {
|
||||||
description: { properties: [] },
|
description: { properties: [] },
|
||||||
} as unknown as INodeType);
|
} 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', () => {
|
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', () => {
|
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 () => {
|
it('should create a tool from the definition', async () => {
|
||||||
const created = makeTool();
|
const created = makeTool();
|
||||||
chatToolRepository.createTool.mockResolvedValue(created);
|
chatToolRepository.createTool.mockResolvedValue(created);
|
||||||
|
|
@ -288,6 +327,40 @@ describe('ChatHubToolService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateTool', () => {
|
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 () => {
|
it('should update tool definition and denormalized fields', async () => {
|
||||||
const existingTool = makeTool();
|
const existingTool = makeTool();
|
||||||
const updatedDef: INode = { ...mockDefinition, name: 'Updated Tool' };
|
const updatedDef: INode = { ...mockDefinition, name: 'Updated Tool' };
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ import { type IBinaryData, type INode, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workfl
|
||||||
import type { Mock } from 'vitest';
|
import type { Mock } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
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 { 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 { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||||
|
|
||||||
import type { ChatHubAgent } from '../chat-hub-agent.entity';
|
import type { ChatHubAgent } from '../chat-hub-agent.entity';
|
||||||
|
|
@ -38,6 +40,7 @@ describe('ChatHubWorkflowService', () => {
|
||||||
|
|
||||||
const mockCipher = mock<Cipher>();
|
const mockCipher = mock<Cipher>();
|
||||||
const policyEnforcementService = mock<PolicyEnforcementService>();
|
const policyEnforcementService = mock<PolicyEnforcementService>();
|
||||||
|
const deprecatedNodesValidationService = mock<DeprecatedNodesValidationService>();
|
||||||
|
|
||||||
let chatHubAttachmentService: ChatHubAttachmentService;
|
let chatHubAttachmentService: ChatHubAttachmentService;
|
||||||
let service: ChatHubWorkflowService;
|
let service: ChatHubWorkflowService;
|
||||||
|
|
@ -72,6 +75,7 @@ describe('ChatHubWorkflowService', () => {
|
||||||
workflowFinderService,
|
workflowFinderService,
|
||||||
mockCipher,
|
mockCipher,
|
||||||
policyEnforcementService,
|
policyEnforcementService,
|
||||||
|
deprecatedNodesValidationService,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mock repository methods
|
// Mock repository methods
|
||||||
|
|
@ -162,6 +166,32 @@ describe('ChatHubWorkflowService', () => {
|
||||||
expect(workflowRepository.createContent).not.toHaveBeenCalled();
|
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 () => {
|
it('enforces the save for the title-generation workflow', async () => {
|
||||||
await service.createTitleGenerationWorkflow(
|
await service.createTitleGenerationWorkflow(
|
||||||
'user-123',
|
'user-123',
|
||||||
|
|
@ -1363,6 +1393,7 @@ describe('ChatHubWorkflowService', () => {
|
||||||
workflowFinderService,
|
workflowFinderService,
|
||||||
mockCipher,
|
mockCipher,
|
||||||
policyEnforcementService,
|
policyEnforcementService,
|
||||||
|
deprecatedNodesValidationService,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mockTrx = mock<EntityManager>();
|
const mockTrx = mock<EntityManager>();
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { collectExpressionDefaults, findDisallowedChatToolExpressions } from 'n8
|
||||||
|
|
||||||
import { BadRequestError, NotFoundError } from '@n8n/errors';
|
import { BadRequestError, NotFoundError } from '@n8n/errors';
|
||||||
import { NodeTypes } from '@/node-types';
|
import { NodeTypes } from '@/node-types';
|
||||||
|
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||||
|
|
||||||
import type { ChatHubTool } from './chat-hub-tool.entity';
|
import type { ChatHubTool } from './chat-hub-tool.entity';
|
||||||
import { ChatHubToolRepository } from './chat-hub-tool.repository';
|
import { ChatHubToolRepository } from './chat-hub-tool.repository';
|
||||||
|
|
@ -21,6 +22,7 @@ export class ChatHubToolService {
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly chatToolRepository: ChatHubToolRepository,
|
private readonly chatToolRepository: ChatHubToolRepository,
|
||||||
private readonly nodeTypes: NodeTypes,
|
private readonly nodeTypes: NodeTypes,
|
||||||
|
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.logger.scoped('chat-hub');
|
this.logger = this.logger.scoped('chat-hub');
|
||||||
}
|
}
|
||||||
|
|
@ -85,6 +87,7 @@ export class ChatHubToolService {
|
||||||
async createTool(user: User, data: ChatHubCreateToolRequest): Promise<ChatHubTool> {
|
async createTool(user: User, data: ChatHubCreateToolRequest): Promise<ChatHubTool> {
|
||||||
const definition = data.definition;
|
const definition = data.definition;
|
||||||
this.validateToolExpressions(definition);
|
this.validateToolExpressions(definition);
|
||||||
|
this.deprecatedNodesValidationService.validateOnCreate([definition]);
|
||||||
|
|
||||||
const tool = await this.chatToolRepository.createTool({
|
const tool = await this.chatToolRepository.createTool({
|
||||||
id: definition.id,
|
id: definition.id,
|
||||||
|
|
@ -117,12 +120,21 @@ export class ChatHubToolService {
|
||||||
|
|
||||||
if (updates.definition !== undefined) {
|
if (updates.definition !== undefined) {
|
||||||
this.validateToolExpressions(updates.definition);
|
this.validateToolExpressions(updates.definition);
|
||||||
|
this.deprecatedNodesValidationService.validateOnUpdate(
|
||||||
|
[updates.definition],
|
||||||
|
[existingTool.definition],
|
||||||
|
);
|
||||||
updateData.definition = updates.definition;
|
updateData.definition = updates.definition;
|
||||||
updateData.name = updates.definition.name;
|
updateData.name = updates.definition.name;
|
||||||
updateData.type = updates.definition.type;
|
updateData.type = updates.definition.type;
|
||||||
updateData.typeVersion = updates.definition.typeVersion ?? 1;
|
updateData.typeVersion = updates.definition.typeVersion ?? 1;
|
||||||
}
|
}
|
||||||
if (updates.enabled !== undefined) {
|
if (updates.enabled !== undefined) {
|
||||||
|
if (updates.enabled) {
|
||||||
|
this.deprecatedNodesValidationService.validateOnCreate([
|
||||||
|
updates.definition ?? existingTool.definition,
|
||||||
|
]);
|
||||||
|
}
|
||||||
updateData.enabled = updates.enabled;
|
updateData.enabled = updates.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||||
import { BadRequestError } from '@n8n/errors';
|
import { BadRequestError } from '@n8n/errors';
|
||||||
import type { UserLike } from '@/types/user-like.types';
|
import type { UserLike } from '@/types/user-like.types';
|
||||||
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||||
|
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||||
|
|
||||||
import { ChatHubAgentRepository } from './chat-hub-agent.repository';
|
import { ChatHubAgentRepository } from './chat-hub-agent.repository';
|
||||||
|
|
@ -96,6 +97,7 @@ export class ChatHubWorkflowService {
|
||||||
private readonly workflowFinderService: WorkflowFinderService,
|
private readonly workflowFinderService: WorkflowFinderService,
|
||||||
private readonly cipher: Cipher,
|
private readonly cipher: Cipher,
|
||||||
private readonly policyEnforcementService: PolicyEnforcementService,
|
private readonly policyEnforcementService: PolicyEnforcementService,
|
||||||
|
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.logger.scoped('chat-hub');
|
this.logger = this.logger.scoped('chat-hub');
|
||||||
}
|
}
|
||||||
|
|
@ -196,6 +198,8 @@ export class ChatHubWorkflowService {
|
||||||
projectId: string,
|
projectId: string,
|
||||||
user: UserLike,
|
user: UserLike,
|
||||||
) {
|
) {
|
||||||
|
this.deprecatedNodesValidationService.validateOnCreate(workflow.nodes, workflow.id);
|
||||||
|
|
||||||
return await this.policyEnforcementService.enforceWorkflowSave(
|
return await this.policyEnforcementService.enforceWorkflowSave(
|
||||||
{
|
{
|
||||||
workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes },
|
workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes },
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
it('uses the environment model connection without resolving stored credentials', async () => {
|
||||||
Object.assign(globalConfig.instanceAi, {
|
Object.assign(globalConfig.instanceAi, {
|
||||||
model: 'openai/gpt-5',
|
model: 'openai/gpt-5',
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import {
|
||||||
import type { GlobalConfig } from '@n8n/config';
|
import type { GlobalConfig } from '@n8n/config';
|
||||||
import { GLOBAL_MEMBER_ROLE } from '@n8n/db';
|
import { GLOBAL_MEMBER_ROLE } from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
|
import { LessThan } from '@n8n/typeorm';
|
||||||
import type {
|
import type {
|
||||||
AiBuilderTemporaryWorkflowRepository,
|
AiBuilderTemporaryWorkflowRepository,
|
||||||
CredentialsEntity,
|
CredentialsEntity,
|
||||||
|
|
@ -393,7 +394,7 @@ describe('stop execution — workflow:execute scope', () => {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('cleanupTestExecutions — scope and deletion pipeline', () => {
|
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);
|
workflowFinderService.findWorkflowForUser.mockResolvedValue(null);
|
||||||
|
|
||||||
const ctx = service.createContext(user);
|
const ctx = service.createContext(user);
|
||||||
|
|
@ -402,26 +403,60 @@ describe('cleanupTestExecutions — scope and deletion pipeline', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(workflowFinderService.findWorkflowForUser).toHaveBeenCalledWith('wf-1', user, [
|
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);
|
workflowFinderService.findWorkflowForUser.mockResolvedValue({ id: 'wf-1' } as never);
|
||||||
executionRepository.find.mockResolvedValue([{ id: 'exec-1' }, { id: 'exec-2' }] as never);
|
executionRepository.find.mockResolvedValue([{ id: 'exec-1' }, { id: 'exec-2' }] as never);
|
||||||
executionPersistence.hardDeleteBy.mockResolvedValue(undefined);
|
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 ctx = service.createContext(user);
|
||||||
const result = await ctx.workspaceService!.cleanupTestExecutions('wf-1');
|
const result = await ctx.workspaceService!.cleanupTestExecutions('wf-1');
|
||||||
|
|
||||||
expect(result.deletedCount).toBe(2);
|
expect(result.deletedCount).toBe(1201);
|
||||||
expect(executionPersistence.hardDeleteBy).toHaveBeenCalledWith({
|
const chunks = executionPersistence.hardDeleteBy.mock.calls.map(
|
||||||
filters: { workflowId: 'wf-1', mode: 'manual' },
|
([criteria]) => criteria.deleteConditions.ids,
|
||||||
accessibleWorkflowIds: ['wf-1'],
|
);
|
||||||
deleteConditions: { deleteBefore: expect.any(Date) },
|
expect(chunks.map((chunk) => chunk?.length)).toEqual([500, 500, 201]);
|
||||||
});
|
expect(chunks.flat()).toEqual(ids);
|
||||||
// Verify deleteByIds is NOT called
|
|
||||||
expect(executionRepository.deleteByIds).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits execution-deleted audit event', async () => {
|
it('emits execution-deleted audit event', async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { isOpenAiCustomEndpoint } from '@n8n/ai-utilities/model-discovery';
|
||||||
import { EventService, CredentialsFinderService } from '@n8n/backend-services';
|
import { EventService, CredentialsFinderService } from '@n8n/backend-services';
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
|
|
@ -1478,8 +1479,8 @@ export class InstanceAiSettingsService {
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
modelName: string,
|
modelName: string,
|
||||||
): ModelConfig | null {
|
): ModelConfig | null {
|
||||||
const provider = CREDENTIAL_TO_MODEL_PROVIDER[credentialType];
|
const credentialProvider = CREDENTIAL_TO_MODEL_PROVIDER[credentialType];
|
||||||
if (!provider) {
|
if (!credentialProvider) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1487,10 +1488,22 @@ export class InstanceAiSettingsService {
|
||||||
const urlField = URL_FIELD_MAP[credentialType];
|
const urlField = URL_FIELD_MAP[credentialType];
|
||||||
const rawUrl = urlField ? data[urlField] : undefined;
|
const rawUrl = urlField ? data[urlField] : undefined;
|
||||||
const baseUrl = typeof rawUrl === 'string' ? rawUrl : '';
|
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}`;
|
const id: `${string}/${string}` = `${provider}/${modelName}`;
|
||||||
if (!baseUrl && !apiKey) return null;
|
if (!baseUrl && !apiKey) return null;
|
||||||
const headers = modelCredentialHeaders(credentialType, data);
|
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 ───────────────────────────────────────────────────
|
// ── Private helpers ───────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -4237,9 +4237,8 @@ export class InstanceAiAdapterService {
|
||||||
options?: { olderThanHours?: number },
|
options?: { olderThanHours?: number },
|
||||||
): Promise<{ deletedCount: number }> {
|
): Promise<{ deletedCount: number }> {
|
||||||
assertNotReadOnly('executions');
|
assertNotReadOnly('executions');
|
||||||
// Access-check the workflow with execute scope (matches controller behavior)
|
|
||||||
const workflow = await workflowFinderService.findWorkflowForUser(workflowId, user, [
|
const workflow = await workflowFinderService.findWorkflowForUser(workflowId, user, [
|
||||||
'workflow:execute',
|
'execution:delete',
|
||||||
]);
|
]);
|
||||||
if (!workflow) {
|
if (!workflow) {
|
||||||
throw new WorkflowNotFoundError(workflowId);
|
throw new WorkflowNotFoundError(workflowId);
|
||||||
|
|
@ -4264,12 +4263,15 @@ export class InstanceAiAdapterService {
|
||||||
|
|
||||||
const ids = executions.map((e) => e.id);
|
const ids = executions.map((e) => e.id);
|
||||||
|
|
||||||
// Use the canonical deletion pipeline (handles binary data and fs blobs)
|
// Use the canonical deletion pipeline (handles binary data and fs blobs).
|
||||||
await executionPersistence.hardDeleteBy({
|
// Delete by ID so only the counted manual executions are removed.
|
||||||
filters: { workflowId, mode: 'manual' },
|
for (let start = 0; start < ids.length; start += EXECUTION_DELETE_CHUNK_SIZE) {
|
||||||
accessibleWorkflowIds: [workflowId],
|
await executionPersistence.hardDeleteBy({
|
||||||
deleteConditions: { deleteBefore: cutoff },
|
filters: undefined,
|
||||||
});
|
accessibleWorkflowIds: [workflowId],
|
||||||
|
deleteConditions: { ids: ids.slice(start, start + EXECUTION_DELETE_CHUNK_SIZE) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Emit audit event (matches controller behavior)
|
// Emit audit event (matches controller behavior)
|
||||||
eventService.emit('execution-deleted', {
|
eventService.emit('execution-deleted', {
|
||||||
|
|
@ -5349,6 +5351,10 @@ function readParentFolder(workflow: WorkflowEntity): { id: string; name: string
|
||||||
return parent ? { id: parent.id, name: parent.name } : undefined;
|
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
|
/** Folder ids per path query. `getFolderPathsToRoot` binds one parameter per id
|
||||||
* and SQLite allows 999 of them, so the ids are read in chunks. */
|
* and SQLite allows 999 of them, so the ids are read in chunks. */
|
||||||
const FOLDER_PATH_CHUNK_SIZE = 500;
|
const FOLDER_PATH_CHUNK_SIZE = 500;
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import { mock } from 'vitest-mock-extended';
|
||||||
import type { CredentialsService } from '@/credentials/credentials.service';
|
import type { CredentialsService } from '@/credentials/credentials.service';
|
||||||
import type { VariablesService } from '@/environments.ee/variables/variables.service.ee';
|
import type { VariablesService } from '@/environments.ee/variables/variables.service.ee';
|
||||||
import type { ExecutionPersistence } from '@/executions/execution-persistence';
|
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 { WorkflowPublishBlockedError } from '@/errors/response-errors/workflow-publish-blocked.error';
|
||||||
import type { DataTableColumnRepository } from '@/modules/data-table/data-table-column.repository';
|
import type { DataTableColumnRepository } from '@/modules/data-table/data-table-column.repository';
|
||||||
import type { DataTableDDLService } from '@/modules/data-table/data-table-ddl.service';
|
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 { RedactionEnforcementService } from '@/modules/redaction/redaction-enforcement.service';
|
||||||
import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||||
import { PolicyViolationError } from '@/policy/policy-violation.error';
|
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 { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
|
||||||
import type { WorkflowMutationHooksProxy } from '@/workflows/workflow-mutation-hooks-proxy.service';
|
import type { WorkflowMutationHooksProxy } from '@/workflows/workflow-mutation-hooks-proxy.service';
|
||||||
import type { WorkflowPublishGuardProxy } from '@/workflows/workflow-publish-guard-proxy.service';
|
import type { WorkflowPublishGuardProxy } from '@/workflows/workflow-publish-guard-proxy.service';
|
||||||
|
|
@ -86,6 +88,7 @@ describe('SourceControlImportService', () => {
|
||||||
const dataTableColumnRepository = mock<DataTableColumnRepository>();
|
const dataTableColumnRepository = mock<DataTableColumnRepository>();
|
||||||
const dataTableDDLService = mock<DataTableDDLService>();
|
const dataTableDDLService = mock<DataTableDDLService>();
|
||||||
const redactionEnforcementService = mock<RedactionEnforcementService>();
|
const redactionEnforcementService = mock<RedactionEnforcementService>();
|
||||||
|
const deprecatedNodesValidationService = mock<DeprecatedNodesValidationService>();
|
||||||
const policyEnforcementService = mock<PolicyEnforcementService>();
|
const policyEnforcementService = mock<PolicyEnforcementService>();
|
||||||
policyEnforcementService.hasChecksFor.mockReturnValue(true);
|
policyEnforcementService.hasChecksFor.mockReturnValue(true);
|
||||||
policyEnforcementService.enforceContentImport.mockResolvedValue(mock());
|
policyEnforcementService.enforceContentImport.mockResolvedValue(mock());
|
||||||
|
|
@ -140,6 +143,7 @@ describe('SourceControlImportService', () => {
|
||||||
workflowPublishGuard,
|
workflowPublishGuard,
|
||||||
workflowMutationHooks,
|
workflowMutationHooks,
|
||||||
workflowFinderService,
|
workflowFinderService,
|
||||||
|
deprecatedNodesValidationService,
|
||||||
);
|
);
|
||||||
|
|
||||||
const globMock = fastGlob.default as unknown as Mock<(...args: string[]) => Promise<string[]>>;
|
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', () => {
|
describe('content-import policy enforcement', () => {
|
||||||
const mockUserId = 'user-id-123';
|
const mockUserId = 'user-id-123';
|
||||||
const mockWorkflowFile = '/mock/workflow1.json';
|
const mockWorkflowFile = '/mock/workflow1.json';
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ import { isUniqueConstraintError } from '@/response-helper';
|
||||||
import { TagService } from '@/services/tag.service';
|
import { TagService } from '@/services/tag.service';
|
||||||
import { assertNever } from '@/utils';
|
import { assertNever } from '@/utils';
|
||||||
import { validateWorkflowNodeGroups, sanitizeNodeGroupDescriptions } from '@/workflow-helpers';
|
import { validateWorkflowNodeGroups, sanitizeNodeGroupDescriptions } from '@/workflow-helpers';
|
||||||
|
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||||
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
|
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
|
||||||
import { WorkflowMutationHooksProxy } from '@/workflows/workflow-mutation-hooks-proxy.service';
|
import { WorkflowMutationHooksProxy } from '@/workflows/workflow-mutation-hooks-proxy.service';
|
||||||
|
|
@ -173,6 +174,7 @@ export class SourceControlImportService {
|
||||||
private readonly workflowPublishGuard: WorkflowPublishGuardProxy,
|
private readonly workflowPublishGuard: WorkflowPublishGuardProxy,
|
||||||
private readonly workflowMutationHooks: WorkflowMutationHooksProxy,
|
private readonly workflowMutationHooks: WorkflowMutationHooksProxy,
|
||||||
private readonly workflowFinderService: WorkflowFinderService,
|
private readonly workflowFinderService: WorkflowFinderService,
|
||||||
|
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
|
||||||
) {
|
) {
|
||||||
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
|
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
|
||||||
this.workflowExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER);
|
this.workflowExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER);
|
||||||
|
|
@ -783,7 +785,7 @@ export class SourceControlImportService {
|
||||||
const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(userId);
|
const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(userId);
|
||||||
const candidateIds = candidates.map((c) => c.id);
|
const candidateIds = candidates.map((c) => c.id);
|
||||||
const existingWorkflows = await this.workflowRepository.findByIds(candidateIds, {
|
const existingWorkflows = await this.workflowRepository.findByIds(candidateIds, {
|
||||||
fields: ['id', 'name', 'versionId', 'active', 'activeVersionId', 'isArchived'],
|
fields: ['id', 'name', 'versionId', 'active', 'activeVersionId', 'isArchived', 'nodes'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const folders = await this.folderRepository.find({ select: ['id'] });
|
const folders = await this.folderRepository.find({ select: ['id'] });
|
||||||
|
|
@ -857,6 +859,19 @@ export class SourceControlImportService {
|
||||||
}
|
}
|
||||||
const existingWorkflow = existingWorkflows.find((e) => e.id === id);
|
const existingWorkflow = existingWorkflows.find((e) => e.id === id);
|
||||||
|
|
||||||
|
// Apply the same deprecated-node enforcement as the REST/public API
|
||||||
|
// endpoints. A re-sync that leaves the deprecated nodes untouched is
|
||||||
|
// allowed; introducing or in-place editing them through git is not.
|
||||||
|
if (existingWorkflow) {
|
||||||
|
this.deprecatedNodesValidationService.validateOnUpdate(
|
||||||
|
nodes,
|
||||||
|
existingWorkflow.nodes ?? [],
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.deprecatedNodesValidationService.validateOnCreate(nodes, id);
|
||||||
|
}
|
||||||
|
|
||||||
await this.redactionEnforcementService.assertPolicyChangeAllowed(
|
await this.redactionEnforcementService.assertPolicyChangeAllowed(
|
||||||
existingWorkflow?.settings?.redactionPolicy,
|
existingWorkflow?.settings?.redactionPolicy,
|
||||||
importedWorkflow.settings?.redactionPolicy,
|
importedWorkflow.settings?.redactionPolicy,
|
||||||
|
|
|
||||||
|
|
@ -641,6 +641,7 @@ describe('auto-close on source-control pull', () => {
|
||||||
mock(), // workflowPublishGuard
|
mock(), // workflowPublishGuard
|
||||||
Container.get(WorkflowMutationHooksProxy),
|
Container.get(WorkflowMutationHooksProxy),
|
||||||
Container.get(WorkflowFinderService),
|
Container.get(WorkflowFinderService),
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Logger } from '@n8n/backend-common';
|
import { Logger } from '@n8n/backend-common';
|
||||||
import { mockInstance } from '@n8n/backend-test-utils';
|
import { mockInstance } from '@n8n/backend-test-utils';
|
||||||
|
import type { NodesConfig } from '@n8n/config';
|
||||||
import { WorkflowRepository } from '@n8n/db';
|
import { WorkflowRepository } from '@n8n/db';
|
||||||
import type { User } from '@n8n/db';
|
import type { User } from '@n8n/db';
|
||||||
import { InstanceSettings } from 'n8n-core';
|
import { InstanceSettings } from 'n8n-core';
|
||||||
|
|
@ -11,7 +12,9 @@ import { CredentialsFinderService } from '@n8n/backend-services';
|
||||||
import { BadRequestError, ForbiddenError } from '@n8n/errors';
|
import { BadRequestError, ForbiddenError } from '@n8n/errors';
|
||||||
import { ExecutionPersistence } from '@/executions/execution-persistence';
|
import { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||||
import { NodeTypes } from '@/node-types';
|
import { NodeTypes } from '@/node-types';
|
||||||
|
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
|
||||||
import { WorkflowRunner } from '@/workflow-runner';
|
import { WorkflowRunner } from '@/workflow-runner';
|
||||||
|
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||||
|
|
||||||
import { ExecuteNodeService } from '../execute-node.service';
|
import { ExecuteNodeService } from '../execute-node.service';
|
||||||
import type { ExecuteNodeRequest } from '../execute-node.service';
|
import type { ExecuteNodeRequest } from '../execute-node.service';
|
||||||
|
|
@ -54,6 +57,11 @@ describe('ExecuteNodeService', () => {
|
||||||
const activeExecutions = mockInstance(ActiveExecutions);
|
const activeExecutions = mockInstance(ActiveExecutions);
|
||||||
const executionPersistence = mockInstance(ExecutionPersistence);
|
const executionPersistence = mockInstance(ExecutionPersistence);
|
||||||
const instanceSettings = mockInstance(InstanceSettings);
|
const instanceSettings = mockInstance(InstanceSettings);
|
||||||
|
const deprecatedNodesValidationService = new DeprecatedNodesValidationService(
|
||||||
|
logger,
|
||||||
|
mock<NodesConfig>({ blockDeprecated: true }),
|
||||||
|
nodeTypes,
|
||||||
|
);
|
||||||
|
|
||||||
const service = new ExecuteNodeService(
|
const service = new ExecuteNodeService(
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
|
|
@ -64,6 +72,7 @@ describe('ExecuteNodeService', () => {
|
||||||
activeExecutions,
|
activeExecutions,
|
||||||
executionPersistence,
|
executionPersistence,
|
||||||
instanceSettings,
|
instanceSettings,
|
||||||
|
deprecatedNodesValidationService,
|
||||||
);
|
);
|
||||||
|
|
||||||
const user = mock<User>({ id: 'user-1' });
|
const user = mock<User>({ id: 'user-1' });
|
||||||
|
|
@ -86,6 +95,17 @@ describe('ExecuteNodeService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('pre-flight validation', () => {
|
describe('pre-flight validation', () => {
|
||||||
|
it('rejects a deprecated node type before creating any workflow row', async () => {
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(
|
||||||
|
mockNodeType({ description: { ...testNodeDescription, deprecated: true } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.run(user, baseRequest({ type: 'n8n-nodes-base.function', version: 1 })),
|
||||||
|
).rejects.toThrow(DeprecatedNodesError);
|
||||||
|
expect(workflowRepository.createWorkflowWithOwner).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects an unknown node type before creating any workflow row', async () => {
|
it('rejects an unknown node type before creating any workflow row', async () => {
|
||||||
nodeTypes.getByNameAndVersion.mockImplementation(() => {
|
nodeTypes.getByNameAndVersion.mockImplementation(() => {
|
||||||
throw new Error('unknown');
|
throw new Error('unknown');
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { BadRequestError, ForbiddenError } from '@n8n/errors';
|
||||||
import { ExecutionPersistence } from '@/executions/execution-persistence';
|
import { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||||
import { NodeTypes } from '@/node-types';
|
import { NodeTypes } from '@/node-types';
|
||||||
import { WorkflowRunner } from '@/workflow-runner';
|
import { WorkflowRunner } from '@/workflow-runner';
|
||||||
|
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||||
|
|
||||||
export const DEFAULT_EXECUTE_NODE_TIMEOUT_MS = 30_000;
|
export const DEFAULT_EXECUTE_NODE_TIMEOUT_MS = 30_000;
|
||||||
export const MAX_EXECUTE_NODE_TIMEOUT_MS = 60_000;
|
export const MAX_EXECUTE_NODE_TIMEOUT_MS = 60_000;
|
||||||
|
|
@ -86,6 +87,7 @@ export class ExecuteNodeService {
|
||||||
private readonly activeExecutions: ActiveExecutions,
|
private readonly activeExecutions: ActiveExecutions,
|
||||||
private readonly executionPersistence: ExecutionPersistence,
|
private readonly executionPersistence: ExecutionPersistence,
|
||||||
private readonly instanceSettings: InstanceSettings,
|
private readonly instanceSettings: InstanceSettings,
|
||||||
|
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async run(user: User, request: ExecuteNodeRequest): Promise<ExecuteNodeResult> {
|
async run(user: User, request: ExecuteNodeRequest): Promise<ExecuteNodeResult> {
|
||||||
|
|
@ -98,6 +100,7 @@ export class ExecuteNodeService {
|
||||||
await this.checkCredentialAccess(user, credentials);
|
await this.checkCredentialAccess(user, credentials);
|
||||||
|
|
||||||
const node = this.buildNode(request, credentials);
|
const node = this.buildNode(request, credentials);
|
||||||
|
this.deprecatedNodesValidationService.validateOnCreate([node]);
|
||||||
const timeoutMs = Math.min(
|
const timeoutMs = Math.min(
|
||||||
request.timeoutMs ?? DEFAULT_EXECUTE_NODE_TIMEOUT_MS,
|
request.timeoutMs ?? DEFAULT_EXECUTE_NODE_TIMEOUT_MS,
|
||||||
MAX_EXECUTE_NODE_TIMEOUT_MS,
|
MAX_EXECUTE_NODE_TIMEOUT_MS,
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ describe('ImportService', () => {
|
||||||
mockPolicyEnforcementService,
|
mockPolicyEnforcementService,
|
||||||
mockSharedWorkflowRepository,
|
mockSharedWorkflowRepository,
|
||||||
mockWorkflowRepository,
|
mockWorkflowRepository,
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import {
|
||||||
validateWorkflowStructure,
|
validateWorkflowStructure,
|
||||||
sanitizeNodeGroupDescriptions,
|
sanitizeNodeGroupDescriptions,
|
||||||
} from '@/workflow-helpers';
|
} from '@/workflow-helpers';
|
||||||
|
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||||
import { WorkflowService } from '@/workflows/workflow.service';
|
import { WorkflowService } from '@/workflows/workflow.service';
|
||||||
|
|
||||||
import { DATA_TABLE_ROWS_FILE_PREFIX } from './data-table-export.constants';
|
import { DATA_TABLE_ROWS_FILE_PREFIX } from './data-table-export.constants';
|
||||||
|
|
@ -90,6 +91,7 @@ export class ImportService {
|
||||||
private readonly policyEnforcementService: PolicyEnforcementService,
|
private readonly policyEnforcementService: PolicyEnforcementService,
|
||||||
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
||||||
private readonly workflowRepository: WorkflowRepository,
|
private readonly workflowRepository: WorkflowRepository,
|
||||||
|
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async initRecords() {
|
async initRecords() {
|
||||||
|
|
@ -118,20 +120,22 @@ export class ImportService {
|
||||||
const workflowIds = workflows.map((w) => w.id).filter((id) => !!id);
|
const workflowIds = workflows.map((w) => w.id).filter((id) => !!id);
|
||||||
const existingWorkflowIds = new Set<string>();
|
const existingWorkflowIds = new Set<string>();
|
||||||
const activeVersionIdByWorkflow = new Map<string, string>();
|
const activeVersionIdByWorkflow = new Map<string, string>();
|
||||||
|
const existingNodesByWorkflow = new Map<string, INode[]>();
|
||||||
|
|
||||||
let existingOwnerProjects = new Map<string, Project>();
|
let existingOwnerProjects = new Map<string, Project>();
|
||||||
|
|
||||||
if (workflowIds.length > 0) {
|
if (workflowIds.length > 0) {
|
||||||
const existingWorkflows = await dbManager.find(WorkflowEntity, {
|
const existingWorkflows = await dbManager.find(WorkflowEntity, {
|
||||||
where: { id: In(workflowIds) },
|
where: { id: In(workflowIds) },
|
||||||
select: ['id', 'activeVersionId'],
|
select: ['id', 'activeVersionId', 'nodes'],
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const { id, activeVersionId } of existingWorkflows) {
|
for (const { id, activeVersionId, nodes } of existingWorkflows) {
|
||||||
existingWorkflowIds.add(id);
|
existingWorkflowIds.add(id);
|
||||||
if (activeVersionId !== null) {
|
if (activeVersionId !== null) {
|
||||||
activeVersionIdByWorkflow.set(id, activeVersionId);
|
activeVersionIdByWorkflow.set(id, activeVersionId);
|
||||||
}
|
}
|
||||||
|
existingNodesByWorkflow.set(id, nodes ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// An existing workflow's policy scope is its own project, not `projectId` — that
|
// An existing workflow's policy scope is its own project, not `projectId` — that
|
||||||
|
|
@ -162,6 +166,21 @@ export class ImportService {
|
||||||
if (hasInvalidCreds) await this.replaceInvalidCreds(workflow, projectId);
|
if (hasInvalidCreds) await this.replaceInvalidCreds(workflow, projectId);
|
||||||
validateWorkflowStructure(workflow);
|
validateWorkflowStructure(workflow);
|
||||||
|
|
||||||
|
// Apply the same deprecated-node enforcement that the REST/public API
|
||||||
|
// endpoints use: treat unknown workflow IDs as creates, known IDs as
|
||||||
|
// updates (so a re-import of an existing workflow with the same
|
||||||
|
// deprecated nodes is allowed, but introducing new ones is not).
|
||||||
|
const existingNodes = workflow.id ? existingNodesByWorkflow.get(workflow.id) : undefined;
|
||||||
|
if (existingNodes) {
|
||||||
|
this.deprecatedNodesValidationService.validateOnUpdate(
|
||||||
|
workflow.nodes,
|
||||||
|
existingNodes,
|
||||||
|
workflow.id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.deprecatedNodesValidationService.validateOnCreate(workflow.nodes, workflow.id);
|
||||||
|
}
|
||||||
|
|
||||||
for (const warning of sanitizeNodeGroupDescriptions(workflow)) {
|
for (const warning of sanitizeNodeGroupDescriptions(workflow)) {
|
||||||
this.logger.warn(`Workflow "${workflow.name}": ${warning}`);
|
this.logger.warn(`Workflow "${workflow.name}": ${warning}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ import { TaskRequester } from '@/task-runners/task-managers/task-requester';
|
||||||
import { findSubworkflowStart } from '@/utils';
|
import { findSubworkflowStart } from '@/utils';
|
||||||
import { objectToError } from '@/utils/object-to-error';
|
import { objectToError } from '@/utils/object-to-error';
|
||||||
import * as WorkflowHelpers from '@/workflow-helpers';
|
import * as WorkflowHelpers from '@/workflow-helpers';
|
||||||
|
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||||
import { getWorkflowProjectDetailsSafe } from '@/workflows/utils';
|
import { getWorkflowProjectDetailsSafe } from '@/workflows/utils';
|
||||||
import { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service';
|
import { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service';
|
||||||
|
|
||||||
|
|
@ -156,6 +157,11 @@ async function fetchWorkflowData(
|
||||||
} else {
|
} else {
|
||||||
const workflowData = workflowInfo.code;
|
const workflowData = workflowInfo.code;
|
||||||
if (workflowData) {
|
if (workflowData) {
|
||||||
|
// The save-time check does not see inline JSON, so it runs here.
|
||||||
|
Container.get(DeprecatedNodesValidationService).validateOnCreate(
|
||||||
|
workflowData.nodes ?? [],
|
||||||
|
parentWorkflowId,
|
||||||
|
);
|
||||||
// An inline sub-workflow is part of the parent that embeds it, not a
|
// An inline sub-workflow is part of the parent that embeds it, not a
|
||||||
// workflow of its own, so it runs under the parent workflow's id.
|
// workflow of its own, so it runs under the parent workflow's id.
|
||||||
workflowData.id = parentWorkflowId;
|
workflowData.id = parentWorkflowId;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
import type { Logger } from '@n8n/backend-common';
|
||||||
|
import type { NodesConfig } from '@n8n/config';
|
||||||
|
import type { INode, INodeType } from 'n8n-workflow';
|
||||||
|
import { fail } from 'node:assert';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeprecatedNodesError,
|
||||||
|
type DeprecatedNodeViolation,
|
||||||
|
} from '@/errors/response-errors/deprecated-nodes.error';
|
||||||
|
import type { NodeTypes } from '@/node-types';
|
||||||
|
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||||
|
|
||||||
|
describe('DeprecatedNodesValidationService', () => {
|
||||||
|
let validator: DeprecatedNodesValidationService;
|
||||||
|
let nodesConfig: NodesConfig;
|
||||||
|
let nodeTypes: ReturnType<typeof mock<NodeTypes>>;
|
||||||
|
let logger: ReturnType<typeof mock<Logger>>;
|
||||||
|
|
||||||
|
const nodeTypeFor = (type: string, deprecated?: boolean): INodeType =>
|
||||||
|
Object.assign(mock<INodeType>(), {
|
||||||
|
description: {
|
||||||
|
name: type,
|
||||||
|
deprecated: deprecated ? true : undefined,
|
||||||
|
properties: [
|
||||||
|
{ displayName: 'Code', name: 'functionCode', type: 'string', default: '// default' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nodesConfig = { blockDeprecated: true } as NodesConfig;
|
||||||
|
nodeTypes = mock<NodeTypes>();
|
||||||
|
logger = mock<Logger>();
|
||||||
|
|
||||||
|
nodeTypes.getByNameAndVersion.mockImplementation((type) => {
|
||||||
|
if (type === 'n8n-nodes-base.function' || type === 'n8n-nodes-base.functionItem') {
|
||||||
|
return nodeTypeFor(type, true);
|
||||||
|
}
|
||||||
|
return nodeTypeFor(type, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
validator = new DeprecatedNodesValidationService(logger, nodesConfig, nodeTypes);
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectViolations = (run: () => void, violations: DeprecatedNodeViolation[]) => {
|
||||||
|
try {
|
||||||
|
run();
|
||||||
|
fail('expected to throw');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(DeprecatedNodesError);
|
||||||
|
expect((error as DeprecatedNodesError).meta.violations).toEqual(violations);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeNode = (overrides: Partial<INode> & Pick<INode, 'id' | 'type'>): INode => ({
|
||||||
|
name: overrides.id,
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateOnCreate', () => {
|
||||||
|
it('passes when no deprecated nodes are present', () => {
|
||||||
|
const nodes = [makeNode({ id: 'a', type: 'n8n-nodes-base.set' })];
|
||||||
|
expect(() => validator.validateOnCreate(nodes)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws DeprecatedNodesError when any node type is deprecated', () => {
|
||||||
|
const nodes = [
|
||||||
|
makeNode({ id: 'a', type: 'n8n-nodes-base.set' }),
|
||||||
|
makeNode({ id: 'b', type: 'n8n-nodes-base.function' }),
|
||||||
|
];
|
||||||
|
expect(() => validator.validateOnCreate(nodes)).toThrow(DeprecatedNodesError);
|
||||||
|
expect(() => validator.validateOnCreate(nodes)).toThrow(/deprecated/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes every deprecated node in error meta and message', () => {
|
||||||
|
const nodes = [
|
||||||
|
makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func A' }),
|
||||||
|
makeNode({ id: 'b', type: 'n8n-nodes-base.functionItem', name: 'Func B' }),
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
validator.validateOnCreate(nodes);
|
||||||
|
fail('expected to throw');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(DeprecatedNodesError);
|
||||||
|
const typed = error as DeprecatedNodesError;
|
||||||
|
expect(typed.meta.violations).toEqual([
|
||||||
|
{ kind: 'added', nodeName: 'Func A', nodeType: 'n8n-nodes-base.function' },
|
||||||
|
{ kind: 'added', nodeName: 'Func B', nodeType: 'n8n-nodes-base.functionItem' },
|
||||||
|
]);
|
||||||
|
expect(typed.message).toContain('Func A');
|
||||||
|
expect(typed.message).toContain('Func B');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat an unknown node type as deprecated', () => {
|
||||||
|
nodeTypes.getByNameAndVersion.mockImplementation(() => {
|
||||||
|
throw new Error('Unrecognized node type');
|
||||||
|
});
|
||||||
|
const nodes = [makeNode({ id: 'a', type: 'community.unknown' })];
|
||||||
|
expect(() => validator.validateOnCreate(nodes)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when the config flag is off', () => {
|
||||||
|
nodesConfig.blockDeprecated = false;
|
||||||
|
const nodes = [makeNode({ id: 'a', type: 'n8n-nodes-base.function' })];
|
||||||
|
expect(() => validator.validateOnCreate(nodes)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateOnUpdate', () => {
|
||||||
|
it('allows an unchanged deprecated node to pass through', () => {
|
||||||
|
const node = makeNode({
|
||||||
|
id: 'a',
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
parameters: { functionCode: 'return items;' },
|
||||||
|
});
|
||||||
|
expect(() => validator.validateOnUpdate([node], [node])).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows position-only changes to a deprecated node', () => {
|
||||||
|
const before = makeNode({
|
||||||
|
id: 'a',
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
name: 'Func',
|
||||||
|
position: [0, 0],
|
||||||
|
});
|
||||||
|
const after = { ...before, position: [200, 100] as [number, number] };
|
||||||
|
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a structurally equal copy of a deprecated node', () => {
|
||||||
|
const before = makeNode({
|
||||||
|
id: 'a',
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
parameters: { functionCode: 'return items;' },
|
||||||
|
credentials: { someApi: { id: '1', name: 'Some API' } },
|
||||||
|
});
|
||||||
|
expect(() => validator.validateOnUpdate([structuredClone(before)], [before])).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats absent and default disabled and credentials as unchanged', () => {
|
||||||
|
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function' });
|
||||||
|
const after = { ...before, disabled: false, credentials: {} };
|
||||||
|
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the editor-saved shape of a deprecated node stored with explicit defaults', () => {
|
||||||
|
const before: INode = {
|
||||||
|
...makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' }),
|
||||||
|
parameters: { functionCode: '// default' },
|
||||||
|
notes: '',
|
||||||
|
onError: 'stopWorkflow',
|
||||||
|
continueOnFail: false,
|
||||||
|
};
|
||||||
|
const after = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||||
|
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks adding notes to an existing deprecated node', () => {
|
||||||
|
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||||
|
const after = { ...before, notes: 'changed' };
|
||||||
|
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks disabling an existing deprecated node', () => {
|
||||||
|
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||||
|
const after = { ...before, disabled: true };
|
||||||
|
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks changing the credentials of an existing deprecated node', () => {
|
||||||
|
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||||
|
const after = { ...before, credentials: { someApi: { id: '2', name: 'Other API' } } };
|
||||||
|
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks changing the error handling of an existing deprecated node', () => {
|
||||||
|
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||||
|
const after: INode = { ...before, onError: 'continueRegularOutput' };
|
||||||
|
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a deprecated node sent with a new id as added', () => {
|
||||||
|
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||||
|
const after = { ...before, id: 'b' };
|
||||||
|
expectViolations(
|
||||||
|
() => validator.validateOnUpdate([after], [before]),
|
||||||
|
[{ kind: 'added', nodeName: 'Func', nodeType: 'n8n-nodes-base.function' }],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a deprecated node sent without an id as added', () => {
|
||||||
|
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||||
|
const after = { ...before, id: undefined as unknown as string };
|
||||||
|
expectViolations(
|
||||||
|
() => validator.validateOnUpdate([after], [before]),
|
||||||
|
[{ kind: 'added', nodeName: 'Func', nodeType: 'n8n-nodes-base.function' }],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a deprecated node reusing the id of a non-deprecated node as added', () => {
|
||||||
|
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.set', name: 'Set' });
|
||||||
|
const after = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Set' });
|
||||||
|
expectViolations(
|
||||||
|
() => validator.validateOnUpdate([after], [before]),
|
||||||
|
[{ kind: 'added', nodeName: 'Set', nodeType: 'n8n-nodes-base.function' }],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a warning with the workflow id when it rejects a save', () => {
|
||||||
|
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||||
|
const after = { ...before, parameters: { functionCode: 'return [];' } };
|
||||||
|
expect(() => validator.validateOnUpdate([after], [before], 'wf-1')).toThrow(
|
||||||
|
DeprecatedNodesError,
|
||||||
|
);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(expect.any(String), {
|
||||||
|
workflowId: 'wf-1',
|
||||||
|
violations: [{ kind: 'edited', nodeType: 'n8n-nodes-base.function' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks adding a deprecated node that did not exist before', () => {
|
||||||
|
const before = [makeNode({ id: 'a', type: 'n8n-nodes-base.set' })];
|
||||||
|
const after = [
|
||||||
|
...before,
|
||||||
|
makeNode({ id: 'b', type: 'n8n-nodes-base.function', name: 'New Func' }),
|
||||||
|
];
|
||||||
|
expect(() => validator.validateOnUpdate(after, before)).toThrow(/Cannot use.*New Func/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks editing the parameters of an existing deprecated node', () => {
|
||||||
|
const before = makeNode({
|
||||||
|
id: 'a',
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
name: 'Func',
|
||||||
|
parameters: { functionCode: 'return items;' },
|
||||||
|
});
|
||||||
|
const after = { ...before, parameters: { functionCode: 'return [];' } };
|
||||||
|
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks editing the name of an existing deprecated node', () => {
|
||||||
|
const before = makeNode({
|
||||||
|
id: 'a',
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
name: 'Func',
|
||||||
|
});
|
||||||
|
const after = { ...before, name: 'Renamed' };
|
||||||
|
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks bumping the typeVersion of a non-deprecated node down to a deprecated version', () => {
|
||||||
|
nodeTypes.getByNameAndVersion.mockImplementation((type, version) => {
|
||||||
|
if (type === 'n8n-nodes-base.myNode' && version === 1) {
|
||||||
|
return nodeTypeFor(type, true);
|
||||||
|
}
|
||||||
|
return nodeTypeFor(type, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.myNode', typeVersion: 2 });
|
||||||
|
const after = { ...before, typeVersion: 1 };
|
||||||
|
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot use/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows migrating a deprecated typeVersion forward, including parameter changes', () => {
|
||||||
|
nodeTypes.getByNameAndVersion.mockImplementation((type, version) => {
|
||||||
|
if (type === 'n8n-nodes-base.myNode' && version === 1) {
|
||||||
|
return nodeTypeFor(type, true);
|
||||||
|
}
|
||||||
|
return nodeTypeFor(type, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const before = makeNode({
|
||||||
|
id: 'a',
|
||||||
|
type: 'n8n-nodes-base.myNode',
|
||||||
|
typeVersion: 1,
|
||||||
|
parameters: { mode: 'legacy' },
|
||||||
|
});
|
||||||
|
const after = {
|
||||||
|
...before,
|
||||||
|
typeVersion: 2,
|
||||||
|
parameters: { mode: 'safe', extra: 'new-field' },
|
||||||
|
};
|
||||||
|
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows replacing a deprecated node with a different node type at the same id', () => {
|
||||||
|
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||||
|
const after = makeNode({ id: 'a', type: 'n8n-nodes-base.code', name: 'Func' });
|
||||||
|
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows deleting a deprecated node', () => {
|
||||||
|
const before = [
|
||||||
|
makeNode({ id: 'a', type: 'n8n-nodes-base.set' }),
|
||||||
|
makeNode({ id: 'b', type: 'n8n-nodes-base.function' }),
|
||||||
|
];
|
||||||
|
const after = [before[0]];
|
||||||
|
expect(() => validator.validateOnUpdate(after, before)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows edits to non-deprecated nodes alongside a frozen deprecated one', () => {
|
||||||
|
const deprecated = makeNode({ id: 'a', type: 'n8n-nodes-base.function' });
|
||||||
|
const otherBefore = makeNode({ id: 'b', type: 'n8n-nodes-base.set', parameters: { x: 1 } });
|
||||||
|
const otherAfter = { ...otherBefore, parameters: { x: 2 } };
|
||||||
|
expect(() =>
|
||||||
|
validator.validateOnUpdate([deprecated, otherAfter], [deprecated, otherBefore]),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when the config flag is off', () => {
|
||||||
|
nodesConfig.blockDeprecated = false;
|
||||||
|
const before = [makeNode({ id: 'a', type: 'n8n-nodes-base.set' })];
|
||||||
|
const after = [...before, makeNode({ id: 'b', type: 'n8n-nodes-base.function' })];
|
||||||
|
expect(() => validator.validateOnUpdate(after, before)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -123,6 +123,7 @@ describe('WorkflowCreationService', () => {
|
||||||
policyEnforcementServiceMock,
|
policyEnforcementServiceMock,
|
||||||
workflowRepositoryMock,
|
workflowRepositoryMock,
|
||||||
nodeGroupRulesFlagGateMock,
|
nodeGroupRulesFlagGateMock,
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ describe('WorkflowService', () => {
|
||||||
workflowPublicationStatusServiceMock, // workflowPublicationStatusService
|
workflowPublicationStatusServiceMock, // workflowPublicationStatusService
|
||||||
mock(), // nodeGroupRulesFlagGate
|
mock(), // nodeGroupRulesFlagGate
|
||||||
mock(), // errorWorkflowValidationService
|
mock(), // errorWorkflowValidationService
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -513,6 +514,7 @@ describe('WorkflowService', () => {
|
||||||
mock(), // workflowPublicationStatusService
|
mock(), // workflowPublicationStatusService
|
||||||
nodeGroupRulesFlagGateMock, // nodeGroupRulesFlagGate
|
nodeGroupRulesFlagGateMock, // nodeGroupRulesFlagGate
|
||||||
mock(), // errorWorkflowValidationService
|
mock(), // errorWorkflowValidationService
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
@ -1358,6 +1360,7 @@ describe('WorkflowService', () => {
|
||||||
mock(), // workflowPublicationStatusService
|
mock(), // workflowPublicationStatusService
|
||||||
mock(), // nodeGroupRulesFlagGate
|
mock(), // nodeGroupRulesFlagGate
|
||||||
mock(), // errorWorkflowValidationService
|
mock(), // errorWorkflowValidationService
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Bypass validation internals
|
// Bypass validation internals
|
||||||
|
|
@ -2119,6 +2122,7 @@ describe('WorkflowService', () => {
|
||||||
mock(), // workflowPublicationStatusService
|
mock(), // workflowPublicationStatusService
|
||||||
mock(), // nodeGroupRulesFlagGate
|
mock(), // nodeGroupRulesFlagGate
|
||||||
mock(), // errorWorkflowValidationService
|
mock(), // errorWorkflowValidationService
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2261,6 +2265,7 @@ describe('WorkflowService', () => {
|
||||||
mock(), // workflowPublicationStatusService
|
mock(), // workflowPublicationStatusService
|
||||||
mock(), // nodeGroupRulesFlagGate
|
mock(), // nodeGroupRulesFlagGate
|
||||||
mock(), // errorWorkflowValidationService
|
mock(), // errorWorkflowValidationService
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2568,6 +2573,7 @@ describe('WorkflowService', () => {
|
||||||
mock(), // workflowPublicationStatusService
|
mock(), // workflowPublicationStatusService
|
||||||
mock(), // nodeGroupRulesFlagGate
|
mock(), // nodeGroupRulesFlagGate
|
||||||
mock(), // errorWorkflowValidationService
|
mock(), // errorWorkflowValidationService
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2739,6 +2745,7 @@ describe('WorkflowService', () => {
|
||||||
mock(), // workflowPublicationStatusService
|
mock(), // workflowPublicationStatusService
|
||||||
mock(), // nodeGroupRulesFlagGate
|
mock(), // nodeGroupRulesFlagGate
|
||||||
mock(), // errorWorkflowValidationService
|
mock(), // errorWorkflowValidationService
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2927,6 +2934,7 @@ describe('WorkflowService', () => {
|
||||||
mock(), // workflowPublicationStatusService
|
mock(), // workflowPublicationStatusService
|
||||||
mock(), // nodeGroupRulesFlagGate
|
mock(), // nodeGroupRulesFlagGate
|
||||||
mock(), // errorWorkflowValidationService
|
mock(), // errorWorkflowValidationService
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3031,6 +3039,7 @@ describe('WorkflowService', () => {
|
||||||
mock(), // workflowPublicationStatusService
|
mock(), // workflowPublicationStatusService
|
||||||
mock(), // nodeGroupRulesFlagGate
|
mock(), // nodeGroupRulesFlagGate
|
||||||
mock(), // errorWorkflowValidationService
|
mock(), // errorWorkflowValidationService
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3134,6 +3143,7 @@ describe('WorkflowService', () => {
|
||||||
mock(), // workflowPublicationStatusService
|
mock(), // workflowPublicationStatusService
|
||||||
mock(), // nodeGroupRulesFlagGate
|
mock(), // nodeGroupRulesFlagGate
|
||||||
errorWorkflowValidationServiceMock, // errorWorkflowValidationService
|
errorWorkflowValidationServiceMock, // errorWorkflowValidationService
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(WorkflowHelpers.removeDefaultValues).mockImplementation((settings) => settings);
|
vi.mocked(WorkflowHelpers.removeDefaultValues).mockImplementation((settings) => settings);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { Logger } from '@n8n/backend-common';
|
||||||
|
import { NodesConfig } from '@n8n/config';
|
||||||
|
import { Service } from '@n8n/di';
|
||||||
|
import isEqual from 'lodash/isEqual';
|
||||||
|
import type { INode, INodeTypeDescription } from 'n8n-workflow';
|
||||||
|
import { NodeHelpers, normalizeNodeShape } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeprecatedNodesError,
|
||||||
|
type DeprecatedNodeViolation,
|
||||||
|
} from '@/errors/response-errors/deprecated-nodes.error';
|
||||||
|
import { NodeTypes } from '@/node-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses adding a deprecated node or changing one in place. Removing a
|
||||||
|
* deprecated node, or moving it to a non-deprecated type or version, is allowed.
|
||||||
|
* Does nothing when `N8N_DEPRECATED_NODES_BLOCK` is off.
|
||||||
|
*/
|
||||||
|
@Service()
|
||||||
|
export class DeprecatedNodesValidationService {
|
||||||
|
constructor(
|
||||||
|
private readonly logger: Logger,
|
||||||
|
private readonly nodesConfig: NodesConfig,
|
||||||
|
private readonly nodeTypes: NodeTypes,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Throws if any node is deprecated. Use where there is no stored version to compare with. */
|
||||||
|
validateOnCreate(nodes: INode[], workflowId?: string): void {
|
||||||
|
if (!this.nodesConfig.blockDeprecated) return;
|
||||||
|
|
||||||
|
const violations: DeprecatedNodeViolation[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (this.deprecatedDescription(node)) {
|
||||||
|
violations.push({ kind: 'added', nodeName: node.name, nodeType: node.type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.throwIfViolations(violations, workflowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws if a deprecated node is new, or differs from the stored node with the
|
||||||
|
* same id other than in position.
|
||||||
|
*/
|
||||||
|
validateOnUpdate(incomingNodes: INode[], existingNodes: INode[], workflowId?: string): void {
|
||||||
|
if (!this.nodesConfig.blockDeprecated) return;
|
||||||
|
|
||||||
|
const existingById = new Map(existingNodes.map((n) => [n.id, n]));
|
||||||
|
const violations: DeprecatedNodeViolation[] = [];
|
||||||
|
|
||||||
|
for (const incoming of incomingNodes) {
|
||||||
|
const description = this.deprecatedDescription(incoming);
|
||||||
|
if (!description) continue;
|
||||||
|
|
||||||
|
const before = existingById.get(incoming.id);
|
||||||
|
// A stored node that was not deprecated counts as adding one, so a node cannot be moved back onto a deprecated version.
|
||||||
|
if (!before || !this.deprecatedDescription(before)) {
|
||||||
|
violations.push({ kind: 'added', nodeName: incoming.name, nodeType: incoming.type });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isEqual(this.frozenFields(before, description), this.frozenFields(incoming, description))
|
||||||
|
) {
|
||||||
|
violations.push({ kind: 'edited', nodeName: incoming.name, nodeType: incoming.type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.throwIfViolations(violations, workflowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts a node in the shape the editor saves, so a re-save of an untouched node compares equal. */
|
||||||
|
private frozenFields(node: INode, description: INodeTypeDescription) {
|
||||||
|
const {
|
||||||
|
position: _position,
|
||||||
|
notes,
|
||||||
|
onError,
|
||||||
|
continueOnFail,
|
||||||
|
disabled,
|
||||||
|
credentials,
|
||||||
|
parameters,
|
||||||
|
...rest
|
||||||
|
} = normalizeNodeShape(node);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
parameters:
|
||||||
|
NodeHelpers.getNodeParameters(
|
||||||
|
description.properties,
|
||||||
|
parameters,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
node,
|
||||||
|
description,
|
||||||
|
) ?? {},
|
||||||
|
credentials: credentials ?? {},
|
||||||
|
disabled: disabled === true,
|
||||||
|
continueOnFail: continueOnFail === true,
|
||||||
|
onError: onError ?? 'stopWorkflow',
|
||||||
|
notes: notes ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private throwIfViolations(violations: DeprecatedNodeViolation[], workflowId?: string) {
|
||||||
|
if (violations.length === 0) return;
|
||||||
|
|
||||||
|
this.logger.warn('Rejected deprecated nodes', {
|
||||||
|
workflowId,
|
||||||
|
violations: violations.map(({ kind, nodeType }) => ({ kind, nodeType })),
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new DeprecatedNodesError(this.formatMessage(violations), { violations });
|
||||||
|
}
|
||||||
|
|
||||||
|
private deprecatedDescription(node: INode): INodeTypeDescription | undefined {
|
||||||
|
try {
|
||||||
|
const { description } = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||||
|
return description.deprecated === true ? description : undefined;
|
||||||
|
} catch {
|
||||||
|
// Unknown node types can't be deprecated by us; they're handled elsewhere.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatMessage(violations: DeprecatedNodeViolation[]): string {
|
||||||
|
const lines = violations.map((v) => {
|
||||||
|
const verb = v.kind === 'added' ? 'use a' : 'modify a';
|
||||||
|
return `Cannot ${verb} "${v.nodeType}" node ("${v.nodeName}"): this node type is deprecated.`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const suffix = ' Replace the node with a supported alternative or remove it from the workflow.';
|
||||||
|
|
||||||
|
return lines.join(' ') + suffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ import { TagService } from '@/services/tag.service';
|
||||||
import * as WorkflowHelpers from '@/workflow-helpers';
|
import * as WorkflowHelpers from '@/workflow-helpers';
|
||||||
import { WorkflowHookContextService } from '@/workflow-hook-context.service';
|
import { WorkflowHookContextService } from '@/workflow-hook-context.service';
|
||||||
|
|
||||||
|
import { DeprecatedNodesValidationService } from './deprecated-nodes-validation.service';
|
||||||
import { NodeGroupRulesFlagGate } from './node-group-rules-flag-gate';
|
import { NodeGroupRulesFlagGate } from './node-group-rules-flag-gate';
|
||||||
import { dropRedactionPolicy } from './utils';
|
import { dropRedactionPolicy } from './utils';
|
||||||
import { WorkflowFinderService } from './workflow-finder.service';
|
import { WorkflowFinderService } from './workflow-finder.service';
|
||||||
|
|
@ -74,6 +75,7 @@ export class WorkflowCreationService {
|
||||||
private readonly policyEnforcementService: PolicyEnforcementService,
|
private readonly policyEnforcementService: PolicyEnforcementService,
|
||||||
private readonly workflowRepository: WorkflowRepository,
|
private readonly workflowRepository: WorkflowRepository,
|
||||||
private readonly nodeGroupRulesFlagGate: NodeGroupRulesFlagGate,
|
private readonly nodeGroupRulesFlagGate: NodeGroupRulesFlagGate,
|
||||||
|
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async prepareBatchContext(
|
async prepareBatchContext(
|
||||||
|
|
@ -222,6 +224,7 @@ export class WorkflowCreationService {
|
||||||
WorkflowHelpers.makeGetNodeTypeForGrouping(this.nodeTypes),
|
WorkflowHelpers.makeGetNodeTypeForGrouping(this.nodeTypes),
|
||||||
rules,
|
rules,
|
||||||
);
|
);
|
||||||
|
this.deprecatedNodesValidationService.validateOnCreate(newWorkflow.nodes, newWorkflow.id);
|
||||||
|
|
||||||
if (parentFolderId && parentFolderId !== PROJECT_ROOT) {
|
if (parentFolderId && parentFolderId !== PROJECT_ROOT) {
|
||||||
if (!batchContext) {
|
if (!batchContext) {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import {
|
||||||
staticErrorWorkflowId,
|
staticErrorWorkflowId,
|
||||||
type ErrorWorkflowProblem,
|
type ErrorWorkflowProblem,
|
||||||
} from './error-workflow-validation.service';
|
} from './error-workflow-validation.service';
|
||||||
|
import { DeprecatedNodesValidationService } from './deprecated-nodes-validation.service';
|
||||||
import { WorkflowPublicationNotifier } from './publication/workflow-publication-notifier';
|
import { WorkflowPublicationNotifier } from './publication/workflow-publication-notifier';
|
||||||
import { WorkflowPublicationStatusService } from './publication/workflow-publication-status.service';
|
import { WorkflowPublicationStatusService } from './publication/workflow-publication-status.service';
|
||||||
import { NodeGroupRulesFlagGate } from './node-group-rules-flag-gate';
|
import { NodeGroupRulesFlagGate } from './node-group-rules-flag-gate';
|
||||||
|
|
@ -135,6 +136,7 @@ export class WorkflowService {
|
||||||
private readonly workflowPublicationStatusService: WorkflowPublicationStatusService,
|
private readonly workflowPublicationStatusService: WorkflowPublicationStatusService,
|
||||||
private readonly nodeGroupRulesFlagGate: NodeGroupRulesFlagGate,
|
private readonly nodeGroupRulesFlagGate: NodeGroupRulesFlagGate,
|
||||||
private readonly errorWorkflowValidationService: ErrorWorkflowValidationService,
|
private readonly errorWorkflowValidationService: ErrorWorkflowValidationService,
|
||||||
|
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -611,6 +613,14 @@ export class WorkflowService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasNodesKey && nodesChanged) {
|
||||||
|
this.deprecatedNodesValidationService.validateOnUpdate(
|
||||||
|
workflowUpdateData.nodes,
|
||||||
|
workflow.nodes,
|
||||||
|
workflow.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Strip redactionPolicy if instance lacks data-redaction license
|
// Strip redactionPolicy if instance lacks data-redaction license
|
||||||
if (
|
if (
|
||||||
workflowUpdateData.settings?.redactionPolicy !== undefined &&
|
workflowUpdateData.settings?.redactionPolicy !== undefined &&
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { testDb, createWorkflow } from '@n8n/backend-test-utils';
|
||||||
|
import { NodesConfig } from '@n8n/config';
|
||||||
|
import { ExecutionRepository, type IWorkflowDb } from '@n8n/db';
|
||||||
|
import { Container } from '@n8n/di';
|
||||||
|
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
|
||||||
|
|
||||||
|
import { createOwner } from './shared/db/users';
|
||||||
|
import * as utils from './shared/utils';
|
||||||
|
import { loadNodesFromDist } from './shared/utils/node-types-data';
|
||||||
|
|
||||||
|
describe('inline sub-workflow with a deprecated node', () => {
|
||||||
|
let owner: Awaited<ReturnType<typeof createOwner>>;
|
||||||
|
let workflowExecutionService: WorkflowExecutionService;
|
||||||
|
let executionRepository: ExecutionRepository;
|
||||||
|
let nodesConfig: NodesConfig;
|
||||||
|
let previousBlockDeprecated: boolean;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await testDb.init();
|
||||||
|
|
||||||
|
owner = await createOwner();
|
||||||
|
|
||||||
|
const nodeTypes = loadNodesFromDist([
|
||||||
|
'n8n-nodes-base.manualTrigger',
|
||||||
|
'n8n-nodes-base.executeWorkflow',
|
||||||
|
'n8n-nodes-base.executeWorkflowTrigger',
|
||||||
|
'n8n-nodes-base.function',
|
||||||
|
]);
|
||||||
|
|
||||||
|
await utils.initNodeTypes(nodeTypes);
|
||||||
|
await utils.initBinaryDataService();
|
||||||
|
|
||||||
|
workflowExecutionService = Container.get(WorkflowExecutionService);
|
||||||
|
executionRepository = Container.get(ExecutionRepository);
|
||||||
|
nodesConfig = Container.get(NodesConfig);
|
||||||
|
previousBlockDeprecated = nodesConfig.blockDeprecated;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
nodesConfig.blockDeprecated = previousBlockDeprecated;
|
||||||
|
await testDb.truncate(['ExecutionEntity', 'WorkflowEntity']);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testDb.terminate();
|
||||||
|
});
|
||||||
|
|
||||||
|
function inlineSubWorkflowWithFunctionNode() {
|
||||||
|
return {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
parameters: { inputSource: 'passthrough' },
|
||||||
|
type: 'n8n-nodes-base.executeWorkflowTrigger',
|
||||||
|
typeVersion: 1.1,
|
||||||
|
id: uuid(),
|
||||||
|
name: 'Sub Trigger',
|
||||||
|
position: [0, 0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parameters: { functionCode: "return [{ json: { ran: 'function' } }];" },
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
typeVersion: 1,
|
||||||
|
id: uuid(),
|
||||||
|
name: 'Function',
|
||||||
|
position: [200, 0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Sub Trigger': {
|
||||||
|
main: [[{ node: 'Function', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentWithInlineSubWorkflow() {
|
||||||
|
return {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
parameters: {},
|
||||||
|
type: 'n8n-nodes-base.manualTrigger',
|
||||||
|
typeVersion: 1,
|
||||||
|
id: uuid(),
|
||||||
|
name: 'Trigger',
|
||||||
|
position: [0, 0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
source: 'parameter',
|
||||||
|
workflowJson: JSON.stringify(inlineSubWorkflowWithFunctionNode()),
|
||||||
|
options: { waitForSubWorkflow: true },
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.executeWorkflow',
|
||||||
|
typeVersion: 1.2,
|
||||||
|
id: uuid(),
|
||||||
|
name: 'Execute Sub-workflow',
|
||||||
|
position: [200, 0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
Trigger: {
|
||||||
|
main: [[{ node: 'Execute Sub-workflow', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runParent() {
|
||||||
|
const parent = await createWorkflow(
|
||||||
|
{ name: 'Parent', ...parentWithInlineSubWorkflow() } as unknown as IWorkflowDb,
|
||||||
|
owner,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await workflowExecutionService.executeManually(
|
||||||
|
parent,
|
||||||
|
{ triggerToStartFrom: { name: 'Trigger' } },
|
||||||
|
owner,
|
||||||
|
);
|
||||||
|
if (!('executionId' in result)) {
|
||||||
|
throw new Error(`Expected an executionId, got ${JSON.stringify(result)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < 10000) {
|
||||||
|
const execution = await executionRepository.findSingleExecution(result.executionId, {
|
||||||
|
includeData: true,
|
||||||
|
unflattenData: true,
|
||||||
|
});
|
||||||
|
if (execution && ['error', 'success'].includes(execution.status)) return execution;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
throw new Error(`Execution ${result.executionId} did not finish within 10000ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fails the parent node before the deprecated node runs when the block is on', async () => {
|
||||||
|
nodesConfig.blockDeprecated = true;
|
||||||
|
|
||||||
|
const execution = await runParent();
|
||||||
|
|
||||||
|
expect(execution.status).toBe('error');
|
||||||
|
const nodeRun = execution.data.resultData.runData['Execute Sub-workflow']?.at(-1);
|
||||||
|
expect(nodeRun?.executionStatus).toBe('error');
|
||||||
|
expect(JSON.stringify(nodeRun?.error)).toContain('n8n-nodes-base.function');
|
||||||
|
expect(await executionRepository.count({ where: { workflowId: execution.workflowId } })).toBe(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the deprecated node when the block is off', async () => {
|
||||||
|
nodesConfig.blockDeprecated = false;
|
||||||
|
|
||||||
|
const execution = await runParent();
|
||||||
|
|
||||||
|
expect(execution.status).toBe('success');
|
||||||
|
const output = execution.data.resultData.runData['Execute Sub-workflow']?.at(-1)?.data?.main;
|
||||||
|
expect(output?.[0]?.[0]?.json).toEqual({ ran: 'function' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -32,23 +32,28 @@ import {
|
||||||
UserRepository,
|
UserRepository,
|
||||||
WorkflowPublishedVersionRepository,
|
WorkflowPublishedVersionRepository,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
|
import type { NodesConfig } from '@n8n/config';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import * as fastGlob from 'fast-glob';
|
import * as fastGlob from 'fast-glob';
|
||||||
import { Cipher } from 'n8n-core';
|
import { Cipher } from 'n8n-core';
|
||||||
import type { InstanceSettings } from 'n8n-core';
|
import type { InstanceSettings } from 'n8n-core';
|
||||||
import * as utils from 'n8n-workflow';
|
import * as utils from 'n8n-workflow';
|
||||||
|
import type { INodeType } from 'n8n-workflow';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import type { Mock, Mocked } from 'vitest';
|
import type { Mock, Mocked } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
|
||||||
import type { IWorkflowToImport } from '@/interfaces';
|
import type { IWorkflowToImport } from '@/interfaces';
|
||||||
import { SourceControlContextFactory } from '@/modules/source-control.ee/source-control-context.factory';
|
import { SourceControlContextFactory } from '@/modules/source-control.ee/source-control-context.factory';
|
||||||
import { SourceControlImportService } from '@/modules/source-control.ee/source-control-import.service.ee';
|
import { SourceControlImportService } from '@/modules/source-control.ee/source-control-import.service.ee';
|
||||||
import { SourceControlScopedService } from '@/modules/source-control.ee/source-control-scoped.service';
|
import { SourceControlScopedService } from '@/modules/source-control.ee/source-control-scoped.service';
|
||||||
import type { ExportableCredential } from '@/modules/source-control.ee/types/exportable-credential';
|
import type { ExportableCredential } from '@/modules/source-control.ee/types/exportable-credential';
|
||||||
|
import type { NodeTypes } from '@/node-types';
|
||||||
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||||
import { PolicyViolationError } from '@/policy/policy-violation.error';
|
import { PolicyViolationError } from '@/policy/policy-violation.error';
|
||||||
|
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||||
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
|
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
|
||||||
import { createFolder } from '@test-integration/db/folders';
|
import { createFolder } from '@test-integration/db/folders';
|
||||||
|
|
@ -115,6 +120,15 @@ describe('SourceControlImportService', () => {
|
||||||
async (context, actor) =>
|
async (context, actor) =>
|
||||||
await Container.get(PolicyEnforcementService).enforceContentImport(context, actor),
|
await Container.get(PolicyEnforcementService).enforceContentImport(context, actor),
|
||||||
);
|
);
|
||||||
|
const nodeTypes = mock<NodeTypes>();
|
||||||
|
nodeTypes.getByNameAndVersion.mockImplementation((type) =>
|
||||||
|
Object.assign(mock<INodeType>(), {
|
||||||
|
description: {
|
||||||
|
deprecated: type === 'n8n-nodes-base.function' ? true : undefined,
|
||||||
|
properties: [{ displayName: 'Code', name: 'functionCode', type: 'string', default: '' }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
service = new SourceControlImportService(
|
service = new SourceControlImportService(
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
|
|
@ -148,6 +162,11 @@ describe('SourceControlImportService', () => {
|
||||||
mock(), // workflowPublishGuard
|
mock(), // workflowPublishGuard
|
||||||
mock(), // workflowMutationHooks
|
mock(), // workflowMutationHooks
|
||||||
Container.get(WorkflowFinderService),
|
Container.get(WorkflowFinderService),
|
||||||
|
new DeprecatedNodesValidationService(
|
||||||
|
mock(),
|
||||||
|
mock<NodesConfig>({ blockDeprecated: true }),
|
||||||
|
nodeTypes,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1853,6 +1872,89 @@ describe('SourceControlImportService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('deprecated nodes', () => {
|
||||||
|
it('rejects pulling a new workflow that contains a deprecated node', async () => {
|
||||||
|
const importingUser = await getGlobalOwner();
|
||||||
|
const workflow = makeWorkflowImport({
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'node-1',
|
||||||
|
name: 'Function',
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [250, 300],
|
||||||
|
parameters: { functionCode: 'return items;' },
|
||||||
|
},
|
||||||
|
] as IWorkflowToImport['nodes'],
|
||||||
|
});
|
||||||
|
const file = putWorkflowFile(workflow.id, workflow);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.importWorkflowFromWorkFolder(
|
||||||
|
[mock<SourceControlledFile>({ id: workflow.id, file })],
|
||||||
|
importingUser.id,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(DeprecatedNodesError);
|
||||||
|
|
||||||
|
await expect(workflowRepository.findOneBy({ id: workflow.id })).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
const functionNode = {
|
||||||
|
id: 'node-1',
|
||||||
|
name: 'Function',
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [250, 300] as [number, number],
|
||||||
|
parameters: { functionCode: 'return items;' },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('allows pulling an existing workflow whose deprecated node is unchanged', async () => {
|
||||||
|
const importingUser = await getGlobalOwner();
|
||||||
|
const workflowId = nanoid();
|
||||||
|
await createWorkflowWithHistory(
|
||||||
|
{ id: workflowId, name: 'Old name', nodes: [functionNode], connections: {} },
|
||||||
|
importingUser,
|
||||||
|
);
|
||||||
|
const workflow = makeWorkflowImport({
|
||||||
|
id: workflowId,
|
||||||
|
name: 'New name',
|
||||||
|
nodes: [functionNode] as IWorkflowToImport['nodes'],
|
||||||
|
});
|
||||||
|
const file = putWorkflowFile(workflowId, workflow);
|
||||||
|
|
||||||
|
await service.importWorkflowFromWorkFolder(
|
||||||
|
[mock<SourceControlledFile>({ id: workflowId, file })],
|
||||||
|
importingUser.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const stored = await workflowRepository.findOneBy({ id: workflowId });
|
||||||
|
expect(stored?.name).toBe('New name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects pulling an existing workflow whose deprecated node was edited', async () => {
|
||||||
|
const importingUser = await getGlobalOwner();
|
||||||
|
const workflowId = nanoid();
|
||||||
|
await createWorkflowWithHistory(
|
||||||
|
{ id: workflowId, name: 'Test Workflow', nodes: [functionNode], connections: {} },
|
||||||
|
importingUser,
|
||||||
|
);
|
||||||
|
const workflow = makeWorkflowImport({
|
||||||
|
id: workflowId,
|
||||||
|
nodes: [
|
||||||
|
{ ...functionNode, parameters: { functionCode: 'return [];' } },
|
||||||
|
] as IWorkflowToImport['nodes'],
|
||||||
|
});
|
||||||
|
const file = putWorkflowFile(workflowId, workflow);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.importWorkflowFromWorkFolder(
|
||||||
|
[mock<SourceControlledFile>({ id: workflowId, file })],
|
||||||
|
importingUser.id,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(DeprecatedNodesError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('workflow history', () => {
|
describe('workflow history', () => {
|
||||||
it('should create workflow history for new workflow on import', async () => {
|
it('should create workflow history for new workflow on import', async () => {
|
||||||
const importingUser = await getGlobalOwner();
|
const importingUser = await getGlobalOwner();
|
||||||
|
|
|
||||||
|
|
@ -19,16 +19,20 @@ import {
|
||||||
UserRepository,
|
UserRepository,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
|
import type { NodesConfig } from '@n8n/config';
|
||||||
import type { ContentImportContext, PolicyViolation } from '@n8n/decorators';
|
import type { ContentImportContext, PolicyViolation } from '@n8n/decorators';
|
||||||
import type { INode } from 'n8n-workflow';
|
import type { INode, INodeType } from 'n8n-workflow';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import type { Mocked } from 'vitest';
|
import type { Mocked } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
|
||||||
import type { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
|
import type { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
|
||||||
|
import type { NodeTypes } from '@/node-types';
|
||||||
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||||
import { PolicyViolationError } from '@/policy/policy-violation.error';
|
import { PolicyViolationError } from '@/policy/policy-violation.error';
|
||||||
import { ImportService } from '@/services/import.service';
|
import { ImportService } from '@/services/import.service';
|
||||||
|
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||||
import type { WorkflowService } from '@/workflows/workflow.service';
|
import type { WorkflowService } from '@/workflows/workflow.service';
|
||||||
|
|
||||||
import { createMember, createOwner } from './shared/db/users';
|
import { createMember, createOwner } from './shared/db/users';
|
||||||
|
|
@ -85,6 +89,7 @@ describe('ImportService', () => {
|
||||||
mockPolicyEnforcementService,
|
mockPolicyEnforcementService,
|
||||||
sharedWorkflowRepository,
|
sharedWorkflowRepository,
|
||||||
Container.get(WorkflowRepository),
|
Container.get(WorkflowRepository),
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -653,4 +658,98 @@ describe('ImportService', () => {
|
||||||
expect(mockWorkflowService.deactivateWorkflow).not.toHaveBeenCalled();
|
expect(mockWorkflowService.deactivateWorkflow).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('deprecated nodes', () => {
|
||||||
|
let blockingImportService: ImportService;
|
||||||
|
|
||||||
|
const deprecatedNode: INode = {
|
||||||
|
id: uuid(),
|
||||||
|
name: 'Function',
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: { functionCode: 'return items;' },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
const nodeTypes = mock<NodeTypes>();
|
||||||
|
nodeTypes.getByNameAndVersion.mockImplementation((type) =>
|
||||||
|
Object.assign(mock<INodeType>(), {
|
||||||
|
description: {
|
||||||
|
deprecated: type === deprecatedNode.type ? true : undefined,
|
||||||
|
properties: [
|
||||||
|
{ displayName: 'Code', name: 'functionCode', type: 'string', default: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
blockingImportService = new ImportService(
|
||||||
|
mock(),
|
||||||
|
Container.get(CredentialsRepository),
|
||||||
|
tagRepository,
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mockWorkflowIndexService,
|
||||||
|
mock(),
|
||||||
|
Container.get(UserRepository),
|
||||||
|
mockWorkflowService,
|
||||||
|
mockPolicyEnforcementService,
|
||||||
|
sharedWorkflowRepository,
|
||||||
|
workflowRepository,
|
||||||
|
new DeprecatedNodesValidationService(
|
||||||
|
mock(),
|
||||||
|
mock<NodesConfig>({ blockDeprecated: true }),
|
||||||
|
nodeTypes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a new workflow with a deprecated node and leaves active workflows running', async () => {
|
||||||
|
const active = await createActiveWorkflow({ name: 'Active' });
|
||||||
|
const withDeprecated = newWorkflow({ id: uuid(), nodes: [deprecatedNode] });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
blockingImportService.importWorkflows(
|
||||||
|
[active, withDeprecated],
|
||||||
|
ownerPersonalProject.id,
|
||||||
|
owner.id,
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
).rejects.toThrow(DeprecatedNodesError);
|
||||||
|
|
||||||
|
await expect(getWorkflowById(withDeprecated.id)).resolves.toBeNull();
|
||||||
|
expect(mockWorkflowService.deactivateWorkflow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects re-importing a workflow whose deprecated node was edited', async () => {
|
||||||
|
const existing = await createWorkflow({ nodes: [deprecatedNode] });
|
||||||
|
const edited = {
|
||||||
|
...existing,
|
||||||
|
nodes: [{ ...deprecatedNode, parameters: { functionCode: 'return [];' } }],
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
blockingImportService.importWorkflows([edited], ownerPersonalProject.id, owner.id, {}),
|
||||||
|
).rejects.toThrow(DeprecatedNodesError);
|
||||||
|
|
||||||
|
const dbWorkflow = await getWorkflowById(existing.id);
|
||||||
|
expect(dbWorkflow?.nodes).toEqual([deprecatedNode]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows re-importing a workflow whose deprecated node is unchanged', async () => {
|
||||||
|
const existing = await createWorkflow({ nodes: [deprecatedNode] });
|
||||||
|
|
||||||
|
await blockingImportService.importWorkflows(
|
||||||
|
[{ ...existing, name: 'Re-imported' }],
|
||||||
|
ownerPersonalProject.id,
|
||||||
|
owner.id,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const dbWorkflow = await getWorkflowById(existing.id);
|
||||||
|
expect(dbWorkflow?.name).toBe('Re-imported');
|
||||||
|
expect(dbWorkflow?.nodes).toEqual([deprecatedNode]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
linkUserToProject,
|
linkUserToProject,
|
||||||
shareWorkflowWithUsers,
|
shareWorkflowWithUsers,
|
||||||
} from '@n8n/backend-test-utils';
|
} from '@n8n/backend-test-utils';
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig, NodesConfig } from '@n8n/config';
|
||||||
import type { Project, TagEntity, User, WorkflowHistory } from '@n8n/db';
|
import type { Project, TagEntity, User, WorkflowHistory } from '@n8n/db';
|
||||||
import {
|
import {
|
||||||
WorkflowRepository,
|
WorkflowRepository,
|
||||||
|
|
@ -4374,3 +4374,92 @@ return [{ json: result }];
|
||||||
expect(retrievedUpdatedNode.parameters.language).toBe('javaScript');
|
expect(retrievedUpdatedNode.parameters.language).toBe('javaScript');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('deprecated nodes', () => {
|
||||||
|
const nodesConfig = Container.get(NodesConfig);
|
||||||
|
let previousBlockDeprecated: boolean;
|
||||||
|
beforeAll(() => {
|
||||||
|
previousBlockDeprecated = nodesConfig.blockDeprecated;
|
||||||
|
nodesConfig.blockDeprecated = true;
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
nodesConfig.blockDeprecated = previousBlockDeprecated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const deprecatedNode: INode = {
|
||||||
|
id: uuid(),
|
||||||
|
name: 'Function',
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: { functionCode: 'return items;' },
|
||||||
|
};
|
||||||
|
|
||||||
|
test('POST /workflows rejects a workflow with a deprecated node', async () => {
|
||||||
|
const response = await authOwnerAgent.post('/workflows').send({
|
||||||
|
name: 'has deprecated',
|
||||||
|
nodes: [deprecatedNode],
|
||||||
|
connections: {},
|
||||||
|
settings: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.body.message).toMatch(/deprecated/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT /workflows/:id rejects editing a deprecated node', async () => {
|
||||||
|
const workflow = await createWorkflowWithHistory({ nodes: [deprecatedNode] }, owner);
|
||||||
|
|
||||||
|
const response = await authOwnerAgent.put(`/workflows/${workflow.id}`).send({
|
||||||
|
name: workflow.name,
|
||||||
|
nodes: [{ ...deprecatedNode, parameters: { functionCode: 'return [];' } }],
|
||||||
|
connections: workflow.connections,
|
||||||
|
settings: workflow.settings,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.body.message).toMatch(/deprecated/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT /workflows/:id allows editing other nodes while a deprecated node stays unchanged', async () => {
|
||||||
|
const otherNode: INode = {
|
||||||
|
id: uuid(),
|
||||||
|
name: 'Start',
|
||||||
|
type: 'n8n-nodes-base.manualTrigger',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [200, 0],
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
const workflow = await createWorkflowWithHistory({ nodes: [deprecatedNode, otherNode] }, owner);
|
||||||
|
|
||||||
|
const response = await authOwnerAgent.put(`/workflows/${workflow.id}`).send({
|
||||||
|
name: workflow.name,
|
||||||
|
nodes: [deprecatedNode, { ...otherNode, notes: 'edited' }],
|
||||||
|
connections: workflow.connections,
|
||||||
|
settings: workflow.settings,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT /workflows/:id allows removing a deprecated node', async () => {
|
||||||
|
const otherNode: INode = {
|
||||||
|
id: uuid(),
|
||||||
|
name: 'Start',
|
||||||
|
type: 'n8n-nodes-base.manualTrigger',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [200, 0],
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
const workflow = await createWorkflowWithHistory({ nodes: [deprecatedNode, otherNode] }, owner);
|
||||||
|
|
||||||
|
const response = await authOwnerAgent.put(`/workflows/${workflow.id}`).send({
|
||||||
|
name: workflow.name,
|
||||||
|
nodes: [otherNode],
|
||||||
|
connections: {},
|
||||||
|
settings: workflow.settings,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import { HttpHeaderAuth } from 'n8n-nodes-base/credentials/HttpHeaderAuth.creden
|
||||||
import { OpenAiApi } from 'n8n-nodes-base/credentials/OpenAiApi.credentials';
|
import { OpenAiApi } from 'n8n-nodes-base/credentials/OpenAiApi.credentials';
|
||||||
import { Cron } from 'n8n-nodes-base/nodes/Cron/Cron.node';
|
import { Cron } from 'n8n-nodes-base/nodes/Cron/Cron.node';
|
||||||
import { FormTrigger } from 'n8n-nodes-base/nodes/Form/FormTrigger.node';
|
import { FormTrigger } from 'n8n-nodes-base/nodes/Form/FormTrigger.node';
|
||||||
|
import { Function as FunctionNode } from 'n8n-nodes-base/nodes/Function/Function.node';
|
||||||
|
import { FunctionItem } from 'n8n-nodes-base/nodes/FunctionItem/FunctionItem.node';
|
||||||
import { ManualTrigger } from 'n8n-nodes-base/nodes/ManualTrigger/ManualTrigger.node';
|
import { ManualTrigger } from 'n8n-nodes-base/nodes/ManualTrigger/ManualTrigger.node';
|
||||||
import { ScheduleTrigger } from 'n8n-nodes-base/nodes/Schedule/ScheduleTrigger.node';
|
import { ScheduleTrigger } from 'n8n-nodes-base/nodes/Schedule/ScheduleTrigger.node';
|
||||||
import { Set } from 'n8n-nodes-base/nodes/Set/Set.node';
|
import { Set } from 'n8n-nodes-base/nodes/Set/Set.node';
|
||||||
|
|
@ -139,6 +141,14 @@ function buildDefaultNodes(): INodeTypeData {
|
||||||
type: new WebhookNode() as unknown as INodeType,
|
type: new WebhookNode() as unknown as INodeType,
|
||||||
sourcePath: '',
|
sourcePath: '',
|
||||||
},
|
},
|
||||||
|
'n8n-nodes-base.function': {
|
||||||
|
type: new FunctionNode(),
|
||||||
|
sourcePath: '',
|
||||||
|
},
|
||||||
|
'n8n-nodes-base.functionItem': {
|
||||||
|
type: new FunctionItem(),
|
||||||
|
sourcePath: '',
|
||||||
|
},
|
||||||
// Minimal mocks for node types the package-import fixtures reference at typeVersion 1.
|
// Minimal mocks for node types the package-import fixtures reference at typeVersion 1.
|
||||||
'n8n-nodes-base.httpRequest': minimalNodeType('n8n-nodes-base.httpRequest'),
|
'n8n-nodes-base.httpRequest': minimalNodeType('n8n-nodes-base.httpRequest'),
|
||||||
'n8n-nodes-base.dataTable': minimalNodeType('n8n-nodes-base.dataTable'),
|
'n8n-nodes-base.dataTable': minimalNodeType('n8n-nodes-base.dataTable'),
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ beforeAll(async () => {
|
||||||
Container.get(WorkflowPublicationStatusService), // workflowPublicationStatusService
|
Container.get(WorkflowPublicationStatusService), // workflowPublicationStatusService
|
||||||
Container.get(NodeGroupRulesFlagGate), // nodeGroupRulesFlagGate
|
Container.get(NodeGroupRulesFlagGate), // nodeGroupRulesFlagGate
|
||||||
Container.get(ErrorWorkflowValidationService), // errorWorkflowValidationService
|
Container.get(ErrorWorkflowValidationService), // errorWorkflowValidationService
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
WorkflowRepository,
|
WorkflowRepository,
|
||||||
WorkflowPublishHistoryRepository,
|
WorkflowPublishHistoryRepository,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
|
import { NodesConfig } from '@n8n/config';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import type { Scope } from '@n8n/permissions';
|
import type { Scope } from '@n8n/permissions';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
@ -880,6 +881,43 @@ describe('POST /workflows', () => {
|
||||||
expect(workflowsInDb).toHaveLength(0);
|
expect(workflowsInDb).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('deprecated nodes', () => {
|
||||||
|
const nodesConfig = Container.get(NodesConfig);
|
||||||
|
let previousBlockDeprecated: boolean;
|
||||||
|
beforeAll(() => {
|
||||||
|
previousBlockDeprecated = nodesConfig.blockDeprecated;
|
||||||
|
nodesConfig.blockDeprecated = true;
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
nodesConfig.blockDeprecated = previousBlockDeprecated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const deprecatedNode: INode = {
|
||||||
|
id: uuid(),
|
||||||
|
name: 'Function',
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: { functionCode: 'return items;' },
|
||||||
|
};
|
||||||
|
|
||||||
|
test('blocks creating a workflow that contains a deprecated node', async () => {
|
||||||
|
const response = await authOwnerAgent.post('/workflows').send({
|
||||||
|
name: 'has deprecated',
|
||||||
|
nodes: [deprecatedNode],
|
||||||
|
connections: {},
|
||||||
|
settings: {},
|
||||||
|
active: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.body.message).toMatch(/deprecated/i);
|
||||||
|
expect(response.body.meta?.violations).toEqual([
|
||||||
|
{ kind: 'added', nodeName: 'Function', nodeType: 'n8n-nodes-base.function' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Security: Mass Assignment Protection', () => {
|
describe('Security: Mass Assignment Protection', () => {
|
||||||
test.each([
|
test.each([
|
||||||
{
|
{
|
||||||
|
|
@ -4011,6 +4049,132 @@ describe('PATCH /workflows/:workflowId', () => {
|
||||||
expect(response.body.data.name).toBe('Updated name');
|
expect(response.body.data.name).toBe('Updated name');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('deprecated nodes', () => {
|
||||||
|
const nodesConfig = Container.get(NodesConfig);
|
||||||
|
let previousBlockDeprecated: boolean;
|
||||||
|
beforeAll(() => {
|
||||||
|
previousBlockDeprecated = nodesConfig.blockDeprecated;
|
||||||
|
nodesConfig.blockDeprecated = true;
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
nodesConfig.blockDeprecated = previousBlockDeprecated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildDeprecatedNode = (overrides: Partial<INode> = {}): INode => ({
|
||||||
|
id: 'deprecated-node-id',
|
||||||
|
name: 'Function',
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: { functionCode: 'return items;' },
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildCleanNode = (overrides: Partial<INode> = {}): INode => ({
|
||||||
|
id: 'clean-node-id',
|
||||||
|
name: 'Manual Trigger',
|
||||||
|
type: 'n8n-nodes-base.manualTrigger',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [200, 0],
|
||||||
|
parameters: {},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocks adding a deprecated node to an existing workflow', async () => {
|
||||||
|
const workflow = await createWorkflow({ nodes: [buildCleanNode()] }, owner);
|
||||||
|
|
||||||
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
|
||||||
|
versionId: workflow.versionId,
|
||||||
|
nodes: [buildCleanNode(), buildDeprecatedNode()],
|
||||||
|
connections: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.body.meta?.violations).toEqual([
|
||||||
|
{ kind: 'added', nodeName: 'Function', nodeType: 'n8n-nodes-base.function' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocks editing a deprecated node that already exists in the workflow', async () => {
|
||||||
|
const deprecated = buildDeprecatedNode();
|
||||||
|
const workflow = await createWorkflow({ nodes: [deprecated] }, owner);
|
||||||
|
|
||||||
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
|
||||||
|
versionId: workflow.versionId,
|
||||||
|
nodes: [{ ...deprecated, parameters: { functionCode: 'return [];' } }],
|
||||||
|
connections: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.body.meta?.violations).toEqual([
|
||||||
|
{ kind: 'edited', nodeName: 'Function', nodeType: 'n8n-nodes-base.function' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows editing other nodes while a deprecated node stays unchanged', async () => {
|
||||||
|
const deprecated = buildDeprecatedNode();
|
||||||
|
const cleanNode = buildCleanNode();
|
||||||
|
const workflow = await createWorkflow({ nodes: [deprecated, cleanNode] }, owner);
|
||||||
|
|
||||||
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
|
||||||
|
versionId: workflow.versionId,
|
||||||
|
nodes: [deprecated, { ...cleanNode, notes: 'edited' }],
|
||||||
|
connections: workflow.connections,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows the editor-saved shape of a deprecated node stored with explicit defaults', async () => {
|
||||||
|
const deprecated = buildDeprecatedNode();
|
||||||
|
const cleanNode = buildCleanNode();
|
||||||
|
const workflow = await createWorkflow(
|
||||||
|
{
|
||||||
|
nodes: [
|
||||||
|
{ ...deprecated, notes: '', onError: 'stopWorkflow', continueOnFail: false },
|
||||||
|
cleanNode,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
owner,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
|
||||||
|
versionId: workflow.versionId,
|
||||||
|
nodes: [deprecated, { ...cleanNode, notes: 'edited' }],
|
||||||
|
connections: workflow.connections,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows moving a deprecated node on the canvas', async () => {
|
||||||
|
const deprecated = buildDeprecatedNode();
|
||||||
|
const workflow = await createWorkflow({ nodes: [deprecated] }, owner);
|
||||||
|
|
||||||
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
|
||||||
|
versionId: workflow.versionId,
|
||||||
|
nodes: [{ ...deprecated, position: [400, 200] }],
|
||||||
|
connections: workflow.connections,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows removing a deprecated node from an existing workflow', async () => {
|
||||||
|
const deprecated = buildDeprecatedNode();
|
||||||
|
const cleanNode = buildCleanNode();
|
||||||
|
const workflow = await createWorkflow({ nodes: [deprecated, cleanNode] }, owner);
|
||||||
|
|
||||||
|
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
|
||||||
|
versionId: workflow.versionId,
|
||||||
|
nodes: [cleanNode],
|
||||||
|
connections: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Security: Mass Assignment Protection on Update', () => {
|
describe('Security: Mass Assignment Protection on Update', () => {
|
||||||
test.each([
|
test.each([
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-core",
|
"name": "n8n-core",
|
||||||
"version": "2.43.0",
|
"version": "2.43.1",
|
||||||
"description": "Core functionality of n8n",
|
"description": "Core functionality of n8n",
|
||||||
"main": "dist/index",
|
"main": "dist/index",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/chat",
|
"name": "@n8n/chat",
|
||||||
"version": "1.42.0",
|
"version": "1.42.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm run --dir=../storybook dev --initial-path=/docs/chat-chat--docs",
|
"dev": "pnpm run --dir=../storybook dev --initial-path=/docs/chat-chat--docs",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/composables",
|
"name": "@n8n/composables",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.33.0",
|
"version": "1.33.1",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"name": "@n8n/design-system",
|
"name": "@n8n/design-system",
|
||||||
"version": "2.42.0",
|
"version": "2.42.2",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -32,16 +32,18 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const menuItemTextViewport = ref<HTMLElement | null>(null);
|
const menuItemTextViewport = ref<HTMLElement | null>(null);
|
||||||
const menuItemText = ref<ComponentPublicInstance | null>(null);
|
const menuItemText = ref<ComponentPublicInstance | null>(null);
|
||||||
const labelOverflows = ref(false);
|
const labelOverflowDistance = ref(0);
|
||||||
|
|
||||||
// Observing the text too catches label changes, which do not resize the viewport.
|
// Observing the text too catches label changes, which do not resize the viewport.
|
||||||
useResizeObserver([menuItemTextViewport, menuItemText], () => {
|
useResizeObserver([menuItemTextViewport, menuItemText], () => {
|
||||||
const viewport = menuItemTextViewport.value;
|
const viewport = menuItemTextViewport.value;
|
||||||
labelOverflows.value = viewport !== null && viewport.scrollWidth > viewport.clientWidth;
|
labelOverflowDistance.value = viewport
|
||||||
|
? Math.max(0, viewport.scrollWidth - viewport.clientWidth)
|
||||||
|
: 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isLabelOverflowing = computed(
|
const isLabelOverflowing = computed(
|
||||||
() => Boolean(props.scrollLabelOnOverflow) && labelOverflows.value,
|
() => Boolean(props.scrollLabelOnOverflow) && labelOverflowDistance.value > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const to = computed(() => {
|
const to = computed(() => {
|
||||||
|
|
@ -156,6 +158,11 @@ const tooltipPlacement = computed(() => {
|
||||||
[$style.labelOverflowing]: isLabelOverflowing,
|
[$style.labelOverflowing]: isLabelOverflowing,
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
|
:style="
|
||||||
|
isLabelOverflowing
|
||||||
|
? { '--menu-item--label-overflow': labelOverflowDistance }
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<N8nText
|
<N8nText
|
||||||
ref="menuItemText"
|
ref="menuItemText"
|
||||||
|
|
@ -262,6 +269,9 @@ const tooltipPlacement = computed(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollLabelOnOverflow {
|
.scrollLabelOnOverflow {
|
||||||
|
// In px per second. The duration follows the overflow distance, so every label
|
||||||
|
// scrolls at the same pace whatever its length.
|
||||||
|
--menu-item--label-scroll-speed: 70;
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
margin-inline-end: calc(var(--spacing--4xs) * -1);
|
margin-inline-end: calc(var(--spacing--4xs) * -1);
|
||||||
|
|
||||||
|
|
@ -286,7 +296,9 @@ const tooltipPlacement = computed(() => {
|
||||||
|
|
||||||
.menuItemText {
|
.menuItemText {
|
||||||
transform: translateX(min(0px, calc(-100% + 100cqi)));
|
transform: translateX(min(0px, calc(-100% + 100cqi)));
|
||||||
transition-duration: calc(var(--duration--slowest) + var(--duration--slowest));
|
transition-duration: calc(
|
||||||
|
var(--menu-item--label-overflow, 0) / var(--menu-item--label-scroll-speed) * 1s
|
||||||
|
);
|
||||||
transition-delay: var(--duration--base);
|
transition-delay: var(--duration--base);
|
||||||
transition-timing-function: linear;
|
transition-timing-function: linear;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"name": "@n8n/frontend-module-sdk",
|
"name": "@n8n/frontend-module-sdk",
|
||||||
"version": "0.13.0",
|
"version": "0.13.2",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"import": "src/index.ts",
|
"import": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/i18n",
|
"name": "@n8n/i18n",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2.43.0",
|
"version": "2.43.1",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/rest-api-client",
|
"name": "@n8n/rest-api-client",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2.43.0",
|
"version": "2.43.1",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/stores",
|
"name": "@n8n/stores",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2.43.0",
|
"version": "2.43.1",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-editor-ui",
|
"name": "n8n-editor-ui",
|
||||||
"version": "2.43.0",
|
"version": "2.43.2",
|
||||||
"description": "Workflow Editor UI for n8n",
|
"description": "Workflow Editor UI for n8n",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { TELEMETRY_EVENT } from '@n8n/telemetry';
|
import { TELEMETRY_EVENT } from '@n8n/telemetry';
|
||||||
import { N8nButton, N8nIcon, N8nTooltip } from '@n8n/design-system';
|
import { N8nButton, N8nIcon, N8nTooltip } from '@n8n/design-system';
|
||||||
import type { ButtonSize } from '@n8n/design-system';
|
import type { ButtonSize, IconSize } from '@n8n/design-system';
|
||||||
import { round2 } from './creditFormatting';
|
import { round2 } from './creditFormatting';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
|
|
@ -14,6 +14,7 @@ const props = withDefaults(
|
||||||
creditsQuota?: number;
|
creditsQuota?: number;
|
||||||
isLowCredits: boolean;
|
isLowCredits: boolean;
|
||||||
buttonSize?: ButtonSize;
|
buttonSize?: ButtonSize;
|
||||||
|
iconSize?: IconSize;
|
||||||
// Per-thread running total (decimal); optional — shared with the builder UI.
|
// Per-thread running total (decimal); optional — shared with the builder UI.
|
||||||
creditsUsed?: number;
|
creditsUsed?: number;
|
||||||
}>(),
|
}>(),
|
||||||
|
|
@ -109,6 +110,7 @@ function onGetMoreCredits() {
|
||||||
icon="circle-dollar-sign"
|
icon="circle-dollar-sign"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
:size="props.buttonSize"
|
:size="props.buttonSize"
|
||||||
|
:icon-size="props.iconSize"
|
||||||
icon-only
|
icon-only
|
||||||
:class="{ [$style.active]: isOpen }"
|
:class="{ [$style.active]: isOpen }"
|
||||||
data-test-id="credits-dropdown-button"
|
data-test-id="credits-dropdown-button"
|
||||||
|
|
|
||||||
|
|
@ -741,6 +741,109 @@ describe('useResourceRegistry', () => {
|
||||||
expect(producedArtifacts.has('agent-1')).toBe(false);
|
expect(producedArtifacts.has('agent-1')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('registers an Agent at spawn while the builder is creating it', async () => {
|
||||||
|
const { messages, producedArtifacts } = setup();
|
||||||
|
|
||||||
|
messages.value = [
|
||||||
|
makeMessage({
|
||||||
|
agentTree: makeAgentNode({
|
||||||
|
children: [
|
||||||
|
makeAgentNode({
|
||||||
|
agentId: 'agent-builder:agent-1',
|
||||||
|
activity: 'creating',
|
||||||
|
status: 'active',
|
||||||
|
targetResource: { type: 'agent', id: 'agent-1', name: 'Support Bot' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
toolCalls: [
|
||||||
|
makeToolCall({
|
||||||
|
toolName: 'build-agent',
|
||||||
|
isLoading: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(producedArtifacts.get('agent-1')).toMatchObject({
|
||||||
|
type: 'agent',
|
||||||
|
id: 'agent-1',
|
||||||
|
name: 'Support Bot',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registers an Agent at spawn while the builder is editing it', async () => {
|
||||||
|
const { messages, producedArtifacts } = setup();
|
||||||
|
|
||||||
|
messages.value = [
|
||||||
|
makeMessage({
|
||||||
|
agentTree: makeAgentNode({
|
||||||
|
children: [
|
||||||
|
makeAgentNode({
|
||||||
|
agentId: 'agent-builder:agent-1',
|
||||||
|
activity: 'editing',
|
||||||
|
status: 'active',
|
||||||
|
targetResource: { type: 'agent', id: 'agent-1', name: 'Support Bot' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
toolCalls: [
|
||||||
|
makeToolCall({
|
||||||
|
toolName: 'build-agent',
|
||||||
|
isLoading: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(producedArtifacts.get('agent-1')).toMatchObject({
|
||||||
|
type: 'agent',
|
||||||
|
id: 'agent-1',
|
||||||
|
name: 'Support Bot',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a spawn-registered Agent when the build settles with no change', async () => {
|
||||||
|
const { messages, producedArtifacts } = setup();
|
||||||
|
|
||||||
|
messages.value = [
|
||||||
|
makeMessage({
|
||||||
|
agentTree: makeAgentNode({
|
||||||
|
children: [
|
||||||
|
makeAgentNode({
|
||||||
|
agentId: 'agent-builder:agent-1',
|
||||||
|
activity: 'creating',
|
||||||
|
status: 'active',
|
||||||
|
targetResource: { type: 'agent', id: 'agent-1', name: 'Support Bot' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
toolCalls: [
|
||||||
|
makeToolCall({
|
||||||
|
toolName: 'build-agent',
|
||||||
|
isLoading: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(producedArtifacts.has('agent-1')).toBe(true);
|
||||||
|
|
||||||
|
const buildAgentCall = messages.value[0].agentTree!.toolCalls[0];
|
||||||
|
buildAgentCall.isLoading = false;
|
||||||
|
buildAgentCall.result = { ok: true, agentId: 'agent-1', agentChange: 'none' };
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(producedArtifacts.get('agent-1')).toMatchObject({
|
||||||
|
type: 'agent',
|
||||||
|
id: 'agent-1',
|
||||||
|
name: 'Support Bot',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('keeps a pending Agent unconfirmed until a build changes it', async () => {
|
test('keeps a pending Agent unconfirmed until a build changes it', async () => {
|
||||||
const { messages, producedArtifacts, linkableResourceNameIndex } = setup();
|
const { messages, producedArtifacts, linkableResourceNameIndex } = setup();
|
||||||
const builder = makeAgentNode({
|
const builder = makeAgentNode({
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ function handleThreadSelect(threadId: string) {
|
||||||
:credits-used="threadCreditsUsed"
|
:credits-used="threadCreditsUsed"
|
||||||
:is-low-credits="store.isLowCredits"
|
:is-low-credits="store.isLowCredits"
|
||||||
button-size="small"
|
button-size="small"
|
||||||
|
icon-size="large"
|
||||||
@upgrade-click="goToUpgrade('instance-ai', 'upgrade-instance-ai')"
|
@upgrade-click="goToUpgrade('instance-ai', 'upgrade-instance-ai')"
|
||||||
/>
|
/>
|
||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
|
|
|
||||||
|
|
@ -775,7 +775,7 @@ onScopeDispose(() => {
|
||||||
:disabled="!helpDisabled"
|
:disabled="!helpDisabled"
|
||||||
:content="i18n.baseText('instanceAi.setupPanel.helpUnavailableWhileBuilding')"
|
:content="i18n.baseText('instanceAi.setupPanel.helpUnavailableWhileBuilding')"
|
||||||
>
|
>
|
||||||
<span :tabindex="helpDisabled ? 0 : undefined">
|
<span :class="$style.helpTrigger" :tabindex="helpDisabled ? 0 : undefined">
|
||||||
<N8nButton
|
<N8nButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="small"
|
size="small"
|
||||||
|
|
@ -853,9 +853,17 @@ onScopeDispose(() => {
|
||||||
color: var(--text-color--subtle);
|
color: var(--text-color--subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tooltip anchors to this wrapper. Inline-flex keeps it the size of the
|
||||||
|
// button; a plain inline span around the button spans the full row.
|
||||||
|
.helpTrigger {
|
||||||
|
display: inline-flex;
|
||||||
|
// Offset the button padding so the label aligns with the content above
|
||||||
|
margin-inline-start: calc(-1 * var(--spacing--2xs));
|
||||||
|
}
|
||||||
|
|
||||||
.help {
|
.help {
|
||||||
padding-inline: 0;
|
--button--color: var(--text-color--subtler);
|
||||||
color: var(--text-color--subtle);
|
padding-inline: var(--spacing--2xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form .existing {
|
.form .existing {
|
||||||
|
|
|
||||||
|
|
@ -370,9 +370,9 @@ function extractFromTargetResource(node: InstanceAiAgentNode, col: Collections):
|
||||||
const existing = col.produced.get(target.id);
|
const existing = col.produced.get(target.id);
|
||||||
const name = optionalString(target.name) ?? existing?.name ?? 'Untitled';
|
const name = optionalString(target.name) ?? existing?.name ?? 'Untitled';
|
||||||
if (target.type === 'agent') {
|
if (target.type === 'agent') {
|
||||||
// New events report the target before the result is known. Only the
|
// A read-only turn cannot confirm that this Agent changed. Wait for the
|
||||||
// build-agent result can confirm that this Agent changed.
|
// build-agent result. A mutating build registers the Agent at spawn.
|
||||||
if (node.activity !== undefined && (!existing || existing.pending)) return;
|
if (node.activity === 'exploring' && (!existing || existing.pending)) return;
|
||||||
const entry = entryFromAgentBuilderTarget(target, existing, name);
|
const entry = entryFromAgentBuilderTarget(target, existing, name);
|
||||||
if (entry) recordProduced(col, entry);
|
if (entry) recordProduced(col, entry);
|
||||||
return;
|
return;
|
||||||
|
|
@ -381,7 +381,7 @@ function extractFromTargetResource(node: InstanceAiAgentNode, col: Collections):
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectFromAgentNode(node: InstanceAiAgentNode, col: Collections): void {
|
function collectFromAgentNode(node: InstanceAiAgentNode, col: Collections): void {
|
||||||
const deferAgentTarget = node.targetResource?.type === 'agent' && node.activity !== undefined;
|
const deferAgentTarget = node.targetResource?.type === 'agent' && node.activity === 'exploring';
|
||||||
// A sub-agent spawned onto a resource is there to work on it.
|
// A sub-agent spawned onto a resource is there to work on it.
|
||||||
col.intake = 'built';
|
col.intake = 'built';
|
||||||
if (!deferAgentTarget) extractFromTargetResource(node, col);
|
if (!deferAgentTarget) extractFromTargetResource(node, col);
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user