Compare commits

...

11 Commits

Author SHA1 Message Date
n8n-assistant[bot]
17f9c4a715
🚀 Release 2.43.3 (#40736)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-10-09 07:14:00 +00:00
n8n-assistant[bot]
3c4f6e3393
feat(core): Block create/update of workflows with deprecated nodes (backport to release-candidate/2.43.x) (#40706)
Co-authored-by: mfsiega <93014743+mfsiega@users.noreply.github.com>
Co-authored-by: Emilia <100027345+sovietspaceship@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:54:55 +00:00
n8n-assistant[bot]
1830ccfcda
feat(Google Gemini Node): Show Nano Banana 2.1 and 2 Lite in the image model list (backport to release-candidate/2.43.x) (#40655)
Co-authored-by: Alexander Gekov <40495748+alexander-gekov@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 10:29:03 +00:00
n8n-assistant[bot]
6ab34a4a42
🚀 Release 2.43.2 (#40631)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-10-08 07:10:33 +00:00
n8n-assistant[bot]
3ee8d7aeef
fix(core): Require execution delete scope for assistant execution cleanup (no-changelog) (backport to release-candidate/2.43.x) (#40562)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:41:01 +00:00
n8n-assistant[bot]
509a3f36a8
fix(core): Fix Kimi K3 and other OpenAI-compatible Assistant model providers on self-hosted Assistant (backport to release-candidate/2.43.x) (#40552)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:54:39 +00:00
n8n-assistant[bot]
204e2ddf17
fix(editor): Show the Agent artifact while the builder creates it (backport to release-candidate/2.43.x) (#40550)
Co-authored-by: Anne Aguirre <anne.aguirre@n8n.io>
2026-10-07 13:36:18 +00:00
n8n-assistant[bot]
b553bca31e
🚀 Release 2.43.1 (#40476)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-10-07 07:09:16 +00:00
n8n-assistant[bot]
fda8dac7d8
perf(core): Prepare Instance AI skill sandbox in the background (backport to release-candidate/2.43.x) (#40442)
Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
2026-10-06 16:35:35 +00:00
n8n-assistant[bot]
9ea78350fa
perf(core): Keep agent builder system prompt static for prompt caching (backport to release-candidate/2.43.x) (#40441)
Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
2026-10-06 16:35:17 +00:00
n8n-assistant[bot]
345ae0100c
fix(editor): Polish n8n Assistant and Execute button UI details (backport to release-candidate/2.43.x) (#40438)
Co-authored-by: Tuukka Kantola <Tuukkaa@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:59:57 +00:00
123 changed files with 2611 additions and 176 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4237,9 +4237,8 @@ export class InstanceAiAdapterService {
options?: { olderThanHours?: number },
): Promise<{ deletedCount: number }> {
assertNotReadOnly('executions');
// Access-check the workflow with execute scope (matches controller behavior)
const workflow = await workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:execute',
'execution:delete',
]);
if (!workflow) {
throw new WorkflowNotFoundError(workflowId);
@ -4264,12 +4263,15 @@ export class InstanceAiAdapterService {
const ids = executions.map((e) => e.id);
// Use the canonical deletion pipeline (handles binary data and fs blobs)
await executionPersistence.hardDeleteBy({
filters: { workflowId, mode: 'manual' },
accessibleWorkflowIds: [workflowId],
deleteConditions: { deleteBefore: cutoff },
});
// Use the canonical deletion pipeline (handles binary data and fs blobs).
// Delete by ID so only the counted manual executions are removed.
for (let start = 0; start < ids.length; start += EXECUTION_DELETE_CHUNK_SIZE) {
await executionPersistence.hardDeleteBy({
filters: undefined,
accessibleWorkflowIds: [workflowId],
deleteConditions: { ids: ids.slice(start, start + EXECUTION_DELETE_CHUNK_SIZE) },
});
}
// Emit audit event (matches controller behavior)
eventService.emit('execution-deleted', {
@ -5349,6 +5351,10 @@ function readParentFolder(workflow: WorkflowEntity): { id: string; name: string
return parent ? { id: parent.id, name: parent.name } : undefined;
}
/** Execution ids per deletion query. The query binds one parameter per id
* and SQLite allows 999 of them, so the ids are deleted in chunks. */
const EXECUTION_DELETE_CHUNK_SIZE = 500;
/** Folder ids per path query. `getFolderPathsToRoot` binds one parameter per id
* and SQLite allows 999 of them, so the ids are read in chunks. */
const FOLDER_PATH_CHUNK_SIZE = 500;

View File

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

View File

@ -61,6 +61,7 @@ import { isUniqueConstraintError } from '@/response-helper';
import { TagService } from '@/services/tag.service';
import { assertNever } from '@/utils';
import { validateWorkflowNodeGroups, sanitizeNodeGroupDescriptions } from '@/workflow-helpers';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
import { WorkflowMutationHooksProxy } from '@/workflows/workflow-mutation-hooks-proxy.service';
@ -173,6 +174,7 @@ export class SourceControlImportService {
private readonly workflowPublishGuard: WorkflowPublishGuardProxy,
private readonly workflowMutationHooks: WorkflowMutationHooksProxy,
private readonly workflowFinderService: WorkflowFinderService,
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
) {
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
this.workflowExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER);
@ -783,7 +785,7 @@ export class SourceControlImportService {
const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(userId);
const candidateIds = candidates.map((c) => c.id);
const existingWorkflows = await this.workflowRepository.findByIds(candidateIds, {
fields: ['id', 'name', 'versionId', 'active', 'activeVersionId', 'isArchived'],
fields: ['id', 'name', 'versionId', 'active', 'activeVersionId', 'isArchived', 'nodes'],
});
const folders = await this.folderRepository.find({ select: ['id'] });
@ -857,6 +859,19 @@ export class SourceControlImportService {
}
const existingWorkflow = existingWorkflows.find((e) => e.id === id);
// Apply the same deprecated-node enforcement as the REST/public API
// endpoints. A re-sync that leaves the deprecated nodes untouched is
// allowed; introducing or in-place editing them through git is not.
if (existingWorkflow) {
this.deprecatedNodesValidationService.validateOnUpdate(
nodes,
existingWorkflow.nodes ?? [],
id,
);
} else {
this.deprecatedNodesValidationService.validateOnCreate(nodes, id);
}
await this.redactionEnforcementService.assertPolicyChangeAllowed(
existingWorkflow?.settings?.redactionPolicy,
importedWorkflow.settings?.redactionPolicy,

View File

@ -641,6 +641,7 @@ describe('auto-close on source-control pull', () => {
mock(), // workflowPublishGuard
Container.get(WorkflowMutationHooksProxy),
Container.get(WorkflowFinderService),
mock(),
);
});

View File

@ -1,5 +1,6 @@
import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import type { NodesConfig } from '@n8n/config';
import { WorkflowRepository } from '@n8n/db';
import type { User } from '@n8n/db';
import { InstanceSettings } from 'n8n-core';
@ -11,7 +12,9 @@ import { CredentialsFinderService } from '@n8n/backend-services';
import { BadRequestError, ForbiddenError } from '@n8n/errors';
import { ExecutionPersistence } from '@/executions/execution-persistence';
import { NodeTypes } from '@/node-types';
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
import { WorkflowRunner } from '@/workflow-runner';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import { ExecuteNodeService } from '../execute-node.service';
import type { ExecuteNodeRequest } from '../execute-node.service';
@ -54,6 +57,11 @@ describe('ExecuteNodeService', () => {
const activeExecutions = mockInstance(ActiveExecutions);
const executionPersistence = mockInstance(ExecutionPersistence);
const instanceSettings = mockInstance(InstanceSettings);
const deprecatedNodesValidationService = new DeprecatedNodesValidationService(
logger,
mock<NodesConfig>({ blockDeprecated: true }),
nodeTypes,
);
const service = new ExecuteNodeService(
nodeTypes,
@ -64,6 +72,7 @@ describe('ExecuteNodeService', () => {
activeExecutions,
executionPersistence,
instanceSettings,
deprecatedNodesValidationService,
);
const user = mock<User>({ id: 'user-1' });
@ -86,6 +95,17 @@ describe('ExecuteNodeService', () => {
});
describe('pre-flight validation', () => {
it('rejects a deprecated node type before creating any workflow row', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(
mockNodeType({ description: { ...testNodeDescription, deprecated: true } }),
);
await expect(
service.run(user, baseRequest({ type: 'n8n-nodes-base.function', version: 1 })),
).rejects.toThrow(DeprecatedNodesError);
expect(workflowRepository.createWorkflowWithOwner).not.toHaveBeenCalled();
});
it('rejects an unknown node type before creating any workflow row', async () => {
nodeTypes.getByNameAndVersion.mockImplementation(() => {
throw new Error('unknown');

View File

@ -22,6 +22,7 @@ import { BadRequestError, ForbiddenError } from '@n8n/errors';
import { ExecutionPersistence } from '@/executions/execution-persistence';
import { NodeTypes } from '@/node-types';
import { WorkflowRunner } from '@/workflow-runner';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
export const DEFAULT_EXECUTE_NODE_TIMEOUT_MS = 30_000;
export const MAX_EXECUTE_NODE_TIMEOUT_MS = 60_000;
@ -86,6 +87,7 @@ export class ExecuteNodeService {
private readonly activeExecutions: ActiveExecutions,
private readonly executionPersistence: ExecutionPersistence,
private readonly instanceSettings: InstanceSettings,
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
) {}
async run(user: User, request: ExecuteNodeRequest): Promise<ExecuteNodeResult> {
@ -98,6 +100,7 @@ export class ExecuteNodeService {
await this.checkCredentialAccess(user, credentials);
const node = this.buildNode(request, credentials);
this.deprecatedNodesValidationService.validateOnCreate([node]);
const timeoutMs = Math.min(
request.timeoutMs ?? DEFAULT_EXECUTE_NODE_TIMEOUT_MS,
MAX_EXECUTE_NODE_TIMEOUT_MS,

View File

@ -135,6 +135,7 @@ describe('ImportService', () => {
mockPolicyEnforcementService,
mockSharedWorkflowRepository,
mockWorkflowRepository,
mock(),
);
});

View File

@ -40,6 +40,7 @@ import {
validateWorkflowStructure,
sanitizeNodeGroupDescriptions,
} from '@/workflow-helpers';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import { WorkflowService } from '@/workflows/workflow.service';
import { DATA_TABLE_ROWS_FILE_PREFIX } from './data-table-export.constants';
@ -90,6 +91,7 @@ export class ImportService {
private readonly policyEnforcementService: PolicyEnforcementService,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
) {}
async initRecords() {
@ -118,20 +120,22 @@ export class ImportService {
const workflowIds = workflows.map((w) => w.id).filter((id) => !!id);
const existingWorkflowIds = new Set<string>();
const activeVersionIdByWorkflow = new Map<string, string>();
const existingNodesByWorkflow = new Map<string, INode[]>();
let existingOwnerProjects = new Map<string, Project>();
if (workflowIds.length > 0) {
const existingWorkflows = await dbManager.find(WorkflowEntity, {
where: { id: In(workflowIds) },
select: ['id', 'activeVersionId'],
select: ['id', 'activeVersionId', 'nodes'],
});
for (const { id, activeVersionId } of existingWorkflows) {
for (const { id, activeVersionId, nodes } of existingWorkflows) {
existingWorkflowIds.add(id);
if (activeVersionId !== null) {
activeVersionIdByWorkflow.set(id, activeVersionId);
}
existingNodesByWorkflow.set(id, nodes ?? []);
}
// An existing workflow's policy scope is its own project, not `projectId` — that
@ -162,6 +166,21 @@ export class ImportService {
if (hasInvalidCreds) await this.replaceInvalidCreds(workflow, projectId);
validateWorkflowStructure(workflow);
// Apply the same deprecated-node enforcement that the REST/public API
// endpoints use: treat unknown workflow IDs as creates, known IDs as
// updates (so a re-import of an existing workflow with the same
// deprecated nodes is allowed, but introducing new ones is not).
const existingNodes = workflow.id ? existingNodesByWorkflow.get(workflow.id) : undefined;
if (existingNodes) {
this.deprecatedNodesValidationService.validateOnUpdate(
workflow.nodes,
existingNodes,
workflow.id,
);
} else {
this.deprecatedNodesValidationService.validateOnCreate(workflow.nodes, workflow.id);
}
for (const warning of sanitizeNodeGroupDescriptions(workflow)) {
this.logger.warn(`Workflow "${workflow.name}": ${warning}`);
}

View File

@ -76,6 +76,7 @@ import { TaskRequester } from '@/task-runners/task-managers/task-requester';
import { findSubworkflowStart } from '@/utils';
import { objectToError } from '@/utils/object-to-error';
import * as WorkflowHelpers from '@/workflow-helpers';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import { getWorkflowProjectDetailsSafe } from '@/workflows/utils';
import { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service';
@ -156,6 +157,11 @@ async function fetchWorkflowData(
} else {
const workflowData = workflowInfo.code;
if (workflowData) {
// The save-time check does not see inline JSON, so it runs here.
Container.get(DeprecatedNodesValidationService).validateOnCreate(
workflowData.nodes ?? [],
parentWorkflowId,
);
// An inline sub-workflow is part of the parent that embeds it, not a
// workflow of its own, so it runs under the parent workflow's id.
workflowData.id = parentWorkflowId;

View File

@ -0,0 +1,322 @@
import type { Logger } from '@n8n/backend-common';
import type { NodesConfig } from '@n8n/config';
import type { INode, INodeType } from 'n8n-workflow';
import { fail } from 'node:assert';
import { mock } from 'vitest-mock-extended';
import {
DeprecatedNodesError,
type DeprecatedNodeViolation,
} from '@/errors/response-errors/deprecated-nodes.error';
import type { NodeTypes } from '@/node-types';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
describe('DeprecatedNodesValidationService', () => {
let validator: DeprecatedNodesValidationService;
let nodesConfig: NodesConfig;
let nodeTypes: ReturnType<typeof mock<NodeTypes>>;
let logger: ReturnType<typeof mock<Logger>>;
const nodeTypeFor = (type: string, deprecated?: boolean): INodeType =>
Object.assign(mock<INodeType>(), {
description: {
name: type,
deprecated: deprecated ? true : undefined,
properties: [
{ displayName: 'Code', name: 'functionCode', type: 'string', default: '// default' },
],
},
});
beforeEach(() => {
nodesConfig = { blockDeprecated: true } as NodesConfig;
nodeTypes = mock<NodeTypes>();
logger = mock<Logger>();
nodeTypes.getByNameAndVersion.mockImplementation((type) => {
if (type === 'n8n-nodes-base.function' || type === 'n8n-nodes-base.functionItem') {
return nodeTypeFor(type, true);
}
return nodeTypeFor(type, false);
});
validator = new DeprecatedNodesValidationService(logger, nodesConfig, nodeTypes);
});
const expectViolations = (run: () => void, violations: DeprecatedNodeViolation[]) => {
try {
run();
fail('expected to throw');
} catch (error) {
expect(error).toBeInstanceOf(DeprecatedNodesError);
expect((error as DeprecatedNodesError).meta.violations).toEqual(violations);
}
};
const makeNode = (overrides: Partial<INode> & Pick<INode, 'id' | 'type'>): INode => ({
name: overrides.id,
typeVersion: 1,
position: [0, 0],
parameters: {},
...overrides,
});
describe('validateOnCreate', () => {
it('passes when no deprecated nodes are present', () => {
const nodes = [makeNode({ id: 'a', type: 'n8n-nodes-base.set' })];
expect(() => validator.validateOnCreate(nodes)).not.toThrow();
});
it('throws DeprecatedNodesError when any node type is deprecated', () => {
const nodes = [
makeNode({ id: 'a', type: 'n8n-nodes-base.set' }),
makeNode({ id: 'b', type: 'n8n-nodes-base.function' }),
];
expect(() => validator.validateOnCreate(nodes)).toThrow(DeprecatedNodesError);
expect(() => validator.validateOnCreate(nodes)).toThrow(/deprecated/);
});
it('exposes every deprecated node in error meta and message', () => {
const nodes = [
makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func A' }),
makeNode({ id: 'b', type: 'n8n-nodes-base.functionItem', name: 'Func B' }),
];
try {
validator.validateOnCreate(nodes);
fail('expected to throw');
} catch (error) {
expect(error).toBeInstanceOf(DeprecatedNodesError);
const typed = error as DeprecatedNodesError;
expect(typed.meta.violations).toEqual([
{ kind: 'added', nodeName: 'Func A', nodeType: 'n8n-nodes-base.function' },
{ kind: 'added', nodeName: 'Func B', nodeType: 'n8n-nodes-base.functionItem' },
]);
expect(typed.message).toContain('Func A');
expect(typed.message).toContain('Func B');
}
});
it('does not treat an unknown node type as deprecated', () => {
nodeTypes.getByNameAndVersion.mockImplementation(() => {
throw new Error('Unrecognized node type');
});
const nodes = [makeNode({ id: 'a', type: 'community.unknown' })];
expect(() => validator.validateOnCreate(nodes)).not.toThrow();
});
it('is a no-op when the config flag is off', () => {
nodesConfig.blockDeprecated = false;
const nodes = [makeNode({ id: 'a', type: 'n8n-nodes-base.function' })];
expect(() => validator.validateOnCreate(nodes)).not.toThrow();
});
});
describe('validateOnUpdate', () => {
it('allows an unchanged deprecated node to pass through', () => {
const node = makeNode({
id: 'a',
type: 'n8n-nodes-base.function',
parameters: { functionCode: 'return items;' },
});
expect(() => validator.validateOnUpdate([node], [node])).not.toThrow();
});
it('allows position-only changes to a deprecated node', () => {
const before = makeNode({
id: 'a',
type: 'n8n-nodes-base.function',
name: 'Func',
position: [0, 0],
});
const after = { ...before, position: [200, 100] as [number, number] };
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
});
it('allows a structurally equal copy of a deprecated node', () => {
const before = makeNode({
id: 'a',
type: 'n8n-nodes-base.function',
parameters: { functionCode: 'return items;' },
credentials: { someApi: { id: '1', name: 'Some API' } },
});
expect(() => validator.validateOnUpdate([structuredClone(before)], [before])).not.toThrow();
});
it('treats absent and default disabled and credentials as unchanged', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function' });
const after = { ...before, disabled: false, credentials: {} };
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
});
it('allows the editor-saved shape of a deprecated node stored with explicit defaults', () => {
const before: INode = {
...makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' }),
parameters: { functionCode: '// default' },
notes: '',
onError: 'stopWorkflow',
continueOnFail: false,
};
const after = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
});
it('blocks adding notes to an existing deprecated node', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
const after = { ...before, notes: 'changed' };
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
});
it('blocks disabling an existing deprecated node', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
const after = { ...before, disabled: true };
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
});
it('blocks changing the credentials of an existing deprecated node', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
const after = { ...before, credentials: { someApi: { id: '2', name: 'Other API' } } };
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
});
it('blocks changing the error handling of an existing deprecated node', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
const after: INode = { ...before, onError: 'continueRegularOutput' };
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
});
it('treats a deprecated node sent with a new id as added', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
const after = { ...before, id: 'b' };
expectViolations(
() => validator.validateOnUpdate([after], [before]),
[{ kind: 'added', nodeName: 'Func', nodeType: 'n8n-nodes-base.function' }],
);
});
it('treats a deprecated node sent without an id as added', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
const after = { ...before, id: undefined as unknown as string };
expectViolations(
() => validator.validateOnUpdate([after], [before]),
[{ kind: 'added', nodeName: 'Func', nodeType: 'n8n-nodes-base.function' }],
);
});
it('treats a deprecated node reusing the id of a non-deprecated node as added', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.set', name: 'Set' });
const after = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Set' });
expectViolations(
() => validator.validateOnUpdate([after], [before]),
[{ kind: 'added', nodeName: 'Set', nodeType: 'n8n-nodes-base.function' }],
);
});
it('logs a warning with the workflow id when it rejects a save', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
const after = { ...before, parameters: { functionCode: 'return [];' } };
expect(() => validator.validateOnUpdate([after], [before], 'wf-1')).toThrow(
DeprecatedNodesError,
);
expect(logger.warn).toHaveBeenCalledWith(expect.any(String), {
workflowId: 'wf-1',
violations: [{ kind: 'edited', nodeType: 'n8n-nodes-base.function' }],
});
});
it('blocks adding a deprecated node that did not exist before', () => {
const before = [makeNode({ id: 'a', type: 'n8n-nodes-base.set' })];
const after = [
...before,
makeNode({ id: 'b', type: 'n8n-nodes-base.function', name: 'New Func' }),
];
expect(() => validator.validateOnUpdate(after, before)).toThrow(/Cannot use.*New Func/);
});
it('blocks editing the parameters of an existing deprecated node', () => {
const before = makeNode({
id: 'a',
type: 'n8n-nodes-base.function',
name: 'Func',
parameters: { functionCode: 'return items;' },
});
const after = { ...before, parameters: { functionCode: 'return [];' } };
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
});
it('blocks editing the name of an existing deprecated node', () => {
const before = makeNode({
id: 'a',
type: 'n8n-nodes-base.function',
name: 'Func',
});
const after = { ...before, name: 'Renamed' };
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify/);
});
it('blocks bumping the typeVersion of a non-deprecated node down to a deprecated version', () => {
nodeTypes.getByNameAndVersion.mockImplementation((type, version) => {
if (type === 'n8n-nodes-base.myNode' && version === 1) {
return nodeTypeFor(type, true);
}
return nodeTypeFor(type, false);
});
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.myNode', typeVersion: 2 });
const after = { ...before, typeVersion: 1 };
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot use/);
});
it('allows migrating a deprecated typeVersion forward, including parameter changes', () => {
nodeTypes.getByNameAndVersion.mockImplementation((type, version) => {
if (type === 'n8n-nodes-base.myNode' && version === 1) {
return nodeTypeFor(type, true);
}
return nodeTypeFor(type, false);
});
const before = makeNode({
id: 'a',
type: 'n8n-nodes-base.myNode',
typeVersion: 1,
parameters: { mode: 'legacy' },
});
const after = {
...before,
typeVersion: 2,
parameters: { mode: 'safe', extra: 'new-field' },
};
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
});
it('allows replacing a deprecated node with a different node type at the same id', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
const after = makeNode({ id: 'a', type: 'n8n-nodes-base.code', name: 'Func' });
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
});
it('allows deleting a deprecated node', () => {
const before = [
makeNode({ id: 'a', type: 'n8n-nodes-base.set' }),
makeNode({ id: 'b', type: 'n8n-nodes-base.function' }),
];
const after = [before[0]];
expect(() => validator.validateOnUpdate(after, before)).not.toThrow();
});
it('allows edits to non-deprecated nodes alongside a frozen deprecated one', () => {
const deprecated = makeNode({ id: 'a', type: 'n8n-nodes-base.function' });
const otherBefore = makeNode({ id: 'b', type: 'n8n-nodes-base.set', parameters: { x: 1 } });
const otherAfter = { ...otherBefore, parameters: { x: 2 } };
expect(() =>
validator.validateOnUpdate([deprecated, otherAfter], [deprecated, otherBefore]),
).not.toThrow();
});
it('is a no-op when the config flag is off', () => {
nodesConfig.blockDeprecated = false;
const before = [makeNode({ id: 'a', type: 'n8n-nodes-base.set' })];
const after = [...before, makeNode({ id: 'b', type: 'n8n-nodes-base.function' })];
expect(() => validator.validateOnUpdate(after, before)).not.toThrow();
});
});
});

View File

@ -123,6 +123,7 @@ describe('WorkflowCreationService', () => {
policyEnforcementServiceMock,
workflowRepositoryMock,
nodeGroupRulesFlagGateMock,
mock(),
);
});

View File

@ -138,6 +138,7 @@ describe('WorkflowService', () => {
workflowPublicationStatusServiceMock, // workflowPublicationStatusService
mock(), // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(),
);
});
@ -513,6 +514,7 @@ describe('WorkflowService', () => {
mock(), // workflowPublicationStatusService
nodeGroupRulesFlagGateMock, // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(),
);
vi.clearAllMocks();
@ -1358,6 +1360,7 @@ describe('WorkflowService', () => {
mock(), // workflowPublicationStatusService
mock(), // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(),
);
// Bypass validation internals
@ -2119,6 +2122,7 @@ describe('WorkflowService', () => {
mock(), // workflowPublicationStatusService
mock(), // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(),
);
});
@ -2261,6 +2265,7 @@ describe('WorkflowService', () => {
mock(), // workflowPublicationStatusService
mock(), // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(),
);
});
@ -2568,6 +2573,7 @@ describe('WorkflowService', () => {
mock(), // workflowPublicationStatusService
mock(), // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(),
);
});
@ -2739,6 +2745,7 @@ describe('WorkflowService', () => {
mock(), // workflowPublicationStatusService
mock(), // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(),
);
});
@ -2927,6 +2934,7 @@ describe('WorkflowService', () => {
mock(), // workflowPublicationStatusService
mock(), // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(),
);
});
@ -3031,6 +3039,7 @@ describe('WorkflowService', () => {
mock(), // workflowPublicationStatusService
mock(), // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(),
);
});
@ -3134,6 +3143,7 @@ describe('WorkflowService', () => {
mock(), // workflowPublicationStatusService
mock(), // nodeGroupRulesFlagGate
errorWorkflowValidationServiceMock, // errorWorkflowValidationService
mock(),
);
vi.mocked(WorkflowHelpers.removeDefaultValues).mockImplementation((settings) => settings);

View File

@ -0,0 +1,135 @@
import { Logger } from '@n8n/backend-common';
import { NodesConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import isEqual from 'lodash/isEqual';
import type { INode, INodeTypeDescription } from 'n8n-workflow';
import { NodeHelpers, normalizeNodeShape } from 'n8n-workflow';
import {
DeprecatedNodesError,
type DeprecatedNodeViolation,
} from '@/errors/response-errors/deprecated-nodes.error';
import { NodeTypes } from '@/node-types';
/**
* Refuses adding a deprecated node or changing one in place. Removing a
* deprecated node, or moving it to a non-deprecated type or version, is allowed.
* Does nothing when `N8N_DEPRECATED_NODES_BLOCK` is off.
*/
@Service()
export class DeprecatedNodesValidationService {
constructor(
private readonly logger: Logger,
private readonly nodesConfig: NodesConfig,
private readonly nodeTypes: NodeTypes,
) {}
/** Throws if any node is deprecated. Use where there is no stored version to compare with. */
validateOnCreate(nodes: INode[], workflowId?: string): void {
if (!this.nodesConfig.blockDeprecated) return;
const violations: DeprecatedNodeViolation[] = [];
for (const node of nodes) {
if (this.deprecatedDescription(node)) {
violations.push({ kind: 'added', nodeName: node.name, nodeType: node.type });
}
}
this.throwIfViolations(violations, workflowId);
}
/**
* Throws if a deprecated node is new, or differs from the stored node with the
* same id other than in position.
*/
validateOnUpdate(incomingNodes: INode[], existingNodes: INode[], workflowId?: string): void {
if (!this.nodesConfig.blockDeprecated) return;
const existingById = new Map(existingNodes.map((n) => [n.id, n]));
const violations: DeprecatedNodeViolation[] = [];
for (const incoming of incomingNodes) {
const description = this.deprecatedDescription(incoming);
if (!description) continue;
const before = existingById.get(incoming.id);
// A stored node that was not deprecated counts as adding one, so a node cannot be moved back onto a deprecated version.
if (!before || !this.deprecatedDescription(before)) {
violations.push({ kind: 'added', nodeName: incoming.name, nodeType: incoming.type });
continue;
}
if (
!isEqual(this.frozenFields(before, description), this.frozenFields(incoming, description))
) {
violations.push({ kind: 'edited', nodeName: incoming.name, nodeType: incoming.type });
}
}
this.throwIfViolations(violations, workflowId);
}
/** Puts a node in the shape the editor saves, so a re-save of an untouched node compares equal. */
private frozenFields(node: INode, description: INodeTypeDescription) {
const {
position: _position,
notes,
onError,
continueOnFail,
disabled,
credentials,
parameters,
...rest
} = normalizeNodeShape(node);
return {
...rest,
parameters:
NodeHelpers.getNodeParameters(
description.properties,
parameters,
false,
false,
node,
description,
) ?? {},
credentials: credentials ?? {},
disabled: disabled === true,
continueOnFail: continueOnFail === true,
onError: onError ?? 'stopWorkflow',
notes: notes ?? '',
};
}
private throwIfViolations(violations: DeprecatedNodeViolation[], workflowId?: string) {
if (violations.length === 0) return;
this.logger.warn('Rejected deprecated nodes', {
workflowId,
violations: violations.map(({ kind, nodeType }) => ({ kind, nodeType })),
});
throw new DeprecatedNodesError(this.formatMessage(violations), { violations });
}
private deprecatedDescription(node: INode): INodeTypeDescription | undefined {
try {
const { description } = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
return description.deprecated === true ? description : undefined;
} catch {
// Unknown node types can't be deprecated by us; they're handled elsewhere.
return undefined;
}
}
private formatMessage(violations: DeprecatedNodeViolation[]): string {
const lines = violations.map((v) => {
const verb = v.kind === 'added' ? 'use a' : 'modify a';
return `Cannot ${verb} "${v.nodeType}" node ("${v.nodeName}"): this node type is deprecated.`;
});
const suffix = ' Replace the node with a supported alternative or remove it from the workflow.';
return lines.join(' ') + suffix;
}
}

View File

@ -32,6 +32,7 @@ import { TagService } from '@/services/tag.service';
import * as WorkflowHelpers from '@/workflow-helpers';
import { WorkflowHookContextService } from '@/workflow-hook-context.service';
import { DeprecatedNodesValidationService } from './deprecated-nodes-validation.service';
import { NodeGroupRulesFlagGate } from './node-group-rules-flag-gate';
import { dropRedactionPolicy } from './utils';
import { WorkflowFinderService } from './workflow-finder.service';
@ -74,6 +75,7 @@ export class WorkflowCreationService {
private readonly policyEnforcementService: PolicyEnforcementService,
private readonly workflowRepository: WorkflowRepository,
private readonly nodeGroupRulesFlagGate: NodeGroupRulesFlagGate,
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
) {}
async prepareBatchContext(
@ -222,6 +224,7 @@ export class WorkflowCreationService {
WorkflowHelpers.makeGetNodeTypeForGrouping(this.nodeTypes),
rules,
);
this.deprecatedNodesValidationService.validateOnCreate(newWorkflow.nodes, newWorkflow.id);
if (parentFolderId && parentFolderId !== PROJECT_ROOT) {
if (!batchContext) {

View File

@ -35,6 +35,7 @@ import {
staticErrorWorkflowId,
type ErrorWorkflowProblem,
} from './error-workflow-validation.service';
import { DeprecatedNodesValidationService } from './deprecated-nodes-validation.service';
import { WorkflowPublicationNotifier } from './publication/workflow-publication-notifier';
import { WorkflowPublicationStatusService } from './publication/workflow-publication-status.service';
import { NodeGroupRulesFlagGate } from './node-group-rules-flag-gate';
@ -135,6 +136,7 @@ export class WorkflowService {
private readonly workflowPublicationStatusService: WorkflowPublicationStatusService,
private readonly nodeGroupRulesFlagGate: NodeGroupRulesFlagGate,
private readonly errorWorkflowValidationService: ErrorWorkflowValidationService,
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
) {}
/**
@ -611,6 +613,14 @@ export class WorkflowService {
);
}
if (hasNodesKey && nodesChanged) {
this.deprecatedNodesValidationService.validateOnUpdate(
workflowUpdateData.nodes,
workflow.nodes,
workflow.id,
);
}
// Strip redactionPolicy if instance lacks data-redaction license
if (
workflowUpdateData.settings?.redactionPolicy !== undefined &&

View File

@ -0,0 +1,161 @@
import { testDb, createWorkflow } from '@n8n/backend-test-utils';
import { NodesConfig } from '@n8n/config';
import { ExecutionRepository, type IWorkflowDb } from '@n8n/db';
import { Container } from '@n8n/di';
import { NodeConnectionTypes } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
import { createOwner } from './shared/db/users';
import * as utils from './shared/utils';
import { loadNodesFromDist } from './shared/utils/node-types-data';
describe('inline sub-workflow with a deprecated node', () => {
let owner: Awaited<ReturnType<typeof createOwner>>;
let workflowExecutionService: WorkflowExecutionService;
let executionRepository: ExecutionRepository;
let nodesConfig: NodesConfig;
let previousBlockDeprecated: boolean;
beforeAll(async () => {
await testDb.init();
owner = await createOwner();
const nodeTypes = loadNodesFromDist([
'n8n-nodes-base.manualTrigger',
'n8n-nodes-base.executeWorkflow',
'n8n-nodes-base.executeWorkflowTrigger',
'n8n-nodes-base.function',
]);
await utils.initNodeTypes(nodeTypes);
await utils.initBinaryDataService();
workflowExecutionService = Container.get(WorkflowExecutionService);
executionRepository = Container.get(ExecutionRepository);
nodesConfig = Container.get(NodesConfig);
previousBlockDeprecated = nodesConfig.blockDeprecated;
});
afterEach(async () => {
nodesConfig.blockDeprecated = previousBlockDeprecated;
await testDb.truncate(['ExecutionEntity', 'WorkflowEntity']);
});
afterAll(async () => {
await testDb.terminate();
});
function inlineSubWorkflowWithFunctionNode() {
return {
nodes: [
{
parameters: { inputSource: 'passthrough' },
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
id: uuid(),
name: 'Sub Trigger',
position: [0, 0],
},
{
parameters: { functionCode: "return [{ json: { ran: 'function' } }];" },
type: 'n8n-nodes-base.function',
typeVersion: 1,
id: uuid(),
name: 'Function',
position: [200, 0],
},
],
connections: {
'Sub Trigger': {
main: [[{ node: 'Function', type: NodeConnectionTypes.Main, index: 0 }]],
},
},
};
}
function parentWithInlineSubWorkflow() {
return {
nodes: [
{
parameters: {},
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
id: uuid(),
name: 'Trigger',
position: [0, 0],
},
{
parameters: {
source: 'parameter',
workflowJson: JSON.stringify(inlineSubWorkflowWithFunctionNode()),
options: { waitForSubWorkflow: true },
},
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1.2,
id: uuid(),
name: 'Execute Sub-workflow',
position: [200, 0],
},
],
connections: {
Trigger: {
main: [[{ node: 'Execute Sub-workflow', type: NodeConnectionTypes.Main, index: 0 }]],
},
},
};
}
async function runParent() {
const parent = await createWorkflow(
{ name: 'Parent', ...parentWithInlineSubWorkflow() } as unknown as IWorkflowDb,
owner,
);
const result = await workflowExecutionService.executeManually(
parent,
{ triggerToStartFrom: { name: 'Trigger' } },
owner,
);
if (!('executionId' in result)) {
throw new Error(`Expected an executionId, got ${JSON.stringify(result)}`);
}
const start = Date.now();
while (Date.now() - start < 10000) {
const execution = await executionRepository.findSingleExecution(result.executionId, {
includeData: true,
unflattenData: true,
});
if (execution && ['error', 'success'].includes(execution.status)) return execution;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`Execution ${result.executionId} did not finish within 10000ms`);
}
it('fails the parent node before the deprecated node runs when the block is on', async () => {
nodesConfig.blockDeprecated = true;
const execution = await runParent();
expect(execution.status).toBe('error');
const nodeRun = execution.data.resultData.runData['Execute Sub-workflow']?.at(-1);
expect(nodeRun?.executionStatus).toBe('error');
expect(JSON.stringify(nodeRun?.error)).toContain('n8n-nodes-base.function');
expect(await executionRepository.count({ where: { workflowId: execution.workflowId } })).toBe(
1,
);
});
it('runs the deprecated node when the block is off', async () => {
nodesConfig.blockDeprecated = false;
const execution = await runParent();
expect(execution.status).toBe('success');
const output = execution.data.resultData.runData['Execute Sub-workflow']?.at(-1)?.data?.main;
expect(output?.[0]?.[0]?.json).toEqual({ ran: 'function' });
});
});

View File

@ -32,23 +32,28 @@ import {
UserRepository,
WorkflowPublishedVersionRepository,
} from '@n8n/db';
import type { NodesConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import * as fastGlob from 'fast-glob';
import { Cipher } from 'n8n-core';
import type { InstanceSettings } from 'n8n-core';
import * as utils from 'n8n-workflow';
import type { INodeType } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { readFile } from 'node:fs/promises';
import type { Mock, Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
import type { IWorkflowToImport } from '@/interfaces';
import { SourceControlContextFactory } from '@/modules/source-control.ee/source-control-context.factory';
import { SourceControlImportService } from '@/modules/source-control.ee/source-control-import.service.ee';
import { SourceControlScopedService } from '@/modules/source-control.ee/source-control-scoped.service';
import type { ExportableCredential } from '@/modules/source-control.ee/types/exportable-credential';
import type { NodeTypes } from '@/node-types';
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
import { PolicyViolationError } from '@/policy/policy-violation.error';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
import { createFolder } from '@test-integration/db/folders';
@ -115,6 +120,15 @@ describe('SourceControlImportService', () => {
async (context, actor) =>
await Container.get(PolicyEnforcementService).enforceContentImport(context, actor),
);
const nodeTypes = mock<NodeTypes>();
nodeTypes.getByNameAndVersion.mockImplementation((type) =>
Object.assign(mock<INodeType>(), {
description: {
deprecated: type === 'n8n-nodes-base.function' ? true : undefined,
properties: [{ displayName: 'Code', name: 'functionCode', type: 'string', default: '' }],
},
}),
);
service = new SourceControlImportService(
mock(),
mock(),
@ -148,6 +162,11 @@ describe('SourceControlImportService', () => {
mock(), // workflowPublishGuard
mock(), // workflowMutationHooks
Container.get(WorkflowFinderService),
new DeprecatedNodesValidationService(
mock(),
mock<NodesConfig>({ blockDeprecated: true }),
nodeTypes,
),
);
});
@ -1853,6 +1872,89 @@ describe('SourceControlImportService', () => {
});
});
describe('deprecated nodes', () => {
it('rejects pulling a new workflow that contains a deprecated node', async () => {
const importingUser = await getGlobalOwner();
const workflow = makeWorkflowImport({
nodes: [
{
id: 'node-1',
name: 'Function',
type: 'n8n-nodes-base.function',
typeVersion: 1,
position: [250, 300],
parameters: { functionCode: 'return items;' },
},
] as IWorkflowToImport['nodes'],
});
const file = putWorkflowFile(workflow.id, workflow);
await expect(
service.importWorkflowFromWorkFolder(
[mock<SourceControlledFile>({ id: workflow.id, file })],
importingUser.id,
),
).rejects.toThrow(DeprecatedNodesError);
await expect(workflowRepository.findOneBy({ id: workflow.id })).resolves.toBeNull();
});
const functionNode = {
id: 'node-1',
name: 'Function',
type: 'n8n-nodes-base.function',
typeVersion: 1,
position: [250, 300] as [number, number],
parameters: { functionCode: 'return items;' },
};
it('allows pulling an existing workflow whose deprecated node is unchanged', async () => {
const importingUser = await getGlobalOwner();
const workflowId = nanoid();
await createWorkflowWithHistory(
{ id: workflowId, name: 'Old name', nodes: [functionNode], connections: {} },
importingUser,
);
const workflow = makeWorkflowImport({
id: workflowId,
name: 'New name',
nodes: [functionNode] as IWorkflowToImport['nodes'],
});
const file = putWorkflowFile(workflowId, workflow);
await service.importWorkflowFromWorkFolder(
[mock<SourceControlledFile>({ id: workflowId, file })],
importingUser.id,
);
const stored = await workflowRepository.findOneBy({ id: workflowId });
expect(stored?.name).toBe('New name');
});
it('rejects pulling an existing workflow whose deprecated node was edited', async () => {
const importingUser = await getGlobalOwner();
const workflowId = nanoid();
await createWorkflowWithHistory(
{ id: workflowId, name: 'Test Workflow', nodes: [functionNode], connections: {} },
importingUser,
);
const workflow = makeWorkflowImport({
id: workflowId,
nodes: [
{ ...functionNode, parameters: { functionCode: 'return [];' } },
] as IWorkflowToImport['nodes'],
});
const file = putWorkflowFile(workflowId, workflow);
await expect(
service.importWorkflowFromWorkFolder(
[mock<SourceControlledFile>({ id: workflowId, file })],
importingUser.id,
),
).rejects.toThrow(DeprecatedNodesError);
});
});
describe('workflow history', () => {
it('should create workflow history for new workflow on import', async () => {
const importingUser = await getGlobalOwner();

View File

@ -19,16 +19,20 @@ import {
UserRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import type { NodesConfig } from '@n8n/config';
import type { ContentImportContext, PolicyViolation } from '@n8n/decorators';
import type { INode } from 'n8n-workflow';
import type { INode, INodeType } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
import type { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
import type { NodeTypes } from '@/node-types';
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
import { PolicyViolationError } from '@/policy/policy-violation.error';
import { ImportService } from '@/services/import.service';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import type { WorkflowService } from '@/workflows/workflow.service';
import { createMember, createOwner } from './shared/db/users';
@ -85,6 +89,7 @@ describe('ImportService', () => {
mockPolicyEnforcementService,
sharedWorkflowRepository,
Container.get(WorkflowRepository),
mock(),
);
});
@ -653,4 +658,98 @@ describe('ImportService', () => {
expect(mockWorkflowService.deactivateWorkflow).not.toHaveBeenCalled();
});
});
describe('deprecated nodes', () => {
let blockingImportService: ImportService;
const deprecatedNode: INode = {
id: uuid(),
name: 'Function',
type: 'n8n-nodes-base.function',
typeVersion: 1,
position: [0, 0],
parameters: { functionCode: 'return items;' },
};
beforeAll(() => {
const nodeTypes = mock<NodeTypes>();
nodeTypes.getByNameAndVersion.mockImplementation((type) =>
Object.assign(mock<INodeType>(), {
description: {
deprecated: type === deprecatedNode.type ? true : undefined,
properties: [
{ displayName: 'Code', name: 'functionCode', type: 'string', default: '' },
],
},
}),
);
blockingImportService = new ImportService(
mock(),
Container.get(CredentialsRepository),
tagRepository,
mock(),
mock(),
mockWorkflowIndexService,
mock(),
Container.get(UserRepository),
mockWorkflowService,
mockPolicyEnforcementService,
sharedWorkflowRepository,
workflowRepository,
new DeprecatedNodesValidationService(
mock(),
mock<NodesConfig>({ blockDeprecated: true }),
nodeTypes,
),
);
});
test('rejects a new workflow with a deprecated node and leaves active workflows running', async () => {
const active = await createActiveWorkflow({ name: 'Active' });
const withDeprecated = newWorkflow({ id: uuid(), nodes: [deprecatedNode] });
await expect(
blockingImportService.importWorkflows(
[active, withDeprecated],
ownerPersonalProject.id,
owner.id,
{},
),
).rejects.toThrow(DeprecatedNodesError);
await expect(getWorkflowById(withDeprecated.id)).resolves.toBeNull();
expect(mockWorkflowService.deactivateWorkflow).not.toHaveBeenCalled();
});
test('rejects re-importing a workflow whose deprecated node was edited', async () => {
const existing = await createWorkflow({ nodes: [deprecatedNode] });
const edited = {
...existing,
nodes: [{ ...deprecatedNode, parameters: { functionCode: 'return [];' } }],
};
await expect(
blockingImportService.importWorkflows([edited], ownerPersonalProject.id, owner.id, {}),
).rejects.toThrow(DeprecatedNodesError);
const dbWorkflow = await getWorkflowById(existing.id);
expect(dbWorkflow?.nodes).toEqual([deprecatedNode]);
});
test('allows re-importing a workflow whose deprecated node is unchanged', async () => {
const existing = await createWorkflow({ nodes: [deprecatedNode] });
await blockingImportService.importWorkflows(
[{ ...existing, name: 'Re-imported' }],
ownerPersonalProject.id,
owner.id,
{},
);
const dbWorkflow = await getWorkflowById(existing.id);
expect(dbWorkflow?.name).toBe('Re-imported');
expect(dbWorkflow?.nodes).toEqual([deprecatedNode]);
});
});
});

View File

@ -10,7 +10,7 @@ import {
linkUserToProject,
shareWorkflowWithUsers,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { GlobalConfig, NodesConfig } from '@n8n/config';
import type { Project, TagEntity, User, WorkflowHistory } from '@n8n/db';
import {
WorkflowRepository,
@ -4374,3 +4374,92 @@ return [{ json: result }];
expect(retrievedUpdatedNode.parameters.language).toBe('javaScript');
});
});
describe('deprecated nodes', () => {
const nodesConfig = Container.get(NodesConfig);
let previousBlockDeprecated: boolean;
beforeAll(() => {
previousBlockDeprecated = nodesConfig.blockDeprecated;
nodesConfig.blockDeprecated = true;
});
afterAll(() => {
nodesConfig.blockDeprecated = previousBlockDeprecated;
});
const deprecatedNode: INode = {
id: uuid(),
name: 'Function',
type: 'n8n-nodes-base.function',
typeVersion: 1,
position: [0, 0],
parameters: { functionCode: 'return items;' },
};
test('POST /workflows rejects a workflow with a deprecated node', async () => {
const response = await authOwnerAgent.post('/workflows').send({
name: 'has deprecated',
nodes: [deprecatedNode],
connections: {},
settings: {},
});
expect(response.statusCode).toBe(400);
expect(response.body.message).toMatch(/deprecated/i);
});
test('PUT /workflows/:id rejects editing a deprecated node', async () => {
const workflow = await createWorkflowWithHistory({ nodes: [deprecatedNode] }, owner);
const response = await authOwnerAgent.put(`/workflows/${workflow.id}`).send({
name: workflow.name,
nodes: [{ ...deprecatedNode, parameters: { functionCode: 'return [];' } }],
connections: workflow.connections,
settings: workflow.settings,
});
expect(response.statusCode).toBe(400);
expect(response.body.message).toMatch(/deprecated/i);
});
test('PUT /workflows/:id allows editing other nodes while a deprecated node stays unchanged', async () => {
const otherNode: INode = {
id: uuid(),
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [200, 0],
parameters: {},
};
const workflow = await createWorkflowWithHistory({ nodes: [deprecatedNode, otherNode] }, owner);
const response = await authOwnerAgent.put(`/workflows/${workflow.id}`).send({
name: workflow.name,
nodes: [deprecatedNode, { ...otherNode, notes: 'edited' }],
connections: workflow.connections,
settings: workflow.settings,
});
expect(response.statusCode).toBe(200);
});
test('PUT /workflows/:id allows removing a deprecated node', async () => {
const otherNode: INode = {
id: uuid(),
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [200, 0],
parameters: {},
};
const workflow = await createWorkflowWithHistory({ nodes: [deprecatedNode, otherNode] }, owner);
const response = await authOwnerAgent.put(`/workflows/${workflow.id}`).send({
name: workflow.name,
nodes: [otherNode],
connections: {},
settings: workflow.settings,
});
expect(response.statusCode).toBe(200);
});
});

View File

@ -17,6 +17,8 @@ import { HttpHeaderAuth } from 'n8n-nodes-base/credentials/HttpHeaderAuth.creden
import { OpenAiApi } from 'n8n-nodes-base/credentials/OpenAiApi.credentials';
import { Cron } from 'n8n-nodes-base/nodes/Cron/Cron.node';
import { FormTrigger } from 'n8n-nodes-base/nodes/Form/FormTrigger.node';
import { Function as FunctionNode } from 'n8n-nodes-base/nodes/Function/Function.node';
import { FunctionItem } from 'n8n-nodes-base/nodes/FunctionItem/FunctionItem.node';
import { ManualTrigger } from 'n8n-nodes-base/nodes/ManualTrigger/ManualTrigger.node';
import { ScheduleTrigger } from 'n8n-nodes-base/nodes/Schedule/ScheduleTrigger.node';
import { Set } from 'n8n-nodes-base/nodes/Set/Set.node';
@ -139,6 +141,14 @@ function buildDefaultNodes(): INodeTypeData {
type: new WebhookNode() as unknown as INodeType,
sourcePath: '',
},
'n8n-nodes-base.function': {
type: new FunctionNode(),
sourcePath: '',
},
'n8n-nodes-base.functionItem': {
type: new FunctionItem(),
sourcePath: '',
},
// Minimal mocks for node types the package-import fixtures reference at typeVersion 1.
'n8n-nodes-base.httpRequest': minimalNodeType('n8n-nodes-base.httpRequest'),
'n8n-nodes-base.dataTable': minimalNodeType('n8n-nodes-base.dataTable'),

View File

@ -131,6 +131,7 @@ beforeAll(async () => {
Container.get(WorkflowPublicationStatusService), // workflowPublicationStatusService
Container.get(NodeGroupRulesFlagGate), // nodeGroupRulesFlagGate
Container.get(ErrorWorkflowValidationService), // errorWorkflowValidationService
mock(),
);
});

View File

@ -31,6 +31,7 @@ import {
WorkflowRepository,
WorkflowPublishHistoryRepository,
} from '@n8n/db';
import { NodesConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { DateTime } from 'luxon';
@ -880,6 +881,43 @@ describe('POST /workflows', () => {
expect(workflowsInDb).toHaveLength(0);
});
describe('deprecated nodes', () => {
const nodesConfig = Container.get(NodesConfig);
let previousBlockDeprecated: boolean;
beforeAll(() => {
previousBlockDeprecated = nodesConfig.blockDeprecated;
nodesConfig.blockDeprecated = true;
});
afterAll(() => {
nodesConfig.blockDeprecated = previousBlockDeprecated;
});
const deprecatedNode: INode = {
id: uuid(),
name: 'Function',
type: 'n8n-nodes-base.function',
typeVersion: 1,
position: [0, 0],
parameters: { functionCode: 'return items;' },
};
test('blocks creating a workflow that contains a deprecated node', async () => {
const response = await authOwnerAgent.post('/workflows').send({
name: 'has deprecated',
nodes: [deprecatedNode],
connections: {},
settings: {},
active: false,
});
expect(response.statusCode).toBe(400);
expect(response.body.message).toMatch(/deprecated/i);
expect(response.body.meta?.violations).toEqual([
{ kind: 'added', nodeName: 'Function', nodeType: 'n8n-nodes-base.function' },
]);
});
});
describe('Security: Mass Assignment Protection', () => {
test.each([
{
@ -4011,6 +4049,132 @@ describe('PATCH /workflows/:workflowId', () => {
expect(response.body.data.name).toBe('Updated name');
});
describe('deprecated nodes', () => {
const nodesConfig = Container.get(NodesConfig);
let previousBlockDeprecated: boolean;
beforeAll(() => {
previousBlockDeprecated = nodesConfig.blockDeprecated;
nodesConfig.blockDeprecated = true;
});
afterAll(() => {
nodesConfig.blockDeprecated = previousBlockDeprecated;
});
const buildDeprecatedNode = (overrides: Partial<INode> = {}): INode => ({
id: 'deprecated-node-id',
name: 'Function',
type: 'n8n-nodes-base.function',
typeVersion: 1,
position: [0, 0],
parameters: { functionCode: 'return items;' },
...overrides,
});
const buildCleanNode = (overrides: Partial<INode> = {}): INode => ({
id: 'clean-node-id',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [200, 0],
parameters: {},
...overrides,
});
test('blocks adding a deprecated node to an existing workflow', async () => {
const workflow = await createWorkflow({ nodes: [buildCleanNode()] }, owner);
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
versionId: workflow.versionId,
nodes: [buildCleanNode(), buildDeprecatedNode()],
connections: {},
});
expect(response.statusCode).toBe(400);
expect(response.body.meta?.violations).toEqual([
{ kind: 'added', nodeName: 'Function', nodeType: 'n8n-nodes-base.function' },
]);
});
test('blocks editing a deprecated node that already exists in the workflow', async () => {
const deprecated = buildDeprecatedNode();
const workflow = await createWorkflow({ nodes: [deprecated] }, owner);
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
versionId: workflow.versionId,
nodes: [{ ...deprecated, parameters: { functionCode: 'return [];' } }],
connections: {},
});
expect(response.statusCode).toBe(400);
expect(response.body.meta?.violations).toEqual([
{ kind: 'edited', nodeName: 'Function', nodeType: 'n8n-nodes-base.function' },
]);
});
test('allows editing other nodes while a deprecated node stays unchanged', async () => {
const deprecated = buildDeprecatedNode();
const cleanNode = buildCleanNode();
const workflow = await createWorkflow({ nodes: [deprecated, cleanNode] }, owner);
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
versionId: workflow.versionId,
nodes: [deprecated, { ...cleanNode, notes: 'edited' }],
connections: workflow.connections,
});
expect(response.statusCode).toBe(200);
});
test('allows the editor-saved shape of a deprecated node stored with explicit defaults', async () => {
const deprecated = buildDeprecatedNode();
const cleanNode = buildCleanNode();
const workflow = await createWorkflow(
{
nodes: [
{ ...deprecated, notes: '', onError: 'stopWorkflow', continueOnFail: false },
cleanNode,
],
},
owner,
);
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
versionId: workflow.versionId,
nodes: [deprecated, { ...cleanNode, notes: 'edited' }],
connections: workflow.connections,
});
expect(response.statusCode).toBe(200);
});
test('allows moving a deprecated node on the canvas', async () => {
const deprecated = buildDeprecatedNode();
const workflow = await createWorkflow({ nodes: [deprecated] }, owner);
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
versionId: workflow.versionId,
nodes: [{ ...deprecated, position: [400, 200] }],
connections: workflow.connections,
});
expect(response.statusCode).toBe(200);
});
test('allows removing a deprecated node from an existing workflow', async () => {
const deprecated = buildDeprecatedNode();
const cleanNode = buildCleanNode();
const workflow = await createWorkflow({ nodes: [deprecated, cleanNode] }, owner);
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
versionId: workflow.versionId,
nodes: [cleanNode],
connections: {},
});
expect(response.statusCode).toBe(200);
});
});
describe('Security: Mass Assignment Protection on Update', () => {
test.each([
{

View File

@ -1,6 +1,6 @@
{
"name": "n8n-core",
"version": "2.43.0",
"version": "2.43.1",
"description": "Core functionality of n8n",
"main": "dist/index",
"types": "dist/index.d.ts",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/chat",
"version": "1.42.0",
"version": "1.42.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm run --dir=../storybook dev --initial-path=/docs/chat-chat--docs",

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/composables",
"type": "module",
"version": "1.33.0",
"version": "1.33.1",
"files": [
"dist"
],

View File

@ -1,7 +1,7 @@
{
"type": "module",
"name": "@n8n/design-system",
"version": "2.42.0",
"version": "2.42.2",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",

View File

@ -32,16 +32,18 @@ const emit = defineEmits<{
const menuItemTextViewport = ref<HTMLElement | null>(null);
const menuItemText = ref<ComponentPublicInstance | null>(null);
const labelOverflows = ref(false);
const labelOverflowDistance = ref(0);
// Observing the text too catches label changes, which do not resize the viewport.
useResizeObserver([menuItemTextViewport, menuItemText], () => {
const viewport = menuItemTextViewport.value;
labelOverflows.value = viewport !== null && viewport.scrollWidth > viewport.clientWidth;
labelOverflowDistance.value = viewport
? Math.max(0, viewport.scrollWidth - viewport.clientWidth)
: 0;
});
const isLabelOverflowing = computed(
() => Boolean(props.scrollLabelOnOverflow) && labelOverflows.value,
() => Boolean(props.scrollLabelOnOverflow) && labelOverflowDistance.value > 0,
);
const to = computed(() => {
@ -156,6 +158,11 @@ const tooltipPlacement = computed(() => {
[$style.labelOverflowing]: isLabelOverflowing,
},
]"
:style="
isLabelOverflowing
? { '--menu-item--label-overflow': labelOverflowDistance }
: undefined
"
>
<N8nText
ref="menuItemText"
@ -262,6 +269,9 @@ const tooltipPlacement = computed(() => {
}
.scrollLabelOnOverflow {
// In px per second. The duration follows the overflow distance, so every label
// scrolls at the same pace whatever its length.
--menu-item--label-scroll-speed: 70;
container-type: inline-size;
margin-inline-end: calc(var(--spacing--4xs) * -1);
@ -286,7 +296,9 @@ const tooltipPlacement = computed(() => {
.menuItemText {
transform: translateX(min(0px, calc(-100% + 100cqi)));
transition-duration: calc(var(--duration--slowest) + var(--duration--slowest));
transition-duration: calc(
var(--menu-item--label-overflow, 0) / var(--menu-item--label-scroll-speed) * 1s
);
transition-delay: var(--duration--base);
transition-timing-function: linear;
}

View File

@ -1,7 +1,7 @@
{
"type": "module",
"name": "@n8n/frontend-module-sdk",
"version": "0.13.0",
"version": "0.13.2",
"main": "src/index.ts",
"import": "src/index.ts",
"exports": {

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/i18n",
"type": "module",
"version": "2.43.0",
"version": "2.43.1",
"files": [
"dist"
],

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/rest-api-client",
"type": "module",
"version": "2.43.0",
"version": "2.43.1",
"files": [
"dist"
],

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/stores",
"type": "module",
"version": "2.43.0",
"version": "2.43.1",
"files": [
"dist"
],

View File

@ -1,6 +1,6 @@
{
"name": "n8n-editor-ui",
"version": "2.43.0",
"version": "2.43.2",
"description": "Workflow Editor UI for n8n",
"main": "index.js",
"type": "module",

View File

@ -5,7 +5,7 @@ import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useI18n } from '@n8n/i18n';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { N8nButton, N8nIcon, N8nTooltip } from '@n8n/design-system';
import type { ButtonSize } from '@n8n/design-system';
import type { ButtonSize, IconSize } from '@n8n/design-system';
import { round2 } from './creditFormatting';
const props = withDefaults(
@ -14,6 +14,7 @@ const props = withDefaults(
creditsQuota?: number;
isLowCredits: boolean;
buttonSize?: ButtonSize;
iconSize?: IconSize;
// Per-thread running total (decimal); optional — shared with the builder UI.
creditsUsed?: number;
}>(),
@ -109,6 +110,7 @@ function onGetMoreCredits() {
icon="circle-dollar-sign"
variant="ghost"
:size="props.buttonSize"
:icon-size="props.iconSize"
icon-only
:class="{ [$style.active]: isOpen }"
data-test-id="credits-dropdown-button"

View File

@ -741,6 +741,109 @@ describe('useResourceRegistry', () => {
expect(producedArtifacts.has('agent-1')).toBe(false);
});
test('registers an Agent at spawn while the builder is creating it', async () => {
const { messages, producedArtifacts } = setup();
messages.value = [
makeMessage({
agentTree: makeAgentNode({
children: [
makeAgentNode({
agentId: 'agent-builder:agent-1',
activity: 'creating',
status: 'active',
targetResource: { type: 'agent', id: 'agent-1', name: 'Support Bot' },
}),
],
toolCalls: [
makeToolCall({
toolName: 'build-agent',
isLoading: true,
}),
],
}),
}),
];
await nextTick();
expect(producedArtifacts.get('agent-1')).toMatchObject({
type: 'agent',
id: 'agent-1',
name: 'Support Bot',
});
});
test('registers an Agent at spawn while the builder is editing it', async () => {
const { messages, producedArtifacts } = setup();
messages.value = [
makeMessage({
agentTree: makeAgentNode({
children: [
makeAgentNode({
agentId: 'agent-builder:agent-1',
activity: 'editing',
status: 'active',
targetResource: { type: 'agent', id: 'agent-1', name: 'Support Bot' },
}),
],
toolCalls: [
makeToolCall({
toolName: 'build-agent',
isLoading: true,
}),
],
}),
}),
];
await nextTick();
expect(producedArtifacts.get('agent-1')).toMatchObject({
type: 'agent',
id: 'agent-1',
name: 'Support Bot',
});
});
test('keeps a spawn-registered Agent when the build settles with no change', async () => {
const { messages, producedArtifacts } = setup();
messages.value = [
makeMessage({
agentTree: makeAgentNode({
children: [
makeAgentNode({
agentId: 'agent-builder:agent-1',
activity: 'creating',
status: 'active',
targetResource: { type: 'agent', id: 'agent-1', name: 'Support Bot' },
}),
],
toolCalls: [
makeToolCall({
toolName: 'build-agent',
isLoading: true,
}),
],
}),
}),
];
await nextTick();
expect(producedArtifacts.has('agent-1')).toBe(true);
const buildAgentCall = messages.value[0].agentTree!.toolCalls[0];
buildAgentCall.isLoading = false;
buildAgentCall.result = { ok: true, agentId: 'agent-1', agentChange: 'none' };
await nextTick();
expect(producedArtifacts.get('agent-1')).toMatchObject({
type: 'agent',
id: 'agent-1',
name: 'Support Bot',
});
});
test('keeps a pending Agent unconfirmed until a build changes it', async () => {
const { messages, producedArtifacts, linkableResourceNameIndex } = setup();
const builder = makeAgentNode({

View File

@ -91,6 +91,7 @@ function handleThreadSelect(threadId: string) {
:credits-used="threadCreditsUsed"
:is-low-credits="store.isLowCredits"
button-size="small"
icon-size="large"
@upgrade-click="goToUpgrade('instance-ai', 'upgrade-instance-ai')"
/>
<slot name="actions" />

View File

@ -775,7 +775,7 @@ onScopeDispose(() => {
:disabled="!helpDisabled"
:content="i18n.baseText('instanceAi.setupPanel.helpUnavailableWhileBuilding')"
>
<span :tabindex="helpDisabled ? 0 : undefined">
<span :class="$style.helpTrigger" :tabindex="helpDisabled ? 0 : undefined">
<N8nButton
variant="ghost"
size="small"
@ -853,9 +853,17 @@ onScopeDispose(() => {
color: var(--text-color--subtle);
}
// The tooltip anchors to this wrapper. Inline-flex keeps it the size of the
// button; a plain inline span around the button spans the full row.
.helpTrigger {
display: inline-flex;
// Offset the button padding so the label aligns with the content above
margin-inline-start: calc(-1 * var(--spacing--2xs));
}
.help {
padding-inline: 0;
color: var(--text-color--subtle);
--button--color: var(--text-color--subtler);
padding-inline: var(--spacing--2xs);
}
.form .existing {

View File

@ -370,9 +370,9 @@ function extractFromTargetResource(node: InstanceAiAgentNode, col: Collections):
const existing = col.produced.get(target.id);
const name = optionalString(target.name) ?? existing?.name ?? 'Untitled';
if (target.type === 'agent') {
// New events report the target before the result is known. Only the
// build-agent result can confirm that this Agent changed.
if (node.activity !== undefined && (!existing || existing.pending)) return;
// A read-only turn cannot confirm that this Agent changed. Wait for the
// build-agent result. A mutating build registers the Agent at spawn.
if (node.activity === 'exploring' && (!existing || existing.pending)) return;
const entry = entryFromAgentBuilderTarget(target, existing, name);
if (entry) recordProduced(col, entry);
return;
@ -381,7 +381,7 @@ function extractFromTargetResource(node: InstanceAiAgentNode, col: Collections):
}
function collectFromAgentNode(node: InstanceAiAgentNode, col: Collections): void {
const deferAgentTarget = node.targetResource?.type === 'agent' && node.activity !== undefined;
const deferAgentTarget = node.targetResource?.type === 'agent' && node.activity === 'exploring';
// A sub-agent spawned onto a resource is there to work on it.
col.intake = 'built';
if (!deferAgentTarget) extractFromTargetResource(node, col);

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