+
+
+ {{ locale.baseText('logs.details.header.actions.viewAgentSession') }}
+
{
let node: MessageAnAgent;
let executeFunctions: jest.Mocked;
+ const mockSession = {
+ agentId: 'agent-1',
+ projectId: 'project-1',
+ sessionId: 'exec-123-0',
+ };
+
const mockAgentResult: ExecuteAgentData = {
response: 'Hello from agent',
structuredOutput: null,
@@ -18,6 +24,7 @@ describe('MessageAnAgent Node', () => {
},
toolCalls: [],
finishReason: 'stop',
+ session: mockSession,
};
beforeEach(() => {
@@ -39,17 +46,20 @@ describe('MessageAnAgent Node', () => {
it('should send a message and return the agent response', async () => {
executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
- executeFunctions.getNodeParameter.mockImplementation((param: string) => {
- if (param === 'agentId') return { mode: 'id', value: 'agent-1' };
- if (param === 'message') return 'Hello agent';
- return undefined;
- });
+ executeFunctions.getNodeParameter.mockImplementation(
+ (param: string, _itemIndex?: number, fallback?: unknown) => {
+ if (param === 'agentId') return { mode: 'id', value: 'agent-1' };
+ if (param === 'message') return 'Hello agent';
+ if (param === 'advanced') return fallback ?? {};
+ return undefined;
+ },
+ );
executeFunctions.executeAgent.mockResolvedValue(mockAgentResult);
const result = await node.execute.call(executeFunctions);
expect(executeFunctions.executeAgent).toHaveBeenCalledWith(
- { agentId: 'agent-1' },
+ { agentId: 'agent-1', sessionId: undefined },
'Hello agent',
'exec-123',
0,
@@ -63,6 +73,7 @@ describe('MessageAnAgent Node', () => {
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
toolCalls: [],
finishReason: 'stop',
+ session: mockSession,
},
pairedItem: { item: 0 },
},
@@ -70,13 +81,56 @@ describe('MessageAnAgent Node', () => {
]);
});
- it('should throw NodeOperationError when message is empty', async () => {
+ it('should forward a user-supplied sessionId from the Advanced collection', async () => {
executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
executeFunctions.getNodeParameter.mockImplementation((param: string) => {
if (param === 'agentId') return { mode: 'id', value: 'agent-1' };
- if (param === 'message') return ' ';
- return undefined;
+ if (param === 'message') return 'Hello agent';
+ if (param === 'advanced') return { sessionId: ' thread-42 ' };
+ return undefined as unknown as string;
});
+ executeFunctions.executeAgent.mockResolvedValue(mockAgentResult);
+
+ await node.execute.call(executeFunctions);
+
+ expect(executeFunctions.executeAgent).toHaveBeenCalledWith(
+ { agentId: 'agent-1', sessionId: 'thread-42' },
+ 'Hello agent',
+ 'exec-123',
+ 0,
+ );
+ });
+
+ it('should treat a whitespace-only sessionId as no override', async () => {
+ executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
+ executeFunctions.getNodeParameter.mockImplementation((param: string) => {
+ if (param === 'agentId') return { mode: 'id', value: 'agent-1' };
+ if (param === 'message') return 'Hello agent';
+ if (param === 'advanced') return { sessionId: ' ' };
+ return undefined as unknown as string;
+ });
+ executeFunctions.executeAgent.mockResolvedValue(mockAgentResult);
+
+ await node.execute.call(executeFunctions);
+
+ expect(executeFunctions.executeAgent).toHaveBeenCalledWith(
+ { agentId: 'agent-1', sessionId: undefined },
+ 'Hello agent',
+ 'exec-123',
+ 0,
+ );
+ });
+
+ it('should throw NodeOperationError when message is empty', async () => {
+ executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
+ executeFunctions.getNodeParameter.mockImplementation(
+ (param: string, _itemIndex?: number, fallback?: unknown) => {
+ if (param === 'agentId') return { mode: 'id', value: 'agent-1' };
+ if (param === 'message') return ' ';
+ if (param === 'advanced') return fallback ?? {};
+ return undefined;
+ },
+ );
executeFunctions.continueOnFail.mockReturnValue(false);
await expect(node.execute.call(executeFunctions)).rejects.toThrow(NodeOperationError);
@@ -85,11 +139,14 @@ describe('MessageAnAgent Node', () => {
it('should process multiple items with different itemIndex values', async () => {
executeFunctions.getInputData.mockReturnValue([{ json: {} }, { json: {} }]);
- executeFunctions.getNodeParameter.mockImplementation((param: string, itemIndex: number) => {
- if (param === 'agentId') return { mode: 'id', value: `agent-${itemIndex + 1}` };
- if (param === 'message') return `Message ${itemIndex + 1}`;
- return undefined;
- });
+ executeFunctions.getNodeParameter.mockImplementation(
+ (param: string, itemIndex?: number, fallback?: unknown) => {
+ if (param === 'agentId') return { mode: 'id', value: `agent-${(itemIndex ?? 0) + 1}` };
+ if (param === 'message') return `Message ${(itemIndex ?? 0) + 1}`;
+ if (param === 'advanced') return fallback ?? {};
+ return undefined;
+ },
+ );
const resultForItem0: ExecuteAgentData = {
...mockAgentResult,
@@ -108,13 +165,13 @@ describe('MessageAnAgent Node', () => {
expect(executeFunctions.executeAgent).toHaveBeenCalledTimes(2);
expect(executeFunctions.executeAgent).toHaveBeenCalledWith(
- { agentId: 'agent-1' },
+ { agentId: 'agent-1', sessionId: undefined },
'Message 1',
'exec-123',
0,
);
expect(executeFunctions.executeAgent).toHaveBeenCalledWith(
- { agentId: 'agent-2' },
+ { agentId: 'agent-2', sessionId: undefined },
'Message 2',
'exec-123',
1,
@@ -128,11 +185,14 @@ describe('MessageAnAgent Node', () => {
it('should return error item instead of throwing when continueOnFail is true', async () => {
executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
- executeFunctions.getNodeParameter.mockImplementation((param: string) => {
- if (param === 'agentId') return { mode: 'id', value: 'agent-1' };
- if (param === 'message') return 'Hello';
- return undefined;
- });
+ executeFunctions.getNodeParameter.mockImplementation(
+ (param: string, _itemIndex?: number, fallback?: unknown) => {
+ if (param === 'agentId') return { mode: 'id', value: 'agent-1' };
+ if (param === 'message') return 'Hello';
+ if (param === 'advanced') return fallback ?? {};
+ return undefined;
+ },
+ );
executeFunctions.continueOnFail.mockReturnValue(true);
executeFunctions.executeAgent.mockRejectedValue(new Error('Agent unavailable'));
@@ -156,11 +216,14 @@ describe('MessageAnAgent Node', () => {
};
executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
- executeFunctions.getNodeParameter.mockImplementation((param: string) => {
- if (param === 'agentId') return { mode: 'list', value: 'agent-1' };
- if (param === 'message') return 'Structured query';
- return undefined;
- });
+ executeFunctions.getNodeParameter.mockImplementation(
+ (param: string, _itemIndex?: number, fallback?: unknown) => {
+ if (param === 'agentId') return { mode: 'list', value: 'agent-1' };
+ if (param === 'message') return 'Structured query';
+ if (param === 'advanced') return fallback ?? {};
+ return undefined;
+ },
+ );
executeFunctions.executeAgent.mockResolvedValue(structuredResult);
const result = await node.execute.call(executeFunctions);
diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts
index 49b8853a876..0dd328fc012 100644
--- a/packages/workflow/src/interfaces.ts
+++ b/packages/workflow/src/interfaces.ts
@@ -1950,6 +1950,13 @@ export interface ExecuteWorkflowData {
export interface ExecuteAgentInfo {
/** The agent ID to execute. */
agentId: string;
+ /**
+ * Optional caller-supplied session id. When set, this becomes the agent
+ * thread id, letting workflows continue the same conversation (and reuse
+ * memory) across executions. When omitted, a per-call thread is derived
+ * from the workflow execution id and item index.
+ */
+ sessionId?: string;
}
export interface ExecuteAgentOptions {
@@ -1978,6 +1985,17 @@ export interface ExecuteAgentData {
}>;
/** Why the agent stopped. */
finishReason: string;
+ /**
+ * Identifiers of the agent session this call wrote to. Surfaced so the
+ * caller (e.g. the MessageAnAgent node) can link from a workflow execution
+ * back to the agent session detail view.
+ */
+ session: {
+ agentId: string;
+ projectId: string;
+ /** The threadId persisted to the agent session. May be a caller-provided override. */
+ sessionId: string;
+ };
}
export type WebhookSetupMethodNames = 'checkExists' | 'create' | 'delete';
From 1cb7c591b30485a823cf5b6e4e0401f9e2e720c0 Mon Sep 17 00:00:00 2001
From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
Date: Thu, 7 May 2026 11:53:36 +0200
Subject: [PATCH 14/20] chore: Match production builder step cap in pairwise
eval (#29977)
Co-authored-by: Claude Opus 4.7 (1M context)
---
.../instance-ai/evaluations/harness/in-process-builder.ts | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/packages/@n8n/instance-ai/evaluations/harness/in-process-builder.ts b/packages/@n8n/instance-ai/evaluations/harness/in-process-builder.ts
index da91d1a99d9..db67f8f0d06 100644
--- a/packages/@n8n/instance-ai/evaluations/harness/in-process-builder.ts
+++ b/packages/@n8n/instance-ai/evaluations/harness/in-process-builder.ts
@@ -42,6 +42,7 @@ import { stringifyError, truncate } from './redact';
import { createStubServices, defaultNodesJsonPath, type StubServiceHandle } from './stub-services';
import type { SimpleWorkflow } from '../../../ai-workflow-builder.ee/evaluations/evaluators/pairwise';
import { registerWithMastra } from '../../src/agent/register-with-mastra';
+import { MAX_STEPS } from '../../src/constants/max-steps';
import type { InstanceAiEventBus, StoredEvent } from '../../src/event-bus';
import type { Logger } from '../../src/logger';
import { executeResumableStream } from '../../src/runtime/resumable-stream-executor';
@@ -141,7 +142,11 @@ export async function buildInProcess(
const started = Date.now();
const timeoutMs = options.timeoutMs ?? 20 * 60 * 1000;
const modelId: ModelConfig = options.modelId ?? 'anthropic/claude-sonnet-4-6';
- const maxSteps = options.maxSteps ?? 30;
+ // Match production: builds run with the same MAX_STEPS.BUILDER cap as
+ // `build-workflow-agent.tool.ts` uses inside the orchestrator. Halving
+ // the budget for evals makes the harness run out of steps on examples
+ // that production would complete, inflating `no_workflow_built` rates.
+ const maxSteps = options.maxSteps ?? MAX_STEPS.BUILDER;
const interactivity = {
askUserCount: 0,
From 5abcae686cf1b64e06bbbd6f62b6871bc4feec56 Mon Sep 17 00:00:00 2001
From: Garrit Franke <32395585+garritfra@users.noreply.github.com>
Date: Thu, 7 May 2026 11:57:03 +0200
Subject: [PATCH 15/20] feat(Strava Node): Allow custom OAuth2 scopes (#29972)
Co-authored-by: Claude Opus 4.7 (1M context)
---
packages/cli/src/constants.ts | 1 +
.../StravaOAuth2Api.credentials.ts | 47 ++++++++++++++++---
.../test/StravaOAuth2Api.credentials.test.ts | 47 +++++++++++++++++++
3 files changed, 88 insertions(+), 7 deletions(-)
create mode 100644 packages/nodes-base/credentials/test/StravaOAuth2Api.credentials.test.ts
diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts
index 87eccc81dd1..cd0895a3383 100644
--- a/packages/cli/src/constants.ts
+++ b/packages/cli/src/constants.ts
@@ -100,6 +100,7 @@ export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [
'microsoftOAuth2Api',
'highLevelOAuth2Api',
'mcpOAuth2Api',
+ 'stravaOAuth2Api',
'wordpressOAuth2Api',
];
diff --git a/packages/nodes-base/credentials/StravaOAuth2Api.credentials.ts b/packages/nodes-base/credentials/StravaOAuth2Api.credentials.ts
index 0581c387e37..2b652c3ecc8 100644
--- a/packages/nodes-base/credentials/StravaOAuth2Api.credentials.ts
+++ b/packages/nodes-base/credentials/StravaOAuth2Api.credentials.ts
@@ -1,5 +1,7 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
+const defaultScopes = 'activity:read_all,activity:write';
+
export class StravaOAuth2Api implements ICredentialType {
name = 'stravaOAuth2Api';
@@ -30,19 +32,50 @@ export class StravaOAuth2Api implements ICredentialType {
default: 'https://www.strava.com/oauth/token',
required: true,
},
- {
- displayName: 'Scope',
- name: 'scope',
- type: 'hidden',
- default: 'activity:read_all,activity:write',
- required: true,
- },
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden',
default: '',
},
+ {
+ displayName: 'Custom Scopes',
+ name: 'customScopes',
+ type: 'boolean',
+ default: false,
+ description: 'Whether to define custom OAuth2 scopes instead of the defaults',
+ },
+ {
+ displayName:
+ 'The default scopes needed for the node to work are already set. If you change these the node may not function correctly.',
+ name: 'customScopesNotice',
+ type: 'notice',
+ default: '',
+ displayOptions: {
+ show: {
+ customScopes: [true],
+ },
+ },
+ },
+ {
+ displayName: 'Enabled Scopes',
+ name: 'enabledScopes',
+ type: 'string',
+ displayOptions: {
+ show: {
+ customScopes: [true],
+ },
+ },
+ default: defaultScopes,
+ description: 'Comma-separated list of Strava OAuth2 scopes to request',
+ },
+ {
+ displayName: 'Scope',
+ name: 'scope',
+ type: 'hidden',
+ default: `={{$self["customScopes"] ? $self["enabledScopes"] : "${defaultScopes}"}}`,
+ required: true,
+ },
{
displayName: 'Authentication',
name: 'authentication',
diff --git a/packages/nodes-base/credentials/test/StravaOAuth2Api.credentials.test.ts b/packages/nodes-base/credentials/test/StravaOAuth2Api.credentials.test.ts
new file mode 100644
index 00000000000..5a032f45c45
--- /dev/null
+++ b/packages/nodes-base/credentials/test/StravaOAuth2Api.credentials.test.ts
@@ -0,0 +1,47 @@
+import { StravaOAuth2Api } from '../StravaOAuth2Api.credentials';
+
+describe('StravaOAuth2Api Credential', () => {
+ const credential = new StravaOAuth2Api();
+ const defaultScopes = 'activity:read_all,activity:write';
+
+ it('should have correct credential metadata', () => {
+ expect(credential.name).toBe('stravaOAuth2Api');
+ expect(credential.extends).toEqual(['oAuth2Api']);
+
+ const authUrlProperty = credential.properties.find((p) => p.name === 'authUrl');
+ expect(authUrlProperty?.default).toBe('https://www.strava.com/oauth/authorize');
+
+ const accessTokenUrlProperty = credential.properties.find((p) => p.name === 'accessTokenUrl');
+ expect(accessTokenUrlProperty?.default).toBe('https://www.strava.com/oauth/token');
+ });
+
+ it('should use body authentication', () => {
+ const authenticationProperty = credential.properties.find((p) => p.name === 'authentication');
+ expect(authenticationProperty?.type).toBe('hidden');
+ expect(authenticationProperty?.default).toBe('body');
+ });
+
+ it('should have custom scopes toggle defaulting to false', () => {
+ const customScopesProperty = credential.properties.find((p) => p.name === 'customScopes');
+ expect(customScopesProperty?.type).toBe('boolean');
+ expect(customScopesProperty?.default).toBe(false);
+ });
+
+ it('should have enabledScopes defaulting to the current default scope list', () => {
+ const enabledScopesProperty = credential.properties.find((p) => p.name === 'enabledScopes');
+ expect(enabledScopesProperty?.default).toBe(defaultScopes);
+ });
+
+ it('should only show enabledScopes when customScopes is true', () => {
+ const enabledScopesProperty = credential.properties.find((p) => p.name === 'enabledScopes');
+ expect(enabledScopesProperty?.displayOptions?.show?.customScopes).toEqual([true]);
+ });
+
+ it('should use enabledScopes when customScopes is true, otherwise fall back to defaults', () => {
+ const scopeProperty = credential.properties.find((p) => p.name === 'scope');
+ expect(scopeProperty?.type).toBe('hidden');
+ expect(scopeProperty?.default).toBe(
+ `={{$self["customScopes"] ? $self["enabledScopes"] : "${defaultScopes}"}}`,
+ );
+ });
+});
From 8474f1e6f32e7abd087f22037b43ada63d315193 Mon Sep 17 00:00:00 2001
From: Rob Hough
Date: Thu, 7 May 2026 10:58:41 +0100
Subject: [PATCH 16/20] fix(editor): Change read-only background color so it's
visible (no-changelog) (#29971)
---
packages/frontend/@n8n/design-system/src/css/_tokens.scss | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/frontend/@n8n/design-system/src/css/_tokens.scss b/packages/frontend/@n8n/design-system/src/css/_tokens.scss
index d9e1a62e183..e3ffafd69f1 100644
--- a/packages/frontend/@n8n/design-system/src/css/_tokens.scss
+++ b/packages/frontend/@n8n/design-system/src/css/_tokens.scss
@@ -72,7 +72,7 @@
// Canvas
--canvas--color--background: var(--color--neutral-125);
--canvas--dot--color: var(--color--neutral-500);
- --canvas--read-only-line--color: var(--color--neutral-100);
+ --canvas--read-only-line--color: var(--color--neutral-200);
--canvas--color--selected: var(--color--neutral-150);
--canvas--color--selected-transparent: hsla(220, 47%, 30%, 0.1);
--canvas--label--color: var(--color--neutral-600);
From 15105610f63c256a647974cda01a4d9f79d67981 Mon Sep 17 00:00:00 2001
From: Garrit Franke <32395585+garritfra@users.noreply.github.com>
Date: Thu, 7 May 2026 12:07:14 +0200
Subject: [PATCH 17/20] docs: Correct rationale for no-overrides-field ESLint
rule (#29973)
Co-authored-by: Claude Opus 4.7 (1M context)
---
.../docs/rules/no-overrides-field.md | 10 +++++-----
.../src/rules/no-overrides-field.ts | 2 +-
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-overrides-field.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-overrides-field.md
index 8fc4687effb..9f577795c7d 100644
--- a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-overrides-field.md
+++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-overrides-field.md
@@ -6,13 +6,13 @@
## Rule Details
-The `overrides` field in `package.json` lets a package force specific versions of its (transitive) dependencies. In the context of n8n community nodes this is dangerous:
+The `overrides` field in `package.json` forces specific versions of (transitive) dependencies. n8n installs each community package into an isolated `node_modules` tree (peer deps stripped before install, `require()` walks up from each node's compiled file), so an override in one node only affects that node's own resolution — it does **not** bleed into other nodes or into n8n core. The rule bans the field anyway because:
-- Community nodes are installed into a shared n8n runtime alongside other nodes. Overriding a shared library (e.g. `axios`, `@langchain/core`, `minimatch`) can silently substitute an incompatible version for every other node that depends on it, causing hard-to-diagnose runtime failures.
-- Community nodes are distributed as pre-built packages with their dependencies already bundled or declared as `peerDependencies`. Any version pinning that the node actually needs should happen during development, not at install time on the user's n8n instance.
-- `overrides` is frequently copy-pasted from an unrelated internal project and is almost never intentional in a community node.
+- **Almost always unintentional.** In practice, `overrides` blocks in community nodes are copy-pasted boilerplate from unrelated projects, sometimes alongside an empty `dependencies` so the override is a literal no-op.
+- **No useful effect today.** Because of isolation, a maintainer who believes their override coordinates versions across nodes is wrong about what it does. The block is dead weight at best, actively misleading at worst.
+- **Future-proofing.** If the install layout ever moves toward hoisting or partial sharing, today's "harmless" overrides start affecting other nodes' resolution. Banning the field now keeps that change safe to make.
-If you have a genuine compatibility need, bundle the dependency into the published artifact or declare it via `peerDependencies` instead.
+Most community nodes do not need third-party runtime libraries at all. n8n core already provides HTTP requests (`this.helpers.httpRequest`, `this.helpers.httpRequestWithAuthentication`), credential resolution, binary data helpers, and other common building blocks via the execute context — these should be the default. `dependencies` and `peerDependencies` are restricted by [`no-runtime-dependencies`](no-runtime-dependencies.md) and [`valid-peer-dependencies`](valid-peer-dependencies.md) respectively, so neither is a workaround for `overrides`.
## Examples
diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-overrides-field.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-overrides-field.ts
index fb46e151d7c..eb238ac9bca 100644
--- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-overrides-field.ts
+++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-overrides-field.ts
@@ -12,7 +12,7 @@ export const NoOverridesFieldRule = createRule({
},
messages: {
overridesForbidden:
- 'The "overrides" field is not allowed in community node packages. Dependency overrides can introduce incompatible versions of shared libraries into the n8n runtime and cause conflicts with other nodes.',
+ 'The "overrides" field is not allowed in community node packages. Each community package installs into an isolated dependency tree, so overrides do not affect other nodes or n8n core — in practice they are copy-pasted boilerplate with no useful effect. Use the helpers on the execute context (this.helpers.httpRequest, etc.) instead; most community nodes do not need third-party runtime libraries.',
},
schema: [],
},
From 5c7921f71c95d97f6730e6b28b06947b1cfbaa23 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mike=20Repe=C4=87?=
Date: Thu, 7 May 2026 12:10:05 +0200
Subject: [PATCH 18/20] fix(core): Filter WaitTracker to only poll waiting
executions (#29898)
Co-authored-by: Claude Sonnet 4.6
---
.../__tests__/execution.repository.test.ts | 154 +++++++++++++++++-
.../src/repositories/execution.repository.ts | 13 +-
.../__tests__/execution.repository.test.ts | 117 -------------
3 files changed, 160 insertions(+), 124 deletions(-)
delete mode 100644 packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts
diff --git a/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts
index 79c76077048..b9ea7f9572b 100644
--- a/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts
+++ b/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts
@@ -1,10 +1,18 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+import { GlobalConfig } from '@n8n/config';
+import type { SqliteConfig } from '@n8n/config';
import { Container } from '@n8n/di';
-import { In, LessThan, And, Not } from '@n8n/typeorm';
+import type { SelectQueryBuilder } from '@n8n/typeorm';
+import { In, LessThan, LessThanOrEqual, And, Not } from '@n8n/typeorm';
+import { mock } from 'jest-mock-extended';
+import { BinaryDataService } from 'n8n-core';
+import type { IRunExecutionData, IWorkflowBase } from 'n8n-workflow';
+import { nanoid } from 'nanoid';
import { ExecutionEntity } from '../../entities';
import type { IExecutionResponse } from '../../entities/types-db';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
+import { mockInstance } from '../../utils/test-utils/mock-instance';
import { ExecutionRepository } from '../execution.repository';
const GREATER_THAN_MAX_UPDATE_THRESHOLD = 901;
@@ -14,6 +22,10 @@ const GREATER_THAN_MAX_UPDATE_THRESHOLD = 901;
*/
describe('ExecutionRepository', () => {
const entityManager = mockEntityManager(ExecutionEntity);
+ const globalConfig = mockInstance(GlobalConfig, {
+ logging: { outputs: ['console'], scopes: [] },
+ });
+ mockInstance(BinaryDataService);
const executionRepository = Container.get(ExecutionRepository);
beforeEach(() => {
@@ -366,6 +378,18 @@ describe('ExecutionRepository', () => {
await executionRepository.markAsCrashed(manyExecutionsToMarkAsCrashed);
expect(entityManager.update).toBeCalledTimes(2);
});
+
+ test('should clear waitTill when marking executions as crashed', async () => {
+ const executionIds = ['1', '2'];
+
+ await executionRepository.markAsCrashed(executionIds);
+
+ expect(entityManager.update).toHaveBeenCalledWith(
+ ExecutionEntity,
+ { id: In(executionIds) },
+ expect.objectContaining({ status: 'crashed', waitTill: null }),
+ );
+ });
});
describe('stopDuringRun', () => {
@@ -427,7 +451,7 @@ describe('ExecutionRepository', () => {
expect(entityManager.update).toHaveBeenCalledWith(
ExecutionEntity,
{ id: executionId },
- { status: 'running', startedAt: expect.any(Date) },
+ { status: 'running', startedAt: expect.any(Date), waitTill: null },
);
expect(result).toBeInstanceOf(Date);
});
@@ -444,9 +468,133 @@ describe('ExecutionRepository', () => {
expect(entityManager.update).toHaveBeenCalledWith(
ExecutionEntity,
{ id: executionId },
- { status: 'running', startedAt: existingStartedAt },
+ { status: 'running', startedAt: existingStartedAt, waitTill: null },
);
expect(result).toBe(existingStartedAt);
});
});
+
+ describe('cancelMany', () => {
+ test('should clear waitTill when canceling executions', async () => {
+ const executionIds = ['1', '2', '3'];
+
+ await executionRepository.cancelMany(executionIds);
+
+ expect(entityManager.update).toHaveBeenCalledWith(
+ ExecutionEntity,
+ { id: In(executionIds) },
+ expect.objectContaining({ status: 'canceled', waitTill: null }),
+ );
+ });
+ });
+
+ describe('stopBeforeRun', () => {
+ test('should clear waitTill when stopping execution before run', async () => {
+ const execution = mock({
+ id: '1',
+ status: 'waiting',
+ waitTill: new Date('2025-01-01T00:00:00.000Z'),
+ });
+
+ await executionRepository.stopBeforeRun(execution);
+
+ expect(execution.waitTill).toBeNull();
+ expect(execution.status).toBe('canceled');
+ expect(entityManager.update).toHaveBeenCalledWith(
+ ExecutionEntity,
+ { id: '1' },
+ expect.objectContaining({ status: 'canceled', waitTill: null }),
+ );
+ });
+ });
+
+ describe('getWaitingExecutions', () => {
+ const mockDate = new Date('2023-12-28 12:34:56.789Z');
+
+ beforeAll(() => jest.useFakeTimers().setSystemTime(mockDate));
+ afterAll(() => jest.useRealTimers());
+
+ test.each(['sqlite', 'postgresdb'] as const)(
+ 'on %s, should only return executions with status=waiting',
+ async (dbType) => {
+ globalConfig.database.type = dbType;
+ entityManager.find.mockResolvedValueOnce([]);
+
+ await executionRepository.getWaitingExecutions();
+
+ expect(entityManager.find).toHaveBeenCalledWith(ExecutionEntity, {
+ order: { waitTill: 'ASC' },
+ select: ['id', 'waitTill'],
+ where: {
+ status: 'waiting',
+ waitTill: LessThanOrEqual(
+ dbType === 'sqlite'
+ ? '2023-12-28 12:36:06.789'
+ : new Date('2023-12-28T12:36:06.789Z'),
+ ),
+ },
+ });
+ },
+ );
+ });
+
+ describe('deleteExecutionsByFilter', () => {
+ test('should delete binary data', async () => {
+ const workflowId = nanoid();
+ const binaryDataService = Container.get(BinaryDataService);
+
+ jest.spyOn(executionRepository, 'createQueryBuilder').mockReturnValue(
+ mock>({
+ select: jest.fn().mockReturnThis(),
+ andWhere: jest.fn().mockReturnThis(),
+ getMany: jest.fn().mockResolvedValue([{ id: '1', workflowId }]),
+ }),
+ );
+
+ await executionRepository.deleteExecutionsByFilter({
+ filters: { id: '1' },
+ accessibleWorkflowIds: ['1'],
+ deleteConditions: { ids: ['1'] },
+ });
+
+ expect(binaryDataService.deleteMany).toHaveBeenCalledWith([
+ { type: 'execution', executionId: '1', workflowId },
+ ]);
+ });
+ });
+
+ describe('updateExistingExecution', () => {
+ test.each(['sqlite', 'postgresdb'] as const)(
+ 'should update execution and data in transaction on %s',
+ async (dbType) => {
+ globalConfig.database.type = dbType;
+ globalConfig.database.sqlite = mock({ poolSize: 1 });
+
+ const executionId = '1';
+ const execution = mock({
+ id: executionId,
+ data: mock(),
+ workflowData: mock(),
+ status: 'success',
+ });
+
+ const txCallback = jest.fn();
+ entityManager.transaction.mockImplementation(async (fn: unknown) => {
+ await (fn as (em: typeof entityManager) => Promise)(entityManager);
+ txCallback();
+ });
+ entityManager.update.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] });
+
+ await executionRepository.updateExistingExecution(executionId, execution);
+
+ expect(entityManager.transaction).toHaveBeenCalled();
+ expect(entityManager.update).toHaveBeenCalledWith(
+ ExecutionEntity,
+ { id: executionId },
+ expect.objectContaining({ status: 'success' }),
+ );
+ expect(txCallback).toHaveBeenCalledTimes(1);
+ },
+ );
+ });
});
diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts
index bd81c3d3128..ecf8763331b 100644
--- a/packages/@n8n/db/src/repositories/execution.repository.ts
+++ b/packages/@n8n/db/src/repositories/execution.repository.ts
@@ -363,6 +363,7 @@ export class ExecutionRepository extends Repository {
{
status: 'crashed',
stoppedAt: new Date(),
+ waitTill: null,
},
);
this.logger.info('Marked executions as `crashed`', { executionIds });
@@ -382,7 +383,7 @@ export class ExecutionRepository extends Repository {
await manager.update(
ExecutionEntity,
{ id: executionId },
- { status: 'running', startedAt: effectiveStartedAt },
+ { status: 'running', startedAt: effectiveStartedAt, waitTill: null },
);
return effectiveStartedAt;
@@ -608,7 +609,7 @@ export class ExecutionRepository extends Repository {
const waitTill = new Date(Date.now() + 70000);
const where: FindOptionsWhere = {
waitTill: LessThanOrEqual(waitTill),
- status: Not('crashed'),
+ status: 'waiting',
};
const dbType = this.globalConfig.database.type;
@@ -783,10 +784,11 @@ export class ExecutionRepository extends Repository {
async stopBeforeRun(execution: IExecutionResponse) {
execution.status = 'canceled';
execution.stoppedAt = new Date();
+ execution.waitTill = null;
await this.update(
{ id: execution.id },
- { status: execution.status, stoppedAt: execution.stoppedAt },
+ { status: execution.status, stoppedAt: execution.stoppedAt, waitTill: execution.waitTill },
);
return execution;
@@ -813,7 +815,10 @@ export class ExecutionRepository extends Repository {
}
async cancelMany(executionIds: string[]) {
- await this.update({ id: In(executionIds) }, { status: 'canceled', stoppedAt: new Date() });
+ await this.update(
+ { id: In(executionIds) },
+ { status: 'canceled', stoppedAt: new Date(), waitTill: null },
+ );
}
// ----------------------------------
diff --git a/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts
deleted file mode 100644
index bf66afdc84e..00000000000
--- a/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { mockInstance } from '@n8n/backend-test-utils';
-import { GlobalConfig } from '@n8n/config';
-import type { SqliteConfig } from '@n8n/config';
-import type { IExecutionResponse } from '@n8n/db';
-import { ExecutionEntity, ExecutionRepository } from '@n8n/db';
-import { Container } from '@n8n/di';
-import type { SelectQueryBuilder } from '@n8n/typeorm';
-import { Not, LessThanOrEqual } from '@n8n/typeorm';
-import { mock } from 'jest-mock-extended';
-import { BinaryDataService } from 'n8n-core';
-import type { IRunExecutionData, IWorkflowBase } from 'n8n-workflow';
-import { nanoid } from 'nanoid';
-
-import { mockEntityManager } from '@test/mocking';
-
-describe('ExecutionRepository', () => {
- const entityManager = mockEntityManager(ExecutionEntity);
- const globalConfig = mockInstance(GlobalConfig, {
- logging: { outputs: ['console'], scopes: [] },
- });
- const binaryDataService = mockInstance(BinaryDataService);
- const executionRepository = Container.get(ExecutionRepository);
- const mockDate = new Date('2023-12-28 12:34:56.789Z');
-
- beforeAll(() => {
- jest.clearAllMocks();
- jest.useFakeTimers().setSystemTime(mockDate);
- });
-
- afterAll(() => jest.useRealTimers());
-
- describe('getWaitingExecutions()', () => {
- test.each(['sqlite', 'postgresdb'] as const)(
- 'on %s, should be called with expected args',
- async (dbType) => {
- globalConfig.database.type = dbType;
- entityManager.find.mockResolvedValueOnce([]);
-
- await executionRepository.getWaitingExecutions();
-
- expect(entityManager.find).toHaveBeenCalledWith(ExecutionEntity, {
- order: { waitTill: 'ASC' },
- select: ['id', 'waitTill'],
- where: {
- status: Not('crashed'),
- waitTill: LessThanOrEqual(
- dbType === 'sqlite'
- ? '2023-12-28 12:36:06.789'
- : new Date('2023-12-28T12:36:06.789Z'),
- ),
- },
- });
- },
- );
- });
-
- describe('deleteExecutionsByFilter', () => {
- test('should delete binary data', async () => {
- const workflowId = nanoid();
-
- jest.spyOn(executionRepository, 'createQueryBuilder').mockReturnValue(
- mock>({
- select: jest.fn().mockReturnThis(),
- andWhere: jest.fn().mockReturnThis(),
- getMany: jest.fn().mockResolvedValue([{ id: '1', workflowId }]),
- }),
- );
-
- await executionRepository.deleteExecutionsByFilter({
- filters: { id: '1' },
- accessibleWorkflowIds: ['1'],
- deleteConditions: { ids: ['1'] },
- });
-
- expect(binaryDataService.deleteMany).toHaveBeenCalledWith([
- { type: 'execution', executionId: '1', workflowId },
- ]);
- });
- });
-
- describe('updateExistingExecution', () => {
- test.each(['sqlite', 'postgresdb'] as const)(
- 'should update execution and data in transaction on %s',
- async (dbType) => {
- globalConfig.database.type = dbType;
- globalConfig.database.sqlite = mock({ poolSize: 1 });
-
- const executionId = '1';
- const execution = mock({
- id: executionId,
- data: mock(),
- workflowData: mock(),
- status: 'success',
- });
-
- const txCallback = jest.fn();
- entityManager.transaction.mockImplementation(async (cb) => {
- // @ts-expect-error Mock
- await cb(entityManager);
- txCallback();
- });
- // Mock update to return affected count
- entityManager.update.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] });
-
- await executionRepository.updateExistingExecution(executionId, execution);
-
- expect(entityManager.transaction).toHaveBeenCalled();
- expect(entityManager.update).toHaveBeenCalledWith(
- ExecutionEntity,
- { id: executionId },
- expect.objectContaining({ status: 'success' }),
- );
- expect(txCallback).toHaveBeenCalledTimes(1);
- },
- );
- });
-});
From db0097c57f7b2f46a1294ee1ef10b24a6245fc9f Mon Sep 17 00:00:00 2001
From: Matsu
Date: Thu, 7 May 2026 13:43:14 +0300
Subject: [PATCH 19/20] ci: Make Chromatic visual checks non-blocking
(no-changelog) (#29965)
Co-authored-by: Claude Opus 4.7 (1M context)
---
.github/workflows/ci-pull-request-review.yml | 9 +++++++--
.github/workflows/test-visual-chromatic.yml | 2 +-
2 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/ci-pull-request-review.yml b/.github/workflows/ci-pull-request-review.yml
index 5ecc3985b58..3926c2dfc00 100644
--- a/.github/workflows/ci-pull-request-review.yml
+++ b/.github/workflows/ci-pull-request-review.yml
@@ -41,7 +41,12 @@ jobs:
chromatic:
name: Chromatic
needs: filter
- if: needs.filter.outputs.design_system == 'true'
+ # Skip on fork PRs — they don't have access to the Chromatic secret.
+ # This job is intentionally not in `required-review-checks` needs, so it
+ # is non-blocking and won't gate merging.
+ if: >-
+ needs.filter.outputs.design_system == 'true' &&
+ github.event.pull_request.head.repo.full_name == github.repository
uses: ./.github/workflows/test-visual-chromatic.yml
with:
ref: ${{ needs.filter.outputs.commit_sha }}
@@ -51,7 +56,7 @@ jobs:
# PRs cannot be merged unless this job passes.
required-review-checks:
name: Required Review Checks
- needs: [filter, chromatic]
+ needs: [filter]
if: always()
runs-on: ubuntu-slim
steps:
diff --git a/.github/workflows/test-visual-chromatic.yml b/.github/workflows/test-visual-chromatic.yml
index 8b8022adca3..f0185c74700 100644
--- a/.github/workflows/test-visual-chromatic.yml
+++ b/.github/workflows/test-visual-chromatic.yml
@@ -34,4 +34,4 @@ jobs:
skip: 'release/**'
onlyChanged: true
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
- exitZeroOnChanges: false
+ exitZeroOnChanges: true
From ad0a6e9d46ab419434203293e649d821f5df642f Mon Sep 17 00:00:00 2001
From: Matsu
Date: Thu, 7 May 2026 13:43:24 +0300
Subject: [PATCH 20/20] ci: Use a configurable json file for safechain options
(#29960)
---
.github/actions/setup-nodejs/action.yml | 11 +++++++----
.../actions/setup-nodejs/safe-chain.config.json | 16 ++++++++++++++++
2 files changed, 23 insertions(+), 4 deletions(-)
create mode 100644 .github/actions/setup-nodejs/safe-chain.config.json
diff --git a/.github/actions/setup-nodejs/action.yml b/.github/actions/setup-nodejs/action.yml
index 97fec95f010..af4617d253e 100644
--- a/.github/actions/setup-nodejs/action.yml
+++ b/.github/actions/setup-nodejs/action.yml
@@ -45,6 +45,13 @@ runs:
mkdir -p "$PNPM_STORE_PATH"
fi
+ - name: Configure SafeChain
+ shell: bash
+ run: |
+ # SafeChain only reads configs from this directory https://github.com/AikidoSec/safe-chain#configuration-options-1
+ mkdir -p "$HOME/.safe-chain"
+ cp "${{ github.action_path }}/safe-chain.config.json" "$HOME/.safe-chain/config.json"
+
- name: Install Aikido SafeChain
run: |
VERSION="1.5.1"
@@ -54,10 +61,6 @@ runs:
echo "${EXPECTED_SHA256} install-safe-chain.sh" | sha256sum -c -
sh install-safe-chain.sh --ci
rm install-safe-chain.sh
- # Exclude first-party @n8n/* packages from SafeChain's minimum-package-age
- # filter so freshly-published versions stay visible to every subsequent
- # step in the job (install, build, and publish).
- echo "SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS=@n8n/*,n8n,n8n-containers,n8n-core,n8n-editor-ui,n8n-node-dev,n8n-nodes-base,n8n-playwright,n8n-workflow" >> "$GITHUB_ENV"
shell: bash
- name: Install Dependencies
diff --git a/.github/actions/setup-nodejs/safe-chain.config.json b/.github/actions/setup-nodejs/safe-chain.config.json
new file mode 100644
index 00000000000..42bda807904
--- /dev/null
+++ b/.github/actions/setup-nodejs/safe-chain.config.json
@@ -0,0 +1,16 @@
+{
+ "npm": {
+ "minimumPackageAgeExclusions": [
+ "@n8n/*",
+ "@n8n_io/*",
+ "n8n",
+ "n8n-containers",
+ "n8n-core",
+ "n8n-editor-ui",
+ "n8n-node-dev",
+ "n8n-nodes-base",
+ "n8n-playwright",
+ "n8n-workflow"
+ ]
+ }
+}