mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
Compare commits
18 Commits
master
...
n8n@2.43.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69d73b9b6a | ||
|
|
dc770620e5 | ||
|
|
c00df8c17f | ||
|
|
5639a819f1 | ||
|
|
ea448c6f61 | ||
|
|
3d47f4bfaa | ||
|
|
3f9a95542b | ||
|
|
17f9c4a715 | ||
|
|
3c4f6e3393 | ||
|
|
1830ccfcda | ||
|
|
6ab34a4a42 | ||
|
|
3ee8d7aeef | ||
|
|
509a3f36a8 | ||
|
|
204e2ddf17 | ||
|
|
b553bca31e | ||
|
|
fda8dac7d8 | ||
|
|
9ea78350fa | ||
|
|
345ae0100c |
41
CHANGELOG.md
41
CHANGELOG.md
|
|
@ -1,3 +1,44 @@
|
||||||
|
## [2.43.4](https://github.com/n8n-io/n8n/compare/n8n@2.43.3...n8n@2.43.4) (2026-10-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Handle each Chat Hub segment once in queue mode ([#40816](https://github.com/n8n-io/n8n/issues/40816)) ([dc77062](https://github.com/n8n-io/n8n/commit/dc770620e5e3830e7e41b5b840028f937dedad8d))
|
||||||
|
* **core:** Resume tool calls for deferred tools that a skill loaded ([#40746](https://github.com/n8n-io/n8n/issues/40746)) ([ea448c6](https://github.com/n8n-io/n8n/commit/ea448c6f61fb7cae06cfbeccbd32713a70d112ca))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.43.3](https://github.com/n8n-io/n8n/compare/n8n@2.43.2...n8n@2.43.3) (2026-10-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **core:** Block create/update of workflows with deprecated nodes ([#40706](https://github.com/n8n-io/n8n/issues/40706)) ([3c4f6e3](https://github.com/n8n-io/n8n/commit/3c4f6e33934adf29d2c76e7723256fa5b69b9ab9))
|
||||||
|
* **Google Gemini Node:** Show Nano Banana 2.1 and 2 Lite in the image model list ([#40655](https://github.com/n8n-io/n8n/issues/40655)) ([1830ccf](https://github.com/n8n-io/n8n/commit/1830ccfcda55fc948197fbb88ac7438211a39940))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.43.2](https://github.com/n8n-io/n8n/compare/n8n@2.43.1...n8n@2.43.2) (2026-10-08)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Fix Kimi K3 and other OpenAI-compatible Assistant model providers on self-hosted Assistant ([#40552](https://github.com/n8n-io/n8n/issues/40552)) ([509a3f3](https://github.com/n8n-io/n8n/commit/509a3f36a8443e544714e3874dc55ba14f2c9463))
|
||||||
|
* **editor:** Show the Agent artifact while the builder creates it ([#40550](https://github.com/n8n-io/n8n/issues/40550)) ([204e2dd](https://github.com/n8n-io/n8n/commit/204e2ddf1745095f940659d827b6f8ca000ead49))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.43.1](https://github.com/n8n-io/n8n/compare/n8n@2.43.0...n8n@2.43.1) (2026-10-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **editor:** Polish n8n Assistant and Execute button UI details ([#40438](https://github.com/n8n-io/n8n/issues/40438)) ([345ae01](https://github.com/n8n-io/n8n/commit/345ae0100c073de418cab4eaf3f48926b414e6e1))
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **core:** Keep agent builder system prompt static for prompt caching ([#40441](https://github.com/n8n-io/n8n/issues/40441)) ([9ea7835](https://github.com/n8n-io/n8n/commit/9ea78350fa4a3972cbef036ccfd8355b0e127262))
|
||||||
|
* **core:** Prepare Instance AI skill sandbox in the background ([#40442](https://github.com/n8n-io/n8n/issues/40442)) ([fda8dac](https://github.com/n8n-io/n8n/commit/fda8dac7d8bd7a3fe427285de281911d68f8f63b))
|
||||||
|
|
||||||
|
|
||||||
# [2.43.0](https://github.com/n8n-io/n8n/compare/n8n@2.42.0...n8n@2.43.0) (2026-10-06)
|
# [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.4",
|
||||||
"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.3",
|
||||||
"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"
|
||||||
|
|
|
||||||
|
|
@ -8753,6 +8753,55 @@ describe('AgentRuntime — mid-run observation', () => {
|
||||||
expect(JSON.stringify(capturedCall(2))).not.toContain('Old workflow policy.');
|
expect(JSON.stringify(capturedCall(2))).not.toContain('Old workflow policy.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resumes a deferred tool that a skill dependency loaded', async () => {
|
||||||
|
const source = createRuntimeSkillSource([
|
||||||
|
{
|
||||||
|
id: 'planning',
|
||||||
|
name: 'planning',
|
||||||
|
description: 'Plan multi-step work.',
|
||||||
|
instructions: 'Ask the user to approve the plan.',
|
||||||
|
dependencies: { tools: ['approve'] },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const checkpointStore = makeClaimingCheckpointStore();
|
||||||
|
const memory = new InMemoryMemory();
|
||||||
|
const options = {
|
||||||
|
skillSource: source,
|
||||||
|
tools: createRuntimeSkillTools(source),
|
||||||
|
deferredTools: [makeInterruptibleTool()],
|
||||||
|
checkpointStorage: checkpointStore,
|
||||||
|
};
|
||||||
|
const first = buildMidRunRuntime(memory, options);
|
||||||
|
generateText
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makeGenerateWithToolCall('load-planning', 'load_skill', { skillId: 'planning' }),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makeGenerateWithToolCall('confirm', 'approve', { question: 'Run the plan?' }),
|
||||||
|
);
|
||||||
|
const result = await first.generate('Plan it', { persistence: PERSISTENCE });
|
||||||
|
await first.dispose();
|
||||||
|
const suspension = result.pendingSuspend?.[0];
|
||||||
|
if (!suspension) throw new Error('Expected a plan confirmation');
|
||||||
|
|
||||||
|
const resumed = buildMidRunRuntime(memory, options);
|
||||||
|
generateText.mockResolvedValueOnce(makeGenerateSuccess('Plan approved.'));
|
||||||
|
const resumeResult = await resumed.resume(
|
||||||
|
'generate',
|
||||||
|
{ approved: true },
|
||||||
|
{ runId: suspension.runId, toolCallId: suspension.toolCallId },
|
||||||
|
);
|
||||||
|
await resumed.dispose();
|
||||||
|
|
||||||
|
expect(resumeResult.error).toBeUndefined();
|
||||||
|
expect(resumeResult.toolCalls).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ tool: 'approve', output: { approved: true } }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(capturedCall(2).tools).toHaveProperty('approve');
|
||||||
|
});
|
||||||
|
|
||||||
it('merges system messages after compaction for custom OpenAI-compatible endpoints', async () => {
|
it('merges system messages after compaction for custom OpenAI-compatible endpoints', async () => {
|
||||||
const memory = new InMemoryMemory();
|
const memory = new InMemoryMemory();
|
||||||
const runtime = buildMidRunRuntime(memory, {
|
const runtime = buildMidRunRuntime(memory, {
|
||||||
|
|
|
||||||
|
|
@ -509,9 +509,11 @@ export class AgentRuntime {
|
||||||
|
|
||||||
const list = await this.restoreCheckpointMessages(state);
|
const list = await this.restoreCheckpointMessages(state);
|
||||||
|
|
||||||
const tool = this.context
|
// A skill dependency loads a deferred tool without a `load_tool` record, and
|
||||||
.getCurrentTools(state.persistence)
|
// skills restore only when the loop starts. Accept any registered deferred tool.
|
||||||
.find((t) => t.name === toolCall.toolName);
|
const tool =
|
||||||
|
this.context.getCurrentTools(state.persistence).find((t) => t.name === toolCall.toolName) ??
|
||||||
|
this.deferredToolManager?.getTool(toolCall.toolName);
|
||||||
if (!tool) throw new Error(`Tool ${toolCall.toolName} not found`);
|
if (!tool) throw new Error(`Tool ${toolCall.toolName} not found`);
|
||||||
|
|
||||||
const resumeSchema = toolCall.suspended ? toolCall.resumeSchema : tool.resumeSchema;
|
const resumeSchema = toolCall.suspended ? toolCall.resumeSchema : tool.resumeSchema;
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,11 @@ export class DeferredToolManager {
|
||||||
return [this.searchTool, this.loadTool];
|
return [this.searchTool, this.loadTool];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A registered deferred tool, loaded or not. */
|
||||||
|
getTool(name: string): BuiltTool | undefined {
|
||||||
|
return this.toolsByName.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
getLoadedTools(): BuiltTool[] {
|
getLoadedTools(): BuiltTool[] {
|
||||||
return Array.from(this.loadedToolNames)
|
return Array.from(this.loadedToolNames)
|
||||||
.map((name) => this.toolsByName.get(name))
|
.map((name) => this.toolsByName.get(name))
|
||||||
|
|
|
||||||
|
|
@ -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.2",
|
||||||
"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.2",
|
||||||
"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.2",
|
||||||
"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.2",
|
||||||
"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.2",
|
||||||
"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.2",
|
||||||
"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.2",
|
||||||
"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.2",
|
||||||
"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.2",
|
||||||
"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.2",
|
||||||
"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.2",
|
||||||
"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.2",
|
||||||
"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/constants",
|
"name": "@n8n/constants",
|
||||||
"version": "0.41.0",
|
"version": "0.41.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,7 @@
|
||||||
export const NPM_COMMUNITY_NODE_SEARCH_API_URL = 'https://api.npms.io/v2/';
|
export const NPM_COMMUNITY_NODE_SEARCH_API_URL = 'https://api.npms.io/v2/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Node-authoring API level supported by the runtime that bundles this copy.
|
* Node-authoring API level this runtime supports, and so the highest
|
||||||
*
|
* `n8nNodesApiVersion` a node may declare to be installed.
|
||||||
* Compatibility rule for community packages:
|
|
||||||
* `package.n8n.n8nNodesApiVersion <= N8N_NODES_API_VERSION`.
|
|
||||||
*
|
|
||||||
* The level mirrors the n8n major for the v3 transition: `1` on master
|
|
||||||
* (which ships as 2.x during the v3 window) and `3` on the `3.x` branch,
|
|
||||||
* where it also serves as the v3 feature flag for node-authoring APIs.
|
|
||||||
*
|
|
||||||
* Lives here, not in `n8n-workflow`, so tooling that cannot depend on the
|
|
||||||
* runtime packages (e.g. `@n8n/node-cli`) can read the same value.
|
|
||||||
*/
|
*/
|
||||||
export const N8N_NODES_API_VERSION = 1;
|
export const N8N_NODES_API_VERSION = '1.0';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/create-node",
|
"name": "@n8n/create-node",
|
||||||
"version": "0.51.0",
|
"version": "0.51.2",
|
||||||
"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.2",
|
||||||
"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.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,11 @@ export type WorkflowExecuteAfterContext = {
|
||||||
retryOf?: string;
|
retryOf?: string;
|
||||||
/** Who initiated the run. Unset means a regular user-initiated run. */
|
/** Who initiated the run. Unset means a regular user-initiated run. */
|
||||||
source?: WorkflowExecutionSource;
|
source?: WorkflowExecutionSource;
|
||||||
|
/**
|
||||||
|
* False when this process only enqueued the run and waited for its job. In queue mode,
|
||||||
|
* the process that ran the job fires its own `workflowExecuteAfter` for the same run.
|
||||||
|
*/
|
||||||
|
executedInThisProcess: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowExecuteResumeContext = {
|
export type WorkflowExecuteResumeContext = {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/engine",
|
"name": "@n8n/engine",
|
||||||
"version": "0.23.0",
|
"version": "0.23.2",
|
||||||
"description": "n8n workflow execution engine (v2)",
|
"description": "n8n workflow execution engine (v2)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo compiled",
|
"clean": "rimraf dist .turbo compiled",
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,9 @@ register at install time and the failure is opaque to the user.
|
||||||
This rule enforces the structural contract:
|
This rule enforces the structural contract:
|
||||||
|
|
||||||
- `package.json` must contain an `n8n` object.
|
- `package.json` must contain an `n8n` object.
|
||||||
- `n8n.n8nNodesApiVersion` must be present and a positive integer. It must live
|
- `n8n.n8nNodesApiVersion` must be present and a positive integer, or a
|
||||||
inside `n8n`, not at the root.
|
`"<major>"` or `"<major>.<minor>"` string such as `"3"` or `"3.1"`. It must
|
||||||
|
live inside `n8n`, not at the root.
|
||||||
- `n8n.nodes` must be a non-empty array of strings, each starting with `dist/`.
|
- `n8n.nodes` must be a non-empty array of strings, each starting with `dist/`.
|
||||||
- `n8n.credentials`, if present, must be an array of strings, each starting
|
- `n8n.credentials`, if present, must be an array of strings, each starting
|
||||||
with `dist/`.
|
with `dist/`.
|
||||||
|
|
@ -53,7 +54,7 @@ the templates published by `@n8n/node-cli` and `@n8n/create-node`.
|
||||||
{
|
{
|
||||||
"name": "n8n-nodes-example",
|
"name": "n8n-nodes-example",
|
||||||
"n8n": {
|
"n8n": {
|
||||||
"n8nNodesApiVersion": "1",
|
"n8nNodesApiVersion": "v1",
|
||||||
"nodes": ["nodes/Foo/Foo.node.js"]
|
"nodes": ["nodes/Foo/Foo.node.js"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/eslint-plugin-community-nodes",
|
"name": "@n8n/eslint-plugin-community-nodes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.35.0",
|
"version": "0.35.1",
|
||||||
"main": "./dist/plugin.js",
|
"main": "./dist/plugin.js",
|
||||||
"types": "./dist/plugin.d.ts",
|
"types": "./dist/plugin.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
"watch": "tsc --watch --project tsconfig.build.json"
|
"watch": "tsc --watch --project tsconfig.build.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@n8n/utils": "workspace:*",
|
||||||
"@typescript-eslint/typescript-estree": "catalog:",
|
"@typescript-eslint/typescript-estree": "catalog:",
|
||||||
"@typescript-eslint/utils": "catalog:",
|
"@typescript-eslint/utils": "catalog:",
|
||||||
"fastest-levenshtein": "catalog:"
|
"fastest-levenshtein": "catalog:"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,21 @@ ruleTester.run('n8n-object-validation', N8nObjectValidationRule, {
|
||||||
filename: 'package.json',
|
filename: 'package.json',
|
||||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 2, "nodes": ["dist/x.js"] } }',
|
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 2, "nodes": ["dist/x.js"] } }',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'major-only string api version is allowed',
|
||||||
|
filename: 'package.json',
|
||||||
|
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3", "nodes": ["dist/x.js"] } }',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'major.minor string api version is allowed',
|
||||||
|
filename: 'package.json',
|
||||||
|
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.1", "nodes": ["dist/x.js"] } }',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'two-digit minor string api version is allowed',
|
||||||
|
filename: 'package.json',
|
||||||
|
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.10", "nodes": ["dist/x.js"] } }',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'non-package.json file is ignored',
|
name: 'non-package.json file is ignored',
|
||||||
filename: 'some-config.json',
|
filename: 'some-config.json',
|
||||||
|
|
@ -77,10 +92,22 @@ ruleTester.run('n8n-object-validation', N8nObjectValidationRule, {
|
||||||
errors: [{ messageId: 'missingNodesApiVersion' }],
|
errors: [{ messageId: 'missingNodesApiVersion' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'n8nNodesApiVersion is a string',
|
name: 'n8nNodesApiVersion is a non-numeric string',
|
||||||
filename: 'package.json',
|
filename: 'package.json',
|
||||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "1", "nodes": ["dist/x.js"] } }',
|
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "v3", "nodes": ["dist/x.js"] } }',
|
||||||
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '1' } }],
|
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: 'v3' } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'n8nNodesApiVersion is a string with a patch part',
|
||||||
|
filename: 'package.json',
|
||||||
|
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.1.0", "nodes": ["dist/x.js"] } }',
|
||||||
|
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '3.1.0' } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'n8nNodesApiVersion is a zero-major string',
|
||||||
|
filename: 'package.json',
|
||||||
|
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "0.1", "nodes": ["dist/x.js"] } }',
|
||||||
|
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '0.1' } }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'n8nNodesApiVersion is zero',
|
name: 'n8nNodesApiVersion is zero',
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { parseNodesApiLevel } from '@n8n/utils/nodes-api-level';
|
||||||
import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
|
import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
|
||||||
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||||
|
|
||||||
|
|
@ -34,9 +35,9 @@ export const N8nObjectValidationRule = createRule<[], MessageIds>({
|
||||||
wrongLocationApiVersion:
|
wrongLocationApiVersion:
|
||||||
'"n8nNodesApiVersion" must be inside the "n8n" section, not at the root level of package.json.',
|
'"n8nNodesApiVersion" must be inside the "n8n" section, not at the root level of package.json.',
|
||||||
missingNodesApiVersion:
|
missingNodesApiVersion:
|
||||||
'The "n8n" object must declare "n8nNodesApiVersion" (a positive integer).',
|
'The "n8n" object must declare "n8nNodesApiVersion" (a positive integer, or a "<major>" or "<major>.<minor>" string).',
|
||||||
invalidNodesApiVersion:
|
invalidNodesApiVersion:
|
||||||
'"n8n.n8nNodesApiVersion" must be a positive integer, got {{ value }}.',
|
'"n8n.n8nNodesApiVersion" must be a positive integer, or a "<major>" or "<major>.<minor>" string, got {{ value }}.',
|
||||||
missingN8nNodes: 'The "n8n" object must declare "nodes" as an array of "dist/" paths.',
|
missingN8nNodes: 'The "n8n" object must declare "nodes" as an array of "dist/" paths.',
|
||||||
n8nNodesNotArray: '"n8n.nodes" must be an array of "dist/" paths.',
|
n8nNodesNotArray: '"n8n.nodes" must be an array of "dist/" paths.',
|
||||||
emptyN8nNodes: '"n8n.nodes" must contain at least one path.',
|
emptyN8nNodes: '"n8n.nodes" must contain at least one path.',
|
||||||
|
|
@ -108,7 +109,7 @@ function validateApiVersion(context: Context, n8nObject: TSESTree.ObjectExpressi
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueNode = apiVersionProp.value;
|
const valueNode = apiVersionProp.value;
|
||||||
if (valueNode.type !== AST_NODE_TYPES.Literal || !isPositiveInteger(valueNode.value)) {
|
if (valueNode.type !== AST_NODE_TYPES.Literal || parseNodesApiLevel(valueNode.value) === null) {
|
||||||
context.report({
|
context.report({
|
||||||
node: apiVersionProp,
|
node: apiVersionProp,
|
||||||
messageId: 'invalidNodesApiVersion',
|
messageId: 'invalidNodesApiVersion',
|
||||||
|
|
@ -194,7 +195,3 @@ function validatePathArray(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPositiveInteger(value: unknown): boolean {
|
|
||||||
return typeof value === 'number' && Number.isInteger(value) && value > 0;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/expression-runtime",
|
"name": "@n8n/expression-runtime",
|
||||||
"version": "0.34.0",
|
"version": "0.34.1",
|
||||||
"description": "Secure, isolated expression evaluation runtime for n8n",
|
"description": "Secure, isolated expression evaluation runtime for n8n",
|
||||||
"main": "dist/cjs/index.js",
|
"main": "dist/cjs/index.js",
|
||||||
"module": "dist/esm/index.js",
|
"module": "dist/esm/index.js",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/imap",
|
"name": "@n8n/imap",
|
||||||
"version": "0.29.0",
|
"version": "0.29.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/inbound-auth",
|
"name": "@n8n/inbound-auth",
|
||||||
"version": "0.2.0",
|
"version": "0.2.2",
|
||||||
"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.3",
|
||||||
"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.3",
|
||||||
"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.2",
|
||||||
"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.2",
|
||||||
"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.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/scan-community-package",
|
"name": "@n8n/scan-community-package",
|
||||||
"version": "0.38.0",
|
"version": "0.38.1",
|
||||||
"description": "Static code analyser for n8n community packages",
|
"description": "Static code analyser for n8n community packages",
|
||||||
"license": "LicenseRef-n8n-sustainable-use",
|
"license": "LicenseRef-n8n-sustainable-use",
|
||||||
"bin": "scanner/cli.mjs",
|
"bin": "scanner/cli.mjs",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/scheduler",
|
"name": "@n8n/scheduler",
|
||||||
"version": "1.12.0",
|
"version": "1.12.2",
|
||||||
"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.2",
|
||||||
"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.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/utils",
|
"name": "@n8n/utils",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.50.0",
|
"version": "1.50.1",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
|
|
@ -41,13 +41,15 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@n8n/constants": "workspace:*",
|
"@n8n/constants": "workspace:*",
|
||||||
"nanoid": "catalog:"
|
"nanoid": "catalog:",
|
||||||
|
"semver": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@n8n/oxlint-config": "workspace:*",
|
"@n8n/oxlint-config": "workspace:*",
|
||||||
"@n8n/typescript-config": "workspace:*",
|
"@n8n/typescript-config": "workspace:*",
|
||||||
"@n8n/vitest-config": "workspace:*",
|
"@n8n/vitest-config": "workspace:*",
|
||||||
"@testing-library/jest-dom": "catalog:frontend",
|
"@testing-library/jest-dom": "catalog:frontend",
|
||||||
|
"@types/semver": "catalog:",
|
||||||
"fast-check": "catalog:",
|
"fast-check": "catalog:",
|
||||||
"oxlint": "catalog:",
|
"oxlint": "catalog:",
|
||||||
"oxlint-tsgolint": "catalog:",
|
"oxlint-tsgolint": "catalog:",
|
||||||
|
|
|
||||||
58
packages/@n8n/utils/src/nodes-api-level.test.ts
Normal file
58
packages/@n8n/utils/src/nodes-api-level.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { formatNodesApiLevel, parseNodesApiLevel } from './nodes-api-level';
|
||||||
|
|
||||||
|
describe('parseNodesApiLevel', () => {
|
||||||
|
it.each([
|
||||||
|
['3.1', { major: 3, minor: 1 }],
|
||||||
|
['3.0', { major: 3, minor: 0 }],
|
||||||
|
['3', { major: 3, minor: 0 }],
|
||||||
|
['3.10', { major: 3, minor: 10 }],
|
||||||
|
])('parses the string %p as %p', (value, expected) => {
|
||||||
|
expect(parseNodesApiLevel(value)).toMatchObject(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[1, { major: 1, minor: 0 }],
|
||||||
|
[3, { major: 3, minor: 0 }],
|
||||||
|
])('reads the integer %p as %p', (value, expected) => {
|
||||||
|
expect(parseNodesApiLevel(value)).toMatchObject(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
0,
|
||||||
|
-1,
|
||||||
|
2.5,
|
||||||
|
3.1,
|
||||||
|
'0.0',
|
||||||
|
'0.1',
|
||||||
|
'01.1',
|
||||||
|
'03',
|
||||||
|
'3.01',
|
||||||
|
'3.',
|
||||||
|
'.1',
|
||||||
|
' 3.2 ',
|
||||||
|
'3.1.2',
|
||||||
|
'v3',
|
||||||
|
'',
|
||||||
|
null,
|
||||||
|
NaN,
|
||||||
|
Infinity,
|
||||||
|
true,
|
||||||
|
{},
|
||||||
|
])('rejects %p', (value) => {
|
||||||
|
expect(parseNodesApiLevel(value)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([2 ** 53, '9007199254740992', '1.9007199254740992'])(
|
||||||
|
'rejects the level %p above the safe integer range',
|
||||||
|
(value) => {
|
||||||
|
expect(parseNodesApiLevel(value)).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatNodesApiLevel', () => {
|
||||||
|
it('writes major and minor', () => {
|
||||||
|
expect(formatNodesApiLevel(parseNodesApiLevel('3.0')!)).toBe('3.0');
|
||||||
|
expect(formatNodesApiLevel(parseNodesApiLevel('3.10')!)).toBe('3.10');
|
||||||
|
});
|
||||||
|
});
|
||||||
19
packages/@n8n/utils/src/nodes-api-level.ts
Normal file
19
packages/@n8n/utils/src/nodes-api-level.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { coerce, type SemVer } from 'semver';
|
||||||
|
|
||||||
|
// A float is rejected, since an unquoted `3.10` in package.json is read as
|
||||||
|
// the number `3.1`. The input must print back from its coerced version as
|
||||||
|
// `<major>` or `<major>.<minor>`.
|
||||||
|
export function parseNodesApiLevel(value: unknown): SemVer | null {
|
||||||
|
const text = typeof value === 'number' && Number.isInteger(value) ? String(value) : value;
|
||||||
|
if (typeof text !== 'string') return null;
|
||||||
|
|
||||||
|
const version = coerce(text);
|
||||||
|
if (version === null || version.major < 1) return null;
|
||||||
|
if (text !== `${version.major}` && text !== `${version.major}.${version.minor}`) return null;
|
||||||
|
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNodesApiLevel({ major, minor }: SemVer): string {
|
||||||
|
return `${major}.${minor}`;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/workflow-sdk",
|
"name": "@n8n/workflow-sdk",
|
||||||
"version": "0.36.0",
|
"version": "0.36.2",
|
||||||
"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.4",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -955,6 +955,7 @@ describe('ActiveWorkflowManager', () => {
|
||||||
mode,
|
mode,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
|
'none',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(eventService.emit).toHaveBeenCalledWith('workflow-executed', {
|
expect(eventService.emit).toHaveBeenCalledWith('workflow-executed', {
|
||||||
|
|
@ -998,6 +999,7 @@ describe('ActiveWorkflowManager', () => {
|
||||||
mode,
|
mode,
|
||||||
undefined,
|
undefined,
|
||||||
'wf-1:node-1:1700000000000',
|
'wf-1:node-1:1700000000000',
|
||||||
|
'none',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,7 @@ describe('WaitTracker', () => {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
pushRef: execution.data.pushRef,
|
pushRef: execution.data.pushRef,
|
||||||
userId: undefined,
|
userId: undefined,
|
||||||
|
callerAwaitsOutcome: 'none',
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
|
@ -373,6 +374,7 @@ describe('WaitTracker', () => {
|
||||||
workflowData: execution.workflowData,
|
workflowData: execution.workflowData,
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
pushRef: execution.data.pushRef,
|
pushRef: execution.data.pushRef,
|
||||||
|
callerAwaitsOutcome: 'none',
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
|
@ -393,6 +395,7 @@ describe('WaitTracker', () => {
|
||||||
workflowData: parentExecution.workflowData,
|
workflowData: parentExecution.workflowData,
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
pushRef: parentExecution.data.pushRef,
|
pushRef: parentExecution.data.pushRef,
|
||||||
|
callerAwaitsOutcome: 'none',
|
||||||
startedAt: parentExecution.startedAt,
|
startedAt: parentExecution.startedAt,
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
|
|
|
||||||
|
|
@ -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>({
|
||||||
|
|
|
||||||
|
|
@ -1222,6 +1222,7 @@ describe('enqueueExecution', () => {
|
||||||
executionData: undefined,
|
executionData: undefined,
|
||||||
pushRef: 'push-ref',
|
pushRef: 'push-ref',
|
||||||
streamingEnabled: true,
|
streamingEnabled: true,
|
||||||
|
callerAwaitsOutcome: 'response',
|
||||||
});
|
});
|
||||||
const error = new Error('stop for test purposes');
|
const error = new Error('stop for test purposes');
|
||||||
|
|
||||||
|
|
@ -1241,6 +1242,7 @@ describe('enqueueExecution', () => {
|
||||||
workflowId: 'workflow-xyz',
|
workflowId: 'workflow-xyz',
|
||||||
executionId: '1',
|
executionId: '1',
|
||||||
restartExecutionId,
|
restartExecutionId,
|
||||||
|
callerAwaitsOutcome: 'response',
|
||||||
}),
|
}),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,9 +9,9 @@ export class IncompatibleNodesApiVersionError extends ResponseError {
|
||||||
message: string,
|
message: string,
|
||||||
readonly meta: {
|
readonly meta: {
|
||||||
/** API version the package requires, or `null` if the declared value is malformed. */
|
/** API version the package requires, or `null` if the declared value is malformed. */
|
||||||
requiredNodesApiVersion: number | null;
|
requiredNodesApiVersion: string | null;
|
||||||
/** Node API version this runtime supports. */
|
/** Node API version this runtime supports. */
|
||||||
supportedNodesApiVersion: number;
|
supportedNodesApiVersion: string;
|
||||||
},
|
},
|
||||||
cause?: unknown,
|
cause?: unknown,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { ExecutionsConfig } from '@n8n/config';
|
||||||
import type { Project, User } from '@n8n/db';
|
import type { Project, User } from '@n8n/db';
|
||||||
import { ExecutionRepository, UserRepository } from '@n8n/db';
|
import { ExecutionRepository, UserRepository } from '@n8n/db';
|
||||||
import { LifecycleMetadata } from '@n8n/decorators';
|
import { LifecycleMetadata } from '@n8n/decorators';
|
||||||
|
import type { WorkflowExecuteAfterContext } from '@n8n/decorators';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { stringify } from 'flatted';
|
import { stringify } from 'flatted';
|
||||||
import {
|
import {
|
||||||
|
|
@ -536,6 +537,67 @@ describe('Execution Lifecycle Hooks', () => {
|
||||||
Container.set(LifecycleMetadata, original);
|
Container.set(LifecycleMetadata, original);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('executedInThisProcess', () => {
|
||||||
|
class RecordingHandler {
|
||||||
|
contexts: WorkflowExecuteAfterContext[] = [];
|
||||||
|
|
||||||
|
async onWorkflowExecuteAfter(ctx: WorkflowExecuteAfterContext) {
|
||||||
|
this.contexts.push(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recorder = new RecordingHandler();
|
||||||
|
|
||||||
|
const runModuleHandler = async (hooks: ExecutionLifecycleHooks) => {
|
||||||
|
await hooks.runHook('workflowExecuteAfter', [successfulRun, {}]);
|
||||||
|
return recorder.contexts.at(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
let original: LifecycleMetadata;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
recorder.contexts = [];
|
||||||
|
const recording = new LifecycleMetadata();
|
||||||
|
recording.register({
|
||||||
|
handlerClass: RecordingHandler as unknown as HandlerClass,
|
||||||
|
methodName: 'onWorkflowExecuteAfter',
|
||||||
|
eventName: 'workflowExecuteAfter',
|
||||||
|
});
|
||||||
|
Container.set(RecordingHandler, recorder);
|
||||||
|
original = Container.get(LifecycleMetadata);
|
||||||
|
Container.set(LifecycleMetadata, recording);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Container.set(LifecycleMetadata, original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be false for the hooks of a process that only enqueued the run', async () => {
|
||||||
|
const hooks = getLifecycleHooksForScalingMain(
|
||||||
|
{ executionMode: 'manual', workflowData },
|
||||||
|
executionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await runModuleHandler(hooks)).toMatchObject({ executedInThisProcess: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be true for the hooks of a process that runs the execution', async () => {
|
||||||
|
const workerHooks = getLifecycleHooksForScalingWorker(
|
||||||
|
{ executionMode: 'manual', workflowData },
|
||||||
|
executionId,
|
||||||
|
);
|
||||||
|
const regularHooks = getLifecycleHooksForRegularMain(
|
||||||
|
{ executionMode: 'manual', workflowData },
|
||||||
|
executionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await runModuleHandler(workerHooks)).toMatchObject({ executedInThisProcess: true });
|
||||||
|
expect(await runModuleHandler(regularHooks)).toMatchObject({
|
||||||
|
executedInThisProcess: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getLifecycleHooksForRegularMain', () => {
|
describe('getLifecycleHooksForRegularMain', () => {
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,13 @@ class ModulesHooksRegistry {
|
||||||
/**
|
/**
|
||||||
* `source` is not carried on `ExecutionLifecycleHooks`, so it is passed in
|
* `source` is not carried on `ExecutionLifecycleHooks`, so it is passed in
|
||||||
* here to let module handlers tell agent-initiated runs from user ones.
|
* here to let module handlers tell agent-initiated runs from user ones.
|
||||||
|
* `executedInThisProcess` is false for the hooks of a process that only enqueued the run.
|
||||||
*/
|
*/
|
||||||
addHooks(hooks: ExecutionLifecycleHooks, source?: IWorkflowExecutionDataProcess['source']) {
|
addHooks(
|
||||||
|
hooks: ExecutionLifecycleHooks,
|
||||||
|
source?: IWorkflowExecutionDataProcess['source'],
|
||||||
|
{ executedInThisProcess = true }: { executedInThisProcess?: boolean } = {},
|
||||||
|
) {
|
||||||
const handlers = Container.get(LifecycleMetadata).getHandlers();
|
const handlers = Container.get(LifecycleMetadata).getHandlers();
|
||||||
|
|
||||||
for (const { handlerClass, methodName, eventName } of handlers) {
|
for (const { handlerClass, methodName, eventName } of handlers) {
|
||||||
|
|
@ -72,6 +77,7 @@ class ModulesHooksRegistry {
|
||||||
executionId: this.executionId,
|
executionId: this.executionId,
|
||||||
retryOf: this.retryOf,
|
retryOf: this.retryOf,
|
||||||
source,
|
source,
|
||||||
|
executedInThisProcess,
|
||||||
};
|
};
|
||||||
|
|
||||||
return await instance[methodName].call(instance, context);
|
return await instance[methodName].call(instance, context);
|
||||||
|
|
@ -977,7 +983,7 @@ export function getLifecycleHooksForScalingMain(
|
||||||
hooks.handlers.nodeExecuteBefore = [];
|
hooks.handlers.nodeExecuteBefore = [];
|
||||||
hooks.handlers.nodeExecuteAfter = [];
|
hooks.handlers.nodeExecuteAfter = [];
|
||||||
|
|
||||||
Container.get(ModulesHooksRegistry).addHooks(hooks, source);
|
Container.get(ModulesHooksRegistry).addHooks(hooks, source, { executedInThisProcess: false });
|
||||||
|
|
||||||
return hooks;
|
return hooks;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -396,6 +396,19 @@ describe('ExecutionService', () => {
|
||||||
const erroredRun = (error: boolean) =>
|
const erroredRun = (error: boolean) =>
|
||||||
[{ error: error ? new Error('boom') : undefined }] as unknown as ITaskData[];
|
[{ error: error ? new Error('boom') : undefined }] as unknown as ITaskData[];
|
||||||
|
|
||||||
|
it('should mark the retry as awaited to completion', async () => {
|
||||||
|
const { service, workflowRunner } = buildRetryService();
|
||||||
|
executionPersistence.findWithUnflattenedData.mockResolvedValue(
|
||||||
|
buildCrashedExecution({ lastNodeExecuted: 'Some Node', runData: {} }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.retry(retryArgs());
|
||||||
|
|
||||||
|
expect(workflowRunner.run).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ callerAwaitsOutcome: 'completion' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should not throw when retrying a crashed execution whose runData is undefined', async () => {
|
it('should not throw when retrying a crashed execution whose runData is undefined', async () => {
|
||||||
const { service, workflowRunner } = buildRetryService();
|
const { service, workflowRunner } = buildRetryService();
|
||||||
executionPersistence.findWithUnflattenedData.mockResolvedValue(
|
executionPersistence.findWithUnflattenedData.mockResolvedValue(
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,8 @@ export class ExecutionService {
|
||||||
retryOf: executionId,
|
retryOf: executionId,
|
||||||
workflowData: execution.workflowData,
|
workflowData: execution.workflowData,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
// The retry caller awaits the result below, so a worker must not park it.
|
||||||
|
callerAwaitsOutcome: 'completion',
|
||||||
};
|
};
|
||||||
|
|
||||||
const { lastNodeExecuted } = data.executionData!.resultData;
|
const { lastNodeExecuted } = data.executionData!.resultData;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,8 +171,12 @@ describe('ChatHubExecutionWatcherService', () => {
|
||||||
...overrides,
|
...overrides,
|
||||||
}) as IRun;
|
}) as IRun;
|
||||||
|
|
||||||
const createAfterContext = (executionId: string, runData: IRun): WorkflowExecuteAfterContext =>
|
const createAfterContext = (
|
||||||
({ executionId, runData }) as WorkflowExecuteAfterContext;
|
executionId: string,
|
||||||
|
runData: IRun,
|
||||||
|
{ executedInThisProcess = true }: { executedInThisProcess?: boolean } = {},
|
||||||
|
): WorkflowExecuteAfterContext =>
|
||||||
|
({ executionId, runData, executedInThisProcess }) as WorkflowExecuteAfterContext;
|
||||||
|
|
||||||
it('should skip if context not found', async () => {
|
it('should skip if context not found', async () => {
|
||||||
executionStore.get.mockResolvedValue(null);
|
executionStore.get.mockResolvedValue(null);
|
||||||
|
|
@ -457,9 +461,50 @@ describe('ChatHubExecutionWatcherService', () => {
|
||||||
createMessageOnResume: true,
|
createMessageOnResume: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle a segment once when the enqueuing process also reports it', async () => {
|
||||||
|
let stored = createContext({ responseMode: 'responseNodes' });
|
||||||
|
executionStore.get.mockImplementation(async () => stored);
|
||||||
|
executionStore.update.mockImplementation(async (_executionId, updates) => {
|
||||||
|
stored = { ...stored, ...updates };
|
||||||
|
});
|
||||||
|
chatHubExecutionService.extractMessage.mockReturnValue('Found the incident.');
|
||||||
|
executionPersistence.findSingleExecution.mockResolvedValue(
|
||||||
|
createExecution('@n8n/n8n-nodes-langchain.chat', { operation: 'send' }),
|
||||||
|
);
|
||||||
|
const runData = createRunData({ status: 'waiting' });
|
||||||
|
|
||||||
|
await service.handleWorkflowExecuteAfter(createAfterContext(EXECUTION_ID, runData));
|
||||||
|
await service.handleWorkflowExecuteAfter(
|
||||||
|
createAfterContext(EXECUTION_ID, runData, { executedInThisProcess: false }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(messageRepository.createChatMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(chatStreamService.endExecution).toHaveBeenCalledTimes(1);
|
||||||
|
expect(executionManager.runWorkflow).toHaveBeenCalledTimes(1);
|
||||||
|
expect(messageRepository.updateChatMessage).not.toHaveBeenCalledWith(
|
||||||
|
MOCK_NEW_MESSAGE_ID,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should ignore a call from a process that only enqueued the run', async () => {
|
||||||
|
executionStore.get.mockResolvedValue(createContext({ responseMode: 'responseNodes' }));
|
||||||
|
|
||||||
|
await service.handleWorkflowExecuteAfter(
|
||||||
|
createAfterContext(EXECUTION_ID, createRunData({ status: 'waiting' }), {
|
||||||
|
executedInThisProcess: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(executionStore.get).not.toHaveBeenCalled();
|
||||||
|
expect(messageRepository.updateChatMessage).not.toHaveBeenCalled();
|
||||||
|
expect(chatStreamService.endExecution).not.toHaveBeenCalled();
|
||||||
|
expect(executionManager.runWorkflow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should not remove context when finished is false', async () => {
|
it('should not remove context when finished is false', async () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
executionStore.get.mockResolvedValue(context);
|
executionStore.get.mockResolvedValue(context);
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
|
|
|
||||||
|
|
@ -1483,6 +1483,7 @@ describe('chatHub', () => {
|
||||||
runData,
|
runData,
|
||||||
newStaticData: {},
|
newStaticData: {},
|
||||||
executionId,
|
executionId,
|
||||||
|
executedInThisProcess: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1601,6 +1602,7 @@ describe('chatHub', () => {
|
||||||
runData,
|
runData,
|
||||||
newStaticData: {},
|
newStaticData: {},
|
||||||
executionId,
|
executionId,
|
||||||
|
executedInThisProcess: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1733,6 +1735,7 @@ describe('chatHub', () => {
|
||||||
runData,
|
runData,
|
||||||
newStaticData: {},
|
newStaticData: {},
|
||||||
executionId,
|
executionId,
|
||||||
|
executedInThisProcess: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1874,6 +1877,7 @@ describe('chatHub', () => {
|
||||||
runData,
|
runData,
|
||||||
newStaticData: {},
|
newStaticData: {},
|
||||||
executionId,
|
executionId,
|
||||||
|
executedInThisProcess: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1930,6 +1934,7 @@ describe('chatHub', () => {
|
||||||
runData,
|
runData,
|
||||||
newStaticData: {},
|
newStaticData: {},
|
||||||
executionId: capturedExecutionId,
|
executionId: capturedExecutionId,
|
||||||
|
executedInThisProcess: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -2064,6 +2069,7 @@ describe('chatHub', () => {
|
||||||
runData,
|
runData,
|
||||||
newStaticData: {},
|
newStaticData: {},
|
||||||
executionId,
|
executionId,
|
||||||
|
executedInThisProcess: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2205,6 +2211,7 @@ describe('chatHub', () => {
|
||||||
runData,
|
runData,
|
||||||
newStaticData: {},
|
newStaticData: {},
|
||||||
executionId,
|
executionId,
|
||||||
|
executedInThisProcess: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2298,6 +2305,7 @@ describe('chatHub', () => {
|
||||||
runData,
|
runData,
|
||||||
newStaticData: {},
|
newStaticData: {},
|
||||||
executionId: capturedExecutionId,
|
executionId: capturedExecutionId,
|
||||||
|
executedInThisProcess: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -2449,6 +2457,7 @@ describe('chatHub', () => {
|
||||||
runData,
|
runData,
|
||||||
newStaticData: {},
|
newStaticData: {},
|
||||||
executionId,
|
executionId,
|
||||||
|
executedInThisProcess: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2658,6 +2667,7 @@ describe('chatHub', () => {
|
||||||
runData,
|
runData,
|
||||||
newStaticData: {},
|
newStaticData: {},
|
||||||
executionId,
|
executionId,
|
||||||
|
executedInThisProcess: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2793,6 +2803,7 @@ describe('chatHub', () => {
|
||||||
runData,
|
runData,
|
||||||
newStaticData: {},
|
newStaticData: {},
|
||||||
executionId,
|
executionId,
|
||||||
|
executedInThisProcess: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2909,6 +2920,7 @@ describe('chatHub', () => {
|
||||||
runData,
|
runData,
|
||||||
newStaticData: {},
|
newStaticData: {},
|
||||||
executionId,
|
executionId,
|
||||||
|
executedInThisProcess: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3025,6 +3037,7 @@ describe('chatHub', () => {
|
||||||
runData,
|
runData,
|
||||||
newStaticData: {},
|
newStaticData: {},
|
||||||
executionId,
|
executionId,
|
||||||
|
executedInThisProcess: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,11 @@ export class ChatHubExecutionWatcherService {
|
||||||
async handleWorkflowExecuteAfter(ctx: WorkflowExecuteAfterContext): Promise<void> {
|
async handleWorkflowExecuteAfter(ctx: WorkflowExecuteAfterContext): Promise<void> {
|
||||||
const { runData, executionId } = ctx;
|
const { runData, executionId } = ctx;
|
||||||
|
|
||||||
|
// An auto-resume enqueues the next segment from the worker, so that worker also waits
|
||||||
|
// for the job and gets a second call for the same segment. The process that ran the
|
||||||
|
// segment handles it.
|
||||||
|
if (!ctx.executedInThisProcess) return;
|
||||||
|
|
||||||
const context = await this.executionStore.get(executionId);
|
const context = await this.executionStore.get(executionId);
|
||||||
if (!context) return; // Not a tracked chat hub execution
|
if (!context) return; // Not a tracked chat hub execution
|
||||||
|
|
||||||
|
|
@ -144,10 +149,7 @@ export class ChatHubExecutionWatcherService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: This check is required because on multi-main/queue mode, the resumed execution's
|
// A `success` run that has not finished must not end the conversation.
|
||||||
// 'workflowExecuteAfter' hook fires one more time after it should have stopped in 'waiting'
|
|
||||||
// state on a Chat response node. On this final hook call the runData.status is 'success' even though
|
|
||||||
// the execution isn't finished. On single-main mode this does not happen.
|
|
||||||
// oxlint-disable-next-line typescript/no-deprecated
|
// oxlint-disable-next-line typescript/no-deprecated
|
||||||
if (runData.finished) {
|
if (runData.finished) {
|
||||||
await this.pushFinalResults(context, message);
|
await this.pushFinalResults(context, message);
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Logger } from '@n8n/backend-common';
|
||||||
import { mockInstance } from '@n8n/backend-test-utils';
|
import { mockInstance } from '@n8n/backend-test-utils';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { InstanceSettings } from 'n8n-core';
|
import { InstanceSettings } from 'n8n-core';
|
||||||
import { N8N_NODES_API_VERSION } from 'n8n-workflow';
|
import { N8N_NODES_API_VERSION, parseNodesApiLevel } from 'n8n-workflow';
|
||||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
@ -305,9 +305,10 @@ describe('node API compatibility at startup', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('boots with an incompatible package on disk and registers no loader for it', async () => {
|
test('boots with an incompatible package on disk and registers no loader for it', async () => {
|
||||||
|
const { major, minor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
|
||||||
writePackage('n8n-nodes-future', {
|
writePackage('n8n-nodes-future', {
|
||||||
nodes: ['dist/nodes/Future.node.js'],
|
nodes: ['dist/nodes/Future.node.js'],
|
||||||
n8nNodesApiVersion: N8N_NODES_API_VERSION + 1,
|
n8nNodesApiVersion: `${major}.${minor + 1}`,
|
||||||
});
|
});
|
||||||
writePackage('n8n-nodes-good');
|
writePackage('n8n-nodes-good');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -310,7 +310,7 @@ describe('CommunityPackagesLifecycleService', () => {
|
||||||
communityPackagesService.installPackage.mockRejectedValue(
|
communityPackagesService.installPackage.mockRejectedValue(
|
||||||
new IncompatibleNodesApiVersionError(
|
new IncompatibleNodesApiVersionError(
|
||||||
'This community node requires n8n node API version 3, but this instance supports up to 1.',
|
'This community node requires n8n node API version 3, but this instance supports up to 1.',
|
||||||
{ requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
|
{ requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -318,7 +318,7 @@ describe('CommunityPackagesLifecycleService', () => {
|
||||||
await expect(promise).rejects.toBeInstanceOf(IncompatibleNodesApiVersionError);
|
await expect(promise).rejects.toBeInstanceOf(IncompatibleNodesApiVersionError);
|
||||||
await expect(promise).rejects.toMatchObject({
|
await expect(promise).rejects.toMatchObject({
|
||||||
httpStatusCode: 400,
|
httpStatusCode: 400,
|
||||||
meta: { requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
|
meta: { requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
|
||||||
message:
|
message:
|
||||||
'This community node requires n8n node API version 3, but this instance supports up to 1.',
|
'This community node requires n8n node API version 3, but this instance supports up to 1.',
|
||||||
});
|
});
|
||||||
|
|
@ -666,7 +666,7 @@ describe('CommunityPackagesLifecycleService', () => {
|
||||||
communityPackagesService.updatePackage.mockRejectedValue(
|
communityPackagesService.updatePackage.mockRejectedValue(
|
||||||
new IncompatibleNodesApiVersionError(
|
new IncompatibleNodesApiVersionError(
|
||||||
"This community node isn't compatible with your version of n8n. Update n8n to use it.",
|
"This community node isn't compatible with your version of n8n. Update n8n to use it.",
|
||||||
{ requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
|
{ requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -678,7 +678,7 @@ describe('CommunityPackagesLifecycleService', () => {
|
||||||
await expect(promise).rejects.toBeInstanceOf(IncompatibleNodesApiVersionError);
|
await expect(promise).rejects.toBeInstanceOf(IncompatibleNodesApiVersionError);
|
||||||
await expect(promise).rejects.toMatchObject({
|
await expect(promise).rejects.toMatchObject({
|
||||||
httpStatusCode: 400,
|
httpStatusCode: 400,
|
||||||
meta: { requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
|
meta: { requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -691,8 +691,8 @@ describe('CommunityPackagesLifecycleService', () => {
|
||||||
communityPackagesService.findInstalledPackage.mockResolvedValue(mockPackage('1.0.0'));
|
communityPackagesService.findInstalledPackage.mockResolvedValue(mockPackage('1.0.0'));
|
||||||
communityPackagesService.updatePackage.mockRejectedValue(
|
communityPackagesService.updatePackage.mockRejectedValue(
|
||||||
new IncompatibleNodesApiVersionError('Not compatible', {
|
new IncompatibleNodesApiVersionError('Not compatible', {
|
||||||
requiredNodesApiVersion: 3,
|
requiredNodesApiVersion: '3',
|
||||||
supportedNodesApiVersion: 1,
|
supportedNodesApiVersion: '1',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
|
||||||
import { mockInstance } from '@n8n/backend-test-utils';
|
import { mockInstance } from '@n8n/backend-test-utils';
|
||||||
import type { InstanceSettings, PackageDirectoryLoader } from 'n8n-core';
|
import type { InstanceSettings, PackageDirectoryLoader } from 'n8n-core';
|
||||||
import { N8N_NODES_API_VERSION } from '@n8n/constants';
|
import { N8N_NODES_API_VERSION } from '@n8n/constants';
|
||||||
|
import { parseNodesApiLevel } from 'n8n-workflow';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
|
@ -34,6 +35,9 @@ vi.mock('../npm-utils', async () => ({
|
||||||
const PACKAGE_NAME = 'n8n-nodes-test';
|
const PACKAGE_NAME = 'n8n-nodes-test';
|
||||||
const TARBALL_NAME = `${PACKAGE_NAME}-2.0.0.tgz`;
|
const TARBALL_NAME = `${PACKAGE_NAME}-2.0.0.tgz`;
|
||||||
|
|
||||||
|
const { major: supportedMajor, minor: supportedMinor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
|
||||||
|
const oneMinorAboveSupported = `${supportedMajor}.${supportedMinor + 1}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exercises the install rollback against a real filesystem rather than mocked `fs`
|
* Exercises the install rollback against a real filesystem rather than mocked `fs`
|
||||||
* calls. The bug being guarded here is a directory that is deleted with no backup
|
* calls. The bug being guarded here is a directory that is deleted with no backup
|
||||||
|
|
@ -264,7 +268,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
test('rejects an update and leaves directory, ledger, and database unchanged', async () => {
|
test('rejects an update and leaves directory, ledger, and database unchanged', async () => {
|
||||||
await expect(updateToIncompatible(N8N_NODES_API_VERSION + 1)).rejects.toThrow(
|
await expect(updateToIncompatible(oneMinorAboveSupported)).rejects.toThrow(
|
||||||
"isn't compatible with your version of n8n",
|
"isn't compatible with your version of n8n",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -284,7 +288,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects an update with a malformed declared version', async () => {
|
test('rejects an update with a malformed declared version', async () => {
|
||||||
await expect(updateToIncompatible('3')).rejects.toThrow('invalid n8n node API version');
|
await expect(updateToIncompatible(3.1)).rejects.toThrow('invalid n8n node API version');
|
||||||
|
|
||||||
expect(existsSync(markerPath())).toBe(true);
|
expect(existsSync(markerPath())).toBe(true);
|
||||||
expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled();
|
expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled();
|
||||||
|
|
@ -314,7 +318,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
|
||||||
downloadedPackageJson = {
|
downloadedPackageJson = {
|
||||||
name: PACKAGE_NAME,
|
name: PACKAGE_NAME,
|
||||||
version: '2.0.0',
|
version: '2.0.0',
|
||||||
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 },
|
n8n: { n8nNodesApiVersion: oneMinorAboveSupported },
|
||||||
};
|
};
|
||||||
|
|
||||||
await expect(communityPackagesService.installPackage(PACKAGE_NAME)).rejects.toThrow(
|
await expect(communityPackagesService.installPackage(PACKAGE_NAME)).rejects.toThrow(
|
||||||
|
|
@ -334,7 +338,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
|
||||||
downloadedPackageJson = {
|
downloadedPackageJson = {
|
||||||
name: PACKAGE_NAME,
|
name: PACKAGE_NAME,
|
||||||
version: '2.0.0',
|
version: '2.0.0',
|
||||||
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 },
|
n8n: { n8nNodesApiVersion: oneMinorAboveSupported },
|
||||||
};
|
};
|
||||||
// The follower resolves the version to install from the leader's database record.
|
// The follower resolves the version to install from the leader's database record.
|
||||||
installedPackageRepository.findOne.mockResolvedValue(
|
installedPackageRepository.findOne.mockResolvedValue(
|
||||||
|
|
@ -359,7 +363,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
|
||||||
packageName: PACKAGE_NAME,
|
packageName: PACKAGE_NAME,
|
||||||
reason: expect.stringContaining("isn't compatible with your version of n8n"),
|
reason: expect.stringContaining("isn't compatible with your version of n8n"),
|
||||||
// The operator log names both versions, unlike the user-facing message.
|
// The operator log names both versions, unlike the user-facing message.
|
||||||
requiredNodesApiVersion: N8N_NODES_API_VERSION + 1,
|
requiredNodesApiVersion: oneMinorAboveSupported,
|
||||||
supportedNodesApiVersion: N8N_NODES_API_VERSION,
|
supportedNodesApiVersion: N8N_NODES_API_VERSION,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
|
||||||
import { mockInstance, randomName } from '@n8n/backend-test-utils';
|
import { mockInstance, randomName } from '@n8n/backend-test-utils';
|
||||||
import { LICENSE_FEATURES } from '@n8n/constants';
|
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||||
import type { InstanceSettings, PackageDirectoryLoader } from 'n8n-core';
|
import type { InstanceSettings, PackageDirectoryLoader } from 'n8n-core';
|
||||||
import { N8N_NODES_API_VERSION, type PublicInstalledPackage } from 'n8n-workflow';
|
import {
|
||||||
|
N8N_NODES_API_VERSION,
|
||||||
|
parseNodesApiLevel,
|
||||||
|
type PublicInstalledPackage,
|
||||||
|
} from 'n8n-workflow';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||||
import path, { join } from 'node:path';
|
import path, { join } from 'node:path';
|
||||||
|
|
@ -52,6 +56,9 @@ const execMock: typeof execFile = ((...args) => {
|
||||||
|
|
||||||
vi.mocked(execFile).mockImplementation(execMock);
|
vi.mocked(execFile).mockImplementation(execMock);
|
||||||
|
|
||||||
|
const { major: supportedMajor, minor: supportedMinor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
|
||||||
|
const oneMinorAboveSupported = `${supportedMajor}.${supportedMinor + 1}`;
|
||||||
|
|
||||||
describe('CommunityPackagesService', () => {
|
describe('CommunityPackagesService', () => {
|
||||||
const license = mock<License>();
|
const license = mock<License>();
|
||||||
const configDefaults = {
|
const configDefaults = {
|
||||||
|
|
@ -675,7 +682,7 @@ describe('CommunityPackagesService', () => {
|
||||||
test('should reject the update when the package requires a newer node API version', async () => {
|
test('should reject the update when the package requires a newer node API version', async () => {
|
||||||
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
|
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
|
||||||
|
|
||||||
await expect(updateToIncompatible(N8N_NODES_API_VERSION + 1)).rejects.toThrow(
|
await expect(updateToIncompatible(oneMinorAboveSupported)).rejects.toThrow(
|
||||||
"This community node isn't compatible with your version of n8n. Update n8n to use it.",
|
"This community node isn't compatible with your version of n8n. Update n8n to use it.",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -687,7 +694,7 @@ describe('CommunityPackagesService', () => {
|
||||||
test('should reject the update when the declared node API version is malformed', async () => {
|
test('should reject the update when the declared node API version is malformed', async () => {
|
||||||
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
|
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
|
||||||
|
|
||||||
await expect(updateToIncompatible('3')).rejects.toThrow('invalid n8n node API version');
|
await expect(updateToIncompatible(3.1)).rejects.toThrow('invalid n8n node API version');
|
||||||
|
|
||||||
expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled();
|
expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled();
|
||||||
expect(installedPackageRepository.replaceInstalledPackageWithNodes).not.toHaveBeenCalled();
|
expect(installedPackageRepository.replaceInstalledPackageWithNodes).not.toHaveBeenCalled();
|
||||||
|
|
@ -701,6 +708,16 @@ describe('CommunityPackagesService', () => {
|
||||||
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(PACKAGE_NAME);
|
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(PACKAGE_NAME);
|
||||||
expect(installedPackageRepository.replaceInstalledPackageWithNodes).toHaveBeenCalled();
|
expect(installedPackageRepository.replaceInstalledPackageWithNodes).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should update to a package that declares the supported level with an explicit minor', async () => {
|
||||||
|
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
|
||||||
|
|
||||||
|
await expect(updateToIncompatible(`${supportedMajor}.${supportedMinor}`)).resolves.toBe(
|
||||||
|
packageAfterUpdate,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(PACKAGE_NAME);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should remove the package.json dependency when a fresh install fails', async () => {
|
test('should remove the package.json dependency when a fresh install fails', async () => {
|
||||||
|
|
@ -1418,7 +1435,7 @@ describe('CommunityPackagesService', () => {
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
name: 'package-1',
|
name: 'package-1',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 },
|
n8n: { n8nNodesApiVersion: oneMinorAboveSupported },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1438,7 +1455,7 @@ describe('CommunityPackagesService', () => {
|
||||||
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
|
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
|
||||||
config.reinstallMissing = true;
|
config.reinstallMissing = true;
|
||||||
vi.mocked(readFile).mockResolvedValue(
|
vi.mocked(readFile).mockResolvedValue(
|
||||||
JSON.stringify({ name: 'package-1', version: '1.0.0', n8n: { n8nNodesApiVersion: '3' } }),
|
JSON.stringify({ name: 'package-1', version: '1.0.0', n8n: { n8nNodesApiVersion: 3.1 } }),
|
||||||
);
|
);
|
||||||
|
|
||||||
await communityPackagesService.checkForMissingPackages();
|
await communityPackagesService.checkForMissingPackages();
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ export async function getCommunityNodeTypes(
|
||||||
environment: 'staging' | 'production',
|
environment: 'staging' | 'production',
|
||||||
qs: { filters?: StrapiFilters; fields?: string[] } = {},
|
qs: { filters?: StrapiFilters; fields?: string[] } = {},
|
||||||
maxAiNodeSdk: number,
|
maxAiNodeSdk: number,
|
||||||
maxN8nNodesApiVersion: number,
|
maxN8nNodesApiVersion: string,
|
||||||
): Promise<StrapiCommunityNodeType[]> {
|
): Promise<StrapiCommunityNodeType[]> {
|
||||||
const url = getUrl(environment);
|
const url = getUrl(environment);
|
||||||
const params = {
|
const params = {
|
||||||
|
|
@ -78,7 +78,7 @@ export async function getCommunityNodeTypes(
|
||||||
export async function getCommunityNodesMetadata(
|
export async function getCommunityNodesMetadata(
|
||||||
environment: 'staging' | 'production',
|
environment: 'staging' | 'production',
|
||||||
maxAiNodeSdk: number,
|
maxAiNodeSdk: number,
|
||||||
maxN8nNodesApiVersion: number,
|
maxN8nNodesApiVersion: string,
|
||||||
): Promise<CommunityNodesMetadata[]> {
|
): Promise<CommunityNodesMetadata[]> {
|
||||||
const url = getUrl(environment);
|
const url = getUrl(environment);
|
||||||
const params = {
|
const params = {
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export class CommunityPackagesConfig {
|
||||||
readonly aiNodeSdkVersion: number = AI_NODE_SDK_VERSION;
|
readonly aiNodeSdkVersion: number = AI_NODE_SDK_VERSION;
|
||||||
|
|
||||||
/** Highest community node API version this instance supports, sent to Strapi API */
|
/** Highest community node API version this instance supports, sent to Strapi API */
|
||||||
readonly nodesApiVersion: number = N8N_NODES_API_VERSION;
|
readonly nodesApiVersion: string = N8N_NODES_API_VERSION;
|
||||||
|
|
||||||
@Env('N8N_DISABLED_MODULES')
|
@Env('N8N_DISABLED_MODULES')
|
||||||
private disabledModules: CommaSeparatedStringArray<string> = [];
|
private disabledModules: CommaSeparatedStringArray<string> = [];
|
||||||
|
|
|
||||||
|
|
@ -304,14 +304,17 @@ export class CommunityPackagesService {
|
||||||
const check = checkNodesApiVersion(packageJson);
|
const check = checkNodesApiVersion(packageJson);
|
||||||
if (check.compatible) return;
|
if (check.compatible) return;
|
||||||
|
|
||||||
const isMalformed = check.reason === 'malformed';
|
if (check.reason === 'malformed') {
|
||||||
|
throw new IncompatibleNodesApiVersionError(
|
||||||
|
`This community node declares an invalid n8n node API version (${JSON.stringify(check.declared)}). Install a version of the package with valid metadata or contact the package author.`,
|
||||||
|
{ requiredNodesApiVersion: null, supportedNodesApiVersion: N8N_NODES_API_VERSION },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
throw new IncompatibleNodesApiVersionError(
|
throw new IncompatibleNodesApiVersionError(
|
||||||
isMalformed
|
"This community node isn't compatible with your version of n8n. Update n8n to use it.",
|
||||||
? `This community node declares an invalid n8n node API version (${JSON.stringify(check.declared)}). Install a version of the package with valid metadata or contact the package author.`
|
|
||||||
: "This community node isn't compatible with your version of n8n. Update n8n to use it.",
|
|
||||||
{
|
{
|
||||||
requiredNodesApiVersion: isMalformed ? null : Number(check.declared),
|
requiredNodesApiVersion: check.required,
|
||||||
supportedNodesApiVersion: N8N_NODES_API_VERSION,
|
supportedNodesApiVersion: N8N_NODES_API_VERSION,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -370,7 +373,7 @@ export class CommunityPackagesService {
|
||||||
const requirement =
|
const requirement =
|
||||||
apiVersionCheck.reason === 'malformed'
|
apiVersionCheck.reason === 'malformed'
|
||||||
? `an invalid n8nNodesApiVersion (${JSON.stringify(apiVersionCheck.declared)})`
|
? `an invalid n8nNodesApiVersion (${JSON.stringify(apiVersionCheck.declared)})`
|
||||||
: `node API version ${String(apiVersionCheck.declared)}, but this n8n version supports up to ${N8N_NODES_API_VERSION}`;
|
: `node API version ${apiVersionCheck.required}, but this n8n version supports up to ${N8N_NODES_API_VERSION}`;
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Not reinstalling package "${installedPackage.packageName}": it requires ${requirement}. Upgrade n8n to use this package, or uninstall it in Settings > Community nodes.`,
|
`Not reinstalling package "${installedPackage.packageName}": it requires ${requirement}. Upgrade n8n to use this package, or uninstall it in Settings > Community 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 () => {
|
||||||
|
|
|
||||||
|
|
@ -5191,6 +5191,28 @@ describe('createExecutionAdapter run()', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks a trigger-mode run as awaited so a worker never parks it at shutdown', async () => {
|
||||||
|
const { adapter, mockWorkflowRunner } = createRunAdapterForTests({
|
||||||
|
id: 'wf-1',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'n1',
|
||||||
|
name: 'Schedule',
|
||||||
|
type: 'n8n-nodes-base.scheduleTrigger',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connections: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await adapter.run('wf-1');
|
||||||
|
|
||||||
|
const runData = mockWorkflowRunner.run.mock.calls[0][0];
|
||||||
|
expect(runData.executionMode).toBe('trigger');
|
||||||
|
expect(runData.callerAwaitsOutcome).toBe('completion');
|
||||||
|
});
|
||||||
|
|
||||||
it('still applies overrides when the workflow has no settings', async () => {
|
it('still applies overrides when the workflow has no settings', async () => {
|
||||||
const { adapter, mockWorkflowRunner } = createRunAdapterForTests({
|
const { adapter, mockWorkflowRunner } = createRunAdapterForTests({
|
||||||
id: 'wf-1',
|
id: 'wf-1',
|
||||||
|
|
|
||||||
|
|
@ -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 ───────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -2149,6 +2149,9 @@ export class InstanceAiAdapterService {
|
||||||
},
|
},
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
pushRef,
|
pushRef,
|
||||||
|
// The agent awaits this run's result and prunes its pins afterwards, so
|
||||||
|
// a worker must not park a trigger-mode run at shutdown.
|
||||||
|
callerAwaitsOutcome: 'completion',
|
||||||
// A verification run picks a production execution mode above so the
|
// A verification run picks a production execution mode above so the
|
||||||
// trigger behaves realistically. Without this opt-out, a failed build
|
// trigger behaves realistically. Without this opt-out, a failed build
|
||||||
// attempt would dispatch the user's error workflow as if production
|
// attempt would dispatch the user's error workflow as if production
|
||||||
|
|
@ -4237,9 +4240,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 +4266,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 +5354,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';
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user