mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
Merge commit '0e1c7549997f43053fda83534b597875b0310df6' into 3.x
# Conflicts: # packages/cli/package.json # packages/frontend/editor-ui/src/app/composables/useRunWorkflow.test.ts # packages/frontend/editor-ui/src/app/constants/nodeTypes.ts # packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.test.ts
This commit is contained in:
commit
df9cc2b150
|
|
@ -981,14 +981,6 @@
|
|||
"hash": "26b2999a3856"
|
||||
}
|
||||
],
|
||||
"packages/extensions/insights/oxlint.config.mts": [
|
||||
{
|
||||
"rule": "lint-config-layering",
|
||||
"line": 9,
|
||||
"message": "packages/extensions/insights turns 'typescript/no-deprecated' down to 'off' for the whole package.",
|
||||
"hash": "f936bed8fa8d"
|
||||
}
|
||||
],
|
||||
"packages/frontend/editor-ui/eslint.config.mjs": [
|
||||
{
|
||||
"rule": "lint-config-layering",
|
||||
|
|
|
|||
10
.github/WORKFLOWS.md
vendored
10
.github/WORKFLOWS.md
vendored
|
|
@ -420,6 +420,7 @@ Run-workflow dropdown, which only a user with write access can pick.
|
|||
| `util-codespace-preview.yml`| Wake, re-serve or delete a PR preview instance by hand |
|
||||
| `util-data-tooling.yml` | SQLite/PostgreSQL export/import validation (manual) |
|
||||
| `util-probe-registry.yml` | Diagnose slow npm metadata fetches (temporary) |
|
||||
| `sec-sync-bundle-branches.yml` | Sync both bundle branches on demand, including before a bundle cut |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1150,9 +1151,9 @@ open the PR there. That PR **must stay a single-parent squash** — the publish
|
|||
posts to `#alerts-build` when that gate fails on a PR opened *from* `bundle/2.x` or
|
||||
`bundle/1.x` (link only, no PR title, since the branch is embargoed).
|
||||
|
||||
`sec-sync-bundle-branches.yml` keeps those branches current after every public-to-private sync,
|
||||
daily, whenever a PR is merged into one, and on `workflow_dispatch`. It **merges the public
|
||||
base into** the bundle branch via
|
||||
`sec-sync-bundle-branches.yml` keeps those branches current nightly at 03:00 UTC and on
|
||||
`workflow_dispatch`, including before a bundle cut. It **merges the public base into** the
|
||||
bundle branch via
|
||||
[`scripts/sync-bundle-branch.mjs`](scripts/sync-bundle-branch.mjs) and pushes without forcing.
|
||||
Every push is verified to carry exactly the tree a merge of the two sides would produce (`git
|
||||
merge-tree`); a mismatch, or a conflict marker, fails the run instead of pushing.
|
||||
|
|
@ -1165,7 +1166,8 @@ private-only until publication. The mirror can replace that commit with the publ
|
|||
the bundle branch is deleted. The next bundle branch must not inherit the replaced commit.
|
||||
|
||||
Deleting a bundle branch takes its open PRs with it: GitHub moves each one onto the deleted
|
||||
branch's own base, and re-creating the branch does not move them back. So the sync then calls
|
||||
branch's own base, and re-creating the branch does not move them back. The next nightly or manual
|
||||
sync re-creates the branch and then calls
|
||||
[`sec-sync-retarget-prs.yml`](workflows/sec-sync-retarget-prs.yml), which moves every open PR
|
||||
on `master` onto `bundle/2.x` and every one on `1.x` onto `bundle/1.x`. It skips a bundle
|
||||
branch that does not exist, and skips PRs whose *head* is `bundle/*` — those are the
|
||||
|
|
|
|||
|
|
@ -58,6 +58,15 @@ describe('matchGlob', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('backend module pattern matches packaged module files', () => {
|
||||
assert.ok(
|
||||
matchGlob(
|
||||
'packages/modules/insights/backend/src/insights.service.ts',
|
||||
'packages/modules/*/backend/**',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('* matches single-level only', () => {
|
||||
assert.ok(matchGlob('README.md', '*.md'));
|
||||
assert.ok(!matchGlob('docs/README.md', '*.md'));
|
||||
|
|
|
|||
2
.github/test-metrics/e2e-impact-map.json
vendored
2
.github/test-metrics/e2e-impact-map.json
vendored
File diff suppressed because one or more lines are too long
8
.github/workflows/ci-pull-requests.yml
vendored
8
.github/workflows/ci-pull-requests.yml
vendored
|
|
@ -178,6 +178,7 @@ jobs:
|
|||
packages/core/src/execution-engine/eval-mock-helpers.ts
|
||||
.github/workflows/test-evals-discovery.yml
|
||||
db:
|
||||
packages/modules/*/backend/**
|
||||
packages/cli/src/databases/**
|
||||
packages/cli/src/modules/*/database/**
|
||||
packages/cli/src/modules/**/*.entity.ts
|
||||
|
|
@ -185,8 +186,7 @@ jobs:
|
|||
packages/cli/test/integration/**
|
||||
packages/cli/test/migration/**
|
||||
packages/cli/test/shared/db/**
|
||||
packages/cli/test/setup-testcontainers.js
|
||||
packages/cli/test/teardown-testcontainers.js
|
||||
packages/cli/test/setup-testcontainers.ts
|
||||
packages/@n8n/db/**
|
||||
packages/@n8n/backend-test-utils/**
|
||||
packages/@n8n/scheduler/src/**
|
||||
|
|
@ -195,6 +195,7 @@ jobs:
|
|||
packages/cli/vitest.*
|
||||
packages/quality/environments/containers/services/postgres.ts
|
||||
packages/quality/environments/containers/services/redis.ts
|
||||
packages/quality/environments/containers/test-db-global-setup.ts
|
||||
packages/quality/environments/containers/postgres-versions.json
|
||||
packages/quality/environments/containers/test-containers.ts
|
||||
.github/workflows/test-db-reusable.yml
|
||||
|
|
@ -209,8 +210,7 @@ jobs:
|
|||
packages/@n8n/backend-test-utils/**
|
||||
packages/cli/test/migration/**
|
||||
packages/cli/test/shared/db/**
|
||||
packages/cli/test/setup-testcontainers.js
|
||||
packages/cli/test/teardown-testcontainers.js
|
||||
packages/cli/test/setup-testcontainers.ts
|
||||
packages/cli/vitest.config.base.ts
|
||||
packages/cli/vitest.config.migration.ts
|
||||
packages/cli/vitest.config.migration.testcontainers.ts
|
||||
|
|
|
|||
20
.github/workflows/sec-sync-bundle-branches.yml
vendored
20
.github/workflows/sec-sync-bundle-branches.yml
vendored
|
|
@ -12,8 +12,8 @@
|
|||
# Every push is verified to carry exactly the tree a merge would produce. See
|
||||
# sync-bundle-branch.mjs.
|
||||
#
|
||||
# The public-to-private mirror dispatches this workflow after each run. The daily schedule is a
|
||||
# fallback, and merged fixes trigger it so the cut PR sees the latest bundle state immediately.
|
||||
# Runs nightly at 03:00 UTC. Maintainers can also dispatch it before a bundle cut or when a
|
||||
# bundle branch needs to be synced sooner.
|
||||
#
|
||||
# One job per bundle branch: a conflict on one FAILS THAT JOB and leaves its branch untouched,
|
||||
# while the other still syncs. Recovery is manual and deliberate — merge the public base locally,
|
||||
|
|
@ -25,11 +25,6 @@ name: 'Security: Sync Bundle Branches'
|
|||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
pull_request: # a fix landing on a bundle branch is when freshness matters most
|
||||
types: [closed]
|
||||
branches:
|
||||
- 'bundle/2.x'
|
||||
- 'bundle/1.x'
|
||||
workflow_dispatch:
|
||||
|
||||
# Least privilege by default; the sync job opts into exactly what it needs.
|
||||
|
|
@ -38,9 +33,7 @@ permissions: {}
|
|||
jobs:
|
||||
sync:
|
||||
name: Sync ${{ matrix.bundle }}
|
||||
if: |
|
||||
github.repository == 'n8n-io/n8n-private' &&
|
||||
(github.event_name != 'pull_request' || github.event.pull_request.merged == true)
|
||||
if: github.repository == 'n8n-io/n8n-private'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
|
@ -68,17 +61,12 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Always run the script from the default branch, never from a pull_request merge
|
||||
# ref — the sync holds a contents:write token and fetches what it needs itself,
|
||||
# so the checked-out branch is irrelevant beyond supplying trusted code.
|
||||
# Always run the script from the default branch. The sync fetches both branches itself.
|
||||
ref: master
|
||||
fetch-depth: 0
|
||||
persist-credentials: false # we push with an explicit token URL instead
|
||||
|
||||
- name: Merge the base into the bundle branch
|
||||
# On a merged bundle PR, only the branch that received it needs syncing.
|
||||
# (`matrix` is not available in a job-level `if`, so the filter lives here.)
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.base.ref == matrix.bundle
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
BUNDLE_BRANCH: ${{ matrix.bundle }}
|
||||
|
|
|
|||
11
.github/workflows/sec-sync-public-to-private.yml
vendored
11
.github/workflows/sec-sync-public-to-private.yml
vendored
|
|
@ -7,9 +7,6 @@
|
|||
# Manual runs always sync (for conflict recovery after failed cherry-pick).
|
||||
#
|
||||
# A skipped branch or a hard failure is reported to #alerts-build.
|
||||
#
|
||||
# This workflow dispatches the bundle sync after each mirror run. Bundle branches merge the
|
||||
# public branch directly, so they never inherit a private-only bundle cut.
|
||||
|
||||
name: 'Security: Sync from Public'
|
||||
run-name: "${{ github.event_name == 'workflow_dispatch' && format('Security: Sync from Public (force={0})', inputs.force) || '' }}"
|
||||
|
|
@ -30,7 +27,6 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- name: Generate App Token
|
||||
|
|
@ -103,13 +99,6 @@ jobs:
|
|||
git reset --hard public-1.x
|
||||
git push origin 1.x --force-with-lease
|
||||
|
||||
- name: Dispatch bundle branch sync
|
||||
if: ${{ !cancelled() }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh workflow run sec-sync-bundle-branches.yml --ref master
|
||||
|
||||
- name: Notify Slack on failure
|
||||
if: failure()
|
||||
env:
|
||||
|
|
|
|||
7
.github/workflows/test-db-reusable.yml
vendored
7
.github/workflows/test-db-reusable.yml
vendored
|
|
@ -55,6 +55,13 @@ jobs:
|
|||
working-directory: packages/cli
|
||||
run: ${{ matrix.test-cmd }}
|
||||
|
||||
# A packaged backend module opts in by defining `test:postgres`.
|
||||
- name: Run packaged backend module PostgreSQL tests
|
||||
if: matrix.TEST_IMAGE_POSTGRES != ''
|
||||
env:
|
||||
COVERAGE_ENABLED: 'false'
|
||||
run: pnpm --filter './packages/modules/*/backend' --if-present run test:postgres --maxWorkers=100%
|
||||
|
||||
# Regenerates the schema docs for this leg's database (sqlite temp file /
|
||||
# postgres testcontainer) and fails if they differ from what's committed.
|
||||
# tbls runs as a Docker image in CI.
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
|||
node_modules
|
||||
.build/
|
||||
.pnpm-store
|
||||
.DS_Store
|
||||
.tmp
|
||||
|
|
|
|||
147
CHANGELOG.md
147
CHANGELOG.md
|
|
@ -1,3 +1,150 @@
|
|||
# [2.43.0](https://github.com/n8n-io/n8n/compare/n8n@2.42.0...n8n@2.43.0) (2026-10-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Agent Node:** Remove outdated tutorial tip callout ([#35505](https://github.com/n8n-io/n8n/issues/35505)) ([53b84d2](https://github.com/n8n-io/n8n/commit/53b84d2baac4f6975627f85ff3d550d84008e749))
|
||||
* **ai-builder:** Treat a blank folder path as no folder in the build tool ([#40116](https://github.com/n8n-io/n8n/issues/40116)) ([b7071c9](https://github.com/n8n-io/n8n/commit/b7071c91c7ef959e1cc3d703495e4fee9fcc9744))
|
||||
* Align From AI button with parameter inputs ([#40152](https://github.com/n8n-io/n8n/issues/40152)) ([9675fc9](https://github.com/n8n-io/n8n/commit/9675fc97fd193c0474b36c084bd3d6e928885fed))
|
||||
* **API:** Emit valid schemas for untyped values in the Public API spec ([#39884](https://github.com/n8n-io/n8n/issues/39884)) ([6918fda](https://github.com/n8n-io/n8n/commit/6918fda0182e55e41efa847a8e6831d08cb3085d))
|
||||
* **API:** Let global members grant variable scopes to API keys ([#40064](https://github.com/n8n-io/n8n/issues/40064)) ([c95e34f](https://github.com/n8n-io/n8n/commit/c95e34f97af49af5d21bf544faacf92e87fe6319))
|
||||
* **API:** Return an execution when its stored trace context is incomplete ([#39715](https://github.com/n8n-io/n8n/issues/39715)) ([dfd7a52](https://github.com/n8n-io/n8n/commit/dfd7a522244720ca536e73b8cb3da7802a850a48))
|
||||
* **Azure OpenAI Chat Model Node:** Support Responses-only deployments ([#39403](https://github.com/n8n-io/n8n/issues/39403)) ([6610987](https://github.com/n8n-io/n8n/commit/66109870570bf195e70bd2ed9c56483945ca6ffd))
|
||||
* **Brevo Node:** Accept both spellings of the recipients parameter ([#40015](https://github.com/n8n-io/n8n/issues/40015)) ([e144b33](https://github.com/n8n-io/n8n/commit/e144b337de3e1f7a54d653c4ae8eb6dedab94c5b))
|
||||
* Center pasted nodes in the canvas viewport ([#39995](https://github.com/n8n-io/n8n/issues/39995)) ([bd9e1d8](https://github.com/n8n-io/n8n/commit/bd9e1d8947cb5a4fd187fa6bbf93c6bf0ff1116f))
|
||||
* Clear connection hover promptly ([#39958](https://github.com/n8n-io/n8n/issues/39958)) ([fa93a7f](https://github.com/n8n-io/n8n/commit/fa93a7f01f0b405f060b3f3c0c43b524e4d2855f))
|
||||
* **core:** Apply MCP availability rules to error workflow settings ([#39885](https://github.com/n8n-io/n8n/issues/39885)) ([e13c480](https://github.com/n8n-io/n8n/commit/e13c4806262469bca3888da0ad91a3e8354e6d78))
|
||||
* **core:** Apply policy checks to CLI import and execute commands ([#39714](https://github.com/n8n-io/n8n/issues/39714)) ([c575f18](https://github.com/n8n-io/n8n/commit/c575f18c999d2f7740ff57862ff1077f08107f9e))
|
||||
* **core:** Apply removed fields when the Agent Builder patches agent config ([#40122](https://github.com/n8n-io/n8n/issues/40122)) ([93c0a5c](https://github.com/n8n-io/n8n/commit/93c0a5c0ff716089047d3b7bb0c233025585f286))
|
||||
* **core:** Block publishing workflows with unconnected required node inputs ([#37265](https://github.com/n8n-io/n8n/issues/37265)) ([5e28ff8](https://github.com/n8n-io/n8n/commit/5e28ff8c238e9481706ebd8aee3f65704f734235))
|
||||
* **core:** Cap pending task request timeouts to the shutdown deadline ([#40136](https://github.com/n8n-io/n8n/issues/40136)) ([28bd124](https://github.com/n8n-io/n8n/commit/28bd124258f0d2c16b76c2485675dd6bbc92b4b9))
|
||||
* **core:** Connect Slack Agent channels through managed setup in MCP ([#39776](https://github.com/n8n-io/n8n/issues/39776)) ([93b658c](https://github.com/n8n-io/n8n/commit/93b658c8db04869c992fc8311a1fec1faf5b195e))
|
||||
* **core:** Correct Agent session pagination on SQLite ([#39802](https://github.com/n8n-io/n8n/issues/39802)) ([1882cff](https://github.com/n8n-io/n8n/commit/1882cff76b8bcac8259ef2ceb55bcedce31132ff))
|
||||
* **core:** Deduplicate agent message deliveries ([#39794](https://github.com/n8n-io/n8n/issues/39794)) ([0811443](https://github.com/n8n-io/n8n/commit/081144390982a1589433a9f318d349109c23ec5c))
|
||||
* **core:** Emit unique keys and conditional requirements in generated node type definitions ([#39960](https://github.com/n8n-io/n8n/issues/39960)) ([1700702](https://github.com/n8n-io/n8n/commit/1700702e52255efadec3def48e9c7575a6c1b956))
|
||||
* **core:** Generate missing workflow IDs in the import:workflow command ([#40011](https://github.com/n8n-io/n8n/issues/40011)) ([d12cab0](https://github.com/n8n-io/n8n/commit/d12cab0ed18abbfb27881afa6cc52f2cddc5a078))
|
||||
* **core:** Improve workflow SDK group layout for AI subtrees and sticky notes ([#39981](https://github.com/n8n-io/n8n/issues/39981)) ([7675d2b](https://github.com/n8n-io/n8n/commit/7675d2bcbd7d9942685179254d9d88b38afaad96))
|
||||
* **core:** Keep concurrent credential tests from sharing one node type registry ([#40054](https://github.com/n8n-io/n8n/issues/40054)) ([1cac1e0](https://github.com/n8n-io/n8n/commit/1cac1e056daade255b123d48a4b39ce8a38f9247))
|
||||
* **core:** Keep floating entitlements attached when a multi-main instance shuts down ([#39713](https://github.com/n8n-io/n8n/issues/39713)) ([ad90c63](https://github.com/n8n-io/n8n/commit/ad90c6392320c5f2d417ea5ae81c15f314af03b0))
|
||||
* **core:** Keep jobs a worker enqueued out of its shutdown drain ([#39800](https://github.com/n8n-io/n8n/issues/39800)) ([cd9d1ac](https://github.com/n8n-io/n8n/commit/cd9d1ac669c48e8c0fa68be8feffc4e51e651183))
|
||||
* **core:** Keep node parameters when n8n Assistant cannot see them ([#39454](https://github.com/n8n-io/n8n/issues/39454)) ([f82fa0c](https://github.com/n8n-io/n8n/commit/f82fa0c1af43b2278bb9f689a2dd73390f1693b4))
|
||||
* **core:** Keep the task runner running on an unhandled promise rejection ([#39946](https://github.com/n8n-io/n8n/issues/39946)) ([59c6ab2](https://github.com/n8n-io/n8n/commit/59c6ab2057ead5026a04e99427c0151c62ecdca6))
|
||||
* **core:** Limit background task continuation to the latest stop group ([#40333](https://github.com/n8n-io/n8n/issues/40333)) ([314c430](https://github.com/n8n-io/n8n/commit/314c430bfc2a1fc38ccf1d11a952a5dcc77b6c3a))
|
||||
* **core:** Load workflow publish history in a separate query ([#40171](https://github.com/n8n-io/n8n/issues/40171)) ([548bd57](https://github.com/n8n-io/n8n/commit/548bd57802f64694d98e70ab1a5fbfa1cdd0fcbe))
|
||||
* **core:** Pass graceful-shutdown timeout to runners in the runners image ([#39796](https://github.com/n8n-io/n8n/issues/39796)) ([d8dcc92](https://github.com/n8n-io/n8n/commit/d8dcc924b730d4f5c713803372006cbfe11aeab4))
|
||||
* **core:** Pass registry and auth token to npm outdated for community packages ([#31515](https://github.com/n8n-io/n8n/issues/31515)) ([1998011](https://github.com/n8n-io/n8n/commit/1998011d90c1c4bec561d8c9007e140cd8a01481))
|
||||
* **core:** Redact MCP tool telemetry properties ([#39887](https://github.com/n8n-io/n8n/issues/39887)) ([fc99aaf](https://github.com/n8n-io/n8n/commit/fc99aaf87be94082f56e3f061e96907c1806a89d))
|
||||
* **core:** Reload static data for queued executions ([#39668](https://github.com/n8n-io/n8n/issues/39668)) ([439e8ba](https://github.com/n8n-io/n8n/commit/439e8ba2e70be371268bbdb86f6afb9bc5e50699))
|
||||
* **core:** Report empty node output in Instance AI workflow verification ([#40047](https://github.com/n8n-io/n8n/issues/40047)) ([6438ead](https://github.com/n8n-io/n8n/commit/6438ead58d109b51b9e29752ea5d58f492bd5fcb))
|
||||
* **core:** Resolve the request target against the dispatcher origin ([#39506](https://github.com/n8n-io/n8n/issues/39506)) ([9183d04](https://github.com/n8n-io/n8n/commit/9183d0422c66b70d83669f255f6d710d6b9c7e7c))
|
||||
* **core:** Resume Preview agents after waiting workflows finish ([#40043](https://github.com/n8n-io/n8n/issues/40043)) ([bdab0b1](https://github.com/n8n-io/n8n/commit/bdab0b10172a7355804cf6e38afbeb8aa5bd194c))
|
||||
* **core:** Return upstream error details from the explore_node_resources MCP tool ([#39775](https://github.com/n8n-io/n8n/issues/39775)) ([a931dd8](https://github.com/n8n-io/n8n/commit/a931dd8660b29bb8cbe6424bb1f9a7a7f1412479))
|
||||
* **core:** Stop waiting on Bull job.finished() for queued executions ([#39723](https://github.com/n8n-io/n8n/issues/39723)) ([c83fee0](https://github.com/n8n-io/n8n/commit/c83fee02fac194543fa4936cfa4f3a57a1da7931))
|
||||
* **core:** Stop waiting on enqueued jobs in worker shutdown ([#40065](https://github.com/n8n-io/n8n/issues/40065)) ([fc3954b](https://github.com/n8n-io/n8n/commit/fc3954bfe2d88338b8d22453cbb4c111a69d057e))
|
||||
* **core:** Use the new client ID and secret for OAuth2 client credentials ([#39891](https://github.com/n8n-io/n8n/issues/39891)) ([44d9adf](https://github.com/n8n-io/n8n/commit/44d9adfc7e311e721a6cc70797b854abe3ed528e))
|
||||
* **core:** Wait for credential deletion to finish ([#40103](https://github.com/n8n-io/n8n/issues/40103)) ([407cf23](https://github.com/n8n-io/n8n/commit/407cf23da75d6027b7490c00ec274114e6438786))
|
||||
* **Databricks Trigger Node:** Cap pipeline event pages at 250, the limit Databricks enforces ([#40154](https://github.com/n8n-io/n8n/issues/40154)) ([b8cd29f](https://github.com/n8n-io/n8n/commit/b8cd29fde40288d0f14db15bd50429035c210174))
|
||||
* **DataTable Node:** Continue paginated reads when row counts change ([#39764](https://github.com/n8n-io/n8n/issues/39764)) ([d2eae41](https://github.com/n8n-io/n8n/commit/d2eae41dd6e46d2cb2d430d9df661c4ea8afd4da))
|
||||
* **editor:** Align Agent confirmation warning icons ([#40040](https://github.com/n8n-io/n8n/issues/40040)) ([f1d4017](https://github.com/n8n-io/n8n/commit/f1d4017883bdae464d0a7588b5eff84443e50351))
|
||||
* **editor:** Align input heights on the project settings page ([#39611](https://github.com/n8n-io/n8n/issues/39611)) ([ea9f451](https://github.com/n8n-io/n8n/commit/ea9f451076722a6784e0f9d78af4410952be9a99))
|
||||
* **editor:** Align Preview and Assistant chat history controls ([#40165](https://github.com/n8n-io/n8n/issues/40165)) ([a30bb7d](https://github.com/n8n-io/n8n/commit/a30bb7d89d051f26030800db9f7e31003a8aaa4e))
|
||||
* **editor:** Align workflow and agent action menus ([#39234](https://github.com/n8n-io/n8n/issues/39234)) ([d09e6d6](https://github.com/n8n-io/n8n/commit/d09e6d65fcb3398098341d63b7df1b7116baf4c7))
|
||||
* **editor:** Close the AI Assistant tab hover card when the pointer leaves the tab ([#40003](https://github.com/n8n-io/n8n/issues/40003)) ([0817b0e](https://github.com/n8n-io/n8n/commit/0817b0e8cc605565d269cfcf329eb8336eafc8ef))
|
||||
* **editor:** Correct the expanded Markdown editor close label ([#40041](https://github.com/n8n-io/n8n/issues/40041)) ([ee46834](https://github.com/n8n-io/n8n/commit/ee46834915197609cd75ce56fdce2c2e8409f787))
|
||||
* **editor:** Do not run the workflow when the save before execute fails ([#39763](https://github.com/n8n-io/n8n/issues/39763)) ([7204df6](https://github.com/n8n-io/n8n/commit/7204df600d9c9b2dc3c8ac2bc635082d207f72e6))
|
||||
* **editor:** Enable Save when SSO role provisioning switches back to manual ([#39925](https://github.com/n8n-io/n8n/issues/39925)) ([360ec09](https://github.com/n8n-io/n8n/commit/360ec09cf2a32da71dc595be2618a3e2d834dac8))
|
||||
* **editor:** Extend workflow review divider to full height ([#40035](https://github.com/n8n-io/n8n/issues/40035)) ([c5658ba](https://github.com/n8n-io/n8n/commit/c5658ba4bf21e839464fc313bb04940ae93506d6))
|
||||
* **editor:** Fix copy button hover state in MCP settings ([#39888](https://github.com/n8n-io/n8n/issues/39888)) ([f0b7013](https://github.com/n8n-io/n8n/commit/f0b70134a8acec730d69a46d71ee76b7358fb4e8))
|
||||
* **editor:** Keep credentials unchanged on read-only canvases ([#39889](https://github.com/n8n-io/n8n/issues/39889)) ([d69b52e](https://github.com/n8n-io/n8n/commit/d69b52e2c78fa1e2c993e11ce270f48f11590d05))
|
||||
* **editor:** Keep execution filters when navigating away from executions ([#39781](https://github.com/n8n-io/n8n/issues/39781)) ([fefb2da](https://github.com/n8n-io/n8n/commit/fefb2da2b1ed670114aa5f38f22bf7fe82e8dd97))
|
||||
* **editor:** Leave the sweep time of interrupted tool calls out of the Assistant thinking duration ([#40161](https://github.com/n8n-io/n8n/issues/40161)) ([3879051](https://github.com/n8n-io/n8n/commit/387905148a793e83f0fa5d4877947b3c52fd0f22))
|
||||
* **editor:** Preserve Agent instruction edits during autosave ([#39972](https://github.com/n8n-io/n8n/issues/39972)) ([4d931ca](https://github.com/n8n-io/n8n/commit/4d931ca77e9fd2250cb75ce3b7a55b995c39cef7))
|
||||
* **editor:** Refresh the Assistant chat list when the tab becomes visible ([#40158](https://github.com/n8n-io/n8n/issues/40158)) ([064c3bf](https://github.com/n8n-io/n8n/commit/064c3bf979538b1ac9e309590b34842265545427))
|
||||
* **editor:** Remove the new-agent pill from the AI assistant composer ([#40014](https://github.com/n8n-io/n8n/issues/40014)) ([e79a2ba](https://github.com/n8n-io/n8n/commit/e79a2ba84bce39a65151d4ba7ad72da08266daeb))
|
||||
* **editor:** Remove the one-click connector for the ChatGPT MCP client ([#40123](https://github.com/n8n-io/n8n/issues/40123)) ([cbaea98](https://github.com/n8n-io/n8n/commit/cbaea9852139ceabdd629d582291965f924d9de1))
|
||||
* **editor:** Restore full-width Agent preview ([#39986](https://github.com/n8n-io/n8n/issues/39986)) ([5c0aff8](https://github.com/n8n-io/n8n/commit/5c0aff8cbc82b384a4205fb3e99bd86954aaade5))
|
||||
* **editor:** Scale up node icons that declare a size smaller than their slot ([#40181](https://github.com/n8n-io/n8n/issues/40181)) ([3fe4612](https://github.com/n8n-io/n8n/commit/3fe4612d93b8ecf7e04e86d985329650411bc256))
|
||||
* **editor:** Show full reasoning on first expand of assistant thinking block ([#39892](https://github.com/n8n-io/n8n/issues/39892)) ([57ef0d1](https://github.com/n8n-io/n8n/commit/57ef0d138fb609c472521d9e8b09981625a8b201))
|
||||
* **editor:** Show remaining credits on Assistant banner for Cloud UBB ([#39901](https://github.com/n8n-io/n8n/issues/39901)) ([29c09c6](https://github.com/n8n-io/n8n/commit/29c09c6f997bcee2fa8133a716d08c3cf2121d0e))
|
||||
* **editor:** Show warning when moving a workflow with data table dependency ([#39752](https://github.com/n8n-io/n8n/issues/39752)) ([fce9570](https://github.com/n8n-io/n8n/commit/fce9570711e5b14c42204ac2b184680f36b956d6))
|
||||
* **editor:** Stop credential icon overlapping the credential name ([#39944](https://github.com/n8n-io/n8n/issues/39944)) ([1694727](https://github.com/n8n-io/n8n/commit/169472754843910279010e1007a842030206c4c1))
|
||||
* **editor:** Unify MCP access labels ([#40090](https://github.com/n8n-io/n8n/issues/40090)) ([88d6579](https://github.com/n8n-io/n8n/commit/88d657945995973b939156b0fb43cdf5baa8cf9d))
|
||||
* **editor:** Use only server-provided hints for the OAuth consent screen icon ([#37317](https://github.com/n8n-io/n8n/issues/37317)) ([01e5ebf](https://github.com/n8n-io/n8n/commit/01e5ebf7267d7d4eb39ba2caded51e92c5ba2945))
|
||||
* **editor:** Wait for OAuth sign-in before the assistant credential card continues ([#39998](https://github.com/n8n-io/n8n/issues/39998)) ([0a1ed4e](https://github.com/n8n-io/n8n/commit/0a1ed4e09b0e16cd55a29ada08b2641447a5f815))
|
||||
* **Execute Workflow Node:** Return declared sub-workflow outputs ([#39755](https://github.com/n8n-io/n8n/issues/39755)) ([41a0cca](https://github.com/n8n-io/n8n/commit/41a0ccaf160c22839ab82211e7741d4a607ba03f))
|
||||
* **Google Calendar Trigger Node:** Handle all-day event boundaries ([#39898](https://github.com/n8n-io/n8n/issues/39898)) ([900fe0c](https://github.com/n8n-io/n8n/commit/900fe0c03a96009aee9103e8ec83e756daa6d943))
|
||||
* Hide node types that the instance does not load ([#39571](https://github.com/n8n-io/n8n/issues/39571)) ([0afda58](https://github.com/n8n-io/n8n/commit/0afda588042c712da6225663669f847cdfe788f7))
|
||||
* Keep workflow sharing open until changes are saved ([#40137](https://github.com/n8n-io/n8n/issues/40137)) ([9e5eb79](https://github.com/n8n-io/n8n/commit/9e5eb79cc46b2d6fe806be00a078225905ee77f5))
|
||||
* Limit MCP Agent model discovery results ([#39973](https://github.com/n8n-io/n8n/issues/39973)) ([996dec3](https://github.com/n8n-io/n8n/commit/996dec31936c7aeba7a9212df8476ead584f7d3d))
|
||||
* Pass shutdown margin to JavaScript runner in runners image ([#40178](https://github.com/n8n-io/n8n/issues/40178)) ([df27223](https://github.com/n8n-io/n8n/commit/df27223a4edf9cca4287bdd321d4acfba1effac9))
|
||||
* Persist nested node custom span attributes ([#40107](https://github.com/n8n-io/n8n/issues/40107)) ([4a0865b](https://github.com/n8n-io/n8n/commit/4a0865bb62a602397a61f6f635bdf16bb4b99e6a))
|
||||
* Replace deprecated APIs in services and tooling ([#40199](https://github.com/n8n-io/n8n/issues/40199)) ([be2ee6f](https://github.com/n8n-io/n8n/commit/be2ee6f35661f4e052cc600047594a153a71bdc3))
|
||||
* Report the real cause when a module entrypoint fails to load ([#37625](https://github.com/n8n-io/n8n/issues/37625)) ([5db0c39](https://github.com/n8n-io/n8n/commit/5db0c39dde9086dbd5ad6041e53ad6d896af0b8f))
|
||||
* Scope the legacy setup credential picker to the target workflow and report unusable credentials on apply ([#38197](https://github.com/n8n-io/n8n/issues/38197)) ([5c1f8f7](https://github.com/n8n-io/n8n/commit/5c1f8f7f6c7537bfd5a65e94a886d8cc29196908))
|
||||
* **Split Out Node:** Support dot notation in destination fields ([#39661](https://github.com/n8n-io/n8n/issues/39661)) ([f9dbd2c](https://github.com/n8n-io/n8n/commit/f9dbd2c8d63413c61ba781ab1050ec77a2499973))
|
||||
* **WhatsApp Node:** Fetch all templates for the dropdown ([#39497](https://github.com/n8n-io/n8n/issues/39497)) ([ce295b6](https://github.com/n8n-io/n8n/commit/ce295b6080f34cc0c9b0c51119d2b1469d19a5ac))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **ai-builder:** Change knowledge subtitle ([#39838](https://github.com/n8n-io/n8n/issues/39838)) ([6cc8c0d](https://github.com/n8n-io/n8n/commit/6cc8c0d4480b4a7a0ea44d38655060325fe939f7))
|
||||
* **Azure OpenAI Chat Model Node:** Add the deployment dropdown ([#39643](https://github.com/n8n-io/n8n/issues/39643)) ([022f4a6](https://github.com/n8n-io/n8n/commit/022f4a6477d0f53f61acda88d5b71617cdd4ca5b))
|
||||
* **core:** Add 'align' prop to Select component ([#39965](https://github.com/n8n-io/n8n/issues/39965)) ([b15ffa9](https://github.com/n8n-io/n8n/commit/b15ffa95423c8a238a155f1d379fcf031bf1a4eb))
|
||||
* **core:** Add a guardrail runner to the agent loop ([#39621](https://github.com/n8n-io/n8n/issues/39621)) ([3745c83](https://github.com/n8n-io/n8n/commit/3745c835b2b47ed484246fcd7aefd5e9be3be5bf))
|
||||
* **core:** Add an instance setting for Agents ([#40153](https://github.com/n8n-io/n8n/issues/40153)) ([628ada7](https://github.com/n8n-io/n8n/commit/628ada76cde4f51348849893dceaa61c3528e36e))
|
||||
* **core:** Add an opt-in budget guardrail for agent runs ([#39765](https://github.com/n8n-io/n8n/issues/39765)) ([d5937ed](https://github.com/n8n-io/n8n/commit/d5937ed2b6e947cb309566b9fb2cbfb32bb33c66))
|
||||
* **core:** Add engine suspension at node boundaries for resumable executions ([#37649](https://github.com/n8n-io/n8n/issues/37649)) ([680f958](https://github.com/n8n-io/n8n/commit/680f9582e16bff0d6e8c4077a13f2ac26061bb88))
|
||||
* **core:** Add inbound-auth-core module with trusted source entities and migration ([#39642](https://github.com/n8n-io/n8n/issues/39642)) ([0f63032](https://github.com/n8n-io/n8n/commit/0f630329167896a1452a2d6c9b5e12cf6dce165b))
|
||||
* **core:** Add remote search and programmatic focus to N8nCombobox so the MCP selects can leave N8nSelect ([#40140](https://github.com/n8n-io/n8n/issues/40140)) ([8f13f28](https://github.com/n8n-io/n8n/commit/8f13f2830a771e10ecfcc3c178a60c614103e3b5))
|
||||
* **core:** Add tool to mark an agent session as failed ([#39754](https://github.com/n8n-io/n8n/issues/39754)) ([114a573](https://github.com/n8n-io/n8n/commit/114a5737f57b271cb4d63dc849a13e8d27e148a4))
|
||||
* **core:** Capture sidecall generation usd cost ([#39627](https://github.com/n8n-io/n8n/issues/39627)) ([8c9733b](https://github.com/n8n-io/n8n/commit/8c9733b5c68202dc17a41aa7d72816879e223c66))
|
||||
* **core:** Change IAI permissions logic ([#39303](https://github.com/n8n-io/n8n/issues/39303)) ([99182c2](https://github.com/n8n-io/n8n/commit/99182c29be23cd2d8cdb1e1e5b46d3d37ceaf716))
|
||||
* **core:** Escalate background sub-agent approvals to parent chats ([#39552](https://github.com/n8n-io/n8n/issues/39552)) ([5d40d83](https://github.com/n8n-io/n8n/commit/5d40d837f632799d3b81b9a4d8dcd0d0dad8ad03))
|
||||
* **core:** Export the stored schedule of durable system tasks as metrics ([#40009](https://github.com/n8n-io/n8n/issues/40009)) ([3faf54f](https://github.com/n8n-io/n8n/commit/3faf54fea360d8bebb2c598d6a730c9e034dee90))
|
||||
* **core:** Hide current Agent references and show built Workflow cards in the embedded Assistant ([#40149](https://github.com/n8n-io/n8n/issues/40149)) ([1043504](https://github.com/n8n-io/n8n/commit/1043504c7a76de1809a9dcf2e1e6f0c88f5b1b9f))
|
||||
* **core:** Indicate unpublished changes in agent resource list view ([#40177](https://github.com/n8n-io/n8n/issues/40177)) ([6a5aa78](https://github.com/n8n-io/n8n/commit/6a5aa7862ea38cbf75445b13993018aaff587c79))
|
||||
* **core:** New agent styled differently on agent list view ([#39856](https://github.com/n8n-io/n8n/issues/39856)) ([f931ea8](https://github.com/n8n-io/n8n/commit/f931ea81d52b920d14000c57eaf71a646981467f))
|
||||
* **core:** Persist agent budget spend in the database ([#40138](https://github.com/n8n-io/n8n/issues/40138)) ([a2fd418](https://github.com/n8n-io/n8n/commit/a2fd4189f1788a8656e63c8d17cf2c3be197f5ca))
|
||||
* **core:** Run legacy Cron node schedules on the durable scheduler ([#39411](https://github.com/n8n-io/n8n/issues/39411)) ([bab2978](https://github.com/n8n-io/n8n/commit/bab2978ccf0a9bcb8a55c71386e973531bd958d9))
|
||||
* **core:** Send a log streaming audit event when a policy blocks an action ([#39581](https://github.com/n8n-io/n8n/issues/39581)) ([55fd9da](https://github.com/n8n-io/n8n/commit/55fd9daa089b3af5f387f968644a6cc10e57c451))
|
||||
* **core:** Serve the migration report overview from the finding table ([#39969](https://github.com/n8n-io/n8n/issues/39969)) ([6769662](https://github.com/n8n-io/n8n/commit/6769662ecf875317187dd70e4b82b6dc099893ed))
|
||||
* **core:** Serve the migration rule detail from the finding table ([#39974](https://github.com/n8n-io/n8n/issues/39974)) ([b8ff99f](https://github.com/n8n-io/n8n/commit/b8ff99f2cc66c64c8347d97d803fb1ed63c13547))
|
||||
* **core:** Stop engine v2 executions from the control plane ([#39786](https://github.com/n8n-io/n8n/issues/39786)) ([56d6e9d](https://github.com/n8n-io/n8n/commit/56d6e9da2c64a3671ead1dfa399f94b934b1f149))
|
||||
* **core:** Store isolated workflow suggestions for review ([#39505](https://github.com/n8n-io/n8n/issues/39505)) ([0352a19](https://github.com/n8n-io/n8n/commit/0352a196542703a8af80a116c7f94973b6eb2cf2))
|
||||
* **core:** Vanish Fix with Assistant banner when AIA fixes error ([#39977](https://github.com/n8n-io/n8n/issues/39977)) ([0f11660](https://github.com/n8n-io/n8n/commit/0f11660414aa34e0792c102d174f59d0dda126cd))
|
||||
* **Crypto Node:** Add RSA-OAEP hash option ([#39853](https://github.com/n8n-io/n8n/issues/39853)) ([94a49d0](https://github.com/n8n-io/n8n/commit/94a49d08ce39efc8c20e46ac4b23138dd9419f06))
|
||||
* **Databricks Node:** Enable the Job resource ([#40142](https://github.com/n8n-io/n8n/issues/40142)) ([fb69206](https://github.com/n8n-io/n8n/commit/fb69206450729bf96f659ed9b7ff5789b395619a))
|
||||
* **editor:** Add activation menus to Agent capabilities ([#40104](https://github.com/n8n-io/n8n/issues/40104)) ([53b6a68](https://github.com/n8n-io/n8n/commit/53b6a68e12483fed869ac907e089bf86b502291b))
|
||||
* **editor:** Add keyboard controls to n8n Assistant panel ([#39386](https://github.com/n8n-io/n8n/issues/39386)) ([41994bc](https://github.com/n8n-io/n8n/commit/41994bc6c7476e051fe6d5b960dd6d6f03924de5))
|
||||
* **editor:** Add Remove context menus to Agent configuration ([#40089](https://github.com/n8n-io/n8n/issues/40089)) ([3d70eef](https://github.com/n8n-io/n8n/commit/3d70eef3a3593f368110d8d76411769b0c6e90f6))
|
||||
* **editor:** Add Stop all tasks to the Preview task panel ([#40120](https://github.com/n8n-io/n8n/issues/40120)) ([25831a8](https://github.com/n8n-io/n8n/commit/25831a8ac0d0f2b5c060dcc39c7ed28ef5da5306))
|
||||
* **editor:** Add usage and budget settings for agents ([#39909](https://github.com/n8n-io/n8n/issues/39909)) ([c4d31db](https://github.com/n8n-io/n8n/commit/c4d31db8d61f972e29d0d48e03987d67edbfec0a))
|
||||
* **editor:** Allow reordering queued messages in Agent Preview ([#40101](https://github.com/n8n-io/n8n/issues/40101)) ([33505d7](https://github.com/n8n-io/n8n/commit/33505d7faade510f270f032a6f7aa44744e3ab8d))
|
||||
* **editor:** Close AI Assistant tabs and keep the open tabs per thread ([#39660](https://github.com/n8n-io/n8n/issues/39660)) ([9ab649f](https://github.com/n8n-io/n8n/commit/9ab649ff55b8b5af3d3331c4355d841d37a41b34))
|
||||
* **editor:** Open and collapse the logs panel around runs in the AI Assistant artifact view ([#38773](https://github.com/n8n-io/n8n/issues/38773)) ([9a4a1dc](https://github.com/n8n-io/n8n/commit/9a4a1dcf27d1a16b32ea6c68a6e7764403853e6a))
|
||||
* **editor:** Open any project resource as an AI Assistant tab ([#39663](https://github.com/n8n-io/n8n/issues/39663)) ([0c4bf38](https://github.com/n8n-io/n8n/commit/0c4bf38033be064f021bcaea2fcfc50d82c874c6))
|
||||
* **editor:** Refine the workflow review detail layout ([#39866](https://github.com/n8n-io/n8n/issues/39866)) ([373bf77](https://github.com/n8n-io/n8n/commit/373bf77f3a4d97cb616f6f1248cdf487ac598f6e))
|
||||
* **editor:** Release Browser Use to all users ([#39762](https://github.com/n8n-io/n8n/issues/39762)) ([f58f0ec](https://github.com/n8n-io/n8n/commit/f58f0ec1d046204b1e1047f6c104606a0277cb41))
|
||||
* **editor:** Reorder AI Assistant tabs by dragging ([#39664](https://github.com/n8n-io/n8n/issues/39664)) ([2f4bbfa](https://github.com/n8n-io/n8n/commit/2f4bbfa7d2e0debdbfa8b8db981b44393c262c33))
|
||||
* **editor:** Replace severity with impact categories in the migration report ([#39551](https://github.com/n8n-io/n8n/issues/39551)) ([0f77ba5](https://github.com/n8n-io/n8n/commit/0f77ba5e7ee90bb378764a2dc5689a1d6fe3f7a6))
|
||||
* **editor:** Show Gateway credits promotions on community nodes ([#40010](https://github.com/n8n-io/n8n/issues/40010)) ([190d7c0](https://github.com/n8n-io/n8n/commit/190d7c0dc0fb6dcef315de97d8cb2d24579afdff))
|
||||
* **editor:** Show migration progress on report overview ([#39861](https://github.com/n8n-io/n8n/issues/39861)) ([9d11369](https://github.com/n8n-io/n8n/commit/9d11369db77719f43ff52e6d48455251c3358f87))
|
||||
* **editor:** Show n8n logo on canvas in canvas-only mode ([#39846](https://github.com/n8n-io/n8n/issues/39846)) ([1153702](https://github.com/n8n-io/n8n/commit/1153702d1a29223f7b80a51e1d73d4772f4bc462))
|
||||
* **editor:** Show the trigger icon on the execute button and run a trigger from its menu ([#39580](https://github.com/n8n-io/n8n/issues/39580)) ([55a8787](https://github.com/n8n-io/n8n/commit/55a87877b5ba82f7e460ac9894fcfc515263e3b3))
|
||||
* **editor:** Show whose credential a node uses and let others switch from it ([#40281](https://github.com/n8n-io/n8n/issues/40281)) ([92ae1b3](https://github.com/n8n-io/n8n/commit/92ae1b336f1542b73eb0c65f01426d9c3739836d))
|
||||
* **editor:** Stop Execute and Publish when a credential is not yours to use ([#39883](https://github.com/n8n-io/n8n/issues/39883)) ([deaa9b1](https://github.com/n8n-io/n8n/commit/deaa9b105477ef0ace3ddedaa32f37c77a106ca3))
|
||||
* **editor:** Submit selected project changes from the promote modal ([#39609](https://github.com/n8n-io/n8n/issues/39609)) ([f7cfb52](https://github.com/n8n-io/n8n/commit/f7cfb520bf666a6122bb8b99e7ae2f976f9a0b0a))
|
||||
* Hide generated AI tools listed in NODES_EXCLUDE and empty node creator entries ([#39577](https://github.com/n8n-io/n8n/issues/39577)) ([5e376b6](https://github.com/n8n-io/n8n/commit/5e376b69edf3eb10dfa7f4a735ddf36a5cb027bf))
|
||||
* Implements n8n Assistant nudge on workflow errors ([#39774](https://github.com/n8n-io/n8n/issues/39774)) ([af26954](https://github.com/n8n-io/n8n/commit/af2695407abd2d299819f94aa8c4a9eb254587f3))
|
||||
* **Supabase Node:** Add OAuth credentials and reuse them for MCP ([#39979](https://github.com/n8n-io/n8n/issues/39979)) ([8e339d7](https://github.com/n8n-io/n8n/commit/8e339d70513d5bed3d708d6ea25b4e43c9ed6099))
|
||||
* Tell the AI Assistant which preview tabs are open ([#39768](https://github.com/n8n-io/n8n/issues/39768)) ([10e484f](https://github.com/n8n-io/n8n/commit/10e484f552abbddf3694f13ccda2875323ba8e68))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **core:** Load project relations without joining every role scope ([#39756](https://github.com/n8n-io/n8n/issues/39756)) ([d92bc4d](https://github.com/n8n-io/n8n/commit/d92bc4d3ab1ca45e0e2d096e4d66a9dcc7e14511))
|
||||
|
||||
|
||||
# [2.42.0](https://github.com/n8n-io/n8n/compare/n8n@2.41.0...n8n@2.42.0) (2026-09-29)
|
||||
|
||||
|
||||
|
|
|
|||
27
OWNERS
27
OWNERS
|
|
@ -52,7 +52,6 @@ packages/@n8n/db/src/migrations/ @n8n-io/migrations-revie
|
|||
# Top-level paths
|
||||
scripts/ @n8n-io/qa-dx
|
||||
patches/ @n8n-io/qa-dx
|
||||
assets/ @n8n-io/adore
|
||||
security/ @n8n-io/qa-dx
|
||||
.code-health-baseline.json @n8n-io/qa-dx required
|
||||
OWNERS @n8n-io/qa-dx required
|
||||
|
|
@ -109,18 +108,19 @@ packages/cli/src/environments.ee/ @n8n-io/ligo
|
|||
packages/cli/src/public-api/ @n8n-io/ligo
|
||||
packages/cli/src/modules/source-control.ee/ @n8n-io/ligo
|
||||
packages/cli/src/modules/external-secrets.ee/ @n8n-io/ligo
|
||||
packages/cli/src/modules/insights/ @n8n-io/ligo
|
||||
packages/modules/insights/ @n8n-io/ligo
|
||||
|
||||
packages/cli/src/collaboration/ @n8n-io/catalysts
|
||||
packages/cli/src/binary-data/ @n8n-io/catalysts
|
||||
packages/cli/src/posthog/ @n8n-io/adore
|
||||
packages/cli/src/posthog/ @n8n-io/growth
|
||||
packages/cli/src/modules/data-table/ @n8n-io/adore
|
||||
|
||||
packages/cli/src/evaluation.ee/ @n8n-io/ai
|
||||
packages/cli/src/chat/ @n8n-io/ai
|
||||
packages/cli/src/tool-generation/ @n8n-io/ai
|
||||
packages/cli/src/modules/workflow-builder/ @n8n-io/ai
|
||||
packages/cli/src/modules/workflow-builder/ @n8n-io/ai-assistant
|
||||
packages/cli/src/modules/mcp/ @n8n-io/adore
|
||||
packages/modules/mcp/ @n8n-io/adore
|
||||
packages/cli/src/modules/quick-connect/ @n8n-io/ai
|
||||
packages/cli/src/modules/chat-hub/ @n8n-io/ai-assistant
|
||||
packages/cli/src/modules/instance-ai/ @n8n-io/ai-assistant
|
||||
|
|
@ -140,14 +140,14 @@ packages/cli/src/controllers/user-settings.controller.ts @n8n-io/iam
|
|||
packages/cli/src/controllers/api-keys.controller.ts @n8n-io/iam
|
||||
packages/cli/src/controllers/security-settings.controller.ts @n8n-io/iam
|
||||
packages/cli/src/controllers/oauth/ @n8n-io/iam
|
||||
packages/cli/src/controllers/ai.controller.ts @n8n-io/ai
|
||||
packages/cli/src/controllers/ai.controller.ts @n8n-io/ai-assistant
|
||||
packages/cli/src/controllers/annotation-tags.controller.ee.ts @n8n-io/ai
|
||||
packages/cli/src/controllers/cta.controller.ts @n8n-io/adore
|
||||
packages/cli/src/controllers/folder.controller.ts @n8n-io/adore
|
||||
packages/cli/src/controllers/tags.controller.ts @n8n-io/adore
|
||||
packages/cli/src/controllers/binary-data.controller.ts @n8n-io/adore
|
||||
packages/cli/src/controllers/posthog.controller.ts @n8n-io/adore
|
||||
packages/cli/src/controllers/translation.controller.ts @n8n-io/adore
|
||||
packages/cli/src/controllers/translation.controller.ts @n8n-io/catalysts
|
||||
packages/cli/src/controllers/project.controller.ts @n8n-io/ligo
|
||||
packages/cli/src/controllers/workflow-statistics.controller.ts @n8n-io/ligo
|
||||
packages/cli/src/controllers/node-types.controller.ts @n8n-io/nodes
|
||||
|
|
@ -162,14 +162,14 @@ packages/cli/src/services/public-api-key.service.ts @n8n-io/iam
|
|||
packages/cli/src/services/security-settings.service.ts @n8n-io/iam
|
||||
packages/cli/src/services/static-auth-service.ts @n8n-io/iam
|
||||
packages/cli/src/services/access.service.ts @n8n-io/iam
|
||||
packages/cli/src/services/ai.service.ts @n8n-io/ai
|
||||
packages/cli/src/services/ai-usage.service.ts @n8n-io/ai
|
||||
packages/cli/src/services/ai-workflow-builder.service.ts @n8n-io/ai
|
||||
packages/cli/src/services/ai.service.ts @n8n-io/ai-assistant
|
||||
packages/cli/src/services/ai-usage.service.ts @n8n-io/ai-assistant
|
||||
packages/cli/src/services/ai-workflow-builder.service.ts @n8n-io/ai-assistant
|
||||
packages/cli/src/services/annotation-tag.service.ee.ts @n8n-io/ai
|
||||
packages/cli/src/services/folder.service.ts @n8n-io/adore
|
||||
packages/cli/src/services/tag.service.ts @n8n-io/adore
|
||||
packages/cli/src/services/cta.service.ts @n8n-io/adore
|
||||
packages/cli/src/services/frontend.service.ts @n8n-io/adore
|
||||
packages/cli/src/services/frontend.service.ts @n8n-io/catalysts
|
||||
packages/cli/src/services/banner.service.ts @n8n-io/adore
|
||||
packages/cli/src/services/project.service.ee.ts @n8n-io/ligo
|
||||
packages/cli/src/services/workflow-statistics.service.ts @n8n-io/ligo
|
||||
|
|
@ -182,6 +182,7 @@ packages/cli/src/services/dynamic-node-parameters.service.ts @n8n-io/nodes
|
|||
|
||||
packages/frontend/editor-ui/ @n8n-io/frontend
|
||||
packages/frontend/editor-ui/src/features/ai/ @n8n-io/ai
|
||||
packages/frontend/editor-ui/src/features/ai/assistant/ @n8n-io/ai-assistant
|
||||
packages/frontend/editor-ui/src/features/ai/chatHub/ @n8n-io/ai-assistant
|
||||
packages/frontend/editor-ui/src/features/credentials/ @n8n-io/iam
|
||||
packages/frontend/editor-ui/src/features/execution/ @n8n-io/ligo
|
||||
|
|
@ -201,7 +202,7 @@ packages/@n8n/instance-ai/ @n8n-io/ai-assistant
|
|||
packages/@n8n/nodes-langchain/ @n8n-io/ai
|
||||
packages/@n8n/ai-utilities/ @n8n-io/ai
|
||||
packages/@n8n/ai-node-sdk/ @n8n-io/ai
|
||||
packages/@n8n/ai-workflow-builder.ee/ @n8n-io/ai
|
||||
packages/@n8n/ai-workflow-builder.ee/ @n8n-io/ai-assistant
|
||||
packages/@n8n/agents/ @n8n-io/ai
|
||||
packages/frontend/@n8n/chat/ @n8n-io/ai
|
||||
|
||||
|
|
@ -249,10 +250,6 @@ packages/@n8n/permissions/ @n8n-io/iam
|
|||
packages/@n8n/client-oauth2/ @n8n-io/iam
|
||||
packages/@n8n/inbound-auth/ @n8n-io/iam
|
||||
|
||||
# LiGo
|
||||
|
||||
packages/extensions/insights/ @n8n-io/ligo
|
||||
|
||||
# Governance & Observability
|
||||
|
||||
packages/modules/type-availability-policies/ @n8n-io/governance-and-observability
|
||||
|
|
|
|||
5
docs/generated/postgres-schema/README.md
generated
5
docs/generated/postgres-schema/README.md
generated
|
|
@ -134,7 +134,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
|
|||
| [public.token_exchange_jti](public.token_exchange_jti.md) | 3 | | BASE TABLE |
|
||||
| [public.trusted_key](public.trusted_key.md) | 4 | | BASE TABLE |
|
||||
| [public.trusted_key_source](public.trusted_key_source.md) | 8 | | BASE TABLE |
|
||||
| [public.trusted_source](public.trusted_source.md) | 12 | | BASE TABLE |
|
||||
| [public.trusted_source](public.trusted_source.md) | 15 | | BASE TABLE |
|
||||
| [public.trusted_source_identity](public.trusted_source_identity.md) | 8 | | BASE TABLE |
|
||||
| [public.type_availability_policy](public.type_availability_policy.md) | 7 | | BASE TABLE |
|
||||
| [public.type_availability_policy_attachment](public.type_availability_policy_attachment.md) | 6 | | BASE TABLE |
|
||||
|
|
@ -1663,11 +1663,14 @@ erDiagram
|
|||
text config
|
||||
integer configVersion
|
||||
timestamp_3__with_time_zone createdAt
|
||||
varchar_36_ discoveryClaimToken
|
||||
timestamp_3__with_time_zone discoveryClaimedAt
|
||||
varchar_36_ id
|
||||
varchar issuer
|
||||
timestamp_3__with_time_zone lastCheckedAt
|
||||
text lastError
|
||||
varchar_16_ managedBy
|
||||
text metadata
|
||||
varchar_128_ name
|
||||
varchar_16_ status
|
||||
varchar_32_ type
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| CHK_agent_chat_subscriptions_integrationType | CHECK | CHECK ((("integrationType")::text = ANY ((ARRAY['telegram'::character varying, 'slack'::character varying, 'linear'::character varying, 'discord'::character varying, 'teams'::character varying])::text[]))) |
|
||||
| CHK_agent_chat_subscriptions_integrationType | CHECK | CHECK ((("integrationType")::text = ANY ((ARRAY['telegram'::character varying, 'slack'::character varying, 'linear'::character varying, 'discord'::character varying, 'teams'::character varying, 'whatsapp'::character varying])::text[]))) |
|
||||
| FK_e79153bd179c011e779d5016796 | FOREIGN KEY | FOREIGN KEY ("agentId") REFERENCES agents(id) ON DELETE CASCADE |
|
||||
| PK_76598cf91038bee1f3ac94c94bc | PRIMARY KEY | PRIMARY KEY ("agentId", "integrationType", "credentialId", "threadId") |
|
||||
| agent_chat_subscriptions_agentId_not_null | n | NOT NULL "agentId" |
|
||||
|
|
|
|||
|
|
@ -7,11 +7,14 @@
|
|||
| config | text | | false | | | |
|
||||
| configVersion | integer | | false | | | |
|
||||
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||
| discoveryClaimToken | varchar(36) | | true | | | |
|
||||
| discoveryClaimedAt | timestamp(3) with time zone | | true | | | |
|
||||
| id | varchar(36) | | false | [public.trusted_source_identity](public.trusted_source_identity.md) | | |
|
||||
| issuer | varchar | | false | | | |
|
||||
| lastCheckedAt | timestamp(3) with time zone | | true | | | |
|
||||
| lastError | text | | true | | | |
|
||||
| managedBy | varchar(16) | | false | | | |
|
||||
| metadata | text | | true | | | |
|
||||
| name | varchar(128) | | false | | | |
|
||||
| status | varchar(16) | | false | | | |
|
||||
| type | varchar(32) | | false | | | |
|
||||
|
|
@ -52,11 +55,14 @@ erDiagram
|
|||
text config
|
||||
integer configVersion
|
||||
timestamp_3__with_time_zone createdAt
|
||||
varchar_36_ discoveryClaimToken
|
||||
timestamp_3__with_time_zone discoveryClaimedAt
|
||||
varchar_36_ id
|
||||
varchar issuer
|
||||
timestamp_3__with_time_zone lastCheckedAt
|
||||
text lastError
|
||||
varchar_16_ managedBy
|
||||
text metadata
|
||||
varchar_128_ name
|
||||
varchar_16_ status
|
||||
varchar_32_ type
|
||||
|
|
|
|||
|
|
@ -57,11 +57,14 @@ erDiagram
|
|||
text config
|
||||
integer configVersion
|
||||
timestamp_3__with_time_zone createdAt
|
||||
varchar_36_ discoveryClaimToken
|
||||
timestamp_3__with_time_zone discoveryClaimedAt
|
||||
varchar_36_ id
|
||||
varchar issuer
|
||||
timestamp_3__with_time_zone lastCheckedAt
|
||||
text lastError
|
||||
varchar_16_ managedBy
|
||||
text metadata
|
||||
varchar_128_ name
|
||||
varchar_16_ status
|
||||
varchar_32_ type
|
||||
|
|
|
|||
5
docs/generated/sqlite-schema/README.md
generated
5
docs/generated/sqlite-schema/README.md
generated
|
|
@ -134,7 +134,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
|
|||
| [token_exchange_jti](token_exchange_jti.md) | 3 | | table |
|
||||
| [trusted_key](trusted_key.md) | 4 | | table |
|
||||
| [trusted_key_source](trusted_key_source.md) | 8 | | table |
|
||||
| [trusted_source](trusted_source.md) | 12 | | table |
|
||||
| [trusted_source](trusted_source.md) | 15 | | table |
|
||||
| [trusted_source_identity](trusted_source_identity.md) | 8 | | table |
|
||||
| [type_availability_policy](type_availability_policy.md) | 7 | | table |
|
||||
| [type_availability_policy_attachment](type_availability_policy_attachment.md) | 6 | | table |
|
||||
|
|
@ -1651,11 +1651,14 @@ erDiagram
|
|||
TEXT config
|
||||
INTEGER configVersion
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ discoveryClaimToken
|
||||
datetime_3_ discoveryClaimedAt
|
||||
varchar_36_ id PK
|
||||
varchar issuer
|
||||
datetime_3_ lastCheckedAt
|
||||
TEXT lastError
|
||||
varchar_16_ managedBy
|
||||
TEXT metadata
|
||||
varchar_128_ name
|
||||
varchar_16_ status
|
||||
varchar_32_ type
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<summary><strong>Table Definition</strong></summary>
|
||||
|
||||
```sql
|
||||
CREATE TABLE "agent_chat_subscriptions" ("agentId" varchar(36) NOT NULL, "integrationType" varchar(64) NOT NULL, "credentialId" varchar(255) NOT NULL, "threadId" varchar(255) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_agent_chat_subscriptions_integrationType" CHECK ("integrationType" IN ('telegram', 'slack', 'linear', 'discord', 'teams')), CONSTRAINT "FK_e79153bd179c011e779d5016796" FOREIGN KEY ("agentId") REFERENCES "agents" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("agentId", "integrationType", "credentialId", "threadId"))
|
||||
CREATE TABLE "agent_chat_subscriptions" ("agentId" varchar(36) NOT NULL, "integrationType" varchar(64) NOT NULL, "credentialId" varchar(255) NOT NULL, "threadId" varchar(255) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_agent_chat_subscriptions_integrationType" CHECK ("integrationType" IN ('telegram', 'slack', 'linear', 'discord', 'teams', 'whatsapp')), CONSTRAINT "FK_e79153bd179c011e779d5016796" FOREIGN KEY ("agentId") REFERENCES "agents" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("agentId", "integrationType", "credentialId", "threadId"))
|
||||
```
|
||||
|
||||
</details>
|
||||
|
|
@ -26,7 +26,7 @@ CREATE TABLE "agent_chat_subscriptions" ("agentId" varchar(36) NOT NULL, "integr
|
|||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| - | CHECK | CHECK ("integrationType" IN ('telegram', 'slack', 'linear', 'discord', 'teams')) |
|
||||
| - | CHECK | CHECK ("integrationType" IN ('telegram', 'slack', 'linear', 'discord', 'teams', 'whatsapp')) |
|
||||
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| agentId | PRIMARY KEY | PRIMARY KEY (agentId) |
|
||||
| credentialId | PRIMARY KEY | PRIMARY KEY (credentialId) |
|
||||
|
|
|
|||
8
docs/generated/sqlite-schema/trusted_source.md
generated
8
docs/generated/sqlite-schema/trusted_source.md
generated
|
|
@ -6,7 +6,7 @@
|
|||
<summary><strong>Table Definition</strong></summary>
|
||||
|
||||
```sql
|
||||
CREATE TABLE "trusted_source" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "type" varchar(32) NOT NULL, "issuer" varchar NOT NULL, "managedBy" varchar(16) NOT NULL, "status" varchar(16) NOT NULL, "lastError" text, "lastCheckedAt" datetime(3), "configVersion" integer NOT NULL, "config" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')))
|
||||
CREATE TABLE "trusted_source" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "type" varchar(32) NOT NULL, "issuer" varchar NOT NULL, "managedBy" varchar(16) NOT NULL, "status" varchar(16) NOT NULL, "lastError" text, "lastCheckedAt" datetime(3), "configVersion" integer NOT NULL, "config" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "metadata" text, "discoveryClaimedAt" datetime(3), "discoveryClaimToken" varchar(36))
|
||||
```
|
||||
|
||||
</details>
|
||||
|
|
@ -18,11 +18,14 @@ CREATE TABLE "trusted_source" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" var
|
|||
| config | TEXT | | false | | | |
|
||||
| configVersion | INTEGER | | false | | | |
|
||||
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
| discoveryClaimToken | varchar(36) | | true | | | |
|
||||
| discoveryClaimedAt | datetime(3) | | true | | | |
|
||||
| id | varchar(36) | | false | [trusted_source_identity](trusted_source_identity.md) | | |
|
||||
| issuer | varchar | | false | | | |
|
||||
| lastCheckedAt | datetime(3) | | true | | | |
|
||||
| lastError | TEXT | | true | | | |
|
||||
| managedBy | varchar(16) | | false | | | |
|
||||
| metadata | TEXT | | true | | | |
|
||||
| name | varchar(128) | | false | | | |
|
||||
| status | varchar(16) | | false | | | |
|
||||
| type | varchar(32) | | false | | | |
|
||||
|
|
@ -54,11 +57,14 @@ erDiagram
|
|||
TEXT config
|
||||
INTEGER configVersion
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ discoveryClaimToken
|
||||
datetime_3_ discoveryClaimedAt
|
||||
varchar_36_ id PK
|
||||
varchar issuer
|
||||
datetime_3_ lastCheckedAt
|
||||
TEXT lastError
|
||||
varchar_16_ managedBy
|
||||
TEXT metadata
|
||||
varchar_128_ name
|
||||
varchar_16_ status
|
||||
varchar_32_ type
|
||||
|
|
|
|||
|
|
@ -63,11 +63,14 @@ erDiagram
|
|||
TEXT config
|
||||
INTEGER configVersion
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ discoveryClaimToken
|
||||
datetime_3_ discoveryClaimedAt
|
||||
varchar_36_ id PK
|
||||
varchar issuer
|
||||
datetime_3_ lastCheckedAt
|
||||
TEXT lastError
|
||||
varchar_16_ managedBy
|
||||
TEXT metadata
|
||||
varchar_128_ name
|
||||
varchar_16_ status
|
||||
varchar_32_ type
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "2.42.0",
|
||||
"version": "2.43.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/agents",
|
||||
"version": "0.27.0",
|
||||
"version": "0.28.0",
|
||||
"description": "AI agent SDK for n8n's code-first execution engine",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
|
|
|
|||
|
|
@ -241,6 +241,8 @@ export type {
|
|||
RuntimeSkillLoader,
|
||||
RuntimeSkillMcpServerDependency,
|
||||
RuntimeSkillPolicyContract,
|
||||
RuntimeSkillReferenceContract,
|
||||
RuntimeSkillReferenceLocation,
|
||||
RuntimeSkillRegistry,
|
||||
RuntimeSkillRegistryEntry,
|
||||
RuntimeSkillSource,
|
||||
|
|
|
|||
|
|
@ -111,6 +111,51 @@ describe('active skills', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('restores a reference loaded by its owner file path', async () => {
|
||||
const referenceSource = createRuntimeSkillSource([
|
||||
{
|
||||
id: 'builder',
|
||||
name: 'builder',
|
||||
description: 'Build workflows.',
|
||||
instructions: 'Build one workflow.',
|
||||
},
|
||||
{
|
||||
id: 'models',
|
||||
name: 'models',
|
||||
description: 'Load before choosing a model.',
|
||||
instructions: 'Model rules.',
|
||||
parents: ['builder'],
|
||||
reference: { owner: 'builder', path: 'references/models.md' },
|
||||
dependencies: { tools: ['searchModels'] },
|
||||
},
|
||||
]);
|
||||
const memory = new InMemoryMemory();
|
||||
const list = new AgentMessageList();
|
||||
list.addHistory([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolName: 'load_skill',
|
||||
toolCallId: 'reference-load',
|
||||
input: { skillId: 'builder', filePath: 'references/models.md' },
|
||||
state: 'resolved',
|
||||
output: { type: 'content', value: [{ type: 'text', text: 'Model rules.' }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const active = new ActiveSkills(referenceSource, 'assistant', memory.skillState);
|
||||
|
||||
await active.restore(list, scope);
|
||||
|
||||
expect(list.activeSkillIds).toEqual(['models']);
|
||||
expect(JSON.stringify(active.modelMessages(list.forLlm('').messages, list))).toContain(
|
||||
'[Reference of: \\"builder\\"]',
|
||||
);
|
||||
});
|
||||
|
||||
it('recovers a skill into the system block once observation masks its load', async () => {
|
||||
const memory = new InMemoryMemory();
|
||||
const list = new AgentMessageList();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ModelMessage, ToolResultPart } from 'ai';
|
||||
|
||||
import { formatActiveSkill } from '../../skills/tools';
|
||||
import { findReferenceByPath, formatActiveSkill } from '../../skills/tools';
|
||||
import {
|
||||
SKILL_LOAD_TOOL_NAME,
|
||||
type RuntimeSkillContent,
|
||||
|
|
@ -240,7 +240,7 @@ export class ActiveSkills {
|
|||
private formatSkill(skillId: string): string | undefined {
|
||||
const skill = this.loaded.get(skillId);
|
||||
const entry = this.source.registry.skills.find((candidate) => candidate.id === skillId);
|
||||
return skill && entry ? formatActiveSkill(skill, entry) : undefined;
|
||||
return skill && entry ? formatActiveSkill(skill, entry, this.source.registry) : undefined;
|
||||
}
|
||||
|
||||
private recordedLoads(list: AgentMessageList): Map<string, string> {
|
||||
|
|
@ -263,14 +263,20 @@ export class ActiveSkills {
|
|||
continue;
|
||||
if (part.output.success !== true && part.output.type !== 'content') continue;
|
||||
const { skillId, name, filePath } = part.input;
|
||||
if (typeof filePath === 'string' && !['', '/', '.', 'SKILL.md'].includes(filePath.trim()))
|
||||
continue;
|
||||
const requested =
|
||||
typeof skillId === 'string' ? skillId : typeof name === 'string' ? name : undefined;
|
||||
if (!requested) continue;
|
||||
const entry = this.source.registry.skills.find(
|
||||
(skill) => skill.id === requested || skill.name === requested,
|
||||
);
|
||||
if (typeof filePath === 'string' && !['', '/', '.', 'SKILL.md'].includes(filePath.trim())) {
|
||||
// Only a path to a reference skill is an activation; other linked files are plain reads.
|
||||
const reference = entry
|
||||
? findReferenceByPath(this.source.registry, entry.id, filePath)
|
||||
: undefined;
|
||||
if (reference) loads.set(part.toolCallId, reference.id);
|
||||
continue;
|
||||
}
|
||||
loads.set(part.toolCallId, entry?.id ?? requested);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1099,7 +1099,6 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
|
||||
let instructions = this.instructionsText;
|
||||
if (this.skillSource) {
|
||||
await this.skillSource.prepare?.();
|
||||
instructions = appendSkillCatalogToInstructions(instructions, this.skillSource.registry);
|
||||
}
|
||||
if (this.workspaceInstance) {
|
||||
|
|
|
|||
|
|
@ -490,25 +490,16 @@ Use the workflow SDK.`,
|
|||
expect(prepare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prepares the runtime skill source before injecting the agent skill catalog', async () => {
|
||||
it('injects the agent skill catalog without preparing the runtime skill source', async () => {
|
||||
const source = createRuntimeSkillSource([
|
||||
{
|
||||
id: 'summarize_notes',
|
||||
name: 'Summarize notes',
|
||||
description: 'Use for meeting notes.',
|
||||
instructions: 'Extract decisions.',
|
||||
instructions: 'Full private skill body: Extract decisions.',
|
||||
},
|
||||
]);
|
||||
const prepare = vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
source.registry = {
|
||||
...source.registry,
|
||||
skills: source.registry.skills.map((skill) => ({
|
||||
...skill,
|
||||
description: 'Use for materialized meeting notes.',
|
||||
})),
|
||||
};
|
||||
});
|
||||
const prepare = vi.fn(async () => {});
|
||||
source.prepare = prepare;
|
||||
|
||||
const agent = new Agent('assistant')
|
||||
|
|
@ -521,11 +512,10 @@ Use the workflow SDK.`,
|
|||
const { instructions } = runtimeConfig;
|
||||
expect(runtimeConfig.skillSource).toBe(source);
|
||||
|
||||
expect(prepare).toHaveBeenCalledTimes(1);
|
||||
expect(prepare).not.toHaveBeenCalled();
|
||||
expect(instructions).toContain('name: "Summarize notes"');
|
||||
expect(instructions).toContain('id: "summarize_notes"');
|
||||
expect(instructions).toContain('description: "Use for materialized meeting notes."');
|
||||
expect(instructions).not.toContain('description: "Use for meeting notes."');
|
||||
expect(instructions).toContain('description: "Use for meeting notes."');
|
||||
expect(instructions).not.toContain('Full private skill body');
|
||||
});
|
||||
|
||||
|
|
@ -743,3 +733,277 @@ Use the workflow SDK.`,
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reference skills', () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'n8n-reference-skills-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeSkill(dir: string, frontmatter: string, body: string) {
|
||||
mkdirSync(join(root, dir, 'references'), { recursive: true });
|
||||
writeFileSync(join(root, dir, 'SKILL.md'), `---\n${frontmatter}\n---\n\n${body}`);
|
||||
}
|
||||
|
||||
function writeReference(dir: string, file: string, frontmatter: string, body: string) {
|
||||
writeFileSync(join(root, dir, 'references', file), `---\n${frontmatter}\n---\n\n${body}`);
|
||||
}
|
||||
|
||||
function writeBuilderWithReferences() {
|
||||
writeSkill('builder', 'name: builder\ndescription: Build workflows.', 'Build steps.');
|
||||
writeReference(
|
||||
'builder',
|
||||
'models.md',
|
||||
'name: models\ndescription: Load before choosing a model.\ndependencies:\n tools:\n - searchModels',
|
||||
'Model rules.',
|
||||
);
|
||||
writeFileSync(join(root, 'builder', 'references', 'notes.md'), 'Plain notes.');
|
||||
writeSkill(
|
||||
'agents',
|
||||
'name: agents\ndescription: Build agents.\nshared_references:\n - models',
|
||||
'Agent steps.',
|
||||
);
|
||||
}
|
||||
|
||||
it('loads references with frontmatter as hidden skills of their owner and sharers', () => {
|
||||
writeBuilderWithReferences();
|
||||
|
||||
const source = loadRuntimeSkillSourceFromDirectory(root);
|
||||
const models = source.registry.skills.find((skill) => skill.id === 'models');
|
||||
|
||||
expect(models).toMatchObject({
|
||||
description: 'Load before choosing a model.',
|
||||
parents: ['builder', 'agents'],
|
||||
reference: { owner: 'builder', path: 'references/models.md' },
|
||||
dependencies: { tools: ['searchModels'] },
|
||||
});
|
||||
expect(source.registry.skills.map((skill) => skill.id)).not.toContain('notes');
|
||||
|
||||
const catalog = renderSkillCatalogPrompt(source.registry);
|
||||
expect(catalog).not.toContain('name: "builder"');
|
||||
expect(catalog).toContain('id: "builder"');
|
||||
expect(catalog).toContain('id: "agents"');
|
||||
expect(catalog).not.toContain('id: "models"');
|
||||
expect(catalog).toContain('Load a reference with');
|
||||
});
|
||||
|
||||
it('does not change the catalog protocol when no references exist', () => {
|
||||
writeSkill('builder', 'name: builder\ndescription: Build workflows.', 'Build steps.');
|
||||
|
||||
const catalog = renderSkillCatalogPrompt(loadRuntimeSkillSourceFromDirectory(root).registry);
|
||||
|
||||
expect(catalog).not.toContain('Load a reference with');
|
||||
});
|
||||
|
||||
it('lists references with descriptions when a parent skill loads', async () => {
|
||||
writeBuilderWithReferences();
|
||||
const loadTool = createSkillLoadTool(loadRuntimeSkillSourceFromDirectory(root));
|
||||
|
||||
const builder = skillLoadText(await loadTool.handler?.({ skillId: 'builder' }, {}));
|
||||
expect(builder).toContain('[References');
|
||||
expect(builder).toContain('- "models": "Load before choosing a model."');
|
||||
expect(builder).toContain(
|
||||
'[Linked files — load via load_skill with filePath: "references/notes.md"]',
|
||||
);
|
||||
expect(builder).not.toContain('filePath: "references/models.md"');
|
||||
|
||||
const agents = skillLoadText(await loadTool.handler?.({ skillId: 'agents' }, {}));
|
||||
expect(agents).toContain('- "models": "Load before choosing a model."');
|
||||
});
|
||||
|
||||
it('records references with descriptions in the activation result', async () => {
|
||||
writeBuilderWithReferences();
|
||||
const source = loadRuntimeSkillSourceFromDirectory(root);
|
||||
const loadTool = createSkillLoadTool(source);
|
||||
|
||||
const result = await loadTool.handler?.(
|
||||
{ skillId: 'builder' },
|
||||
{ loadSkill: async (skillId: string) => await source.loadSkill(skillId) },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
skillId: 'builder',
|
||||
references: [{ skillId: 'models', description: 'Load before choosing a model.' }],
|
||||
linkedFiles: { references: [expect.objectContaining({ path: 'references/notes.md' })] },
|
||||
});
|
||||
});
|
||||
|
||||
it('loads a reference by id or by its owner file path', async () => {
|
||||
writeBuilderWithReferences();
|
||||
const loadTool = createSkillLoadTool(loadRuntimeSkillSourceFromDirectory(root));
|
||||
|
||||
const byId = skillLoadText(await loadTool.handler?.({ skillId: 'models' }, {}));
|
||||
expect(byId).toContain('[Reference of: "builder", "agents"]');
|
||||
expect(byId).toContain('Model rules.');
|
||||
expect(byId).not.toContain('name: models');
|
||||
|
||||
const byPath = skillLoadText(
|
||||
await loadTool.handler?.({ skillId: 'builder', filePath: 'references/models.md' }, {}),
|
||||
);
|
||||
expect(byPath).toBe(byId);
|
||||
});
|
||||
|
||||
it('hides references whose parents are all filtered out', async () => {
|
||||
writeBuilderWithReferences();
|
||||
const source = loadRuntimeSkillSourceFromDirectory(root);
|
||||
|
||||
const withoutAgents = filterRuntimeSkillSource(source, ['agents']);
|
||||
expect(withoutAgents.registry.skills.find((skill) => skill.id === 'models')?.parents).toEqual([
|
||||
'builder',
|
||||
]);
|
||||
|
||||
const withoutOwner = filterRuntimeSkillSource(source, ['builder']);
|
||||
expect(withoutOwner.registry.skills.find((skill) => skill.id === 'models')?.parents).toEqual([
|
||||
'agents',
|
||||
]);
|
||||
await expect(withoutOwner.loadSkill('models')).resolves.toMatchObject({ id: 'models' });
|
||||
|
||||
const withoutBoth = filterRuntimeSkillSource(source, ['agents', 'builder']);
|
||||
expect(withoutBoth.registry.skills).toEqual([]);
|
||||
await expect(withoutBoth.loadSkill('models')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('hides a filtered reference from its parent listing', async () => {
|
||||
writeBuilderWithReferences();
|
||||
const filtered = filterRuntimeSkillSource(loadRuntimeSkillSourceFromDirectory(root), [
|
||||
'models',
|
||||
]);
|
||||
|
||||
const loadTool = createSkillLoadTool(filtered);
|
||||
const builder = skillLoadText(await loadTool.handler?.({ skillId: 'builder' }, {}));
|
||||
expect(builder).not.toContain('[References');
|
||||
expect(builder).not.toContain('references/models.md');
|
||||
await expect(filtered.loadFile?.('builder', 'references/models.md')).resolves.toBeNull();
|
||||
await expect(
|
||||
loadTool.handler?.({ skillId: 'builder', filePath: 'references/models.md' }, {}),
|
||||
).resolves.toMatchObject({ success: false });
|
||||
});
|
||||
|
||||
it('hides a reference excluded by the directory loader from its owner file path', async () => {
|
||||
writeBuilderWithReferences();
|
||||
const source = loadRuntimeSkillSourceFromDirectory(root, { exclude: ['models'] });
|
||||
|
||||
expect(source.registry.skills.map((skill) => skill.id)).not.toContain('models');
|
||||
await expect(source.loadSkill('models')).resolves.toBeNull();
|
||||
await expect(source.loadFile?.('builder', 'references/models.md')).resolves.toBeNull();
|
||||
await expect(source.loadFile?.('builder', 'references/notes.md')).resolves.toMatchObject({
|
||||
content: 'Plain notes.',
|
||||
});
|
||||
|
||||
const loadTool = createSkillLoadTool(source);
|
||||
const builder = skillLoadText(await loadTool.handler?.({ skillId: 'builder' }, {}));
|
||||
expect(builder).not.toContain('references/models.md');
|
||||
await expect(
|
||||
loadTool.handler?.({ skillId: 'builder', filePath: 'references/models.md' }, {}),
|
||||
).resolves.toMatchObject({ success: false });
|
||||
});
|
||||
|
||||
it('hides references excluded by the directory loader when all their parents are excluded', async () => {
|
||||
writeBuilderWithReferences();
|
||||
const source = loadRuntimeSkillSourceFromDirectory(root, { exclude: ['builder', 'agents'] });
|
||||
|
||||
expect(source.registry.skills).toEqual([]);
|
||||
await expect(source.loadSkill('models')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a shared reference when the directory loader excludes its owner', async () => {
|
||||
writeBuilderWithReferences();
|
||||
const source = loadRuntimeSkillSourceFromDirectory(root, { exclude: ['builder'] });
|
||||
|
||||
expect(source.registry.skills.find((skill) => skill.id === 'models')?.parents).toEqual([
|
||||
'agents',
|
||||
]);
|
||||
await expect(source.loadSkill('models')).resolves.toMatchObject({ parents: ['agents'] });
|
||||
|
||||
const loadTool = createSkillLoadTool(source);
|
||||
const agents = skillLoadText(await loadTool.handler?.({ skillId: 'agents' }, {}));
|
||||
expect(agents).toContain('- "models": "Load before choosing a model."');
|
||||
});
|
||||
|
||||
it('rejects references with a name that does not match the file', () => {
|
||||
writeSkill('builder', 'name: builder\ndescription: Build workflows.', 'Build steps.');
|
||||
writeReference('builder', 'models.md', 'name: other\ndescription: Wrong name.', 'Body.');
|
||||
|
||||
expect(() => loadRuntimeSkillSourceFromDirectory(root)).toThrow(
|
||||
'Reference at builder/references/models.md must be named "models", got "other"',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects references without a description', () => {
|
||||
writeSkill('builder', 'name: builder\ndescription: Build workflows.', 'Build steps.');
|
||||
writeReference('builder', 'models.md', 'name: models', 'Body.');
|
||||
|
||||
expect(() => loadRuntimeSkillSourceFromDirectory(root)).toThrow(InvalidRuntimeSkillError);
|
||||
});
|
||||
|
||||
it('rejects shared references that do not exist', () => {
|
||||
writeSkill(
|
||||
'agents',
|
||||
'name: agents\ndescription: Build agents.\nshared_references:\n - missing',
|
||||
'Agent steps.',
|
||||
);
|
||||
|
||||
expect(() => loadRuntimeSkillSourceFromDirectory(root)).toThrow(
|
||||
'Skill "agents" shares unknown reference "missing"',
|
||||
);
|
||||
});
|
||||
|
||||
describe('in-memory reference metadata', () => {
|
||||
const owner = { id: 'builder', name: 'builder', description: 'Build.', instructions: 'Build.' };
|
||||
const reference = {
|
||||
id: 'models',
|
||||
name: 'models',
|
||||
description: 'Models.',
|
||||
instructions: 'Models.',
|
||||
};
|
||||
|
||||
it('accepts parents and reference together', () => {
|
||||
const registry = createRuntimeSkillRegistry([
|
||||
owner,
|
||||
{
|
||||
...reference,
|
||||
parents: ['builder'],
|
||||
reference: { owner: 'builder', path: 'references/models.md' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(registry.skills.find((skill) => skill.id === 'models')?.parents).toEqual(['builder']);
|
||||
});
|
||||
|
||||
it('rejects parents without a reference location', () => {
|
||||
expect(() =>
|
||||
createRuntimeSkillSource([owner, { ...reference, parents: ['builder'] }]),
|
||||
).toThrow('Reference "models" must set both parents and reference');
|
||||
});
|
||||
|
||||
it('rejects a reference location without parents', () => {
|
||||
expect(() =>
|
||||
createRuntimeSkillSource([
|
||||
owner,
|
||||
{ ...reference, reference: { owner: 'builder', path: 'references/models.md' } },
|
||||
]),
|
||||
).toThrow('Reference "models" must set both parents and reference');
|
||||
});
|
||||
|
||||
it('keeps a reference whose owner is not in the skill set while another parent is', () => {
|
||||
const source = createRuntimeSkillSource([
|
||||
{ id: 'agents', name: 'agents', description: 'Agents.', instructions: 'Agents.' },
|
||||
{
|
||||
...reference,
|
||||
parents: ['builder', 'agents'],
|
||||
reference: { owner: 'builder', path: 'references/models.md' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(source.registry.skills.find((skill) => skill.id === 'models')?.parents).toEqual([
|
||||
'agents',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export type {
|
|||
RuntimeSkillLoader,
|
||||
RuntimeSkillMcpServerDependency,
|
||||
RuntimeSkillPolicyContract,
|
||||
RuntimeSkillReferenceContract,
|
||||
RuntimeSkillReferenceLocation,
|
||||
RuntimeSkillRegistry,
|
||||
RuntimeSkillRegistryEntry,
|
||||
RuntimeSkillSource,
|
||||
|
|
|
|||
|
|
@ -8,14 +8,22 @@ export function renderSkillCatalogPrompt(
|
|||
registry: RuntimeSkillRegistry,
|
||||
options: RenderSkillCatalogOptions = {},
|
||||
): string {
|
||||
if (registry.skills.length === 0) return '';
|
||||
const topLevel = registry.skills.filter((skill) => !skill.parents);
|
||||
if (topLevel.length === 0) return '';
|
||||
const hasReferences = topLevel.length < registry.skills.length;
|
||||
|
||||
const catalog = registry.skills
|
||||
const catalog = topLevel
|
||||
.map((skill) =>
|
||||
[
|
||||
`- name: ${promptString(skill.name)}`,
|
||||
` description: ${promptString(skill.description)}`,
|
||||
` id: ${promptString(skill.id)}`,
|
||||
// Folder skills derive the id from the name, so the name adds nothing.
|
||||
// Generated ids need the name to be readable.
|
||||
...(skill.name === skill.id
|
||||
? [`- id: ${promptString(skill.id)}`, ` description: ${promptString(skill.description)}`]
|
||||
: [
|
||||
`- name: ${promptString(skill.name)}`,
|
||||
` description: ${promptString(skill.description)}`,
|
||||
` id: ${promptString(skill.id)}`,
|
||||
]),
|
||||
...(skill.category ? [` category: ${promptString(skill.category)}`] : []),
|
||||
...(skill.recommendedTools?.length
|
||||
? [` recommendedTools: ${promptStringArray(skill.recommendedTools)}`]
|
||||
|
|
@ -35,7 +43,12 @@ ${catalog}
|
|||
When deciding whether to load a skill:
|
||||
- Match the user's request against the skill name and description.
|
||||
- If one skill clearly matches, call load_skill once with \`{ "skillId": "<id>" }\`, then follow the returned instructions.
|
||||
- If a loaded skill references a supporting file, call load_skill with \`{ "skillId": "<id>", "filePath": "<relative path>" }\`.
|
||||
- If a loaded skill references a supporting file, call load_skill with \`{ "skillId": "<id>", "filePath": "<relative path>" }\`.${
|
||||
hasReferences
|
||||
? `
|
||||
- A loaded skill can list references with a description of when each applies. Load a reference with \`{ "skillId": "<reference id>" }\` only when its description matches the current step.`
|
||||
: ''
|
||||
}
|
||||
- If the relevant skill was already loaded for this request, do not call load_skill again.
|
||||
- If no skill clearly matches, do not call load_skill.
|
||||
- Do not load a skill just because it is listed here.`;
|
||||
|
|
|
|||
|
|
@ -70,8 +70,10 @@ export function filterRuntimeSkillSource(
|
|||
source: RuntimeSkillSource,
|
||||
excludeSkillIds: string[],
|
||||
): RuntimeSkillSource {
|
||||
const excluded = new Set(excludeSkillIds);
|
||||
const skills = source.registry.skills.filter((skill) => !excluded.has(skill.id));
|
||||
const { skills, excluded, hiddenFiles } = excludeRuntimeSkills(
|
||||
source.registry.skills,
|
||||
excludeSkillIds,
|
||||
);
|
||||
const { loadFile } = source;
|
||||
|
||||
return {
|
||||
|
|
@ -85,7 +87,10 @@ export function filterRuntimeSkillSource(
|
|||
...(loadFile
|
||||
? {
|
||||
loadFile: async (skillId: string, filePath: string) =>
|
||||
excluded.has(skillId) ? null : await loadFile(skillId, filePath),
|
||||
excluded.has(skillId) ||
|
||||
hiddenFiles.has(linkedFileKey(skillId, posix.normalize(filePath)))
|
||||
? null
|
||||
: await loadFile(skillId, filePath),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
|
@ -95,20 +100,20 @@ export function loadRuntimeSkillSourceFromDirectory(
|
|||
rootDir: string,
|
||||
options: LoadRuntimeSkillSourceFromDirectoryOptions = {},
|
||||
): RuntimeSkillSource {
|
||||
const excludedSkillIds = new Set(options.exclude ?? []);
|
||||
const { transformInstructions = (instructions) => instructions } = options;
|
||||
|
||||
const skills = loadRuntimeSkillsFromDirectory(rootDir)
|
||||
.filter((skill) => !excludedSkillIds.has(skill.id))
|
||||
.map((skill) => {
|
||||
return {
|
||||
...skill,
|
||||
instructions: transformInstructions(skill.instructions),
|
||||
};
|
||||
});
|
||||
// Exclude before building the source, so hashes, parents, and linked files match the result.
|
||||
const { skills } = excludeRuntimeSkills(
|
||||
resolveReferenceParents(loadRuntimeSkillsFromDirectory(rootDir)),
|
||||
options.exclude ?? [],
|
||||
);
|
||||
const transformedSkills = skills.map((skill) => ({
|
||||
...skill,
|
||||
instructions: transformInstructions(skill.instructions),
|
||||
}));
|
||||
|
||||
const source = createRuntimeSkillSource(skills);
|
||||
const skillsById = new Map(skills.map((skill) => [skill.id, skill]));
|
||||
const source = createRuntimeSkillSource(transformedSkills);
|
||||
const skillsById = new Map(transformedSkills.map((skill) => [skill.id, skill]));
|
||||
|
||||
return {
|
||||
...source,
|
||||
|
|
@ -133,10 +138,63 @@ export function loadRuntimeSkillSourceFromDirectory(
|
|||
};
|
||||
}
|
||||
|
||||
type ExcludableSkill = Pick<RuntimeSkill, 'id' | 'parents' | 'reference' | 'linkedFiles'>;
|
||||
|
||||
/**
|
||||
* Removes the excluded skills, plus references left without a parent. A hidden
|
||||
* reference's file is also removed from its owner's linked files, so it cannot
|
||||
* be read through the owner.
|
||||
*/
|
||||
function excludeRuntimeSkills<T extends ExcludableSkill>(
|
||||
allSkills: T[],
|
||||
excludeSkillIds: string[],
|
||||
): { skills: T[]; excluded: Set<string>; hiddenFiles: Set<string> } {
|
||||
const excluded = new Set(excludeSkillIds);
|
||||
if (excluded.size === 0) return { skills: allSkills, excluded, hiddenFiles: new Set() };
|
||||
|
||||
const remaining = new Set(
|
||||
allSkills.filter((skill) => !excluded.has(skill.id)).map((skill) => skill.id),
|
||||
);
|
||||
const skills = allSkills.flatMap((skill) => {
|
||||
if (excluded.has(skill.id)) return [];
|
||||
if (!skill.parents) return [skill];
|
||||
const parents = skill.parents.filter((id) => remaining.has(id));
|
||||
// A reference with no remaining parent cannot be discovered, so it is hidden too.
|
||||
if (parents.length === 0) {
|
||||
excluded.add(skill.id);
|
||||
return [];
|
||||
}
|
||||
return [parents.length === skill.parents.length ? skill : { ...skill, parents }];
|
||||
});
|
||||
const hiddenFiles = new Set(
|
||||
allSkills.flatMap((skill) =>
|
||||
skill.reference && excluded.has(skill.id)
|
||||
? [linkedFileKey(skill.reference.owner, skill.reference.path)]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
if (hiddenFiles.size === 0) return { skills, excluded, hiddenFiles };
|
||||
|
||||
const visibleSkills = skills.map((skill) =>
|
||||
skill.linkedFiles
|
||||
? {
|
||||
...skill,
|
||||
linkedFiles: {
|
||||
...skill.linkedFiles,
|
||||
references: skill.linkedFiles.references.filter(
|
||||
(file) => !hiddenFiles.has(linkedFileKey(skill.id, file.path)),
|
||||
),
|
||||
},
|
||||
}
|
||||
: skill,
|
||||
);
|
||||
return { skills: visibleSkills, excluded, hiddenFiles };
|
||||
}
|
||||
|
||||
export function loadRuntimeSkillsFromDirectory(rootDir: string): RuntimeSkill[] {
|
||||
if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) return [];
|
||||
|
||||
return collectSkillFiles(rootDir).map((skillPath) => {
|
||||
const skills = collectSkillFiles(rootDir).flatMap((skillPath) => {
|
||||
const skillDir = dirname(skillPath);
|
||||
const sourceDirectory = toPosixPath(relative(rootDir, skillDir));
|
||||
validateRuntimeSkillFolder(skillDir, skillPath, sourceDirectory);
|
||||
|
|
@ -157,10 +215,107 @@ export function loadRuntimeSkillsFromDirectory(rootDir: string): RuntimeSkill[]
|
|||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...parsed.skill,
|
||||
linkedFiles: loadLinkedFiles(skillDir),
|
||||
};
|
||||
const skill: RuntimeSkill = { ...parsed.skill, linkedFiles: loadLinkedFiles(skillDir) };
|
||||
return [skill, ...loadReferenceSkills(skill, skillDir, sourceDirectory)];
|
||||
});
|
||||
assertSharedReferencesExist(skills);
|
||||
return skills;
|
||||
}
|
||||
|
||||
const REFERENCE_SKILL_PATH = /^references\/[^/]+\.md$/;
|
||||
|
||||
function linkedFileKey(skillId: string, path: string): string {
|
||||
return `${skillId}\0${path}`;
|
||||
}
|
||||
|
||||
/** `references/*.md` files that start with frontmatter become reference skills of their owner. */
|
||||
function loadReferenceSkills(
|
||||
owner: RuntimeSkill,
|
||||
skillDir: string,
|
||||
sourceDirectory: string,
|
||||
): RuntimeSkill[] {
|
||||
return (owner.linkedFiles?.references ?? []).flatMap((file) => {
|
||||
if (!REFERENCE_SKILL_PATH.test(file.path)) return [];
|
||||
const referencePath = join(skillDir, file.path);
|
||||
const content = readFileSync(referencePath, 'utf-8');
|
||||
if (content.split(/\r?\n/, 1)[0]?.trim() !== '---') return [];
|
||||
|
||||
const location = `${sourceDirectory}/${file.path}`;
|
||||
const expectedName = posix.basename(file.path, '.md');
|
||||
const parsed = parseRuntimeSkillMarkdown(content, {
|
||||
sourceName: expectedName,
|
||||
path: toPosixPath(referencePath),
|
||||
sourcePath: toPosixPath(referencePath),
|
||||
directory: toPosixPath(skillDir),
|
||||
category: categoryFor(sourceDirectory),
|
||||
});
|
||||
if (!parsed.ok) {
|
||||
throw new InvalidRuntimeSkillError(
|
||||
`Invalid reference at ${location}: ${formatSkillValidationErrors(parsed.errors)}`,
|
||||
);
|
||||
}
|
||||
if (parsed.skill.name !== expectedName) {
|
||||
throw new InvalidRuntimeSkillError(
|
||||
`Reference at ${location} must be named "${expectedName}", got "${parsed.skill.name}"`,
|
||||
);
|
||||
}
|
||||
if (parsed.skill.sharedReferences) {
|
||||
throw new InvalidRuntimeSkillError(
|
||||
`Reference at ${location} cannot declare shared_references`,
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
...parsed.skill,
|
||||
parents: [owner.id],
|
||||
reference: { owner: owner.id, path: file.path },
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function assertSharedReferencesExist(skills: RuntimeSkill[]): void {
|
||||
const references = new Set(skills.filter((skill) => skill.reference).map((skill) => skill.id));
|
||||
for (const skill of skills) {
|
||||
for (const id of skill.sharedReferences ?? []) {
|
||||
if (!references.has(id)) {
|
||||
throw new InvalidRuntimeSkillError(`Skill "${skill.id}" shares unknown reference "${id}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds skills that share a reference to its parents, drops parents that are
|
||||
* not in the set, and drops references left without a parent.
|
||||
*/
|
||||
function resolveReferenceParents(skills: RuntimeSkill[]): RuntimeSkill[] {
|
||||
const ids = new Set(skills.map((skill) => skill.id));
|
||||
const sharers = new Map<string, string[]>();
|
||||
for (const skill of skills) {
|
||||
// The catalog hides a skill by `parents`; materialization and file lookups key on `reference`.
|
||||
if (!skill.parents !== !skill.reference) {
|
||||
throw new InvalidRuntimeSkillError(
|
||||
`Reference "${skill.id}" must set both parents and reference`,
|
||||
);
|
||||
}
|
||||
if (skill.parents && skill.sharedReferences?.length) {
|
||||
throw new InvalidRuntimeSkillError(`Reference "${skill.id}" cannot share other references`);
|
||||
}
|
||||
for (const id of skill.sharedReferences ?? []) {
|
||||
sharers.set(id, [...(sharers.get(id) ?? []), skill.id]);
|
||||
}
|
||||
}
|
||||
|
||||
return skills.flatMap((skill) => {
|
||||
const shared = sharers.get(skill.id);
|
||||
if (shared && !skill.parents) {
|
||||
throw new InvalidRuntimeSkillError(`Shared skill "${skill.id}" is not a reference`);
|
||||
}
|
||||
if (!skill.parents) return [skill];
|
||||
const parents = [...new Set([...skill.parents, ...(shared ?? [])])].filter((id) => ids.has(id));
|
||||
return parents.length > 0 ? [{ ...skill, parents }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -178,7 +333,7 @@ export function formatSkillValidationErrors(
|
|||
}
|
||||
|
||||
function normalizeRuntimeSkills(skills: RuntimeSkill[]): RuntimeSkill[] {
|
||||
const sortedSkills = [...skills].sort(compareRuntimeSkills);
|
||||
const sortedSkills = resolveReferenceParents(skills).sort(compareRuntimeSkills);
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
const seenSourceDirectories = new Set<string>();
|
||||
|
|
@ -235,6 +390,9 @@ function toRegistryEntry(skill: RuntimeSkill): RuntimeSkillRegistryEntry {
|
|||
...(skill.interface ? { interface: stableRecord(skill.interface) } : {}),
|
||||
...(skill.policy ? { policy: stableRecord(skill.policy) } : {}),
|
||||
...(skill.dependencies ? { dependencies: stableRecord(skill.dependencies) } : {}),
|
||||
...(skill.parents ? { parents: [...skill.parents] } : {}),
|
||||
...(skill.reference ? { reference: { ...skill.reference } } : {}),
|
||||
...(skill.sharedReferences ? { sharedReferences: [...skill.sharedReferences] } : {}),
|
||||
...(skill.version ? { version: skill.version } : {}),
|
||||
...(skill.license ? { license: skill.license } : {}),
|
||||
...(skill.compatibility ? { compatibility: skill.compatibility } : {}),
|
||||
|
|
@ -255,6 +413,9 @@ function hashSkill(skill: RuntimeSkill): string {
|
|||
interface: skill.interface,
|
||||
policy: skill.policy,
|
||||
dependencies: skill.dependencies,
|
||||
parents: skill.parents,
|
||||
reference: skill.reference,
|
||||
sharedReferences: skill.sharedReferences,
|
||||
version: skill.version,
|
||||
license: skill.license,
|
||||
compatibility: skill.compatibility,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { scrubSecretsInText } from '@n8n/utils/scrub-secrets';
|
||||
import { posix } from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Tool } from '../sdk/tool';
|
||||
|
|
@ -82,6 +83,7 @@ const skillLoadOutputSchema = z.object({
|
|||
bytes: z.number().optional(),
|
||||
sha256: z.string().optional(),
|
||||
activation: z.string().optional(),
|
||||
references: z.array(z.object({ skillId: z.string(), description: z.string() })).optional(),
|
||||
linkedFiles: linkedFilesSchema.optional(),
|
||||
error: z.string().optional(),
|
||||
availableSkills: z.array(z.string()).optional(),
|
||||
|
|
@ -142,9 +144,16 @@ async function loadSkill(
|
|||
},
|
||||
activate?: RuntimeSkillLoader,
|
||||
): Promise<SkillLoadOutput | SkillLoadContentOutput> {
|
||||
const { skillId, name, filePath, loadFile } = input;
|
||||
const { skillId, name, loadFile } = input;
|
||||
await source.prepare?.();
|
||||
const skillEntry = findSkillEntry(source.registry, { skillId, name });
|
||||
const requestedEntry = findSkillEntry(source.registry, { skillId, name });
|
||||
// A path to a reference file activates the reference instead of returning raw text.
|
||||
const referenceEntry =
|
||||
requestedEntry && input.filePath !== undefined
|
||||
? findReferenceByPath(source.registry, requestedEntry.id, input.filePath)
|
||||
: undefined;
|
||||
const skillEntry = referenceEntry ?? requestedEntry;
|
||||
const filePath = referenceEntry ? undefined : input.filePath;
|
||||
if (!skillEntry) {
|
||||
return {
|
||||
ok: false,
|
||||
|
|
@ -224,31 +233,53 @@ async function loadSkill(
|
|||
}
|
||||
|
||||
if (activate) {
|
||||
const { references, linkedFiles } = skillReferences(skillEntry, source.registry);
|
||||
return {
|
||||
success: true,
|
||||
skillId: skillEntry.id,
|
||||
name: skillEntry.name,
|
||||
hash: skillEntry.hash,
|
||||
content: 'The skill instructions are active. Follow them for this task.',
|
||||
linkedFiles: skillEntry.linkedFiles,
|
||||
...(references.length > 0
|
||||
? {
|
||||
references: references.map((entry) => ({
|
||||
skillId: entry.id,
|
||||
description: entry.description,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
linkedFiles,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'content',
|
||||
value: [{ type: 'text', text: formatActiveSkill(skill, skillEntry) }],
|
||||
value: [{ type: 'text', text: formatActiveSkill(skill, skillEntry, source.registry) }],
|
||||
};
|
||||
}
|
||||
|
||||
export function formatActiveSkill(
|
||||
skill: RuntimeSkillContent,
|
||||
skillEntry: RuntimeSkillRegistryEntry,
|
||||
registry?: RuntimeSkillRegistry,
|
||||
): string {
|
||||
const content = cap(skill.instructions);
|
||||
const linkedFilePaths = LINKED_FILE_GROUPS.flatMap((group) => skillEntry.linkedFiles[group]).map(
|
||||
const { references, linkedFiles } = skillReferences(skillEntry, registry);
|
||||
const linkedFilePaths = LINKED_FILE_GROUPS.flatMap((group) => linkedFiles[group]).map(
|
||||
(file) => file.path,
|
||||
);
|
||||
const header = [
|
||||
activationEnvelope(skillEntry),
|
||||
...(references.length > 0
|
||||
? [
|
||||
[
|
||||
'[References — load one via load_skill with { "skillId": "<id>" } only when its description matches the current step:',
|
||||
...references.map(
|
||||
(entry) => `- ${envelopeValue(entry.id)}: ${envelopeValue(entry.description)}`,
|
||||
),
|
||||
']',
|
||||
].join('\n'),
|
||||
]
|
||||
: []),
|
||||
...(linkedFilePaths.length > 0
|
||||
? [
|
||||
`[Linked files — load via load_skill with filePath: ${linkedFilePaths.map(envelopeValue).join(', ')}]`,
|
||||
|
|
@ -258,6 +289,43 @@ export function formatActiveSkill(
|
|||
return `${header.join('\n')}\n\n${content}`;
|
||||
}
|
||||
|
||||
/** A skill's references, and its linked files without the files those references come from. */
|
||||
function skillReferences(
|
||||
skillEntry: RuntimeSkillRegistryEntry,
|
||||
registry: RuntimeSkillRegistry | undefined,
|
||||
): { references: RuntimeSkillRegistryEntry[]; linkedFiles: RuntimeSkillLinkedFiles } {
|
||||
const references = (registry?.skills ?? []).filter((entry) =>
|
||||
entry.parents?.includes(skillEntry.id),
|
||||
);
|
||||
const referencePaths = new Set(
|
||||
references.flatMap((entry) =>
|
||||
entry.reference?.owner === skillEntry.id ? [entry.reference.path] : [],
|
||||
),
|
||||
);
|
||||
if (referencePaths.size === 0) return { references, linkedFiles: skillEntry.linkedFiles };
|
||||
return {
|
||||
references,
|
||||
linkedFiles: {
|
||||
...skillEntry.linkedFiles,
|
||||
references: skillEntry.linkedFiles.references.filter(
|
||||
(file) => !referencePaths.has(file.path),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The reference skill that a parent's linked file path points to, if any. */
|
||||
export function findReferenceByPath(
|
||||
registry: RuntimeSkillRegistry,
|
||||
ownerId: string,
|
||||
filePath: string,
|
||||
): RuntimeSkillRegistryEntry | undefined {
|
||||
const normalized = posix.normalize(filePath.trim());
|
||||
return registry.skills.find(
|
||||
(entry) => entry.reference?.owner === ownerId && entry.reference.path === normalized,
|
||||
);
|
||||
}
|
||||
|
||||
function findSkillEntry(
|
||||
registry: RuntimeSkillRegistry,
|
||||
input: { skillId?: string; name?: string },
|
||||
|
|
@ -277,6 +345,9 @@ function findSkillEntry(
|
|||
function activationEnvelope(skill: RuntimeSkillRegistryEntry): string {
|
||||
return [
|
||||
`[Skill: ${envelopeValue(skill.name)}]`,
|
||||
...(skill.parents?.length
|
||||
? [`[Reference of: ${skill.parents.map(envelopeValue).join(', ')}]`]
|
||||
: []),
|
||||
...(skill.category ? [`[Skill category: ${envelopeValue(skill.category)}]`] : []),
|
||||
...(skill.directory ? [`[Skill directory: ${envelopeValue(skill.directory)}]`] : []),
|
||||
...((skill.path ?? skill.sourcePath)
|
||||
|
|
|
|||
|
|
@ -68,7 +68,26 @@ export interface RuntimeSkillDependenciesContract {
|
|||
mcpServers?: RuntimeSkillMcpServerDependency[];
|
||||
}
|
||||
|
||||
export interface RuntimeSkill extends RuntimeSkillIndexEntry {
|
||||
/** Where a reference skill's file lives: a path relative to the owning skill's directory. */
|
||||
export interface RuntimeSkillReferenceLocation {
|
||||
owner: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference skill is a `references/*.md` file with skill frontmatter. It is
|
||||
* hidden from the catalog and listed, with its description, when a parent
|
||||
* skill loads. It activates like any other skill.
|
||||
*/
|
||||
export interface RuntimeSkillReferenceContract {
|
||||
/** Skills that list this reference. The owner comes first. */
|
||||
parents?: string[];
|
||||
reference?: RuntimeSkillReferenceLocation;
|
||||
/** Ids of references owned by other skills that this skill also lists. */
|
||||
sharedReferences?: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeSkill extends RuntimeSkillIndexEntry, RuntimeSkillReferenceContract {
|
||||
id: string;
|
||||
instructions: string;
|
||||
sourceName?: string;
|
||||
|
|
@ -89,7 +108,9 @@ export interface RuntimeSkill extends RuntimeSkillIndexEntry {
|
|||
linkedFiles?: RuntimeSkillLinkedFiles;
|
||||
}
|
||||
|
||||
export interface RuntimeSkillRegistryEntry extends RuntimeSkillIndexEntry {
|
||||
export interface RuntimeSkillRegistryEntry
|
||||
extends RuntimeSkillIndexEntry,
|
||||
RuntimeSkillReferenceContract {
|
||||
id: string;
|
||||
hash: string;
|
||||
sourceName?: string;
|
||||
|
|
@ -116,7 +137,7 @@ export interface RuntimeSkillRegistry {
|
|||
skills: RuntimeSkillRegistryEntry[];
|
||||
}
|
||||
|
||||
export interface RuntimeSkillContent extends RuntimeSkillIndexEntry {
|
||||
export interface RuntimeSkillContent extends RuntimeSkillIndexEntry, RuntimeSkillReferenceContract {
|
||||
id: string;
|
||||
instructions: string;
|
||||
sourceName?: string;
|
||||
|
|
@ -162,6 +183,12 @@ export type RuntimeSkillFileLoader = (
|
|||
|
||||
export interface RuntimeSkillSource {
|
||||
registry: RuntimeSkillRegistry;
|
||||
/**
|
||||
* Lazy setup that runs before a skill is loaded, not when the agent is built.
|
||||
* It must not change catalog fields (id, name, description, category,
|
||||
* recommendedTools), because the catalog is rendered before it runs.
|
||||
* Only load_skill calls it, so loadSkill and loadFile must not depend on it.
|
||||
*/
|
||||
prepare?: () => Promise<void>;
|
||||
loadSkill: RuntimeSkillLoader;
|
||||
loadFile?: RuntimeSkillFileLoader;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const RUNTIME_SKILL_FRONTMATTER_FIELDS = [
|
|||
'interface',
|
||||
'policy',
|
||||
'dependencies',
|
||||
'shared_references',
|
||||
'platforms',
|
||||
'version',
|
||||
'license',
|
||||
|
|
@ -82,6 +83,7 @@ export function parseRuntimeSkillMarkdown(
|
|||
const skillInterface = optionalSkillInterface(frontmatter.data, errors);
|
||||
const policy = optionalSkillPolicy(frontmatter.data, errors);
|
||||
const dependencies = optionalSkillDependencies(frontmatter.data, errors);
|
||||
const sharedReferences = optionalStringArray(frontmatter.data, 'shared_references', errors);
|
||||
const platforms = optionalStringArray(frontmatter.data, 'platforms', errors)?.map((platform) =>
|
||||
platform.toLowerCase(),
|
||||
);
|
||||
|
|
@ -108,6 +110,7 @@ export function parseRuntimeSkillMarkdown(
|
|||
...(skillInterface ? { interface: skillInterface } : {}),
|
||||
...(policy ? { policy } : {}),
|
||||
...(dependencies ? { dependencies } : {}),
|
||||
...(sharedReferences?.length ? { sharedReferences } : {}),
|
||||
...(version ? { version } : {}),
|
||||
...(license ? { license } : {}),
|
||||
...(compatibility ? { compatibility } : {}),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-node-sdk",
|
||||
"version": "0.32.0",
|
||||
"version": "0.33.0",
|
||||
"description": "SDK for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-utilities",
|
||||
"version": "0.35.0",
|
||||
"version": "0.36.0",
|
||||
"description": "Utilities for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
|||
import type { Mock, Mocked } from 'vitest';
|
||||
|
||||
import * as logAiEventModule from 'src/utils/log-ai-event';
|
||||
import { N8nLlmTracing } from 'src/utils/n8n-llm-tracing';
|
||||
import { anthropicTokensUsageParser, N8nLlmTracing } from 'src/utils/n8n-llm-tracing';
|
||||
import * as tokenEstimatorModule from 'src/utils/tokenizer/token-estimator';
|
||||
|
||||
// Mock the dependencies
|
||||
|
|
@ -1012,3 +1012,39 @@ describe('N8nLlmTracing', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('anthropicTokensUsageParser', () => {
|
||||
const result = (usage?: Record<string, number>): LLMResult => ({
|
||||
generations: [],
|
||||
llmOutput: usage ? { usage } : {},
|
||||
});
|
||||
|
||||
it('should count cache creation and cache read tokens as prompt tokens', () => {
|
||||
expect(
|
||||
anthropicTokensUsageParser(
|
||||
result({
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cache_creation_input_tokens: 3,
|
||||
cache_read_input_tokens: 2,
|
||||
}),
|
||||
),
|
||||
).toEqual({ promptTokens: 15, completionTokens: 5, totalTokens: 20 });
|
||||
});
|
||||
|
||||
it('should work without cache fields', () => {
|
||||
expect(anthropicTokensUsageParser(result({ input_tokens: 10, output_tokens: 5 }))).toEqual({
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return zeros when usage is missing', () => {
|
||||
expect(anthropicTokensUsageParser(result())).toEqual({
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export { getMetadataFiltersValues, hasLongSequentialRepeat } from './utils/helpe
|
|||
export { N8nBinaryLoader } from './utils/n8n-binary-loader';
|
||||
export { N8nJsonLoader } from './utils/n8n-json-loader';
|
||||
export { N8nPdfLoader } from './utils/loaders/n8n-pdf-loader';
|
||||
export { N8nLlmTracing } from './utils/n8n-llm-tracing';
|
||||
export { N8nLlmTracing, anthropicTokensUsageParser } from './utils/n8n-llm-tracing';
|
||||
export { redactHeaderValues } from './utils/redact-headers';
|
||||
export {
|
||||
TextEditorDocument,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,27 @@ type ProviderTokenUsageResult = TokenUsageResult & {
|
|||
|
||||
type TokensUsageParser = (result: LLMResult) => TokenUsageResult;
|
||||
|
||||
/** Anthropic Messages usage. Cached input tokens count as prompt tokens. */
|
||||
export function anthropicTokensUsageParser(result: LLMResult): TokenUsageResult {
|
||||
const usage = (result?.llmOutput?.usage as
|
||||
| {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
}
|
||||
| undefined) ?? { input_tokens: 0, output_tokens: 0 };
|
||||
const promptTokens =
|
||||
usage.input_tokens +
|
||||
(usage.cache_creation_input_tokens ?? 0) +
|
||||
(usage.cache_read_input_tokens ?? 0);
|
||||
return {
|
||||
completionTokens: usage.output_tokens,
|
||||
promptTokens,
|
||||
totalTokens: promptTokens + usage.output_tokens,
|
||||
};
|
||||
}
|
||||
|
||||
type RunDetail = {
|
||||
index: number;
|
||||
messages: BaseMessage[] | string[] | string;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-workflow-builder",
|
||||
"version": "1.42.0",
|
||||
"version": "1.43.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "1.42.0",
|
||||
"version": "1.43.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export const AGENT_BUILDER_HIDDEN_AVAILABLE_TOOL_NODE_TYPES: readonly string[] =
|
|||
'@n8n/n8n-nodes-langchain.toolHttpRequest',
|
||||
// Agents call workflows via native workflow tools, not the sub-workflow node
|
||||
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
||||
// Agents call other agents as native sub-agents, not through the workflow node
|
||||
'n8n-nodes-base.messageAnAgentTool',
|
||||
// Reasoning helpers an agent does not need
|
||||
...AGENT_BUILDER_AI_UTILITY_TOOL_NODE_TYPES,
|
||||
// Provider nodes: an agent already has its own model
|
||||
|
|
|
|||
|
|
@ -659,18 +659,21 @@ describe('formatAgentConfigZodError', () => {
|
|||
});
|
||||
|
||||
describe('WorkflowToolJsonConfigSchema — inputs', () => {
|
||||
it('accepts AI and fixed input bindings on a workflow tool', () => {
|
||||
it('round-trips workflow input bindings', () => {
|
||||
const inputs = {
|
||||
chatId: { mode: 'ai' },
|
||||
query: { mode: 'ai', description: 'Input guidance' },
|
||||
shoppingListId: { mode: 'fixed', value: 'OySx3QNU0BcCs8yz' },
|
||||
label: { mode: 'fixed', value: '={{ literal }}' },
|
||||
count: { mode: 'expression', value: '={{ 1 + 2 }}' },
|
||||
};
|
||||
const result = AgentJsonConfigSchema.safeParse({
|
||||
...minimalConfig,
|
||||
tools: [
|
||||
{
|
||||
type: 'workflow',
|
||||
workflow: 'Show Shopping List',
|
||||
inputs: {
|
||||
chatId: { mode: 'ai' },
|
||||
shoppingListId: { mode: 'fixed', value: 'OySx3QNU0BcCs8yz' },
|
||||
botName: { mode: 'fixed', value: 'Jarvis' },
|
||||
},
|
||||
inputs,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -679,16 +682,17 @@ describe('WorkflowToolJsonConfigSchema — inputs', () => {
|
|||
if (result.success) {
|
||||
expect(result.data.tools?.[0]).toMatchObject({
|
||||
type: 'workflow',
|
||||
inputs: {
|
||||
chatId: { mode: 'ai' },
|
||||
shoppingListId: { mode: 'fixed', value: 'OySx3QNU0BcCs8yz' },
|
||||
botName: { mode: 'fixed', value: 'Jarvis' },
|
||||
},
|
||||
inputs,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a fixed binding without a value', () => {
|
||||
it.each([
|
||||
{ mode: 'fixed' },
|
||||
{ mode: 'expression' },
|
||||
{ mode: 'expression', value: '{{ 1 }}' },
|
||||
{ mode: 'expression', value: '= ' },
|
||||
])('rejects an invalid binding: %j', (binding) => {
|
||||
const result = AgentJsonConfigSchema.safeParse({
|
||||
...minimalConfig,
|
||||
tools: [
|
||||
|
|
@ -696,7 +700,7 @@ describe('WorkflowToolJsonConfigSchema — inputs', () => {
|
|||
type: 'workflow',
|
||||
workflow: 'Show Shopping List',
|
||||
inputs: {
|
||||
botName: { mode: 'fixed' },
|
||||
botName: binding,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { ListN8nChatThreadsQueryDto } from '../dto';
|
||||
|
||||
describe('ListN8nChatThreadsQueryDto', () => {
|
||||
it('accepts an ISO datetime cursor', () => {
|
||||
const result = ListN8nChatThreadsQueryDto.safeParse({ cursor: '2025-01-02T00:00:00.000Z' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ cursor: '2025-01-02T00:00:00.000Z' });
|
||||
});
|
||||
|
||||
it('rejects a malformed cursor', () => {
|
||||
const result = ListN8nChatThreadsQueryDto.safeParse({ cursor: 'not-a-date' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts an absent cursor', () => {
|
||||
const result = ListN8nChatThreadsQueryDto.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('accepts an agentId', () => {
|
||||
const result = ListN8nChatThreadsQueryDto.safeParse({ agentId: 'agent-1' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ agentId: 'agent-1' });
|
||||
});
|
||||
|
||||
it('accepts a string limit', () => {
|
||||
const result = ListN8nChatThreadsQueryDto.safeParse({ limit: '50' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ limit: '50' });
|
||||
});
|
||||
|
||||
it('rejects a number limit', () => {
|
||||
const result = ListN8nChatThreadsQueryDto.safeParse({ limit: 50 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -104,6 +104,9 @@ export type AgentDiscordIntegrationSettings = z.infer<typeof AgentDiscordSetting
|
|||
export const AgentLinearSettingsSchema = AgentSessionOnlySettingsSchema;
|
||||
export type AgentLinearIntegrationSettings = z.infer<typeof AgentLinearSettingsSchema>;
|
||||
|
||||
export const AgentWhatsAppSettingsSchema = AgentSessionOnlySettingsSchema;
|
||||
export type AgentWhatsAppIntegrationSettings = z.infer<typeof AgentWhatsAppSettingsSchema>;
|
||||
|
||||
/**
|
||||
* Where the Teams app may be used, and how much it may read there.
|
||||
*
|
||||
|
|
@ -169,6 +172,7 @@ export const AgentIntegrationSettingsSchema = z.union([
|
|||
AgentSlackSettingsSchema,
|
||||
AgentDiscordSettingsSchema,
|
||||
AgentLinearSettingsSchema,
|
||||
AgentWhatsAppSettingsSchema,
|
||||
// Teams was left out while it aliased the shared shape, when the entry was a
|
||||
// no-op. Its settings now differ, so leaving it out would reject them.
|
||||
AgentTeamsSettingsSchema,
|
||||
|
|
@ -191,6 +195,9 @@ const credentialIntegrations = [
|
|||
createCredIntegrationSchema('discord', AgentDiscordSettingsSchema).extend({
|
||||
settings: AgentDiscordSettingsSchema.optional(),
|
||||
}),
|
||||
createCredIntegrationSchema('whatsapp', AgentWhatsAppSettingsSchema).extend({
|
||||
settings: AgentWhatsAppSettingsSchema.optional(),
|
||||
}),
|
||||
createCredIntegrationSchema('teams', AgentTeamsSettingsSchema).extend({
|
||||
settings: AgentTeamsSettingsSchema.optional(),
|
||||
}),
|
||||
|
|
@ -209,6 +216,9 @@ const draftCredentialIntegrations = [
|
|||
createDraftCredIntegrationSchema('discord', AgentDiscordSettingsSchema).extend({
|
||||
settings: AgentDiscordSettingsSchema.optional(),
|
||||
}),
|
||||
createDraftCredIntegrationSchema('whatsapp', AgentWhatsAppSettingsSchema).extend({
|
||||
settings: AgentWhatsAppSettingsSchema.optional(),
|
||||
}),
|
||||
createDraftCredIntegrationSchema('teams', AgentTeamsSettingsSchema).extend({
|
||||
settings: AgentTeamsSettingsSchema.optional(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -378,11 +378,12 @@ const CustomToolJsonConfigSchema = z.object({
|
|||
|
||||
/**
|
||||
* Per-field binding for a workflow tool's Execute Workflow Trigger inputs.
|
||||
* - `ai`: field is advertised to the LLM and must be supplied at call time.
|
||||
* - `ai`: field is advertised to the LLM with optional input guidance.
|
||||
* - `fixed`: field is omitted from the LLM schema and injected at invoke time.
|
||||
* - `expression`: field is omitted from the LLM schema and resolved at invoke time.
|
||||
*/
|
||||
export const WorkflowToolInputFieldSchema = z.discriminatedUnion('mode', [
|
||||
z.object({ mode: z.literal('ai') }).strict(),
|
||||
z.object({ mode: z.literal('ai'), description: z.string().optional() }).strict(),
|
||||
z
|
||||
.object({
|
||||
mode: z.literal('fixed'),
|
||||
|
|
@ -390,6 +391,12 @@ export const WorkflowToolInputFieldSchema = z.discriminatedUnion('mode', [
|
|||
value: jsonValueSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
mode: z.literal('expression'),
|
||||
value: z.string().regex(/^=\s*\S/, 'Enter an expression that starts with ='),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
export const WorkflowToolJsonConfigSchema = z
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ export const AGENTS_LIST_SORT_OPTIONS = [
|
|||
'createdAt:desc',
|
||||
'updatedAt:asc',
|
||||
'updatedAt:desc',
|
||||
// Ranks by the requesting user's n8n Chat thread count per agent. Only the
|
||||
// chat-filtered list supplies usage counts; other consumers of this sort
|
||||
// option fall back to createdAt desc.
|
||||
'usage:desc',
|
||||
] as const;
|
||||
|
||||
export const AGENT_SESSION_STATUSES = [
|
||||
|
|
@ -50,6 +54,7 @@ export const AGENT_SESSION_ORIGINS = [
|
|||
'telegram',
|
||||
'linear',
|
||||
'discord',
|
||||
'whatsapp',
|
||||
'teams',
|
||||
] as const;
|
||||
|
||||
|
|
@ -114,6 +119,15 @@ export type AgentSessionQueryFilters = Pick<
|
|||
'status' | 'origin' | 'scope' | 'updatedAfter' | 'updatedBefore' | 'previewOnly'
|
||||
>;
|
||||
|
||||
/** Cross-agent n8n Chat thread list: cursor + limit only, no status/origin/scope filters. */
|
||||
export class ListN8nChatThreadsQueryDto extends Z.class({
|
||||
// The cursor is a thread's `updatedAt` ISO string (see `paginateByUpdatedAt`).
|
||||
cursor: z.string().datetime().optional(),
|
||||
limit: z.string().optional(),
|
||||
/** Filters threads to one agent. */
|
||||
agentId: z.string().min(1).max(128).optional(),
|
||||
}) {}
|
||||
|
||||
export class AgentProviderModelsQueryDto extends Z.class({
|
||||
credentialId: z.string().min(1).max(64).optional(),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -192,6 +192,15 @@ export interface AgentIntegrationConnectResponse {
|
|||
status: Extract<AgentChannelRuntimeStatus, 'configured' | 'connected'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* WhatsApp needs the user to paste this token by hand into the Meta app's
|
||||
* webhook config, unlike other platforms' secrets — so it's exposed here for
|
||||
* the setup screen to display next to the webhook URL.
|
||||
*/
|
||||
export interface AgentWhatsAppVerifyTokenResponse {
|
||||
verifyToken: string;
|
||||
}
|
||||
|
||||
export interface AgentSkillReference {
|
||||
path: string;
|
||||
content: string;
|
||||
|
|
@ -413,13 +422,16 @@ export interface AgentChatMessagesResponse {
|
|||
* Deliberately narrow. Its audience holds `agent:execute` alone — a
|
||||
* `project:chatUser` can start a production chat but has no `agent:read` — so
|
||||
* the published instructions, tools and skills must not travel with the list.
|
||||
* The page needs a label, an avatar and the owning project, and nothing else.
|
||||
* The page needs a label, an avatar, the owning project, and a user-facing
|
||||
* blurb (`description`) to help a chat user pick an agent — nothing else.
|
||||
*/
|
||||
export interface AgentChatListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Icon and gradient from the published config; unset when the agent has none. */
|
||||
personalisation?: AgentPersonalisation;
|
||||
/** User-facing blurb from the published config; unset when the agent has none. */
|
||||
description?: string;
|
||||
project: { id: string; name: string };
|
||||
}
|
||||
|
||||
|
|
@ -428,6 +440,24 @@ export interface AgentChatListResponse {
|
|||
data: AgentChatListItem[];
|
||||
}
|
||||
|
||||
/** One of the user's own n8n Chat threads. `agent` is as narrow as {@link AgentChatListItem}. */
|
||||
export interface AgentN8nChatThreadSummary {
|
||||
id: string;
|
||||
title: string | null;
|
||||
updatedAt: string;
|
||||
agent: {
|
||||
id: string;
|
||||
name: string;
|
||||
personalisation?: AgentPersonalisation;
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentN8nChatThreadsResponse {
|
||||
data: AgentN8nChatThreadSummary[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface AgentSessionLangSmithExportResponse {
|
||||
traceId: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,4 +56,4 @@ export type ApiKeyList = {
|
|||
owners: ApiKeyOwnerSummary[];
|
||||
};
|
||||
|
||||
export type ApiKeyAudience = 'public-api' | 'mcp-server-api';
|
||||
export type ApiKeyAudience = 'public-api' | 'mcp-server-api' | 'scim-api';
|
||||
|
|
|
|||
|
|
@ -40,12 +40,12 @@ export const LLM_JUDGE_PROVIDERS: LlmJudgeProvider[] = [
|
|||
},
|
||||
{
|
||||
nodeType: '@n8n/n8n-nodes-langchain.lmChatAzureOpenAi',
|
||||
displayName: 'Azure AI Foundry Chat Model',
|
||||
displayName: 'Microsoft Foundry Chat Model',
|
||||
credentialTypes: [
|
||||
{ name: 'azureOpenAiApi', displayName: 'Azure OpenAI' },
|
||||
{ name: 'azureOpenAiApi', displayName: 'Microsoft Foundry (API Key)' },
|
||||
{
|
||||
name: 'azureEntraCognitiveServicesOAuth2Api',
|
||||
displayName: 'Azure Entra (Cognitive Services)',
|
||||
displayName: 'Microsoft Foundry (Entra ID)',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -334,12 +334,14 @@ export {
|
|||
export { TestDestinationQueryDto } from './log-streaming/test-destination-query.dto';
|
||||
export { DeleteDestinationQueryDto } from './log-streaming/delete-destination-query.dto';
|
||||
export {
|
||||
LogStreamingDestinationListPublicDto,
|
||||
LogStreamingDestinationPublicDto,
|
||||
LogStreamingEventTypesPublicDto,
|
||||
PublicCreateDestinationDto,
|
||||
PublicDestinationResponseDto,
|
||||
type LogStreamingDestinationPublic,
|
||||
type PublicCreateDestination,
|
||||
type PublicDestinationType,
|
||||
} from './log-streaming/public-destination.dto';
|
||||
export { LogStreamingEventTypesPublicDto } from './log-streaming/log-streaming-public.dto';
|
||||
} from './log-streaming/log-streaming-public.dto';
|
||||
|
||||
export { PaginationDto, MAX_ITEMS_PER_PAGE } from './pagination/pagination.dto';
|
||||
|
||||
|
|
@ -524,7 +526,9 @@ export {
|
|||
workflowVersionListItemPublicSchema,
|
||||
} from './workflow-history/workflow-version-history-public.dto';
|
||||
export {
|
||||
DeprecatedWorkflowVersionPublicDto,
|
||||
WorkflowVersionPublicDto,
|
||||
deprecatedWorkflowVersionPublicSchema,
|
||||
workflowVersionPublicSchema,
|
||||
} from './workflow-history/workflow-version-public.dto';
|
||||
|
||||
|
|
@ -730,3 +734,5 @@ export {
|
|||
promotePackageResultSchema,
|
||||
promotionGitResultSchema,
|
||||
} from './promotions/promotion-operations.dto';
|
||||
|
||||
export { ScimConfigPatchDto } from './scim';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { LogStreamingEventTypesPublicDto } from '../log-streaming-public.dto';
|
||||
import {
|
||||
LogStreamingDestinationPublicDto,
|
||||
LogStreamingEventTypesPublicDto,
|
||||
} from '../log-streaming-public.dto';
|
||||
|
||||
describe('LogStreamingEventTypesPublicDto', () => {
|
||||
test('accepts a list of event names', () => {
|
||||
|
|
@ -21,3 +24,21 @@ describe('LogStreamingEventTypesPublicDto', () => {
|
|||
expect(LogStreamingEventTypesPublicDto.safeParse(input).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const webhook = {
|
||||
id: '88be6560-bfb4-455c-8aa1-06971e9e5522',
|
||||
type: 'webhook',
|
||||
label: 'My destination',
|
||||
enabled: true,
|
||||
subscribedEvents: ['n8n.workflow'],
|
||||
url: 'https://example.com/n8n-events',
|
||||
};
|
||||
|
||||
describe('LogStreamingDestinationPublicDto', () => {
|
||||
test.each([
|
||||
['a missing id', { ...webhook, id: undefined }],
|
||||
['an unknown type', { ...webhook, type: 'kafka' }],
|
||||
])('rejects %s', (_, input) => {
|
||||
expect(LogStreamingDestinationPublicDto.safeParse(input).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,28 @@
|
|||
import '../../openapi-extend';
|
||||
|
||||
import {
|
||||
circuitBreakerSchema,
|
||||
MessageEventBusDestinationOptionsSchema,
|
||||
MessageEventBusDestinationSentryOptionsSchema,
|
||||
MessageEventBusDestinationSyslogOptionsSchema,
|
||||
MessageEventBusDestinationWebhookOptionsSchema,
|
||||
webhookParameterOptionsSchema,
|
||||
} from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { logStreamingEventTypesFieldDocs } from './log-streaming-public.openapi';
|
||||
import {
|
||||
logStreamingCircuitBreakerFieldDocs,
|
||||
logStreamingDestinationCommonFieldDocs,
|
||||
logStreamingDestinationDocs,
|
||||
logStreamingDestinationListFieldDocs,
|
||||
logStreamingEventTypesFieldDocs,
|
||||
logStreamingParameterFieldDocs,
|
||||
logStreamingParameterListDocs,
|
||||
logStreamingSentryFieldDocs,
|
||||
logStreamingSyslogFieldDocs,
|
||||
logStreamingWebhookFieldDocs,
|
||||
logStreamingWebhookOptionsFieldDocs,
|
||||
} from './log-streaming-public.openapi';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
const logStreamingEventTypesPublicSchema = z
|
||||
|
|
@ -16,3 +36,181 @@ export class LogStreamingEventTypesPublicDto extends Z.class(
|
|||
) {
|
||||
static schema = logStreamingEventTypesPublicSchema;
|
||||
}
|
||||
|
||||
// Only the fields the log streaming UI exposes. Credentials and auth settings stay out, so a
|
||||
// read never returns them.
|
||||
|
||||
const commonDocs = logStreamingDestinationCommonFieldDocs;
|
||||
const baseShape = MessageEventBusDestinationOptionsSchema.shape;
|
||||
const circuitBreakerShape = circuitBreakerSchema.unwrap().shape;
|
||||
|
||||
const publicCircuitBreakerSchema = z
|
||||
.object({
|
||||
maxFailures: circuitBreakerShape.maxFailures.openapi(
|
||||
logStreamingCircuitBreakerFieldDocs.maxFailures,
|
||||
),
|
||||
failureWindow: circuitBreakerShape.failureWindow.openapi(
|
||||
logStreamingCircuitBreakerFieldDocs.failureWindow,
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.openapi(commonDocs.circuitBreaker);
|
||||
|
||||
const publicCommonShape = {
|
||||
label: baseShape.label.openapi(commonDocs.label),
|
||||
enabled: baseShape.enabled.openapi(commonDocs.enabled),
|
||||
subscribedEvents: baseShape.subscribedEvents.openapi(commonDocs.subscribedEvents),
|
||||
anonymizeAuditMessages: baseShape.anonymizeAuditMessages.openapi(
|
||||
commonDocs.anonymizeAuditMessages,
|
||||
),
|
||||
circuitBreaker: publicCircuitBreakerSchema,
|
||||
};
|
||||
|
||||
const webhookShape = MessageEventBusDestinationWebhookOptionsSchema.shape;
|
||||
const webhookDocs = logStreamingWebhookFieldDocs;
|
||||
const optionsDocs = logStreamingWebhookOptionsFieldDocs;
|
||||
const webhookOptionsShape = webhookParameterOptionsSchema.unwrap().shape;
|
||||
const redirectShape = webhookOptionsShape.redirect.unwrap().shape.redirect.shape;
|
||||
const proxyShape = webhookOptionsShape.proxy.unwrap().shape.proxy.shape;
|
||||
const socketShape = webhookOptionsShape.socket.unwrap().shape;
|
||||
|
||||
const publicWebhookOptionsSchema = z
|
||||
.object({
|
||||
timeout: webhookOptionsShape.timeout.openapi(optionsDocs.timeout),
|
||||
allowUnauthorizedCerts: webhookOptionsShape.allowUnauthorizedCerts.openapi(
|
||||
optionsDocs.allowUnauthorizedCerts,
|
||||
),
|
||||
queryParameterArrays: webhookOptionsShape.queryParameterArrays.openapi(
|
||||
optionsDocs.queryParameterArrays,
|
||||
),
|
||||
redirect: z
|
||||
.object({
|
||||
redirect: z.object({
|
||||
followRedirects: redirectShape.followRedirects.openapi(optionsDocs.followRedirects),
|
||||
maxRedirects: redirectShape.maxRedirects.openapi(optionsDocs.maxRedirects),
|
||||
}),
|
||||
})
|
||||
.optional()
|
||||
.openapi(optionsDocs.redirect),
|
||||
proxy: z
|
||||
.object({
|
||||
proxy: z.object({
|
||||
protocol: proxyShape.protocol.openapi(optionsDocs.protocol),
|
||||
host: proxyShape.host.openapi(optionsDocs.host),
|
||||
port: proxyShape.port.openapi(optionsDocs.port),
|
||||
}),
|
||||
})
|
||||
.optional()
|
||||
.openapi(optionsDocs.proxy),
|
||||
socket: z
|
||||
.object({
|
||||
keepAlive: socketShape.keepAlive.openapi(optionsDocs.keepAlive),
|
||||
maxSockets: socketShape.maxSockets.openapi(optionsDocs.maxSockets),
|
||||
maxFreeSockets: socketShape.maxFreeSockets.openapi(optionsDocs.maxFreeSockets),
|
||||
})
|
||||
.optional()
|
||||
.openapi(optionsDocs.socket),
|
||||
})
|
||||
.optional()
|
||||
.openapi(webhookDocs.options);
|
||||
|
||||
const parameterShape = webhookShape.headerParameters.unwrap().shape.parameters.element.shape;
|
||||
const publicParameterListSchema = z
|
||||
.object({
|
||||
parameters: z.array(
|
||||
z.object({
|
||||
name: parameterShape.name.openapi(logStreamingParameterFieldDocs.name),
|
||||
value: parameterShape.value.openapi(logStreamingParameterFieldDocs.value),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.openapi(logStreamingParameterListDocs);
|
||||
|
||||
const publicWebhookSchema = z.object({
|
||||
...publicCommonShape,
|
||||
type: z.literal('webhook').openapi(webhookDocs.type),
|
||||
url: webhookShape.url.openapi(webhookDocs.url),
|
||||
method: webhookShape.method.openapi(webhookDocs.method),
|
||||
sendHeaders: webhookShape.sendHeaders.openapi(webhookDocs.sendHeaders),
|
||||
specifyHeaders: webhookShape.specifyHeaders.openapi(webhookDocs.specifyHeaders),
|
||||
headerParameters: publicParameterListSchema,
|
||||
jsonHeaders: webhookShape.jsonHeaders.openapi(webhookDocs.jsonHeaders),
|
||||
sendQuery: webhookShape.sendQuery.openapi(webhookDocs.sendQuery),
|
||||
specifyQuery: webhookShape.specifyQuery.openapi(webhookDocs.specifyQuery),
|
||||
queryParameters: publicParameterListSchema,
|
||||
jsonQuery: webhookShape.jsonQuery.openapi(webhookDocs.jsonQuery),
|
||||
options: publicWebhookOptionsSchema,
|
||||
});
|
||||
|
||||
const syslogShape = MessageEventBusDestinationSyslogOptionsSchema.shape;
|
||||
const syslogDocs = logStreamingSyslogFieldDocs;
|
||||
|
||||
const publicSyslogSchema = z.object({
|
||||
...publicCommonShape,
|
||||
type: z.literal('syslog').openapi(syslogDocs.type),
|
||||
host: syslogShape.host.openapi(syslogDocs.host),
|
||||
port: syslogShape.port.openapi(syslogDocs.port),
|
||||
protocol: syslogShape.protocol.openapi(syslogDocs.protocol),
|
||||
facility: syslogShape.facility.openapi(syslogDocs.facility),
|
||||
app_name: syslogShape.app_name.openapi(syslogDocs.app_name),
|
||||
tlsCa: syslogShape.tlsCa.openapi(syslogDocs.tlsCa),
|
||||
});
|
||||
|
||||
const sentryShape = MessageEventBusDestinationSentryOptionsSchema.shape;
|
||||
const sentryDocs = logStreamingSentryFieldDocs;
|
||||
|
||||
const publicSentrySchema = z.object({
|
||||
...publicCommonShape,
|
||||
type: z.literal('sentry').openapi(sentryDocs.type),
|
||||
dsn: sentryShape.dsn.openapi(sentryDocs.dsn),
|
||||
});
|
||||
|
||||
export const PublicCreateDestinationDto = z.discriminatedUnion('type', [
|
||||
publicWebhookSchema,
|
||||
publicSyslogSchema,
|
||||
publicSentrySchema,
|
||||
]);
|
||||
|
||||
export type PublicCreateDestination = z.infer<typeof PublicCreateDestinationDto>;
|
||||
|
||||
export type PublicDestinationType = PublicCreateDestination['type'];
|
||||
|
||||
const idSchema = z.string().openapi(commonDocs.id);
|
||||
|
||||
export const logStreamingDestinationPublicSchema = z
|
||||
.discriminatedUnion('type', [
|
||||
publicWebhookSchema.extend({ id: idSchema }),
|
||||
publicSyslogSchema.extend({ id: idSchema }),
|
||||
publicSentrySchema.extend({ id: idSchema }),
|
||||
])
|
||||
.openapi(logStreamingDestinationDocs);
|
||||
|
||||
export type LogStreamingDestinationPublic = z.infer<typeof logStreamingDestinationPublicSchema>;
|
||||
|
||||
/** A discriminated union has no object shape, so this cannot extend `Z.class`. */
|
||||
export class LogStreamingDestinationPublicDto {
|
||||
static schema = logStreamingDestinationPublicSchema;
|
||||
|
||||
static safeParse(data: unknown) {
|
||||
return logStreamingDestinationPublicSchema.safeParse(data);
|
||||
}
|
||||
|
||||
static parse(data: unknown): LogStreamingDestinationPublic {
|
||||
return logStreamingDestinationPublicSchema.parse(data);
|
||||
}
|
||||
}
|
||||
|
||||
const logStreamingDestinationListPublicSchema = z
|
||||
.object({
|
||||
data: z
|
||||
.array(logStreamingDestinationPublicSchema)
|
||||
.openapi(logStreamingDestinationListFieldDocs.data),
|
||||
})
|
||||
.openapi({ additionalProperties: false });
|
||||
|
||||
export class LogStreamingDestinationListPublicDto extends Z.class(
|
||||
logStreamingDestinationListPublicSchema.shape,
|
||||
) {
|
||||
static schema = logStreamingDestinationListPublicSchema;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,3 +6,121 @@ export const logStreamingEventTypesFieldDocs = {
|
|||
example: ['n8n.workflow.started', 'n8n.workflow.success', 'n8n.workflow.failed'] as string[],
|
||||
},
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
export const logStreamingDestinationDocs = {
|
||||
description:
|
||||
'A log streaming destination. The `type` field selects the variant and its type-specific fields: `webhook`, `syslog`, or `sentry`.',
|
||||
} as const satisfies ZodOpenAPIMetadata;
|
||||
|
||||
export const logStreamingDestinationListFieldDocs = {
|
||||
data: { description: 'The configured log streaming destinations.' },
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
export const logStreamingDestinationCommonFieldDocs = {
|
||||
id: {
|
||||
description:
|
||||
'Server-generated unique id of the destination. Returned in responses; not accepted in requests.',
|
||||
example: '88be6560-bfb4-455c-8aa1-06971e9e5522',
|
||||
},
|
||||
label: { description: 'Human-readable name shown in the UI.', example: 'My destination' },
|
||||
enabled: { description: 'Whether the destination currently receives events.', example: true },
|
||||
subscribedEvents: {
|
||||
description:
|
||||
'Event names (or group prefixes, e.g. `n8n.workflow`, which matches all `n8n.workflow.*` events) this destination receives. Retrieve the full list of streamable event names from `GET /settings/log-streaming/event-types`.',
|
||||
example: ['n8n.workflow', 'n8n.audit'] as string[],
|
||||
},
|
||||
anonymizeAuditMessages: {
|
||||
description: 'Whether audit message payloads are anonymized before being sent.',
|
||||
example: false,
|
||||
},
|
||||
circuitBreaker: {
|
||||
description:
|
||||
'Circuit-breaker tuning for delivery. After repeated failures the destination stops sending for a cool-down window instead of hammering an unhealthy target. All fields are optional; sensible defaults apply when omitted.',
|
||||
},
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
export const logStreamingCircuitBreakerFieldDocs = {
|
||||
maxFailures: {
|
||||
description: 'Maximum failures within the sliding window before the breaker opens.',
|
||||
},
|
||||
failureWindow: {
|
||||
description: 'Sliding window, in milliseconds, over which failures are counted.',
|
||||
},
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
export const logStreamingParameterListDocs = {
|
||||
description: 'Key-value pairs used when the matching `specify*` field is `keypair`.',
|
||||
} as const satisfies ZodOpenAPIMetadata;
|
||||
|
||||
export const logStreamingParameterFieldDocs = {
|
||||
name: { example: 'Authorization' },
|
||||
value: {
|
||||
description: 'Parameter value. Usually a string; numbers, booleans and null are also accepted.',
|
||||
example: 'Bearer <token>',
|
||||
},
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
export const logStreamingWebhookFieldDocs = {
|
||||
type: { description: 'Sends each event as an HTTP request to a URL.', example: 'webhook' },
|
||||
url: {
|
||||
description: 'Target URL that receives the event payload.',
|
||||
example: 'https://example.com/n8n-events',
|
||||
},
|
||||
method: { description: 'HTTP method used for the request.', example: 'POST' },
|
||||
sendHeaders: { description: 'Whether to attach custom headers to the request.', example: false },
|
||||
specifyHeaders: {
|
||||
description: 'How custom headers are provided — `keypair` or `json`.',
|
||||
example: 'keypair',
|
||||
},
|
||||
jsonHeaders: { description: 'Custom headers as a JSON string when `specifyHeaders` is `json`.' },
|
||||
sendQuery: {
|
||||
description: 'Whether to attach query parameters to the request.',
|
||||
example: false,
|
||||
},
|
||||
specifyQuery: { description: 'How query parameters are provided — `keypair` or `json`.' },
|
||||
jsonQuery: { description: 'Query parameters as a JSON string when `specifyQuery` is `json`.' },
|
||||
options: { description: 'Additional HTTP request options.' },
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
export const logStreamingWebhookOptionsFieldDocs = {
|
||||
timeout: { description: 'Request timeout in milliseconds.' },
|
||||
allowUnauthorizedCerts: {
|
||||
description: 'Accept self-signed or otherwise invalid TLS certificates.',
|
||||
},
|
||||
queryParameterArrays: {
|
||||
description: 'How array-valued query parameters are serialized into the query string.',
|
||||
},
|
||||
redirect: { description: 'Redirect-following behaviour.' },
|
||||
followRedirects: { description: 'Whether to follow HTTP redirects.' },
|
||||
maxRedirects: { description: 'Maximum number of redirects to follow.' },
|
||||
proxy: { description: 'Outbound proxy configuration.' },
|
||||
protocol: { description: 'Proxy protocol.' },
|
||||
host: { description: 'Proxy host, without protocol or port.' },
|
||||
port: { description: 'Proxy port.' },
|
||||
socket: { description: 'Connection socket options.' },
|
||||
keepAlive: { description: 'Whether to keep sockets open for reuse.' },
|
||||
maxSockets: { description: 'Maximum sockets per host kept open at once.' },
|
||||
maxFreeSockets: { description: 'Maximum idle sockets per host kept open.' },
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
export const logStreamingSyslogFieldDocs = {
|
||||
type: { description: 'Sends each event to a syslog server.', example: 'syslog' },
|
||||
host: { description: 'Syslog server host.', example: 'syslog.example.com' },
|
||||
port: { description: 'Syslog server port.', example: 514 },
|
||||
protocol: { description: 'Transport protocol.', example: 'udp' },
|
||||
facility: {
|
||||
description:
|
||||
'Syslog facility code (0–23, per RFC 5424). Common values: 0 Kernel, 1 User, 3 System, 13 Audit, 14 Alert, 16 Local0, 17 Local1, 18 Local2, 19 Local3, 20 Local4, 21 Local5, 22 Local6, 23 Local7.',
|
||||
example: 16,
|
||||
},
|
||||
app_name: { description: 'Application name reported in the syslog message.', example: 'n8n' },
|
||||
tlsCa: { description: 'PEM-encoded CA certificate used when `protocol` is `tls`.' },
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
export const logStreamingSentryFieldDocs = {
|
||||
type: { description: 'Sends each event to a Sentry project.', example: 'sentry' },
|
||||
dsn: {
|
||||
description: 'Sentry DSN the events are sent to.',
|
||||
example: 'https://examplePublicKey@o0.ingest.sentry.io/0',
|
||||
},
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
import {
|
||||
circuitBreakerSchema,
|
||||
MessageEventBusDestinationSentryOptionsSchema,
|
||||
MessageEventBusDestinationSyslogOptionsSchema,
|
||||
MessageEventBusDestinationWebhookOptionsSchema,
|
||||
webhookParameterOptionsSchema,
|
||||
} from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
// These schemas are scoped to the fields the log streaming UI currently supports; backend-only
|
||||
// fields (credential auth, extra circuit-breaker knobs, batch/response options, etc.) and the
|
||||
// server-generated id are excluded. Nested schemas are derived from the canonical ones so
|
||||
// validation stays in sync.
|
||||
|
||||
const publicCircuitBreakerSchema = circuitBreakerSchema
|
||||
.unwrap()
|
||||
.pick({ maxFailures: true, failureWindow: true })
|
||||
.optional();
|
||||
|
||||
const publicWebhookOptionsSchema = webhookParameterOptionsSchema
|
||||
.unwrap()
|
||||
.omit({ batch: true, response: true })
|
||||
.optional();
|
||||
|
||||
const publicWebhookSchema = MessageEventBusDestinationWebhookOptionsSchema.omit({
|
||||
__type: true,
|
||||
id: true,
|
||||
credentials: true,
|
||||
authentication: true,
|
||||
genericAuthType: true,
|
||||
nodeCredentialType: true,
|
||||
responseCodeMustMatch: true,
|
||||
expectedStatusCode: true,
|
||||
sendPayload: true,
|
||||
}).extend({
|
||||
type: z.literal('webhook'),
|
||||
options: publicWebhookOptionsSchema,
|
||||
circuitBreaker: publicCircuitBreakerSchema,
|
||||
});
|
||||
|
||||
const publicSyslogSchema = MessageEventBusDestinationSyslogOptionsSchema.omit({
|
||||
__type: true,
|
||||
id: true,
|
||||
credentials: true,
|
||||
eol: true,
|
||||
expectedStatusCode: true,
|
||||
}).extend({ type: z.literal('syslog'), circuitBreaker: publicCircuitBreakerSchema });
|
||||
|
||||
const publicSentrySchema = MessageEventBusDestinationSentryOptionsSchema.omit({
|
||||
__type: true,
|
||||
id: true,
|
||||
credentials: true,
|
||||
tracesSampleRate: true,
|
||||
sendPayload: true,
|
||||
}).extend({ type: z.literal('sentry'), circuitBreaker: publicCircuitBreakerSchema });
|
||||
|
||||
export const PublicCreateDestinationDto = z.discriminatedUnion('type', [
|
||||
publicWebhookSchema,
|
||||
publicSyslogSchema,
|
||||
publicSentrySchema,
|
||||
]);
|
||||
|
||||
// Response shape adds the server-generated `id`; parsing through it strips non-public fields.
|
||||
export const PublicDestinationResponseDto = z.discriminatedUnion('type', [
|
||||
publicWebhookSchema.extend({ id: z.string() }),
|
||||
publicSyslogSchema.extend({ id: z.string() }),
|
||||
publicSentrySchema.extend({ id: z.string() }),
|
||||
]);
|
||||
|
||||
export type PublicCreateDestination = z.infer<typeof PublicCreateDestinationDto>;
|
||||
|
||||
export type PublicDestinationType = PublicCreateDestination['type'];
|
||||
1
packages/@n8n/api-types/src/dto/scim/index.ts
Normal file
1
packages/@n8n/api-types/src/dto/scim/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ScimConfigPatchDto } from './scim-config.dto';
|
||||
6
packages/@n8n/api-types/src/dto/scim/scim-config.dto.ts
Normal file
6
packages/@n8n/api-types/src/dto/scim/scim-config.dto.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class ScimConfigPatchDto extends Z.class({
|
||||
enabled: z.boolean(),
|
||||
}) {}
|
||||
|
|
@ -7,3 +7,15 @@ export const workflowVersionPublicSchema = activeWorkflowVersionPublicSchema.omi
|
|||
});
|
||||
|
||||
export class WorkflowVersionPublicDto extends Z.class(workflowVersionPublicSchema.shape) {}
|
||||
|
||||
export const deprecatedWorkflowVersionPublicSchema = workflowVersionPublicSchema.partial({
|
||||
nodeGroups: true,
|
||||
name: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
|
||||
export class DeprecatedWorkflowVersionPublicDto extends Z.class(
|
||||
deprecatedWorkflowVersionPublicSchema.shape,
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -363,6 +363,14 @@ export type FrontendModuleSettings = {
|
|||
rotationEnabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Client settings for SCIM user-provisioning module.
|
||||
*/
|
||||
scim?: {
|
||||
/** Whether SCIM provisioning is enabled in the instance. */
|
||||
scimEnabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Client settings for Chat module.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ export {
|
|||
nodeTypePolicyIdParamSchema,
|
||||
nodeTypePolicyScopeIdParamSchema,
|
||||
folderIdParamSchema,
|
||||
logStreamingDestinationIdParamSchema,
|
||||
projectIdParamSchema,
|
||||
promotionConnectionIdParamSchema,
|
||||
promotionDirectionParamSchema,
|
||||
|
|
|
|||
|
|
@ -1721,7 +1721,10 @@ export const instanceAiThreadTabsStateSchema = z.object({
|
|||
export type InstanceAiThreadTabsState = z.infer<typeof instanceAiThreadTabsStateSchema>;
|
||||
|
||||
export interface InstanceAiThreadTabsResponse {
|
||||
/** `null` when the user has not changed the tabs of this thread yet. */
|
||||
/**
|
||||
* `null` when no tabs are stored for this thread yet. The server stores them
|
||||
* when the agent changes an artifact, and the client when the user changes a tab.
|
||||
*/
|
||||
state: InstanceAiThreadTabsState | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ export const columnIdParamSchema = z
|
|||
.regex(NANOID_REGEX, 'must match format "nanoid"')
|
||||
.openapi({ format: 'nanoid', param: { description: 'The ID of the column' } });
|
||||
export const variableIdParamSchema = stringIdParamSchema('The ID of the variable.');
|
||||
export const logStreamingDestinationIdParamSchema = stringIdParamSchema(
|
||||
'The id of the log streaming destination.',
|
||||
);
|
||||
export const nodeTypePolicyIdParamSchema = stringIdParamSchema(
|
||||
'The ID of the node type policy document.',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-common",
|
||||
"version": "1.42.0",
|
||||
"version": "1.43.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ describe('eligibleModules', () => {
|
|||
'workflow-reviews',
|
||||
'instance-ai',
|
||||
'agents',
|
||||
'scim',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -150,6 +151,7 @@ describe('eligibleModules', () => {
|
|||
'workflow-reviews',
|
||||
'instance-ai',
|
||||
'agents',
|
||||
'scim',
|
||||
'type-availability-policies',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export class ModuleRegistry {
|
|||
'workflow-reviews',
|
||||
'instance-ai',
|
||||
'agents',
|
||||
'scim',
|
||||
];
|
||||
|
||||
private readonly activeModules: string[] = [];
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export const MODULE_NAMES = [
|
|||
'type-availability-policies',
|
||||
'instance-reporting',
|
||||
'inbound-auth-core',
|
||||
'scim',
|
||||
] as const;
|
||||
|
||||
export type ModuleName = (typeof MODULE_NAMES)[number];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-network",
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-services",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-test-utils",
|
||||
"version": "1.42.0",
|
||||
"version": "1.43.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/blob-storage",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/chat-hub",
|
||||
"version": "1.35.0",
|
||||
"version": "1.36.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/cli",
|
||||
"version": "0.21.0",
|
||||
"version": "0.22.0",
|
||||
"description": "Client CLI for n8n — manage workflows, executions, and credentials from the terminal",
|
||||
"license": "LicenseRef-n8n-sustainable-use",
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/client-oauth2",
|
||||
"version": "1.24.0",
|
||||
"version": "1.25.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/codemirror-lang-sql",
|
||||
"version": "1.13.0",
|
||||
"version": "1.14.0",
|
||||
"description": "SQL + n8n expression language support for CodeMirror 6",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/codemirror-lang",
|
||||
"version": "0.12.0",
|
||||
"version": "0.13.0",
|
||||
"description": "Language support package for CodeMirror 6 in n8n",
|
||||
"sideEffects": false,
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/computer-use",
|
||||
"version": "0.25.0",
|
||||
"version": "0.26.0",
|
||||
"description": "Local AI gateway for n8n Assistant — filesystem, shell, screenshots, mouse/keyboard, and browser automation",
|
||||
"publishConfig": {
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "2.40.0",
|
||||
"version": "2.41.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
|
|||
import path from 'node:path';
|
||||
|
||||
import { Config, Env } from '../decorators';
|
||||
import { positiveIntSchema } from '../schemas';
|
||||
|
||||
@Config
|
||||
export class DataTableConfig {
|
||||
|
|
@ -32,7 +33,7 @@ export class DataTableConfig {
|
|||
uploadMaxFileSize?: number;
|
||||
|
||||
/** Interval in milliseconds between cleanup runs for orphaned upload files. Default: 1 minute. */
|
||||
@Env('N8N_DATA_TABLES_CLEANUP_INTERVAL_MS')
|
||||
@Env('N8N_DATA_TABLES_CLEANUP_INTERVAL_MS', positiveIntSchema)
|
||||
cleanupIntervalMs: number = 1 * Time.minutes.toMilliseconds;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -124,7 +124,8 @@ export class InstanceAiConfig {
|
|||
/**
|
||||
* Marks an instance that serves the Instance AI eval harness. Only then may an eval run
|
||||
* reset per-workflow state (Remove Duplicates history) around a scenario: on a normal
|
||||
* instance an eval pointed at a real workflow must leave its history alone.
|
||||
* instance an eval pointed at a real workflow must leave its history alone. An eval
|
||||
* instance also skips PostHog, so feature flags resolve from their defaults plus env overrides.
|
||||
*/
|
||||
@Env('N8N_INSTANCE_AI_EVAL_INSTANCE')
|
||||
evalInstance: boolean = false;
|
||||
|
|
|
|||
|
|
@ -276,12 +276,6 @@ export class SchedulerConfig {
|
|||
* was and the next scheduled poll covers the same ground. It counts as a poll
|
||||
* failure, so a source that keeps timing out is polled at a widening interval.
|
||||
* Guards against a poll stuck on an unresponsive source running indefinitely.
|
||||
*
|
||||
* Keep it below {@link leaseDurationSeconds}: the deadline only starts after
|
||||
* the occurrence's setup reads, so a poll allowed to run as long as the claim
|
||||
* on its run can still be in flight when that claim expires and another
|
||||
* instance takes the run over. The default leaves that headroom, and the
|
||||
* scheduler warns at startup when the timeout reaches the lease duration.
|
||||
* Must be greater than 0 and at most one day.
|
||||
*/
|
||||
@Env('N8N_SCHEDULER_POLL_TIMEOUT', positiveIntSchema.max(Time.days.toSeconds))
|
||||
|
|
|
|||
|
|
@ -1003,6 +1003,26 @@ describe('GlobalConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('data table cleanup interval config validation', () => {
|
||||
it('should accept a positive N8N_DATA_TABLES_CLEANUP_INTERVAL_MS', () => {
|
||||
process.env = { N8N_DATA_TABLES_CLEANUP_INTERVAL_MS: '30000' };
|
||||
const config = Container.get(GlobalConfig);
|
||||
expect(config.dataTable.cleanupIntervalMs).toBe(30000);
|
||||
});
|
||||
|
||||
it.each(['0', '-5', '1500.5'])(
|
||||
'should reject a data table cleanup interval of %s and fall back to the default',
|
||||
(value) => {
|
||||
process.env = { N8N_DATA_TABLES_CLEANUP_INTERVAL_MS: value };
|
||||
const config = Container.get(GlobalConfig);
|
||||
expect(config.dataTable.cleanupIntervalMs).toBe(60000);
|
||||
expect(consoleWarnMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('N8N_DATA_TABLES_CLEANUP_INTERVAL_MS'),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('workflow publication outbox cleanup config validation', () => {
|
||||
it('should reject a cleanup interval of 0 and fall back to the default', () => {
|
||||
process.env = { N8N_WORKFLOW_PUBLICATION_OUTBOX_CLEANUP_INTERVAL_SECONDS: '0' };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/constants",
|
||||
"version": "0.40.0",
|
||||
"version": "0.41.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export * from './uuid';
|
|||
export const LICENSE_FEATURES = {
|
||||
SHARING: 'feat:sharing',
|
||||
LDAP: 'feat:ldap',
|
||||
SCIM: 'feat:scim',
|
||||
TYPE_AVAILABILITY_POLICIES: 'feat:typeAvailabilityPolicies',
|
||||
SAML: 'feat:saml',
|
||||
OIDC: 'feat:oidc',
|
||||
|
|
|
|||
|
|
@ -3,3 +3,26 @@
|
|||
* These names are also reserved and cannot be installed as community packages.
|
||||
*/
|
||||
export const BUILTIN_NODES_PACKAGES = ['n8n-nodes-base', '@n8n/n8n-nodes-langchain'] as const;
|
||||
|
||||
/**
|
||||
* Built-in node types that need a backend module to run, keyed to that module.
|
||||
* The node panel and the AI builder offer them only while the module is enabled.
|
||||
* Workflows that already use them still load, and the node fails with a clear error.
|
||||
*/
|
||||
export const MODULE_GATED_NODE_TYPES = {
|
||||
'n8n-nodes-base.dataTable': 'data-table',
|
||||
'n8n-nodes-base.dataTableTool': 'data-table',
|
||||
'n8n-nodes-base.messageAnAgent': 'agents',
|
||||
'n8n-nodes-base.messageAnAgentTool': 'agents',
|
||||
} as const;
|
||||
|
||||
export type ModuleGatedNodeType = keyof typeof MODULE_GATED_NODE_TYPES;
|
||||
export type NodeGatingModule = (typeof MODULE_GATED_NODE_TYPES)[ModuleGatedNodeType];
|
||||
|
||||
export const isModuleGatedNodeType = (nodeType: string): nodeType is ModuleGatedNodeType =>
|
||||
Object.hasOwn(MODULE_GATED_NODE_TYPES, nodeType);
|
||||
|
||||
/** The module that `nodeType` needs, or `undefined` when it needs none. */
|
||||
export function getNodeGatingModule(nodeType: string): NodeGatingModule | undefined {
|
||||
return isModuleGatedNodeType(nodeType) ? MODULE_GATED_NODE_TYPES[nodeType] : undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/create-node",
|
||||
"version": "0.50.0",
|
||||
"version": "0.51.0",
|
||||
"description": "Official CLI to create new community nodes for n8n",
|
||||
"bin": {
|
||||
"create-node": "bin/create-node.cjs"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/db",
|
||||
"version": "1.42.0",
|
||||
"version": "1.43.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
||||
"gen:migration-index": "node scripts/generate-migration-index.mjs",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const table = 'trusted_source';
|
||||
|
||||
/**
|
||||
* `metadata` holds the discovered OAuth2 documents as plain JSON. `discoveryClaimToken` names the
|
||||
* discovery run that holds the lease on the row, so two instances never refresh the same source at
|
||||
* once; `discoveryClaimedAt` only tells when that lease expires.
|
||||
*/
|
||||
export class AddMetadataToTrustedSource1791276719782 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
|
||||
await addColumns(
|
||||
table,
|
||||
[
|
||||
column('metadata').text,
|
||||
column('discoveryClaimedAt').timestampTimezone(3),
|
||||
column('discoveryClaimToken').varchar(36),
|
||||
],
|
||||
{ recreatesOnSqlite: true },
|
||||
);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
|
||||
await dropColumns(table, ['metadata', 'discoveryClaimedAt', 'discoveryClaimToken'], {
|
||||
recreatesOnSqlite: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const TABLE = 'agent_chat_subscriptions';
|
||||
const COLUMN = 'integrationType';
|
||||
// Includes 'teams': the Teams agent channel's own migration
|
||||
// (AllowTeamsAgentChatSubscriptions1790231058977) already ran by the time this
|
||||
// one does and rebuilt the check constraint without 'whatsapp'. Starting from
|
||||
// the same list Teams left behind — rather than the pre-Teams list — keeps
|
||||
// both platforms in the constraint instead of this migration silently
|
||||
// dropping 'teams' the same way that one dropped 'whatsapp'.
|
||||
const VALUES_BEFORE = ['telegram', 'slack', 'linear', 'discord', 'teams'];
|
||||
const VALUES_AFTER = [...VALUES_BEFORE, 'whatsapp'];
|
||||
|
||||
export class AllowWhatsAppAgentChatSubscriptions1791276719783 implements ReversibleMigration {
|
||||
async up({ schemaBuilder }: MigrationContext) {
|
||||
await schemaBuilder.dropEnumCheck(TABLE, COLUMN, { recreatesOnSqlite: true });
|
||||
await schemaBuilder.addEnumCheck(TABLE, COLUMN, VALUES_AFTER, { recreatesOnSqlite: true });
|
||||
}
|
||||
|
||||
async down({ escape, runQuery, schemaBuilder }: MigrationContext) {
|
||||
await runQuery(
|
||||
`DELETE FROM ${escape.tableName(TABLE)} WHERE ${escape.columnName(COLUMN)} = 'whatsapp'`,
|
||||
);
|
||||
await schemaBuilder.dropEnumCheck(TABLE, COLUMN, { recreatesOnSqlite: true });
|
||||
await schemaBuilder.addEnumCheck(TABLE, COLUMN, VALUES_BEFORE, { recreatesOnSqlite: true });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { AddMetadataToTrustedSource1791276719782 as BaseMigration } from '../common/1791276719782-AddMetadataToTrustedSource';
|
||||
|
||||
/**
|
||||
* Adding and dropping the columns recreates `trusted_source` on SQLite. `trusted_source_identity`
|
||||
* references it with ON DELETE CASCADE, so the recreate's DROP would wipe the bindings. TypeORM
|
||||
* turns foreign keys off before the `up` transaction opens, but issues that pragma inside the
|
||||
* rollback transaction, where SQLite ignores it. This flag keeps the drop local on both paths.
|
||||
*/
|
||||
export class AddMetadataToTrustedSource1791276719782 extends BaseMigration {
|
||||
withFKsDisabled = true as const;
|
||||
}
|
||||
|
|
@ -493,6 +493,22 @@ export class ScheduledTaskRepository extends Repository<ScheduledTask> {
|
|||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the lease of a running claim, so the reaper leaves a handler that is
|
||||
* still working alone. Only the claiming host, at the current epoch, can renew.
|
||||
*
|
||||
* @param {HostedClaimedRef} claim the claim to renew
|
||||
* @param {number} expiresInMs the new lease expiry, in milliseconds from the database's now
|
||||
* @returns {Promise<boolean>} `true` when the lease was extended, `false` when the
|
||||
* claim is gone (reclaimed, finished or deleted)
|
||||
*/
|
||||
async renewLease(claim: HostedClaimedRef, expiresInMs: number): Promise<boolean> {
|
||||
const rowsAffected = await this.runGuardedUpdate(claim, {
|
||||
leaseExpiresAt: () => dbNowPlusMsLiteral(this.isPostgres, expiresInMs),
|
||||
});
|
||||
return rowsAffected > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp `dispatchedAt`, the effect-boundary marker, once the handler reports its
|
||||
* effect was handed off. Guarded so it only affects the row this `claim` still
|
||||
|
|
|
|||
|
|
@ -70,6 +70,23 @@ export class SettingsRepository extends BaseRepository<Settings> {
|
|||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the value, not loaded on startup, only when the key does not exist yet.
|
||||
* Returns whether the stored value is this value, so exactly one of several
|
||||
* concurrent callers with different values wins.
|
||||
*/
|
||||
async insertIfAbsent(key: string, value: string): Promise<boolean> {
|
||||
await this.manager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(Settings)
|
||||
.values({ key, value, loadOnStartup: false })
|
||||
.orIgnore()
|
||||
.execute();
|
||||
const stored = await this.findByKey(key);
|
||||
return stored?.value === value;
|
||||
}
|
||||
|
||||
async findByKeys(keys: string[]): Promise<Settings[]> {
|
||||
return await this.findBy({ key: In(keys) });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { DataSource, In, LessThan } from '@n8n/typeorm';
|
||||
import { DataSource, In, IsNull, LessThan } from '@n8n/typeorm';
|
||||
import { DiffMetaData, DiffRule, groupWorkflows, SKIP_RULES } from 'n8n-workflow';
|
||||
|
||||
import { WorkflowHistory, WorkflowEntity, WorkflowPublishedVersion } from '../entities';
|
||||
import {
|
||||
WorkflowHistory,
|
||||
WorkflowEntity,
|
||||
WorkflowPublishedVersion,
|
||||
WorkflowPublishHistory,
|
||||
} from '../entities';
|
||||
import { BaseRepository } from './base-repository';
|
||||
import { WorkflowPublishHistoryRepository } from './workflow-publish-history.repository';
|
||||
import { WorkflowReviewRequestWorkflow } from '../entities/workflow-review-request-workflow.ee';
|
||||
import { WorkflowReviewRequest } from '../entities/workflow-review-request.ee';
|
||||
import type { OperationContext } from '../services/transaction';
|
||||
|
|
@ -12,11 +16,7 @@ import { TransactionRunner } from '../services/transaction';
|
|||
|
||||
@Service()
|
||||
export class WorkflowHistoryRepository extends BaseRepository<WorkflowHistory> {
|
||||
constructor(
|
||||
dataSource: DataSource,
|
||||
private readonly workflowPublishHistoryRepository: WorkflowPublishHistoryRepository,
|
||||
transactionRunner: TransactionRunner,
|
||||
) {
|
||||
constructor(dataSource: DataSource, transactionRunner: TransactionRunner) {
|
||||
super(WorkflowHistory, dataSource.manager, transactionRunner);
|
||||
}
|
||||
|
||||
|
|
@ -150,8 +150,21 @@ export class WorkflowHistoryRepository extends BaseRepository<WorkflowHistory> {
|
|||
skipRules: DiffRule[] = [],
|
||||
metaData?: Partial<Record<keyof DiffMetaData, boolean>>,
|
||||
): Promise<{ seen: number; deleted: number }> {
|
||||
const workflows = await this.manager
|
||||
const publishedVersionSubquery = this.manager
|
||||
.createQueryBuilder()
|
||||
.subQuery()
|
||||
.select('1')
|
||||
.from(WorkflowPublishHistory, 'wph')
|
||||
.where('wph.workflowId = wh.workflowId')
|
||||
.andWhere('wph.versionId = wh.versionId')
|
||||
.getQuery();
|
||||
const { entities: workflows, raw } = await this.manager
|
||||
.createQueryBuilder(WorkflowHistory, 'wh')
|
||||
.leftJoin(WorkflowEntity, 'w', 'w.id = wh.workflowId')
|
||||
.addSelect(
|
||||
`CASE WHEN w.versionId = wh.versionId OR EXISTS ${publishedVersionSubquery} THEN 1 ELSE 0 END`,
|
||||
'isProtected',
|
||||
)
|
||||
.where('wh.workflowId = :workflowId', { workflowId })
|
||||
.andWhere('wh.createdAt <= :endDate', {
|
||||
endDate,
|
||||
|
|
@ -160,26 +173,29 @@ export class WorkflowHistoryRepository extends BaseRepository<WorkflowHistory> {
|
|||
startDate,
|
||||
})
|
||||
.orderBy('wh.createdAt', 'ASC')
|
||||
.getMany();
|
||||
.addOrderBy('wh.versionId', 'ASC')
|
||||
.getRawAndEntities<{ wh_versionId: string; isProtected: number }>();
|
||||
|
||||
// Group by workflowId
|
||||
const publishedVersions =
|
||||
await this.workflowPublishHistoryRepository.getPublishedVersions(workflowId);
|
||||
// The current version and every version that was ever published stay.
|
||||
const protectedVersions = new Set(
|
||||
raw.filter((row) => Number(row.isProtected) === 1).map((row) => row.wh_versionId),
|
||||
);
|
||||
const grouped = groupWorkflows<WorkflowHistory>(
|
||||
workflows,
|
||||
rules,
|
||||
[
|
||||
this.makeSkipActiveAndNamedVersionsRule(
|
||||
new Set(publishedVersions.map((v) => v.versionId).filter((v) => v !== null)),
|
||||
),
|
||||
this.makeSkipActiveAndNamedVersionsRule(protectedVersions),
|
||||
SKIP_RULES.skipDifferentUsers,
|
||||
...skipRules,
|
||||
],
|
||||
metaData,
|
||||
);
|
||||
|
||||
// A version named after the read above stays, like one named before it.
|
||||
const { affected } = await this.delete({
|
||||
versionId: In(grouped.removed.map((x) => x.versionId)),
|
||||
name: IsNull(),
|
||||
description: IsNull(),
|
||||
});
|
||||
return { seen: workflows.length, deleted: affected ?? grouped.removed.length };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,17 +38,6 @@ export class WorkflowPublishHistoryRepository extends Repository<WorkflowPublish
|
|||
return await repository.find({ where: { workflowId, versionId }, order: { id: 'ASC' } });
|
||||
}
|
||||
|
||||
async getPublishedVersions(
|
||||
workflowId: string,
|
||||
): Promise<Array<Pick<WorkflowPublishHistory, 'versionId'>>> {
|
||||
return await this.manager
|
||||
.createQueryBuilder(WorkflowPublishHistory, 'wph')
|
||||
.select('wph.versionId')
|
||||
.distinct(true)
|
||||
.where('wph.workflowId = :workflowId', { workflowId })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findActivatedByUserId(workflowId: string): Promise<string | undefined> {
|
||||
const record = await this.findOne({
|
||||
select: ['userId'],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/decorators",
|
||||
"version": "1.42.0",
|
||||
"version": "1.43.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const ENFORCEMENT_POINTS = [
|
|||
'workflowStart',
|
||||
'workflowTransfer',
|
||||
'credentialSave',
|
||||
'credentialTransfer',
|
||||
'credentialDecrypt',
|
||||
'contentImport',
|
||||
] as const;
|
||||
|
|
@ -76,7 +77,7 @@ export type WorkflowStartContext = {
|
|||
export type WorkflowTransferContext = {
|
||||
readonly workflow: PolicedWorkflow;
|
||||
/** The project the workflow is moving *into* — that's whose policy applies. */
|
||||
readonly targetProjectId: string | null;
|
||||
readonly targetProjectId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -105,6 +106,11 @@ export type CredentialSaveContext = {
|
|||
readonly projectId: string | null;
|
||||
};
|
||||
|
||||
export type CredentialTransferContext = {
|
||||
readonly credential: PolicedCredential & { readonly id: string };
|
||||
readonly targetProjectId: string;
|
||||
};
|
||||
|
||||
export type CredentialDecryptContext = {
|
||||
readonly credentialType: string;
|
||||
readonly credentialId: string;
|
||||
|
|
@ -217,6 +223,10 @@ export interface RegisteredPolicyCheck {
|
|||
signal: AbortSignal,
|
||||
): Promise<PolicyCheckResult>;
|
||||
onCredentialSave?(ctx: CredentialSaveContext, signal: AbortSignal): Promise<PolicyCheckResult>;
|
||||
onCredentialTransfer?(
|
||||
ctx: CredentialTransferContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<PolicyCheckResult>;
|
||||
onCredentialDecrypt?(
|
||||
ctx: CredentialDecryptContext,
|
||||
signal: AbortSignal,
|
||||
|
|
@ -242,6 +252,7 @@ export const ENFORCEMENT_POINT_METHODS: {
|
|||
workflowStart: 'onWorkflowStart',
|
||||
workflowTransfer: 'onWorkflowTransfer',
|
||||
credentialSave: 'onCredentialSave',
|
||||
credentialTransfer: 'onCredentialTransfer',
|
||||
credentialDecrypt: 'onCredentialDecrypt',
|
||||
contentImport: 'onContentImport',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export {
|
|||
export type {
|
||||
SystemTaskClass,
|
||||
SystemTaskEffects,
|
||||
SystemTaskRunContext,
|
||||
SystemTaskRunOptions,
|
||||
SystemTaskSchedule,
|
||||
} from './system-task';
|
||||
|
|
|
|||
|
|
@ -74,12 +74,18 @@ export interface SystemTask {
|
|||
readonly concurrencyLimit?: number | null;
|
||||
|
||||
/**
|
||||
* Executes one occurrence of the task. `signal` aborts when the run should
|
||||
* stop early: the instance is shutting down or, for a run on the in-memory
|
||||
* timer, leadership was lost. Honoring it is optional, but a run that
|
||||
* ignores it delays stepdown and shutdown until it settles.
|
||||
* Executes one occurrence of the task. A run may take as long as it needs.
|
||||
* `signal` aborts on shutdown, and on loss of leadership for an in-memory timer.
|
||||
* A durable run also aborts on lease loss or expiry, unless its dispatch marker is stored.
|
||||
* Ignoring the signal delays shutdown and can let another run overlap.
|
||||
* An idempotent durable run that settles after a lease abort counts as a failed
|
||||
* attempt while its claim still matches, and retries only while attempts remain.
|
||||
*/
|
||||
run(signal: AbortSignal): Promise<void>;
|
||||
run(signal: AbortSignal, context: SystemTaskRunContext): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SystemTaskRunContext {
|
||||
readonly durable: boolean;
|
||||
}
|
||||
|
||||
/** How a task's occurrences are retried and how late they may still run. */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/engine",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"description": "n8n workflow execution engine (v2)",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo compiled",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/errors",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/expression-runtime",
|
||||
"version": "0.33.0",
|
||||
"version": "0.34.0",
|
||||
"description": "Secure, isolated expression evaluation runtime for n8n",
|
||||
"main": "dist/cjs/index.js",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -68,13 +68,13 @@ describe('createDeepLazyProxy', () => {
|
|||
});
|
||||
|
||||
it('toString() returns "[object Object]"', () => {
|
||||
const p = proxy();
|
||||
const p = proxy(undefined, []);
|
||||
expect(p.toString()).toBe('[object Object]');
|
||||
expect(mocks.getValueAtPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('valueOf() returns the proxy target', () => {
|
||||
const p = proxy();
|
||||
const p = proxy(undefined, []);
|
||||
const val = p.valueOf();
|
||||
expect(typeof val).toBe('object');
|
||||
expect(val).not.toBeNull();
|
||||
|
|
@ -614,20 +614,27 @@ describe('createDeepLazyProxy', () => {
|
|||
expect(Object.keys(p)).toEqual(['name']);
|
||||
});
|
||||
|
||||
it('host data with an own __proto__ key is cached as an own property, not a prototype', () => {
|
||||
const protoValue = { polluted: true };
|
||||
mocks.getValueAtPath.mockImplementation((path: string[]) => {
|
||||
const key = path[path.length - 1];
|
||||
if (key === 'name') return 'Alice';
|
||||
if (key === '__proto__') return protoValue;
|
||||
return undefined;
|
||||
});
|
||||
const p = proxy(['user'], ['name', '__proto__']);
|
||||
p.name = 'Zed'; // triggers materialization over all keys
|
||||
expect(Object.keys(p)).toEqual(['name', '__proto__']);
|
||||
expect(p.polluted).toBeUndefined();
|
||||
expect(p['__proto__']).toBe(protoValue);
|
||||
});
|
||||
it.each(['__proto__', 'constructor', 'toString', 'hasOwnProperty', 'valueOf'])(
|
||||
'preserves the own field %s before and after a write',
|
||||
(field) => {
|
||||
const fieldValue = { label: 'Field value' };
|
||||
mocks.getValueAtPath.mockImplementation((path: string[]) => {
|
||||
const key = path[path.length - 1];
|
||||
if (key === 'name') return 'Alice';
|
||||
if (key === field) return fieldValue;
|
||||
return undefined;
|
||||
});
|
||||
const p = proxy(['user'], ['name', field]);
|
||||
expect(Object.fromEntries(Object.entries(p))).toEqual({
|
||||
name: 'Alice',
|
||||
[field]: fieldValue,
|
||||
});
|
||||
p.name = 'Zed'; // triggers materialization over all keys
|
||||
expect(Object.keys(p)).toEqual(['name', field]);
|
||||
expect(p.label).toBeUndefined();
|
||||
expect(p[field]).toBe(fieldValue);
|
||||
},
|
||||
);
|
||||
|
||||
it('write fetches each uncached key exactly once; later access stays local', () => {
|
||||
const p = writableObjectProxy();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { isKeyOf } from './utils';
|
||||
|
||||
// ============================================================================
|
||||
// Deep Lazy Proxy System
|
||||
// ============================================================================
|
||||
|
|
@ -312,7 +314,10 @@ export function createDeepLazyProxy(
|
|||
return arrayLength;
|
||||
}
|
||||
|
||||
if (prop in targetObj) {
|
||||
if (
|
||||
isKeyOf(targetObj, prop) ||
|
||||
(prop in targetObj && (isArray || !resolveObjectKeys().includes(prop)))
|
||||
) {
|
||||
return targetObj[prop];
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user