🔀 Sync master

This commit is contained in:
Milorad FIlipović 2026-09-21 11:03:39 +02:00
commit 5106791666
578 changed files with 28248 additions and 6756 deletions

View File

@ -45,6 +45,9 @@ throw new UnexpectedError('message', { extra: { context } });
**Testing:**
- Vitest (unit), Playwright (E2E)
- Mock external dependencies
- Keep filesystem tests in a test-owned temporary directory
- Set `N8N_USER_FOLDER` before importing settings code
- Trace branches activated by mocks and isolate every reachable mutation
- Work from package directory: `pushd packages/cli && pnpm test`
**Database:**

View File

@ -73,6 +73,12 @@ Write a regression test that:
- Asserts the CORRECT behavior (test will fail on current code)
- Also includes a "happy path" test to prove the setup works
Complete the test safety gate in `/AGENTS.md` before you run the test:
- Trace side effects from imports, constructors, hooks, and mocked branches
- Keep filesystem access in a test-owned temporary directory
- Set `N8N_USER_FOLDER` before importing modules that resolve it
- When a mock changes a state check, inspect and isolate the branch that it activates
## Step 8: Run and Score
Run the test from the package directory (e.g., `cd packages/nodes-base && pnpm test <file>`).

View File

@ -93,7 +93,7 @@ needs `gh` with the codespace scope, the same as `pnpm session`.
present. `preview:debug` sets `N8N_LOG_LEVEL=debug`. Adding or removing one
re-serves the box; it never creates or deletes one. From a laptop the labels
apply the same way — `pnpm preview refresh <pr>` reads them from the PR. The
toggles are defined in `scripts/preview-labels.mjs`; add new ones there.
toggles are defined in `scripts/codespace-preview/preview-labels.mjs`; add new ones there.
- **Configure the instance from a webhook.** A preview also reads extra
environment from an n8n webhook, so a value can change without a commit. It
needs the `CODESPACE_ENV_URL`, `CODESPACE_ENV_USER` and `CODESPACE_ENV_PASSWORD`
@ -108,7 +108,7 @@ needs `gh` with the codespace scope, the same as `pnpm session`.
builds a new one.
- **A PR from a fork gets no preview.** A codespace's token is scoped to
`n8n-io/n8n`, so it cannot check out a fork head.
- **A PR that predates this tooling has no `scripts/preview-serve.mjs`.** The
- **A PR that predates this tooling has no `scripts/codespace-preview/preview-serve.mjs`.** The
serve step says so and stops; rebase the PR on master and retry.
## Agent worker (drive a session from n8n)

View File

@ -36,7 +36,7 @@
"postStartCommand": "git config --global --get-all credential.helper 2>/dev/null | grep -qs gitcredential-refresh || git config --global --add credential.helper /usr/local/bin/gitcredential-refresh.sh",
// Declarations only. All of these are set at the repository level, so Codespaces
// injects them into every preview box whether or not they are listed here.
// scripts/preview-serve.mjs reads them with codespaceSecret().
// scripts/codespace-preview/preview-serve.mjs reads them with codespaceSecret().
"secrets": {
"N8N_LICENSE_ACTIVATION_KEY": {
"description": "Sandbox licence key. The preview:enterprise label decides whether it reaches n8n."

View File

@ -1,6 +1,6 @@
/**
* One-click sign-in for preview instances. Loaded through EXTERNAL_HOOK_FILES by
* scripts/preview-serve.mjs, so it is never part of a packaged n8n.
* scripts/codespace-preview/preview-serve.mjs, so it is never part of a packaged n8n.
*
* The route performs an ordinary login on the visitor's behalf: it calls
* /rest/login with the preview instance's own seeded credentials and forwards the

60
.github/WORKFLOWS.md vendored
View File

@ -254,7 +254,7 @@ parallelism). See the `--build-via-mcp` section in
| `preview:debug` | `util-codespace-preview.yml` | Re-serves the instance with `N8N_LOG_LEVEL=debug` |
**Why:** A reviewer gets a running instance of the PR without a Docker build or a
cloud deploy. The workflow calls `scripts/preview.mjs`, which keeps one codespace
cloud deploy. The workflow calls `scripts/codespace-preview/preview.mjs`, which keeps one codespace
for each PR (display name `preview/pr-<number>`) and shares port 5678 with the
organization. A later push serves the new head in the same box. Removing the
label, or closing the PR, deletes the box.
@ -266,7 +266,7 @@ scoped to `n8n-io/n8n` and cannot check out a fork head.
`up` and `refresh` take minutes, and an absent comment looks the same as a broken
preview. So the comment goes up before the box work starts, as a checklist of the
phases in `scripts/preview-phases.mjs`, and is edited for each phase and once a
phases in `scripts/codespace-preview/preview-phases.mjs`, and is edited for each phase and once a
minute after that. The final URL replaces it in place.
A comment **edit sends no notification** — only a create does. That is what makes a
@ -310,12 +310,12 @@ A `preview:*` label configures an instance that already exists, so adding or
removing one re-serves the box instead of creating or deleting it. It does
nothing on a PR without `codespace-preview`.
The vocabulary lives in `scripts/preview-labels.mjs`, which both ends import:
The vocabulary lives in `scripts/codespace-preview/preview-labels.mjs`, which both ends import:
`preview.mjs` turns the PR's labels into slugs, and `preview-serve.mjs` turns
those slugs into environment inside the box. Add a toggle there, in one place.
Two things cross the gap, and both are shape-checked rather than trusted: a
`preview:*` slug, and a phase key from `scripts/preview-phases.mjs`. The `gh
`preview:*` slug, and a phase key from `scripts/codespace-preview/preview-phases.mjs`. The `gh
codespace ssh` command is a shell string that appears in the box's process list, so
a value is never passed through it — `preview:enterprise` resolves to a licence key
inside the box, not on the runner.
@ -336,7 +336,7 @@ in this repository, so that is the set of people who already have write access.
#### Preview environment from a webhook
A preview can also take environment from an n8n webhook we control, so a value
can change without a commit and a merge. `scripts/preview-remote-env.mjs` fetches
can change without a commit and a merge. `scripts/codespace-preview/preview-remote-env.mjs` fetches
it, and `preview-serve.mjs` hands the result to the backend.
It needs three **Codespaces** secrets on `n8n-io/n8n`, again not Actions secrets:
@ -802,11 +802,11 @@ Scripts in `.github/scripts/`:
| Script | Purpose | Called By |
|---------------------------------|-------------------------------------------------------------------------|--------------------------------|
| `codespace-preview.mjs` | Map a `pull_request` event or a manual operation onto a preview operation, comment the result | `util-codespace-preview.yml` |
| `../../scripts/preview.mjs` | One codespace for each PR: `up`, `refresh`, `down`, `ls`. `--json` for CI | `codespace-preview.mjs`, developers |
| `../../scripts/preview-remote-env.mjs` | Fetch extra environment for a preview from the webhook, inside the box | `../../scripts/preview-serve.mjs` |
| `../../scripts/preview-phases.mjs` | The phase vocabulary and its one-line marker, so the runner, the box and the comment cannot drift | `codespace-preview.mjs`, `../../scripts/preview.mjs`, `../../scripts/preview-serve.mjs` |
| `../../scripts/codespace-preview/preview.mjs` | One codespace for each PR: `up`, `refresh`, `down`, `ls`. `--json` for CI | `codespace-preview.mjs`, developers |
| `../../scripts/codespace-preview/preview-remote-env.mjs` | Fetch extra environment for a preview from the webhook, inside the box | `../../scripts/codespace-preview/preview-serve.mjs` |
| `../../scripts/codespace-preview/preview-phases.mjs` | The phase vocabulary and its one-line marker, so the runner, the box and the comment cannot drift | `codespace-preview.mjs`, `../../scripts/codespace-preview/preview.mjs`, `../../scripts/codespace-preview/preview-serve.mjs` |
`scripts/preview.mjs` is also the developer entry point (`pnpm preview up <pr>`).
`scripts/codespace-preview/preview.mjs` is also the developer entry point (`pnpm preview up <pr>`).
In `--json` mode stdout carries one line for each phase and then the report object,
and all human progress goes to stderr. So a workflow can follow a run that also
streams an in-box build log. The reader tells the two apart by the `url` field: the
@ -889,14 +889,42 @@ PR changes and review events, and reports a commit status
named **Required Reviews** on the head SHA. A missing approval reports
`pending` ("Waiting for approval from: …"), not `failure`, so an unreviewed PR
does not show red CI; any non-success state blocks the merge equally. The
ruleset for `master` must list
that status as a required check for the block to take effect. Merge-queue runs
report success on the queue head without re-evaluating: a PR cannot enter the
queue unless the status is green on its head, and the queue does not change
approvals.
ruleset for a branch must list that status as a required check for the block
to take effect. Merge-queue runs report success on the queue head without
re-evaluating: a PR cannot enter the queue unless the status is green on its
head, and the queue does not change approvals.
The workflow reads OWNERS and its scripts from the base branch only, so a PR
cannot lift its own review requirement.
The status is evaluated for a PR into any base branch, from a same-repo head
or a fork. Both matter because the ruleset that gates a PR is not always the
one on its base branch: GitHub applies the ruleset of a stack's target branch
to every PR in the stack, so a stacked PR into a feature branch is gated by
the `master` ruleset. A required status that no run ever writes leaves the PR
blocked on "Expected". Routes that skip the evaluation are listed in
`REQUIRED_REVIEW_EXEMPTIONS` in
`required-reviews.mjs`. An entry is `<head> -> <base>` or just `<base>`
(any head); `*` matches any run of characters. An exempt PR reports
`success` with the route in the description. Only heads in this repository
can match, so a fork branch with a matching name is still evaluated. Add a
route only when every commit it carries was already reviewed elsewhere, as
with `sync/master-to-3x -> 3.x`: its commits landed on `master` first.
The workflow reads OWNERS, its scripts and the exemption routes from `master`
only, never from the base branch or the PR: any writable branch can be a base,
so only `master` is trusted input. A PR cannot lift its own review
requirement. A retarget re-evaluates the PR, so a verdict computed against the
old base does not carry over.
Every path that writes the status runs in the base repository context, because
a fork-context run has no secrets and a read-only token. PR changes arrive
through `pull_request_target`, which is safe here because no step checks out
or runs PR code. Review events on a same-repo PR arrive through
`pull_request_review`. Review events on a fork PR arrive through the
`workflow_run` of `ci-pull-request-review.yml`, which runs on every submitted
or dismissed review; the owners workflow looks the PR up from that run's head
and skips same-repo heads, which the direct event already covers. A first
contribution whose runs still wait for approval gets no review-event
re-evaluation until a maintainer approves the runs; `workflow_dispatch` with
the PR number is the manual fallback.
### Transition from CODEOWNERS

View File

@ -1,5 +1,5 @@
#!/usr/bin/env node
// Drives a PR preview instance from CI. `scripts/preview.mjs` does the work; this
// Drives a PR preview instance from CI. `scripts/codespace-preview/preview.mjs` does the work; this
// script maps the pull_request event onto one of its operations and reports the
// result back to the PR as a single, edited-in-place comment.
//
@ -23,8 +23,8 @@
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { PREVIEW_LABEL_PREFIX } from '../../scripts/preview-labels.mjs';
import { PREVIEW_PHASES, createLineSplitter, phaseIndex } from '../../scripts/preview-phases.mjs';
import { PREVIEW_LABEL_PREFIX } from '../../scripts/codespace-preview/preview-labels.mjs';
import { PREVIEW_PHASES, createLineSplitter, phaseIndex } from '../../scripts/codespace-preview/preview-phases.mjs';
import {
ensureEnvVar,
findCommentByMarker,
@ -50,7 +50,7 @@ export const WORKFLOW_URL =
// What a manual run may ask for. `ls` is absent: it needs no PR and posts no comment.
export const DISPATCH_OPERATIONS = ['up', 'refresh', 'down'];
// Resolved against this file, so the script runs the same from any directory.
const PREVIEW_SCRIPT = fileURLToPath(new URL('../../scripts/preview.mjs', import.meta.url));
const PREVIEW_SCRIPT = fileURLToPath(new URL('../../scripts/codespace-preview/preview.mjs', import.meta.url));
// Often enough to look alive, rarely enough that the edit history stays readable.
const HEARTBEAT_MS = 60_000;

View File

@ -414,6 +414,26 @@ export async function getPullRequestById(pullRequestId) {
return pullRequest.data;
}
/**
* Open PRs whose head is `headOwner:headBranch`, newest first.
*
* @param { string } headOwner Owner of the head repository (a fork owner or this org).
* @param { string } headBranch
* @returns { Promise<any[]> }
*/
export async function listOpenPullRequestsByHead(headOwner, headBranch) {
const { octokit, owner, repo } = initGithub();
const pullRequests = await octokit.rest.pulls.list({
owner,
repo,
state: 'open',
head: `${headOwner}:${headBranch}`,
});
return pullRequests.data;
}
/**
* Returns the set of files changed in a PR, including previous filenames for renames.
*

View File

@ -8,6 +8,9 @@
* runs do not reach this script: the workflow reports success on the queue
* head directly, because entering the queue already required a green status.
*
* Every base branch is enforced unless the PR matches a route in
* REQUIRED_REVIEW_EXEMPTIONS; an exempt PR reports success without evaluation.
*
* The job itself always succeeds when the evaluation runs; the commit status
* carries the verdict. The status is set to pending before the evaluation
* starts, so a crash (e.g. the team membership API is unavailable) cannot
@ -20,12 +23,30 @@ import {
getPrReviews,
getPullRequestById,
isTeamMember,
listOpenPullRequestsByHead,
setCommitStatus,
} from '../github-helpers.mjs';
import { parseOwnersFile, resolveRequiredTeams, teamHandleToSlug } from './owners.mjs';
export const STATUS_CONTEXT = 'Required Reviews';
const TARGET_BRANCH = 'master';
/**
* PR routes that skip the evaluation. Each entry is `<head> -> <base>` or
* just `<base>` (any head). `*` matches any run of characters, including
* `/`. Only heads in this repository qualify; a fork branch with a
* matching name does not.
*
* Add a route here only when every commit it carries was already reviewed
* elsewhere, like the master-to-3.x sync, whose commits landed on master.
*/
export const REQUIRED_REVIEW_EXEMPTIONS = ['sync/master-to-3x -> 3.x'];
/**
* @typedef ExemptRoute
* @property { string } head Glob for the head branch.
* @property { string } base Glob for the base branch.
* @property { string } source The entry as written.
*/
/**
* A PR review as returned by the reviews API.
@ -36,17 +57,68 @@ const TARGET_BRANCH = 'master';
* @property { string } [submitted_at]
*/
/**
* @param { string } entry `<head> -> <base>` or `<base>`
* @returns { ExemptRoute }
*/
export function parseExemption(entry) {
const parts = entry.split('->').map((part) => part.trim());
if (parts.length > 2 || parts.some((part) => part === '')) {
throw new Error(`Invalid exemption "${entry}": expected "<head> -> <base>" or "<base>"`);
}
const [head, base] = parts.length === 2 ? parts : ['*', parts[0]];
return { head, base, source: entry };
}
/**
* @param { string } ref Branch name.
* @param { string } pattern Glob where `*` matches any run of characters.
* @returns { boolean }
*/
export function matchesBranchPattern(ref, pattern) {
const escaped = pattern.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return new RegExp(`^${escaped.join('.*')}$`).test(ref);
}
/**
* @param { { head: { ref: string, repo: { full_name: string } | null }, base: { ref: string, repo: { full_name: string } } } } pullRequest
* @param { string[] } [exemptions]
* @returns { ExemptRoute | undefined } The first matching route.
*/
export function findExemption(pullRequest, exemptions = REQUIRED_REVIEW_EXEMPTIONS) {
// A fork can name its branch anything, so the head must live in this repo.
if (pullRequest.head.repo?.full_name !== pullRequest.base.repo.full_name) return undefined;
return exemptions
.map(parseExemption)
.find(
(route) =>
matchesBranchPattern(pullRequest.head.ref, route.head) &&
matchesBranchPattern(pullRequest.base.ref, route.base),
);
}
/**
* Resolve the PR number from the triggering event.
*
* A workflow_run event carries no PR: the PR is looked up from the
* triggering run's head. Only an open PR whose head SHA is the run's head
* SHA counts; several PRs can share a head branch.
*
* @param { string } eventName
* @param { any } event Parsed GITHUB_EVENT_PATH payload.
* @param { string | undefined } pullRequestNumberEnv PULL_REQUEST_NUMBER (workflow_dispatch input).
* @returns { number }
* @returns { Promise<number | undefined> } undefined when the event has no open PR to evaluate.
*/
export function resolvePullRequestNumber(eventName, event, pullRequestNumberEnv) {
export async function resolvePullRequestNumber(eventName, event, pullRequestNumberEnv) {
if (event?.pull_request?.number) return event.pull_request.number;
if (eventName === 'workflow_run' && event?.workflow_run) {
const { head_sha: sha, head_branch: branch, head_repository: headRepository } = event.workflow_run;
const candidates = await listOpenPullRequestsByHead(headRepository.owner.login, branch);
return candidates.find((pullRequest) => pullRequest.head.sha === sha)?.number;
}
const parsed = parseInt(pullRequestNumberEnv ?? '');
if (Number.isNaN(parsed)) {
throw new Error(`Cannot resolve a PR number for event "${eventName}"`);
@ -160,18 +232,16 @@ export async function run() {
const eventName = process.env.GITHUB_EVENT_NAME ?? 'workflow_dispatch';
const event = getEventFromGithubEventPath();
const pullRequestNumber = resolvePullRequestNumber(
const pullRequestNumber = await resolvePullRequestNumber(
eventName,
event,
process.env.PULL_REQUEST_NUMBER,
);
const pullRequest = await getPullRequestById(pullRequestNumber);
if (pullRequest.base.ref !== TARGET_BRANCH) {
console.log(`PR #${pullRequestNumber} targets "${pullRequest.base.ref}", not "${TARGET_BRANCH}"; skipping.`);
if (pullRequestNumber === undefined) {
console.log(`No open PR for the ${eventName} head; nothing to evaluate.`);
return;
}
const pullRequest = await getPullRequestById(pullRequestNumber);
const statusSha = pullRequest.head.sha;
const targetUrl = statusTargetUrl();
@ -187,7 +257,13 @@ export async function run() {
/** @type { { state: 'success' | 'pending', description: string } } */
let status;
try {
status = await evaluateRequiredReviews(pullRequestNumber);
const exemption = findExemption(pullRequest);
if (exemption) {
console.log(`PR #${pullRequestNumber} matches exempt route "${exemption.source}"; skipping the evaluation.`);
status = { state: 'success', description: `Exempt route: ${exemption.source}` };
} else {
status = await evaluateRequiredReviews(pullRequestNumber);
}
} catch (evaluationError) {
// Best effort: nicer than a stuck pending status. The pending status
// already blocks the merge if this write fails too.

View File

@ -19,6 +19,8 @@ let getPrReviewsImpl = async () => [];
let isTeamMemberImpl = async () => false;
/** @type {(sha: string, status: any) => Promise<void>} */
let setCommitStatusImpl = async () => {};
/** @type {(headOwner: string, headBranch: string) => Promise<any[]>} */
let listOpenPullRequestsByHeadImpl = async () => [];
mock.module('../github-helpers.mjs', {
namedExports: {
@ -28,6 +30,8 @@ mock.module('../github-helpers.mjs', {
getPrReviews: (n) => getPrReviewsImpl(n),
isTeamMember: (slug, username) => isTeamMemberImpl(slug, username),
setCommitStatus: (sha, status) => setCommitStatusImpl(sha, status),
listOpenPullRequestsByHead: (headOwner, headBranch) =>
listOpenPullRequestsByHeadImpl(headOwner, headBranch),
},
});
@ -46,28 +50,150 @@ mock.module('./owners.mjs', {
});
const {
REQUIRED_REVIEW_EXEMPTIONS,
STATUS_CONTEXT,
buildStatus,
collectApprovers,
findExemption,
latestReviewStates,
matchesBranchPattern,
parseExemption,
resolvePullRequestNumber,
run,
} = await import('./required-reviews.mjs');
const REPO = 'n8n-io/n8n';
/** @param {string} head @param {string} base @param {string} [headRepo] */
function pullRequestFor(head, base, headRepo = REPO) {
return {
head: { ref: head, sha: 'head-sha', repo: { full_name: headRepo } },
base: { ref: base, repo: { full_name: REPO } },
};
}
describe('parseExemption', () => {
it('splits a "<head> -> <base>" route', () => {
assert.deepEqual(parseExemption('sync/master-to-3x -> 3.x'), {
head: 'sync/master-to-3x',
base: '3.x',
source: 'sync/master-to-3x -> 3.x',
});
});
it('treats a bare "<base>" as any head', () => {
assert.deepEqual(parseExemption('release/*'), { head: '*', base: 'release/*', source: 'release/*' });
});
it('rejects malformed routes', () => {
assert.throws(() => parseExemption('a -> b -> c'), /Invalid exemption/);
assert.throws(() => parseExemption(' -> 3.x'), /Invalid exemption/);
assert.throws(() => parseExemption('3.x -> '), /Invalid exemption/);
});
it('accepts every configured exemption', () => {
for (const entry of REQUIRED_REVIEW_EXEMPTIONS) parseExemption(entry);
});
});
describe('matchesBranchPattern', () => {
it('matches whole branch names', () => {
assert.equal(matchesBranchPattern('3.x', '3.x'), true);
assert.equal(matchesBranchPattern('3.xy', '3.x'), false);
assert.equal(matchesBranchPattern('v3.x', '3.x'), false);
});
it('treats "." as a literal', () => {
assert.equal(matchesBranchPattern('3ax', '3.x'), false);
});
it('expands "*" across path separators', () => {
assert.equal(matchesBranchPattern('release/2.39.5', 'release/*'), true);
assert.equal(matchesBranchPattern('release/2.39.5/hotfix', 'release/*'), true);
assert.equal(matchesBranchPattern('anything', '*'), true);
assert.equal(matchesBranchPattern('release-candidate/2.40.0', 'release/*'), false);
});
});
describe('findExemption', () => {
const exemptions = ['sync/master-to-3x -> 3.x', 'release/*'];
it('finds the route for a matching head and base', () => {
const route = findExemption(pullRequestFor('sync/master-to-3x', '3.x'), exemptions);
assert.equal(route?.source, 'sync/master-to-3x -> 3.x');
});
it('returns nothing when only the base matches', () => {
assert.equal(findExemption(pullRequestFor('feature/x', '3.x'), exemptions), undefined);
});
it('returns nothing when only the head matches', () => {
assert.equal(findExemption(pullRequestFor('sync/master-to-3x', 'master'), exemptions), undefined);
});
it('matches a bare base route for any head', () => {
const route = findExemption(pullRequestFor('feature/x', 'release/2.39.5'), exemptions);
assert.equal(route?.source, 'release/*');
});
it('ignores heads from another repository', () => {
const fromFork = pullRequestFor('sync/master-to-3x', '3.x', 'someone/n8n');
assert.equal(findExemption(fromFork, exemptions), undefined);
});
it('ignores heads whose repository is gone', () => {
const pullRequest = pullRequestFor('sync/master-to-3x', '3.x');
pullRequest.head.repo = null;
assert.equal(findExemption(pullRequest, exemptions), undefined);
});
});
describe('resolvePullRequestNumber', () => {
it('reads the PR number from pull_request payloads', () => {
/** A workflow_run payload for a run on `owner/branch` at `sha`. */
function workflowRunEvent(owner, branch, sha) {
return { workflow_run: { head_sha: sha, head_branch: branch, head_repository: { owner: { login: owner } } } };
}
beforeEach(() => {
listOpenPullRequestsByHeadImpl = async () => [];
});
it('reads the PR number from pull_request payloads', async () => {
assert.equal(
resolvePullRequestNumber('pull_request', { pull_request: { number: 42 } }, undefined),
await resolvePullRequestNumber('pull_request_target', { pull_request: { number: 42 } }, undefined),
42,
);
});
it('falls back to the PULL_REQUEST_NUMBER env value for workflow_dispatch', () => {
assert.equal(resolvePullRequestNumber('workflow_dispatch', {}, '7'), 7);
it('falls back to the PULL_REQUEST_NUMBER env value for workflow_dispatch', async () => {
assert.equal(await resolvePullRequestNumber('workflow_dispatch', {}, '7'), 7);
});
it('throws when no source yields a PR number', () => {
assert.throws(() => resolvePullRequestNumber('workflow_dispatch', {}, undefined), /Cannot resolve/);
it('rejects when no source yields a PR number', async () => {
await assert.rejects(resolvePullRequestNumber('workflow_dispatch', {}, undefined), /Cannot resolve/);
});
it('looks up the open PR for a workflow_run head', async () => {
const listOpenPullRequestsByHead = mock.fn(async () => [
{ number: 11, head: { sha: 'old-sha' } },
{ number: 12, head: { sha: 'run-sha' } },
]);
listOpenPullRequestsByHeadImpl = listOpenPullRequestsByHead;
assert.equal(
await resolvePullRequestNumber('workflow_run', workflowRunEvent('forker', 'fix/x', 'run-sha'), undefined),
12,
);
assert.deepEqual(listOpenPullRequestsByHead.mock.calls[0].arguments, ['forker', 'fix/x']);
});
it('returns undefined when no open PR has the workflow_run head SHA', async () => {
listOpenPullRequestsByHeadImpl = async () => [{ number: 11, head: { sha: 'old-sha' } }];
assert.equal(
await resolvePullRequestNumber('workflow_run', workflowRunEvent('forker', 'fix/x', 'run-sha'), undefined),
undefined,
);
});
});
@ -152,8 +278,7 @@ describe('run', () => {
eventImpl = () => ({ pull_request: { number: 42 } });
getPullRequestByIdImpl = async () => ({
base: { ref: 'master' },
head: { sha: 'head-sha' },
...pullRequestFor('feature/x', 'master'),
user: { login: 'author' },
});
getChangedFilesImpl = async () => new Set(['a.ts']);
@ -267,15 +392,53 @@ describe('run', () => {
assert.equal(status.state, 'pending');
});
it('skips PRs that do not target master without setting a status', async () => {
getPullRequestByIdImpl = async () => ({
base: { ref: 'release-candidate/2.9.x' },
head: { sha: 'head-sha' },
it('writes no status for a workflow_run head without an open PR', async () => {
process.env.GITHUB_EVENT_NAME = 'workflow_run';
eventImpl = () => ({
workflow_run: { head_sha: 'gone-sha', head_branch: 'fix/x', head_repository: { owner: { login: 'forker' } } },
});
listOpenPullRequestsByHeadImpl = async () => [];
await run();
assert.equal(setCommitStatus.mock.calls.length, 0);
});
it('evaluates PRs into branches other than master', async () => {
getPullRequestByIdImpl = async () => pullRequestFor('feature/x', 'release-candidate/2.9.x');
resolveRequiredTeamsImpl = () => new Map([['@n8n-io/qa-dx', ['a.ts']]]);
await run();
const [, status] = setCommitStatus.mock.calls.at(-1).arguments;
assert.equal(status.state, 'pending');
assert.match(status.description, /Waiting for approval from: qa-dx/);
});
it('reports success without evaluating a PR on an exempt route', async () => {
getPullRequestByIdImpl = async () => pullRequestFor('sync/master-to-3x', '3.x');
const getChangedFiles = mock.fn(async () => new Set(['a.ts']));
getChangedFilesImpl = getChangedFiles;
resolveRequiredTeamsImpl = () => new Map([['@n8n-io/qa-dx', ['a.ts']]]);
await run();
assert.equal(getChangedFiles.mock.calls.length, 0);
assert.equal(setCommitStatus.mock.calls.length, 2);
assert.equal(setCommitStatus.mock.calls[0].arguments[1].state, 'pending');
const [, status] = setCommitStatus.mock.calls[1].arguments;
assert.equal(status.state, 'success');
assert.equal(status.description, 'Exempt route: sync/master-to-3x -> 3.x');
});
it('does not exempt a fork branch that is named like an exempt route', async () => {
getPullRequestByIdImpl = async () => pullRequestFor('sync/master-to-3x', '3.x', 'someone/n8n');
resolveRequiredTeamsImpl = () => new Map([['@n8n-io/qa-dx', ['a.ts']]]);
await run();
const [, status] = setCommitStatus.mock.calls.at(-1).arguments;
assert.equal(status.state, 'pending');
});
});

View File

@ -6,7 +6,7 @@
},
"packageManager": "pnpm@12.4.2",
"scripts": {
"test": "node --test --experimental-test-module-mocks ./*.test.mjs ./docker/*.test.mjs ./owners/*.test.mjs ./quality/*.test.mjs ./slack/*.test.mjs ./stale/*.test.mjs ../../scripts/*.test.mjs ../../scripts/licenses/*.test.mjs ../../scripts/mutation-health/*.test.mjs ../../.devcontainer/codespaces/*.test.mjs",
"test": "node --test --experimental-test-module-mocks ./*.test.mjs ./docker/*.test.mjs ./owners/*.test.mjs ./quality/*.test.mjs ./slack/*.test.mjs ./stale/*.test.mjs ../../scripts/*.test.mjs ../../scripts/codespace-preview/*.test.mjs ../../scripts/licenses/*.test.mjs ../../scripts/mutation-health/*.test.mjs ../../.devcontainer/codespaces/*.test.mjs",
"generate-sbom": "FETCH_LICENSE=true cdxgen -t pnpm --no-install-deps --profile license-compliance --spec-version 1.6 -o ../../sbom-source.cdx.json ../../compiled/",
"enrich-sbom": "node ../../scripts/licenses/enrich-sbom.mjs ../../sbom-source.cdx.json",
"render-licenses-md": "node ../../scripts/licenses/render-licenses-md.mjs ../../sbom-source.cdx.json ../../packages/cli/THIRD_PARTY_LICENSES.md ../../compiled/node_modules",

File diff suppressed because one or more lines are too long

View File

@ -8,25 +8,44 @@ run-name: "${{ github.event_name == 'workflow_dispatch' && format('CI: Owners Re
# Replaces the GitHub-native CODEOWNERS enforcement, which could not express
# team ownership metadata beyond "request a review".
#
# Every PR is evaluated, whatever its base branch and whether or not its head
# is a fork. Two PR classes make this necessary: GitHub applies the ruleset of
# a stack's target branch to every PR in the stack, so a PR into a feature
# branch can be gated by the master ruleset; and a fork PR cannot write the
# status from a fork-context run. Every path that reports the status runs
# in the base repository context (secrets, write token). The routes that
# skip the evaluation (for example the master-to-3.x sync branch) live in
# REQUIRED_REVIEW_EXEMPTIONS in required-reviews.mjs; an exempt PR gets a
# success status.
#
# Triggers
# - pull_request (opened/synchronize/reopened/ready_for_review):
# re-evaluates whenever the changeset can change. Skipped for fork PRs
# (no secrets): their status stays "expected" — fail closed — until the
# first review event reports it.
# - pull_request_target (opened/synchronize/reopened/ready_for_review, and
# edited when the base branch changes): re-evaluates whenever the
# changeset can change, for same-repo and fork heads alike. A retargeted
# PR keeps its head SHA, so a verdict computed against the old base must
# not survive the retarget.
# - pull_request_review (submitted/dismissed): re-evaluates when approvals
# change. A privileged event (base repo context, secrets), so it covers
# fork PRs too — and approvals can only change through review events.
# change on a same-repo PR. A fork PR's review event runs without secrets
# and with a read-only token, so this path skips fork heads.
# Comment-only reviews are skipped: they cannot change approvals, and
# review bots submit one after every push.
# - workflow_run of "CI: Pull Request Review" (completed): the fork
# counterpart of pull_request_review. That workflow runs on every review
# event; its completion arrives here in the base repository context. The
# PR is looked up from the triggering run's head. Same-repo heads skip
# this path, which pull_request_review already covers.
# - merge_group: reports success on the queue head without re-evaluating.
# A PR cannot enter the queue unless this status is green on its head,
# and the queue does not change approvals.
# - workflow_dispatch: manual re-check (e.g. after a team membership change,
# or a fork PR push after an approval).
# - workflow_dispatch: manual re-check (e.g. after a team membership
# change).
#
# The checkout below always pins the base branch, never the PR merge ref: the
# scripts and the OWNERS file are trusted input, so a PR cannot lift its own
# review requirement or reach the status-writing token.
# The checkout below always pins master, never the base branch or the PR
# merge ref: the scripts, the OWNERS file and the exemption routes are trusted
# input, so a PR cannot lift its own review requirement or reach the
# status-writing token. Any branch can be a base, and anyone with push access
# can edit a branch, so only master is trusted. This is also what makes
# pull_request_target safe here: no step checks out or runs PR code.
#
# Output
# - A commit status named "Required Reviews" on the PR head SHA (or
@ -38,12 +57,13 @@ run-name: "${{ github.event_name == 'workflow_dispatch' && format('CI: Owners Re
# stale verdict in effect. The job itself only fails on errors.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches:
- master
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review, edited]
pull_request_review:
types: [submitted, dismissed]
workflow_run:
workflows: ['CI: Pull Request Review']
types: [completed]
merge_group:
workflow_dispatch:
inputs:
@ -90,24 +110,29 @@ jobs:
required-reviews:
name: Check required reviews
# Fork PRs are skipped on pull_request (no secrets for the app token);
# pull_request_review covers them. pull_request_review has no `branches`
# filter; the script also skips non-master bases, the guard here keeps
# the early pending status off non-master heads.
# Of the edited events only a base change matters; title and body edits
# do not change the verdict. Review events split by head: same-repo
# heads come in through pull_request_review, fork heads through the
# workflow_run of the review workflow.
if: >-
github.event_name != 'merge_group' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) &&
(github.event_name != 'pull_request_target' ||
github.event.action != 'edited' || github.event.changes.base) &&
(github.event_name != 'pull_request_review' ||
(github.event.pull_request.base.ref == 'master' &&
github.event.review.state != 'commented'))
(github.event.pull_request.head.repo.full_name == github.repository &&
github.event.review.state != 'commented')) &&
(github.event_name != 'workflow_run' ||
(github.event.workflow_run.event == 'pull_request_review' &&
github.event.workflow_run.head_repository.full_name != github.repository))
runs-on: ubuntu-latest
timeout-minutes: 5
# Job-level, not workflow-level: a run whose job is skipped must not
# cancel an in-flight evaluation, or the status stays pending.
# cancel an in-flight evaluation, or the status stays pending. Keyed by
# head SHA, so the paths above share one group per PR head.
concurrency:
group: >-
owners-required-reviews-${{ github.event.pull_request.number
owners-required-reviews-${{ github.event.pull_request.head.sha
|| github.event.workflow_run.head_sha
|| inputs.pr_number
|| github.ref }}
cancel-in-progress: true
@ -135,7 +160,7 @@ jobs:
if: github.event_name != 'workflow_dispatch'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
SHA: ${{ github.event.pull_request.head.sha }}
SHA: ${{ github.event.pull_request.head.sha || github.event.workflow_run.head_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh api "repos/${GITHUB_REPOSITORY}/statuses/${SHA}" \
@ -144,44 +169,17 @@ jobs:
-f description='Evaluating required reviews' \
-f "target_url=${RUN_URL}"
# Always check out the base branch, never the PR merge ref: the OWNERS
# file and the scripts must be trusted input. The 'master' fallback
# covers workflow_dispatch, so a dispatch from another branch cannot
# run that branch's policy. The script reads only `.github/` and the
# root files, which cone mode always includes.
# Always check out master, never the base branch or the PR merge ref:
# the OWNERS file, the scripts and the exemption routes must be trusted
# input, and any writable branch can be a base. The script reads only
# `.github/` and the root files, which cone mode always includes.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.ref || 'master' }}
ref: master
persist-credentials: false
sparse-checkout: .github
# Bootstrap: the checkout is the trusted base branch. Until the action
# exists there, there is nothing to run. Report success so the PR that
# introduces it can merge; the check is enforced again from the next PR.
- name: Check for the enforcement action on the base branch
id: bootstrap
shell: bash
run: |
if [ -f .github/actions/run-workflow-script/action.yml ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "No enforcement action on the base branch; nothing to enforce."
fi
- name: Report the bootstrap state
if: steps.bootstrap.outputs.ready != 'true' && github.event.pull_request.head.sha != ''
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
SHA: ${{ github.event.pull_request.head.sha }}
run: |
gh api "repos/${GITHUB_REPOSITORY}/statuses/${SHA}" \
-f state=success \
-f context='Required Reviews' \
-f description='Owners enforcement is not on the base branch yet'
- name: Evaluate required reviews
if: steps.bootstrap.outputs.ready == 'true'
uses: ./.github/actions/run-workflow-script
env:
PULL_REQUEST_NUMBER: ${{ inputs.pr_number || '' }}

View File

@ -1,8 +1,11 @@
name: 'CI: Pull Request Review'
# "CI: Owners Required Reviews" listens to this workflow's completion
# (workflow_run) to re-evaluate fork PRs: a fork's review event runs without
# secrets, a workflow_run does not. Keep the trigger on every review event.
on:
pull_request_review:
types: [submitted]
types: [submitted, dismissed]
concurrency:
group: ci-review-${{ github.event.pull_request.number }}

View File

@ -78,8 +78,7 @@ jobs:
!scripts/agent-setup.mjs
!scripts/backend-module/**
!scripts/cloud-session.mjs
!scripts/preview.mjs
!scripts/preview-serve.mjs
!scripts/codespace-preview/**
!scripts/licenses/**
!scripts/mutation-health/**
!scripts/sync-agent-skill-links.mjs
@ -95,8 +94,7 @@ jobs:
!scripts/agent-setup.mjs
!scripts/backend-module/**
!scripts/cloud-session.mjs
!scripts/preview.mjs
!scripts/preview-serve.mjs
!scripts/codespace-preview/**
!scripts/licenses/**
!scripts/mutation-health/**
!scripts/sync-agent-skill-links.mjs
@ -134,9 +132,7 @@ jobs:
OWNERS
scripts/*.test.mjs
scripts/codespace-env.mjs
scripts/preview.mjs
scripts/preview*
scripts/serve-ready.mjs
scripts/codespace-preview/**
scripts/licenses/**
scripts/mutation-health/**
cubic.yaml

View File

@ -46,6 +46,9 @@ concurrency:
jobs:
encryption-cycle:
name: ${{ matrix.mode }} cycle (${{ matrix.db }})
# The mirror repository must not run this suite: its runners run out of
# disk during the image build, and the cron would fire twice.
if: github.repository == 'n8n-io/n8n'
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:

View File

@ -26,13 +26,14 @@ jobs:
shards: '[{"shard":1,"images":""}]'
- lane: webhook
grep: '@bench:webhook'
# 2 shards — webhook lane grew from 6 to 9 spec files with the
# dedicated-proc topology (DEVP-200). Single shard pushed wall time
# past 40 min; two shards halves that. Bump to 3 if specs grow further.
shards: '[{"shard":1,"images":""},{"shard":2,"images":""}]'
# Three shards keep the runtime comparison profiles from extending
# this lane beyond the existing 25-minute target.
shards: '[{"shard":1,"images":""},{"shard":2,"images":""},{"shard":3,"images":""}]'
- lane: kafka
grep: '@bench:kafka'
shards: '[{"shard":1,"images":""},{"shard":2,"images":""}]'
# The v2 profiles add two isolated stacks. Keep lane wall time near
# the existing 22-minute target instead of serializing both profiles.
shards: '[{"shard":1,"images":""},{"shard":2,"images":""},{"shard":3,"images":""}]'
uses: ./.github/workflows/test-e2e-reusable.yml
with:
test-mode: docker-artifact
@ -96,7 +97,7 @@ jobs:
name: Notify Cats on failure
runs-on: ubuntu-latest
needs: [prepare-docker, benchmark, sizing-matrix]
if: ${{ always() && contains(needs.*.result, 'failure') }}
if: ${{ always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure') }}
permissions:
contents: read
steps:

View File

@ -173,7 +173,7 @@ The monorepo is organized into these key packages:
- **Frontend:** Vue 3 + TypeScript + Vite + Pinia + Storybook UI Library
- **Backend:** Node.js + TypeScript + Express + TypeORM
- **Testing:** Vitest (unit) + Playwright (E2E)
- **Testing:** Vitest (unit) + Playwright (UI, API, infrastructure, lifecycle, performance, and E2E orchestration)
- **Database:** TypeORM with SQLite/PostgreSQL support
- **Code Quality:** Biome (for formatting) + ESLint + lefthook git hooks
@ -327,33 +327,48 @@ extends one of those layers):
- **data-testid must be a single value** (no spaces or multiple values)
- Always use the `design-system` skill in reviews
### Testing Guidelines
- **Always work from within the package directory** when running tests
- **Mock all external dependencies** in unit tests
- **Prefer reusing hoisted shared `mock<T>(...)` fixtures** when a typed mock is immutable and used across tests. This rule exists to avoid massive test slowdowns from repeatedly creating nested proxy mocks while preserving the type contract. Avoid replacing these with `as unknown as T` helpers for entities like `User`.
- **Confirm test cases with user** before writing unit tests
- **Typecheck is critical before committing** - always run `pnpm typecheck`
- **When modifying pinia stores**, check for unused computed properties
- **For Vitest packages that use `@n8n/di` decorators**, use `createVitestConfigWithDecorators` from `@n8n/vitest-config/node-decorators`. It enables SWC `decoratorMetadata` (esbuild doesn't emit it) and externalizes workspace packages that register services (`@n8n/di`, `@n8n/config`, `@n8n/constants`, `n8n-workflow`) so a single DI `Container` instance is shared across the runtime. Loading them through Vitest's pipeline alongside their CJS dist produces two `Container`s and `Container.get(...)` returns `undefined`.
### Testing and Local Development
What we use for testing and writing tests:
- For testing nodes and other backend components, we use Vitest for unit tests. Examples can be found in `packages/nodes-base/nodes/**/*test*`.
- We use `nock` for server mocking
- For frontend we use `vitest`
- For E2E tests we use Playwright. Run with `pnpm --filter=n8n-playwright test:local`.
See `packages/testing/playwright/README.md` for details.
- **To iterate on a feature without docker rebuilds**, boot service containers
and run the dev servers locally — `pnpm --filter n8n-containers services --services postgres,redis,mailpit,proxy`
then `pnpm dev:be` (backend on 5678). For frontend hot reload, also run
`pnpm dev:fe:editor` (8080). The root `pnpm dev` does not exist: it prints a
notice and exits with code 0, thus `pnpm dev && …` looks successful but no
server runs. See
[Develop against running containers](packages/testing/playwright/README.md#develop-against-running-containers-avoid-docker-rebuilds).
- **In a codespace agent session**, use `pnpm dev:up`. It installs the missing
dependencies, starts the backend, waits for health, shares the port with the
org, and prints the URL. See
[.devcontainer/codespaces/README.md](.devcontainer/codespaces/README.md).
- **For Playwright test maintenance/cleanup**, see `packages/testing/playwright/AGENTS.md` (includes janitor tool for static analysis, dead code removal, architecture enforcement, and TCR workflows).
Choose the smallest runner that owns the behavior:
| Need | Use |
|------|-----|
| Unit or component behavior | Vitest from the owning package |
| UI, API, lifecycle, topology, or performance orchestration | Playwright; read `packages/testing/playwright/AGENTS.md` |
| Add a test service, capability, or managed stack | Read `packages/testing/containers/README.md` |
Testing rules:
- Run tests and `pnpm typecheck` from the owning package.
- Confirm unit test cases with the user before you write them.
- Mock external dependencies. Use `nock` for HTTP services.
- Trace side effects from imports, constructors, hooks, and mocked branches before
you run a new or changed test.
- Do not let tests read from or write to the developer's home directory,
`~/.n8n`, or other user-owned locations.
- Use a test-owned temporary directory for filesystem tests. Set
+ `N8N_USER_FOLDER` before you import modules that resolve it. n8n writes to `${N8N_USER_FOLDER}/.n8n`, so expect the `.n8n` subfolder there.
- When a mock changes a state check such as `existsSync()`, inspect the branch
that it activates. Mock every reachable filesystem mutation unless filesystem
behavior is under test.
- Run tests that can initialize n8n settings with an isolated
`N8N_USER_FOLDER` first. Clean up only paths that the test created.
- Reuse immutable hoisted `mock<T>(...)` fixtures. Do not replace typed entity mocks with `as unknown as T`.
- Use `createVitestConfigWithDecorators` for Vitest packages that use `@n8n/di` decorators.
- Check for unused computed properties after you change a Pinia store.
Choose a local development path:
| Goal | Command |
|------|---------|
| Run product E2E against a local instance | `pnpm --filter=n8n-playwright test:local` |
| Run backend with PostgreSQL, Redis, email, and proxy services | `pnpm --filter n8n-containers services --services postgres,redis,mailpit,proxy`, then `pnpm dev:be` |
| Add editor hot reload | `pnpm dev:fe:editor` |
| Start a Codespace backend and share its port | `pnpm dev:up` |
The root `pnpm dev` command does not start a server. See the
[Playwright guide](packages/testing/playwright/README.md) and the
[Codespaces guide](.devcontainer/codespaces/README.md) for details.
### Common Development Tasks

3
OWNERS
View File

@ -27,6 +27,7 @@ packages/workflow/stryker.config.mjs @n8n-io/qa-dx
packages/@n8n/config/ @n8n-io/catalysts
packages/@n8n/backend-common/ @n8n-io/catalysts
packages/@n8n/backend-network/ @n8n-io/catalysts
packages/@n8n/backend-services/ @n8n-io/catalysts
packages/@n8n/backend-test-utils/ @n8n-io/catalysts
packages/@n8n/di/ @n8n-io/catalysts
packages/@n8n/errors/ @n8n-io/catalysts
@ -41,7 +42,7 @@ packages/@n8n/db/ @n8n-io/catalysts
packages/@n8n/json-schema-to-zod/ @n8n-io/catalysts
packages/@n8n/crdt/ @n8n-io/catalysts
packages/@n8n/extension-sdk/ @n8n-io/catalysts
packages/@n8n/eslint-config/ @n8n-io/qa-dx
packages/@n8n/eslint-config/ @n8n-io/qa-dx required
packages/@n8n/typescript-config/ @n8n-io/qa-dx
packages/@n8n/db/src/migrations/ @n8n-io/migrations-review required

View File

@ -60,6 +60,7 @@ component_management:
- packages/extensions/**
- packages/@n8n/api-types/**
- packages/@n8n/backend-common/**
- packages/@n8n/backend-services/**
- packages/@n8n/backend-test-utils/**
- packages/@n8n/cli/**
- packages/@n8n/client-oauth2/**

View File

@ -34,8 +34,8 @@
"dev-metrics:reset": "node scripts/dev-metrics/setup.mjs --reset",
"clean": "turbo run clean",
"session": "node scripts/cloud-session.mjs",
"preview": "node scripts/preview.mjs",
"preview:serve": "node scripts/preview-serve.mjs",
"preview": "node scripts/codespace-preview/preview.mjs",
"preview:serve": "node scripts/codespace-preview/preview-serve.mjs",
"session:opencode": "node scripts/cloud-session.mjs --opencode",
"session:shell": "node scripts/cloud-session.mjs --shell",
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",

View File

@ -197,6 +197,7 @@ export {
renderSkillCatalogPrompt,
RUNTIME_SKILL_TOOL_NAMES,
RUNTIME_SKILL_FILE_NAME,
RUNTIME_SKILL_MAX_OUTPUT_BYTES,
RUNTIME_SKILL_LINKED_FILE_GROUPS,
RUNTIME_SKILL_NAME_PATTERN,
RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION,
@ -265,6 +266,7 @@ export type { BuiltFileStore } from './types/sdk/file-store';
export type { HandlerExecutor } from './types/sdk/handler-executor';
export {
filterLlmMessages,
getCreatedAt,
isLlmMessage,
} from './sdk/message';
export { fetchProviderCatalog } from './sdk/catalog';

View File

@ -2206,17 +2206,17 @@ function createRuntimeWithCheckpointStore(
}
function makeClaimingCheckpointStore(): ClaimingCheckpointStore {
const checkpoints = new Map<string, SerializableAgentState>();
const checkpoints = new Map<string, string>();
return {
save: vi.fn(async (key: string, state: SerializableAgentState): Promise<void> => {
await Promise.resolve();
checkpoints.set(key, structuredClone(state));
checkpoints.set(key, JSON.stringify(state));
}),
load: vi.fn(async (key: string): Promise<SerializableAgentState | undefined> => {
await Promise.resolve();
const state = checkpoints.get(key);
return state ? structuredClone(state) : undefined;
return state ? (JSON.parse(state) as SerializableAgentState) : undefined;
}),
delete: vi.fn(async (key: string): Promise<void> => {
await Promise.resolve();
@ -2225,8 +2225,8 @@ function makeClaimingCheckpointStore(): ClaimingCheckpointStore {
claimForResume: vi.fn<ClaimForResume>(async (key, state) => {
await Promise.resolve();
const current = checkpoints.get(key);
if (!current || JSON.stringify(current) !== JSON.stringify(state)) return false;
checkpoints.set(key, { ...state, status: 'running' });
if (!current || current !== JSON.stringify(state)) return false;
checkpoints.set(key, JSON.stringify({ ...state, status: 'running' }));
return true;
}),
};
@ -5732,6 +5732,86 @@ describe('AgentRuntime.resume() — checkpoint lifecycle', () => {
).rejects.toThrow(`No suspended run found for runId: ${runId}`);
});
it.each(['generate', 'stream'] as const)(
'restores and updates host metadata across repeated %s resumes',
async (method) => {
const checkpointStore = makeClaimingCheckpointStore();
const observed: Array<ToolContext['persistence']> = [];
const tool = makeSuspendingTool('suspend_tool', async (_input, ctx) => {
if (!ctx.resumeData) return await ctx.suspend({ reason: 'needs approval' });
observed.push(structuredClone(ctx.persistence));
ctx.persistence!.hostMetadata!.actor = 'tool-update';
return { approved: true };
});
const persistence = {
threadId: 'thread-1',
resourceId: 'resource-1',
hostMetadata: { scope: { tenant: 'tenant-1' }, actor: 'original' },
};
generateText.mockResolvedValueOnce(
makeGenerateWithToolCalls([
{ toolCallId: 'tc-1', toolName: 'suspend_tool', args: {} },
{ toolCallId: 'tc-2', toolName: 'suspend_tool', args: {} },
]),
);
const initial = createRuntimeWithCheckpointStore([tool], checkpointStore);
const { runId } = await initial.generate('run tools', { persistence });
const checkpoint = await checkpointStore.load(runId);
const onResumeClaimed = vi.fn(async () => {
expect(await checkpointStore.load(runId)).toEqual({ ...checkpoint, status: 'running' });
});
const resumed = createRuntimeWithCheckpointStore([tool], checkpointStore);
const options = {
runId,
toolCallId: 'tc-1',
hostMetadata: { actor: 'selected' },
onResumeClaimed,
};
if (method === 'stream') {
await collectChunks((await resumed.resume('stream', { approved: true }, options)).stream);
} else {
await resumed.resume('generate', { approved: true }, options);
}
expect(observed).toEqual([
{ ...persistence, hostMetadata: { scope: { tenant: 'tenant-1' }, actor: 'selected' } },
]);
expect(onResumeClaimed).toHaveBeenCalledOnce();
expect((await checkpointStore.load(runId))?.persistence).toEqual({
...persistence,
hostMetadata: { scope: { tenant: 'tenant-1' }, actor: 'tool-update' },
});
expect(persistence.hostMetadata.actor).toBe('original');
generateText.mockResolvedValueOnce(makeGenerateSuccess('done'));
const next = createRuntimeWithCheckpointStore([tool], checkpointStore);
await next.resume('generate', { approved: true }, { runId, toolCallId: 'tc-2' });
expect(observed[1]?.hostMetadata?.actor).toBe('tool-update');
expect(await checkpointStore.load(runId)).toBeUndefined();
},
);
it('rejects resume metadata without persistence before claiming the checkpoint', async () => {
const checkpointStore = makeClaimingCheckpointStore();
const runtime = createRuntimeWithCheckpointStore([makeApprovalTool()], checkpointStore);
generateText.mockResolvedValueOnce(
makeGenerateWithToolCalls([{ toolCallId: 'tc-1', toolName: 'suspend_tool', args: {} }]),
);
const { runId } = await runtime.generate('run tool');
await expect(
runtime.resume(
'generate',
{ approved: true },
{
runId,
toolCallId: 'tc-1',
hostMetadata: { actor: 'selected' },
},
),
).rejects.toThrow('Cannot update host metadata without persistence');
expect(checkpointStore.claimForResume).not.toHaveBeenCalled();
expect((await checkpointStore.load(runId))?.status).toBe('suspended');
});
it('claims the checkpoint after resume validation passes', async () => {
const checkpointStore = makeClaimingCheckpointStore();
const runtime = createRuntimeWithCheckpointStore([makeApprovalTool()], checkpointStore);
@ -5777,16 +5857,28 @@ describe('AgentRuntime.resume() — checkpoint lifecycle', () => {
generateText.mockResolvedValueOnce(
makeGenerateWithToolCalls([{ toolCallId: 'tc-1', toolName: 'suspend_tool', args: {} }]),
);
const first = await runtime.generate('run tool');
const first = await runtime.generate('run tool', {
persistence: {
threadId: 'thread-1',
resourceId: 'resource-1',
hostMetadata: { actor: 'original' },
},
});
const { runId, toolCallId } = first.pendingSuspend![0];
const checkpoint = await checkpointStore.load(runId);
checkpointStore.claimForResume.mockResolvedValueOnce(false);
await expect(
runtime.resume('stream', { approved: true }, { runId, toolCallId, onResumeClaimed }),
runtime.resume(
'stream',
{ approved: true },
{ runId, toolCallId, onResumeClaimed, hostMetadata: { actor: 'selected' } },
),
).rejects.toBeInstanceOf(StaleResumeError);
expect(errorEvents).toEqual([]);
expect(streamText).not.toHaveBeenCalled();
expect(onResumeClaimed).not.toHaveBeenCalled();
expect(await checkpointStore.load(runId)).toEqual(checkpoint);
});
it('does not invoke the claim hook when checkpoint claiming fails', async () => {
@ -6934,6 +7026,7 @@ describe('AgentRuntime — mid-run observation', () => {
type CapturedModelCall = {
instructions: { content: string } | Array<{ content: string }>;
messages: Array<{ role: string; content: unknown }>;
tools: Record<string, unknown>;
};
function capturedCall(index: number): CapturedModelCall {
@ -6960,6 +7053,7 @@ describe('AgentRuntime — mid-run observation', () => {
extra?: {
skillSource?: RuntimeSkillSource;
tools?: BuiltTool[];
deferredTools?: BuiltTool[];
checkpointStorage?: CheckpointStore;
model?: ModelConfig;
},
@ -6970,6 +7064,7 @@ describe('AgentRuntime — mid-run observation', () => {
instructions: 'You are a test assistant.',
memory,
tools: extra?.tools ?? [makeStepTool()],
deferredTools: extra?.deferredTools,
...(extra?.skillSource ? { skillSource: extra.skillSource } : {}),
...(extra?.checkpointStorage ? { checkpointStorage: extra.checkpointStorage } : {}),
observationalMemory: {
@ -7084,6 +7179,61 @@ describe('AgentRuntime — mid-run observation', () => {
expect(JSON.stringify(capturedCall(2).messages)).not.toContain('Wait for a real execution');
});
it.each(['load_skill', 'inspect_node'])(
'activates skill tool dependencies after %s and restores them on the next turn',
async (activationTool) => {
const source = createRuntimeSkillSource([
{
id: 'builder',
name: 'builder',
description: 'Build workflows.',
instructions: 'Choose a model from the catalog.',
dependencies: { tools: ['catalog', 'unregistered'] },
recommendedTools: ['optional_tool'],
},
]);
const catalogHandler = vi.fn(async () => ({ models: [] }));
const catalog = makeMockTool('catalog', catalogHandler);
const inspectNode: BuiltTool = {
name: 'inspect_node',
description: 'Inspect a node.',
inputSchema: z.object({}),
handler: async (_input, ctx) => {
await ctx.loadSkill?.('builder');
return { node: 'model' };
},
};
const options = {
skillSource: source,
tools: [...createRuntimeSkillTools(source), inspectNode],
deferredTools: [catalog, makeMockTool('optional_tool', async () => ({ done: true }))],
};
const memory = new InMemoryMemory();
const runtime = buildMidRunRuntime(memory, options);
generateText
.mockResolvedValueOnce(
makeGenerateWithToolCall('activate', activationTool, { skillId: 'builder' }),
)
.mockResolvedValueOnce(makeGenerateSuccess('Ready to choose a model.'));
await runtime.generate('Build it', { persistence: PERSISTENCE });
await runtime.dispose();
expect(capturedCall(0).tools).not.toHaveProperty('catalog');
expect(capturedCall(1).tools).toHaveProperty('catalog');
expect(capturedCall(1).tools).not.toHaveProperty('optional_tool');
expect(flattenInstructions(capturedCall(1).instructions)).toContain(
'Choose a model from the catalog.',
);
const next = buildMidRunRuntime(memory, options);
generateText.mockResolvedValueOnce(makeGenerateSuccess('Still ready.'));
await next.generate('Continue', { persistence: PERSISTENCE });
await next.dispose();
expect(capturedCall(2).tools).toHaveProperty('catalog');
expect(catalogHandler).not.toHaveBeenCalled();
},
);
it('restores active skills from a checkpoint with the newly selected content', async () => {
const source = createRuntimeSkillSource([
{

View File

@ -399,6 +399,9 @@ export class AgentRuntime {
if (!toolCall) {
throw new StaleResumeError(`No tool call found for toolCallId: ${options.toolCallId}`);
}
if (options.hostMetadata !== undefined && !state.persistence) {
throw new Error('Cannot update host metadata without persistence');
}
const list = AgentMessageList.deserialize(state.messageList);
this.context.hydrateDeferredToolsFromList(list);
@ -430,6 +433,7 @@ export class AgentRuntime {
runId: _rid,
toolCallId: _tcid,
onResumeClaimed: _onResumeClaimed,
hostMetadata,
...callerExecOptions
} = options;
const persisted = state.executionOptions ?? {};
@ -452,16 +456,22 @@ export class AgentRuntime {
...(state.iterationCount !== undefined ? { iterationCount: state.iterationCount } : {}),
};
const resumeOptions: RuntimeExecutionOptions = {
persistence: state.persistence,
...mergedExecOptions,
};
const claimed = await this.runState.claimResume(this.runId, state);
if (!claimed) {
throw new StaleResumeError(`Run ${this.runId} is not suspended. Cannot resume.`);
}
resumeClaimed = true;
const resumeOptions: RuntimeExecutionOptions = {
persistence: state.persistence
? {
...state.persistence,
...(state.persistence.hostMetadata || hostMetadata
? { hostMetadata: { ...state.persistence.hostMetadata, ...hostMetadata } }
: {}),
}
: undefined,
...mergedExecOptions,
};
await options.onResumeClaimed?.();
abortScope = this.eventBus.createAbortScope(resumeOptions.abortSignal);
@ -890,6 +900,10 @@ export class AgentRuntime {
this.eventBus.emit({ type: AgentEvent.TurnStart });
for (const toolName of this.activeSkills?.toolDependencies() ?? []) {
this.deferredToolManager?.load(toolName);
}
const {
toolMap,
aiTools,

View File

@ -66,6 +66,12 @@ export class ActiveSkills {
return skill;
}
toolDependencies(): string[] {
return [
...new Set([...this.loaded.values()].flatMap((skill) => skill.dependencies?.tools ?? [])),
];
}
instructions(): string | undefined {
if (this.loaded.size === 0) return undefined;
const sections = [...this.loaded].flatMap(([id, skill]) => {

View File

@ -206,7 +206,7 @@ export class DeferredToolManager {
};
}
private load(toolName: string): LoadToolOutput {
load(toolName: string): LoadToolOutput {
const activeTool = this.activeToolsByName.get(toolName);
if (activeTool) {
return {

View File

@ -68,12 +68,15 @@ describe('fetchProviderCatalog', () => {
const catalog = await fetchProviderCatalog({ signal: abortController.signal });
expect(catalog.openai.models['gpt-5'].toolCall).toBe(true);
expect(catalog.openai.models['gpt-5'].toolCallKnown).toBe(true);
expect(catalog.openai.models['gpt-5'].modalities).toEqual({
input: ['text', 'image'],
output: ['text'],
});
expect(catalog.openai.models['gpt-5']).not.toHaveProperty('reasoning');
expect(catalog.openai.models['gpt-4.1-mini'].reasoning).toBe(false);
expect(catalog.openai.models['gpt-4.1-mini'].toolCall).toBe(false);
expect(catalog.openai.models['gpt-4.1-mini'].toolCallKnown).toBe(false);
expect(catalog['aws-bedrock'].models['anthropic.claude-sonnet-4-5-v1:0'].name).toBe(
'Claude Sonnet 4.5',
);
@ -137,6 +140,7 @@ describe('fetchProviderCatalog', () => {
id: 'gpt-valid',
name: 'GPT Valid',
toolCall: true,
toolCallKnown: true,
},
});
expect(catalog.openai.deprecatedModelIds).toEqual([]);
@ -177,6 +181,7 @@ describe('fetchProviderCatalog', () => {
expect(catalog.anthropic.deprecatedModelIds).toContain('claude-3-haiku-20240307');
expect(catalog.anthropic.models['claude-sonnet-4-6'].name).toBe('Claude Sonnet 4.6');
expect(catalog.anthropic.models['claude-beta-model'].name).toBe('Claude Beta Model');
expect(catalog.anthropic.models['claude-beta-model'].status).toBe('beta');
});
it('retains a provider that only has deprecated models so callers can detect them', async () => {

View File

@ -51,6 +51,8 @@ export interface ModelInfo {
name: string;
/** Release date in ISO date format when available from models.dev. */
releaseDate?: string;
/** Lifecycle status reported by the catalog, such as alpha or beta. */
status?: string;
/** Whether the model supports reasoning / thinking, when reported by models.dev. */
reasoning?: boolean;
/**
@ -60,6 +62,8 @@ export interface ModelInfo {
temperature?: boolean;
/** Whether the model supports tool calling. */
toolCall: boolean;
/** Whether the catalog supplied tool-call support. Keeps the legacy false default intact. */
toolCallKnown?: boolean;
/** Input and output types supported by the model. */
modalities?: ModelModalities;
/** Cost per million tokens. */
@ -207,9 +211,11 @@ export async function fetchProviderCatalog(options?: {
id: model.id,
name: model.name,
...(model.release_date !== undefined && { releaseDate: model.release_date }),
...(model.status !== undefined && { status: model.status }),
...(model.reasoning !== undefined && { reasoning: model.reasoning }),
...(model.temperature !== undefined && { temperature: model.temperature }),
toolCall: model.tool_call ?? false,
toolCallKnown: model.tool_call !== undefined,
...(model.modalities !== undefined && { modalities: model.modalities }),
};
if (model.cost?.input !== undefined && model.cost?.output !== undefined) {

View File

@ -1,5 +1,6 @@
export {
RUNTIME_SKILL_FILE_NAME,
RUNTIME_SKILL_MAX_OUTPUT_BYTES,
RUNTIME_SKILL_LINKED_FILE_GROUPS,
RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION,
SKILL_LOAD_TOOL_NAME,

View File

@ -5,6 +5,7 @@ import { Tool } from '../sdk/tool';
import type { BuiltTool } from '../types';
import {
RUNTIME_SKILL_FILE_NAME,
RUNTIME_SKILL_MAX_OUTPUT_BYTES,
SKILL_LOAD_TOOL_NAME,
type RuntimeSkillLinkedFile,
type RuntimeSkillLinkedFiles,
@ -15,8 +16,7 @@ import {
type RuntimeSkillContent,
} from './types';
const MAX_OUTPUT_BYTES = 64 * 1024;
const TRUNCATION_FOOTER = '\n\n[... output truncated to 64 KB ...]';
const TRUNCATION_FOOTER = `\n\n[... output truncated to ${RUNTIME_SKILL_MAX_OUTPUT_BYTES / 1024} KB ...]`;
const LINKED_FILE_GROUPS: Array<keyof RuntimeSkillLinkedFiles> = [
'references',
'templates',
@ -315,6 +315,6 @@ function envelopeValue(value: string): string {
function cap(content: string): string {
const redacted = scrubSecretsInText(content);
const bytes = Buffer.from(redacted, 'utf8');
if (bytes.byteLength <= MAX_OUTPUT_BYTES) return redacted;
return `${bytes.subarray(0, MAX_OUTPUT_BYTES).toString('utf8')}${TRUNCATION_FOOTER}`;
if (bytes.byteLength <= RUNTIME_SKILL_MAX_OUTPUT_BYTES) return redacted;
return `${bytes.subarray(0, RUNTIME_SKILL_MAX_OUTPUT_BYTES).toString('utf8')}${TRUNCATION_FOOTER}`;
}

View File

@ -2,6 +2,9 @@ export const RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION = 1 as const;
export const RUNTIME_SKILL_FILE_NAME = 'SKILL.md';
/** Maximum UTF-8 bytes of instruction or linked-file content before truncation. */
export const RUNTIME_SKILL_MAX_OUTPUT_BYTES = 72 * 1024;
export const SKILL_LOAD_TOOL_NAME = 'load_skill';
export const RUNTIME_SKILL_LINKED_FILE_GROUPS = [
@ -59,6 +62,7 @@ export interface RuntimeSkillMcpServerDependency {
}
export interface RuntimeSkillDependenciesContract {
/** Activate matching deferred tools while the skill is active. Tools must be registered on the agent. */
tools?: string[];
secrets?: string[];
mcpServers?: RuntimeSkillMcpServerDependency[];

View File

@ -336,6 +336,8 @@ export interface StreamResult {
export interface ResumeOptions {
runId: string;
toolCallId: string;
/** Merge these host metadata keys after the resume claim succeeds. Requires persistence. */
hostMetadata?: JSONObject;
/** @internal Host lifecycle hook invoked after the checkpoint claim succeeds. */
onResumeClaimed?: () => void | Promise<void>;
}

View File

@ -125,6 +125,7 @@ interface VectorStoreNodeConstructorArgs<T extends VectorStore> {
populateVectorStore: Function; // Store documents in vector store (accepts batches in v1.1+)
getVectorStoreClient: Function; // Get vector store instance
releaseVectorStoreClient?: Function; // Clean up resources
searchByText?: true; // Text-search modes call similaritySearchWithScore and skip embedQuery
}
```

View File

@ -109,6 +109,32 @@ describe('handleLoadOperation', () => {
expect(result).toHaveLength(3);
});
it('should search by text when searchByText is set', async () => {
mockVectorStore.similaritySearchWithScore.mockResolvedValue([
[{ pageContent: 'test content 1', metadata: { test: 'metadata 1' } } as Document, 0.95],
[{ pageContent: 'test content 2', metadata: { test: 'metadata 2' } } as Document, 0.85],
[{ pageContent: 'test content 3', metadata: { test: 'metadata 3' } } as Document, 0.75],
]);
const result = await handleLoadOperation(
mockContext,
{ ...mockArgs, searchByText: true },
mockEmbeddings,
0,
);
expect(mockVectorStore.similaritySearchWithScore).toHaveBeenCalledWith('test search query', 3, {
testFilter: 'value',
});
expect(mockEmbeddings.embedQuery).not.toHaveBeenCalled();
expect(mockVectorStore.similaritySearchVectorWithScore).not.toHaveBeenCalled();
expect(result.map((item) => (item.json.document as IDataObject).pageContent)).toEqual([
'test content 1',
'test content 2',
'test content 3',
]);
});
it('should include document metadata when includeDocumentMetadata is true', async () => {
const result = await handleLoadOperation(mockContext, mockArgs, mockEmbeddings, 0);

View File

@ -141,6 +141,33 @@ describe('handleRetrieveAsToolExecuteOperation', () => {
});
});
it('should search by text when searchByText is set', async () => {
mockVectorStore.similaritySearchWithScore.mockResolvedValue([
[{ pageContent: 'test content 1', metadata: { test: 'metadata 1' } } as Document, 0.95],
[{ pageContent: 'test content 2', metadata: { test: 'metadata 2' } } as Document, 0.85],
[{ pageContent: 'test content 3', metadata: { test: 'metadata 3' } } as Document, 0.75],
]);
const result = await handleRetrieveAsToolExecuteOperation(
mockContext,
{ ...mockArgs, searchByText: true },
mockEmbeddings,
0,
);
expect(mockVectorStore.similaritySearchWithScore).toHaveBeenCalledWith('test search query', 3, {
testFilter: 'value',
});
expect(mockEmbeddings.embedQuery).not.toHaveBeenCalled();
expect(mockVectorStore.similaritySearchVectorWithScore).not.toHaveBeenCalled();
const response = result[0].json.response as Array<{ type: string; text: string }>;
expect(response.map((entry) => JSON.parse(entry.text).pageContent)).toEqual([
'test content 1',
'test content 2',
'test content 3',
]);
});
it('should throw error when input data does not contain query', async () => {
inputData[0].json = { notQuery: 'some value' };

View File

@ -180,6 +180,34 @@ describe('handleRetrieveAsToolOperation', () => {
expect(mockArgs.releaseVectorStoreClient).toHaveBeenCalledWith(mockVectorStore);
});
it('should search by text when searchByText is set', async () => {
mockVectorStore.similaritySearchWithScore.mockResolvedValue([
[{ pageContent: 'test content 1', metadata: { test: 'metadata 1' } } as Document, 0.95],
[{ pageContent: 'test content 2', metadata: { test: 'metadata 2' } } as Document, 0.85],
[{ pageContent: 'test content 3', metadata: { test: 'metadata 3' } } as Document, 0.75],
]);
const result = await handleRetrieveAsToolOperation(
mockContext,
{ ...mockArgs, searchByText: true },
mockEmbeddings,
0,
);
const tool = result.response as DynamicTool;
const toolResult = await tool.invoke({ input: 'test query' });
expect(mockVectorStore.similaritySearchWithScore).toHaveBeenCalledWith('test query', 3, {
testFilter: 'value',
});
expect(mockEmbeddings.embedQuery).not.toHaveBeenCalled();
expect(mockVectorStore.similaritySearchVectorWithScore).not.toHaveBeenCalled();
expect(toolResult).toHaveLength(3);
expect(JSON.parse(toolResult[0].text)).toEqual({
pageContent: 'test content 1',
metadata: { test: 'metadata 1' },
});
});
it('should include metadata in results when includeDocumentMetadata is true', async () => {
const result = await handleRetrieveAsToolOperation(mockContext, mockArgs, mockEmbeddings, 0);
const tool = result.response as DynamicTool;

View File

@ -39,11 +39,15 @@ export async function handleLoadOperation<T extends VectorStore = VectorStore>(
true,
) as boolean;
// Embed the prompt to prepare for vector similarity search
const embeddedPrompt = await embeddings.embedQuery(prompt);
// Get the most similar documents to the embedded prompt
let docs = await vectorStore.similaritySearchVectorWithScore(embeddedPrompt, topK, filter);
// Get the most similar documents to the prompt
let docs =
args.searchByText === true
? await vectorStore.similaritySearchWithScore(prompt, topK, filter)
: await vectorStore.similaritySearchVectorWithScore(
await embeddings.embedQuery(prompt),
topK,
filter,
);
// If reranker is used, rerank the documents
if (useReranker && docs.length > 0) {

View File

@ -50,11 +50,15 @@ export async function handleRetrieveAsToolExecuteOperation<T extends VectorStore
);
assertParamIsBoolean('includeDocumentMetadata', includeDocumentMetadata, context.getNode());
// Embed the query to prepare for vector similarity search
const embeddedQuery = await embeddings.embedQuery(query);
// Get the most similar documents to the embedded query
let docs = await vectorStore.similaritySearchVectorWithScore(embeddedQuery, topK, filter);
// Get the most similar documents to the query
let docs =
args.searchByText === true
? await vectorStore.similaritySearchWithScore(query, topK, filter)
: await vectorStore.similaritySearchVectorWithScore(
await embeddings.embedQuery(query),
topK,
filter,
);
// If reranker is used, rerank the documents
if (useReranker && docs.length > 0) {

View File

@ -61,15 +61,15 @@ export async function handleRetrieveAsToolOperation<T extends VectorStore = Vect
);
try {
// Embed the input query
const embeddedPrompt = await embeddings.embedQuery(queryString);
// Search for similar documents
let documents = await vectorStore.similaritySearchVectorWithScore(
embeddedPrompt,
topK,
filter,
);
let documents =
args.searchByText === true
? await vectorStore.similaritySearchWithScore(queryString, topK, filter)
: await vectorStore.similaritySearchVectorWithScore(
await embeddings.embedQuery(queryString),
topK,
filter,
);
// If reranker is used, rerank the documents
if (useReranker && documents.length > 0) {

View File

@ -2,17 +2,13 @@ import type { Document } from '@langchain/core/documents';
import type { Embeddings } from '@langchain/core/embeddings';
import type { VectorStore } from '@langchain/core/vectorstores';
import type {
ICredentialTestFunction,
IExecuteFunctions,
INodeCredentialDescription,
INodeProperties,
ILoadOptionsFunctions,
INodeListSearchResult,
INodeType,
Icon,
ISupplyDataFunctions,
ThemeIconColor,
IDataObject,
NodeParameterValueType,
IBuilderHint,
} from 'n8n-workflow';
@ -35,24 +31,7 @@ export interface NodeMeta {
export interface VectorStoreNodeConstructorArgs<T extends VectorStore = VectorStore> {
meta: NodeMeta;
methods?: {
listSearch?: {
[key: string]: (
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
) => Promise<INodeListSearchResult>;
};
actionHandler?: {
[functionName: string]: (
this: ILoadOptionsFunctions,
payload: IDataObject | string | undefined,
) => Promise<NodeParameterValueType>;
};
credentialTest?: {
[functionName: string]: ICredentialTestFunction;
};
};
methods?: INodeType['methods'];
sharedFields: INodeProperties[];
insertFields?: INodeProperties[];
@ -60,6 +39,13 @@ export interface VectorStoreNodeConstructorArgs<T extends VectorStore = VectorSt
retrieveFields?: INodeProperties[];
updateFields?: INodeProperties[];
hidden?: true;
/**
* The text-search operations call `similaritySearchWithScore(prompt, k, filter)` instead of
* embedding the prompt and calling `similaritySearchVectorWithScore`. The store must override
* `similaritySearchWithScore`: the base class version calls `embeddings.embedQuery` first,
* which a backend that embeds server-side cannot do.
*/
searchByText?: true;
/**
* Optional function called once before any documents are inserted.

View File

@ -353,6 +353,8 @@ export interface AgentPersistedMessageDto {
* so history renders the same error bubble the live stream showed.
*/
executionError?: string;
/** ISO timestamp of when this turn was recorded. Absent on older history. */
createdAt?: string;
}
export interface AgentBuilderOpenSuspension {

View File

@ -0,0 +1,48 @@
import { CreateCredentialPublicDto } from '../credential-public.dto';
const input = { name: 'Source credential', type: 'githubApi', data: { accessToken: 'secret' } };
describe('CreateCredentialPublicDto', () => {
test.each([undefined, 'source123', '12345', 'x'.repeat(16), 'source-ID_123'])(
'preserves the supplied ID %j',
(id) => {
const result = CreateCredentialPublicDto.parse({ ...input, id, unknown: true });
expect(result.id).toBe(id);
expect(result).not.toHaveProperty('unknown');
},
);
test.each([
'',
'x'.repeat(17),
'c9cc7ffd-2c38-44a1-a9a6-03eb9da46c70',
'team/github',
'team?github',
'team#github',
'team%2Fgithub',
'team\\github',
'.',
'..',
' source ',
'source\n',
null,
42,
[],
{},
])('rejects invalid ID %j', (id) => {
expect(CreateCredentialPublicDto.safeParse({ ...input, id }).success).toBe(false);
});
test.each(['createdAt', 'updatedAt'])('keeps %s read-only', (field) => {
expect(
CreateCredentialPublicDto.safeParse({ ...input, [field]: new Date().toISOString() }).success,
).toBe(false);
});
test('requires credential data', () => {
expect(
CreateCredentialPublicDto.safeParse({ id: 'source', name: input.name, type: input.type })
.success,
).toBe(false);
});
});

View File

@ -0,0 +1,83 @@
import { CredentialSchemaPublicDto } from '../credential-public.dto';
describe('CredentialSchemaPublicDto', () => {
test('accepts a flat schema with no allOf', () => {
const result = CredentialSchemaPublicDto.safeParse({
additionalProperties: false,
type: 'object',
properties: { apiKey: { type: 'string' }, domain: { type: 'string' } },
required: ['apiKey', 'domain'],
});
expect(result.success).toBe(true);
});
test('accepts a schema with allOf conditionals, and keeps their content untouched', () => {
const allOf = [
{
if: { properties: { keyType: { enum: ['pemKey'] } }, required: ['keyType'] },
then: { allOf: [{ required: ['privateKey'] }] },
},
];
const result = CredentialSchemaPublicDto.safeParse({
additionalProperties: false,
type: 'object',
properties: { keyType: { type: 'string', enum: ['passphrase', 'pemKey'] } },
required: [],
allOf,
});
expect(result).toMatchObject({ success: true, data: { allOf } });
});
test('passes through per-property content unchanged, including nested enum arrays', () => {
const properties = { authType: { type: 'string', enum: ['basic', 'oauth2'] } };
const result = CredentialSchemaPublicDto.safeParse({
additionalProperties: false,
type: 'object',
properties,
required: ['authType'],
});
expect(result).toMatchObject({ success: true, data: { properties } });
});
test('rejects an unknown top-level key', () => {
const result = CredentialSchemaPublicDto.safeParse({
additionalProperties: false,
type: 'object',
properties: {},
required: [],
somethingElse: true,
});
expect(result.success).toBe(false);
});
test.each([
['additionalProperties', { additionalProperties: true }],
['type', { type: 'array' }],
])('rejects a wrong literal value for %s', (_name, override) => {
const result = CredentialSchemaPublicDto.safeParse({
additionalProperties: false,
type: 'object',
properties: {},
required: [],
...override,
});
expect(result.success).toBe(false);
});
test('rejects a missing required field', () => {
const result = CredentialSchemaPublicDto.safeParse({
additionalProperties: false,
type: 'object',
properties: {},
});
expect(result.success).toBe(false);
});
});

View File

@ -3,6 +3,7 @@ import '../../openapi-extend';
import { z } from 'zod';
import { publicApiCredentialResponseSchema } from '../../schemas/credential-response.schema';
import { n8nIdSchema } from '../../schemas/id.schema';
import { readOnlyPublicSchema } from '../../schemas/read-only-public.schema';
import { Z } from '../../zod-class';
@ -38,7 +39,15 @@ export class CredentialListPublicDto extends Z.class({
}) {}
export class CreateCredentialPublicDto extends Z.class({
id: readOnlyPublicSchema({ type: 'string', readOnly: true, example: 'R2DjclaysHbqn778' }),
id: n8nIdSchema
.max(16)
.regex(/^[a-zA-Z0-9_-]+$/, 'Use only letters, digits, underscores, and hyphens for the ID')
.optional()
.openapi({
example: 'R2DjclaysHbqn778',
description:
'An unused credential ID of 1–16 letters, digits, underscores, or hyphens. The supplied ID is preserved exactly. Omit to generate an ID.',
}),
name: z.string().openapi({ example: "Joe's Github Credentials" }),
type: z.string().openapi({ example: 'githubApi' }),
data: z.record(z.string(), z.unknown()).openapi({
@ -110,3 +119,26 @@ export class TransferCredentialPublicDto extends Z.class({
example: 'VmwOO9HeTEj20kxM',
}),
}) {}
export class CredentialTestPublicDto extends Z.class({
status: z.enum(['OK', 'Error']).openapi({ example: 'OK' }),
message: z.string().openapi({ example: 'Connection successful!' }),
}) {}
export class CredentialSchemaPublicDto extends Z.class(
{
additionalProperties: z.literal(false),
type: z.literal('object'),
properties: z.record(z.string(), z.unknown()).openapi({
description:
"JSON Schema fragment for each of the credential type's fields, keyed by field name.",
example: { apiKey: { type: 'string' }, domain: { type: 'string' } },
}),
required: z.array(z.string()).openapi({
description: 'Names of the fields that are required for this credential type.',
example: ['apiKey', 'domain'],
}),
allOf: z.array(z.unknown()).optional(),
},
{ strict: true },
) {}

View File

@ -136,6 +136,8 @@ export {
UpdateCredentialPublicDto,
DeleteCredentialPublicDto,
TransferCredentialPublicDto,
CredentialTestPublicDto,
CredentialSchemaPublicDto,
credentialPublicSchema,
} from './credentials/credential-public.dto';
export { ListCredentialsQueryDto } from './credentials/list-credentials-query.dto';
@ -265,6 +267,10 @@ export type { FolderPublic } from './folders/folder-public.dto';
export { ListInsightsWorkflowQueryDto } from './insights/list-workflow-query.dto';
export { InsightsDateFilterDto } from './insights/date-filter.dto';
export {
InsightsSummaryPublicDto,
InsightsSummaryQueryPublicDto,
} from './insights/insights-summary-public.dto';
export { GetDestinationQueryDto } from './log-streaming/get-destination-query.dto';
export {
@ -575,15 +581,21 @@ export {
UpsertPromotionApplyConfigDto,
UpsertPromotionPromoteConfigDto,
promotionApplyConfigPublicSchema,
promotionConfigCheckoutSchema,
promotionConfigSettingsSchema,
promotionConnectionConfigsPublicSchema,
promotionConnectionConfigsSummarySchema,
promotionDirectionSchema,
promotionGitApplySettingsSchema,
promotionGitPromoteSettingsSchema,
promotionGitSettingsSchemas,
promotionApplyConfigSummarySchema,
promotionPromoteConfigSummarySchema,
promotionPromoteConfigPublicSchema,
type PromotionConfigCheckout,
type PromotionConfigSettings,
type PromotionConnectionConfigsPublic,
type PromotionConnectionConfigsSummary,
type PromotionDirection,
} from './promotions/promotion-config.dto';
export {
@ -602,11 +614,14 @@ export {
promotionConnectionProjectPublicSchema,
promotionConnectionPublicSchema,
promotionConnectionScopeSchema,
promotionConnectionSummarySchema,
promotionConnectionTargetSchema,
type PromotionConnectionSummary,
type PromotionConnectionScope,
type PromotionConnectionTarget,
} from './promotions/promotion-connection.dto';
export {
ApplyPackageDto,
ApplyPackageResultDto,
ContinueApplyPackageDto,
PromotePackageDto,

View File

@ -0,0 +1,34 @@
import '../../openapi-extend';
import { z } from 'zod';
import { insightsSummaryQueryFieldDocs } from './insights-summary-public.openapi';
import { Z } from '../../zod-class';
const summaryMetricPublicSchema = <Unit extends string>(unit: Unit) =>
z.object({
value: z.number(),
deviation: z.number().nullable(),
unit: z.literal(unit),
});
export class InsightsSummaryPublicDto extends Z.class({
total: summaryMetricPublicSchema('count'),
failed: summaryMetricPublicSchema('count'),
failureRate: summaryMetricPublicSchema('ratio'),
timeSaved: summaryMetricPublicSchema('minute'),
averageRunTime: summaryMetricPublicSchema('millisecond'),
}) {}
// `{ offset: true }` matches the legacy `format: date-time` check. `.datetime()` only checks the
// pattern, so an impossible offset such as `+99:99` passes it; the refine rejects those values.
const dateTimeQuerySchema = z
.string()
.datetime({ offset: true })
.refine((value) => !Number.isNaN(Date.parse(value)), 'Invalid datetime');
export class InsightsSummaryQueryPublicDto extends Z.class({
startDate: dateTimeQuerySchema.optional().openapi(insightsSummaryQueryFieldDocs.startDate),
endDate: dateTimeQuerySchema.optional().openapi(insightsSummaryQueryFieldDocs.endDate),
projectId: z.string().optional().openapi(insightsSummaryQueryFieldDocs.projectId),
}) {}

View File

@ -0,0 +1,10 @@
import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi';
export const insightsSummaryQueryFieldDocs = {
startDate: { description: 'ISO 8601 start date. Defaults to 7 days ago.' },
endDate: { description: 'ISO 8601 end date. Defaults to now.' },
projectId: {
description: 'Project identifier to filter insights by project.',
example: 'VmwOO9HeTEj20kxM',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;

View File

@ -1,7 +1,10 @@
import {
PromotionApplyConfigPublicDto,
PromotionPromoteConfigPublicDto,
UpsertPromotionApplyConfigDto,
UpsertPromotionPromoteConfigDto,
promotionApplyConfigSummarySchema,
promotionPromoteConfigSummarySchema,
} from '../promotion-config.dto';
const applySettings = { schemaVersion: 1 as const, branchName: 'dev' };
@ -10,6 +13,13 @@ const promoteSettings = {
baseBranchName: 'staging',
createBranchOnPromotion: false,
};
const checkout = { hasCheckout: true, matchesConfig: true };
const publicConfigFields = {
id: 'cfg-1',
name: 'Promote',
createdAt: '2026-09-07T08:00:00.000Z',
updatedAt: '2026-09-07T08:00:00.000Z',
};
describe('UpsertPromotionApplyConfigDto', () => {
it('accepts settings with an optional name', () => {
@ -87,10 +97,8 @@ describe('UpsertPromotionPromoteConfigDto', () => {
describe('PromotionPromoteConfigPublicDto', () => {
const base = {
id: 'cfg-1',
name: 'Promote',
createdAt: '2026-09-07T08:00:00.000Z',
updatedAt: '2026-09-07T08:00:00.000Z',
...publicConfigFields,
checkout,
};
it('always reports createBranchOnPromotion', () => {
@ -105,3 +113,38 @@ describe('PromotionPromoteConfigPublicDto', () => {
).toBe(true);
});
});
describe('promotion config response schemas', () => {
it('requires checkout state in public responses', () => {
expect(
PromotionApplyConfigPublicDto.safeParse({
...publicConfigFields,
settings: applySettings,
}).success,
).toBe(false);
expect(
PromotionPromoteConfigPublicDto.safeParse({
...publicConfigFields,
settings: promoteSettings,
}).success,
).toBe(false);
});
it('omits checkout state from summary responses', () => {
const applySummary = promotionApplyConfigSummarySchema.parse({
...publicConfigFields,
checkout,
settings: applySettings,
});
const promoteSummary = promotionPromoteConfigSummarySchema.parse({
...publicConfigFields,
checkout,
settings: promoteSettings,
});
expect(applySummary).not.toHaveProperty('checkout');
expect(applySummary.settings).toEqual(applySettings);
expect(promoteSummary).not.toHaveProperty('checkout');
expect(promoteSummary.settings).toEqual(promoteSettings);
});
});

View File

@ -113,7 +113,6 @@ describe('UpdatePromotionConnectionDto', () => {
});
describe('PromotionConnectionPublicDto', () => {
// Detail and list share this shape, so a full provider would put the key in every row.
it('embeds the provider without its public key', () => {
const result = PromotionConnectionPublicDto.safeParse({
id: 'conn-1',
@ -128,6 +127,7 @@ describe('PromotionConnectionPublicDto', () => {
name: 'Apply',
createdAt: timestamp,
updatedAt: timestamp,
checkout: { hasCheckout: false, matchesConfig: false },
},
},
createdAt: timestamp,

View File

@ -1,4 +1,5 @@
import {
ApplyPackageDto,
ApplyPackageResultDto,
ContinueApplyPackageDto,
PromotePackageDto,
@ -30,6 +31,13 @@ describe('ContinueApplyPackageDto', () => {
expect(ContinueApplyPackageDto.parse({ expectedSource })).toEqual({ expectedSource });
});
it('accepts a SHA-256 object name', () => {
const source = { ...expectedSource, commitSha: 'b'.repeat(64) };
expect(ContinueApplyPackageDto.parse({ expectedSource: source })).toEqual({
expectedSource: source,
});
});
it.each([
{},
{ expectedSource: {} },
@ -48,6 +56,8 @@ describe('ContinueApplyPackageDto', () => {
'a'.repeat(7),
'a'.repeat(39),
'a'.repeat(41),
'a'.repeat(63),
'a'.repeat(65),
'A'.repeat(40),
'g'.repeat(40),
])('rejects commit identity %s', (commitSha) => {
@ -58,6 +68,23 @@ describe('ContinueApplyPackageDto', () => {
});
});
describe('ApplyPackageDto', () => {
const expectedSource = { configId: 'config1', branchName: 'main', commitSha: 'a'.repeat(40) };
it('accepts a request without a source and one with the reviewed source', () => {
expect(ApplyPackageDto.parse({})).toEqual({});
expect(ApplyPackageDto.parse({ expectedSource })).toEqual({ expectedSource });
});
it.each([
{ force: true },
{ expectedSource: {} },
{ expectedSource: { ...expectedSource, commitSha: 'HEAD' } },
])('rejects unsupported or incomplete fields: %j', (body) => {
expect(ApplyPackageDto.safeParse(body).success).toBe(false);
});
});
describe('ApplyPackageResultDto', () => {
const identity = {
connectionId: 'connection1',

View File

@ -78,6 +78,19 @@ export class UpsertPromotionPromoteConfigDto extends Z.class(
{ strict: true },
) {}
/**
* The local checkout state of one direction on the instance that answered. A
* package operation needs a checkout that was cloned from the current remote and
* branch, so both flags report on this replica only. Other replicas may differ.
*/
export const promotionConfigCheckoutSchema = z.object({
/** A local checkout exists for this direction on this instance. */
hasCheckout: z.boolean(),
/** The checkout was cloned from the current remote and branch, so it is not stale. */
matchesConfig: z.boolean(),
});
export type PromotionConfigCheckout = z.infer<typeof promotionConfigCheckoutSchema>;
const configPublicFields = {
/**
* Also names the local checkout directory. Stable while the config exists, and
@ -87,6 +100,7 @@ const configPublicFields = {
name: z.string(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
checkout: promotionConfigCheckoutSchema,
};
/*
@ -123,3 +137,20 @@ export const promotionConnectionConfigsPublicSchema = z.object({
export type PromotionConnectionConfigsPublic = z.infer<
typeof promotionConnectionConfigsPublicSchema
>;
/** List responses contain stored config fields only. */
export const promotionApplyConfigSummarySchema = promotionApplyConfigPublicSchema.omit({
checkout: true,
});
export const promotionPromoteConfigSummarySchema = promotionPromoteConfigPublicSchema.omit({
checkout: true,
});
export const promotionConnectionConfigsSummarySchema = z.object({
apply: promotionApplyConfigSummarySchema.optional(),
promote: promotionPromoteConfigSummarySchema.optional(),
});
export type PromotionConnectionConfigsSummary = z.infer<
typeof promotionConnectionConfigsSummarySchema
>;

View File

@ -7,6 +7,7 @@ import {
UpsertPromotionApplyConfigDto,
UpsertPromotionPromoteConfigDto,
promotionConnectionConfigsPublicSchema,
promotionConnectionConfigsSummarySchema,
} from './promotion-config.dto';
import { promotionProviderSummarySchema } from './promotion-provider.dto';
import { n8nIdSchema } from '../../schemas/id.schema';
@ -119,9 +120,7 @@ export class ListPromotionConnectionsQueryDto extends Z.class({
* by direction to match the request side.
*
* The provider is embedded as a summary, which names it but leaves out its public
* config. Detail and list share this schema, so the full form would put the SSH
* public key in every row of the connection list. Read the key from the provider
* detail route.
* config. Read the key from the provider detail route.
*/
export const promotionConnectionPublicSchema = z.object({
id: n8nIdSchema,
@ -136,8 +135,17 @@ export const promotionConnectionPublicSchema = z.object({
export class PromotionConnectionPublicDto extends Z.class(promotionConnectionPublicSchema.shape) {}
/**
* Lists contain stored connection data only. Checkout state is local to the
* responding instance, so callers read it from the connection detail route.
*/
export const promotionConnectionSummarySchema = promotionConnectionPublicSchema.extend({
configs: promotionConnectionConfigsSummarySchema,
});
export type PromotionConnectionSummary = z.infer<typeof promotionConnectionSummarySchema>;
export class PromotionConnectionListPublicDto extends Z.class({
data: z.array(promotionConnectionPublicSchema),
data: z.array(promotionConnectionSummarySchema),
nextCursor: z.string().nullable(),
}) {}

View File

@ -84,16 +84,23 @@ export const applyPackageCountsSchema = z.object({
}),
});
const expectedSourceSchema = z
.object({
configId: n8nIdSchema,
branchName: z.string().min(1),
commitSha: z.string().regex(/^[0-9a-f]{40}([0-9a-f]{24})?$/),
})
.strict();
/** Apply may pin the reviewed source. Without it, the branch tip is applied. */
export class ApplyPackageDto extends Z.class(
{ expectedSource: expectedSourceSchema.optional() },
{ strict: true },
) {}
/** Continue must name the source that the paused Apply reported. */
export class ContinueApplyPackageDto extends Z.class(
{
expectedSource: z
.object({
configId: n8nIdSchema,
branchName: z.string().min(1),
commitSha: z.string().regex(/^[0-9a-f]{40}$/),
})
.strict(),
},
{ expectedSource: expectedSourceSchema },
{ strict: true },
) {}

View File

@ -152,6 +152,7 @@ export { passwordSchema, createPasswordSchema } from './schemas/password.schema'
export { n8nIdSchema } from './schemas/id.schema';
export {
credentialIdParamSchema,
credentialTypeNameParamSchema,
executionIdParamSchema,
nodeTypePolicyIdParamSchema,
nodeTypePolicyScopeIdParamSchema,
@ -357,6 +358,7 @@ export {
export {
buildRunWorkflowSessionGrantKey,
buildExecuteNodeSessionGrantKey,
buildRunStepSessionGrantKey,
buildUpdateWorkflowSessionGrantKey,
buildCredentialDestinationGrantKey,
@ -365,6 +367,7 @@ export {
parseSetupSkipGrants,
buildFetchUrlGrantKey,
FETCH_URL_ALLOW_ALL_GRANT_KEY,
NODE_RESOURCE_GRANT_FALLBACK_KEYS,
WEB_SEARCH_GRANT_KEY,
parseDomainAccessGrants,
instanceAiEventTypeSchema,
@ -498,6 +501,7 @@ export {
findUnbackedSeedWorkflowTools,
findSeedFolderIssues,
applyBranchReadOnlyOverrides,
resolveInstanceAiPermissions,
deriveInstanceAiSetupState,
INSTANCE_AI_THREAD_SOURCES,
INSTANCE_AI_THREAD_SOURCE_FALLBACK,

View File

@ -904,6 +904,11 @@ describe('agent-run-reducer', () => {
reduceEvent(state, makeSetupItems('run-1', 'root', 'wf-1', 'slackApi'));
reduceEvent(state, makeSetupItems('run-1', 'root', 'wf-1', 'notionApi'));
reduceEvent(state, makeSetupItems('run-1', 'root', 'wf-2', 'gmailOAuth2'));
reduceEvent(state, makeSetupItems('run-1', 'root', 'wf-1', 'notionApi'));
expect(state.agentsById['root'].latestSetupAnnouncement).toMatchObject({
workflowId: 'wf-1',
agentId: 'root',
});
const byWorkflowId = state.agentsById['root'].setupItemsByWorkflowId!;
expect(byWorkflowId['wf-1']).toHaveLength(1);
@ -918,6 +923,25 @@ describe('agent-run-reducer', () => {
expect(state.agentsById['root'].setupItemsByWorkflowId?.['wf-1']).toHaveLength(1);
expect(state.agentsById['sub-1'].setupItemsByWorkflowId).toBeUndefined();
expect(state.agentsById['root'].latestSetupAnnouncement?.agentId).toBe('sub-1');
});
it('preserves the emitting agent identity across an aliased follow-up run', () => {
const state = stateWithRun('run-1', 'root');
reduceEvent(state, makeSetupItems('run-1', 'root', 'wf-1', 'slackApi'));
reduceEvent(state, makeRunFinish('run-1', 'root', 'completed'));
reduceEvent(state, makeRunStart('run-2', 'follow-up-root'));
reduceEvent(state, makeToolCall('run-2', 'follow-up-root', 'build-2', 'build-workflow'));
reduceEvent(state, makeSetupItems('run-2', 'follow-up-root', 'wf-2', 'notionApi'));
const root = toAgentTree(state);
expect(root.latestSetupAnnouncement).toMatchObject({
workflowId: 'wf-2',
agentId: root.agentId,
});
expect(root.toolCalls).toContainEqual(
expect.objectContaining({ toolCallId: 'build-2', isLoading: true }),
);
});
it('survives a tree snapshot round trip (history restore)', () => {
@ -932,6 +956,9 @@ describe('agent-run-reducer', () => {
expect(restored?.agentsById['root'].setupItemsByWorkflowId?.['wf-1'][0]).toMatchObject({
credentialType: 'slackApi',
});
expect(restored?.agentsById['root'].latestSetupAnnouncement).toEqual(
state.agentsById['root'].latestSetupAnnouncement,
);
});
it('is preserved across a follow-up run-start when it is the only content', () => {

View File

@ -12,6 +12,7 @@ import {
applyBranchReadOnlyOverrides,
buildCredentialDestinationGrantKey,
buildDataTablesSessionGrantKey,
buildExecuteNodeSessionGrantKey,
buildUpdateWorkflowSessionGrantKey,
buildSetupSkipGrantKey,
parseSetupSkipGrants,
@ -45,6 +46,7 @@ import {
isInstanceAiSandboxProvider,
isKnownInstanceAiErrorCode,
parseDomainAccessGrants,
resolveInstanceAiPermissions,
WEB_SEARCH_GRANT_KEY,
workflowSetupNodeSchema,
type InstanceAiConfirmationInputType,
@ -410,6 +412,7 @@ describe('applyBranchReadOnlyOverrides', () => {
expect(result.mutateDataTableSchema).toBe('blocked');
expect(result.mutateDataTableRows).toBe('blocked');
expect(result.cleanupTestExecutions).toBe('blocked');
expect(result.executeNode).toBe('blocked');
});
it('should preserve safe permissions even when set to always_allow', () => {
@ -437,6 +440,46 @@ describe('applyBranchReadOnlyOverrides', () => {
});
});
describe('resolveInstanceAiPermissions', () => {
it('should fill missing keys from the defaults', () => {
const result = resolveInstanceAiPermissions({ createWorkflow: 'always_allow' });
expect(result.createWorkflow).toBe('always_allow');
expect(result.deleteWorkflow).toBe('require_approval');
expect(result.executeNode).toBe('require_approval');
});
it('should carry a blocked runWorkflow over to executeNode', () => {
const result = resolveInstanceAiPermissions({ runWorkflow: 'blocked' });
expect(result.executeNode).toBe('blocked');
});
it('should not carry an always_allow runWorkflow over to executeNode', () => {
const result = resolveInstanceAiPermissions({ runWorkflow: 'always_allow' });
expect(result.executeNode).toBe('require_approval');
});
it('should prefer an explicit executeNode over the runWorkflow fallback', () => {
expect(
resolveInstanceAiPermissions({ runWorkflow: 'blocked', executeNode: 'always_allow' })
.executeNode,
).toBe('always_allow');
expect(
resolveInstanceAiPermissions({ runWorkflow: 'always_allow', executeNode: 'blocked' })
.executeNode,
).toBe('blocked');
});
it('should not mutate the persisted permissions object', () => {
const persisted: Partial<InstanceAiPermissions> = { runWorkflow: 'blocked' };
resolveInstanceAiPermissions(persisted);
expect(persisted.executeNode).toBeUndefined();
});
});
function makeConfirmation(
overrides: Partial<InstanceAiConfirmationRequestPayload> = {},
): InstanceAiConfirmationRequestPayload {
@ -710,6 +753,45 @@ describe('data-tables session grant keys', () => {
});
});
describe('node execution session grant keys', () => {
it('scopes the key by resource and operation', () => {
expect(
buildExecuteNodeSessionGrantKey('n8n-nodes-base.slack', {
resource: 'message',
operation: 'post',
}),
).toBe('nodes:execute:n8n-nodes-base.slack:message:post');
});
it('falls back to the first scoping parameter a node without resource/operation declares', () => {
expect(
buildExecuteNodeSessionGrantKey('n8n-nodes-base.httpRequest', {
method: 'POST',
url: 'https://example.com/v4/sheets',
}),
).toBe('nodes:execute:n8n-nodes-base.httpRequest:https://example.com/v4/sheets');
// `mode` precedes `url` in the fallback order, so a node declaring both is scoped by mode.
expect(
buildExecuteNodeSessionGrantKey('n8n-nodes-base.set', { mode: 'manual', url: 'https://x' }),
).toBe('nodes:execute:n8n-nodes-base.set:manual');
});
it('refuses a key a fallback parameter would push past the column width', () => {
expect(
buildExecuteNodeSessionGrantKey('n8n-nodes-base.graphql', { query: 'q'.repeat(600) }),
).toBeNull();
});
it('keeps the key at the node type when nothing scopes the call', () => {
expect(buildExecuteNodeSessionGrantKey('n8n-nodes-base.filter', { conditions: {} })).toBe(
'nodes:execute:n8n-nodes-base.filter',
);
expect(buildExecuteNodeSessionGrantKey('n8n-nodes-base.filter')).toBe(
'nodes:execute:n8n-nodes-base.filter',
);
});
});
describe('workflow update session grant keys', () => {
it('builds per-workflow keys matching the frontend always-allow format', () => {
expect(buildUpdateWorkflowSessionGrantKey('wf-1')).toBe('workflows:update:wf-1');

View File

@ -499,6 +499,11 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
// semantics: last event wins per workflowId.
const root = ensureAgent(state, state.rootAgentId);
if (root && isSafeObjectKey(event.payload.workflowId)) {
root.latestSetupAnnouncement = {
workflowId: event.payload.workflowId,
agentId: ensureAgent(state, event.agentId)?.agentId ?? event.agentId,
timestamp: eventTimestamp(event),
};
root.setupItemsByWorkflowId = {
...root.setupItemsByWorkflowId,
[event.payload.workflowId]: event.payload.items,

View File

@ -56,6 +56,43 @@ export function buildRunWorkflowSessionGrantKey(workflowId: string): string {
return `executions:run:${workflowId}`;
}
/** Used to determine resource for nodes that have no resource and operation, like http request node*/
export const NODE_RESOURCE_GRANT_FALLBACK_KEYS = ['mode', 'url', 'query', 'command', 'action'];
/** Width of `instance_ai_thread_grants.grantKey`. */
const MAX_GRANT_KEY_LENGTH = 512;
/**
* Builds the thread-level grant key for `nodes(action="execute")`. Scoped per
* node type + resource + operation (the split the generated node TS types use).
* Extracts the discriminators from the parameters itself so backend and
* frontend cannot build diverging keys.
*
* Null when a fallback discriminator (a long URL, GraphQL query or command) pushes the key
* past the column width: the call then needs approval every time. Truncating instead would
* let one approved URL stand for every other URL sharing its prefix.
*/
export function buildExecuteNodeSessionGrantKey(
nodeType: string,
parameters?: Record<string, unknown>,
): string | null {
const resourceParts = [
nodeType,
typeof parameters?.resource === 'string' ? parameters.resource : '',
typeof parameters?.operation === 'string' ? parameters.operation : '',
].filter(Boolean);
if (resourceParts.length === 1) {
for (const name of NODE_RESOURCE_GRANT_FALLBACK_KEYS) {
if (typeof parameters?.[name] === 'string' && parameters?.[name]) {
resourceParts.push(parameters?.[name]);
break;
}
}
}
const key = `nodes:execute:${resourceParts.join(':')}`;
return key.length <= MAX_GRANT_KEY_LENGTH ? key : null;
}
/**
* Builds the thread-level "always allow" grant key for running one node of a
* workflow ("execute step").
@ -1814,6 +1851,8 @@ export interface InstanceAiAgentNode {
* regardless of which agent emitted.
*/
setupItemsByWorkflowId?: Record<string, InstanceAiSetupItem[]>;
/** Latest setup announcement, including its emitting agent and replay-stable time. */
latestSetupAnnouncement?: { workflowId: string; agentId: string; timestamp: string };
result?: string;
error?: string;
errorDetails?: {
@ -2052,6 +2091,7 @@ const instanceAiPermissionsSchema = z.object({
fetchUrl: instanceAiPermissionModeSchema,
webSearch: instanceAiPermissionModeSchema,
restoreWorkflowVersion: instanceAiPermissionModeSchema,
executeNode: instanceAiPermissionModeSchema,
executeMcpTool: instanceAiPermissionModeSchema,
});
@ -2078,6 +2118,7 @@ export const DEFAULT_INSTANCE_AI_PERMISSIONS: InstanceAiPermissions = {
fetchUrl: 'require_approval',
webSearch: 'require_approval',
restoreWorkflowVersion: 'require_approval',
executeNode: 'require_approval',
executeMcpTool: 'require_approval',
};
@ -2118,6 +2159,17 @@ export function applyBranchReadOnlyOverrides(
return overridden;
}
export function resolveInstanceAiPermissions(
persisted: Partial<InstanceAiPermissions>,
): InstanceAiPermissions {
const resolved = { ...DEFAULT_INSTANCE_AI_PERMISSIONS, ...persisted };
// Only a saved block carries over; inheriting always_allow would widen the grant.
if (persisted.executeNode === undefined && persisted.runWorkflow === 'blocked') {
resolved.executeNode = 'blocked';
}
return resolved;
}
// ---------------------------------------------------------------------------
// Admin settings — instance-scoped, admin-only
// ---------------------------------------------------------------------------

View File

@ -37,6 +37,9 @@ export const promotionDirectionParamSchema = z.string().openapi({
param: { description: 'The direction of the promotion: apply or promote.' },
});
export const credentialIdParamSchema = stringIdParamSchema('The ID of the credential.');
export const credentialTypeNameParamSchema = stringIdParamSchema(
'The credential type name that you want to get the schema for',
);
export const variableIdParamSchema = stringIdParamSchema('The ID of the variable.');
export const nodeTypePolicyIdParamSchema = stringIdParamSchema(
'The ID of the node type policy document.',

View File

@ -0,0 +1,65 @@
# @n8n/backend-services
Backend services and HTTP error classes that many backend modules share.
## Why this package exists
Backend modules live in `packages/cli/src/modules/<name>` today. Most of them
import a small set of services from the rest of `cli`. A module cannot move
into its own workspace package while those imports point at `@/...` paths in
`cli`.
This package is the home for that shared set. It sits above `@n8n/db` and
below `n8n` (`cli`) in the dependency graph:
```mermaid
flowchart TD
cli["n8n (cli) and backend modules"] --> bs["@n8n/backend-services"]
bs --> db["@n8n/db"]
bs --> bc["@n8n/backend-common"]
db --> bc
```
`@n8n/backend-common` holds the foundation that `@n8n/db` also needs (logger,
license state, module registry, locks). `@n8n/backend-services` holds business
and infrastructure services that need the persistence layer or that only
`cli` and the modules consume.
## What lives here
Nothing yet. The next PRs move the response errors, `UrlService`, `CacheService`,
`RedisClientService`, `ProtectedResourceRegistry`, `EventService`, `RoleService`,
the finder services and the scope checks here, one area at a time.
## Compatibility with cli
The package config mirrors `packages/cli`, so a file moved from `cli` compiles,
lints and tests here without edits:
| Concern | Setup | Mirrors |
| --- | --- | --- |
| TypeScript | `common.go` + `backend.go`, `lib` es2023, `strictFunctionTypes`, `strictPropertyInitialization` and `useUnknownInCatchVariables` off | `packages/cli/tsconfig.json` |
| Lint | `oxlint --type-aware`, then the ESLint guardrails pass. `eslint.config.mjs` is the policy twin that code-health and the guardrails read | `packages/cli/oxlint.config.mts`, `eslint.guardrails.config.mjs` |
| Tests | Vitest with the decorators config, one fork per file, the same `N8N_USER_FOLDER` and `N8N_ENCRYPTION_KEY` setup | `packages/cli/vitest.config.base.ts`, `test/setup-test-folder.ts` |
Two things do not carry over on purpose:
- There is no `@/` path alias. `cli` tests load this package from `dist/`
and other consumers inline it from `src/`. `tsc` does not rewrite an alias
in `dist/`, so it resolves in neither. Use relative imports inside the
package.
- `cli` turns a set of rules down to `warn` for its whole tree. This package
keeps the shared backend layer as is. Fix such findings when you move a file.
## Rules for adding code
- The code must not import from `n8n` (`cli`). If it needs a `cli` seam,
define a narrow DI port here and bind it in `cli` at bootstrap.
- At least two backend modules must use the code. A service that one module
uses belongs to that module.
- Keep the file layout of `cli` (`errors/`, `services/`). A move with `git mv`
keeps the history readable.
- Add each new export to `src/index.ts`. Consumers import from the package
root only.
- Keep `oxlint.config.mts` and `eslint.config.mjs` in step. A ratchet
allowlist for a moved TypeORM leak goes into both.

View File

@ -0,0 +1,39 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import { backendConfig } from '@n8n/eslint-config/backend';
// Policy twin of oxlint.config.mts for the tools that read ESLint configs (guardrails, code-health).
export default defineConfig(
globalIgnores(['coverage/**', 'vitest.config.ts']),
backendConfig,
{
rules: {
'n8n-local-rules/misplaced-n8n-typeorm-import': 'error',
'n8n-local-rules/no-guardrail-disable': [
'error',
{
guarded: [
{
rule: 'misplaced-n8n-typeorm-import',
message:
'Keep TypeORM in the persistence layer: put the query behind a use-case repository method in @n8n/db.',
},
{
rule: 'no-unsealed-workflow-entity-write',
message: 'Route the write through a token-gated `WorkflowRepository` method.',
},
],
},
],
'n8n-local-rules/no-type-unsafe-event-emitter': 'error',
},
},
{
files: ['./test/**/*.ts', './src/**/__tests__/**/*.ts'],
rules: {
'n8n-local-rules/misplaced-n8n-typeorm-import': 'off',
'n8n-local-rules/no-type-unsafe-event-emitter': 'off',
// `vi.importActual<typeof import('x')>('x')` needs inline import types.
'@typescript-eslint/consistent-type-imports': ['error', { disallowTypeAnnotations: false }],
},
},
);

View File

@ -0,0 +1,43 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import { functionalGuardrailsConfig } from '@n8n/eslint-config/functional-guardrails';
import { localRulesPlugin } from '@n8n/eslint-config/plugin';
import packageConfig from './eslint.config.mjs';
const directivePluginConfigs = packageConfig.flatMap(({ plugins }) => {
const externalPlugins = Object.fromEntries(
Object.entries(plugins ?? {}).filter(([name]) => name !== 'n8n-local-rules'),
);
return Object.keys(externalPlugins).length === 0 ? [] : [{ plugins: externalPlugins }];
});
const dependencyRuleConfigs = packageConfig.flatMap(({ files, ignores, settings, rules }) => {
const rule = rules?.['import-x/no-extraneous-dependencies'];
if (!rule) return [];
return [
{
...(files ? { files } : {}),
...(ignores ? { ignores } : {}),
...(settings ? { settings } : {}),
rules: { 'import-x/no-extraneous-dependencies': rule },
},
];
});
export default defineConfig(
globalIgnores([
'dist/**',
'coverage/**',
'eslint.config.mjs',
'eslint.guardrails.config.mjs',
'oxlint.config.mts',
'vitest.config.ts',
]),
{
plugins: { 'n8n-local-rules': localRulesPlugin },
},
functionalGuardrailsConfig,
...directivePluginConfigs,
...dependencyRuleConfigs,
);

View File

@ -0,0 +1,40 @@
import { backendConfig } from '@n8n/oxlint-config/backend';
import { defineConfig } from 'oxlint';
// Same rule set as packages/cli/oxlint.config.mts, so a file moved from cli lints the same way.
export default defineConfig({
extends: [backendConfig],
ignorePatterns: ['coverage/**', 'eslint.guardrails.config.mjs'],
rules: {
'n8n-local-rules/misplaced-n8n-typeorm-import': 'error',
// The narrow ESLint guardrail config owns no-unsealed-workflow-entity-write.
'n8n-local-rules/no-guardrail-disable': [
'error',
{
guarded: [
{
rule: 'misplaced-n8n-typeorm-import',
message:
'Keep TypeORM in the persistence layer: put the query behind a use-case repository method in @n8n/db.',
},
{
rule: 'no-unsealed-workflow-entity-write',
message: 'Route the write through a token-gated `WorkflowRepository` method.',
},
],
},
],
'n8n-local-rules/no-type-unsafe-event-emitter': 'error',
},
overrides: [
{
files: ['./test/**/*.ts', './src/**/__tests__/**/*.ts'],
// An override that names a jsPlugin rule must re-declare the plugin.
jsPlugins: ['@n8n/eslint-config/plugin'],
rules: {
'n8n-local-rules/misplaced-n8n-typeorm-import': 'off',
'n8n-local-rules/no-type-unsafe-event-emitter': 'off',
},
},
],
});

View File

@ -0,0 +1,40 @@
{
"name": "@n8n/backend-services",
"version": "1.0.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"build:unchecked": "tsc -p tsconfig.build.json --noCheck",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "pnpm run lint:base && pnpm run lint:guardrails",
"lint:base": "oxlint --type-aware . --quiet",
"lint:fix": "oxlint --type-aware . --fix && pnpm run lint:guardrails",
"lint:guardrails": "eslint --config eslint.guardrails.config.mjs . --quiet",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "vitest run --passWithNoTests",
"test:unit": "vitest run --passWithNoTests",
"test:dev": "vitest --silent=false"
},
"main": "dist/index.js",
"module": "src/index.ts",
"types": "dist/index.d.ts",
"files": [
"dist/**/*"
],
"devDependencies": {
"@n8n/eslint-config": "workspace:*",
"@n8n/oxlint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@vitest/coverage-v8": "catalog:",
"eslint": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
"typescript": "catalog:typescript",
"vitest": "catalog:"
},
"license": "LicenseRef-n8n-sustainable-use"
}

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,23 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
// Same instance setup as packages/cli/test/setup-test-folder.ts, so a moved test finds it.
process.env.N8N_ENCRYPTION_KEY = 'test_key';
const baseDir = join(tmpdir(), 'n8n-tests/');
mkdirSync(baseDir, { recursive: true });
const testDir = mkdtempSync(baseDir);
mkdirSync(join(testDir, '.n8n'));
process.env.N8N_USER_FOLDER = testDir;
process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'true';
writeFileSync(
join(testDir, '.n8n/config'),
JSON.stringify({ encryptionKey: 'test_key', instanceId: '123' }),
{
encoding: 'utf-8',
mode: 0o600,
},
);

View File

@ -0,0 +1,10 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.go.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["test/**", "src/**/__tests__/**"]
}

View File

@ -0,0 +1,19 @@
{
"extends": [
"@n8n/typescript-config/tsconfig.common.go.json",
"@n8n/typescript-config/tsconfig.backend.go.json"
],
"compilerOptions": {
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"lib": ["es2023", "dom"],
"types": ["node", "vitest/globals"],
// Same relaxations as packages/cli/tsconfig.json, so a file moved from cli compiles unchanged.
"strict": true,
"strictFunctionTypes": false,
"strictPropertyInitialization": false,
// TODO: remove all options below this line
"useUnknownInCatchVariables": false
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}

View File

@ -0,0 +1,12 @@
import { createVitestConfigWithDecorators } from '@n8n/vitest-config/node-decorators';
import { configDefaults, mergeConfig } from 'vitest/config';
export default mergeConfig(createVitestConfigWithDecorators(), {
test: {
// One fork per file: tests register mocks in the shared `@n8n/di` Container.
pool: 'forks',
setupFiles: ['./test/setup-test-folder.ts'],
// Vitest's default exclude does not cover dist; stale compiled tests there fail as CJS.
exclude: [...configDefaults.exclude, '**/dist/**'],
},
});

View File

@ -12,7 +12,9 @@ import {
import { WithCreatedAt } from './abstract-entity';
import type { CredentialsEntity } from './credentials-entity';
export type CredentialDependencyType = 'externalSecretProvider';
export const EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE = 'externalSecretProvider' as const;
export type CredentialDependencyType = typeof EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE;
@Entity({ name: 'credential_dependency' })
@Index(['dependencyType', 'dependencyId'])

View File

@ -19,6 +19,7 @@ import { AuthProviderSyncHistory } from './auth-provider-sync-history';
import { BinaryDataFile, SourceTypeSchema, type SourceType } from './binary-data-file';
import {
CredentialDependency,
EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE,
type CredentialDependencyType,
} from './credential-dependency-entity';
import { CredentialsEntity, type CredentialUsageScope } from './credentials-entity';
@ -136,6 +137,7 @@ export {
CredentialsEntity,
type CredentialUsageScope,
CredentialDependency,
EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE,
type CredentialDependencyType,
DeploymentKey,
EvaluationCollection,

View File

@ -157,7 +157,15 @@ describe('CredentialDependencyRepository', () => {
describe('addCredentialDependencyExistsFilter', () => {
it('applies the EXISTS dependency filter using andWhere', () => {
const subQuery = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
andWhere: vi.fn().mockReturnThis(),
getQuery: vi.fn().mockReturnValue('(SELECT 1 FROM "cd")'),
};
const qb = {
subQuery: vi.fn().mockReturnValue(subQuery),
andWhere: vi.fn().mockReturnThis(),
};
const filter = {
@ -167,10 +175,15 @@ describe('addCredentialDependencyExistsFilter', () => {
const result = addCredentialDependencyExistsFilter(qb as never, filter);
expect(qb.andWhere).toHaveBeenCalledWith(
expect.stringContaining('FROM credential_dependency cd'),
filter,
);
expect(subQuery.select).toHaveBeenCalledWith('1');
expect(subQuery.from).toHaveBeenCalledWith(CredentialDependency, 'cd');
expect(subQuery.where).toHaveBeenCalledWith('cd.credentialId = credential.id');
// Both dependency conditions must be present, or the filter matches any dependency row.
expect(subQuery.andWhere.mock.calls).toEqual([
['cd.dependencyType = :dependencyType'],
['cd.dependencyId = :dependencyId'],
]);
expect(qb.andWhere).toHaveBeenCalledWith('EXISTS (SELECT 1 FROM "cd")', filter);
expect(result).toBe(qb);
});
});

View File

@ -1,6 +1,6 @@
import { Container } from '@n8n/di';
import type { EntityManager, SelectQueryBuilder } from '@n8n/typeorm';
import { In, Not, QueryFailedError } from '@n8n/typeorm';
import { In, Like, Not, QueryFailedError } from '@n8n/typeorm';
import { mock } from 'vitest-mock-extended';
import { CredentialsEntity, SharedCredentials } from '../../entities';
@ -189,13 +189,14 @@ describe('CredentialsRepository', () => {
});
describe('findManyAndCount', () => {
it('should call findAndCount with options from toFindManyOptions and return [entities, count]', async () => {
it('should find with options from toFindManyOptions, count on the filter, and return [entities, count]', async () => {
const mockCredentials = [
{ id: '1', name: 'Cred 1', type: 'githubApi' },
{ id: '2', name: 'Cred 2', type: 'githubApi' },
] as CredentialsEntity[];
const count = 2;
entityManager.findAndCount.mockResolvedValueOnce([mockCredentials, count]);
entityManager.find.mockResolvedValueOnce(mockCredentials);
entityManager.count.mockResolvedValueOnce(count);
const [credentials, total] = await credentialsRepository.findManyAndCount({
take: 10,
@ -204,21 +205,22 @@ describe('CredentialsRepository', () => {
expect(credentials).toEqual(mockCredentials);
expect(total).toBe(count);
expect(entityManager.findAndCount).toHaveBeenCalledTimes(1);
const callArg = entityManager.findAndCount.mock.calls[0]?.[1];
expect(entityManager.find).toHaveBeenCalledTimes(1);
const callArg = entityManager.find.mock.calls[0]?.[1];
expect(callArg).toBeDefined();
expect(callArg!.take).toBe(10);
expect(callArg!.select).toBeDefined();
expect(callArg!.relations).toEqual([
'shared',
'shared.project',
'shared.project.projectRelations',
]);
expect(callArg!.relations).toEqual(['shared', 'shared.project']);
expect(callArg!.order).toBeUndefined();
// The count sees only the filter, never the relations or the page window.
expect(entityManager.count).toHaveBeenCalledWith(CredentialsEntity, {
where: callArg!.where,
});
});
it('should honor a caller-provided relations array', async () => {
entityManager.findAndCount.mockResolvedValueOnce([[], 0]);
entityManager.find.mockResolvedValueOnce([]);
entityManager.count.mockResolvedValueOnce(0);
await credentialsRepository.findManyAndCount({
take: 10,
@ -226,23 +228,25 @@ describe('CredentialsRepository', () => {
relations: ['shared', 'shared.project'],
});
const callArg = entityManager.findAndCount.mock.calls[0]?.[1];
const callArg = entityManager.find.mock.calls[0]?.[1];
expect(callArg?.relations).toEqual(['shared', 'shared.project']);
});
it('should apply credentialIds filter when provided', async () => {
entityManager.findAndCount.mockResolvedValueOnce([[], 0]);
entityManager.find.mockResolvedValueOnce([]);
entityManager.count.mockResolvedValueOnce(0);
await credentialsRepository.findManyAndCount({ take: 5, skip: 0 }, ['id1', 'id2']);
expect(entityManager.findAndCount).toHaveBeenCalledTimes(1);
const callArg = entityManager.findAndCount.mock.calls[0]?.[1];
expect(entityManager.find).toHaveBeenCalledTimes(1);
const callArg = entityManager.find.mock.calls[0]?.[1];
expect(callArg).toBeDefined();
expect(callArg!.where).toEqual(expect.objectContaining({ id: In(['id1', 'id2']) }));
});
it('should apply sortBy as TypeORM order', async () => {
entityManager.findAndCount.mockResolvedValueOnce([[], 0]);
entityManager.find.mockResolvedValueOnce([]);
entityManager.count.mockResolvedValueOnce(0);
await credentialsRepository.findManyAndCount({
take: 10,
@ -250,57 +254,49 @@ describe('CredentialsRepository', () => {
sortBy: 'createdAt:desc',
});
const callArg = entityManager.findAndCount.mock.calls[0]?.[1];
const callArg = entityManager.find.mock.calls[0]?.[1];
expect(callArg?.order).toEqual({ createdAt: 'DESC' });
});
it('should default sort direction to ASC when omitted', async () => {
entityManager.findAndCount.mockResolvedValueOnce([[], 0]);
entityManager.find.mockResolvedValueOnce([]);
entityManager.count.mockResolvedValueOnce(0);
await credentialsRepository.findManyAndCount({ sortBy: 'name' });
const callArg = entityManager.findAndCount.mock.calls[0]?.[1];
const callArg = entityManager.find.mock.calls[0]?.[1];
expect(callArg?.order).toEqual({ name: 'ASC' });
});
it('should ignore unknown sortBy columns', async () => {
entityManager.findAndCount.mockResolvedValueOnce([[], 0]);
entityManager.find.mockResolvedValueOnce([]);
entityManager.count.mockResolvedValueOnce(0);
await credentialsRepository.findManyAndCount({ sortBy: 'data:desc' });
const callArg = entityManager.findAndCount.mock.calls[0]?.[1];
const callArg = entityManager.find.mock.calls[0]?.[1];
expect(callArg?.order).toBeUndefined();
});
});
describe('findAllGlobalCredentials', () => {
it('applies dependency filter through query builder when provided', async () => {
const andWhereSpy = vi.fn().mockReturnThis();
const getManySpy = vi.fn().mockResolvedValue([]);
const qb = mock<SelectQueryBuilder<CredentialsEntity>>({
andWhere: andWhereSpy,
getMany: getManySpy,
});
vi.spyOn(credentialsRepository, 'createQueryBuilder').mockReturnValue(qb);
describe('findManyAndCount with includeGlobal', () => {
it('matches globals as an alternative to the sharing filter, keeping column filters', async () => {
entityManager.find.mockResolvedValueOnce([]);
entityManager.count.mockResolvedValueOnce(0);
await credentialsRepository.findAllGlobalCredentials({
filters: {
dependency: {
dependencyType: 'externalSecretProvider',
dependencyId: 'provider-1',
},
},
await credentialsRepository.findManyAndCount({
includeGlobal: true,
filter: { type: 'githubApi', projectId: 'p1' },
});
expect(andWhereSpy).toHaveBeenCalledWith(
expect.stringContaining('FROM credential_dependency cd'),
{
dependencyType: 'externalSecretProvider',
dependencyId: 'provider-1',
},
);
expect(getManySpy).toHaveBeenCalledTimes(1);
expect(entityManager.find).not.toHaveBeenCalled();
const callArg = entityManager.find.mock.calls[0]?.[1];
expect(callArg?.where).toEqual([
{ type: Like('%githubApi%'), shared: { projectId: 'p1' }, usageScope: 'project' },
{ type: Like('%githubApi%'), usageScope: 'project', isGlobal: true },
]);
expect(entityManager.count).toHaveBeenCalledWith(CredentialsEntity, {
where: callArg?.where,
});
});
});
});

View File

@ -0,0 +1,67 @@
import { GlobalConfig } from '@n8n/config';
import { mock } from 'vitest-mock-extended';
import { WorkflowEntity } from '../../entities';
import { SharedWorkflow } from '../../entities/shared-workflow';
import type { OperationContext, TransactionRunner } from '../../services/transaction';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
import { mockInstance } from '../../utils/test-utils/mock-instance';
import { FolderRepository } from '../folder.repository';
import { SharedWorkflowRepository } from '../shared-workflow.repository';
import { WorkflowHistoryRepository } from '../workflow-history.repository';
import { WorkflowRepository } from '../workflow.repository';
describe('WorkflowRepository.createWorkflowWithOwner', () => {
const entityManager = mockEntityManager(WorkflowEntity);
const transactionRunner = mock<TransactionRunner>();
const workflowRepository = new WorkflowRepository(
entityManager.connection,
mockInstance(GlobalConfig, { database: { type: 'postgresdb' } }),
mockInstance(FolderRepository),
mockInstance(SharedWorkflowRepository),
mockInstance(WorkflowHistoryRepository),
transactionRunner,
);
beforeEach(() => {
vi.resetAllMocks();
transactionRunner.run.mockImplementation(
async (ctx: OperationContext, fn: (ctx: OperationContext) => Promise<unknown>) =>
await fn(ctx),
);
entityManager.create.mockImplementation((_target, entityLike) => entityLike as never);
entityManager.save.mockImplementation(async (entity: unknown) => {
if (entity instanceof WorkflowEntity) entity.id = 'wf-1';
return await Promise.resolve(entity);
});
});
it('saves the workflow and its owner share in one transaction', async () => {
const workflow = new WorkflowEntity();
const saved = await workflowRepository.createWorkflowWithOwner(workflow, 'project-1');
expect(saved).toBe(workflow);
expect(transactionRunner.run).toHaveBeenCalledTimes(1);
expect(entityManager.save).toHaveBeenNthCalledWith(1, workflow);
expect(entityManager.create).toHaveBeenCalledWith(SharedWorkflow, {
role: 'workflow:owner',
projectId: 'project-1',
workflowId: 'wf-1',
});
expect(entityManager.save).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ workflowId: 'wf-1' }),
);
});
it('does not create the share when saving the workflow fails', async () => {
entityManager.save.mockRejectedValueOnce(new Error('boom'));
await expect(
workflowRepository.createWorkflowWithOwner(new WorkflowEntity(), 'project-1'),
).rejects.toThrow('boom');
expect(entityManager.create).not.toHaveBeenCalled();
});
});

View File

@ -26,16 +26,18 @@ export function addCredentialDependencyExistsFilter(
qb: SelectQueryBuilder<CredentialsEntity>,
filter: CredentialDependencyFilter,
) {
return qb.andWhere(
`EXISTS (
SELECT 1
FROM credential_dependency cd
WHERE cd."credentialId" = credential.id
AND cd."dependencyType" = :dependencyType
AND cd."dependencyId" = :dependencyId
)`,
filter,
);
// Built as a subquery so TypeORM resolves the table path (schema, table prefix)
// and escapes column names for the active driver.
const exists = qb
.subQuery()
.select('1')
.from(CredentialDependency, 'cd')
.where('cd.credentialId = credential.id')
.andWhere('cd.dependencyType = :dependencyType')
.andWhere('cd.dependencyId = :dependencyId')
.getQuery();
return qb.andWhere(`EXISTS ${exists}`, filter);
}
type DependencyMutationOptions = {

View File

@ -1,11 +1,19 @@
import { Container, Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import type { FindManyOptions, SelectQueryBuilder } from '@n8n/typeorm';
import type { FindManyOptions, FindOptionsWhere, SelectQueryBuilder } from '@n8n/typeorm';
import { DataSource, In, Like, Not, QueryFailedError } from '@n8n/typeorm';
import { CredentialsEntity, SharedCredentials, type User } from '../entities';
import { UserError } from 'n8n-workflow';
import {
CredentialsEntity,
EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE,
SharedCredentials,
type User,
} from '../entities';
import { BaseRepository } from './base-repository';
import {
CredentialDependencyRepository,
addCredentialDependencyExistsFilter,
type CredentialDependencyFilter,
} from './credential-dependency.repository';
@ -14,9 +22,16 @@ import { SharedCredentialsRepository } from './shared-credentials.repository';
import type { ICredentialsDb, ListQuery } from '../entities/types-db';
import type { OperationContext } from '../services/transaction';
import { TransactionRunner } from '../services/transaction';
import { isUniqueConstraintError } from '../utils/is-unique-constraint-error';
import { chunkIds } from '../utils/chunk-ids';
import { parseListQuerySortBy } from '../utils/list-query-sort';
export class CredentialIdConflictError extends UserError {
constructor() {
super('A credential with this ID already exists');
}
}
const SORTABLE_COLUMNS = new Set(['id', 'name', 'createdAt', 'updatedAt']);
export type CredentialSharingRelation =
@ -24,14 +39,14 @@ export type CredentialSharingRelation =
| 'shared.project'
| 'shared.project.projectRelations';
const DEFAULT_CREDENTIAL_RELATIONS: CredentialSharingRelation[] = [
'shared',
'shared.project',
'shared.project.projectRelations',
];
// The list path reads `shared[].role` and `shared[].project`; loading every member of
// every shared project would multiply the joined rows by the project sizes.
const DEFAULT_CREDENTIAL_RELATIONS: CredentialSharingRelation[] = ['shared', 'shared.project'];
type CredentialsListQueryOptions = ListQuery.Options & {
includeData?: boolean;
/** Also match global credentials, so they page, count and filter like every other row. */
includeGlobal?: boolean;
user?: User;
relations?: CredentialSharingRelation[];
};
@ -42,10 +57,45 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
dataSource: DataSource,
private readonly instanceCredentialAssignmentRepository: InstanceCredentialAssignmentRepository,
transactionRunner: TransactionRunner,
private readonly credentialDependencyRepository: CredentialDependencyRepository,
) {
super(CredentialsEntity, dataSource.manager, transactionRunner);
}
async insertProjectCredentialWithOwner(
credential: Pick<
CredentialsEntity,
'id' | 'name' | 'type' | 'data' | 'isManaged' | 'isResolvable'
> &
Partial<Pick<CredentialsEntity, 'isGlobal'>>,
projectId: string,
externalSecretProviderIds: string[],
ctx: OperationContext,
): Promise<CredentialsEntity> {
return await this.runInTransaction(ctx, async (manager) => {
const entity = this.create({ ...credential, usageScope: 'project' });
try {
await manager.insert(CredentialsEntity, entity);
} catch (error) {
// Only the credential insert can report an ID conflict.
if (isUniqueConstraintError(error)) throw new CredentialIdConflictError();
throw error;
}
await manager.insert(SharedCredentials, {
credentialsId: entity.id,
projectId,
role: 'credential:owner',
});
await this.credentialDependencyRepository.upsertDependenciesForCredential({
credentialId: entity.id,
dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE,
dependencyIds: externalSecretProviderIds,
entityManager: manager,
});
return await manager.findOneByOrFail(CredentialsEntity, { id: entity.id });
});
}
async findStartingWith(credentialName: string) {
return await this.find({
select: ['name'],
@ -206,7 +256,23 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
findManyOptions.where = { ...findManyOptions.where, id: In(credentialIds) };
}
return await this.findAndCount(this.onlyProjectCredentials(findManyOptions));
const options = this.onlyProjectCredentials(findManyOptions);
if (listQueryOptions?.includeGlobal) {
// Globals are visible whatever the sharing filter says; the column filters still apply.
const { shared: _shared, ...columnFilters } =
options.where as FindOptionsWhere<CredentialsEntity>;
options.where = [
options.where as FindOptionsWhere<CredentialsEntity>,
{ ...columnFilters, isGlobal: true },
];
}
// `findAndCount` would count over the selected relations too, multiplying the rows
// it has to scan by every sharing and project member. Count on the filter alone.
const credentials = await this.find(options);
const count = await this.count({ where: options.where });
return [credentials, count];
}
private onlyProjectCredentials(
@ -346,78 +412,16 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
}
/**
* Find all global credentials, optionally narrowed by credential type.
* Find all global credentials.
*/
async findAllGlobalCredentials(
options: {
includeData?: boolean;
type?: string;
filters?: {
dependency?: CredentialDependencyFilter;
};
} = {},
options: { includeData?: boolean } = {},
): Promise<CredentialsEntity[]> {
const { includeData = false, type, filters } = options;
const dependencyFilter = filters?.dependency;
if (dependencyFilter) {
return await this.findAllGlobalCredentialsByDependencyFilter({
dependencyFilter,
includeData,
type,
});
}
const findManyOptions = this.toFindManyOptions({ includeData });
findManyOptions.where = {
...findManyOptions.where,
isGlobal: true,
usageScope: 'project',
...(type ? { type: Like(`%${type}%`) } : {}),
};
const findManyOptions = this.toFindManyOptions({ includeData: options.includeData ?? false });
findManyOptions.where = { ...findManyOptions.where, isGlobal: true, usageScope: 'project' };
return await this.find(findManyOptions);
}
private async findAllGlobalCredentialsByDependencyFilter(options: {
dependencyFilter: CredentialDependencyFilter;
includeData?: boolean;
type?: string;
}): Promise<CredentialsEntity[]> {
const { includeData, dependencyFilter, type } = options;
const qb = this.createQueryBuilder('credential');
qb.where('credential.isGlobal = :isGlobal', { isGlobal: true });
qb.andWhere('credential.usageScope = :usageScope', { usageScope: 'project' });
if (type) {
qb.andWhere('credential.type LIKE :type', { type: `%${type}%` });
}
addCredentialDependencyExistsFilter(qb, dependencyFilter);
const defaultSelect: Array<keyof CredentialsEntity> = [
'id',
'name',
'description',
'type',
'isManaged',
'createdAt',
'updatedAt',
'isGlobal',
'isResolvable',
'resolverId',
];
const selectColumns = defaultSelect.map((k) => `credential.${k}`);
if (includeData) {
selectColumns.push('credential.data');
}
qb.select(selectColumns);
qb.leftJoinAndSelect('credential.shared', 'shared');
qb.leftJoinAndSelect('shared.project', 'project');
qb.leftJoinAndSelect('project.projectRelations', 'projectRelations');
return await qb.getMany();
}
/**
* Find all credentials that are owned by a personal project.
*/
@ -489,15 +493,13 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
// Get credentials with pagination
const credentials = await query.getMany();
// Build count query without pagination and relations
const countQuery = this.getManyQueryWithSharingSubquery(user, sharingOptions, {
...options,
take: undefined,
skip: undefined,
select: undefined,
});
// Remove relations and select for count
// Count on the filter alone: no pagination, no relation joins.
const countQuery = this.getManyQueryWithSharingSubquery(
user,
sharingOptions,
{ ...options, take: undefined, skip: undefined, select: undefined, sortBy: undefined },
{ joinRelations: false },
);
const count = await countQuery.select('credential.id').getCount();
return { credentials, count };
@ -521,6 +523,7 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
dependency?: CredentialDependencyFilter;
};
} = {},
{ joinRelations = true }: { joinRelations?: boolean } = {},
): SelectQueryBuilder<CredentialsEntity> {
const qb = this.createQueryBuilder('credential');
qb.andWhere('credential.usageScope = :usageScope', { usageScope: 'project' });
@ -544,8 +547,15 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
sharingOptionsWithProjectId,
);
// Apply the sharing filter using the subquery
qb.andWhere(`credential.id IN (${sharedCredentialSubquery.getQuery()})`);
// Apply the sharing filter using the subquery; globals bypass it when requested.
const sharingCondition = `credential.id IN (${sharedCredentialSubquery.getQuery()})`;
if (options.includeGlobal) {
qb.andWhere(`(${sharingCondition} OR credential.isGlobal = :includeGlobal)`, {
includeGlobal: true,
});
} else {
qb.andWhere(sharingCondition);
}
qb.setParameters(sharedCredentialSubquery.getParameters());
// Apply other filters
@ -598,11 +608,18 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
qb.addSelect('credential.data');
}
// Apply relations
if (!options.select) {
qb.leftJoinAndSelect('credential.shared', 'shared')
.leftJoinAndSelect('shared.project', 'project')
.leftJoinAndSelect('project.projectRelations', 'projectRelations');
// Apply relations, same set as `toFindManyOptions`
if (joinRelations && !options.select) {
const relations = options.relations ?? DEFAULT_CREDENTIAL_RELATIONS;
if (relations.includes('shared')) {
qb.leftJoinAndSelect('credential.shared', 'shared');
}
if (relations.includes('shared.project')) {
qb.leftJoinAndSelect('shared.project', 'project');
}
if (relations.includes('shared.project.projectRelations')) {
qb.leftJoinAndSelect('project.projectRelations', 'projectRelations');
}
}
if (options.sortBy) {

View File

@ -20,7 +20,11 @@ export { AuthIdentityRepository } from './auth-identity.repository';
export { AuthProviderSyncHistoryRepository } from './auth-provider-sync-history.repository';
export { BaseRepository } from './base-repository';
export { BinaryDataRepository } from './binary-data.repository';
export { CredentialsRepository, type CredentialSharingRelation } from './credentials.repository';
export {
CredentialsRepository,
CredentialIdConflictError,
type CredentialSharingRelation,
} from './credentials.repository';
export { CredentialDependencyRepository } from './credential-dependency.repository';
export {
DeploymentKeyRepository,

View File

@ -308,6 +308,28 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
await this.managerFor(ctx).update(WorkflowEntity, id, content);
}
/**
* Creates the workflow together with its `workflow:owner` share in one transaction.
*
* Deliberately outside the `workflowSave` clearance the other writes here assert: the only
* caller is standalone node execution, whose row is archived, single-node and deleted after
* the run. The content is still policed where it matters — `PolicyLifecycleHandler` enforces
* `workflowStart` on `workflowExecuteBefore`, which every execution path reaches.
*/
async createWorkflowWithOwner(
workflow: WorkflowEntity,
projectId: string,
ctx: OperationContext = {},
): Promise<WorkflowEntity> {
return await this.runInTransaction(ctx, async (em) => {
const saved = await em.save(workflow);
await em.save(
em.create(SharedWorkflow, { role: 'workflow:owner', projectId, workflowId: saved.id }),
);
return saved;
});
}
/**
* Persists a new workflow, gated on a clearance for its content.
*

View File

@ -53,7 +53,7 @@ keep their explicit pattern. `like` matches case; `ilike` ignores case.
| `workspace` | 8 |
| `executions` | 8 |
| `credentials` | 6 |
| `nodes` | 6 |
| `nodes` | 7 |
| `mcp-servers` | 4 |
| `conversation-history` | 2 |
| `task-control` | 3 |
@ -61,7 +61,7 @@ keep their explicit pattern. `like` matches case; `ilike` ignores case.
| `eval-config` | 6 |
| `n8n-docs` | 3 |
| `agents` | 1 |
| `build-workflow`, `ask-user`, `parse-file` | single-purpose |
| `build-workflow`, `ask-user`, `parse-file`, `searchModels` | single-purpose |
## Orchestration Tools
@ -535,14 +535,18 @@ workflow is configured correctly before suggesting the user run or publish it.
### `workflows(action="restore-version")` *(conditional — requires license)*
Restore a workflow to a previous version (overwrites current draft). HITL
approval required.
approval required. This does not publish the restored draft. A production rollback
must also use the publish action and its normal approval flow.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `workflowId` | string | yes | Workflow ID |
| `versionId` | string | yes | Version to restore |
**Returns**: `{ success: boolean }`
**Returns on success**: `{ success: true, workflowId, publishState, publishStateNote }`.
`publishState` contains `savedVersionId`, `activeVersionId`, and `live`
(`unpublished`, `current`, or `stale`). The note states whether publication is still
required. Denied and failed restores retain their existing error responses.
### `workflows(action="update-version")` *(conditional — requires `feat:namedVersions` license)*
@ -828,13 +832,13 @@ Test whether a credential is valid and can connect to its service.
---
## `nodes` (6 actions)
## `nodes` (7 actions)
The full domain surface has six actions. The orchestrator receives all six
The full domain surface has seven actions. The orchestrator receives all seven
actions in the current registry. The tool also defines a restricted
`type-definition` and `explore-resources` surface, but the orchestrator registry
does not currently select it. Specialized agents that resolve the full domain
tool can also receive all six actions.
tool can also receive all seven actions.
### `nodes(action="list")`
@ -910,6 +914,110 @@ discriminator values like spreadsheet IDs, calendar names, etc.
**Returns**: `{ results, paginationToken?, builderHint?, error? }`.
### `nodes(action="execute")`
Execute a single node standalone — real credentials, caller-supplied parameters
and input items — and return its real output items. The node runs through the
regular execution engine (an archived temporary workflow is created for the run
and deleted afterwards), so queue-mode worker dispatch applies. Intended for
learning a node's exact output shape before wiring downstream expressions, or
testing one node in isolation.
Before the approval prompt, `config` is validated against the generated
workflow-sdk node schema (`validateNodeConfig`) - a malformed config returns
field-level errors immediately. One exception: a missing discriminator (e.g.
`resource`/`operation`) does not block — n8n falls back to the node's defaults
at runtime, so the node can still run and cause side effects.
**Approval mirrors `executions(action="run")`** — executing one node is
equivalent to running a one-node workflow, so the same `runWorkflow` admin
policy applies (`blocked` denies; `always_allow` skips the prompt — a
standalone node request is always agent-authored, the analog of an AI-created
workflow). A *scoped* `always_allow` — the checkpoint follow-up override, which
names the workflow IDs it covers — does not skip the prompt: a standalone node
run has no workflow ID to match. Under the default `require_approval`, the tool suspends with
severity `warning`; "Always allow" persists a session grant scoped by node
type + resource + operation (`nodes:execute:<type>:<resource>:<operation>`) —
the same split the generated node TS types use, so a future per-operation
destructiveness policy plugs in without changing the key format. Later
executions of the same operation skip the prompt for the session.
A node that declares neither discriminator (HTTP Request, Set, Merge, Filter, …)
is scoped by the first of `mode`, `url`, `query`, `command` or `action` it
declares, and by node type alone when it declares none. "Always allow" is not
offered when that value is long enough to push the key past the grant column
width — one approved URL must not stand for every URL sharing its prefix.
The request envelope mirrors a workflow-sdk node (`{ type, version, config }`),
so the agent can pass a node it is building verbatim:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | yes | Full node type name, e.g. `n8n-nodes-base.slack` |
| `version` | number | yes | Node type version |
| `config.parameters` | object | yes | Same shape as workflow-sdk `NodeConfig.parameters` |
| `config.credentials` | object | no | Resolved credential references `{ id, name }` by credential type; n8n Connect managed credentials use `{ id: null, name, __aiGatewayManaged: true }` |
| `input` | array | no | Input items `{ json }` (defaults to one empty item) |
| `timeoutMs` | number | no | Max execution time, capped at 60s |
**Returns**: `{ status: 'success', output }` or
`{ status: 'error', error: { message, description?, nodeErrorType? } }`. The
output is the serialized items inside an `<untrusted_data
source="execution-output">` boundary, the same envelope the workflow-execution
path puts on node output — the items come from whatever service the node
called. Binary output is reduced to metadata (`fileName`, `mimeType`,
`fileSize`). Output is size-capped (a `truncated` field reports shown vs total
items).
When `N8N_AI_ALLOW_SENDING_PARAMETER_VALUES` is disabled, output items and
upstream error details are suppressed, mirroring `executions(action="run")`.
Wait states are not supported — a node that starts waiting (e.g. Wait,
send-and-wait operations) returns an error.
Limitations: the node really runs (side effects happen); expressions
referencing other nodes cannot resolve; trigger/webhook-only nodes are
rejected; credentials must be resolved references — the SDK's
placeholder/new-credential forms have no stored row and cannot execute.
---
## `searchModels`
Preliminary models.dev catalog search when choosing a model without a relevant
credential or a suitable named builder-hint recommendation. The `model-selection`
skill activates this deferred tool when model-bearing node definitions are
inspected. It can also be discovered with `search_tools` and loaded with
`load_tool`. Activation does not call the catalog. If a provider credential or Gateway credits is
available, use `nodes(action="explore-resources")` with that credential instead.
Do not use catalog search to validate an unfamiliar model or to recover from a
failed credential lookup.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `provider` | string | yes | Canonical catalog provider ID, such as `openai`, `google`, `anthropic`, `openrouter`, `aws-bedrock`, or `azure-openai`. Trimmed and case-insensitive. Model-family names are not provider IDs. |
| `query` | string | no | Case-insensitive substring match on model IDs or names, applied before sorting and limiting. Trimmed; blank means no filter. Maximum 100 characters. |
| `limit` | integer | no | Default 10, minimum 1, maximum 10. |
For Claude through OpenRouter, use `provider: "openrouter", query: "claude"`.
For OpenAI through OpenRouter, use `query: "openai"`. `hasMore` counts only
matching eligible models.
Returns recent non-deprecated models whose catalog input and output modalities
both include text. Preview models remain eligible. Results are ordered by a valid ISO release
date, newest first, then by model ID. Missing or invalid dates sort last and are
returned as `null`. Existing catalog alias normalization removes equivalent
dated snapshots where the catalog identifies a latest alias.
The result includes exact IDs, model metadata, catalog pricing, `hasMore`,
`source`, `fetchedAt`, `freshness`, and `credentialAccess: "not_checked"`.
Missing metadata is `null`. Status is `ok`, `unknown_provider`,
`no_matching_models`, or `catalog_unavailable`. Absence from this limited
catalog result does not establish that a model is invalid.
The public catalog cache is shared across requests for one hour. Refreshes have
a five-second deadline. On failure, a snapshot younger than 24 hours can be
returned with `freshness: "stale"`. Older snapshots are not returned. Cancelling
one caller stops its wait without cancelling a refresh shared with other callers.
---
## `data-tables` (11 actions)

View File

@ -400,7 +400,7 @@ describe('createCasePipeline', () => {
const orchestrator = makeOrchestrator({ build, lane, buildDurationMs: 3 });
const agentArtifact = {
agentId: 'agent-1',
config: { name: 'Support agent' },
config: { name: 'Support agent', model: 'openai/gpt-5-mini' },
skills: {},
};
const pipeline = createCasePipeline(
@ -424,6 +424,86 @@ describe('createCasePipeline', () => {
expect(lane.tracedExecute).not.toHaveBeenCalled();
expect(lane.tracedExecuteAgent).toHaveBeenCalledTimes(1);
});
it('does not run a draft Agent and owns the red as framework_issue when no LLM credential was declared', async () => {
const lane = makeLane();
const build = okBuild({
workflowId: undefined,
workflowJsons: [],
transcript: [] as never,
artifactRefs: [{ type: 'agent', id: 'agent-1' }] as never,
});
const orchestrator = makeOrchestrator({ build, lane, buildDurationMs: 3 });
const draftArtifact = { agentId: 'agent-1', config: { name: 'Draft', model: '' }, skills: {} };
const pipeline = createCasePipeline(
makeDeps(orchestrator, {
agentContextByKey: new Map([
['0:case-a', Promise.resolve({ rendered: 'AGENT CONTEXT', artifact: draftArtifact })],
]),
}),
);
const output = await pipeline.runRow(rowInputs('happy-path'));
expect(output).toMatchObject({
passed: false,
agentId: 'agent-1',
attribution: 'framework_issue',
failureCategory: 'framework_issue',
});
expect(lane.tracedExecuteAgent).not.toHaveBeenCalled();
});
it('counts googlePalmApi as a declared LLM credential', async () => {
const lane = makeLane();
const build = okBuild({
workflowId: undefined,
workflowJsons: [],
transcript: [] as never,
artifactRefs: [{ type: 'agent', id: 'agent-1' }] as never,
});
const orchestrator = makeOrchestrator({ build, lane, buildDurationMs: 3 });
const draftArtifact = { agentId: 'agent-1', config: { name: 'Draft', model: '' }, skills: {} };
const testCase = { ...scenarioCase(['happy-path']), credentials: [{ type: 'googlePalmApi' }] };
const pipeline = createCasePipeline(
makeDeps(orchestrator, {
testCaseByFileSlug: new Map([['case-a', testCase]]),
agentContextByKey: new Map([
['0:case-a', Promise.resolve({ rendered: 'AGENT CONTEXT', artifact: draftArtifact })],
]),
}),
);
const output = await pipeline.runRow(rowInputs('happy-path'));
expect(output).toMatchObject({ passed: false, attribution: 'builder_issue' });
});
it('owns a draft Agent as builder_issue when the case declared an LLM credential', async () => {
const lane = makeLane();
const build = okBuild({
workflowId: undefined,
workflowJsons: [],
transcript: [] as never,
artifactRefs: [{ type: 'agent', id: 'agent-1' }] as never,
});
const orchestrator = makeOrchestrator({ build, lane, buildDurationMs: 3 });
const draftArtifact = { agentId: 'agent-1', config: { name: 'Draft', model: '' }, skills: {} };
const testCase = { ...scenarioCase(['happy-path']), credentials: [{ type: 'openAiApi' }] };
const pipeline = createCasePipeline(
makeDeps(orchestrator, {
testCaseByFileSlug: new Map([['case-a', testCase]]),
agentContextByKey: new Map([
['0:case-a', Promise.resolve({ rendered: 'AGENT CONTEXT', artifact: draftArtifact })],
]),
}),
);
const output = await pipeline.runRow(rowInputs('happy-path'));
expect(output).toMatchObject({ passed: false, attribution: 'builder_issue' });
expect(lane.tracedExecuteAgent).not.toHaveBeenCalled();
});
});
function deferred(): { promise: Promise<unknown>; resolve: (v: unknown) => void } {

View File

@ -561,6 +561,22 @@ describe('formatComparisonMarkdown', () => {
expect(md).toMatch(/Run 1 \[builder_issue\]: Builder produced/);
});
it('marks a case that built an Agent as "(agent)" in both renderers', () => {
const ev = evaluation({
totalRuns: 2,
testCases: [{ scenarios: [{ name: 'happy', passCount: 2, passes: [true, true] }] }],
});
ev.testCases[0].runs[1].agentId = 'agent-1';
const slugs = slugMap(ev, ['a']);
const pr = bucket('pr', [s('a', 'happy', 2, 2)]);
const outcome = ok(compareBuckets(pr, pr));
expect(formatComparisonMarkdown(ev, outcome, { slugByTestCase: slugs })).toMatch(
/`a` \(agent\)/,
);
expect(formatComparisonTerminal(ev, outcome, { slugByTestCase: slugs })).toMatch(/a \(agent\)/);
});
it('uses `file/scenario` slug headers in the bottom Failure details section', () => {
const evalWithFailures = evaluation({
totalRuns: 3,

View File

@ -708,6 +708,12 @@ function renderFailureCategorySection(categories: FailureCategoryComparison[]):
return lines;
}
// Which artifact the builder chose is a result, not a given: a case routed to
// an Agent one night and a workflow the next shows up here, not as a crash.
function builtAgent(tc: TestCaseAggregation): boolean {
return tc.runs.some((run) => run.agentId !== undefined);
}
function renderPerTestCaseDetails(
evaluation: MultiRunEvaluation,
slugByTestCase?: Map<WorkflowTestCase, string>,
@ -719,7 +725,8 @@ function renderPerTestCaseDetails(
lines.push('');
const renderName = (tc: TestCaseAggregation): string => {
const slug = slugByTestCase?.get(tc.testCase);
return slug ? `\`${slug}\`` : `\`${caseDisplayPrompt(tc.testCase).slice(0, 70)}\``;
const name = slug ? `\`${slug}\`` : `\`${caseDisplayPrompt(tc.testCase).slice(0, 70)}\``;
return builtAgent(tc) ? `${name} (agent)` : name;
};
if (totalRuns > 1) {
lines.push(`| Test case | Status | pass@${totalRuns} | pass^${totalRuns} |`);
@ -1197,8 +1204,8 @@ function formatTerminalPerTestCase(
lines.push(TERMINAL_INDENT + heading);
const nameOf = (tc: TestCaseAggregation, max: number): string => {
const slug = slugByTestCase?.get(tc.testCase);
return slug ?? caseDisplayPrompt(tc.testCase).slice(0, max);
const name = slugByTestCase?.get(tc.testCase) ?? caseDisplayPrompt(tc.testCase).slice(0, max);
return builtAgent(tc) ? `${name} (agent)` : name;
};
if (totalRuns > 1) {

View File

@ -11,7 +11,7 @@ import { isRecord } from '@n8n/utils/is-record';
import { setTimeout as delay } from 'node:timers/promises';
import { agentHandler } from './artifacts/agent-handler';
import { attributionForScenario } from './attribution';
import { attributionForScenario, type EvalAttribution } from './attribution';
import type { EvalLogger } from './logger';
import { writeScenarioVerificationSnapshot, type VerificationArtifact } from './scenario-execution';
import {
@ -28,8 +28,39 @@ import type {
ChecklistItem,
ExecutionScenarioResult,
ExecutionScenario,
TestCaseCredential,
} from '../types';
/** LLM credential types the eval seeder can create (credentials/seeder.ts). */
const LLM_CREDENTIAL_TYPES = new Set(['openAiApi', 'googlePalmApi']);
/**
* A built Agent with no model cannot run. Who owns that depends on what the
* case offered the builder: with no LLM credential declared, leaving the model
* for setup is the builder's documented behaviour, so the eval is what cannot
* proceed. With one declared, an empty model is the builder's miss.
*/
export function draftAgentVerdict(
artifact: AgentArtifact | undefined,
credentials: TestCaseCredential[] | undefined,
): { attribution: EvalAttribution; reasoning: string } | undefined {
if (!artifact || !isRecord(artifact.config)) return undefined;
const model = artifact.config.model;
if (typeof model === 'string' && model.trim() !== '') return undefined;
const offeredLlmCredential = (credentials ?? []).some((c) => LLM_CREDENTIAL_TYPES.has(c.type));
return offeredLlmCredential
? {
attribution: 'builder_issue',
reasoning:
'The built Agent has no model although the case declared an LLM credential the builder could have used, so the scenario cannot run.',
}
: {
attribution: 'framework_issue',
reasoning:
'The built Agent has no model. The case declared no LLM credential, so the builder left model selection to setup by design; the eval has no credential to run the Agent with.',
};
}
/** Shared routing rule for both eval paths: an agent ref marks the case
* agent-anchored — the agent, not any co-built helper workflow, is the target. */
export function findAgentArtifactRef(

View File

@ -15,7 +15,11 @@ import type { InstanceAiRunDebugResponse } from '@n8n/api-types';
import type { BuildOrchestrator } from './build-orchestrator';
import { sentinelOutcomeFromVerdicts, type TargetOutput } from './reshape';
import type { CliArgs } from '../cli/args';
import { findAgentArtifactRef, type AgentScenarioContext } from '../harness/agent-execution';
import {
draftAgentVerdict,
findAgentArtifactRef,
type AgentScenarioContext,
} from '../harness/agent-execution';
import { buildFailedOnInfra } from '../harness/build-workflow';
import { cleanupBuild, effectiveTimeoutMs } from '../harness/cleanup';
import type { EvalLogger } from '../harness/logger';
@ -317,6 +321,32 @@ export function createCasePipeline(deps: CasePipelineDeps): CasePipeline {
const agentArtifactFields = capturedAgent?.artifact
? { agentArtifact: capturedAgent.artifact }
: {};
const declaredCredentials = testCaseByFileSlug.get(inputs.testCaseFile)?.credentials;
// A draft Agent throws before its first model turn; running it only produces
// a red the judge pins on the builder. Decide ownership here instead.
const draft = draftAgentVerdict(capturedAgent?.artifact, declaredCredentials);
if (draft) {
logger.warn(` [${scenario.name}] not run: ${draft.reasoning}`);
return await attachExpectations({
buildSuccess: true,
agentId: agentRef.id,
agentContext,
...agentArtifactFields,
passed: false,
score: 0,
reasoning: draft.reasoning,
failureCategory: draft.attribution,
attribution: draft.attribution,
execErrors: ['Agent has no model configured'],
buildDurationMs,
...buildSpendFields,
execDurationMs: 0,
nodeCount: 0,
threadId: build.threadId,
buildTrace: build.buildTrace,
planRejections: build.proxyDecisionStats?.rejection ?? 0,
});
}
let agentResult;
for (let attempt = 1; ; attempt++) {
try {

View File

@ -32,7 +32,7 @@ tool and act on the result; do not ask for chat approval beforehand.
| `name` | always | e.g. `"Correctness"` |
| `preset` | always | `correctness` or `helpfulness` |
| `credentialId` | always | credential id for the judge model; also determines the provider |
| `model` | always | e.g. `gpt-4o` |
| `model` | always | model ID supported by the judge credential |
| `outputType` | defaults to `numeric` | `numeric` or `boolean` |
| `actualAnswer` | always | expression for the produced answer, e.g. `={{ $json.output }}` |
| `expectedAnswer` | `correctness` preset | expression for ground truth, e.g. `={{ $json.expected_output }}` |
@ -72,7 +72,7 @@ resolved output. Only omit `=` for a genuinely fixed constant string.
name: "Correctness",
preset: "correctness",
credentialId: "<cred_id>", // provider is derived from this credential
model: "gpt-4o",
model: "<model_id>",
actualAnswer: "={{ $json.output }}",
expectedAnswer: "={{ $json.expected_output }}"
}]
@ -98,7 +98,7 @@ eval-config(
name: "Helpfulness",
preset: "helpfulness",
credentialId: "<cred_id>", // provider is derived from this credential
model: "claude-sonnet-4-5",
model: "<model_id>",
userQuery: "={{ $json.input }}",
actualAnswer: "={{ $json.output }}"
}]

View File

@ -0,0 +1,104 @@
---
name: model-selection
description: >-
Guides AI model choices in new workflows, existing workflows, pasted workflow
JSON, and model-availability questions. Load before recommending, selecting,
replacing, or judging a model. Preserve working or explicitly requested IDs.
An unfamiliar name or generic 404 does not establish that a model is invalid.
Do not claim otherwise or suggest a replacement without provider evidence.
Prefer credential-specific resource lookup when available. Use searchModels
only to choose an unspecified model without a relevant credential or suitable
named recommendation, never to validate a supplied ID or diagnose an existing
failure. For a new choice, do not narrow discovery to a remembered model ID.
Do not choose GPT-4-family or Claude 3.x for an unspecified model unless verified
access constraints require them. Also activated when inspecting a model-bearing
node.
dependencies:
tools:
- searchModels
recommended_tools:
- nodes
- credentials
- searchModels
- research
---
# Model selection
Your model memory tends to suggest GPT-4o, GPT-4.1, or Claude 3.x. These are
outdated defaults for new workflows. Do not recommend them just because they
seem familiar, stable, or inexpensive. Preserve an existing or requested model.
When choosing an unspecified model, use an older one only when verified access
constraints require it.
First check whether the user supplied a model or the workflow already has one.
**Preserve that ID without calling `searchModels` to validate it.** A model in a
revised design is still a user choice, even if its name is unfamiliar.
Do not question its validity or suggest a replacement solely because you do not
recognize it.
Only when YOU must choose an unspecified model, obtain a credential model list,
an explicit named recommendation in the node's `@builderHint`, or current catalog
evidence. A node's `@default` and generic advice to "prefer stable models" are not
recommendations. **Unspecified model plus no credential and no named hint means
call `searchModels` before writing the workflow.** Do not substitute a remembered ID.
## Choose the source
1. Preserve an existing working or explicitly requested model, including IDs in
configuration fields and expressions. Do not upgrade it during an unrelated
edit. If evidence shows it cannot meet the request, explain that evidence and
agree on an alternative before changing it.
2. For a new choice, check relevant credentials, including Gateway credits.
Use `nodes(action="explore-resources")` with that credential and the exact
lookup method from the node definition. Its available models take precedence
over builder hints and public catalogs. Choose one that fits the operation,
task, and budget. Explain access constraints if they require an older model.
3. Without a relevant credential, use an explicit model recommendation from the
node's `@builderHint` when it fits the task and serving provider. Generic
advice such as "prefer stable models" does not name a candidate. Do not treat
`@default` as a builder-hint recommendation.
4. If there is no suitable explicit hint, call `searchModels` with the serving
provider before choosing an ID. Choosing a model while building a workflow
is preliminary selection too. Reuse relevant results already retrieved for
this task; do not repeat discovery for every node.
For an unspecified model, leave `query` empty unless the user requested a model
maker or family. Do not search for a remembered model ID and treat its presence
as proof that it is current. Compare current candidates before choosing one.
Keep the requested serving provider and model maker. For Claude through OpenRouter,
call `searchModels({ provider: "openrouter", query: "claude" })`. For OpenAI through
OpenRouter, use `query: "openai"`. The query filters model IDs and names before
selecting the ten most recent matches, so other makers do not fill the results.
Use an exact returned ID in the `anthropic/` or `openai/` namespace. Do not construct
OpenRouter IDs from direct-provider names. If no suitable model is returned,
consult the serving provider's official catalog or documentation.
Catalog results are advisory and may include previews. Prefer a stable model
that fits the task, and report the source and freshness of catalog-based advice.
Catalog presence does not prove credential access. Once a credential is connected,
its resource lookup takes precedence. Never use catalog search to replace a
failed credential lookup or merely to check an unfamiliar model.
When making a new recommendation, state which access or capabilities remain
unverified. Do not invent model IDs, versions, prices, or rankings. Do not force
a paid credential to get a newer model.
## Diagnose availability
Do not call `searchModels` to diagnose an existing model error. Catalog recency
and catalog absence do not justify a repair. Keep the model unchanged while
collecting the actual failure evidence; a failed lookup is not permission to guess.
A generic `404` or `MODEL_NOT_FOUND` reports a failed request. It does not alone
prove global model nonexistence or account-wide unavailability. Check the actual
request, provider endpoint, credential, operation, and execution evidence before
recommending a replacement. Scope any provider rejection to what it establishes.
Absence from a partial model list or documentation page is not a rejection.
Use execution-specific evidence to distinguish simulated verification from real
provider calls. The current credential inventory does not establish whether an
earlier run was simulated. When direct execution and chat behave differently,
compare their records and configuration. Keep unsupported causes as hypotheses;
do not claim a live fix from mocked verification.

View File

@ -10,6 +10,7 @@ description: >-
for automations the user will run again — that is the normal build +
post-build-flow path.
recommended_tools:
- nodes
- build-workflow
- workflows
- executions
@ -50,11 +51,51 @@ treat the request as reusable** and follow the normal build flow — a reusable
workflow that runs once is harmless; a one-off flow applied to an automation
skips verification the user would have wanted.
A one-off that touches external systems is still workflow-anchored (you cannot
write to external services directly) — the intent changes the *post-build
flow*, not the anchor.
## Single-node one-offs: prefer direct node execution
## The one-off flow
When the entire effect is **one node operation**, skip the workflow: execute
the node directly with `nodes(action="execute")`. It runs a single node with
real credentials through the regular execution engine and returns its real
output items — no workflow to build, set up, verify, or clean up afterwards.
Direct execution is sufficient when ALL of these hold:
- The effect is one node call (one write, one API operation). A read-back of
the destination may be a second `execute` call of a read operation.
- The input items are already at hand — pasted into the chat or read earlier
in the conversation — so you can pass them literally as `input` items.
Parameters must not use expressions referencing other nodes; they cannot
resolve (the node runs alone).
- The node runs standalone on `main` input alone — no required sub-node
connections (e.g. an AI Agent needs a language model attached; such nodes
need a workflow).
- A usable credential already exists (`credentials(action="list")`); the
action takes resolved `{ id, name }` references. If credentials must be
created first, route that through the credentials setup as usual.
- The run fits the 60s cap and the input volume is modest.
The run executes in the current conversation's project, so credentials shared
with that project are usable. If it fails because a credential is not
accessible there, fall back to the one-off workflow flow below.
Call it with the same shape as a workflow-sdk node — `{ type, version,
config: { parameters, credentials } }` plus `input` items. Read
`nodes(action="type-definition")` first, as you would before configuring any
node. Approval works exactly like `executions(action="run")` — the same
run-approval card, admin policy, and session grants — and for a one-off that
prompt is the consent gate. The returned output items are
real, so the read-back rule below is satisfied by reading what came back —
report only from those items (binary content is returned as metadata only).
If the task needs more than that — several chained steps, branching, merges,
non-trivial transformations, or data that must flow between nodes — build the
workflow and use the flow below.
A one-off that touches external systems is still anchored on n8n nodes (you
cannot write to external services directly) — the intent changes the
*post-build flow*, not the anchor.
## The one-off flow (multi-node)
1. **Build** the workflow with a **manual trigger** — always. A one-off is
never published, so an event trigger (webhook, form, schedule) would never

View File

@ -39,6 +39,9 @@ Use this section when the system prompt describes the persistent setup panel,
setup returns `announced: true`, or the current user input contains
`<workflow-test-request>`. Otherwise, keep the setup card flow below.
- Setup requirements can appear while the workflow is being built. The user can
complete them immediately. Refer to the "setup panel" without a position or
a claim that setup must wait until the build finishes.
- Verify what the build can simulate before asking the user to finish setup.
Missing credentials do not prevent this verification. Report which outputs
were simulated. A simulated result does not prove a live connection.

View File

@ -9,6 +9,9 @@ description: >-
data-table-manager first, then this skill. Do not load planning or
create-tasks first. Load planning only when multiple coordinated workflows
or shared cross-task data tables require a dependency-aware task graph.
Don't use this skill for explicit one-off tasks that can be done by a single
node execution: load one-off-operations and run the node with
nodes(action="execute").
recommended_tools:
- read_file
- write_file
@ -198,6 +201,8 @@ follow its build → publish → assign steps.
method name, method type, credential type, and credential ID — mandatory
for calendars, spreadsheets, channels, folders, databases, models, and any
other list-backed parameter when a credential is available.
For new model choices, follow `model-selection` before writing code,
even without credentials.
5. Pick a stable workspace `filePath` for the source file, typically
`src/workflows/main.workflow.ts` for a one-off new workflow, or a clearly
named `.workflow.ts` file when multiple source files are useful. For an
@ -516,7 +521,9 @@ When `nodes(action="explore-resources")` returns no results for a required
resource:
1. If the resource can be represented as a user choice, use
`placeholder('Select <resource>')` and let setup collect it after the build.
`placeholder('Select <resource>')` and let setup collect it. When the persistent
setup panel is enabled, the user can fill announced requirements during the
build. Do not tell them to wait until the build finishes.
2. If the user explicitly asked you to create the resource and the node type
definition has a safe create operation, build and verify that
resource-creation workflow as part of the requested work.
@ -795,10 +802,9 @@ asked for that exact name.
## Node Configuration Safety Rules
- Fetch `nodes(action="type-definition")` before configuring nodes. Generated
definitions and `@builderHint` annotations are the source of truth.
- Fetch `nodes(action="type-definition")` for parameter names and shapes.
- Use live `nodes(action="explore-resources")` for resource locator, list, and
model fields when credentials are available.
model fields when credentials are available, including Gateway credits.
- If a configuration is unclear after reading the definition, ask for
clarification or use placeholders. Do not guess.
- Pay attention to `@builderHint` annotations in search results and type

View File

@ -18,7 +18,8 @@ describe('getSystemPrompt — setup panel', () => {
const prompt = getSystemPrompt({ setupPanelEnabled: true });
expect(prompt).not.toContain('opens the inline setup card');
expect(prompt).toContain('setup panel next to the chat');
expect(prompt).toContain('setup panel while the workflow is being built');
expect(prompt).not.toContain('next to the chat');
expect(prompt).toContain('<workflow-setup-state>');
expect(prompt).toContain('end your turn');
expect(prompt).toContain('When the result has `announced: true`');

View File

@ -201,7 +201,7 @@ This n8n instance is in **read-only mode** (protected by source control settings
- Creating, modifying, or deleting workflows
- Creating data tables, modifying their schema, or mutating their rows
- Creating or deleting folders, moving or tagging workflows
- Running or stopping workflow executions
- Running or stopping workflow executions, and executing a single node
The following operations remain available:
- Listing, searching, and reading all resources
@ -221,7 +221,7 @@ If the user asks for a blocked operation, explain that the instance is in read-o
*/
function getCredentialSetupBullet(setupPanelEnabled?: boolean): string {
if (setupPanelEnabled) {
return '**Credential setup** uses `workflows(action="setup")` when a workflowId is available. When the result has `announced: true`, the setup panel next to the chat lists the remaining credentials and parameters. Summarize that result, report any validation warnings, and end your turn. Other results need their returned guidance: correct validation errors, respect denials and skipped items, and wait for requested destination approvals. Explicit credential replacement and an already-open setup card keep their card flow, including apply and test-trigger results. Do not treat a resumed card as a panel announcement. Each new user turn carries a `<workflow-setup-state>` block with current configuration; trust it over older tool results. Configuration alone does not prove successful testing. Use `credentials(action="setup")` when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor, and never ask the user to paste secrets into chat.';
return '**Credential setup** uses `workflows(action="setup")` when a workflowId is available. Requirements can appear in the setup panel while the workflow is being built. The user can complete them immediately. Do not describe setup as happening only after the build. When the result has `announced: true`, the setup panel lists the remaining credentials and parameters. Summarize that result, report any validation warnings, and end your turn. Other results need their returned guidance: correct validation errors, respect denials and skipped items, and wait for requested destination approvals. Explicit credential replacement and an already-open setup card keep their card flow, including apply and test-trigger results. Do not treat a resumed card as a panel announcement. Each new user turn carries a `<workflow-setup-state>` block with current configuration; trust it over older tool results. Configuration alone does not prove successful testing. Use `credentials(action="setup")` when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor, and never ask the user to paste secrets into chat.';
}
return '**Credential setup** uses `workflows(action="setup")` when a workflowId is available — it opens the inline setup card in the n8n Assistant panel and handles credentials, parameters, and triggers in one step. Use `credentials(action="setup")` only when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor. Setup cards are only open while the setup call is pending — once it returns a result, the card is resolved: describe the outcome (e.g. credentials selected and ready), never that a card is open or that the user still needs to authorize. When a node in `nodesStillNeedingSetup` carries `parameterIssues`, the connected credential can\'t reach the value that was configured (e.g. a model outside what the credential allows) — fix the value, then tell the user plainly which value didn\'t work and what you set instead. Never silently swap a model or other parameter without saying so. Nodes listed under `skippedByUser` are different: the user chose to skip them, so never re-open the setup card for those — say what stays unconfigured and offer to set it up later.';
}
@ -256,7 +256,6 @@ ${getToolDiscoverySection(toolSearchEnabled, mcpToolSearchEnabled)}
- Be concise.
- When the user opens with a greeting or another open-ended message without a specific request, briefly greet them and offer concrete ways you can help. Include building an agent and building a workflow among the options, alongside any other relevant capabilities.
- Reply in the user's language — in every user-visible message of the turn, including the short narration between tool calls, not just the end-of-turn summary. Tool results, skill instructions, and system follow-ups are written in English; do not let them pull your replies into English.
- ${ASK_USER_FALLBACK}
- No emojis unless the user explicitly requests them.
- At the beginning of a normal user-visible turn, before your first tool call, write one short sentence explaining what you are about to do or what decision you need. Keep it tied to the user's goal, not the tool name. For system-generated background or checkpoint follow-up turns, follow the follow-up instructions.
@ -295,5 +294,11 @@ ${UNTRUSTED_CONTENT_DOCTRINE}
${getComputerUsePrompt({ state: computerUseState })}
${getLicenseLimitationsSection(licenseHints)}
${getReadOnlySection(branchReadOnly)}`;
${getReadOnlySection(branchReadOnly)}
## Reply language
Reply in the same language as the user's latest request, unless they explicitly ask you to reply in another language. Determine the language from the request text itself, outside application context such as <thread-context>. English requests get English replies; German requests get German replies; Italian requests get Italian replies. Use that language from the first word of every user-visible message, including narration between tool calls, questions, approval summaries, and the final reply. Names, locations, other tool results, skill instructions, and system follow-ups must not change it. Language requirements for a target agent apply to its configuration, not to your replies. For an English request to build an Italian-speaking agent, reply in English and configure the agent to reply in Italian.
The most recent non-empty \`answers[].customText\` returned by \`ask-user\` or \`build-agent\` is the user's latest request. These are the user's own words. Apply the reply-language rule to that text. It takes precedence over the initial request and all earlier answers. For example, switch to German after a German answer, then back to English after a later English answer. Option selections and approvals without free text keep the current reply language.`;
}

View File

@ -640,6 +640,8 @@ export type {
InstanceAiToolRegistry,
InstanceAiWorkflowService,
InstanceAiExecutionService,
InstanceAiExecuteNodeService,
ExecuteNodeResult,
InstanceAiCredentialService,
InstanceAiNodeService,
InstanceAiDataTableService,

View File

@ -1,4 +1,5 @@
import {
RUNTIME_SKILL_MAX_OUTPUT_BYTES,
RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION,
createSkillLoadTool,
type RuntimeSkillLinkedFiles,
@ -356,7 +357,6 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => {
});
it('warns when materialized skill files exceed the load_skill output limit', async () => {
const runtimeSkillMaxOutputBytes = 64 * 1024;
const source: RuntimeSkillSource = {
registry: {
schemaVersion: RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION,
@ -376,7 +376,7 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => {
id: 'large-skill',
name: 'large-skill',
description: 'Large skill',
instructions: 'x'.repeat(runtimeSkillMaxOutputBytes + 1),
instructions: 'x'.repeat(RUNTIME_SKILL_MAX_OUTPUT_BYTES + 1),
}),
};
const { workspace } = createMockWorkspace();
@ -399,7 +399,7 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => {
const [message, meta] = limitWarnCall!;
expect(message).toBe('Runtime skill file exceeds load_skill output limit');
expect(meta?.skill).toBe('large-skill');
expect(meta?.maxBytes).toBe(runtimeSkillMaxOutputBytes);
expect(meta?.maxBytes).toBe(RUNTIME_SKILL_MAX_OUTPUT_BYTES);
});
});

View File

@ -1,5 +1,6 @@
import {
RUNTIME_SKILL_FILE_NAME,
RUNTIME_SKILL_MAX_OUTPUT_BYTES,
type RuntimeSkillContent,
type RuntimeSkillDependenciesContract,
type RuntimeSkillInterfaceContract,
@ -91,7 +92,6 @@ const LINKED_FILE_GROUPS = [
const N8N_SKILL_DIR_TEMPLATE = '$' + '{N8N_SKILL_DIR}';
const N8N_SKILLS_DIR_TEMPLATE = '$' + '{N8N_SKILLS_DIR}';
const N8N_WORKSPACE_DIR_TEMPLATE = '$' + '{N8N_WORKSPACE_DIR}';
const LOAD_SKILL_OUTPUT_LIMIT_BYTES = 64 * 1024;
function isNonEmptyRecord(value: Record<string, unknown>): boolean {
return Object.keys(value).length > 0;
@ -410,13 +410,13 @@ function warnIfExceedsLoadSkillLimit(
content: string,
): void {
const bytes = Buffer.byteLength(content, 'utf8');
if (bytes <= LOAD_SKILL_OUTPUT_LIMIT_BYTES) return;
if (bytes <= RUNTIME_SKILL_MAX_OUTPUT_BYTES) return;
logger.warn('Runtime skill file exceeds load_skill output limit', {
skill: entry.name,
path: filePath,
bytes,
maxBytes: LOAD_SKILL_OUTPUT_LIMIT_BYTES,
maxBytes: RUNTIME_SKILL_MAX_OUTPUT_BYTES,
});
}

View File

@ -39,6 +39,10 @@ vi.mock('../nodes.tool', () => ({
})),
}));
vi.mock('../search-models.tool', () => ({
createSearchModelsTool: vi.fn(() => ({ id: 'searchModels' })),
}));
vi.mock('../n8n-docs.tool', () => ({
createN8nDocsTool: vi.fn(() => ({ id: 'n8n-docs' })),
}));
@ -135,6 +139,7 @@ describe('domain tool construction', () => {
research: { id: 'research' },
'n8n-docs': { id: 'n8n-docs' },
nodes: { id: 'nodes' },
searchModels: { id: 'searchModels' },
'ask-user': { id: 'ask-user' },
'build-workflow': { id: 'build-workflow' },
});
@ -143,12 +148,19 @@ describe('domain tool construction', () => {
const { createWorkflowsTool } = await import('../workflows.tool.js');
const { createNodesTool } = await import('../nodes.tool.js');
const { createSearchModelsTool } = await import('../search-models.tool.js');
const { createDataTablesTool } = await import('../data-tables.tool.js');
expect(createWorkflowsTool).toHaveBeenCalledWith(context);
expect(createNodesTool).toHaveBeenCalledWith(context);
expect(createSearchModelsTool).toHaveBeenCalledOnce();
expect(createDataTablesTool).toHaveBeenCalledWith(context);
});
it('makes model catalog search discoverable without loading it for every turn', () => {
expect(getActiveOrchestratorDomainToolNames(makeContext())).toContain('searchModels');
expect(ALWAYS_LOADED_TOOL_NAMES.has('searchModels')).toBe(false);
});
it('does not include local MCP server tools in orchestrator domain tools', () => {
const context = makeContext({
localMcpServer: {} as InstanceAiContext['localMcpServer'],

View File

@ -3,6 +3,7 @@ import {
suggestedNodesData,
type SearchableNodeType,
} from '@n8n/ai-utilities/node-catalog';
import { validateNodeConfig } from '@n8n/workflow-sdk';
import type { Mock } from 'vitest';
import { executeTool } from '../../__tests__/tool-test-utils';
@ -10,6 +11,11 @@ import type { InstanceAiContext } from '../../types';
import { addSetupPreference } from '../nodes/setup-preference';
import { createNodesTool } from '../nodes.tool';
vi.mock('@n8n/workflow-sdk', async (importOriginal) => ({
...(await importOriginal<typeof import('@n8n/workflow-sdk')>()),
validateNodeConfig: vi.fn(() => ({ valid: true, errors: [] })),
}));
function createMockContext(overrides: Partial<InstanceAiContext> = {}): InstanceAiContext {
return {
userId: 'user-1',
@ -519,6 +525,65 @@ describe('nodes tool', () => {
});
describe('type-definition action', () => {
it.each(['full', 'orchestrator'] as const)(
'activates model selection for model-bearing definitions on the %s surface',
async (surface) => {
const context = createMockContext();
context.nodeService.getNodeTypeDefinition = vi.fn().mockResolvedValue({
content: 'export interface Params {\n modelId?: string;\n}',
});
vi.mocked(context.nodeService.listSearchable).mockResolvedValue([
{
name: '@n8n/n8n-nodes-langchain.lmChatOpenRouter',
displayName: 'OpenRouter Chat Model',
description: '',
version: 1,
inputs: [],
outputs: ['ai_languageModel'],
},
]);
const loadSkill = vi.fn().mockResolvedValue(null);
const tool = createNodesTool(context, surface);
for (const nodeType of [
'@n8n/n8n-nodes-langchain.lmChatOpenRouter',
'@n8n/n8n-nodes-langchain.openAi',
'@n8n/n8n-nodes-langchain.googleGemini',
'@n8n/n8n-nodes-langchain.anthropic',
]) {
loadSkill.mockClear();
const result = await executeTool(
tool,
{ action: 'type-definition', nodeTypes: [nodeType] },
{ loadSkill },
);
expect(result.definitions).toEqual([
expect.objectContaining({ nodeType, content: expect.stringContaining('modelId') }),
]);
expect(loadSkill).toHaveBeenCalledExactlyOnceWith('model-selection');
}
},
);
it('keeps model selection inactive for unrelated operations and failed definitions', async () => {
const context = createMockContext();
context.nodeService.getNodeTypeDefinition = vi
.fn()
.mockResolvedValueOnce({ content: 'export type Params = { fileId: string };' })
.mockResolvedValueOnce({ error: 'Unknown node type' });
vi.mocked(context.nodeService.listSearchable).mockResolvedValue([]);
const loadSkill = vi.fn();
const tool = createNodesTool(context);
await executeTool(
tool,
{
action: 'type-definition',
nodeTypes: ['@n8n/n8n-nodes-langchain.openAi', 'missing.node'],
},
{ loadSkill },
);
expect(loadSkill).not.toHaveBeenCalled();
});
it('should return a Zod-derived error when nodeTypes is missing', async () => {
// The discriminated union is flattened for Anthropic, so `nodeTypes`
// becomes optional at the top-level schema. The handler re-validates
@ -644,4 +709,489 @@ describe('nodes tool', () => {
});
});
});
describe('execute action', () => {
const executeInput = {
action: 'execute',
type: 'n8n-nodes-base.set',
version: 3.4,
config: { parameters: { mode: 'manual' } },
};
it('should return a structured error when required execute fields are missing', async () => {
const executeNodeService = { execute: vi.fn() };
const tool = createNodesTool(createMockContext({ executeNodeService }), 'full');
const result = await executeTool(
tool,
{ action: 'execute', type: 'n8n-nodes-base.set' } as never,
{} as never,
);
expect(result).toMatchObject({
status: 'error',
error: { message: expect.stringContaining('version') },
});
expect(executeNodeService.execute).not.toHaveBeenCalled();
});
it('should reject a config that fails schema validation before suspending', async () => {
const executeNodeService = { execute: vi.fn() };
const suspendFn = vi.fn();
vi.mocked(validateNodeConfig).mockReturnValueOnce({
valid: false,
errors: [
{ path: 'parameters.text', message: 'Required' },
{ path: 'parameters.select', message: 'Invalid value', missingDiscriminator: true },
],
});
const tool = createNodesTool(createMockContext({ executeNodeService }), 'full');
const result = await executeTool(
tool,
executeInput as never,
{
suspend: suspendFn,
} as never,
);
expect(result).toEqual({
status: 'error',
error: {
message: 'Node parameters do not match the schema for n8n-nodes-base.set v3.4',
issues: [{ path: 'parameters.text', message: 'Required' }],
},
});
expect(suspendFn).not.toHaveBeenCalled();
expect(executeNodeService.execute).not.toHaveBeenCalled();
});
it('should treat missing-discriminator-only validation errors as non-blocking', async () => {
const executeNodeService = { execute: vi.fn() };
const suspendFn = vi.fn();
vi.mocked(validateNodeConfig).mockReturnValueOnce({
valid: false,
errors: [{ path: 'parameters.resource', message: 'Required', missingDiscriminator: true }],
});
const tool = createNodesTool(createMockContext({ executeNodeService }), 'full');
await executeTool(tool, executeInput as never, { suspend: suspendFn } as never);
expect(suspendFn).toHaveBeenCalledTimes(1);
});
it('should suspend for confirmation on the first call', async () => {
const executeNodeService = { execute: vi.fn() };
const suspendFn = vi.fn();
const tool = createNodesTool(createMockContext({ executeNodeService }), 'full');
await executeTool(tool, executeInput as never, { suspend: suspendFn } as never);
expect(suspendFn).toHaveBeenCalledTimes(1);
expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({
requestId: expect.any(String),
message: 'manual',
resourceName: 'n8n-nodes-base.set node',
severity: 'warning',
}),
);
expect(executeNodeService.execute).not.toHaveBeenCalled();
});
/** Mirrors Google Sheets: `create` is an operation of both resources, with its own label. */
const splitNodeDescription = {
name: 'n8n-nodes-base.googleSheets',
displayName: 'Google Sheets',
properties: [
{
name: 'resource',
displayName: 'Resource',
type: 'options',
default: 'sheet',
options: [
{ name: 'Document', value: 'spreadsheet' },
{ name: 'Sheet Within Document', value: 'sheet' },
],
},
{
name: 'operation',
displayName: 'Operation',
type: 'options',
default: 'read',
displayOptions: { show: { resource: ['sheet'] } },
options: [
{ name: 'Create Sheet', value: 'create' },
{ name: 'Get Row(s)', value: 'read' },
],
},
{
name: 'operation',
displayName: 'Operation',
type: 'options',
default: 'create',
displayOptions: { show: { resource: ['spreadsheet'] } },
options: [{ name: 'Create Document', value: 'create' }],
},
],
};
async function suspendPayloadFor(
parameters: Record<string, unknown>,
description: unknown = splitNodeDescription,
) {
const suspendFn = vi.fn();
const context = createMockContext({ executeNodeService: { execute: vi.fn() } });
(context.nodeService.getDescription as Mock).mockResolvedValue(description);
await executeTool(
createNodesTool(context, 'full'),
{
action: 'execute',
type: 'n8n-nodes-base.googleSheets',
version: 4.7,
config: { parameters },
} as never,
{ suspend: suspendFn } as never,
);
return suspendFn.mock.calls[0][0];
}
it('should name the node in the title and the operation in the description', async () => {
expect(await suspendPayloadFor({ resource: 'sheet', operation: 'create' })).toMatchObject({
resourceName: 'Google Sheets node',
message: 'Sheet Within Document > Create Sheet',
});
});
it('should label a shared operation value by the resource it belongs to', async () => {
expect(
await suspendPayloadFor({ resource: 'spreadsheet', operation: 'create' }),
).toMatchObject({ message: 'Document > Create Document' });
});
it('should fall back to the node defaults for omitted discriminators', async () => {
expect(await suspendPayloadFor({ operation: 'create' })).toMatchObject({
message: 'Sheet Within Document > Create Sheet',
});
expect(await suspendPayloadFor({})).toMatchObject({
message: 'Sheet Within Document > Get Row(s)',
});
});
it('should name the first headline parameter for a node without resource or operation', async () => {
const httpRequest = {
name: 'n8n-nodes-base.httpRequest',
displayName: 'HTTP Request',
properties: [
{ name: 'method', displayName: 'Method', type: 'options', default: 'GET' },
{ name: 'url', displayName: 'URL', type: 'string', default: '' },
],
};
expect(
await suspendPayloadFor(
{ method: 'POST', url: 'https://example.com/v4/sheets' },
httpRequest,
),
).toMatchObject({
resourceName: 'HTTP Request node',
message: 'https://example.com/v4/sheets',
});
});
it('should prefer mode over the later headline parameters and label it', async () => {
const set = {
name: 'n8n-nodes-base.set',
displayName: 'Edit Fields (Set)',
properties: [
{
name: 'mode',
displayName: 'Mode',
type: 'options',
default: 'manual',
options: [
{ name: 'Manual Mapping', value: 'manual' },
{ name: 'JSON', value: 'raw' },
],
},
{ name: 'url', displayName: 'URL', type: 'string', default: '' },
],
};
expect(await suspendPayloadFor({ url: 'https://example.com' }, set)).toMatchObject({
message: 'Manual Mapping',
});
});
it('should fall back to a generic description when no parameter names the call', async () => {
const filter = { name: 'n8n-nodes-base.filter', displayName: 'Filter', properties: [] };
expect(await suspendPayloadFor({}, filter)).toMatchObject({
resourceName: 'Filter node',
message: 'Single run',
});
});
it('should fall back to raw values when the node description does not resolve', async () => {
const suspendFn = vi.fn();
const context = createMockContext({ executeNodeService: { execute: vi.fn() } });
(context.nodeService.getDescription as Mock).mockRejectedValue(new Error('not found'));
await executeTool(
createNodesTool(context, 'full'),
{
action: 'execute',
type: 'n8n-nodes-base.slack',
version: 2.3,
config: { parameters: { resource: 'message', operation: 'post' } },
} as never,
{ suspend: suspendFn } as never,
);
expect(suspendFn.mock.calls[0][0]).toMatchObject({
resourceName: 'n8n-nodes-base.slack node',
message: 'message > post',
});
});
it('should deny without suspending when the admin policy blocks node execution', async () => {
const executeNodeService = { execute: vi.fn() };
const suspendFn = vi.fn();
const tool = createNodesTool(
createMockContext({ executeNodeService, permissions: { executeNode: 'blocked' } as never }),
'full',
);
const result = await executeTool(
tool,
executeInput as never,
{
suspend: suspendFn,
} as never,
);
expect(result).toEqual({ status: 'error', denied: true, reason: 'Action blocked by admin' });
expect(suspendFn).not.toHaveBeenCalled();
expect(executeNodeService.execute).not.toHaveBeenCalled();
});
it('should skip approval when the admin policy is always_allow', async () => {
const serviceResult = { status: 'success', output: [[{ json: {} }]] };
const executeNodeService = { execute: vi.fn().mockResolvedValue(serviceResult) };
const suspendFn = vi.fn();
const tool = createNodesTool(
createMockContext({
executeNodeService,
permissions: { executeNode: 'always_allow' } as never,
}),
'full',
);
const result = await executeTool(
tool,
executeInput as never,
{
suspend: suspendFn,
} as never,
);
expect(suspendFn).not.toHaveBeenCalled();
expect(result).toEqual(serviceResult);
});
it('should not consult the runWorkflow policy', async () => {
const serviceResult = { status: 'success', output: [[{ json: {} }]] };
const executeNodeService = { execute: vi.fn().mockResolvedValue(serviceResult) };
const suspendFn = vi.fn();
const tool = createNodesTool(
createMockContext({
executeNodeService,
permissions: { runWorkflow: 'blocked', executeNode: 'always_allow' } as never,
}),
'full',
);
const result = await executeTool(
tool,
executeInput as never,
{
suspend: suspendFn,
} as never,
);
expect(suspendFn).not.toHaveBeenCalled();
expect(result).toEqual(serviceResult);
});
it('should require approval when always_allow is scoped to specific workflows', async () => {
const executeNodeService = { execute: vi.fn() };
const suspendFn = vi.fn();
const tool = createNodesTool(
createMockContext({
executeNodeService,
permissions: { executeNode: 'always_allow' } as never,
allowedRunWorkflowIds: new Set(['wf-under-verification']),
}),
'full',
);
await executeTool(tool, executeInput as never, { suspend: suspendFn } as never);
expect(suspendFn).toHaveBeenCalledTimes(1);
expect(executeNodeService.execute).not.toHaveBeenCalled();
});
it('should skip approval when a session grant exists for the node type', async () => {
const serviceResult = { status: 'success', output: [[{ json: {} }]] };
const executeNodeService = { execute: vi.fn().mockResolvedValue(serviceResult) };
const suspendFn = vi.fn();
const tool = createNodesTool(
createMockContext({
executeNodeService,
sessionApprovedToolKeys: new Set(['nodes:execute:n8n-nodes-base.set:manual']),
}),
'full',
);
const result = await executeTool(
tool,
executeInput as never,
{
suspend: suspendFn,
} as never,
);
expect(suspendFn).not.toHaveBeenCalled();
expect(result).toEqual(serviceResult);
});
it('should persist a session grant split by type, resource, and operation on "always allow"', async () => {
const executeNodeService = { execute: vi.fn().mockResolvedValue({ status: 'success' }) };
const grantSessionToolApproval = vi.fn();
const tool = createNodesTool(
createMockContext({ executeNodeService, grantSessionToolApproval }),
'full',
);
await executeTool(
tool,
{
action: 'execute',
type: 'n8n-nodes-base.slack',
version: 2.7,
config: { parameters: { resource: 'message', operation: 'post', text: 'hi' } },
} as never,
{ resumeData: { approved: true, scope: 'session' } } as never,
);
expect(grantSessionToolApproval).toHaveBeenCalledWith(
'nodes:execute:n8n-nodes-base.slack:message:post',
);
expect(executeNodeService.execute).toHaveBeenCalled();
});
it('should persist a grant scoped by the fallback parameter without resource/operation', async () => {
const executeNodeService = { execute: vi.fn().mockResolvedValue({ status: 'success' }) };
const grantSessionToolApproval = vi.fn();
const tool = createNodesTool(
createMockContext({ executeNodeService, grantSessionToolApproval }),
'full',
);
await executeTool(
tool,
executeInput as never,
{
resumeData: { approved: true, scope: 'session' },
} as never,
);
expect(grantSessionToolApproval).toHaveBeenCalledWith(
'nodes:execute:n8n-nodes-base.set:manual',
);
});
it('should persist a per-type grant for a node with no scoping parameter at all', async () => {
const executeNodeService = { execute: vi.fn().mockResolvedValue({ status: 'success' }) };
const grantSessionToolApproval = vi.fn();
const tool = createNodesTool(
createMockContext({ executeNodeService, grantSessionToolApproval }),
'full',
);
await executeTool(
tool,
{
action: 'execute',
type: 'n8n-nodes-base.filter',
version: 2.2,
config: { parameters: { conditions: {} } },
} as never,
{ resumeData: { approved: true, scope: 'session' } } as never,
);
expect(grantSessionToolApproval).toHaveBeenCalledWith('nodes:execute:n8n-nodes-base.filter');
});
it('should return a denied result without executing when the user denies', async () => {
const executeNodeService = { execute: vi.fn() };
const tool = createNodesTool(createMockContext({ executeNodeService }), 'full');
const result = await executeTool(
tool,
executeInput as never,
{
resumeData: { approved: false },
} as never,
);
expect(result).toEqual({ status: 'error', denied: true, reason: 'User denied the action' });
expect(executeNodeService.execute).not.toHaveBeenCalled();
});
it('should execute the node with the workflow-sdk-shaped request when approved', async () => {
const serviceResult = { status: 'success', output: [[{ json: { done: true } }]] };
const executeNodeService = { execute: vi.fn().mockResolvedValue(serviceResult) };
const tool = createNodesTool(createMockContext({ executeNodeService }), 'full');
const result = await executeTool(
tool,
{
...executeInput,
config: {
parameters: { mode: 'manual' },
credentials: { slackApi: { id: 'cred-1', name: 'Slack' } },
},
input: [{ json: { text: 'hi' } }],
timeoutMs: 10_000,
} as never,
{ resumeData: { approved: true } } as never,
);
expect(executeNodeService.execute).toHaveBeenCalledWith({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
parameters: { mode: 'manual' },
credentials: { slackApi: { id: 'cred-1', name: 'Slack' } },
},
input: [{ json: { text: 'hi' } }],
timeoutMs: 10_000,
});
expect(result).toEqual(serviceResult);
});
it('should return an error when executeNodeService is not wired', async () => {
const tool = createNodesTool(createMockContext(), 'full');
const result = await executeTool(
tool,
executeInput as never,
{
resumeData: { approved: true },
} as never,
);
expect(result).toMatchObject({ status: 'error' });
});
});
});

View File

@ -0,0 +1,66 @@
import { fetchProviderCatalog } from '@n8n/agents/catalog';
import { executeTool, parseToolInput } from '../../__tests__/tool-test-utils';
import { ModelCatalogService } from '../models/model-catalog.service';
import { searchModelsOutputSchema } from '../models/schemas';
import { createSearchModelsTool } from '../search-models.tool';
vi.mock('@n8n/agents/catalog', () => ({ fetchProviderCatalog: vi.fn() }));
describe('searchModels tool', () => {
// INS-539: Keep external catalog results marked as untrusted.
it('declares catalog output as untrusted', () => {
expect(createSearchModelsTool().outputTrust).toBe('untrusted');
});
it('returns ten results by default and preserves the declared output contract', async () => {
vi.mocked(fetchProviderCatalog).mockResolvedValue({
openai: {
id: 'openai',
name: 'OpenAI',
models: Object.fromEntries(
Array.from({ length: 12 }, (_, index) => [
`model-${index}`,
{
id: `model-${index}`,
name: `Model ${index}`,
toolCall: true,
toolCallKnown: true,
modalities: { input: ['text'], output: ['text'] },
releaseDate: '2026-09-01',
},
]),
),
},
});
const tool = createSearchModelsTool(new ModelCatalogService());
const input = parseToolInput(tool, { provider: 'openai' });
expect(input.success).toBe(true);
if (!input.success) throw new Error('Expected valid tool input');
const result = searchModelsOutputSchema.parse(await executeTool(tool, input.data));
expect(tool.name).toBe('searchModels');
expect(result.models).toHaveLength(10);
expect(result.hasMore).toBe(true);
expect(result.credentialAccess).toBe('not_checked');
const filteredInput = parseToolInput(tool, { provider: 'openai', query: ' Model 11 ' });
if (!filteredInput.success) throw new Error('Expected valid query input');
const filtered = searchModelsOutputSchema.parse(await executeTool(tool, filteredInput.data));
expect(filtered.models.map(({ id }) => id)).toEqual(['model-11']);
expect(filtered.hasMore).toBe(false);
});
it.each([
{ provider: '' },
{ provider: ' ' },
{ provider: 'openai', limit: 0 },
{ provider: 'openai', limit: 11 },
{ provider: 'openai', limit: 1.5 },
{ provider: 'openai', query: 123 },
{ provider: 'openai', query: 'x'.repeat(101) },
{ provider: 'openai', includePreview: true },
{ provider: 'openai', requirements: {} },
])('rejects invalid or unsupported inputs: %j', (input) => {
expect(parseToolInput(createSearchModelsTool(), input).success).toBe(false);
});
});

View File

@ -804,7 +804,7 @@ async function announceSetupItems(
workflowId,
credentials: existingByType,
message:
`Listed ${typeNames} in the setup panel next to the chat. No card is open and nothing is waiting on ` +
`Listed ${typeNames} in the setup panel. No card is open and nothing is waiting on ` +
'you: continue the build. The user can connect these at any time while you work — the build ' +
'attaches a matching stored credential automatically and the panel tracks what remains. Do not ' +
'ask the user to connect them now, do not describe a card, and do not call setup again for these ' +

View File

@ -30,6 +30,9 @@ const loadExecutionsTool = lazyMod(
() => require('./executions.tool') as typeof import('./executions.tool'),
);
const loadNodesTool = lazyMod(() => require('./nodes.tool') as typeof import('./nodes.tool'));
const loadSearchModelsTool = lazyMod(
() => require('./search-models.tool') as typeof import('./search-models.tool'),
);
const loadMcpServersTool = lazyMod(
() => require('./mcp-servers.tool') as typeof import('./mcp-servers.tool'),
);
@ -105,6 +108,7 @@ function getOrchestratorDomainToolFactories(
[DOMAIN_TOOL_IDS.RESEARCH, () => loadResearchTool().createResearchTool(context)],
[DOMAIN_TOOL_IDS.N8N_DOCS, () => loadN8nDocsTool().createN8nDocsTool(context)],
[DOMAIN_TOOL_IDS.NODES, () => loadNodesTool().createNodesTool(context)],
[DOMAIN_TOOL_IDS.SEARCH_MODELS, () => loadSearchModelsTool().createSearchModelsTool()],
[DOMAIN_TOOL_IDS.ASK_USER, () => loadAskUserTool().createAskUserTool(context)],
[
DOMAIN_TOOL_IDS.BUILD_WORKFLOW,

View File

@ -0,0 +1,303 @@
import { fetchProviderCatalog, type ModelInfo, type ProviderCatalog } from '@n8n/agents/catalog';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import { ModelCatalogService } from '../model-catalog.service';
import { searchModelsOutputSchema } from '../schemas';
vi.mock('@n8n/agents/catalog', () => ({ fetchProviderCatalog: vi.fn() }));
const fetchCatalog = vi.mocked(fetchProviderCatalog);
const HOUR = 60 * 60 * 1000;
function model(id: string, overrides: Partial<ModelInfo> = {}): ModelInfo {
return {
id,
name: id,
toolCall: false,
toolCallKnown: false,
modalities: { input: ['text'], output: ['text'] },
...overrides,
};
}
function catalog(models: ModelInfo[], provider = 'openai'): ProviderCatalog {
return {
[provider]: {
id: provider,
name: provider,
models: Object.fromEntries(models.map((entry) => [entry.id, entry])),
},
};
}
describe('ModelCatalogService', () => {
let service: ModelCatalogService;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-09-16T12:00:00Z'));
fetchCatalog.mockReset();
service = new ModelCatalogService();
});
afterEach(() => vi.useRealTimers());
it('orders text models by release date, preserves previews and tiers, and reports omitted results', async () => {
fetchCatalog.mockResolvedValue(
catalog([
model('old-large', { releaseDate: '2025-12-01' }),
model('new-small', { releaseDate: '2026-09-01', status: 'beta' }),
model('new-large', { releaseDate: '2026-09-01' }),
model('image-only', { releaseDate: '2026-09-15', modalities: { output: ['image'] } }),
model('retired', { releaseDate: '2026-09-14', status: 'deprecated' }),
model('unknown-output', { modalities: undefined }),
model('transcription', { modalities: { input: ['audio'], output: ['text'] } }),
]),
);
const result = await service.search({ provider: 'OpenAI', limit: 2 });
expect(result.status).toBe('ok');
expect(result.models.map((entry) => entry.id)).toEqual(['new-large', 'new-small']);
expect(result.models[1].status).toBe('beta');
expect(result.hasMore).toBe(true);
expect(searchModelsOutputSchema.safeParse(result).success).toBe(true);
});
it('puts missing and invalid release dates last without inferring dates from model IDs', async () => {
fetchCatalog.mockResolvedValue(
catalog([
model('future-2099'),
model('invalid-day', { releaseDate: '2026-02-30' }),
model('invalid-month', { releaseDate: '2026-09' }),
model('known-date', { releaseDate: '2026-01-01' }),
]),
);
const result = await service.search({ provider: 'openai', limit: 10 });
expect(result.models.map((entry) => entry.id)).toEqual([
'known-date',
'future-2099',
'invalid-day',
'invalid-month',
]);
expect(result.models.slice(1).map((entry) => entry.releaseDate)).toEqual([null, null, null]);
expect(result.hasMore).toBe(false);
});
it('filters IDs and names before ordering and limiting, with hasMore scoped to matches', async () => {
fetchCatalog.mockResolvedValue(
catalog(
[
...Array.from({ length: 12 }, (_, index) =>
model(`other/model-${index}`, { releaseDate: '2026-09-15' }),
),
model('anthropic/claude-small', {
name: 'Small model',
releaseDate: '2026-08-01',
}),
model('anthropic/assistant', {
name: 'Claude Assistant',
releaseDate: '2026-09-01',
}),
],
'openrouter',
),
);
const limited = await service.search({ provider: 'openrouter', query: ' CLAUDE ', limit: 1 });
expect(limited.models.map(({ id }) => id)).toEqual(['anthropic/assistant']);
expect(limited.hasMore).toBe(true);
const all = await service.search({ provider: 'openrouter', query: 'claude', limit: 10 });
expect(all.models.map(({ id }) => id)).toEqual([
'anthropic/assistant',
'anthropic/claude-small',
]);
expect(all.hasMore).toBe(false);
const other = await service.search({ provider: 'openrouter', query: 'other/', limit: 10 });
expect(other.models).toHaveLength(10);
expect(other.hasMore).toBe(true);
expect(fetchCatalog).toHaveBeenCalledOnce();
});
it('returns no matches for an unmatched query and treats a blank query as unfiltered', async () => {
fetchCatalog.mockResolvedValue(catalog([model('example')]));
expect(await service.search({ provider: 'openai', query: 'missing', limit: 10 })).toMatchObject(
{
status: 'no_matching_models',
models: [],
hasMore: false,
},
);
const unfiltered = await service.search({ provider: 'openai', limit: 10 });
expect(await service.search({ provider: 'openai', query: ' ', limit: 10 })).toEqual(
unfiltered,
);
expect(unfiltered.models.map(({ id }) => id)).toEqual(['example']);
expect(fetchCatalog).toHaveBeenCalledOnce();
});
it('normalizes provider case and whitespace while preserving exact model IDs and unknown metadata', async () => {
fetchCatalog.mockResolvedValue(
catalog(
[
model('models/example-preview'),
model('models/example-standard', {
toolCall: false,
toolCallKnown: true,
reasoning: false,
cost: { input: 0, output: 1.2 },
}),
],
'google',
),
);
const result = await service.search({ provider: ' Google ', limit: 10 });
expect(result).toMatchObject({
provider: 'google',
source: 'https://models.dev/api.json',
credentialAccess: 'not_checked',
freshness: 'fresh',
fetchedAt: '2026-09-16T12:00:00.000Z',
});
expect(result.models[0]).toMatchObject({
id: 'models/example-preview',
toolCalling: null,
reasoning: null,
status: null,
pricing: null,
});
expect(result.models[1]).toMatchObject({
toolCalling: false,
reasoning: false,
pricing: { currency: 'USD', unit: 'per_million_tokens', input: 0, output: 1.2 },
});
});
it('distinguishes unknown providers from providers with no text-generation models', async () => {
fetchCatalog.mockResolvedValue(
catalog([model('image', { modalities: { output: ['image'] } })]),
);
expect(await service.search({ provider: 'openai', limit: 10 })).toMatchObject({
status: 'no_matching_models',
models: [],
hasMore: false,
});
for (const provider of ['missing', 'constructor', '__proto__']) {
expect(await service.search({ provider, limit: 10 })).toMatchObject({
status: 'unknown_provider',
models: [],
});
}
expect(fetchCatalog).toHaveBeenCalledOnce();
});
it('keeps cached metadata isolated from mutations to a returned result', async () => {
fetchCatalog.mockResolvedValue(catalog([model('example')]));
const first = await service.search({ provider: 'openai', limit: 10 });
first.models[0].modalities.output?.splice(0);
const second = await service.search({ provider: 'openai', limit: 10 });
expect(second.models[0].modalities.output).toEqual(['text']);
expect(fetchCatalog).toHaveBeenCalledOnce();
});
it('shares concurrent fetches and refreshes the snapshot after one hour', async () => {
const pending = createDeferredPromise<ProviderCatalog>();
fetchCatalog.mockReturnValueOnce(pending.promise);
const first = service.search({ provider: 'openai', limit: 10 });
const second = service.search({ provider: 'openai', limit: 1 });
expect(fetchCatalog).toHaveBeenCalledOnce();
pending.resolve(catalog([model('first')]));
await Promise.all([first, second]);
await vi.advanceTimersByTimeAsync(HOUR - 1);
await service.search({ provider: 'openai', limit: 10 });
expect(fetchCatalog).toHaveBeenCalledOnce();
fetchCatalog.mockResolvedValue(catalog([model('second')]));
await vi.advanceTimersByTimeAsync(1);
const refreshed = await service.search({ provider: 'openai', limit: 10 });
expect(refreshed.models[0].id).toBe('second');
expect(refreshed.fetchedAt).toBe('2026-09-16T13:00:00.000Z');
expect(fetchCatalog).toHaveBeenCalledTimes(2);
});
it('marks cached data stale on failure and refuses it at 24 hours', async () => {
fetchCatalog.mockResolvedValueOnce(catalog([model('first')]));
await service.search({ provider: 'openai', limit: 10 });
fetchCatalog.mockRejectedValue(new Error('Unavailable'));
await vi.advanceTimersByTimeAsync(HOUR);
expect(await service.search({ provider: 'openai', limit: 10 })).toMatchObject({
status: 'ok',
freshness: 'stale',
fetchedAt: '2026-09-16T12:00:00.000Z',
models: [{ id: 'first' }],
});
await vi.advanceTimersByTimeAsync(23 * HOUR);
expect(await service.search({ provider: 'openai', limit: 10 })).toMatchObject({
status: 'catalog_unavailable',
freshness: 'unavailable',
fetchedAt: null,
models: [],
});
});
it('keeps the last successful snapshot when the parser returns an empty catalog', async () => {
fetchCatalog.mockResolvedValueOnce(catalog([model('first')]));
await service.search({ provider: 'openai', limit: 10 });
fetchCatalog.mockResolvedValue({});
await vi.advanceTimersByTimeAsync(HOUR);
expect(await service.search({ provider: 'openai', limit: 10 })).toMatchObject({
freshness: 'stale',
models: [{ id: 'first' }],
});
expect(await new ModelCatalogService().search({ provider: 'openai', limit: 10 })).toMatchObject(
{
status: 'catalog_unavailable',
models: [],
},
);
});
it('bounds a stalled fetch to five seconds and retries on a later call', async () => {
const pending = createDeferredPromise<ProviderCatalog>();
fetchCatalog.mockReturnValueOnce(pending.promise);
const result = service.search({ provider: 'openai', limit: 10 });
await vi.advanceTimersByTimeAsync(5000);
expect(await result).toMatchObject({ status: 'catalog_unavailable', models: [] });
expect(fetchCatalog.mock.calls[0][0]?.signal?.aborted).toBe(true);
pending.resolve(catalog([model('late')]));
fetchCatalog.mockResolvedValue(catalog([model('retry')]));
expect((await service.search({ provider: 'openai', limit: 10 })).models[0].id).toBe('retry');
});
it('cancels one caller without cancelling a shared fetch for another caller', async () => {
const pending = createDeferredPromise<ProviderCatalog>();
fetchCatalog.mockReturnValue(pending.promise);
const controller = new AbortController();
const cancelled = service.search({ provider: 'openai', limit: 10 }, controller.signal);
const active = service.search({ provider: 'openai', limit: 10 });
const rejected = expect(cancelled).rejects.toMatchObject({ name: 'AbortError' });
controller.abort();
await rejected;
expect(fetchCatalog.mock.calls[0][0]?.signal?.aborted).toBe(false);
pending.resolve(catalog([model('available')]));
expect((await active).models[0].id).toBe('available');
});
it('does not fetch for a caller that was already cancelled', async () => {
const controller = new AbortController();
controller.abort();
await expect(
service.search({ provider: 'openai', limit: 10 }, controller.signal),
).rejects.toMatchObject({ name: 'AbortError' });
expect(fetchCatalog).not.toHaveBeenCalled();
});
});

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