From a11777054a7072b2414955cd7ee68487e7d77be6 Mon Sep 17 00:00:00 2001 From: "n8n-assistant[bot]" <100856346+n8n-assistant[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:10:16 +0000 Subject: [PATCH 01/61] :rocket: Release 2.43.0 (#40376) Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com> --- CHANGELOG.md | 147 ++++++++++++++++++ package.json | 2 +- packages/@n8n/agents/package.json | 2 +- packages/@n8n/ai-node-sdk/package.json | 2 +- packages/@n8n/ai-utilities/package.json | 2 +- .../@n8n/ai-workflow-builder.ee/package.json | 2 +- packages/@n8n/api-types/package.json | 2 +- packages/@n8n/backend-common/package.json | 2 +- packages/@n8n/backend-network/package.json | 2 +- packages/@n8n/backend-services/package.json | 2 +- packages/@n8n/backend-test-utils/package.json | 2 +- packages/@n8n/blob-storage/package.json | 2 +- packages/@n8n/chat-hub/package.json | 2 +- packages/@n8n/cli/package.json | 2 +- packages/@n8n/client-oauth2/package.json | 2 +- .../@n8n/codemirror-lang-sql/package.json | 2 +- packages/@n8n/codemirror-lang/package.json | 2 +- packages/@n8n/computer-use/package.json | 2 +- packages/@n8n/config/package.json | 2 +- packages/@n8n/constants/package.json | 2 +- packages/@n8n/create-node/package.json | 2 +- packages/@n8n/db/package.json | 2 +- packages/@n8n/decorators/package.json | 2 +- packages/@n8n/engine/package.json | 2 +- packages/@n8n/errors/package.json | 2 +- packages/@n8n/expression-runtime/package.json | 2 +- packages/@n8n/extension-sdk/package.json | 2 +- packages/@n8n/imap/package.json | 2 +- packages/@n8n/inbound-auth/package.json | 2 +- packages/@n8n/instance-ai/package.json | 2 +- packages/@n8n/json-schema-to-zod/package.json | 2 +- packages/@n8n/mcp-apps/package.json | 2 +- packages/@n8n/mcp-browser/package.json | 2 +- packages/@n8n/node-cli/package.json | 2 +- .../node-engine-compatibility/package.json | 2 +- packages/@n8n/nodes-langchain/package.json | 2 +- packages/@n8n/permissions/package.json | 2 +- packages/@n8n/scheduler/package.json | 2 +- packages/@n8n/task-runner/package.json | 2 +- packages/@n8n/telemetry/package.json | 2 +- packages/@n8n/tournament/package.json | 2 +- packages/@n8n/typeorm/package.json | 2 +- packages/@n8n/utils/package.json | 2 +- packages/@n8n/vitest-config/package.json | 2 +- packages/@n8n/workflow-sdk/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/extensions/insights/package.json | 2 +- packages/frontend/@n8n/chat/package.json | 2 +- .../frontend/@n8n/composables/package.json | 2 +- .../frontend/@n8n/design-system/package.json | 2 +- .../@n8n/frontend-module-sdk/package.json | 2 +- packages/frontend/@n8n/i18n/package.json | 2 +- .../@n8n/rest-api-client/package.json | 2 +- packages/frontend/@n8n/stores/package.json | 2 +- packages/frontend/editor-ui/package.json | 2 +- .../modules/insights/frontend/package.json | 2 +- .../instance-registry/frontend/package.json | 2 +- packages/modules/otel/frontend/package.json | 2 +- .../frontend/package.json | 2 +- packages/nodes-base/package.json | 2 +- .../efficiency/scale/benchmark/package.json | 2 +- packages/workflow/package.json | 2 +- 63 files changed, 209 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57d1558a9a7..55b772c9d1f 100644 --- a/CHANGELOG.md +++ b/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) diff --git a/package.json b/package.json index 45721106900..e38a1c0a374 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "2.42.0", + "version": "2.43.0", "private": true, "engines": { "node": ">=24.0.0", diff --git a/packages/@n8n/agents/package.json b/packages/@n8n/agents/package.json index a3080844330..017fbda60c9 100644 --- a/packages/@n8n/agents/package.json +++ b/packages/@n8n/agents/package.json @@ -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" diff --git a/packages/@n8n/ai-node-sdk/package.json b/packages/@n8n/ai-node-sdk/package.json index 2eb7f19bcf8..54d3024f41f 100644 --- a/packages/@n8n/ai-node-sdk/package.json +++ b/packages/@n8n/ai-node-sdk/package.json @@ -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", diff --git a/packages/@n8n/ai-utilities/package.json b/packages/@n8n/ai-utilities/package.json index bd6cde14b12..c631fced966 100644 --- a/packages/@n8n/ai-utilities/package.json +++ b/packages/@n8n/ai-utilities/package.json @@ -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", diff --git a/packages/@n8n/ai-workflow-builder.ee/package.json b/packages/@n8n/ai-workflow-builder.ee/package.json index 44a369f49be..d28d6fc377e 100644 --- a/packages/@n8n/ai-workflow-builder.ee/package.json +++ b/packages/@n8n/ai-workflow-builder.ee/package.json @@ -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", diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 8d057aa8c9f..c752f8ac085 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/api-types", - "version": "1.42.0", + "version": "1.43.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/backend-common/package.json b/packages/@n8n/backend-common/package.json index 26e7204d4be..75938338acb 100644 --- a/packages/@n8n/backend-common/package.json +++ b/packages/@n8n/backend-common/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/backend-common", - "version": "1.42.0", + "version": "1.43.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/backend-network/package.json b/packages/@n8n/backend-network/package.json index cdf24fa9b43..706aca2cb0e 100644 --- a/packages/@n8n/backend-network/package.json +++ b/packages/@n8n/backend-network/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/backend-network", - "version": "1.16.0", + "version": "1.17.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/backend-services/package.json b/packages/@n8n/backend-services/package.json index 124747ad60e..14a3a02c218 100644 --- a/packages/@n8n/backend-services/package.json +++ b/packages/@n8n/backend-services/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/backend-services", - "version": "1.2.0", + "version": "1.3.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/backend-test-utils/package.json b/packages/@n8n/backend-test-utils/package.json index 2d5eac6d51d..4e7b653b738 100644 --- a/packages/@n8n/backend-test-utils/package.json +++ b/packages/@n8n/backend-test-utils/package.json @@ -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", diff --git a/packages/@n8n/blob-storage/package.json b/packages/@n8n/blob-storage/package.json index ad3cc62327a..aea353956ee 100644 --- a/packages/@n8n/blob-storage/package.json +++ b/packages/@n8n/blob-storage/package.json @@ -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", diff --git a/packages/@n8n/chat-hub/package.json b/packages/@n8n/chat-hub/package.json index 46d460f2c67..f135327f5f5 100644 --- a/packages/@n8n/chat-hub/package.json +++ b/packages/@n8n/chat-hub/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat-hub", - "version": "1.35.0", + "version": "1.36.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/cli/package.json b/packages/@n8n/cli/package.json index 48d644cfee5..35a56509f48 100644 --- a/packages/@n8n/cli/package.json +++ b/packages/@n8n/cli/package.json @@ -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": { diff --git a/packages/@n8n/client-oauth2/package.json b/packages/@n8n/client-oauth2/package.json index 0bb93ed9bfc..8cecba73cdf 100644 --- a/packages/@n8n/client-oauth2/package.json +++ b/packages/@n8n/client-oauth2/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/client-oauth2", - "version": "1.24.0", + "version": "1.25.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/codemirror-lang-sql/package.json b/packages/@n8n/codemirror-lang-sql/package.json index f69eb00edff..a28db88ed49 100644 --- a/packages/@n8n/codemirror-lang-sql/package.json +++ b/packages/@n8n/codemirror-lang-sql/package.json @@ -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", diff --git a/packages/@n8n/codemirror-lang/package.json b/packages/@n8n/codemirror-lang/package.json index a96bd6b3fd2..fc868d66bd6 100644 --- a/packages/@n8n/codemirror-lang/package.json +++ b/packages/@n8n/codemirror-lang/package.json @@ -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", diff --git a/packages/@n8n/computer-use/package.json b/packages/@n8n/computer-use/package.json index e013ba1b1e2..743c38b6148 100644 --- a/packages/@n8n/computer-use/package.json +++ b/packages/@n8n/computer-use/package.json @@ -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": { diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index b916a41f832..d60927c8500 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "2.40.0", + "version": "2.41.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/constants/package.json b/packages/@n8n/constants/package.json index e4cd54e5c33..7b52d79fac4 100644 --- a/packages/@n8n/constants/package.json +++ b/packages/@n8n/constants/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/constants", - "version": "0.40.0", + "version": "0.41.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/create-node/package.json b/packages/@n8n/create-node/package.json index 43c3d5e39e4..1928a2318a4 100644 --- a/packages/@n8n/create-node/package.json +++ b/packages/@n8n/create-node/package.json @@ -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" diff --git a/packages/@n8n/db/package.json b/packages/@n8n/db/package.json index 32b8b1719fa..21bfdce39b5 100644 --- a/packages/@n8n/db/package.json +++ b/packages/@n8n/db/package.json @@ -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", diff --git a/packages/@n8n/decorators/package.json b/packages/@n8n/decorators/package.json index f8c8b3f6fff..63e5abbb4b6 100644 --- a/packages/@n8n/decorators/package.json +++ b/packages/@n8n/decorators/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/decorators", - "version": "1.42.0", + "version": "1.43.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/engine/package.json b/packages/@n8n/engine/package.json index c576644385c..e5a665588ea 100644 --- a/packages/@n8n/engine/package.json +++ b/packages/@n8n/engine/package.json @@ -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", diff --git a/packages/@n8n/errors/package.json b/packages/@n8n/errors/package.json index c7e6c5dca7e..4275cdf7676 100644 --- a/packages/@n8n/errors/package.json +++ b/packages/@n8n/errors/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/errors", - "version": "0.14.0", + "version": "0.15.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/expression-runtime/package.json b/packages/@n8n/expression-runtime/package.json index 5de2b2e4a86..55b2c599eb4 100644 --- a/packages/@n8n/expression-runtime/package.json +++ b/packages/@n8n/expression-runtime/package.json @@ -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", diff --git a/packages/@n8n/extension-sdk/package.json b/packages/@n8n/extension-sdk/package.json index 4d35da798d5..7711c585d56 100644 --- a/packages/@n8n/extension-sdk/package.json +++ b/packages/@n8n/extension-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/extension-sdk", - "version": "0.19.0", + "version": "0.20.0", "type": "module", "files": [ "dist", diff --git a/packages/@n8n/imap/package.json b/packages/@n8n/imap/package.json index 84b327804bd..b40b8afdf3a 100644 --- a/packages/@n8n/imap/package.json +++ b/packages/@n8n/imap/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/imap", - "version": "0.28.0", + "version": "0.29.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/inbound-auth/package.json b/packages/@n8n/inbound-auth/package.json index 8ab7f589603..46edcdc1967 100644 --- a/packages/@n8n/inbound-auth/package.json +++ b/packages/@n8n/inbound-auth/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/inbound-auth", - "version": "0.1.0", + "version": "0.2.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/instance-ai/package.json b/packages/@n8n/instance-ai/package.json index b846e314fa1..725c2b7f955 100644 --- a/packages/@n8n/instance-ai/package.json +++ b/packages/@n8n/instance-ai/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/instance-ai", - "version": "1.27.0", + "version": "1.28.0", "scripts": { "clean": "rimraf dist .turbo", "typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json", diff --git a/packages/@n8n/json-schema-to-zod/package.json b/packages/@n8n/json-schema-to-zod/package.json index 8b8aae45c9b..71d10f2a969 100644 --- a/packages/@n8n/json-schema-to-zod/package.json +++ b/packages/@n8n/json-schema-to-zod/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/json-schema-to-zod", - "version": "1.17.0", + "version": "1.18.0", "description": "Converts JSON schema objects into Zod schemas", "types": "./dist/types/index.d.ts", "main": "./dist/cjs/index.js", diff --git a/packages/@n8n/mcp-apps/package.json b/packages/@n8n/mcp-apps/package.json index bfe2c00a983..8c4a451f09b 100644 --- a/packages/@n8n/mcp-apps/package.json +++ b/packages/@n8n/mcp-apps/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/mcp-apps", - "version": "0.19.0", + "version": "0.20.0", "description": "MCP Apps UI resources and server helpers for n8n", "main": "dist/server/index.js", "types": "dist/server/index.d.ts", diff --git a/packages/@n8n/mcp-browser/package.json b/packages/@n8n/mcp-browser/package.json index 54e3c1aa016..51cb9892647 100644 --- a/packages/@n8n/mcp-browser/package.json +++ b/packages/@n8n/mcp-browser/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/mcp-browser", - "version": "0.22.0", + "version": "0.23.0", "description": "Browser automation MCP tools built on Playwright, WebDriver BiDi, and safaridriver", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/@n8n/node-cli/package.json b/packages/@n8n/node-cli/package.json index b7cb1572923..c7a9f296b07 100644 --- a/packages/@n8n/node-cli/package.json +++ b/packages/@n8n/node-cli/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/node-cli", - "version": "0.51.0", + "version": "0.52.0", "description": "Official CLI for developing community nodes for n8n", "bin": { "n8n-node": "bin/n8n-node.mjs" diff --git a/packages/@n8n/node-engine-compatibility/package.json b/packages/@n8n/node-engine-compatibility/package.json index 49f3da9596d..b830781858f 100644 --- a/packages/@n8n/node-engine-compatibility/package.json +++ b/packages/@n8n/node-engine-compatibility/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/node-engine-compatibility", - "version": "0.10.0", + "version": "0.11.0", "description": "Adapts v1 nodes and workflows to the Engine v2 execution model", "scripts": { "clean": "rimraf dist .turbo", diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index dd4076a60b5..879fc57b3dd 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "2.42.0", + "version": "2.43.0", "description": "", "main": "index.js", "exports": { diff --git a/packages/@n8n/permissions/package.json b/packages/@n8n/permissions/package.json index d8a007097dc..d925c93bfb0 100644 --- a/packages/@n8n/permissions/package.json +++ b/packages/@n8n/permissions/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/permissions", - "version": "0.79.0", + "version": "0.80.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/scheduler/package.json b/packages/@n8n/scheduler/package.json index 22a7eebacef..961df54180d 100644 --- a/packages/@n8n/scheduler/package.json +++ b/packages/@n8n/scheduler/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/scheduler", - "version": "1.11.0", + "version": "1.12.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index af8b9dd3127..fa1cc353ed6 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/task-runner", - "version": "2.42.0", + "version": "2.43.0", "scripts": { "clean": "rimraf dist .turbo", "start": "node dist/start.js", diff --git a/packages/@n8n/telemetry/package.json b/packages/@n8n/telemetry/package.json index 464c8a02fcc..e4e08b9de6c 100644 --- a/packages/@n8n/telemetry/package.json +++ b/packages/@n8n/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/telemetry", - "version": "1.10.0", + "version": "1.11.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/tournament/package.json b/packages/@n8n/tournament/package.json index 03c7645323a..ab4a96bfd2e 100644 --- a/packages/@n8n/tournament/package.json +++ b/packages/@n8n/tournament/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/tournament", - "version": "1.13.0", + "version": "1.14.0", "description": "Output compatible rewrite of riot tmpl", "main": "dist/index.js", "module": "src/index.ts", diff --git a/packages/@n8n/typeorm/package.json b/packages/@n8n/typeorm/package.json index bbadcbf2090..d051d746101 100644 --- a/packages/@n8n/typeorm/package.json +++ b/packages/@n8n/typeorm/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/typeorm", - "version": "0.11.0", + "version": "0.12.0", "description": "Data-Mapper ORM for TypeScript, ES7, ES6, ES5. Supports PostgreSQL and SQLite databases.", "license": "MIT", "readmeFilename": "README.md", diff --git a/packages/@n8n/utils/package.json b/packages/@n8n/utils/package.json index e713efc7d06..059b65304cb 100644 --- a/packages/@n8n/utils/package.json +++ b/packages/@n8n/utils/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/utils", "type": "module", - "version": "1.49.0", + "version": "1.50.0", "files": [ "dist" ], diff --git a/packages/@n8n/vitest-config/package.json b/packages/@n8n/vitest-config/package.json index 86f5a9b2739..19299483b91 100644 --- a/packages/@n8n/vitest-config/package.json +++ b/packages/@n8n/vitest-config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/vitest-config", - "version": "1.23.0", + "version": "1.24.0", "type": "module", "peerDependencies": { "@testing-library/jest-dom": "catalog:frontend", diff --git a/packages/@n8n/workflow-sdk/package.json b/packages/@n8n/workflow-sdk/package.json index a194a953d67..c1b89414b2c 100644 --- a/packages/@n8n/workflow-sdk/package.json +++ b/packages/@n8n/workflow-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/workflow-sdk", - "version": "0.35.0", + "version": "0.36.0", "description": "TypeScript SDK for programmatically creating n8n workflows", "exports": { ".": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 957e513ac82..29dc31f2e6f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "2.42.0", + "version": "2.43.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/core/package.json b/packages/core/package.json index e0531689a61..7fc22610a1e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "2.42.0", + "version": "2.43.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/extensions/insights/package.json b/packages/extensions/insights/package.json index 34cfe781479..a0eac54fc56 100644 --- a/packages/extensions/insights/package.json +++ b/packages/extensions/insights/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-extension-insights", - "version": "0.22.0", + "version": "0.23.0", "type": "module", "files": [ "dist", diff --git a/packages/frontend/@n8n/chat/package.json b/packages/frontend/@n8n/chat/package.json index 304770cbebd..bad0d586108 100644 --- a/packages/frontend/@n8n/chat/package.json +++ b/packages/frontend/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "1.41.0", + "version": "1.42.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm run --dir=../storybook dev --initial-path=/docs/chat-chat--docs", diff --git a/packages/frontend/@n8n/composables/package.json b/packages/frontend/@n8n/composables/package.json index a30915a4a4b..c89e8d7e1de 100644 --- a/packages/frontend/@n8n/composables/package.json +++ b/packages/frontend/@n8n/composables/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/composables", "type": "module", - "version": "1.32.0", + "version": "1.33.0", "files": [ "dist" ], diff --git a/packages/frontend/@n8n/design-system/package.json b/packages/frontend/@n8n/design-system/package.json index 05eab7fe6f4..7af2cffbe11 100644 --- a/packages/frontend/@n8n/design-system/package.json +++ b/packages/frontend/@n8n/design-system/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@n8n/design-system", - "version": "2.41.0", + "version": "2.42.0", "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/frontend/@n8n/frontend-module-sdk/package.json b/packages/frontend/@n8n/frontend-module-sdk/package.json index a2737ec7f64..2f23e870651 100644 --- a/packages/frontend/@n8n/frontend-module-sdk/package.json +++ b/packages/frontend/@n8n/frontend-module-sdk/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@n8n/frontend-module-sdk", - "version": "0.12.0", + "version": "0.13.0", "main": "src/index.ts", "import": "src/index.ts", "exports": { diff --git a/packages/frontend/@n8n/i18n/package.json b/packages/frontend/@n8n/i18n/package.json index 25946ee24b8..1906332ef87 100644 --- a/packages/frontend/@n8n/i18n/package.json +++ b/packages/frontend/@n8n/i18n/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/i18n", "type": "module", - "version": "2.42.0", + "version": "2.43.0", "files": [ "dist" ], diff --git a/packages/frontend/@n8n/rest-api-client/package.json b/packages/frontend/@n8n/rest-api-client/package.json index b8aa3c100aa..ce8a9081675 100644 --- a/packages/frontend/@n8n/rest-api-client/package.json +++ b/packages/frontend/@n8n/rest-api-client/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/rest-api-client", "type": "module", - "version": "2.42.0", + "version": "2.43.0", "files": [ "dist" ], diff --git a/packages/frontend/@n8n/stores/package.json b/packages/frontend/@n8n/stores/package.json index 90a0470d054..741fff5249b 100644 --- a/packages/frontend/@n8n/stores/package.json +++ b/packages/frontend/@n8n/stores/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/stores", "type": "module", - "version": "2.42.0", + "version": "2.43.0", "files": [ "dist" ], diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index c7497e69f26..b7af023f795 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "2.42.0", + "version": "2.43.0", "description": "Workflow Editor UI for n8n", "main": "index.js", "type": "module", diff --git a/packages/modules/insights/frontend/package.json b/packages/modules/insights/frontend/package.json index 1993b7776d5..a67dfad58c2 100644 --- a/packages/modules/insights/frontend/package.json +++ b/packages/modules/insights/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/frontend-module-insights", - "version": "0.4.0", + "version": "0.5.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/modules/instance-registry/frontend/package.json b/packages/modules/instance-registry/frontend/package.json index 510b2be6216..13f953b7358 100644 --- a/packages/modules/instance-registry/frontend/package.json +++ b/packages/modules/instance-registry/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/frontend-module-instance-registry", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/modules/otel/frontend/package.json b/packages/modules/otel/frontend/package.json index a19a8a746c2..def0885347a 100644 --- a/packages/modules/otel/frontend/package.json +++ b/packages/modules/otel/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/frontend-module-otel", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/modules/type-availability-policies/frontend/package.json b/packages/modules/type-availability-policies/frontend/package.json index 098aa4b0bb6..64ca210a404 100644 --- a/packages/modules/type-availability-policies/frontend/package.json +++ b/packages/modules/type-availability-policies/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/frontend-module-type-availability-policies", - "version": "0.3.0", + "version": "0.4.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index aea4209e176..d849135a11a 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "2.42.0", + "version": "2.43.0", "description": "Base nodes of n8n", "main": "index.js", "typesVersions": { diff --git a/packages/quality/efficiency/scale/benchmark/package.json b/packages/quality/efficiency/scale/benchmark/package.json index d3b71a150ca..909979b5e83 100644 --- a/packages/quality/efficiency/scale/benchmark/package.json +++ b/packages/quality/efficiency/scale/benchmark/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-benchmark", - "version": "2.23.0", + "version": "2.24.0", "description": "Cli for running benchmark tests for n8n", "main": "dist/index", "scripts": { diff --git a/packages/workflow/package.json b/packages/workflow/package.json index a4ad4fbbec7..cf6a18e4a26 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "2.42.0", + "version": "2.43.0", "description": "Workflow base code of n8n", "types": "dist/esm/index.d.ts", "module": "dist/esm/index.js", From 3f2144f062d85bd96a99636c70651f19607bd20d Mon Sep 17 00:00:00 2001 From: Matsu Date: Tue, 6 Oct 2026 07:27:56 +0000 Subject: [PATCH 02/61] chore: Update turbo.json oxlint configurations (#40377) --- turbo.json | 163 +---------------------------------------------------- 1 file changed, 1 insertion(+), 162 deletions(-) diff --git a/turbo.json b/turbo.json index 6c020bc70ad..cad6b844a7d 100644 --- a/turbo.json +++ b/turbo.json @@ -55,168 +55,7 @@ "format": {}, "format:check": {}, "lint": { - "dependsOn": ["^build", "@n8n/eslint-config#build"] - }, - // Oxlint packages run Oxlint instead of ESLint, so - // they need the oxlint layer built, not the ESLint one. - "@n8n/ai-node-sdk#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/agents#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/ai-utilities#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/ai-workflow-builder#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/api-types#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/backend-common#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/backend-network#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/backend-services#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/backend-test-utils#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/blob-storage#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/chat-hub#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/cli#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/code-health#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/client-oauth2#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/codemirror-lang#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/codemirror-lang-sql#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/computer-use#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/config#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/constants#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/crdt#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/db#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/decorators#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/di#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/engine#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/extension-sdk#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/errors#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/imap#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/inbound-auth#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/instance-ai#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/json-schema-to-zod#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/local-gateway#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/mcp-browser#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/mcp-browser-extension#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/node-cli#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/node-engine-compatibility#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/n8n-extension-insights#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/n8n-benchmark#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/playwright-janitor#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/permissions#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/rules-engine#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/scheduler#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/syslog-client#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/task-runner#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/tournament#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/telemetry#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/test-impact#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/utils#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "@n8n/workflow-sdk#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "n8n-core#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "n8n-containers#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "n8n-playwright#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "n8n-workflow#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] - }, - "n8n#lint": { - "dependsOn": ["^build", "@n8n/oxlint-config#build"] + "dependsOn": ["^build", "@n8n/eslint-config#build", "@n8n/oxlint-config#build"] }, // The n8n-nodes-base lint script also validates load-options methods // against dist/methods/*.json, which only its own build emits. From 7db7fa42c80a8a24fb5ca5ec7448ffb12431fad9 Mon Sep 17 00:00:00 2001 From: Matsu Date: Tue, 6 Oct 2026 07:28:47 +0000 Subject: [PATCH 03/61] fix: Multiple migration timestamps withing a single PR (#40379) --- .../rules/migration-timestamp.rule.test.ts | 89 +++++++++++++++---- .../src/rules/migration-timestamp.rule.ts | 55 ++++++++---- 2 files changed, 112 insertions(+), 32 deletions(-) diff --git a/packages/quality/policy/code-health/src/rules/migration-timestamp.rule.test.ts b/packages/quality/policy/code-health/src/rules/migration-timestamp.rule.test.ts index 1e1c030d4e1..9cf1e91d504 100644 --- a/packages/quality/policy/code-health/src/rules/migration-timestamp.rule.test.ts +++ b/packages/quality/policy/code-health/src/rules/migration-timestamp.rule.test.ts @@ -140,14 +140,57 @@ describe('MigrationTimestampRule', () => { const violations = await rule.analyze(context([a, b, head])); - // `a` and `b` are below the new head — they violate ordering. - // This is intentional and expected for a multi-migration PR: each - // added migration must be greater than every other migration - // including the others added in the same PR. Authors should - // instead order them so each is strictly the head at the time - // of its conceptual insertion. - const violatingFiles = violations.map((v) => path.basename(v.file)).sort(); - expect(violatingFiles).toEqual(['1799000000001-A.ts', '1799000000002-B.ts']); + expect(violations).toEqual([]); + }); + + it('accepts multiple added migrations when no existing migrations exist', async () => { + const a = writeMigration(tmpDir, COMMON_DIR, '1777000000000-A.ts'); + const b = writeMigration(tmpDir, POSTGRES_DIR, '1777000000001-B.ts'); + const c = writeMigration(tmpDir, SQLITE_DIR, '1777000000002-C.ts'); + + const violations = await rule.analyze(context([c, a, b])); + + expect(violations).toEqual([]); + }); + + it('checks every added migration against the existing head', async () => { + writeMigration(tmpDir, COMMON_DIR, '1700000000000-Old.ts'); + writeMigration(tmpDir, POSTGRES_DIR, '1777000000000-Head.ts'); + const below = writeMigration(tmpDir, COMMON_DIR, '1750000000000-Below.ts'); + const above = writeMigration(tmpDir, SQLITE_DIR, '1777000000001-Above.ts'); + const head = writeMigration(tmpDir, COMMON_DIR, '1777000000002-NewHead.ts'); + + const violations = await rule.analyze(context([below, above, head])); + + expect(violations).toHaveLength(1); + expect(violations[0].file).toBe(path.join(tmpDir, below)); + expect(violations[0].message).toContain('1777000000000'); + }); + + it('does not use added migrations to extend the future timestamp ceiling', async () => { + writeMigration(tmpDir, COMMON_DIR, '1777000000000-Existing.ts'); + const a = writeMigration(tmpDir, COMMON_DIR, '1820000000000-A.ts'); + const b = writeMigration(tmpDir, COMMON_DIR, '1820000000001-B.ts'); + + const violations = await rule.analyze(context([a, b])); + + expect(violations).toHaveLength(2); + for (const violation of violations) { + expect(violation.message).toContain('future timestamp'); + } + }); + + it('checks all added migrations against the same ceiling buffer', async () => { + writeMigration(tmpDir, COMMON_DIR, `${NOW}-Existing.ts`); + const a = writeMigration(tmpDir, COMMON_DIR, `${NOW + 1}-A.ts`); + const b = writeMigration(tmpDir, COMMON_DIR, `${NOW + 1000}-B.ts`); + const above = writeMigration(tmpDir, COMMON_DIR, `${NOW + 1001}-Above.ts`); + + const violations = await rule.analyze(context([a, b, above])); + + expect(violations).toHaveLength(1); + expect(violations[0].file).toBe(path.join(tmpDir, above)); + expect(violations[0].message).toContain('future timestamp'); }); it('flags a newly-added migration that exactly ties the head', async () => { @@ -220,6 +263,20 @@ describe('MigrationTimestampRule', () => { expect(violations).toEqual([]); }); + it('keeps an existing dialect file as the floor for other added migrations', async () => { + writeMigration(tmpDir, COMMON_DIR, '1700000000000-Old.ts'); + writeMigration(tmpDir, COMMON_DIR, '1777000000000-Head.ts'); + const override = writeMigration(tmpDir, SQLITE_DIR, '1777000000000-Head.ts'); + const below = writeMigration(tmpDir, POSTGRES_DIR, '1750000000000-Below.ts'); + const above = writeMigration(tmpDir, COMMON_DIR, '1777000000001-Above.ts'); + + const violations = await rule.analyze(context([override, below, above])); + + expect(violations).toHaveLength(1); + expect(violations[0].file).toBe(path.join(tmpDir, below)); + expect(violations[0].message).toContain('1777000000000'); + }); + it('still flags two unrelated migrations colliding on the same timestamp', async () => { // Same timestamp + DIFFERENT suffix = genuine collision. Override // pairing must be by filename, not just by timestamp. @@ -231,12 +288,12 @@ describe('MigrationTimestampRule', () => { const files = violations.map((v) => path.basename(v.file)).sort(); expect(files).toEqual(['1777000000000-Bar.ts', '1777000000000-Foo.ts']); + for (const violation of violations) { + expect(violation.message).toContain('shares its timestamp'); + } }); it('still flags a sub-floor override file', async () => { - // An override pair below the head is still out of order — the - // floor is computed from non-self slots, so the pair shares one - // slot at the override timestamp but is still below the head. writeMigration(tmpDir, COMMON_DIR, '1777000000000-Head.ts'); const commonLate = writeMigration(tmpDir, COMMON_DIR, '1700000000000-Override.ts'); const sqliteLate = writeMigration(tmpDir, SQLITE_DIR, '1700000000000-Override.ts'); @@ -273,17 +330,19 @@ describe('MigrationTimestampRule', () => { }); it('scans every configured migration directory', async () => { + writeMigration(tmpDir, COMMON_DIR, '1777000000000-Existing.ts'); const common = writeMigration(tmpDir, COMMON_DIR, '1700000000000-Common.ts'); const postgres = writeMigration(tmpDir, POSTGRES_DIR, '1750000000000-Postgres.ts'); const sqlite = writeMigration(tmpDir, SQLITE_DIR, '1755000000000-Sqlite.ts'); const violations = await rule.analyze(context([common, postgres, sqlite])); - // Common and Postgres are below Sqlite's head — both trip - // ordering, proving every directory is scanned and addedFiles - // matches across directories. const files = violations.map((v) => path.basename(v.file)).sort(); - expect(files).toEqual(['1700000000000-Common.ts', '1750000000000-Postgres.ts']); + expect(files).toEqual([ + '1700000000000-Common.ts', + '1750000000000-Postgres.ts', + '1755000000000-Sqlite.ts', + ]); }); }); }); diff --git a/packages/quality/policy/code-health/src/rules/migration-timestamp.rule.ts b/packages/quality/policy/code-health/src/rules/migration-timestamp.rule.ts index 24f19dd13ce..89b437e5f5c 100644 --- a/packages/quality/policy/code-health/src/rules/migration-timestamp.rule.ts +++ b/packages/quality/policy/code-health/src/rules/migration-timestamp.rule.ts @@ -79,31 +79,39 @@ export class MigrationTimestampRule extends BaseRule { ? new Set(addedFiles.map((p) => path.normalize(p))) : undefined; - // Dedupe to ordering slots: a `common/-.ts` paired with a - // `postgresdb|sqlite/-.ts` override is one logical migration - // (same TypeORM class name, only one runs per dialect) and occupies a - // single slot. Two files at the same timestamp with *different* - // suffixes remain distinct slots and still trip ordering. - const slotByKey = new Map(); + // Dialect overrides with the same filename share one ordering slot. + const slotByKey = new Map< + string, + { timestamp: number; slotKey: string; isExisting: boolean } + >(); + const slotsPerTimestamp = new Map(); for (const m of parsed) { - if (!slotByKey.has(m.slotKey)) { - slotByKey.set(m.slotKey, { timestamp: m.timestamp, slotKey: m.slotKey }); + const isExisting = !addedFilesSet?.has(path.normalize(m.relativePath)); + const slot = slotByKey.get(m.slotKey); + if (slot) { + // Keep the slot as existing if any dialect file predates the PR. + slot.isExisting ||= isExisting; + } else { + slotByKey.set(m.slotKey, { timestamp: m.timestamp, slotKey: m.slotKey, isExisting }); + slotsPerTimestamp.set(m.timestamp, (slotsPerTimestamp.get(m.timestamp) ?? 0) + 1); } } - const slotsDesc = Array.from(slotByKey.values()).sort((a, b) => { - if (b.timestamp !== a.timestamp) return b.timestamp - a.timestamp; - return a.slotKey.localeCompare(b.slotKey); - }); - const globalMaxSlot = slotsDesc[0]; - const secondMaxSlot = slotsDesc[1]; - const globalMax = globalMaxSlot?.timestamp ?? 0; + const existingSlotsDesc = Array.from(slotByKey.values()) + .filter((slot) => slot.isExisting) + .sort((a, b) => { + if (b.timestamp !== a.timestamp) return b.timestamp - a.timestamp; + return a.slotKey.localeCompare(b.slotKey); + }); + const existingMaxSlot = existingSlotsDesc[0]; + const secondMaxSlot = existingSlotsDesc[1]; + const existingMax = existingMaxSlot?.timestamp ?? 0; const secondMax = secondMaxSlot?.timestamp ?? 0; const violations: Violation[] = []; for (const migration of parsed) { const { filePath, relativePath, fileName, timestamp, slotKey } = migration; - const isHeadSlot = globalMaxSlot?.slotKey === slotKey; - const floor = isHeadSlot ? secondMax : globalMax; + const isHeadSlot = existingMaxSlot?.slotKey === slotKey; + const floor = isHeadSlot ? secondMax : existingMax; const ceiling = Math.max(now, floor + ceilingBuffer); const isAdded = addedFilesSet?.has(path.normalize(relativePath)) ?? false; @@ -121,6 +129,19 @@ export class MigrationTimestampRule extends BaseRule { continue; } + if (isAdded && (slotsPerTimestamp.get(timestamp) ?? 0) > 1) { + violations.push( + this.createViolation( + filePath, + 1, + 1, + `${fileName} shares its timestamp (${timestamp}) with a different migration. New migrations must use distinct timestamps.`, + `Rename the file using an unused timestamp between ${floor + 1} and ${ceiling}.`, + ), + ); + continue; + } + if (timestamp > ceiling) { violations.push( this.createViolation( From 9ee5d1ad7a54cde445ac945fc56a7ba5a4ee4707 Mon Sep 17 00:00:00 2001 From: Robin Braumann <50590409+bjorger@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:01:38 +0000 Subject: [PATCH 04/61] fix(core): Save agent draft definitions atomically (#40374) --- .../__tests__/agent-config.service.test.ts | 113 +++--- .../agent-custom-tools.service.test.ts | 49 ++- ...nt-integration-persistence.service.test.ts | 77 ++-- .../__tests__/agent-publish.service.test.ts | 242 ++++++++---- ...e-reconstruction-integration-tools.test.ts | 50 +-- .../__tests__/agent-skills.service.test.ts | 13 +- .../__tests__/agent-task.service.test.ts | 73 ++-- .../agent-validation.service.test.ts | 15 +- .../agents/__tests__/agent.repository.test.ts | 3 +- .../agent-config-preparation.service.ts | 130 +++++++ .../modules/agents/agent-config.service.ts | 259 ++++--------- .../agents/agent-custom-tools.service.ts | 32 +- .../agents/agent-definition.service.ts | 64 ++++ .../agent-integration-persistence.service.ts | 33 +- .../agent-modification-telemetry.service.ts | 2 +- .../modules/agents/agent-publish.service.ts | 235 ++++-------- .../agents/agent-save-completion.service.ts | 56 +++ .../modules/agents/agent-skills.service.ts | 20 +- .../src/modules/agents/agent-task.service.ts | 61 ++- .../agents/agent-validation.service.ts | 81 ++-- ...sanitize-unknown-agent-credentials.test.ts | 49 ++- .../prune-missing-config-references.ts | 33 ++ .../sanitize-unknown-agent-credentials.ts | 164 +------- .../agent-credential-dependency.repository.ts | 5 +- .../repositories/agent-history.repository.ts | 22 +- .../agent-task-snapshot.repository.ts | 23 +- .../repositories/agent-task.repository.ts | 56 ++- .../agents/repositories/agent.repository.ts | 53 ++- .../extract-agent-credential-ids.test.ts | 48 ++- .../modules/agents/utils/agent-definition.ts | 21 ++ .../modules/agents/utils/agent-draft.utils.ts | 8 +- .../agents/utils/agent-published-snapshot.ts | 9 +- .../utils/extract-agent-credential-ids.ts | 55 +-- .../visit-agent-credential-references.ts | 90 +++++ .../mcp/__tests__/agent-tools.service.test.ts | 55 +-- .../mcp/tools/agents/agent-tools.service.ts | 17 +- .../cli/src/workflows/workflow.service.ee.ts | 9 +- .../agent-execution.repository.test.ts | 2 +- .../repositories/agent.repository.test.ts | 353 +++++++++++++++++- 39 files changed, 1637 insertions(+), 1043 deletions(-) create mode 100644 packages/cli/src/modules/agents/agent-config-preparation.service.ts create mode 100644 packages/cli/src/modules/agents/agent-definition.service.ts create mode 100644 packages/cli/src/modules/agents/agent-save-completion.service.ts create mode 100644 packages/cli/src/modules/agents/json-config/prune-missing-config-references.ts create mode 100644 packages/cli/src/modules/agents/utils/agent-definition.ts create mode 100644 packages/cli/src/modules/agents/utils/visit-agent-credential-references.ts diff --git a/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts index 26838af0571..690a82ffa89 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts @@ -6,7 +6,8 @@ import { type AgentJsonConfig, } from '@n8n/api-types'; import { mockLogger } from '@n8n/backend-test-utils'; -import type { User, WorkflowRepository } from '@n8n/db'; +import type { User, WorkflowRepository, TransactionRunner } from '@n8n/db'; +import { Container } from '@n8n/di'; import { TELEMETRY_EVENT } from '@n8n/telemetry'; import { mock } from 'vitest-mock-extended'; @@ -14,9 +15,11 @@ import type { CredentialsService } from '@/credentials/credentials.service'; import type { Telemetry } from '@/telemetry'; +import { AgentConfigPreparationService } from '../agent-config-preparation.service'; import { AgentConfigService } from '../agent-config.service'; import { AgentModificationTelemetryService } from '../agent-modification-telemetry.service'; -import type { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; +import { AgentSaveCompletionService } from '../agent-save-completion.service'; +import { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; import { AgentSetupCompletionService } from '../agent-setup-completion.service'; import type { AgentSkillsService } from '../agent-skills.service'; import type { AgentUpdateBroadcaster } from '../agent-update-broadcaster'; @@ -102,19 +105,26 @@ function makeService() { ); }); + const transactionRunner = mock(); + transactionRunner.run.mockImplementation(async (ctx, fn) => await fn(ctx)); + Container.set(AgentRuntimeCacheService, runtimeCacheService); const service = new AgentConfigService( mockLogger(), agentRepository, agentTaskRepository, agentSkillsService, - runtimeCacheService, - credentialsService, - workflowRepository, - nodeToolAiGatewayService, - eventService, + new AgentConfigPreparationService( + credentialsService, + workflowRepository, + nodeToolAiGatewayService, + ), new AgentSetupCompletionService(agentValidationService, telemetry, agentRepository), - new AgentModificationTelemetryService(telemetry), - agentUpdateBroadcaster, + transactionRunner, + new AgentSaveCompletionService( + eventService, + agentUpdateBroadcaster, + new AgentModificationTelemetryService(telemetry), + ), ); return { @@ -290,40 +300,44 @@ describe('AgentConfigService', () => { }, ); - it('rejects saving an HTTP Request URL controlled by $fromAI', async () => { - const { service, agentRepository } = makeService(); - const agent = makeAgent(); - agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + it.each([undefined, true, false])( + 'validates HTTP Request URLs with enabled=%s', + async (enabled) => { + const { service, agentRepository } = makeService(); + const agent = makeAgent(); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); - await expect( - service.updateConfig( - agentId, - projectId, - { - ...baseConfig, - tools: [ - { - type: 'node', - name: 'Fetch page', - node: { - nodeType: 'n8n-nodes-base.httpRequestTool', - nodeTypeVersion: 4.5, - nodeParameters: { - url: "={{ $fromAI('url', 'The URL to inspect', 'string') }}", - }, + const config: AgentJsonConfig = { + ...baseConfig, + tools: [ + { + type: 'node', + name: 'Fetch page', + enabled, + node: { + nodeType: 'n8n-nodes-base.httpRequestTool', + nodeTypeVersion: 4.5, + nodeParameters: { + url: "={{ $fromAI('url', 'The URL to inspect', 'string') }}", }, }, - ], - }, - user, - byUser, - ), - ).rejects.toThrow( - 'HTTP Request tool "Fetch page" cannot use $fromAI in tools.0.node.nodeParameters.url. Enter a fixed URL.', - ); - expect(agent.schema).toBe(baseConfig); - expect(agentRepository.save).not.toHaveBeenCalled(); - }); + }, + ], + }; + const save = service.updateConfig(agentId, projectId, config, user, byUser); + + if (enabled === false) { + await expect(save).resolves.toMatchObject({ config: { tools: config.tools } }); + return; + } + + await expect(save).rejects.toThrow( + 'HTTP Request tool "Fetch page" cannot use $fromAI in tools.0.node.nodeParameters.url. Enter a fixed URL.', + ); + expect(agent.schema).toBe(baseConfig); + expect(agentRepository.saveDraftFenced).not.toHaveBeenCalled(); + }, + ); it('persists an explicit web-search disable and clears native provider tools', async () => { // Regression: previously the disable was stripped on write and resurrected @@ -625,7 +639,7 @@ describe('AgentConfigService', () => { const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; expect(saved.tools).toEqual({}); expect(saved.skills).toEqual({}); - expect(agentTaskRepository.delete).toHaveBeenCalledWith(['task-1']); + expect(agentTaskRepository.deleteForAgent).toHaveBeenCalledWith(agentId, ['task-1'], {}); }); it('keeps the resources of omitted tools, skills, and tasks by default', async () => { @@ -646,7 +660,7 @@ describe('AgentConfigService', () => { const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; expect(saved.tools).toEqual(storedCustomTool); expect(Object.keys(saved.skills ?? {})).toEqual(['skill-1']); - expect(agentTaskRepository.delete).not.toHaveBeenCalled(); + expect(agentTaskRepository.deleteForAgent).not.toHaveBeenCalled(); }); it('resolves accessible credentials via the user when one is provided', async () => { @@ -782,11 +796,13 @@ describe('AgentConfigService', () => { tools: [ { type: 'custom', id: 'tool_1', enabled: false, requireApproval: true }, { type: 'custom', id: 'missing_tool' }, + { type: 'custom', id: 'toString' }, { type: 'custom', id: 'disabled_missing_tool', enabled: false }, ], skills: [ { type: 'skill', id: 'skill-1', enabled: false }, { type: 'skill', id: 'missing-skill' }, + { type: 'skill', id: 'toString' }, { type: 'skill', id: 'disabled-missing-skill', enabled: false }, ], tasks: [ @@ -809,7 +825,7 @@ describe('AgentConfigService', () => { ]); expect(saved.schema?.tasks).toEqual([{ type: 'task', id: 'task-1', enabled: true }]); expect(Object.keys(saved.tools)).toEqual(['tool_1']); - expect(agentTaskRepository.delete).toHaveBeenCalledWith(['task-2']); + expect(agentTaskRepository.deleteForAgent).toHaveBeenCalledWith(agentId, ['task-2'], {}); expect(agentSkillsService.removeUnreferencedSkills).toHaveBeenCalled(); expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId); }); @@ -1168,7 +1184,14 @@ describe('AgentConfigService', () => { }); it('surfaces a lost revision fence as a retryable conflict without side effects', async () => { - const { service, agentRepository, telemetry, eventService } = makeService(); + const { + service, + agentRepository, + telemetry, + eventService, + runtimeCacheService, + agentUpdateBroadcaster, + } = makeService(); agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); // A concurrent publish/unpublish/edit bumped `revision` between this // request's load and its save. @@ -1180,6 +1203,8 @@ describe('AgentConfigService', () => { expect(telemetry.track).not.toHaveBeenCalled(); expect(eventService.emit).not.toHaveBeenCalled(); + expect(runtimeCacheService.clearRuntimes).not.toHaveBeenCalled(); + expect(agentUpdateBroadcaster.notify).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-custom-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-custom-tools.service.test.ts index 4d82a1ace56..ed94ad9616b 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-custom-tools.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-custom-tools.service.test.ts @@ -1,12 +1,15 @@ +import type { EventService } from '@n8n/backend-services'; +import { Container } from '@n8n/di'; import type { ToolDescriptor } from '@n8n/agents'; import { mockLogger } from '@n8n/backend-test-utils'; import { mock } from 'vitest-mock-extended'; import { UserError } from 'n8n-workflow'; -import { NotFoundError } from '@n8n/errors'; +import { ConflictError, NotFoundError } from '@n8n/errors'; import type { AgentModificationTelemetryService } from '../agent-modification-telemetry.service'; -import type { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; +import { AgentSaveCompletionService } from '../agent-save-completion.service'; +import { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; import { AgentCustomToolsService } from '../agent-custom-tools.service'; import type { AgentUpdateBroadcaster } from '../agent-update-broadcaster'; import type { Agent } from '../entities/agent.entity'; @@ -52,22 +55,33 @@ function makeService() { const agentRepository = mock(); const runtimeCacheService = mock(); const modificationTelemetry = mock(); + const agentUpdateBroadcaster = mock(); agentRepository.saveDraftFenced.mockResolvedValue(true); + Container.set(AgentRuntimeCacheService, runtimeCacheService); const service = new AgentCustomToolsService( mockLogger(), agentRepository, - runtimeCacheService, - modificationTelemetry, - mock(), + new AgentSaveCompletionService( + mock(), + agentUpdateBroadcaster, + modificationTelemetry, + ), ); - return { service, agentRepository, runtimeCacheService, modificationTelemetry }; + return { + service, + agentRepository, + runtimeCacheService, + modificationTelemetry, + agentUpdateBroadcaster, + }; } describe('AgentCustomToolsService', () => { beforeEach(() => { vi.clearAllMocks(); + Container.reset(); }); it('builds and stores a custom tool, marks the draft dirty, and clears runtime cache', async () => { @@ -92,7 +106,26 @@ describe('AgentCustomToolsService', () => { expect(agent.tools[result.id]).toEqual({ code: 'return 1;', descriptor }); expect(agent.versionId).not.toBe(agent.activeVersionId); expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId); - expect(agentRepository.saveDraftFenced).toHaveBeenCalledWith(agent, undefined); + expect(agentRepository.saveDraftFenced).toHaveBeenCalledWith(agent, {}); + }); + + it('keeps save effects silent when a custom tool loses the revision fence', async () => { + const { + service, + agentRepository, + runtimeCacheService, + modificationTelemetry, + agentUpdateBroadcaster, + } = makeService(); + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); + agentRepository.saveDraftFenced.mockResolvedValue(false); + + await expect( + service.buildCustomTool(agentId, projectId, 'return 1;', descriptor, telemetryContext), + ).rejects.toThrow(ConflictError); + expect(runtimeCacheService.clearRuntimes).not.toHaveBeenCalled(); + expect(agentUpdateBroadcaster.notify).not.toHaveBeenCalled(); + expect(modificationTelemetry.record).not.toHaveBeenCalled(); }); it('throws when building a tool for a missing agent', async () => { @@ -169,7 +202,7 @@ describe('AgentCustomToolsService', () => { ]); expect(agent.versionId).not.toBe(agent.activeVersionId); expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId); - expect(agentRepository.saveDraftFenced).toHaveBeenCalledWith(agent, undefined); + expect(agentRepository.saveDraftFenced).toHaveBeenCalledWith(agent, {}); }); it('snapshots only configured custom tools', () => { diff --git a/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts index dc892ab9a62..158e090c369 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts @@ -15,7 +15,7 @@ import type { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; import type { AgentSetupCompletionService } from '../agent-setup-completion.service'; import type { Agent } from '../entities/agent.entity'; import type { ChatIntegrationRegistry } from '../integrations/agent-chat-integration'; -import type { AgentIntegrationState, AgentRepository } from '../repositories/agent.repository'; +import type { AgentRepository } from '../repositories/agent.repository'; const agentId = 'agent-1'; const projectId = 'project-1'; @@ -58,38 +58,39 @@ function setup(options: SetupOptions = {}) { const credentialsService = mock(); const setupCompletionService = mock(); - const row: AgentIntegrationState = { + const row = { + id: agentId, + projectId, + revision: 0, + schema: options.schema === undefined ? configuredConfig : options.schema, integrations: options.integrations ?? [], versionId: options.versionId ?? 'version-1', activeVersionId: options.activeVersionId === undefined ? 'version-1' : options.activeVersionId, - }; + setupCompletedAt: null, + updatedAt: new Date('2025-01-01T00:00:00Z'), + } as Agent; - agentRepository.findIntegrationState.mockImplementation(async () => ({ + agentRepository.findById.mockImplementation(async () => ({ ...row, integrations: [...(row.integrations ?? [])], })); agentRepository.updateIntegrations.mockImplementation( async (_id, integrations, expected, versionId) => { - // Mirrors the real WHERE clause: both guarded columns must still match. + if (row.revision !== expected.revision) return false; if (row.versionId !== expected.versionId) return false; if (row.activeVersionId !== expected.activeVersionId) return false; row.integrations = integrations; row.versionId = versionId; + row.revision += 1; return true; }, ); setupCompletionService.recordIfSetupComplete.mockResolvedValue(null); const agent = { - id: agentId, - projectId, - versionId: row.versionId, - activeVersionId: row.activeVersionId, - schema: options.schema === undefined ? configuredConfig : options.schema, + ...row, integrations: options.stale ?? [...(row.integrations ?? [])], - setupCompletedAt: null, - updatedAt: new Date('2025-01-01T00:00:00Z'), - } as Agent; + }; return { service: new AgentIntegrationPersistenceService( @@ -179,6 +180,7 @@ describe('AgentIntegrationPersistenceService', () => { expect(row.versionId).not.toBe(row.activeVersionId); expect(agent.integrations).toEqual(row.integrations); expect(agent.versionId).toBe(row.versionId); + expect(agent.revision).toBe(1); expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId); expect(eventService.emit).toHaveBeenCalledWith('agent-saved', { agentId }); }); @@ -196,8 +198,7 @@ describe('AgentIntegrationPersistenceService', () => { expect(agentRepository.updateIntegrations).toHaveBeenCalledWith( agentId, [{ type: 'slack', credentialId: 'slack-1' }], - // Both guarded columns, so a publish landing after the read is caught. - { versionId: 'version-1', activeVersionId: 'version-1' }, + { revision: 0, versionId: 'version-1', activeVersionId: 'version-1' }, expect.not.stringMatching('^version-1$'), ); }); @@ -270,8 +271,6 @@ describe('AgentIntegrationPersistenceService', () => { }); it('rotates the draft version even when the agent is already dirty', async () => { - // The version is the compare-and-set token, so it has to move on every - // write or a concurrent channel write would match the same value. const { service, agent, row } = setup({ versionId: 'draft-9', activeVersionId: 'version-1' }); await service.applyIntegrationDelta( @@ -289,9 +288,7 @@ describe('AgentIntegrationPersistenceService', () => { ['already dirty', { versionId: 'draft-9', activeVersionId: 'version-1' }], ['in sync with its published version', { versionId: 'v', activeVersionId: 'v' }], ['never published', { versionId: 'v', activeVersionId: null }], - ])('advances the compare-and-set token for an agent %s', async (_case, versions) => { - // A write that guards on a value it also writes back cannot detect a - // concurrent writer that read the same value. + ])('starts a new version for an agent %s', async (_case, versions) => { const { service, agent, agentRepository } = setup(versions); await service.applyIntegrationDelta( @@ -511,14 +508,26 @@ describe('AgentIntegrationPersistenceService', () => { }); describe('concurrent writers', () => { - it('re-reads and reapplies the delta when it loses the compare-and-set', async () => { - const { service, agent, row, agentRepository } = setup(); - // A concurrent publish bumps the version between our read and our write. - agentRepository.updateIntegrations.mockImplementationOnce(async () => { - row.versionId = 'version-2'; - row.integrations = [{ type: 'linear', credentialId: 'linear-1' }]; - return false; + it('reapplies the delta and validates the current draft after a revision conflict', async () => { + const { service, agent, row, setupCompletionService } = setup({ + versionId: 'draft-1', + activeVersionId: 'version-1', }); + const discardedCompletion = vi.fn(async () => {}); + const savedCompletion = vi.fn(async () => {}); + const updatedSchema = { ...configuredConfig, instructions: 'Updated instructions' }; + setupCompletionService.recordIfSetupComplete + .mockImplementationOnce(async () => { + row.revision = 1; + row.schema = updatedSchema; + row.integrations = [{ type: 'linear', credentialId: 'linear-1' }]; + return discardedCompletion; + }) + .mockImplementationOnce(async (candidate) => { + expect(candidate.schema).toEqual(updatedSchema); + expect(candidate.revision).toBe(1); + return savedCompletion; + }); const result = await service.applyIntegrationDelta( agent, @@ -527,7 +536,11 @@ describe('AgentIntegrationPersistenceService', () => { ); expect(result.changed).toBe(true); - expect(agentRepository.findIntegrationState).toHaveBeenCalledTimes(2); + expect(result.agent.schema).toEqual(updatedSchema); + expect(result.agent.revision).toBe(2); + expect(row.revision).toBe(2); + expect(discardedCompletion).not.toHaveBeenCalled(); + expect(savedCompletion).toHaveBeenCalledOnce(); // The winner's change survives, and ours is applied on top of it. expect(row.integrations).toEqual([ { type: 'linear', credentialId: 'linear-1' }, @@ -542,7 +555,7 @@ describe('AgentIntegrationPersistenceService', () => { versionId: 'v', activeVersionId: null, }); - agentRepository.findIntegrationState.mockImplementationOnce(async () => { + agentRepository.findById.mockImplementationOnce(async () => { const snapshot = { ...row, integrations: [...(row.integrations ?? [])] }; row.activeVersionId = 'version-1'; return snapshot; @@ -557,10 +570,12 @@ describe('AgentIntegrationPersistenceService', () => { // The first attempt is refused, and the retry guards on what is now there. expect(agentRepository.updateIntegrations).toHaveBeenCalledTimes(2); expect(agentRepository.updateIntegrations.mock.calls[0][2]).toEqual({ + revision: 0, versionId: 'v', activeVersionId: null, }); expect(agentRepository.updateIntegrations.mock.calls[1][2]).toEqual({ + revision: 0, versionId: 'v', activeVersionId: 'version-1', }); @@ -576,7 +591,7 @@ describe('AgentIntegrationPersistenceService', () => { versionId: 'v', activeVersionId: 'version-1', }); - agentRepository.findIntegrationState.mockImplementationOnce(async () => { + agentRepository.findById.mockImplementationOnce(async () => { const snapshot = { ...row, integrations: [...(row.integrations ?? [])] }; row.activeVersionId = null; return snapshot; @@ -615,7 +630,7 @@ describe('AgentIntegrationPersistenceService', () => { it('fails when the agent was deleted underneath the mutation', async () => { const { service, agent, agentRepository } = setup(); - agentRepository.findIntegrationState.mockResolvedValue(null); + agentRepository.findById.mockResolvedValue(null); await expect( service.applyIntegrationDelta( diff --git a/packages/cli/src/modules/agents/__tests__/agent-publish.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-publish.service.test.ts index 6d53dc61479..9ce1e1bd9a8 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-publish.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-publish.service.test.ts @@ -1,7 +1,8 @@ +import type { ToolDescriptor } from '@n8n/agents'; import type { AgentJsonConfig } from '@n8n/api-types'; import type { EventService } from '@n8n/backend-services'; import { mockLogger } from '@n8n/backend-test-utils'; -import type { User } from '@n8n/db'; +import type { User, TransactionRunner, OperationContext, Transaction } from '@n8n/db'; import { Container } from '@n8n/di'; import { TELEMETRY_EVENT } from '@n8n/telemetry'; import { QueryFailedError } from '@n8n/typeorm'; @@ -12,8 +13,10 @@ import { ConflictError } from '@n8n/errors'; import type { Telemetry } from '@/telemetry'; import type { AgentCustomToolsService } from '../agent-custom-tools.service'; +import { AgentSaveCompletionService } from '../agent-save-completion.service'; +import { AgentDefinitionService } from '../agent-definition.service'; import { AgentPublishService } from '../agent-publish.service'; -import type { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; +import { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; import { AgentModificationTelemetryService } from '../agent-modification-telemetry.service'; import { AgentSetupCompletionService } from '../agent-setup-completion.service'; import { AgentTaskService } from '../agent-task.service'; @@ -86,24 +89,6 @@ function makeTaskSnapshot(overrides: Partial = {}): AgentTask } as AgentTaskSnapshot; } -function makeTransaction() { - const taskRepo = { - findBy: vi.fn().mockResolvedValue([]), - delete: vi.fn(), - update: vi.fn(), - insert: vi.fn(), - }; - const trx = { - save: vi.fn(), - getRepository: vi.fn().mockReturnValue(taskRepo), - }; - const transaction = vi.fn(async (callback: (manager: typeof trx) => Promise) => { - await callback(trx); - }); - - return { trx, taskRepo, transaction }; -} - function makeService() { const agentRepository = mock(); const agentHistoryRepository = mock(); @@ -118,12 +103,12 @@ function makeService() { const telemetry = mock(); const eventService = mock(); const agentUpdateBroadcaster = mock(); - const { trx, taskRepo, transaction } = makeTransaction(); - - Object.defineProperty(agentRepository, 'manager', { - value: { transaction }, - configurable: true, - }); + const ctx: OperationContext = { trx: mock() }; + const transactionRunner = mock(); + transactionRunner.run.mockImplementation(async (_ctx, fn) => await fn(ctx)); + taskSnapshotRepository.findByVersionId.mockResolvedValue([]); + agentTaskRepository.replaceForAgent.mockResolvedValue(false); + Container.set(AgentRuntimeCacheService, runtimeCacheService); agentRepository.claimSetupCompleted.mockResolvedValue(true); agentRepository.setActiveVersionFenced.mockResolvedValue(true); @@ -159,8 +144,19 @@ function makeService() { telemetry, eventService, new AgentSetupCompletionService(agentValidationService, telemetry, agentRepository), - new AgentModificationTelemetryService(telemetry), agentUpdateBroadcaster, + transactionRunner, + new AgentSaveCompletionService( + eventService, + agentUpdateBroadcaster, + new AgentModificationTelemetryService(telemetry), + ), + new AgentDefinitionService( + agentTaskRepository, + taskSnapshotRepository, + agentRepository, + transactionRunner, + ), ); return { @@ -178,8 +174,7 @@ function makeService() { credentialsService, telemetry, eventService, - trx, - taskRepo, + ctx, }; } @@ -197,7 +192,9 @@ describe('AgentPublishService', () => { taskSnapshotRepository, agentValidationService, runtimeCacheService, - trx, + eventService, + agentUpdateBroadcaster, + telemetry, } = makeService(); const agent = makeAgent(); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); @@ -219,8 +216,10 @@ describe('AgentPublishService', () => { expect(agentValidationService.validateAgentConfiguration).not.toHaveBeenCalled(); expect(agentHistoryRepository.saveVersion).not.toHaveBeenCalled(); expect(taskSnapshotRepository.saveForVersion).not.toHaveBeenCalled(); - expect(trx.save).not.toHaveBeenCalled(); expect(runtimeCacheService.clearRuntimes).not.toHaveBeenCalled(); + expect(eventService.emit).not.toHaveBeenCalled(); + expect(agentUpdateBroadcaster.notify).not.toHaveBeenCalled(); + expect(telemetry.track).not.toHaveBeenCalled(); expect(agent.activeVersionId).toBeNull(); }); @@ -264,7 +263,9 @@ describe('AgentPublishService', () => { agentHistoryRepository, chatIntegrationService, runtimeCacheService, - trx, + eventService, + agentUpdateBroadcaster, + telemetry, } = makeService(); const agent = makeAgent({ integrations: [telegram] }); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); @@ -277,9 +278,11 @@ describe('AgentPublishService', () => { ); expect(agentHistoryRepository.saveVersion).not.toHaveBeenCalled(); - expect(trx.save).not.toHaveBeenCalled(); expect(runtimeCacheService.clearRuntimes).not.toHaveBeenCalled(); expect(chatIntegrationService.syncToConfig).not.toHaveBeenCalled(); + expect(eventService.emit).not.toHaveBeenCalled(); + expect(agentUpdateBroadcaster.notify).not.toHaveBeenCalled(); + expect(telemetry.track).not.toHaveBeenCalled(); expect(agent.activeVersionId).toBeNull(); }); @@ -382,7 +385,7 @@ describe('AgentPublishService', () => { telemetry, eventService, agentUpdateBroadcaster, - trx, + ctx, } = makeService(); const configuredTools = { tool: { descriptor: { name: 'tool' } } }; const configuredSkills = { @@ -445,11 +448,11 @@ describe('AgentPublishService', () => { skills: configuredSkills, publishedBy: user, }, - trx, + ctx, ); expect(taskSnapshotRepository.saveForVersion).toHaveBeenCalledWith( [expect.objectContaining({ versionId, taskId: 'task-1', objective: 'Summarize messages' })], - trx, + ctx, ); expect(agent.activeVersionId).toBe(versionId); expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId); @@ -609,7 +612,7 @@ describe('AgentPublishService', () => { }); it('switches to an existing history row when publishing a specific version', async () => { - const { service, agentRepository, agentHistoryRepository, trx } = makeService(); + const { service, agentRepository, agentHistoryRepository, ctx } = makeService(); const agent = makeAgent({ versionId: 'draft-v2', activeVersionId: 'v0', @@ -636,9 +639,8 @@ describe('AgentPublishService', () => { agent.id, 0, { activeVersionId: 'v1', versionId: agent.versionId }, - trx, + ctx, ); - expect(trx.save).not.toHaveBeenCalled(); }); it('reports activating an older version as a republish, not the explicit publish the caller asked for', async () => { @@ -675,7 +677,7 @@ describe('AgentPublishService', () => { }); it('reverts draft fields and task bodies from the active published snapshot', async () => { - const { service, agentRepository, taskSnapshotRepository, taskRepo } = makeService(); + const { service, agentRepository, taskSnapshotRepository, agentTaskRepository } = makeService(); const activeVersion = makeHistory({ versionId: 'published-v1', schema: { @@ -700,7 +702,7 @@ describe('AgentPublishService', () => { agentRepository.findByIdAndProjectId.mockResolvedValue(agent); taskSnapshotRepository.findByVersionId.mockResolvedValue([makeTaskSnapshot()]); - taskRepo.findBy.mockResolvedValue([{ id: 'task-1' }, { id: 'draft-only' }]); + agentTaskRepository.replaceForAgent.mockResolvedValue(true); await expect(service.revertToPublishedAgent(agentId, projectId, user, 'user')).resolves.toBe( agent, @@ -711,16 +713,21 @@ describe('AgentPublishService', () => { expect(agent.versionId).toBe('published-v1'); expect(agent.tools).toEqual(activeVersion.tools); expect(agent.skills).toEqual(activeVersion.skills); - expect(taskRepo.delete).toHaveBeenCalledWith(['draft-only']); - expect(taskRepo.update).toHaveBeenCalledWith( - 'task-1', - expect.objectContaining({ objective: 'Summarize messages' }), + expect(agentTaskRepository.replaceForAgent).toHaveBeenCalledWith( + agentId, + new Map([['task-1', expect.objectContaining({ objective: 'Summarize messages' })]]), + expect.anything(), ); }); it('reverts to a selected history row and task snapshot without changing the active published version', async () => { - const { service, agentRepository, agentHistoryRepository, taskSnapshotRepository, taskRepo } = - makeService(); + const { + service, + agentRepository, + agentHistoryRepository, + taskSnapshotRepository, + agentTaskRepository, + } = makeService(); const agent = makeAgent({ activeVersionId: 'current-active', activeVersion: makeHistory({ versionId: 'current-active' }), @@ -741,7 +748,7 @@ describe('AgentPublishService', () => { objective: 'Use the older task objective', }), ]); - taskRepo.findBy.mockResolvedValue([{ id: 'task-1' }, { id: 'draft-only' }]); + agentTaskRepository.replaceForAgent.mockResolvedValue(true); await service.revertToVersion(agentId, projectId, 'older-version', user, 'user'); @@ -750,10 +757,10 @@ describe('AgentPublishService', () => { expect(agent.activeVersionId).toBe('current-active'); expect(agent.integrations).toEqual([{ type: 'n8n_chat', credentialId: '' }]); expect(agent.versionId).not.toBe('older-version'); - expect(taskRepo.delete).toHaveBeenCalledWith(['draft-only']); - expect(taskRepo.update).toHaveBeenCalledWith( - 'task-1', - expect.objectContaining({ objective: 'Use the older task objective' }), + expect(agentTaskRepository.replaceForAgent).toHaveBeenCalledWith( + agentId, + new Map([['task-1', expect.objectContaining({ objective: 'Use the older task objective' })]]), + expect.anything(), ); }); @@ -785,7 +792,8 @@ describe('AgentPublishService', () => { }); it('reports sidecar body-only reverts when schema references are unchanged', async () => { - const { service, agentRepository, taskSnapshotRepository, taskRepo, telemetry } = makeService(); + const { service, agentRepository, taskSnapshotRepository, agentTaskRepository, telemetry } = + makeService(); const schemaWithRefs: AgentJsonConfig = { ...schema, tools: [{ type: 'custom', id: 'tool-1' }], @@ -820,14 +828,7 @@ describe('AgentPublishService', () => { objective: 'Published objective', }), ]); - taskRepo.findBy.mockResolvedValue([ - { - id: 'task-1', - name: 'Daily summary', - objective: 'Draft objective', - cronExpression: '0 9 * * *', - }, - ]); + agentTaskRepository.replaceForAgent.mockResolvedValue(true); await service.revertToPublishedAgent(agentId, projectId, user, 'user'); @@ -842,13 +843,53 @@ describe('AgentPublishService', () => { expect(telemetry.track).toHaveBeenCalledTimes(1); }); - it('returns a version snapshot with its task snapshots', async () => { - const { service, agentRepository, agentHistoryRepository, taskSnapshotRepository } = - makeService(); - const version = makeHistory({ versionId: 'old-version' }); - agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); + it('reads the selected version without using draft or active-version bodies', async () => { + const { + service, + agentRepository, + agentHistoryRepository, + taskSnapshotRepository, + agentTaskRepository, + } = makeService(); + const version = makeHistory({ + versionId: 'old-version', + schema: { + ...schema, + name: 'Older agent', + tasks: [{ type: 'task', id: 'task-1', enabled: false }], + integrations: [{ type: 'n8n_chat', credentialId: '' }], + }, + skills: { + skill: { + name: 'Older skill', + description: 'Older description', + instructions: 'Older body', + }, + }, + }); + const agent = makeAgent({ + schema: { ...schema, name: 'Draft agent' }, + tools: { + draft_tool: { code: 'return 1;', descriptor: mock({ name: 'draft_tool' }) }, + }, + skills: { + skill: { + name: 'Draft skill', + description: 'Draft description', + instructions: 'Draft body', + }, + }, + activeVersion: makeHistory({ + versionId: 'active-version', + schema: { ...schema, name: 'Active agent' }, + }), + activeVersionId: 'active-version', + integrations: [{ type: 'slack', credentialId: 'current-credential' }], + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); agentHistoryRepository.findByVersionAndAgentId.mockResolvedValue(version); - taskSnapshotRepository.findByVersionId.mockResolvedValue([makeTaskSnapshot()]); + const snapshot = makeTaskSnapshot({ versionId: 'old-version', enabled: false }); + taskSnapshotRepository.findByVersionId.mockResolvedValue([snapshot]); const result = await service.getVersion(agentId, projectId, 'old-version'); @@ -857,7 +898,26 @@ describe('AgentPublishService', () => { agentId, ); expect(result.version).toBe(version); - expect(result.tasks).toEqual([makeTaskSnapshot()]); + expect(result.definition).toEqual({ + schema: version.schema, + tools: {}, + skills: version.skills, + tasks: new Map([ + [ + 'task-1', + { + name: 'Daily summary', + objective: 'Summarize messages', + cronExpression: '0 9 * * *', + timezone: null, + }, + ], + ]), + }); + expect(result.agent.integrations).toEqual([ + { type: 'slack', credentialId: 'current-credential' }, + ]); + expect(agentTaskRepository.findByAgentId).not.toHaveBeenCalled(); }); it('throws when the requested version does not exist for the agent', async () => { @@ -895,7 +955,15 @@ describe('AgentPublishService', () => { }); it('loses the fence and conflicts when a concurrent edit bumped revision before publish', async () => { - const { service, agentRepository, agentHistoryRepository, telemetry, trx } = makeService(); + const { + service, + agentRepository, + agentHistoryRepository, + telemetry, + eventService, + agentUpdateBroadcaster, + ctx, + } = makeService(); const agent = makeAgent({ revision: 5 }); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); agentRepository.setActiveVersionFenced.mockResolvedValue(false); @@ -908,11 +976,12 @@ describe('AgentPublishService', () => { agentId, 5, expect.objectContaining({ activeVersionId: versionId, versionId }), - trx, + ctx, ); expect(agentHistoryRepository.saveVersion).toHaveBeenCalled(); - expect(trx.save).not.toHaveBeenCalled(); expect(telemetry.track).not.toHaveBeenCalled(); + expect(eventService.emit).not.toHaveBeenCalled(); + expect(agentUpdateBroadcaster.notify).not.toHaveBeenCalled(); expect(agent.activeVersionId).toBeNull(); expect(agent.activeVersion).toBeNull(); expect(agent.versionId).toBe(versionId); @@ -920,7 +989,14 @@ describe('AgentPublishService', () => { }); it('conflicts when a second publish races the same agent', async () => { - const { service, agentRepository, agentHistoryRepository, telemetry } = makeService(); + const { + service, + agentRepository, + agentHistoryRepository, + telemetry, + eventService, + agentUpdateBroadcaster, + } = makeService(); const agent = makeAgent({ revision: 1 }); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); agentHistoryRepository.findByVersionAndAgentId.mockResolvedValue( @@ -937,13 +1013,22 @@ describe('AgentPublishService', () => { // entity must stay on the pre-publish active version. expect(agentHistoryRepository.saveVersion).not.toHaveBeenCalled(); expect(telemetry.track).not.toHaveBeenCalled(); + expect(eventService.emit).not.toHaveBeenCalled(); + expect(agentUpdateBroadcaster.notify).not.toHaveBeenCalled(); expect(agent.activeVersionId).toBeNull(); expect(agent.versionId).toBe(versionId); expect(agent.revision).toBe(1); }); it('conflicts when a concurrent publish already inserted the same draft version snapshot', async () => { - const { service, agentRepository, agentHistoryRepository, telemetry } = makeService(); + const { + service, + agentRepository, + agentHistoryRepository, + telemetry, + eventService, + agentUpdateBroadcaster, + } = makeService(); const agent = makeAgent({ revision: 2 }); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); // Both racers share the draft's versionId as the history primary key, @@ -962,6 +1047,8 @@ describe('AgentPublishService', () => { expect(agentRepository.setActiveVersionFenced).not.toHaveBeenCalled(); expect(telemetry.track).not.toHaveBeenCalled(); + expect(eventService.emit).not.toHaveBeenCalled(); + expect(agentUpdateBroadcaster.notify).not.toHaveBeenCalled(); expect(agent.activeVersionId).toBeNull(); expect(agent.revision).toBe(2); }); @@ -975,7 +1062,8 @@ describe('AgentPublishService', () => { }); it('unpublish conflicts on a stale revision and skips telemetry', async () => { - const { service, agentRepository, telemetry } = makeService(); + const { service, agentRepository, telemetry, eventService, agentUpdateBroadcaster } = + makeService(); const agent = makeAgent({ activeVersionId: 'v1', revision: 3 }); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); agentRepository.setActiveVersionFenced.mockResolvedValue(false); @@ -987,11 +1075,13 @@ describe('AgentPublishService', () => { // Losing the fence means the active version is left untouched and no // unpublish side effects or telemetry run. expect(telemetry.track).not.toHaveBeenCalled(); + expect(eventService.emit).not.toHaveBeenCalled(); + expect(agentUpdateBroadcaster.notify).not.toHaveBeenCalled(); expect(agent.activeVersionId).toBe('v1'); }); it('passes the loaded revision as the fence expectation on publish', async () => { - const { service, agentRepository, trx } = makeService(); + const { service, agentRepository, ctx } = makeService(); const agent = makeAgent({ revision: 7 }); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); @@ -1001,7 +1091,7 @@ describe('AgentPublishService', () => { agentId, 7, expect.objectContaining({ activeVersionId: versionId, versionId }), - trx, + ctx, ); expect(agent.revision).toBe(8); expect(agent.activeVersionId).toBe(versionId); diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts index bfd76ff3a7c..7345f76772d 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts @@ -11,6 +11,7 @@ import type { CustomFetch, HttpTransport, OutboundHttp } from '@n8n/backend-netw import { mockLogger } from '@n8n/backend-test-utils'; import type { AiConfig, GlobalConfig } from '@n8n/config'; import type { + TransactionRunner, User, CredentialsEntity, ProjectRelationRepository, @@ -34,6 +35,7 @@ import type { WorkflowFinderService } from '@/workflows/workflow-finder.service' import { AgentChangePublisher } from '../agent-change-publisher.service'; import type { AgentChatAttachmentService } from '../agent-chat-attachment.service'; +import { AgentConfigPreparationService } from '../agent-config-preparation.service'; import { AgentConfigService } from '../agent-config.service'; import type { NodeToolAiGatewayService } from '../json-config/node-tool-ai-gateway.service'; import { AgentCustomToolsService } from '../agent-custom-tools.service'; @@ -55,6 +57,8 @@ import { AgentTurnExecutionService } from '../agent-turn-execution.service'; import type { AgentToolApprovalService } from '../agent-tool-approval.service'; import { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service'; import type { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service'; +import { AgentSaveCompletionService } from '../agent-save-completion.service'; +import { AgentDefinitionService } from '../agent-definition.service'; import { AgentSkillsService } from '../agent-skills.service'; import type { AgentUpdateBroadcaster } from '../agent-update-broadcaster'; @@ -256,33 +260,36 @@ describe('AgentRuntimeReconstructionService integration tools', () => { Container.set(AgentRuntimeCacheService, runtimeCacheService); const modificationTelemetry = mock(); const agentUpdateBroadcaster = mock(); - agentSkillsService = new AgentSkillsService( - logger, - agentRepository, - modificationTelemetry, + const saveCompletion = new AgentSaveCompletionService( + mock(), agentUpdateBroadcaster, + modificationTelemetry, ); + const transactionRunner = mock(); + transactionRunner.run.mockImplementation(async (ctx, fn) => await fn(ctx)); + agentRepository.hasRevision.mockResolvedValue(true); + const definitionService = new AgentDefinitionService( + agentTaskRepository, + agentTaskSnapshotRepository, + agentRepository, + transactionRunner, + ); + agentSkillsService = new AgentSkillsService(logger, agentRepository, saveCompletion); agentConfigService = new AgentConfigService( logger, agentRepository, agentTaskRepository, agentSkillsService, - runtimeCacheService, - credentialsService, - mock(), - mock(), - mock(), + new AgentConfigPreparationService( + credentialsService, + mock(), + mock(), + ), mock(), - modificationTelemetry, - agentUpdateBroadcaster, - ); - agentCustomToolsService = new AgentCustomToolsService( - logger, - agentRepository, - runtimeCacheService, - modificationTelemetry, - agentUpdateBroadcaster, + transactionRunner, + saveCompletion, ); + agentCustomToolsService = new AgentCustomToolsService(logger, agentRepository, saveCompletion); agentExecutionOrchestratorService = new AgentExecutionOrchestratorService( logger, n8nCheckpointStorage, @@ -319,8 +326,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => { ); agentValidationService = new AgentValidationService( agentRepository, - agentTaskRepository, - agentTaskSnapshotRepository, + definitionService, mock(), mock(), chatIntegrationRegistry, @@ -339,8 +345,10 @@ describe('AgentRuntimeReconstructionService integration tools', () => { telemetry, mock(), mock(), - mock(), mock(), + transactionRunner, + saveCompletion, + definitionService, ); agentTestChatService = new AgentTestChatService(n8nMemory, mock()); agentsService = new AgentsService( diff --git a/packages/cli/src/modules/agents/__tests__/agent-skills.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-skills.service.test.ts index b56109686d2..b747f12b954 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-skills.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-skills.service.test.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/require-await, @typescript-eslint/unbound-method -- async mock stubs and unbound-method references are acceptable test idioms */ import type { Mocked } from 'vitest'; +import type { EventService } from '@n8n/backend-services'; import { mockLogger } from '@n8n/backend-test-utils'; import { Container } from '@n8n/di'; import { mock } from 'vitest-mock-extended'; @@ -7,6 +8,7 @@ import { mock } from 'vitest-mock-extended'; import type { Agent } from '../entities/agent.entity'; import { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; import type { AgentModificationTelemetryService } from '../agent-modification-telemetry.service'; +import { AgentSaveCompletionService } from '../agent-save-completion.service'; import { AgentSkillsService } from '../agent-skills.service'; import type { AgentUpdateBroadcaster } from '../agent-update-broadcaster'; import type { AgentRepository } from '../repositories/agent.repository'; @@ -57,8 +59,11 @@ describe('AgentSkillsService', () => { service = new AgentSkillsService( mockLogger(), agentRepository, - modificationTelemetry, - agentUpdateBroadcaster, + new AgentSaveCompletionService( + mock(), + agentUpdateBroadcaster, + modificationTelemetry, + ), ); }); @@ -125,7 +130,7 @@ describe('AgentSkillsService', () => { [result.id]: expect.objectContaining({ references: result.skill.references }), }, }), - undefined, + {}, ); }); @@ -366,7 +371,7 @@ describe('AgentSkillsService', () => { summarize_notes: skill, }, }), - undefined, + {}, ); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts index 273b699800b..99e122b74de 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts @@ -1,7 +1,8 @@ import type { Mock } from 'vitest'; import type { Logger } from '@n8n/backend-common'; import type { GlobalConfig } from '@n8n/config'; -import type { User } from '@n8n/db'; +import type { User, TransactionRunner, OperationContext, Transaction } from '@n8n/db'; +import type { EventService } from '@n8n/backend-services'; import { mock } from 'vitest-mock-extended'; import type { InstanceSettings, ScheduledTaskManager } from 'n8n-core'; @@ -11,6 +12,7 @@ import type { Publisher } from '@/scaling/pubsub/publisher.service'; import { AgentChangePublisher } from '../agent-change-publisher.service'; import type { AgentExecutionOrchestratorService } from '../agent-execution-orchestrator.service'; import type { AgentModificationTelemetryService } from '../agent-modification-telemetry.service'; +import { AgentSaveCompletionService } from '../agent-save-completion.service'; import { AgentTaskService } from '../agent-task.service'; import type { AgentUpdateBroadcaster } from '../agent-update-broadcaster'; import type { AgentsSettingsService } from '../agents-settings.service'; @@ -134,7 +136,8 @@ describe('AgentTaskService', () => { let modificationTelemetry: ReturnType>; let durableJobRegistrar: ReturnType>; let settingsService: ReturnType>; - let txManager: { save: Mock; remove: Mock }; + let transactionRunner: ReturnType>; + const ctx: OperationContext = { trx: mock() }; let service: AgentTaskService; function setMultiMain(enabled: boolean): void { @@ -154,10 +157,14 @@ describe('AgentTaskService', () => { mock({ isLeader }), agentTaskScheduler, new AgentChangePublisher(publisher, globalConfig, logger), - modificationTelemetry, durableJobRegistrar, - mock(), + new AgentSaveCompletionService( + mock(), + mock(), + modificationTelemetry, + ), settingsService, + transactionRunner, ); } @@ -178,17 +185,9 @@ describe('AgentTaskService', () => { ); agentRepository = mock(); agentRepository.saveDraftFenced.mockResolvedValue(true); - // `manager` is a TypeORM getter, not auto-mocked; run transaction callbacks - // against a manager that records save/remove. - txManager = { - save: vi.fn(async (e: unknown) => e), - remove: vi.fn(async (e: unknown) => e), - }; - (agentRepository as unknown as { manager: unknown }).manager = { - transaction: vi.fn( - async (cb: (m: typeof txManager) => Promise) => await cb(txManager), - ), - }; + transactionRunner = mock(); + transactionRunner.run.mockImplementation(async (_ctx, fn) => await fn(ctx)); + taskRepository.saveDefinitions.mockImplementation(async (tasks) => tasks); agentExecutionOrchestratorService = mock(); agentTaskScheduler = mock(); agentTaskScheduler.register.mockReturnValue(true); @@ -239,8 +238,8 @@ describe('AgentTaskService', () => { expect(agent.schema?.tasks).toEqual([ { type: 'task', id: expect.stringMatching(/^task_/), enabled: true }, ]); - expect(txManager.save).toHaveBeenCalledTimes(1); - expect(agentRepository.saveDraftFenced).toHaveBeenCalledWith(agent, txManager); + expect(taskRepository.saveDefinitions).toHaveBeenCalledTimes(1); + expect(agentRepository.saveDraftFenced).toHaveBeenCalledWith(agent, ctx); }); it('rejects an invalid cron without creating', async () => { @@ -364,10 +363,16 @@ describe('AgentTaskService', () => { { type: 'task', id: dtos[0].id, enabled: true }, { type: 'task', id: dtos[1].id, enabled: true }, ]); - // 2 task saves + 1 fenced agent save, all inside the same transaction call. - expect(agentRepository.manager.transaction).toHaveBeenCalledTimes(1); - expect(txManager.save).toHaveBeenCalledTimes(2); - expect(agentRepository.saveDraftFenced).toHaveBeenCalledWith(agent, txManager); + // Save task bodies and config refs in the same transaction. + expect(transactionRunner.run).toHaveBeenCalledTimes(1); + expect(taskRepository.saveDefinitions).toHaveBeenCalledWith( + [ + expect.objectContaining({ name: taskOneDto.name }), + expect.objectContaining({ name: taskTwoDto.name }), + ], + ctx, + ); + expect(agentRepository.saveDraftFenced).toHaveBeenCalledWith(agent, ctx); }); it('rejects an empty batch before loading or writing anything', async () => { @@ -377,7 +382,7 @@ describe('AgentTaskService', () => { expect(agentRepository.findByIdAndProjectId).not.toHaveBeenCalled(); expect(taskRepository.create).not.toHaveBeenCalled(); - expect(txManager.save).not.toHaveBeenCalled(); + expect(taskRepository.saveDefinitions).not.toHaveBeenCalled(); }); it('rejects the whole batch without writing when any cron is invalid', async () => { @@ -392,7 +397,7 @@ describe('AgentTaskService', () => { expect(agentRepository.findByIdAndProjectId).not.toHaveBeenCalled(); expect(taskRepository.create).not.toHaveBeenCalled(); - expect(txManager.save).not.toHaveBeenCalled(); + expect(taskRepository.saveDefinitions).not.toHaveBeenCalled(); }); it('rejects when the agent is not found in the project', async () => { @@ -403,7 +408,7 @@ describe('AgentTaskService', () => { ).rejects.toThrow(NotFoundError); expect(taskRepository.create).not.toHaveBeenCalled(); - expect(txManager.save).not.toHaveBeenCalled(); + expect(taskRepository.saveDefinitions).not.toHaveBeenCalled(); }); it('rejects when the agent has no config yet', async () => { @@ -416,7 +421,7 @@ describe('AgentTaskService', () => { ).rejects.toThrow(BadRequestError); expect(taskRepository.create).not.toHaveBeenCalled(); - expect(txManager.save).not.toHaveBeenCalled(); + expect(taskRepository.saveDefinitions).not.toHaveBeenCalled(); }); }); @@ -466,7 +471,7 @@ describe('AgentTaskService', () => { expect(task.cronExpression).toBe('0 10 * * *'); // An omitted timezone keeps the current one, so old clients stay safe. expect(task.timezone).toBe('Asia/Tokyo'); - expect(txManager.save).toHaveBeenCalled(); + expect(taskRepository.saveDefinitions).toHaveBeenCalled(); expect(agentTaskScheduler.register).not.toHaveBeenCalled(); }); @@ -505,7 +510,7 @@ describe('AgentTaskService', () => { expect(dto.timezone).toBe('Asia/Tokyo'); expect(task.timezone).toBe('Asia/Tokyo'); - expect(txManager.save).toHaveBeenCalled(); + expect(taskRepository.saveDefinitions).toHaveBeenCalled(); }); it('resets to the instance timezone when passed null', async () => { @@ -536,12 +541,11 @@ describe('AgentTaskService', () => { ), ).rejects.toThrow(BadRequestError); expect(task.timezone).toBe('Asia/Tokyo'); - expect(txManager.save).not.toHaveBeenCalled(); + expect(taskRepository.saveDefinitions).not.toHaveBeenCalled(); }); it('is a no-op when no field changes (skips the agent write)', async () => { - const task = makeTask(); - (taskRepository.findByIdAndAgentId as Mock).mockResolvedValue(task); + const task = arrangeUpdate(); const dto = await service.update( AGENT_ID, @@ -556,11 +560,12 @@ describe('AgentTaskService', () => { ); expect(dto.cronExpression).toBe(task.cronExpression); - expect(agentRepository.findByIdAndProjectId).not.toHaveBeenCalled(); - expect(txManager.save).not.toHaveBeenCalled(); + expect(agentRepository.saveDraftFenced).not.toHaveBeenCalled(); + expect(taskRepository.saveDefinitions).not.toHaveBeenCalled(); }); it('throws NotFoundError when updating a missing task', async () => { + arrangeUpdate(); (taskRepository.findByIdAndAgentId as Mock).mockResolvedValue(null); await expect( service.update(AGENT_ID, PROJECT_ID, 'missing', { name: 'x' }, telemetryContext), @@ -574,7 +579,7 @@ describe('AgentTaskService', () => { await expect( service.delete(AGENT_ID, PROJECT_ID, 'missing', telemetryContext), ).rejects.toThrow(NotFoundError); - expect(txManager.remove).not.toHaveBeenCalled(); + expect(taskRepository.deleteForAgent).not.toHaveBeenCalled(); }); it('removes the body and drops its config ref', async () => { @@ -592,7 +597,7 @@ describe('AgentTaskService', () => { await service.delete(AGENT_ID, PROJECT_ID, 'task-1', telemetryContext); - expect(txManager.remove).toHaveBeenCalledWith(task); + expect(taskRepository.deleteForAgent).toHaveBeenCalledWith(AGENT_ID, [task.id], ctx); expect(agent.schema?.tasks).toEqual([]); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts index 93ab4731770..fb133d192d4 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts @@ -1,12 +1,13 @@ import type { CredentialProvider } from '@n8n/agents'; import { AI_GATEWAY_MANAGED_TAG, type AgentJsonConfig } from '@n8n/api-types'; -import type { WorkflowRepository } from '@n8n/db'; +import type { TransactionRunner, WorkflowRepository } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; import type { NodeTypes } from '@/node-types'; import type { AiGatewayService } from '@/services/ai-gateway.service'; import type { AgentSkillsService } from '../agent-skills.service'; +import { AgentDefinitionService } from '../agent-definition.service'; import { AgentValidationService } from '../agent-validation.service'; import type { Agent } from '../entities/agent.entity'; import type { ChatIntegrationRegistry } from '../integrations/agent-chat-integration'; @@ -47,6 +48,7 @@ function makeAgent( skills, tools: {}, integrations: [], + revision: 0, ...overrides, } as unknown as Agent; } @@ -63,6 +65,7 @@ function makeCredentialProvider( function makeService() { const agentRepository = mock(); + agentRepository.hasRevision.mockResolvedValue(true); const agentSkillsService = mock(); const agentTaskRepository = mock(); agentTaskRepository.findByAgentId.mockResolvedValue([]); @@ -78,8 +81,12 @@ function makeService() { return { service: new AgentValidationService( agentRepository, - agentTaskRepository, - agentTaskSnapshotRepository, + new AgentDefinitionService( + agentTaskRepository, + agentTaskSnapshotRepository, + agentRepository, + mock(), + ), nodeTypes, workflowRepository, chatIntegrationRegistry, @@ -1231,12 +1238,14 @@ describe('AgentValidationService — structured issues', () => { makeCredentialProvider([{ id: 'openai-main', type: 'openAiApi' }]), 'publish', ); + agentTaskRepository.findByAgentId.mockClear(); const runtimeResult = await service.validateAgentIsRunnable( agentId, projectId, makeCredentialProvider([{ id: 'openai-main', type: 'openAiApi' }]), ); + expect(agentTaskRepository.findByAgentId).not.toHaveBeenCalled(); expect(publishResult.status).toBe('invalid'); expect(publishResult.issues).toEqual( expect.arrayContaining([ diff --git a/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts b/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts index cc0caf2e97b..58d57791fe3 100644 --- a/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */ import type { AgentIntegrationConfig } from '@n8n/api-types'; +import type { TransactionRunner } from '@n8n/db'; import { In } from '@n8n/typeorm'; import { mock } from 'vitest-mock-extended'; @@ -16,7 +17,7 @@ describe('AgentRepository', () => { beforeEach(() => { vi.clearAllMocks(); - repository = new AgentRepository(mockDataSource as never); + repository = new AgentRepository(mockDataSource as never, mock()); }); describe('findByIdAndProjectId', () => { diff --git a/packages/cli/src/modules/agents/agent-config-preparation.service.ts b/packages/cli/src/modules/agents/agent-config-preparation.service.ts new file mode 100644 index 00000000000..ccb17622b93 --- /dev/null +++ b/packages/cli/src/modules/agents/agent-config-preparation.service.ts @@ -0,0 +1,130 @@ +import { reconcileNativeWebSearch } from '@n8n/ai-utilities/agent-config'; +import { + AgentJsonConfigSchema, + findVectorStoreToolNameCollisions, + formatAgentConfigZodError, + sanitizeAgentJsonConfig, + type AgentJsonConfig, +} from '@n8n/api-types'; +import { WorkflowRepository, type User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { isRecord } from '@n8n/utils/is-record'; +import { UserError } from 'n8n-workflow'; + +import { CredentialsService } from '@/credentials/credentials.service'; + +import { NodeToolAiGatewayService } from './json-config/node-tool-ai-gateway.service'; +import { sanitizeUnknownAgentCredentials } from './json-config/sanitize-unknown-agent-credentials'; +import { normalizeWorkflowToolRefs } from './tools/workflow-tool-workflow-resolver'; +import { createAgentCredentialProvider } from './utils/agent-credential-provider'; +import { + findHttpRequestToolUrlFromAiViolations, + validateNodeToolConfigs, + validateNodeToolExpressions, +} from './utils/node-tool-validation'; + +@Service() +export class AgentConfigPreparationService { + constructor( + private readonly credentialsService: CredentialsService, + private readonly workflowRepository: WorkflowRepository, + private readonly nodeToolAiGatewayService: NodeToolAiGatewayService, + ) {} + + async prepareConfig(config: unknown, projectId: string, user: User) { + const credentialProvider = createAgentCredentialProvider( + this.credentialsService, + projectId, + user, + ); + const accessibleCredentials = await credentialProvider.list(); + const accessibleCredentialIds = new Set( + accessibleCredentials.map((credential) => credential.id), + ); + const sanitizedBaseConfig = sanitizeAgentJsonConfig(config); + const sanitizedConfig = sanitizeUnknownAgentCredentials( + sanitizedBaseConfig, + accessibleCredentialIds, + ); + + const result = await this.validateConfig(sanitizedConfig); + if (!result.valid) { + throw new UserError(`Invalid agent config: ${result.error}`); + } + + // Keep persisted provider tools consistent with the web-search settings. + const validatedConfig = reconcileNativeWebSearch(result.config); + + if (validatedConfig.tools !== undefined) { + await this.nodeToolAiGatewayService.assignManagedCredentials( + validatedConfig.tools.filter((tool) => tool.enabled !== false), + new Set(accessibleCredentials.map((credential) => credential.type)), + ); + } + await normalizeWorkflowToolRefs(this.workflowRepository, validatedConfig, projectId); + + return { validatedConfig, credentialProvider }; + } + + /** Validate the config shape and the node tool schemas. */ + async validateConfig( + raw: unknown, + ): Promise<{ valid: true; config: AgentJsonConfig } | { valid: false; error: string }> { + if (hasNodeToolInputSchema(raw)) { + return { valid: false, error: 'Node tool configs must not include inputSchema.' }; + } + + const parsed = AgentJsonConfigSchema.safeParse(sanitizeAgentJsonConfig(raw)); + if (!parsed.success) { + return { valid: false, error: formatAgentConfigZodError(parsed.error) }; + } + + const config = parsed.data; + + const toolNameCollisions = findVectorStoreToolNameCollisions(config); + if (toolNameCollisions.length > 0) { + return { + valid: false, + error: `Vector store tool name collides with an existing tool: ${toolNameCollisions.join(', ')}`, + }; + } + + try { + validateNodeToolExpressions(config.tools); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + valid: false, + error: `Invalid $fromAI expression in node tool config: ${message}`, + }; + } + + const urlViolations = findHttpRequestToolUrlFromAiViolations(config.tools).filter( + ({ toolIndex }) => config.tools?.[toolIndex].enabled !== false, + ); + if (urlViolations.length > 0) { + return { + valid: false, + error: urlViolations + .map( + ({ toolName, path }) => + `HTTP Request tool "${toolName}" cannot use $fromAI in ${path}. Enter a fixed URL.`, + ) + .join('\n'), + }; + } + + const nodeError = await validateNodeToolConfigs(config.tools); + if (nodeError) { + return { valid: false, error: nodeError }; + } + + return { valid: true, config }; + } +} + +function hasNodeToolInputSchema(raw: unknown): boolean { + if (!isRecord(raw) || !Array.isArray(raw.tools)) return false; + + return raw.tools.some((tool) => isRecord(tool) && tool.type === 'node' && 'inputSchema' in tool); +} diff --git a/packages/cli/src/modules/agents/agent-config.service.ts b/packages/cli/src/modules/agents/agent-config.service.ts index 5a829546eb7..7419f2ed432 100644 --- a/packages/cli/src/modules/agents/agent-config.service.ts +++ b/packages/cli/src/modules/agents/agent-config.service.ts @@ -1,51 +1,35 @@ -import { reconcileNativeWebSearch } from '@n8n/ai-utilities/agent-config'; -import { - AgentJsonConfigSchema, - findVectorStoreToolNameCollisions, - formatAgentConfigZodError, - sanitizeAgentJsonConfig, - type AgentConfigMutationResponse, - type AgentJsonConfig, - type AgentJsonToolConfig, +import type { + AgentConfigMutationResponse, + AgentJsonConfig, + AgentJsonToolConfig, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; -import { EventService } from '@n8n/backend-services'; -import { WorkflowRepository, type User } from '@n8n/db'; +import { TransactionRunner, type OperationContext, type User } from '@n8n/db'; import { Service } from '@n8n/di'; import { isRecord } from '@n8n/utils/is-record'; import { UserError } from 'n8n-workflow'; -import { CredentialsService } from '@/credentials/credentials.service'; import { ConflictError } from '@n8n/errors'; import { type AgentConfigPart, - AgentModificationTelemetryService, diffAgentConfigParts, isUnconfiguredAgent, type AgentActor, } from './agent-modification-telemetry.service'; -import { AgentRuntimeCacheService } from './agent-runtime-cache.service'; +import { AgentConfigPreparationService } from './agent-config-preparation.service'; +import { AgentSaveCompletionService } from './agent-save-completion.service'; import { AgentSetupCompletionService } from './agent-setup-completion.service'; import { AgentSkillsService } from './agent-skills.service'; -import { AgentUpdateBroadcaster } from './agent-update-broadcaster'; import type { Agent } from './entities/agent.entity'; import { syncAgentIntegrations } from './integrations/integrations-sync'; import { composeJsonConfig, decomposeJsonConfig } from './json-config/agent-config-composition'; -import { NodeToolAiGatewayService } from './json-config/node-tool-ai-gateway.service'; -import { sanitizeUnknownAgentCredentials } from './json-config/sanitize-unknown-agent-credentials'; +import { pruneMissingConfigReferences } from './json-config/prune-missing-config-references'; import { AgentTaskRepository } from './repositories/agent-task.repository'; import { AgentRepository } from './repositories/agent.repository'; import { getAgentOrThrow } from './utils/get-agent-or-throw'; -import { normalizeWorkflowToolRefs } from './tools/workflow-tool-workflow-resolver'; -import { createAgentCredentialProvider } from './utils/agent-credential-provider'; import { getAgentConfigHash } from './utils/agent-config-hash'; import { markAgentDraftDirty, saveAgentDraftFenced } from './utils/agent-draft.utils'; -import { - findHttpRequestToolUrlFromAiViolations, - validateNodeToolConfigs, - validateNodeToolExpressions, -} from './utils/node-tool-validation'; import { resolveUniqueSubAgents, type ResolvedSubAgentRef } from './utils/sub-agent-resolver'; import type { AgentsCredentialProvider } from './adapters/agents-credential-provider'; @@ -73,14 +57,10 @@ export class AgentConfigService { private readonly agentRepository: AgentRepository, private readonly agentTaskRepository: AgentTaskRepository, private readonly agentSkillsService: AgentSkillsService, - private readonly runtimeCacheService: AgentRuntimeCacheService, - private readonly credentialsService: CredentialsService, - private readonly workflowRepository: WorkflowRepository, - private readonly nodeToolAiGatewayService: NodeToolAiGatewayService, - private readonly eventService: EventService, + private readonly configPreparation: AgentConfigPreparationService, private readonly setupCompletionService: AgentSetupCompletionService, - private readonly modificationTelemetry: AgentModificationTelemetryService, - private readonly agentUpdateBroadcaster: AgentUpdateBroadcaster, + private readonly transactionRunner: TransactionRunner, + private readonly saveCompletion: AgentSaveCompletionService, ) {} /** @@ -100,61 +80,8 @@ export class AgentConfigService { return config; } - /** - * Validate an AgentJsonConfig: runs Zod schema validation and checks any - * node tool configurations against their JSON-Schema definitions. - */ - async validateConfig( - raw: unknown, - ): Promise<{ valid: true; config: AgentJsonConfig } | { valid: false; error: string }> { - if (hasNodeToolInputSchema(raw)) { - return { valid: false, error: 'Node tool configs must not include inputSchema.' }; - } - - const parsed = AgentJsonConfigSchema.safeParse(sanitizeAgentJsonConfig(raw)); - if (!parsed.success) { - return { valid: false, error: formatAgentConfigZodError(parsed.error) }; - } - - const config = parsed.data; - - const toolNameCollisions = findVectorStoreToolNameCollisions(config); - if (toolNameCollisions.length > 0) { - return { - valid: false, - error: `Vector store tool name collides with an existing tool: ${toolNameCollisions.join(', ')}`, - }; - } - - try { - validateNodeToolExpressions(config.tools); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - valid: false, - error: `Invalid $fromAI expression in node tool config: ${message}`, - }; - } - - const urlViolations = findHttpRequestToolUrlFromAiViolations(config.tools); - if (urlViolations.length > 0) { - return { - valid: false, - error: urlViolations - .map( - ({ toolName, path }) => - `HTTP Request tool "${toolName}" cannot use $fromAI in ${path}. Enter a fixed URL.`, - ) - .join('\n'), - }; - } - - const nodeError = await validateNodeToolConfigs(config.tools); - if (nodeError) { - return { valid: false, error: nodeError }; - } - - return { valid: true, config }; + async validateConfig(raw: unknown) { + return await this.configPreparation.validateConfig(raw); } /** @@ -186,10 +113,14 @@ export class AgentConfigService { } const clearOmitted = options.clearOmittedOptionalFields === true; - const { validatedConfig, credentialProvider, existingTaskIds } = await this.prepareConfig( - entity, + const { validatedConfig, credentialProvider } = await this.configPreparation.prepareConfig( config, + projectId, user, + ); + const existingTaskIds = await this.reconcileConfigReferences( + entity, + validatedConfig, clearOmitted, ); const replacement = this.buildConfigReplacement(entity, validatedConfig, config, clearOmitted); @@ -199,24 +130,23 @@ export class AgentConfigService { markAgentDraftDirty(entity); this.removeUnreferencedResources(entity, validatedConfig, clearOmitted); - const saved = await this.saveConfig(entity, credentialProvider, user, options, replacement); - return await this.finishConfigUpdate( - saved, - validatedConfig, - existingTaskIds, + const saved = await this.saveConfig( + entity, + credentialProvider, + user, + options, replacement, - clearOmitted, + existingTaskIds, ); + return await this.finishConfigUpdate(saved, validatedConfig, replacement, clearOmitted); } private async finishConfigUpdate( saved: Agent, validatedConfig: AgentJsonConfig, - existingTaskIds: string[], replacement: ConfigReplacement, clearOmitted: boolean, ): Promise { - await this.removeUnreferencedTasks(validatedConfig, existingTaskIds, clearOmitted); if (writesField(validatedConfig, 'integrations', clearOmitted)) { await syncAgentIntegrations( saved, @@ -235,14 +165,15 @@ export class AgentConfigService { } private async removeUnreferencedTasks( - config: AgentJsonConfig, + agent: Agent, existingTaskIds: string[], - clearOmitted: boolean, + ctx: OperationContext, ): Promise { - if (!writesField(config, 'tasks', clearOmitted)) return; - const referencedTaskIds = new Set((config.tasks ?? []).map((ref) => ref.id)); + const referencedTaskIds = new Set((agent.schema?.tasks ?? []).map((ref) => ref.id)); const orphanTaskIds = existingTaskIds.filter((id) => !referencedTaskIds.has(id)); - if (orphanTaskIds.length > 0) await this.agentTaskRepository.delete(orphanTaskIds); + if (orphanTaskIds.length > 0) { + await this.agentTaskRepository.deleteForAgent(agent.id, orphanTaskIds, ctx); + } } private async saveConfig( @@ -251,10 +182,10 @@ export class AgentConfigService { user: User, options: AgentConfigUpdateOptions, replacement: ConfigReplacement, + existingTaskIds: string[], ) { const { id: agentId, projectId } = entity; const { changedParts, previousSchema, previousIntegrations } = replacement; - this.runtimeCacheService.clearRuntimes(agentId); // Gate evaluated against the state about to be written; the marker is // claimed and reported only once that write succeeded. @@ -265,25 +196,24 @@ export class AgentConfigService { user, ); - const saved = await saveAgentDraftFenced(this.agentRepository, entity); - this.eventService.emit('agent-saved', { agentId }); - // Every config writer (editor, builder, MCP) lands here, so this is where - // other open Agent Builder tabs learn that their loaded config is stale. - this.agentUpdateBroadcaster.notify( - { projectId, agentId, source: options.modifiedBy }, + const saved = await this.transactionRunner.run({}, async (ctx) => { + await saveAgentDraftFenced(this.agentRepository, entity, ctx); + await this.removeUnreferencedTasks(entity, existingTaskIds, ctx); + return entity; + }); + await this.saveCompletion.configurationSaved( + { + agent: saved, + projectId, + user, + by: options.modifiedBy, + changedParts, + wasUnconfigured: isUnconfiguredAgent(previousSchema, previousIntegrations), + }, options.pushRef, + emitSetupCompleted, ); this.logger.debug('Updated agent JSON config', { agentId, projectId }); - - this.modificationTelemetry.record({ - agent: saved, - projectId, - user, - by: options.modifiedBy, - changedParts, - wasUnconfigured: isUnconfiguredAgent(previousSchema, previousIntegrations), - }); - await emitSetupCompleted?.(); return saved; } @@ -383,85 +313,28 @@ export class AgentConfigService { return nextSchema; } - private async prepareConfig(entity: Agent, config: unknown, user: User, clearOmitted: boolean) { - const { id: agentId, projectId } = entity; - const credentialProvider = createAgentCredentialProvider( - this.credentialsService, - projectId, - user, - ); - const accessibleCredentials = await credentialProvider.list(); - const accessibleCredentialIds = new Set( - accessibleCredentials.map((credential) => credential.id), - ); - const sanitizedBaseConfig = sanitizeAgentJsonConfig(config); - const sanitizedConfig = sanitizeUnknownAgentCredentials( - sanitizedBaseConfig, - accessibleCredentialIds, - ); - - const result = await this.validateConfig(sanitizedConfig); - if (!result.valid) { - throw new UserError(`Invalid agent config: ${result.error}`); - } - - // Reconcile native web-search provider tools with the config's explicit - // `webSearch` state. This is the single write path, so persisted config - // always agrees with read/compose paths. - const validatedConfig = reconcileNativeWebSearch(result.config); - - if (validatedConfig.tools !== undefined) { - await this.nodeToolAiGatewayService.assignManagedCredentials( - validatedConfig.tools.filter((tool) => tool.enabled !== false), - new Set(accessibleCredentials.map((credential) => credential.type)), - ); - } - await normalizeWorkflowToolRefs(this.workflowRepository, validatedConfig, projectId); - - const existingTaskIds = writesField(validatedConfig, 'tasks', clearOmitted) - ? (await this.agentTaskRepository.findByAgentId(agentId)).map((task) => task.id) + private async reconcileConfigReferences( + entity: Agent, + config: AgentJsonConfig, + clearOmitted: boolean, + ) { + const existingTaskIds = writesField(config, 'tasks', clearOmitted) + ? (await this.agentTaskRepository.findByAgentId(entity.id)).map((task) => task.id) : []; - - const resolvedSubAgents = await this.removeMissingConfigRefs( - validatedConfig, - entity, - new Set(existingTaskIds), - ); + pruneMissingConfigReferences(config, entity.schema, { + tools: entity.tools ?? {}, + skills: entity.skills ?? {}, + taskIds: new Set(existingTaskIds), + }); + const resolvedSubAgents = await this.reconcileSubAgentReferences(config, entity); this.validateSubAgentRefs(resolvedSubAgents, entity); - return { validatedConfig, credentialProvider, existingTaskIds }; + return existingTaskIds; } - private async removeMissingConfigRefs( + private async reconcileSubAgentReferences( config: AgentJsonConfig, entity: Agent, - existingTaskIds: ReadonlySet, ): Promise { - if (config.skills !== undefined) { - const skills = entity.skills ?? {}; - const existingSkillIds = new Set((entity.schema?.skills ?? []).map((ref) => ref.id)); - config.skills = config.skills.filter( - (ref) => ref.enabled === false || existingSkillIds.has(ref.id) || Boolean(skills[ref.id]), - ); - } - - if (config.tools !== undefined) { - const tools = entity.tools ?? {}; - const existingToolIds = new Set( - (entity.schema?.tools ?? []).filter((ref) => ref.type === 'custom').map((ref) => ref.id), - ); - config.tools = config.tools.filter( - (ref) => - ref.enabled === false || - ref.type !== 'custom' || - existingToolIds.has(ref.id) || - Boolean(tools[ref.id]), - ); - } - - if (config.tasks !== undefined) { - config.tasks = config.tasks.filter((ref) => existingTaskIds.has(ref.id)); - } - if (config.subAgents?.agents !== undefined) { const existingAgentIds = new Set( (entity.schema?.subAgents?.agents ?? []).map((ref) => ref.agentId), @@ -509,12 +382,6 @@ function mergePersonalisationWithPreviousGradient( }; } -function hasNodeToolInputSchema(raw: unknown): boolean { - if (!isRecord(raw) || !Array.isArray(raw.tools)) return false; - - return raw.tools.some((tool) => isRecord(tool) && tool.type === 'node' && 'inputSchema' in tool); -} - const OPTIONAL_SCHEMA_FIELDS = [ 'credential', 'description', diff --git a/packages/cli/src/modules/agents/agent-custom-tools.service.ts b/packages/cli/src/modules/agents/agent-custom-tools.service.ts index 64beae402e2..c2c28fb8c6b 100644 --- a/packages/cli/src/modules/agents/agent-custom-tools.service.ts +++ b/packages/cli/src/modules/agents/agent-custom-tools.service.ts @@ -10,13 +10,12 @@ import isEqual from 'lodash/isEqual'; import { UserError } from 'n8n-workflow'; import { - AgentModificationTelemetryService, type AgentMutationTelemetryContext, + type AgentMutationSnapshot, buildAgentMutationEvent, captureAgentMutation, } from './agent-modification-telemetry.service'; -import { AgentRuntimeCacheService } from './agent-runtime-cache.service'; -import { AgentUpdateBroadcaster } from './agent-update-broadcaster'; +import { AgentSaveCompletionService } from './agent-save-completion.service'; import type { Agent } from './entities/agent.entity'; import { AgentRepository } from './repositories/agent.repository'; import { getAgentOrThrow } from './utils/get-agent-or-throw'; @@ -29,9 +28,7 @@ export class AgentCustomToolsService { constructor( private readonly logger: Logger, private readonly agentRepository: AgentRepository, - private readonly runtimeCacheService: AgentRuntimeCacheService, - private readonly modificationTelemetry: AgentModificationTelemetryService, - private readonly agentUpdateBroadcaster: AgentUpdateBroadcaster, + private readonly saveCompletion: AgentSaveCompletionService, ) {} /** @@ -73,12 +70,7 @@ export class AgentCustomToolsService { [toolId]: nextEntry, }; - const saved = await this.saveToolChanges(entity, projectId, context); - if (options.recordTelemetry !== false) { - this.modificationTelemetry.record( - buildAgentMutationEvent(saved, projectId, context, previous, { tools: true }), - ); - } + await this.saveToolChanges(entity, projectId, context, previous, options.recordTelemetry); this.logger.debug('Built custom tool', { agentId, projectId, toolId }); @@ -114,10 +106,7 @@ export class AgentCustomToolsService { ); } - const saved = await this.saveToolChanges(entity, projectId, context); - this.modificationTelemetry.record( - buildAgentMutationEvent(saved, projectId, context, previous, { tools: true }), - ); + await this.saveToolChanges(entity, projectId, context, previous); this.logger.debug('Deleted custom tool', { agentId, projectId, toolId }); } @@ -164,14 +153,15 @@ export class AgentCustomToolsService { entity: Agent, projectId: string, context: AgentMutationTelemetryContext, - ): Promise { + previous: AgentMutationSnapshot, + recordTelemetry = true, + ): Promise { markAgentDraftDirty(entity); - this.runtimeCacheService.clearRuntimes(entity.id); const saved = await saveAgentDraftFenced(this.agentRepository, entity); - this.agentUpdateBroadcaster.notify( - { projectId, agentId: entity.id, source: context.modifiedBy }, + await this.saveCompletion.bodySaved( + buildAgentMutationEvent(saved, projectId, context, previous, { tools: true }), context.pushRef, + recordTelemetry, ); - return saved; } } diff --git a/packages/cli/src/modules/agents/agent-definition.service.ts b/packages/cli/src/modules/agents/agent-definition.service.ts new file mode 100644 index 00000000000..da3e1a94db2 --- /dev/null +++ b/packages/cli/src/modules/agents/agent-definition.service.ts @@ -0,0 +1,64 @@ +import { TransactionRunner, type OperationContext } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { ConflictError } from '@n8n/errors'; +import { deepCopy } from 'n8n-workflow'; + +import type { AgentHistory } from './entities/agent-history.entity'; +import type { Agent } from './entities/agent.entity'; +import { AgentTaskSnapshotRepository } from './repositories/agent-task-snapshot.repository'; +import { AgentTaskRepository } from './repositories/agent-task.repository'; +import { AgentRepository } from './repositories/agent.repository'; +import { + getAgentDefinitionContent, + getAgentTaskBody, + type AgentDefinition, +} from './utils/agent-definition'; +import { saveAgentDraftFenced } from './utils/agent-draft.utils'; + +@Service() +export class AgentDefinitionService { + constructor( + private readonly taskRepository: AgentTaskRepository, + private readonly taskSnapshotRepository: AgentTaskSnapshotRepository, + private readonly agentRepository: AgentRepository, + private readonly transactionRunner: TransactionRunner, + ) {} + + async readDraft(agent: Agent): Promise { + const tasks = await this.taskRepository.findByAgentId(agent.id); + if (!(await this.agentRepository.hasRevision(agent.id, agent.revision))) { + throw new ConflictError('Agent was modified concurrently; please retry'); + } + return { + ...getAgentDefinitionContent(agent), + tasks: new Map(tasks.map((task) => [task.id, getAgentTaskBody(task)])), + }; + } + + async readVersion(version: AgentHistory, ctx: OperationContext = {}): Promise { + const tasks = await this.taskSnapshotRepository.findByVersionId(version.versionId, ctx); + return { + ...getAgentDefinitionContent(version), + tasks: new Map(tasks.map((task) => [task.taskId, getAgentTaskBody(task)])), + }; + } + + /** + * Replace the draft atomically. Return whether task bodies changed. + * The caller sets the version and integrations. + */ + async replaceDraft( + agent: Agent, + definition: AgentDefinition, + ctx: OperationContext = {}, + ): Promise { + return await this.transactionRunner.run(ctx, async (txCtx) => { + agent.schema = deepCopy(definition.schema); + agent.tools = deepCopy(definition.tools); + agent.skills = deepCopy(definition.skills); + if (agent.schema) agent.name = agent.schema.name; + await saveAgentDraftFenced(this.agentRepository, agent, txCtx); + return await this.taskRepository.replaceForAgent(agent.id, definition.tasks, txCtx); + }); + } +} diff --git a/packages/cli/src/modules/agents/agent-integration-persistence.service.ts b/packages/cli/src/modules/agents/agent-integration-persistence.service.ts index b5635f9f581..e88f90774c9 100644 --- a/packages/cli/src/modules/agents/agent-integration-persistence.service.ts +++ b/packages/cli/src/modules/agents/agent-integration-persistence.service.ts @@ -124,11 +124,9 @@ export class AgentIntegrationPersistenceService { /** * Apply one durable change to an agent's channels. * - * The delta is projected onto the freshly read column and only - * `integrations`/`versionId` are written, so it can neither clobber unrelated - * columns nor lose a concurrent channel change; a lost compare-and-set retries - * against a fresh read. Runtime connections are the caller's concern, and - * anything observable outside the row waits until the write has landed. + * Apply the delta to the current draft and advance its revision. + * Retry a lost compare-and-set with fresh state for setup validation. + * Leave runtime connections to the caller. Emit effects after the write. */ async applyIntegrationDelta( agent: Agent, @@ -183,8 +181,6 @@ export class AgentIntegrationPersistenceService { previousIntegrations: AgentIntegrationConfig[], context: CredentialIntegrationMutationContext, ): void { - // The schema is not re-read — it is not part of this write, and the entity - // copy is only used to classify the change for telemetry. const previousSchema = agent.schema ?? null; const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations); this.modificationTelemetry.record({ @@ -207,8 +203,10 @@ export class AgentIntegrationPersistenceService { context: CredentialIntegrationMutationContext, credentialProvider: ReturnType, ): Promise { - const state = await this.agentRepository.findIntegrationState(agent.id); + const state = await this.agentRepository.findById(agent.id); if (!state) throw new UserError(`Agent "${agent.id}" no longer exists`); + // Setup validation must use the definition that belongs to this revision. + Object.assign(agent, state); const current = state.integrations ?? []; const removed = remove @@ -216,16 +214,10 @@ export class AgentIntegrationPersistenceService { : undefined; const published = state.activeVersionId !== null; - // Callers derive their response and their runtime decisions from the - // entity, so correct it to what was read. Scalar only — nothing here reads - // the `activeVersion` relation, and fabricating one would be worse. - agent.activeVersionId = state.activeVersionId; // A removal of something already gone is not a failure — and with // nothing to add there is no write left to make. if (!add && !removed) { - agent.integrations = current; - agent.versionId = state.versionId; return { agent, changed: false, published }; } @@ -249,13 +241,11 @@ export class AgentIntegrationPersistenceService { private async persistIntegrations( agent: Agent, integrations: AgentIntegrationConfig[], - state: Pick, + state: Pick, context: CredentialIntegrationMutationContext, credentialProvider: ReturnType, ) { - // Always fresh: `versionId` is the compare-and-set token, so writing back - // the value we guarded on would let two concurrent writes both match. - // Consumers only compare it to `activeVersionId`, which a rotation keeps. + // Keep each channel change distinct from the published version. const versionId = uuid(); // Gate evaluated against the state about to be written; the marker is @@ -271,12 +261,17 @@ export class AgentIntegrationPersistenceService { const written = await this.agentRepository.updateIntegrations( agent.id, integrations, - { versionId: state.versionId, activeVersionId: state.activeVersionId }, + { + revision: state.revision, + versionId: state.versionId, + activeVersionId: state.activeVersionId, + }, versionId, ); if (!written) return undefined; agent.versionId = versionId; + agent.revision = state.revision + 1; return { emitSetupCompleted }; } } diff --git a/packages/cli/src/modules/agents/agent-modification-telemetry.service.ts b/packages/cli/src/modules/agents/agent-modification-telemetry.service.ts index 606a779fffa..974f666c784 100644 --- a/packages/cli/src/modules/agents/agent-modification-telemetry.service.ts +++ b/packages/cli/src/modules/agents/agent-modification-telemetry.service.ts @@ -79,7 +79,7 @@ export function diffAgentConfigParts( }); } -interface AgentModificationEvent { +export interface AgentModificationEvent { /** Post-save entity, so the reported profile is the one that landed. */ agent: Agent; projectId: string; diff --git a/packages/cli/src/modules/agents/agent-publish.service.ts b/packages/cli/src/modules/agents/agent-publish.service.ts index 5c0f3aedd7c..21dd29ab4fc 100644 --- a/packages/cli/src/modules/agents/agent-publish.service.ts +++ b/packages/cli/src/modules/agents/agent-publish.service.ts @@ -9,10 +9,14 @@ import { } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { EventService } from '@n8n/backend-services'; -import { isUniqueConstraintError, type User } from '@n8n/db'; +import { + TransactionRunner, + isUniqueConstraintError, + type OperationContext, + type User, +} from '@n8n/db'; import { Container, Service } from '@n8n/di'; import { TELEMETRY_EVENT } from '@n8n/telemetry'; -import type { EntityManager } from '@n8n/typeorm'; import isEqual from 'lodash/isEqual'; import { deepCopy, UserError } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; @@ -23,13 +27,15 @@ import { getMissingSkillIds } from '@/modules/agents/utils/agent-missing-skill-i import { Telemetry } from '@/telemetry'; import { AgentsCredentialProvider } from './adapters/agents-credential-provider'; +import { AgentDefinitionService } from './agent-definition.service'; +import { AgentSaveCompletionService } from './agent-save-completion.service'; +import type { AgentDefinition } from './utils/agent-definition'; import { AgentCustomToolsService } from './agent-custom-tools.service'; import { buildAgentCapabilityTelemetryProperties } from './agent-telemetry'; import { - AgentModificationTelemetryService, diffAgentConfigParts, type AgentActor, - type AgentSidecarChanges, + type AgentMutationTelemetryContext, } from './agent-modification-telemetry.service'; import { AgentRuntimeCacheService } from './agent-runtime-cache.service'; import { AgentSetupCompletionService } from './agent-setup-completion.service'; @@ -37,7 +43,6 @@ import { AgentUpdateBroadcaster } from './agent-update-broadcaster'; import { AgentValidationService } from './agent-validation.service'; import type { AgentHistory } from './entities/agent-history.entity'; import { AgentTask } from './entities/agent-task.entity'; -import type { AgentTaskSnapshot } from './entities/agent-task-snapshot.entity'; import type { Agent } from './entities/agent.entity'; import { ChatIntegrationService } from './integrations/chat-integration.service'; import { AgentHistoryRepository } from './repositories/agent-history.repository'; @@ -45,7 +50,6 @@ import { AgentTaskSnapshotRepository } from './repositories/agent-task-snapshot. import { AgentTaskRepository } from './repositories/agent-task.repository'; import { AgentRepository } from './repositories/agent.repository'; import { getAgentOrThrow } from './utils/get-agent-or-throw'; -import { saveAgentDraftFenced } from './utils/agent-draft.utils'; export type AgentPublishTrigger = 'explicit' | 'republish'; @@ -82,7 +86,7 @@ function requireValidValidation( function draftSchemaFromVersion(schema: AgentJsonConfig | null): AgentJsonConfig | null { if (!schema) return null; - const draft = deepCopy(schema); + const draft = { ...schema }; delete draft.integrations; return draft; } @@ -116,8 +120,10 @@ export class AgentPublishService { private readonly telemetry: Telemetry, private readonly eventService: EventService, private readonly setupCompletionService: AgentSetupCompletionService, - private readonly modificationTelemetry: AgentModificationTelemetryService, private readonly agentUpdateBroadcaster: AgentUpdateBroadcaster, + private readonly transactionRunner: TransactionRunner, + private readonly saveCompletion: AgentSaveCompletionService, + private readonly definitionService: AgentDefinitionService, ) {} /** `pushRef`: push connection of the tab that made the change; excluded from the `agentUpdated` broadcast. */ @@ -369,32 +375,10 @@ export class AgentPublishService { throw new ConflictError(`Agent "${agentId}" is not published`); } - const previousSchema = agent.schema; - const previousTools = agent.tools ?? {}; - const previousSkills = agent.skills ?? {}; - - let tasksChanged = false; - await this.agentRepository.manager.transaction(async (trx) => { - agent.schema = draftSchemaFromVersion(activeVersion.schema); - agent.tools = deepCopy(activeVersion.tools ?? {}); - agent.skills = deepCopy(activeVersion.skills ?? {}); - agent.versionId = activeVersion.versionId; - - if (agent.schema) { - agent.name = agent.schema.name; - } - - await saveAgentDraftFenced(this.agentRepository, agent, trx); - tasksChanged = await this.restoreTasksFromSnapshot(trx, agentId, activeVersion.versionId); - }); - this.eventService.emit('agent-saved', { agentId }); - this.agentUpdateBroadcaster.notify({ projectId, agentId, source: modifiedBy }, pushRef); - - this.runtimeCacheService.clearRuntimes(agentId); - await this.recordRevert(agent, projectId, user, modifiedBy, previousSchema, { - tools: !isEqual(previousTools, agent.tools ?? {}), - skills: !isEqual(previousSkills, agent.skills ?? {}), - tasks: tasksChanged, + await this.restoreVersion(agent, activeVersion, activeVersion.versionId, { + user, + modifiedBy, + pushRef, }); this.logger.debug('Reverted SDK agent to published version', { agentId, projectId }); @@ -411,42 +395,9 @@ export class AgentPublishService { ): Promise { const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId); - const previousSchema = agent.schema; - const previousTools = agent.tools ?? {}; - const previousSkills = agent.skills ?? {}; - - let tasksChanged = false; - await this.agentRepository.manager.transaction(async (trx) => { - const target = await this.agentHistoryRepository.findByVersionAndAgentId( - versionId, - agentId, - trx, - ); - if (!target) { - throw new NotFoundError(`Version "${versionId}" not found`); - } - - agent.schema = draftSchemaFromVersion(target.schema); - agent.tools = deepCopy(target.tools ?? {}); - agent.skills = deepCopy(target.skills ?? {}); - agent.versionId = uuid(); - - if (agent.schema) { - agent.name = agent.schema.name; - } - - await saveAgentDraftFenced(this.agentRepository, agent, trx); - tasksChanged = await this.restoreTasksFromSnapshot(trx, agentId, target.versionId); - }); - this.eventService.emit('agent-saved', { agentId }); - this.agentUpdateBroadcaster.notify({ projectId, agentId, source: modifiedBy }, pushRef); - - this.runtimeCacheService.clearRuntimes(agentId); - await this.recordRevert(agent, projectId, user, modifiedBy, previousSchema, { - tools: !isEqual(previousTools, agent.tools ?? {}), - skills: !isEqual(previousSkills, agent.skills ?? {}), - tasks: tasksChanged, - }); + const version = await this.agentHistoryRepository.findByVersionAndAgentId(versionId, agentId); + if (!version) throw new NotFoundError(`Version "${versionId}" not found`); + await this.restoreVersion(agent, version, uuid(), { user, modifiedBy, pushRef }); this.logger.debug('Reverted SDK agent to a specific version', { agentId, @@ -456,36 +407,45 @@ export class AgentPublishService { return agent; } - /** - * A revert restores the schema but leaves draft integrations unchanged. - * The published n8n Chat entry is removed from the restored draft schema. - * Sidecar body flags cover tool/skill/task bodies restored outside the schema. - */ - private async recordRevert( + /** Restore versioned content and keep current credential-backed integrations. */ + private async restoreVersion( agent: Agent, - projectId: string, - user: User, - modifiedBy: AgentActor, - previousSchema: AgentJsonConfig | null, - sidecarChanges: AgentSidecarChanges, + version: AgentHistory, + nextVersionId: string, + context: AgentMutationTelemetryContext, ): Promise { - const integrations = agent.integrations ?? []; - this.modificationTelemetry.record({ - agent, - projectId, - user, - by: modifiedBy, - changedParts: diffAgentConfigParts( - previousSchema, - agent.schema, - integrations, - integrations, - sidecarChanges, - ), - // A revert needs a published version to revert to, so the agent was - // configured long before this. - wasUnconfigured: false, + const previousSchema = agent.schema; + const previousTools = agent.tools ?? {}; + const previousSkills = agent.skills ?? {}; + const tasksChanged = await this.transactionRunner.run({}, async (ctx) => { + const definition = await this.definitionService.readVersion(version, ctx); + definition.schema = draftSchemaFromVersion(definition.schema); + agent.versionId = nextVersionId; + return await this.definitionService.replaceDraft(agent, definition, ctx); }); + const integrations = agent.integrations ?? []; + await this.saveCompletion.configurationSaved( + { + agent, + projectId: agent.projectId, + user: context.user, + by: context.modifiedBy, + changedParts: diffAgentConfigParts( + previousSchema, + agent.schema, + integrations, + integrations, + { + tools: !isEqual(previousTools, agent.tools), + skills: !isEqual(previousSkills, agent.skills), + tasks: tasksChanged, + }, + ), + wasUnconfigured: false, + }, + context.pushRef, + null, + ); } /** @@ -504,7 +464,7 @@ export class AgentPublishService { agentId: string, projectId: string, versionId: string, - ): Promise<{ agent: Agent; version: AgentHistory; tasks: AgentTaskSnapshot[] }> { + ): Promise<{ agent: Agent; version: AgentHistory; definition: AgentDefinition }> { const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId); const version = await this.agentHistoryRepository.findByVersionAndAgentId(versionId, agentId); @@ -512,8 +472,8 @@ export class AgentPublishService { throw new NotFoundError(`Version "${versionId}" not found for agent "${agentId}"`); } - const tasks = await this.agentTaskSnapshotRepository.findByVersionId(versionId); - return { agent, version, tasks }; + const definition = await this.definitionService.readVersion(version); + return { agent, version, definition }; } async listPublishHistory( @@ -544,7 +504,7 @@ export class AgentPublishService { * the snapshot can never diverge from what was just validated. */ private async snapshotConfiguredTasks( - trx: EntityManager, + ctx: OperationContext, versionId: string, config: AgentJsonConfig | null, tasks: ReadonlyMap, @@ -574,7 +534,7 @@ export class AgentPublishService { timezone: body.timezone, }; }), - trx, + ctx, ); } @@ -598,47 +558,6 @@ export class AgentPublishService { return snapshot; } - /** - * Bring the draft task definition rows back in line with a published snapshot - * on revert. Returns whether task bodies changed (name/objective/cron/timezone - * only). - */ - private async restoreTasksFromSnapshot( - trx: EntityManager, - agentId: string, - versionId: string, - ): Promise { - const repo = trx.getRepository(AgentTask); - const existing = await repo.findBy({ agentId }); - const snapshots = await this.agentTaskSnapshotRepository.findByVersionId(versionId, trx); - - const existingBodies = Object.fromEntries(existing.map((row) => [row.id, taskBody(row)])); - const snapshotBodies = Object.fromEntries( - snapshots.map((snapshot) => [snapshot.taskId, taskBody(snapshot)]), - ); - const tasksChanged = !isEqual(existingBodies, snapshotBodies); - - const snapshotIds = new Set(snapshots.map((snapshot) => snapshot.taskId)); - - const orphanIds = existing.filter((row) => !snapshotIds.has(row.id)).map((row) => row.id); - if (orphanIds.length > 0) await repo.delete(orphanIds); - - const existingIds = new Set(existing.map((row) => row.id)); - for (const snapshot of snapshots) { - if (existingIds.has(snapshot.taskId)) { - await repo.update(snapshot.taskId, taskBody(snapshot)); - } else { - await repo.insert({ - id: snapshot.taskId, - agentId, - ...taskBody(snapshot), - }); - } - } - - return tasksChanged; - } - private async startPublishedServices(agent: Agent): Promise { const agentId = agent.id; const credentialIntegrations = (agent.integrations ?? []).filter(isCredentialAgentIntegration); @@ -662,7 +581,7 @@ export class AgentPublishService { } private async commitUnpublication(agent: Agent, expectedRevision: number): Promise { - await this.agentRepository.manager.transaction(async (trx) => { + await this.transactionRunner.run({}, async (ctx) => { const nextVersionId = uuid(); // Fence first: only mutate the in-memory entity once the row is ours, @@ -672,7 +591,7 @@ export class AgentPublishService { agent.id, expectedRevision, { activeVersionId: null, versionId: nextVersionId }, - trx, + ctx, ); if (!won) { throw new ConflictError('Agent was modified concurrently while unpublishing; please retry'); @@ -702,13 +621,13 @@ export class AgentPublishService { tasks: ReadonlyMap, targetHistory?: AgentHistory, ): Promise { - await this.agentRepository.manager.transaction(async (trx) => { - const next = await this.preparePublishedVersion(trx, agent, user, tasks, targetHistory); + await this.transactionRunner.run({}, async (ctx) => { + const next = await this.preparePublishedVersion(ctx, agent, user, tasks, targetHistory); const won = await this.agentRepository.setActiveVersionFenced( agent.id, expectedRevision, { activeVersionId: next.activeVersionId, versionId: next.versionId }, - trx, + ctx, ); if (!won) throw new ConflictError('Agent was modified concurrently while publishing; please retry'); @@ -721,7 +640,7 @@ export class AgentPublishService { } private async preparePublishedVersion( - trx: EntityManager, + ctx: OperationContext, agent: Agent, user: User, tasks: ReadonlyMap, @@ -735,12 +654,17 @@ export class AgentPublishService { }; } const versionId = agent.versionId ?? uuid(); - const activeVersion = await this.saveDraftHistory(trx, agent, user, versionId); - await this.snapshotConfiguredTasks(trx, versionId, agent.schema, tasks); + const activeVersion = await this.saveDraftHistory(ctx, agent, user, versionId); + await this.snapshotConfiguredTasks(ctx, versionId, agent.schema, tasks); return { activeVersionId: versionId, activeVersion, versionId }; } - private async saveDraftHistory(trx: EntityManager, agent: Agent, user: User, versionId: string) { + private async saveDraftHistory( + ctx: OperationContext, + agent: Agent, + user: User, + versionId: string, + ) { try { return await this.agentHistoryRepository.saveVersion( { @@ -758,7 +682,7 @@ export class AgentPublishService { skills: this.pickConfiguredSkillBodies(agent.schema, agent.skills ?? {}), publishedBy: user, }, - trx, + ctx, ); } catch (error) { // Concurrent publishes of one draft can collide before they reach the revision fence. @@ -769,8 +693,3 @@ export class AgentPublishService { } } } - -function taskBody(task: Pick) { - const { name, objective, cronExpression, timezone } = task; - return { name, objective, cronExpression, timezone }; -} diff --git a/packages/cli/src/modules/agents/agent-save-completion.service.ts b/packages/cli/src/modules/agents/agent-save-completion.service.ts new file mode 100644 index 00000000000..19ac12dd570 --- /dev/null +++ b/packages/cli/src/modules/agents/agent-save-completion.service.ts @@ -0,0 +1,56 @@ +import { EventService } from '@n8n/backend-services'; +import { Container, Service } from '@n8n/di'; + +import { + AgentModificationTelemetryService, + type AgentModificationEvent, +} from './agent-modification-telemetry.service'; +import { AgentUpdateBroadcaster } from './agent-update-broadcaster'; + +/** Run save effects only after the complete draft write commits. */ +@Service() +export class AgentSaveCompletionService { + constructor( + private readonly eventService: EventService, + private readonly broadcaster: AgentUpdateBroadcaster, + private readonly modificationTelemetry: AgentModificationTelemetryService, + ) {} + + async configurationSaved( + event: AgentModificationEvent, + pushRef: string | undefined, + emitSetupCompleted: (() => Promise) | null, + ): Promise { + await this.clearRuntimes(event.agent.id); + this.eventService.emit('agent-saved', { agentId: event.agent.id }); + this.notify(event, pushRef); + await emitSetupCompleted?.(); + } + + async bodySaved( + event: AgentModificationEvent, + pushRef?: string, + recordTelemetry = true, + ): Promise { + await this.clearRuntimes(event.agent.id); + this.notify(event, pushRef, recordTelemetry); + } + + // Task bodies are read for each run. A draft task edit does not change scheduling. + taskSaved(event: AgentModificationEvent, pushRef?: string): void { + this.notify(event, pushRef); + } + + private notify(event: AgentModificationEvent, pushRef?: string, recordTelemetry = true): void { + this.broadcaster.notify( + { projectId: event.projectId, agentId: event.agent.id, source: event.by }, + pushRef, + ); + if (recordTelemetry) this.modificationTelemetry.record(event); + } + + private async clearRuntimes(agentId: string): Promise { + const { AgentRuntimeCacheService } = await import('./agent-runtime-cache.service.js'); + Container.get(AgentRuntimeCacheService).clearRuntimes(agentId); + } +} diff --git a/packages/cli/src/modules/agents/agent-skills.service.ts b/packages/cli/src/modules/agents/agent-skills.service.ts index 2bf1be3691e..59f72a254c1 100644 --- a/packages/cli/src/modules/agents/agent-skills.service.ts +++ b/packages/cli/src/modules/agents/agent-skills.service.ts @@ -5,20 +5,19 @@ import { type AgentSkillMutationResponse, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; -import { Container, Service } from '@n8n/di'; +import { Service } from '@n8n/di'; import isEqual from 'lodash/isEqual'; import { UserError } from 'n8n-workflow'; import { ConflictError, NotFoundError } from '@n8n/errors'; import { - AgentModificationTelemetryService, type AgentMutationTelemetryContext, buildAgentMutationEvent, captureAgentMutation, type AgentMutationSnapshot, } from './agent-modification-telemetry.service'; -import { AgentUpdateBroadcaster } from './agent-update-broadcaster'; +import { AgentSaveCompletionService } from './agent-save-completion.service'; import { markAgentDraftDirty, saveAgentDraftFenced } from './utils/agent-draft.utils'; import { Agent } from './entities/agent.entity'; import { AgentRepository } from './repositories/agent.repository'; @@ -31,8 +30,7 @@ export class AgentSkillsService { constructor( private readonly logger: Logger, private readonly agentRepository: AgentRepository, - private readonly modificationTelemetry: AgentModificationTelemetryService, - private readonly agentUpdateBroadcaster: AgentUpdateBroadcaster, + private readonly saveCompletion: AgentSaveCompletionService, ) {} async listSkills(agentId: string, projectId: string): Promise> { @@ -302,10 +300,6 @@ export class AgentSkillsService { ]; } - private async clearRuntimes(agentId: string): Promise { - const { AgentRuntimeCacheService } = await import('./agent-runtime-cache.service.js'); - Container.get(AgentRuntimeCacheService).clearRuntimes(agentId); - } private async saveSkillChanges( entity: Agent, projectId: string, @@ -314,13 +308,9 @@ export class AgentSkillsService { ): Promise { markAgentDraftDirty(entity); const saved = await saveAgentDraftFenced(this.agentRepository, entity); - this.agentUpdateBroadcaster.notify( - { projectId, agentId: entity.id, source: context.modifiedBy }, - context.pushRef, - ); - await this.clearRuntimes(entity.id); - this.modificationTelemetry.record( + await this.saveCompletion.bodySaved( buildAgentMutationEvent(saved, projectId, context, previous, { skills: true }), + context.pushRef, ); return saved; } diff --git a/packages/cli/src/modules/agents/agent-task.service.ts b/packages/cli/src/modules/agents/agent-task.service.ts index 6e21e08c290..b80219157f9 100644 --- a/packages/cli/src/modules/agents/agent-task.service.ts +++ b/packages/cli/src/modules/agents/agent-task.service.ts @@ -2,7 +2,7 @@ import type { AgentTaskDto, CreateAgentTaskDto, UpdateAgentTaskDto } from '@n8n/ import { isValidTimeZone } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; -import type { User } from '@n8n/db'; +import { TransactionRunner, type User } from '@n8n/db'; import { OnLeaderStepdown, OnLeaderTakeover, OnPubSubEvent, OnShutdown } from '@n8n/decorators'; import { Service } from '@n8n/di'; import { IsNull, Not } from '@n8n/typeorm'; @@ -14,14 +14,13 @@ import { BadRequestError, NotFoundError } from '@n8n/errors'; import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map'; import { - AgentModificationTelemetryService, type AgentMutationTelemetryContext, buildAgentMutationEvent, captureAgentMutation, } from './agent-modification-telemetry.service'; import { AgentExecutionOrchestratorService } from './agent-execution-orchestrator.service'; import { AgentChangePublisher } from './agent-change-publisher.service'; -import { AgentUpdateBroadcaster } from './agent-update-broadcaster'; +import { AgentSaveCompletionService } from './agent-save-completion.service'; import { AgentsSettingsService } from './agents-settings.service'; import { AgentTaskJobRegistrar } from './scheduling/agent-task-job-registrar'; import { knownTaskTimezone } from './scheduling/task-timezone'; @@ -75,10 +74,10 @@ export class AgentTaskService { private readonly instanceSettings: InstanceSettings, private readonly scheduledTaskManager: ScheduledTaskManager, private readonly changePublisher: AgentChangePublisher, - private readonly modificationTelemetry: AgentModificationTelemetryService, private readonly durableJobRegistrar: AgentTaskJobRegistrar, - private readonly agentUpdateBroadcaster: AgentUpdateBroadcaster, + private readonly saveCompletion: AgentSaveCompletionService, private readonly settingsService: AgentsSettingsService, + private readonly transactionRunner: TransactionRunner, ) {} // ── CRUD ────────────────────────────────────────────────────────────── @@ -163,19 +162,13 @@ export class AgentTaskService { markAgentDraftDirty(agent); - await this.agentRepository.manager.transaction(async (em) => { - for (const task of tasks) { - await em.save(task); - } - await saveAgentDraftFenced(this.agentRepository, agent, em); + await this.transactionRunner.run({}, async (ctx) => { + await saveAgentDraftFenced(this.agentRepository, agent, ctx); + await this.taskRepository.saveDefinitions(tasks, ctx); }); - this.agentUpdateBroadcaster.notify( - { projectId, agentId, source: context.modifiedBy }, - context.pushRef, - ); - - this.modificationTelemetry.record( + this.saveCompletion.taskSaved( buildAgentMutationEvent(agent, projectId, context, previous, { tasks: true }), + context.pushRef, ); this.logger.debug('[AgentTaskService] Created tasks', { @@ -210,30 +203,26 @@ export class AgentTaskService { dto: UpdateAgentTaskDto, context: AgentMutationTelemetryContext, ): Promise<{ task: AgentTaskDto; changed: boolean }> { + // Read the revision before the task to detect concurrent replacements. + const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId); const task = await this.getOrThrow(agentId, taskId); const changed = this.applyTaskUpdates(task, dto); - // Nothing actually changed — skip the agent lookup, draft-dirty bump, and writes. + // Skip the draft version change and writes when no field changed. if (!changed) return { task: this.toDto(task), changed: false }; - const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId); - const previous = captureAgentMutation(agent); - const saved = await this.agentRepository.manager.transaction(async (em) => { - const savedTask = await em.save(task); + const saved = await this.transactionRunner.run({}, async (ctx) => { markAgentDraftDirty(agent); - await saveAgentDraftFenced(this.agentRepository, agent, em); + await saveAgentDraftFenced(this.agentRepository, agent, ctx); + const [savedTask] = await this.taskRepository.saveDefinitions([task], ctx); return savedTask; }); - this.agentUpdateBroadcaster.notify( - { projectId, agentId, source: context.modifiedBy }, - context.pushRef, - ); - - this.modificationTelemetry.record( + this.saveCompletion.taskSaved( buildAgentMutationEvent(agent, projectId, context, previous, { tasks: true }), + context.pushRef, ); return { task: this.toDto(saved), changed: true }; @@ -246,7 +235,7 @@ export class AgentTaskService { taskId: string, context: AgentMutationTelemetryContext, ): Promise { - const task = await this.getOrThrow(agentId, taskId); + await this.getOrThrow(agentId, taskId); const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId); const previous = captureAgentMutation(agent); @@ -256,17 +245,13 @@ export class AgentTaskService { markAgentDraftDirty(agent); } - await this.agentRepository.manager.transaction(async (em) => { - await em.remove(task); - await saveAgentDraftFenced(this.agentRepository, agent, em); + await this.transactionRunner.run({}, async (ctx) => { + await saveAgentDraftFenced(this.agentRepository, agent, ctx); + await this.taskRepository.deleteForAgent(agentId, [taskId], ctx); }); - this.agentUpdateBroadcaster.notify( - { projectId, agentId, source: context.modifiedBy }, - context.pushRef, - ); - - this.modificationTelemetry.record( + this.saveCompletion.taskSaved( buildAgentMutationEvent(agent, projectId, context, previous, { tasks: true }), + context.pushRef, ); this.logger.debug('[AgentTaskService] Deleted task', { agentId, taskId }); diff --git a/packages/cli/src/modules/agents/agent-validation.service.ts b/packages/cli/src/modules/agents/agent-validation.service.ts index 24ecb2ab164..3eb7b9eac2f 100644 --- a/packages/cli/src/modules/agents/agent-validation.service.ts +++ b/packages/cli/src/modules/agents/agent-validation.service.ts @@ -34,12 +34,12 @@ import { NodeTypes } from '@/node-types'; import { checkAiGatewayEligibility } from '@/services/ai-gateway-eligibility'; import { AiGatewayService } from '@/services/ai-gateway.service'; +import { AgentDefinitionService } from './agent-definition.service'; +import { getAgentDefinitionContent, type AgentDefinition } from './utils/agent-definition'; import type { AgentHistory } from './entities/agent-history.entity'; import type { Agent } from './entities/agent.entity'; import { ChatIntegrationRegistry } from './integrations/agent-chat-integration'; import { isValidCronExpression } from './integrations/cron-validation'; -import { AgentTaskSnapshotRepository } from './repositories/agent-task-snapshot.repository'; -import { AgentTaskRepository } from './repositories/agent-task.repository'; import { AgentRepository } from './repositories/agent.repository'; import { findWorkflowToolWorkflows } from './tools/workflow-tool-workflow-resolver'; import { extractAgentWorkflowRefs } from './utils/extract-agent-workflow-refs'; @@ -85,8 +85,7 @@ function agentIssue( export class AgentValidationService { constructor( private readonly agentRepository: AgentRepository, - private readonly agentTaskRepository: AgentTaskRepository, - private readonly agentTaskSnapshotRepository: AgentTaskSnapshotRepository, + private readonly definitionService: AgentDefinitionService, private readonly nodeTypes: NodeTypes, private readonly workflowRepository: WorkflowRepository, private readonly chatIntegrationRegistry: ChatIntegrationRegistry, @@ -160,17 +159,21 @@ export class AgentValidationService { credentialProvider: CredentialProvider, scope: AgentValidationScope = 'publish', ): Promise { - const tasks = - scope === 'publish' - ? new Map( - (await this.agentTaskRepository.findByAgentId(agent.id)).map((task) => [task.id, task]), - ) - : new Map(); - - return await this.validateAgentEntityConfiguration( - agent, + if (scope === 'runtime') { + return await this.validateAgentEntityConfiguration( + agent, + projectId, + new Map(), + credentialProvider, + scope, + ); + } + const definition = await this.definitionService.readDraft(agent); + return await this.validateDefinition( + agent.id, projectId, - tasks, + definition, + agent.integrations ?? [], credentialProvider, scope, ); @@ -192,17 +195,12 @@ export class AgentValidationService { credentialProvider: CredentialProvider, scope: AgentValidationScope = 'publish', ): Promise { - return await this.runValidation( - { - agentId: agent.id, - projectId, - config: agent.schema, - skills: agent.skills ?? {}, - customTools: agent.tools ?? {}, - integrations: agent.integrations ?? [], - tasks, - credentialProvider, - }, + return await this.validateDefinition( + agent.id, + projectId, + { ...getAgentDefinitionContent(agent), tasks }, + agent.integrations ?? [], + credentialProvider, scope, ); } @@ -220,24 +218,37 @@ export class AgentValidationService { currentIntegrations: AgentIntegrationConfig[], credentialProvider: CredentialProvider, ): Promise { - const tasks = new Map( - (await this.agentTaskSnapshotRepository.findByVersionId(history.versionId)).map( - (snapshot) => [snapshot.taskId, snapshot], - ), + const definition = await this.definitionService.readVersion(history); + return await this.validateDefinition( + agentId, + projectId, + definition, + currentIntegrations, + credentialProvider, + 'publish', ); + } + private async validateDefinition( + agentId: string, + projectId: string, + definition: AgentDefinition, + integrations: AgentIntegrationConfig[], + credentialProvider: CredentialProvider, + scope: AgentValidationScope, + ): Promise { return await this.runValidation( { agentId, projectId, - config: history.schema, - skills: history.skills ?? {}, - customTools: history.tools ?? {}, - integrations: currentIntegrations, - tasks, + config: definition.schema, + skills: definition.skills, + customTools: definition.tools, + tasks: definition.tasks, + integrations, credentialProvider, }, - 'publish', + scope, ); } diff --git a/packages/cli/src/modules/agents/json-config/__tests__/sanitize-unknown-agent-credentials.test.ts b/packages/cli/src/modules/agents/json-config/__tests__/sanitize-unknown-agent-credentials.test.ts index 26cddb55212..0e8dcb15e60 100644 --- a/packages/cli/src/modules/agents/json-config/__tests__/sanitize-unknown-agent-credentials.test.ts +++ b/packages/cli/src/modules/agents/json-config/__tests__/sanitize-unknown-agent-credentials.test.ts @@ -239,9 +239,6 @@ describe('sanitizeUnknownAgentCredentials', () => { }); it('preserves the n8n Connect managed sentinel on a node-tool credential', () => { - // Relies on the non-string-id recursion branch of the `credentials` - // handler — this pin exists so a refactor of that branch can't silently - // start clearing managed refs. const result = sanitizeUnknownAgentCredentials( { tools: [ @@ -278,7 +275,7 @@ describe('sanitizeUnknownAgentCredentials', () => { }); }); - it('clears unknown credentialId fields at arbitrary nesting depth', () => { + it('clears unknown integration and memory credentials', () => { const result = sanitizeUnknownAgentCredentials( { integrations: [{ type: 'slack', credentialId: 'unknown-cred' }], @@ -303,21 +300,43 @@ describe('sanitizeUnknownAgentCredentials', () => { }); }); - it('clears unknown credentials map ids but leaves unrelated id fields untouched', () => { - const result = sanitizeUnknownAgentCredentials( - { - tools: [ - { type: 'custom', id: 'tool-1', credentials: { openAiApi: { id: 'unknown-cred' } } }, - ], - tasks: [{ type: 'task', id: 'task-1', enabled: true }], - }, - accessibleCredentialIds, - ); + it('clears node credentials without changing authored inputs or the original config', () => { + const authoredInput = { + credentialId: 'remote-id', + credential: 'authored-value', + credentials: { remote: { id: 'remote-value' } }, + }; + const node = { + nodeType: 'n8n-nodes-base.n8n', + nodeTypeVersion: 1, + credentials: { n8nApi: { id: 'unknown-cred', name: 'Remote instance' } }, + nodeParameters: authoredInput, + }; + const config = { + tools: [ + { type: 'node', name: 'Remote', enabled: false, node }, + { type: 'workflow', inputs: { data: { value: authoredInput } } }, + ], + providerTools: { custom: authoredInput }, + tasks: [{ type: 'task', id: 'task-1', enabled: true }], + }; + const original = structuredClone(config); + const result = sanitizeUnknownAgentCredentials(config, accessibleCredentialIds); expect(result).toEqual({ - tools: [{ type: 'custom', id: 'tool-1', credentials: { openAiApi: { id: '' } } }], + tools: [ + { + type: 'node', + name: 'Remote', + enabled: false, + node: { ...node, credentials: { n8nApi: { id: '', name: 'Remote instance' } } }, + }, + { type: 'workflow', inputs: { data: { value: authoredInput } } }, + ], + providerTools: { custom: authoredInput }, tasks: [{ type: 'task', id: 'task-1', enabled: true }], }); + expect(config).toEqual(original); }); it('clears unknown credentials on vector store connections, including the nested embedding credential', () => { diff --git a/packages/cli/src/modules/agents/json-config/prune-missing-config-references.ts b/packages/cli/src/modules/agents/json-config/prune-missing-config-references.ts new file mode 100644 index 00000000000..beadccc9477 --- /dev/null +++ b/packages/cli/src/modules/agents/json-config/prune-missing-config-references.ts @@ -0,0 +1,33 @@ +import type { AgentJsonConfig } from '@n8n/api-types'; + +import type { AgentDefinition } from '../utils/agent-definition'; + +/** Preserve existing and disabled refs while new refs require an available body. */ +export function pruneMissingConfigReferences( + config: AgentJsonConfig, + previousSchema: AgentJsonConfig | null, + available: Pick & { taskIds: ReadonlySet }, +): void { + if (config.skills !== undefined) { + const existingIds = new Set((previousSchema?.skills ?? []).map((ref) => ref.id)); + config.skills = config.skills.filter( + (ref) => + ref.enabled === false || existingIds.has(ref.id) || Object.hasOwn(available.skills, ref.id), + ); + } + if (config.tools !== undefined) { + const existingIds = new Set( + (previousSchema?.tools ?? []).filter((ref) => ref.type === 'custom').map((ref) => ref.id), + ); + config.tools = config.tools.filter( + (ref) => + ref.enabled === false || + ref.type !== 'custom' || + existingIds.has(ref.id) || + Object.hasOwn(available.tools, ref.id), + ); + } + if (config.tasks !== undefined) { + config.tasks = config.tasks.filter((ref) => available.taskIds.has(ref.id)); + } +} diff --git a/packages/cli/src/modules/agents/json-config/sanitize-unknown-agent-credentials.ts b/packages/cli/src/modules/agents/json-config/sanitize-unknown-agent-credentials.ts index 6f9e15ab004..514b2194b73 100644 --- a/packages/cli/src/modules/agents/json-config/sanitize-unknown-agent-credentials.ts +++ b/packages/cli/src/modules/agents/json-config/sanitize-unknown-agent-credentials.ts @@ -1,167 +1,23 @@ -import { - AI_GATEWAY_MANAGED_TAG, - isDraftAgentConfig, - MANAGED_CREDENTIAL_TOKEN, - SUB_AGENT_TASK_DIFFICULTIES, -} from '@n8n/api-types'; +import { isDraftAgentConfig } from '@n8n/api-types'; import { isRecord } from '@n8n/utils/is-record'; -function clearUnknownCredentialId( - credentialId: unknown, - accessibleCredentialIds: ReadonlySet, - allowedManagedTokens: readonly string[] = [], -): unknown { - if (typeof credentialId !== 'string' || credentialId === '') { - return credentialId; - } +import { visitAgentCredentialReferences } from '../utils/visit-agent-credential-references'; - if (allowedManagedTokens.includes(credentialId)) { - return credentialId; - } - - return accessibleCredentialIds.has(credentialId) ? credentialId : ''; -} - -function isManagedEpisodicMemoryCredentialPath(path: readonly string[]): boolean { - return path.join('.') === 'memory.episodicMemory.credential'; -} - -/** - * Model-credential paths where the n8n Connect managed tag is a valid value. - * Memory worker models are ordinary chat models, so the gateway can serve them - * just like the main and per-difficulty models. - */ -const AI_GATEWAY_MODEL_CREDENTIAL_PATHS: ReadonlySet = new Set([ - 'credential', - ...SUB_AGENT_TASK_DIFFICULTIES.map( - (difficulty) => `subAgents.modelsByDifficulty.${difficulty}.credential`, - ), - 'memory.observationalMemory.observerModel.credential', - 'memory.observationalMemory.reflectorModel.credential', - 'memory.episodicMemory.reflectorModel.credential', -]); - -function isAiGatewayModelCredentialPath(path: readonly string[]): boolean { - return AI_GATEWAY_MODEL_CREDENTIAL_PATHS.has(path.join('.')); -} - -/** The web-search credential can carry the n8n Connect tag for the Brave provider. */ -function isAiGatewayWebSearchCredentialPath(path: readonly string[]): boolean { - return path.join('.') === 'config.webSearch.credential'; -} - -/** Managed credential tokens allowed to survive sanitization at the given path. */ -function allowedManagedTokensForPath(path: readonly string[]): string[] { - const tokens: string[] = []; - if (isManagedEpisodicMemoryCredentialPath(path)) tokens.push(MANAGED_CREDENTIAL_TOKEN); - if (isAiGatewayModelCredentialPath(path) || isAiGatewayWebSearchCredentialPath(path)) { - tokens.push(AI_GATEWAY_MANAGED_TAG); - } - return tokens; -} - -function sanitizeUnknownCredentialsInValue( - value: unknown, - accessibleCredentialIds: ReadonlySet, - path: readonly string[] = [], -): unknown { - if (Array.isArray(value)) { - return value.map((entry) => - sanitizeUnknownCredentialsInValue(entry, accessibleCredentialIds, path), - ); - } - - if (typeof value !== 'object' || value === null) { - return value; - } - - const record = value as Record; - const sanitized: Record = {}; - - for (const [key, entry] of Object.entries(record)) { - const nextPath = [...path, key]; - if (key === 'credential' && typeof entry === 'string') { - sanitized[key] = clearUnknownCredentialId( - entry, - accessibleCredentialIds, - allowedManagedTokensForPath(nextPath), - ); - continue; - } - - if (key === 'credentialId' && typeof entry === 'string') { - sanitized[key] = clearUnknownCredentialId(entry, accessibleCredentialIds); - continue; - } - - if ( - key === 'credentials' && - typeof entry === 'object' && - entry !== null && - !Array.isArray(entry) - ) { - sanitized[key] = Object.fromEntries( - Object.entries(entry as Record).map(([credType, credRef]) => { - if (typeof credRef !== 'object' || credRef === null || Array.isArray(credRef)) { - return [credType, credRef]; - } - - const credentialRef = credRef as Record; - if (!('id' in credentialRef) || typeof credentialRef.id !== 'string') { - return [ - credType, - sanitizeUnknownCredentialsInValue(credentialRef, accessibleCredentialIds, [ - ...nextPath, - credType, - ]), - ]; - } - - return [ - credType, - { - ...credentialRef, - id: clearUnknownCredentialId(credentialRef.id, accessibleCredentialIds), - }, - ]; - }), - ); - continue; - } - - sanitized[key] = sanitizeUnknownCredentialsInValue(entry, accessibleCredentialIds, nextPath); - } - - return sanitized; -} -/** - * Replace credential IDs that are not accessible to the agent project with `""`. - * Walks the config recursively and only targets credential-like fields: - * `credential`, `credentialId`, and `credentials.*.id`. - * - * A top-level credential without a model is also cleared so legacy rows - * converge to a clean draft instead of failing the `credential ⇒ model` - * schema refine. - */ +/** Clear inaccessible references without changing authored tool inputs. */ export function sanitizeUnknownAgentCredentials( raw: unknown, accessibleCredentialIds: ReadonlySet, ): unknown { - if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { - return raw; - } + if (!isRecord(raw)) return raw; - const sanitized = sanitizeUnknownCredentialsInValue(raw, accessibleCredentialIds); - if (!isRecord(sanitized)) { - return sanitized; - } + const sanitized = structuredClone(raw); + visitAgentCredentialReferences(sanitized, ({ id, setId, managedToken }) => { + if (id !== managedToken && !accessibleCredentialIds.has(id)) setId(''); + }); + // Legacy drafts can retain a credential after their model is removed. const model = typeof sanitized.model === 'string' ? sanitized.model : undefined; - if ( - isDraftAgentConfig({ model }) && - typeof sanitized.credential === 'string' && - sanitized.credential !== '' - ) { + if (isDraftAgentConfig({ model }) && typeof sanitized.credential === 'string') { sanitized.credential = ''; } diff --git a/packages/cli/src/modules/agents/repositories/agent-credential-dependency.repository.ts b/packages/cli/src/modules/agents/repositories/agent-credential-dependency.repository.ts index 9e40941a41c..a53bb960ced 100644 --- a/packages/cli/src/modules/agents/repositories/agent-credential-dependency.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-credential-dependency.repository.ts @@ -49,7 +49,10 @@ export class AgentCredentialDependencyRepository extends BaseRepository { - constructor(dataSource: DataSource) { - super(AgentHistory, dataSource.manager); +export class AgentHistoryRepository extends BaseRepository { + constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + super(AgentHistory, dataSource.manager, transactionRunner); } /** - * Insert an immutable snapshot for the agent at `versionId`. Pass `trx` - * to participate in an existing transaction. + * Insert an immutable snapshot for the agent at `versionId`. * * `publishedBy` accepts either the publishing `User` (renders both the * `author` display string and the `publishedById` runtime pointer) or a @@ -45,12 +43,12 @@ export class AgentHistoryRepository extends Repository { skills: Agent['skills'] | null; publishedBy: User | string; }, - trx?: EntityManager, + ctx: OperationContext = {}, ): Promise { const { publishedBy } = data; const author = typeof publishedBy === 'string' ? publishedBy : renderAuthor(publishedBy); const publishedById = typeof publishedBy === 'string' ? null : publishedBy.id; - const repo = trx?.getRepository(AgentHistory) ?? this; + const repo = this.managerFor(ctx).getRepository(AgentHistory); // TypeORM's QueryDeepPartialEntity can't express our @JsonColumn shapes // (Zod-inferred AgentJsonConfig, the tools `Record`, etc.), // so each JSON field is cast individually. The non-JSON fields @@ -72,9 +70,9 @@ export class AgentHistoryRepository extends Repository { async findByVersionAndAgentId( versionId: string, agentId: string, - trx?: EntityManager, + ctx: OperationContext = {}, ): Promise { - const repo = trx?.getRepository(AgentHistory) ?? this; + const repo = this.managerFor(ctx).getRepository(AgentHistory); return await repo.findOneBy({ versionId, agentId }); } diff --git a/packages/cli/src/modules/agents/repositories/agent-task-snapshot.repository.ts b/packages/cli/src/modules/agents/repositories/agent-task-snapshot.repository.ts index 65fd52fca07..bb0f394d8bf 100644 --- a/packages/cli/src/modules/agents/repositories/agent-task-snapshot.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-task-snapshot.repository.ts @@ -1,5 +1,6 @@ +import { BaseRepository, TransactionRunner, type OperationContext } from '@n8n/db'; import { Service } from '@n8n/di'; -import { DataSource, Repository, type EntityManager } from '@n8n/typeorm'; +import { DataSource } from '@n8n/typeorm'; import { AgentTaskSnapshot } from '../entities/agent-task-snapshot.entity'; @@ -9,19 +10,25 @@ type AgentTaskSnapshotData = Pick< >; @Service() -export class AgentTaskSnapshotRepository extends Repository { - constructor(dataSource: DataSource) { - super(AgentTaskSnapshot, dataSource.manager); +export class AgentTaskSnapshotRepository extends BaseRepository { + constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + super(AgentTaskSnapshot, dataSource.manager, transactionRunner); } - async saveForVersion(snapshots: AgentTaskSnapshotData[], trx?: EntityManager): Promise { + async saveForVersion( + snapshots: AgentTaskSnapshotData[], + ctx: OperationContext = {}, + ): Promise { if (snapshots.length === 0) return; - const repo = trx?.getRepository(AgentTaskSnapshot) ?? this; + const repo = this.managerFor(ctx).getRepository(AgentTaskSnapshot); await repo.insert(snapshots); } - async findByVersionId(versionId: string, trx?: EntityManager): Promise { - const repo = trx?.getRepository(AgentTaskSnapshot) ?? this; + async findByVersionId( + versionId: string, + ctx: OperationContext = {}, + ): Promise { + const repo = this.managerFor(ctx).getRepository(AgentTaskSnapshot); return await repo.find({ where: { versionId }, order: { createdAt: 'ASC' } }); } diff --git a/packages/cli/src/modules/agents/repositories/agent-task.repository.ts b/packages/cli/src/modules/agents/repositories/agent-task.repository.ts index e4357f43202..b0214180e53 100644 --- a/packages/cli/src/modules/agents/repositories/agent-task.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-task.repository.ts @@ -1,19 +1,63 @@ +import type { AgentTaskConfig } from '@n8n/api-types'; +import { BaseRepository, TransactionRunner, type OperationContext } from '@n8n/db'; import { Service } from '@n8n/di'; -import { DataSource, Repository } from '@n8n/typeorm'; +import { DataSource, In } from '@n8n/typeorm'; +import isEqual from 'lodash/isEqual'; import { AgentTask } from '../entities/agent-task.entity'; +import { getAgentTaskBody } from '../utils/agent-definition'; @Service() -export class AgentTaskRepository extends Repository { - constructor(dataSource: DataSource) { - super(AgentTask, dataSource.manager); +export class AgentTaskRepository extends BaseRepository { + constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + super(AgentTask, dataSource.manager, transactionRunner); } - async findByAgentId(agentId: string): Promise { - return await this.find({ where: { agentId }, order: { createdAt: 'ASC' } }); + async findByAgentId(agentId: string, ctx: OperationContext = {}): Promise { + return await this.managerFor(ctx).find(AgentTask, { + where: { agentId }, + order: { createdAt: 'ASC' }, + }); } async findByIdAndAgentId(id: string, agentId: string): Promise { return await this.findOne({ where: { id, agentId } }); } + + async saveDefinitions(tasks: AgentTask[], ctx: OperationContext): Promise { + return await this.managerFor(ctx).save(AgentTask, tasks); + } + + async deleteForAgent(agentId: string, ids: string[], ctx: OperationContext): Promise { + if (ids.length === 0) return; + await this.managerFor(ctx).delete(AgentTask, { agentId, id: In(ids) }); + } + + /** Restore task bodies without changing their owner or creation time. */ + async replaceForAgent( + agentId: string, + definitions: ReadonlyMap, + ctx: OperationContext, + ): Promise { + const repo = this.managerFor(ctx).getRepository(AgentTask); + const existing = await this.findByAgentId(agentId, ctx); + const existingBodies = new Map(existing.map((task) => [task.id, getAgentTaskBody(task)])); + const nextBodies = new Map([...definitions].map(([id, task]) => [id, getAgentTaskBody(task)])); + const changed = !isEqual(existingBodies, nextBodies); + + await this.deleteForAgent( + agentId, + existing.filter((task) => !definitions.has(task.id)).map((task) => task.id), + ctx, + ); + for (const [id, body] of nextBodies) { + if (existingBodies.has(id)) { + await repo.update({ id, agentId }, body); + } else { + // A task ID owned by another agent must fail, not move to this agent. + await repo.insert({ id, agentId, ...body }); + } + } + return changed; + } } diff --git a/packages/cli/src/modules/agents/repositories/agent.repository.ts b/packages/cli/src/modules/agents/repositories/agent.repository.ts index 8ca9a7f1c80..1dd43ea7b2f 100644 --- a/packages/cli/src/modules/agents/repositories/agent.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent.repository.ts @@ -1,15 +1,8 @@ import { N8N_CHAT_INTEGRATION_TYPE } from '@n8n/api-types'; import type { AgentIntegrationConfig, ListAgentsQueryDto } from '@n8n/api-types'; +import { BaseRepository, TransactionRunner, type OperationContext } from '@n8n/db'; import { Service } from '@n8n/di'; -import { - DataSource, - In, - IsNull, - Not, - Repository, - type EntityManager, - type SelectQueryBuilder, -} from '@n8n/typeorm'; +import { DataSource, In, IsNull, Not, type SelectQueryBuilder } from '@n8n/typeorm'; import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; import { Agent } from '../entities/agent.entity'; @@ -24,7 +17,7 @@ export type AgentSummary = Pick< 'id' | 'name' | 'projectId' | 'activeVersionId' | 'availableInMCP' | 'updatedAt' >; -/** The only columns an integration mutation reads or writes. */ +/** Integration and publication state for channel runtime decisions. */ export type AgentIntegrationState = Pick; export type AgentSummaryFilters = { @@ -35,9 +28,9 @@ export type AgentSummaryFilters = { }; @Service() -export class AgentRepository extends Repository { - constructor(dataSource: DataSource) { - super(Agent, dataSource.manager); +export class AgentRepository extends BaseRepository { + constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + super(Agent, dataSource.manager, transactionRunner); } /** @@ -51,6 +44,10 @@ export class AgentRepository extends Repository { await this.insert(agent as QueryDeepPartialEntity); } + async hasRevision(id: string, revision: number): Promise { + return await this.existsBy({ id, revision }); + } + async findByProjectId(projectId: string): Promise { return await this.find({ where: { projectId }, @@ -322,10 +319,7 @@ export class AgentRepository extends Repository { return (result.affected ?? 0) > 0; } - /** - * Reads just the columns an integration mutation needs, so its write derives - * from the current row rather than a possibly-stale request-scoped entity. - */ + /** Read current channel state without loading the agent definition. */ async findIntegrationState(id: string): Promise { return await this.findOne({ select: ['integrations', 'versionId', 'activeVersionId'], @@ -334,29 +328,24 @@ export class AgentRepository extends Repository { } /** - * Compare-and-set the two columns an integration mutation owns, so a channel - * change can never revert a concurrent publish or config write. Returns false - * when another writer got there first; the caller can re-read and reapply, - * because its input is a delta rather than a whole array. - * - * `activeVersionId` is guarded but never written: publishing leaves `versionId` - * untouched, so without it in the `WHERE` a publish landing after the read - * would let the write through and the caller would act on stale publication - * state. + * Fence channel changes against draft and publication writes. + * Advance the revision so stale draft saves cannot overwrite the channels. + * Return false on a conflict so the caller can reapply its delta to fresh state. */ async updateIntegrations( id: string, integrations: AgentIntegrationConfig[], - expected: Pick, + expected: Pick, versionId: string | null, ): Promise { const result = await this.update( { id, + revision: expected.revision, versionId: expected.versionId ?? IsNull(), activeVersionId: expected.activeVersionId ?? IsNull(), }, - { integrations, versionId }, + { integrations, versionId, revision: () => 'revision + 1' }, ); return (result.affected ?? 0) > 0; @@ -465,9 +454,9 @@ export class AgentRepository extends Repository { id: string, expectedRevision: number, next: { activeVersionId: string | null; versionId: string }, - trx?: EntityManager, + ctx: OperationContext = {}, ): Promise { - const result = await (trx ?? this) + const result = await this.managerFor(ctx) .createQueryBuilder() .update(Agent) .set({ @@ -489,12 +478,12 @@ export class AgentRepository extends Repository { * `updatedAt` are synced to what was written. Returns whether this caller * won the fence. */ - async saveDraftFenced(agent: Agent, trx?: EntityManager): Promise { + async saveDraftFenced(agent: Agent, ctx: OperationContext = {}): Promise { const expectedRevision = agent.revision; // Written explicitly (instead of the builder's CURRENT_TIMESTAMP default) // so the in-memory entity can report the exact persisted timestamp. const updatedAt = new Date(); - const result = await (trx ?? this) + const result = await this.managerFor(ctx) .createQueryBuilder() .update(Agent) .set({ diff --git a/packages/cli/src/modules/agents/utils/__tests__/extract-agent-credential-ids.test.ts b/packages/cli/src/modules/agents/utils/__tests__/extract-agent-credential-ids.test.ts index 08f94252d64..74636be560a 100644 --- a/packages/cli/src/modules/agents/utils/__tests__/extract-agent-credential-ids.test.ts +++ b/packages/cli/src/modules/agents/utils/__tests__/extract-agent-credential-ids.test.ts @@ -3,30 +3,70 @@ import { AI_GATEWAY_MANAGED_TAG, MANAGED_CREDENTIAL_TOKEN } from '@n8n/api-types import { extractAgentCredentialIds } from '../extract-agent-credential-ids'; describe('extractAgentCredentialIds', () => { - it('recursively extracts credential, credentialId, and credentials.*.id references', () => { + it('extracts local credential references and leaves tool inputs opaque', () => { const result = extractAgentCredentialIds({ credential: 'main-model-credential', memory: { - worker: { credential: 'memory-worker-credential' }, + observationalMemory: { + observerModel: { credential: 'observer-credential' }, + reflectorModel: { credential: 'reflector-credential' }, + }, + episodicMemory: { + credential: 'memory-embedding-credential', + reflectorModel: { credential: 'episodic-reflector-credential' }, + }, }, + subAgents: { + modelsByDifficulty: { + low: { credential: 'low-credential' }, + medium: { credential: 'medium-credential' }, + high: { credential: 'high-credential' }, + }, + }, + config: { webSearch: { credential: 'search-credential' } }, integrations: [{ credentialId: 'integration-credential' }], + mcpServers: [{ credential: 'mcp-credential' }], + vectorStores: [ + { + credential: 'vector-store-credential', + embedding: { credential: 'vector-embedding-credential' }, + }, + ], tools: [ { + enabled: false, node: { credentials: { openAiApi: { id: 'node-tool-credential', name: 'OpenAI account' }, }, + nodeParameters: { + credentialId: 'remote-credential', + credential: 'authored-value', + credentials: { remote: { id: 'remote-value' } }, + }, }, }, + { type: 'workflow', inputs: { data: { value: { credentialId: 'input-value' } } } }, ], + providerTools: { custom: { credential: 'provider-argument' } }, unrelated: { id: 'not-a-credential-reference' }, }); expect(result).toEqual( new Set([ 'main-model-credential', - 'memory-worker-credential', + 'observer-credential', + 'reflector-credential', + 'memory-embedding-credential', + 'episodic-reflector-credential', + 'low-credential', + 'medium-credential', + 'high-credential', + 'search-credential', 'integration-credential', + 'mcp-credential', + 'vector-store-credential', + 'vector-embedding-credential', 'node-tool-credential', ]), ); @@ -55,7 +95,7 @@ describe('extractAgentCredentialIds', () => { credential: '', memory: { episodicMemory: { credential: MANAGED_CREDENTIAL_TOKEN }, - worker: { credential: AI_GATEWAY_MANAGED_TAG }, + observationalMemory: { observerModel: { credential: AI_GATEWAY_MANAGED_TAG } }, }, integrations: [ { credentialId: '' }, diff --git a/packages/cli/src/modules/agents/utils/agent-definition.ts b/packages/cli/src/modules/agents/utils/agent-definition.ts new file mode 100644 index 00000000000..11acb990c2c --- /dev/null +++ b/packages/cli/src/modules/agents/utils/agent-definition.ts @@ -0,0 +1,21 @@ +import type { AgentTaskConfig } from '@n8n/api-types'; + +import type { AgentHistory } from '../entities/agent-history.entity'; +import type { Agent } from '../entities/agent.entity'; + +/** Published chat settings stay in schema. Credential integrations stay on the agent. */ +export interface AgentDefinition extends Pick { + tasks: ReadonlyMap; +} + +/** Keep configuration and inline bodies from the same draft or version. */ +export function getAgentDefinitionContent( + source: Pick, +) { + return { schema: source.schema, tools: source.tools ?? {}, skills: source.skills ?? {} }; +} + +export function getAgentTaskBody(task: AgentTaskConfig) { + const { name, objective, cronExpression, timezone } = task; + return { name, objective, cronExpression, timezone: timezone ?? null }; +} diff --git a/packages/cli/src/modules/agents/utils/agent-draft.utils.ts b/packages/cli/src/modules/agents/utils/agent-draft.utils.ts index ecdced9246d..ee5ad8f0a6f 100644 --- a/packages/cli/src/modules/agents/utils/agent-draft.utils.ts +++ b/packages/cli/src/modules/agents/utils/agent-draft.utils.ts @@ -1,3 +1,4 @@ +import type { OperationContext } from '@n8n/db'; import { v4 as uuid } from 'uuid'; import { ConflictError } from '@n8n/errors'; @@ -5,9 +6,6 @@ import { ConflictError } from '@n8n/errors'; import type { Agent } from '../entities/agent.entity'; import type { AgentRepository } from '../repositories/agent.repository'; -/** The repository's opaque transaction handle, without importing the ORM here. */ -type DraftSaveTransaction = Parameters[1]; - /** * Start a new draft if the agent is currently in sync with the published snapshot. * Any mutation that changes how the agent would run must call this so that @@ -28,9 +26,9 @@ export function markAgentDraftDirty(agent: Agent): void { export async function saveAgentDraftFenced( agentRepository: AgentRepository, agent: Agent, - trx?: DraftSaveTransaction, + ctx: OperationContext = {}, ): Promise { - const won = await agentRepository.saveDraftFenced(agent, trx); + const won = await agentRepository.saveDraftFenced(agent, ctx); if (!won) { throw new ConflictError('Agent was modified concurrently; please retry'); } diff --git a/packages/cli/src/modules/agents/utils/agent-published-snapshot.ts b/packages/cli/src/modules/agents/utils/agent-published-snapshot.ts index 81e9485e2cf..09b668a108a 100644 --- a/packages/cli/src/modules/agents/utils/agent-published-snapshot.ts +++ b/packages/cli/src/modules/agents/utils/agent-published-snapshot.ts @@ -1,10 +1,11 @@ import { OperationalError } from 'n8n-workflow'; import type { Agent } from '../entities/agent.entity'; +import { getAgentDefinitionContent } from './agent-definition'; export function getPublishedAgentSnapshot(agentEntity: Agent): Agent { - const activeVersionSchema = agentEntity.activeVersion?.schema; - if (!activeVersionSchema) { + const activeVersion = agentEntity.activeVersion; + if (!activeVersion?.schema) { throw new OperationalError( 'Agent is not published. Publish the agent before using it in a workflow.', ); @@ -12,8 +13,6 @@ export function getPublishedAgentSnapshot(agentEntity: Agent): Agent { return { ...agentEntity, - schema: activeVersionSchema, - tools: agentEntity.activeVersion?.tools ?? {}, - skills: agentEntity.activeVersion?.skills ?? {}, + ...getAgentDefinitionContent(activeVersion), } as Agent; } diff --git a/packages/cli/src/modules/agents/utils/extract-agent-credential-ids.ts b/packages/cli/src/modules/agents/utils/extract-agent-credential-ids.ts index 45ab45412f0..102acf2999d 100644 --- a/packages/cli/src/modules/agents/utils/extract-agent-credential-ids.ts +++ b/packages/cli/src/modules/agents/utils/extract-agent-credential-ids.ts @@ -1,56 +1,13 @@ import { AI_GATEWAY_MANAGED_TAG, MANAGED_CREDENTIAL_TOKEN } from '@n8n/api-types'; -import { isRecord } from '@n8n/utils/is-record'; + +import { visitAgentCredentialReferences } from './visit-agent-credential-references'; const MANAGED_CREDENTIAL_IDS = new Set([MANAGED_CREDENTIAL_TOKEN, AI_GATEWAY_MANAGED_TAG]); -const DEFAULT_ID_KEYS: ReadonlySet = new Set(['credential', 'credentialId']); - -/** - * Key set for an agent config that comes from node parameters. An agent config - * references credentials through `credential` and `credentials.*.id` only, while - * a node parameter named `credentialId` holds an id on a remote instance — for - * example in the n8n node. Collecting it there gives a false match. - */ -export const AGENT_CONFIG_ID_KEYS: ReadonlySet = new Set(['credential']); - -function addCredentialId(value: unknown, credentialIds: Set): void { - if (typeof value === 'string' && value !== '' && !MANAGED_CREDENTIAL_IDS.has(value)) { - credentialIds.add(value); - } -} - -function collectCredentialIds( - value: unknown, - credentialIds: Set, - idKeys: ReadonlySet, -): void { - if (Array.isArray(value)) { - for (const entry of value) collectCredentialIds(entry, credentialIds, idKeys); - return; - } - - if (!isRecord(value)) return; - - for (const [key, entry] of Object.entries(value)) { - if (idKeys.has(key)) { - addCredentialId(entry, credentialIds); - } else if (key === 'credentials' && isRecord(entry)) { - for (const credentialReference of Object.values(entry)) { - if (isRecord(credentialReference)) { - addCredentialId(credentialReference.id, credentialIds); - } - } - } - - collectCredentialIds(entry, credentialIds, idKeys); - } -} - -export function extractAgentCredentialIds( - value: unknown, - idKeys: ReadonlySet = DEFAULT_ID_KEYS, -): Set { +export function extractAgentCredentialIds(config: unknown): Set { const credentialIds = new Set(); - collectCredentialIds(value, credentialIds, idKeys); + visitAgentCredentialReferences(config, ({ id }) => { + if (id !== '' && !MANAGED_CREDENTIAL_IDS.has(id)) credentialIds.add(id); + }); return credentialIds; } diff --git a/packages/cli/src/modules/agents/utils/visit-agent-credential-references.ts b/packages/cli/src/modules/agents/utils/visit-agent-credential-references.ts new file mode 100644 index 00000000000..0bfa8190272 --- /dev/null +++ b/packages/cli/src/modules/agents/utils/visit-agent-credential-references.ts @@ -0,0 +1,90 @@ +import { + AI_GATEWAY_MANAGED_TAG, + MANAGED_CREDENTIAL_TOKEN, + SUB_AGENT_TASK_DIFFICULTIES, +} from '@n8n/api-types'; +import { isRecord } from '@n8n/utils/is-record'; + +interface CredentialReference { + id: string; + setId: (id: string) => void; + managedToken?: string; +} + +type CredentialVisitor = (reference: CredentialReference) => void; + +interface CredentialLocation { + path: string[]; + managedToken?: string; +} + +// Only these fields refer to local credentials. Tool inputs and node parameters are opaque. +const CREDENTIAL_LOCATIONS: CredentialLocation[] = [ + { path: ['credential'], managedToken: AI_GATEWAY_MANAGED_TAG }, + { path: ['memory', 'episodicMemory', 'credential'], managedToken: MANAGED_CREDENTIAL_TOKEN }, + { + path: ['memory', 'observationalMemory', 'observerModel', 'credential'], + managedToken: AI_GATEWAY_MANAGED_TAG, + }, + { + path: ['memory', 'observationalMemory', 'reflectorModel', 'credential'], + managedToken: AI_GATEWAY_MANAGED_TAG, + }, + { + path: ['memory', 'episodicMemory', 'reflectorModel', 'credential'], + managedToken: AI_GATEWAY_MANAGED_TAG, + }, + ...SUB_AGENT_TASK_DIFFICULTIES.map((difficulty) => ({ + path: ['subAgents', 'modelsByDifficulty', difficulty, 'credential'], + managedToken: AI_GATEWAY_MANAGED_TAG, + })), + { path: ['config', 'webSearch', 'credential'], managedToken: AI_GATEWAY_MANAGED_TAG }, + { path: ['integrations', '[]', 'credentialId'] }, + { path: ['mcpServers', '[]', 'credential'] }, + { path: ['vectorStores', '[]', 'credential'] }, + { path: ['vectorStores', '[]', 'embedding', 'credential'] }, + { path: ['tools', '[]', 'node', 'credentials', '*', 'id'] }, +]; + +function visitLocation( + value: unknown, + [key, ...rest]: string[], + visit: CredentialVisitor, + managedToken?: string, +): void { + if (key === '[]') { + if (Array.isArray(value)) { + for (const entry of value) visitLocation(entry, rest, visit, managedToken); + } + return; + } + + if (!isRecord(value)) return; + + if (key === '*') { + for (const entry of Object.values(value)) visitLocation(entry, rest, visit, managedToken); + return; + } + + if (rest.length > 0) { + visitLocation(value[key], rest, visit, managedToken); + return; + } + + const id = value[key]; + if (typeof id === 'string') { + visit({ + id, + managedToken, + setId: (nextId) => { + value[key] = nextId; + }, + }); + } +} + +export function visitAgentCredentialReferences(config: unknown, visit: CredentialVisitor): void { + for (const { path, managedToken } of CREDENTIAL_LOCATIONS) { + visitLocation(config, path, visit, managedToken); + } +} diff --git a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts index eec28bdb32e..218477852a5 100644 --- a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts @@ -4,7 +4,8 @@ import type { AgentJsonConfig } from '@n8n/api-types'; import { type EventService, ProjectScopeService, UrlService } from '@n8n/backend-services'; import { mockInstance, mockLogger } from '@n8n/backend-test-utils'; import { OutboundHttp } from '@n8n/backend-network'; -import { User, type WorkflowRepository } from '@n8n/db'; +import { User, type WorkflowRepository, type TransactionRunner } from '@n8n/db'; +import { Container } from '@n8n/di'; import { TELEMETRY_EVENT } from '@n8n/telemetry'; import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; @@ -28,6 +29,7 @@ vi.mock('@/modules/agents/json-config/mcp-client-factory', () => ({ import { CredentialsService } from '@/credentials/credentials.service'; import { ConflictError } from '@n8n/errors'; +import { AgentConfigPreparationService } from '@/modules/agents/agent-config-preparation.service'; import { AgentConfigService } from '@/modules/agents/agent-config.service'; import { AgentCustomToolsService } from '@/modules/agents/agent-custom-tools.service'; import { AgentIntegrationManagementService } from '@/modules/agents/agent-integration-management.service'; @@ -35,7 +37,8 @@ import { AgentIntegrationPersistenceService } from '@/modules/agents/agent-integ import { AgentModelCatalogService } from '@/modules/agents/agent-model-catalog.service'; import { AgentModificationTelemetryService } from '@/modules/agents/agent-modification-telemetry.service'; import { AgentPublishService } from '@/modules/agents/agent-publish.service'; -import type { AgentRuntimeCacheService } from '@/modules/agents/agent-runtime-cache.service'; +import { AgentSaveCompletionService } from '@/modules/agents/agent-save-completion.service'; +import { AgentRuntimeCacheService } from '@/modules/agents/agent-runtime-cache.service'; import type { AgentSetupCompletionService } from '@/modules/agents/agent-setup-completion.service'; import { AgentSkillsService } from '@/modules/agents/agent-skills.service'; import type { AgentUpdateBroadcaster } from '@/modules/agents/agent-update-broadcaster'; @@ -229,26 +232,32 @@ describe('McpAgentToolsService', () => { agentsService.findByIdForUser.mockResolvedValue(agent); const agentUpdateBroadcaster = mock(); + const saveCompletion = new AgentSaveCompletionService( + mock(), + agentUpdateBroadcaster, + modificationTelemetry, + ); + const transactionRunner = mock(); + transactionRunner.run.mockImplementation(async (ctx, fn) => await fn(ctx)); + Container.set(AgentRuntimeCacheService, runtimeCacheService); const customToolsService = new AgentCustomToolsService( mockLogger(), agentRepository, - runtimeCacheService, - modificationTelemetry, - agentUpdateBroadcaster, + saveCompletion, ); const configService = new AgentConfigService( mockLogger(), agentRepository, agentTaskRepository, mock(), - runtimeCacheService, - localCredentialsService, - workflowRepository, - mock(), - mock(), + new AgentConfigPreparationService( + localCredentialsService, + workflowRepository, + mock(), + ), mock(), - modificationTelemetry, - agentUpdateBroadcaster, + transactionRunner, + saveCompletion, ); agentCustomToolsService.buildCustomTool.mockImplementation( async (agentId, projectId, code, descriptor, context, options) => @@ -1916,22 +1925,26 @@ describe('McpAgentToolsService', () => { agentId: 'agent-1', author: 'Ada Lovelace', createdAt: new Date('2026-01-01T00:00:00.000Z'), + }, + definition: { schema: { ...baseConfig, + tasks: [{ type: 'task', id: 'task-1', enabled: true }], integrations: [{ type: 'slack', credentialId: 'cred-1' }], }, tools: { my_tool: { code: 'code', descriptor: { name: 'my_tool' } } }, skills: { 'skill-1': { name: 'Skill' } }, + tasks: new Map([ + [ + 'task-1', + { + name: 'Daily', + objective: 'Summarize', + cronExpression: '0 9 * * *', + }, + ], + ]), }, - tasks: [ - { - taskId: 'task-1', - name: 'Daily', - objective: 'Summarize', - cronExpression: '0 9 * * *', - enabled: true, - }, - ], } as never); const result = await callTool('get_agent', { agentId: 'agent-1', versionId: 'v0' }); diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts index c51fd82d6ff..c791b8c678e 100644 --- a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts +++ b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts @@ -1154,16 +1154,17 @@ export class McpAgentToolsService { * must not be usable as a mutate_agent baseConfigHash. */ private async getAgentVersionSnapshot(projectId: string, agentId: string, versionId: string) { - const { agent, version, tasks } = await this.agentPublishService.getVersion( + const { agent, version, definition } = await this.agentPublishService.getVersion( agentId, projectId, versionId, ); - if (!version.schema) throw new UserError(`Version "${versionId}" has no JSON config.`); + if (!definition.schema) throw new UserError(`Version "${versionId}" has no JSON config.`); // Integrations are managed separately and are not versioned, so omit any // legacy snapshot field here for the same reason as in getAgentSnapshot. - const { integrations: _integrations, ...editableConfig } = version.schema; + const { integrations: _integrations, ...editableConfig } = definition.schema; + const enabledTasks = new Map((editableConfig.tasks ?? []).map((ref) => [ref.id, ref.enabled])); return { agent: { id: agent.id, @@ -1180,16 +1181,16 @@ export class McpAgentToolsService { isActive: version.versionId === agent.activeVersionId, }, config: editableConfig, - skills: version.skills ?? {}, - tasks: tasks.map((task) => ({ - id: task.taskId, + skills: definition.skills, + tasks: [...definition.tasks].map(([id, task]) => ({ + id, name: task.name, objective: task.objective, cronExpression: task.cronExpression, timezone: task.timezone, - enabled: task.enabled, + enabled: enabledTasks.get(id) ?? false, })), - customTools: Object.entries(version.tools ?? {}).map(([id, tool]) => ({ + customTools: Object.entries(definition.tools).map(([id, tool]) => ({ id, descriptor: tool.descriptor, })), diff --git a/packages/cli/src/workflows/workflow.service.ee.ts b/packages/cli/src/workflows/workflow.service.ee.ts index 5331c3205e0..627bb6641a6 100644 --- a/packages/cli/src/workflows/workflow.service.ee.ts +++ b/packages/cli/src/workflows/workflow.service.ee.ts @@ -38,10 +38,7 @@ import { EnterpriseCredentialsService } from '@/credentials/credentials.service. import { FolderNotFoundError } from '@/errors/folder-not-found.error'; import { BadRequestError, NotFoundError } from '@n8n/errors'; import { TransferWorkflowError } from '@/errors/response-errors/transfer-workflow.error'; -import { - AGENT_CONFIG_ID_KEYS, - extractAgentCredentialIds, -} from '@/modules/agents/utils/extract-agent-credential-ids'; +import { extractAgentCredentialIds } from '@/modules/agents/utils/extract-agent-credential-ids'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { OwnershipService } from '@/services/ownership.service'; import { ProjectService } from '@/services/project.service.ee'; @@ -386,8 +383,8 @@ export class EnterpriseWorkflowService { } const inlineAgent = this.parseInlineAgent(current.parameters?.inlineAgent); - if (inlineAgent) { - ids.push(...extractAgentCredentialIds(inlineAgent, AGENT_CONFIG_ID_KEYS)); + if (isRecord(inlineAgent)) { + ids.push(...extractAgentCredentialIds(inlineAgent.config)); stack.push(...this.getAgentToolNodes(inlineAgent)); } } diff --git a/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts b/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts index 83e7ff9e78f..7f58a93e3de 100644 --- a/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts @@ -237,7 +237,7 @@ describe('AgentExecutionRepository', () => { executions, executionService, checkpointStorage, - connection ? new AgentRepository(connection) : agentRepo, + connection ? new AgentRepository(connection, txRunner) : agentRepo, attachmentService, queueUpdates, messageRepository, diff --git a/packages/cli/test/integration/database/repositories/agent.repository.test.ts b/packages/cli/test/integration/database/repositories/agent.repository.test.ts index ce063913099..93b3e5a2f4a 100644 --- a/packages/cli/test/integration/database/repositories/agent.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/agent.repository.test.ts @@ -1,16 +1,35 @@ import { N8N_CHAT_INTEGRATION_TYPE } from '@n8n/api-types'; import type { AgentIntegrationConfig, AgentJsonConfig } from '@n8n/api-types'; -import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; +import { createTeamProject, mockLogger, testDb, testModules } from '@n8n/backend-test-utils'; +import { TransactionRunner, type User, type WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; +import { ConflictError } from '@n8n/errors'; import { v4 as uuid } from 'uuid'; +import { mock } from 'vitest-mock-extended'; +import type { CredentialsService } from '@/credentials/credentials.service'; +import { AgentConfigPreparationService } from '@/modules/agents/agent-config-preparation.service'; +import { AgentConfigService } from '@/modules/agents/agent-config.service'; +import { AgentDefinitionService } from '@/modules/agents/agent-definition.service'; +import type { AgentSaveCompletionService } from '@/modules/agents/agent-save-completion.service'; +import type { AgentSetupCompletionService } from '@/modules/agents/agent-setup-completion.service'; +import type { AgentSkillsService } from '@/modules/agents/agent-skills.service'; +import { AgentTaskService } from '@/modules/agents/agent-task.service'; import type { Agent } from '@/modules/agents/entities/agent.entity'; +import { composeJsonConfig } from '@/modules/agents/json-config/agent-config-composition'; +import type { NodeToolAiGatewayService } from '@/modules/agents/json-config/node-tool-ai-gateway.service'; import { AgentHistoryRepository } from '@/modules/agents/repositories/agent-history.repository'; +import { AgentTaskRepository } from '@/modules/agents/repositories/agent-task.repository'; import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; +import { getAgentConfigHash } from '@/modules/agents/utils/agent-config-hash'; +import type { AgentDefinition } from '@/modules/agents/utils/agent-definition'; describe('AgentRepository', () => { let agentRepo: AgentRepository; let agentHistoryRepo: AgentHistoryRepository; + let taskRepo: AgentTaskRepository; + let transactionRunner: TransactionRunner; + let definitionService: AgentDefinitionService; let projectId: string; async function createAgent(overrides: Partial = {}): Promise { @@ -45,6 +64,9 @@ describe('AgentRepository', () => { await testDb.init(); agentRepo = Container.get(AgentRepository); agentHistoryRepo = Container.get(AgentHistoryRepository); + taskRepo = Container.get(AgentTaskRepository); + transactionRunner = Container.get(TransactionRunner); + definitionService = Container.get(AgentDefinitionService); }); beforeEach(async () => { @@ -53,10 +75,297 @@ describe('AgentRepository', () => { }); afterEach(async () => { + vi.restoreAllMocks(); await agentHistoryRepo.delete({}); await agentRepo.delete({}); }); + describe('draft definition writes', () => { + const taskBody = { + name: 'Daily task', + objective: 'Summarize notes', + cronExpression: '0 9 * * *', + timezone: null, + }; + + it('rolls back a config save when task cleanup fails and can retry the complete write', async () => { + const schema: AgentJsonConfig = { + name: 'Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Help users', + tasks: [{ type: 'task', id: 'task-remove', enabled: false }], + }; + const agent = await createAgent({ schema }); + await taskRepo.insert({ id: 'task-remove', agentId: agent.id, ...taskBody }); + const credentials = mock(); + credentials.findAllCredentialIdsForProject.mockResolvedValue([]); + credentials.findAllGlobalCredentialIds.mockResolvedValue([]); + credentials.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([]); + const setup = mock(); + setup.recordIfSetupComplete.mockResolvedValue(null); + const completion = mock(); + const service = new AgentConfigService( + mockLogger(), + agentRepo, + taskRepo, + mock(), + new AgentConfigPreparationService( + credentials, + mock(), + mock(), + ), + setup, + transactionRunner, + completion, + ); + const deleteTasks = taskRepo.deleteForAgent.bind(taskRepo); + vi.spyOn(taskRepo, 'deleteForAgent').mockImplementationOnce(async (id, ids, ctx) => { + await deleteTasks(id, ids, ctx); + throw new Error('Task cleanup failed'); + }); + const config = { ...schema, name: 'Updated agent', tasks: [] }; + const options = { + baseConfigHash: getAgentConfigHash(composeJsonConfig(agent)), + modifiedBy: 'user', + } as const; + await expect( + service.updateConfig(agent.id, projectId, config, mock(), options), + ).rejects.toThrow('Task cleanup failed'); + expect(await agentRepo.findById(agent.id)).toMatchObject({ + schema, + revision: agent.revision, + }); + expect(await taskRepo.findByAgentId(agent.id)).toMatchObject([{ id: 'task-remove' }]); + expect(completion.configurationSaved).not.toHaveBeenCalled(); + + const saved = await service.updateConfig(agent.id, projectId, config, mock(), options); + expect(saved.config).toMatchObject({ name: 'Updated agent', tasks: [] }); + expect(await agentRepo.findById(agent.id)).toMatchObject({ + schema: { name: 'Updated agent', tasks: [] }, + }); + expect(await taskRepo.findByAgentId(agent.id)).toEqual([]); + expect(completion.configurationSaved).toHaveBeenCalled(); + }); + + it('replaces the complete draft and preserves existing task creation times', async () => { + const agent = await createAgent(); + const createdAt = new Date('2025-01-01T00:00:00.000Z'); + await taskRepo.insert([ + { id: 'task-keep', agentId: agent.id, ...taskBody, createdAt }, + { id: 'task-remove', agentId: agent.id, ...taskBody }, + ]); + const definitions = new Map([ + ['task-keep', { ...taskBody, objective: 'Restored objective' }], + ['task-new', taskBody], + ]); + const definition: AgentDefinition = { + schema: { + name: 'Restored agent', + model: '', + instructions: 'Summarize notes', + tools: [{ type: 'custom', id: 'summarize' }], + skills: [{ type: 'skill', id: 'writing' }], + tasks: [...definitions.keys()].map((id) => ({ type: 'task', id, enabled: true })), + }, + tools: { + summarize: { + code: 'return "Summary";', + descriptor: { + name: 'summarize', + description: 'Summarize notes', + systemInstruction: null, + inputSchema: {}, + outputSchema: null, + hasSuspend: false, + hasResume: false, + hasToMessage: false, + requireApproval: false, + providerOptions: null, + }, + }, + }, + skills: { + writing: { + name: 'Writing', + description: 'Write notes', + instructions: 'Use short sentences', + }, + }, + tasks: definitions, + }; + const restore = async () => await definitionService.replaceDraft(agent, definition); + expect(await restore()).toBe(true); + expect(await agentRepo.findById(agent.id)).toMatchObject({ + name: 'Restored agent', + schema: definition.schema, + tools: definition.tools, + skills: definition.skills, + revision: 1, + }); + const tasks = await taskRepo.findByAgentId(agent.id); + expect(tasks).toHaveLength(2); + expect(tasks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'task-keep', objective: 'Restored objective', createdAt }), + expect.objectContaining({ id: 'task-new', objective: taskBody.objective }), + ]), + ); + expect(await restore()).toBe(false); + }); + + it('rejects a task update when a definition replacement commits after the task read', async () => { + const agent = await createAgent(); + await taskRepo.insert({ id: 'task-1', agentId: agent.id, ...taskBody }); + const completion = mock(); + const service = new AgentTaskService( + mockLogger(), + mock(), + taskRepo, + mock(), + mock(), + agentRepo, + mock(), + mock(), + mock(), + mock(), + mock(), + completion, + mock(), + transactionRunner, + ); + const readTask = taskRepo.findByIdAndAgentId.bind(taskRepo); + vi.spyOn(taskRepo, 'findByIdAndAgentId').mockImplementationOnce(async (id, agentId) => { + const task = await readTask(id, agentId); + await definitionService.replaceDraft(agent, { + schema: agent.schema, + tools: agent.tools, + skills: agent.skills, + tasks: new Map([['task-1', { ...taskBody, objective: 'Restored objective' }]]), + }); + return task; + }); + + await expect( + service.update( + agent.id, + projectId, + 'task-1', + { name: 'Renamed task' }, + { + user: mock(), + modifiedBy: 'user', + }, + ), + ).rejects.toThrow(ConflictError); + expect(await taskRepo.findByIdAndAgentId('task-1', agent.id)).toMatchObject({ + ...taskBody, + objective: 'Restored objective', + }); + expect(await agentRepo.findById(agent.id)).toMatchObject({ revision: 1 }); + expect(completion.taskSaved).not.toHaveBeenCalled(); + }); + + it('rejects a stale definition replacement after an integration change', async () => { + const agent = await createAgent(); + const originalSchema = agent.schema; + await taskRepo.insert({ id: 'task-1', agentId: agent.id, ...taskBody }); + await expect( + agentRepo.updateIntegrations( + agent.id, + [{ type: 'slack', credentialId: 'slack-1' }], + agent, + 'version-2', + ), + ).resolves.toBe(true); + + await expect( + definitionService.replaceDraft(agent, { + schema: { name: 'Restored agent', model: '', instructions: 'Restored instructions' }, + tools: {}, + skills: {}, + tasks: new Map(), + }), + ).rejects.toThrow(ConflictError); + expect(await agentRepo.findById(agent.id)).toMatchObject({ + schema: originalSchema, + integrations: [{ type: 'slack', credentialId: 'slack-1' }], + versionId: 'version-2', + revision: 1, + }); + expect(await taskRepo.findByIdAndAgentId('task-1', agent.id)).toMatchObject(taskBody); + }); + + it('rolls back draft and task changes when a restored task ID belongs to another agent', async () => { + const agent = await createAgent(); + const other = await createAgent(); + await taskRepo.insert([ + { id: 'own-task', agentId: agent.id, ...taskBody }, + { id: 'other-task', agentId: other.id, ...taskBody }, + ]); + const original = structuredClone(agent); + await expect( + definitionService.replaceDraft(agent, { + schema: { name: 'Restored agent', model: '', instructions: 'Restored instructions' }, + tools: {}, + skills: { + writing: { + name: 'Writing', + description: 'Write notes', + instructions: 'Use short sentences', + }, + }, + tasks: new Map([['other-task', taskBody]]), + }), + ).rejects.toThrow(); + expect(await agentRepo.findById(agent.id)).toMatchObject({ + name: original.name, + schema: original.schema, + tools: original.tools, + skills: original.skills, + revision: original.revision, + }); + expect(await taskRepo.findByAgentId(agent.id)).toMatchObject([{ id: 'own-task' }]); + expect(await taskRepo.findByAgentId(other.id)).toMatchObject([ + { id: 'other-task', ...taskBody }, + ]); + }); + + it.each(['before', 'after'] as const)( + 'rejects a draft read when its revision changes %s reading task bodies', + async (timing) => { + const agent = await createAgent(); + await taskRepo.insert({ id: 'task-1', agentId: agent.id, ...taskBody }); + const concurrentAgent = await agentRepo.findByIdAndProjectId(agent.id, projectId); + if (!concurrentAgent) throw new Error('Agent not found'); + + // Setup validation reads pending edits against their stored base revision. + agent.schema = { name: 'Pending edit', model: '', instructions: 'Pending instructions' }; + await expect(definitionService.readDraft(agent)).resolves.toEqual({ + schema: agent.schema, + tools: {}, + skills: {}, + tasks: new Map([['task-1', taskBody]]), + }); + const edit = async () => + await definitionService.replaceDraft(concurrentAgent, { + schema: { name: 'Concurrent edit', model: '', instructions: 'New instructions' }, + tools: {}, + skills: {}, + tasks: new Map([['task-1', { ...taskBody, objective: 'New objective' }]]), + }); + const readTasks = taskRepo.findByAgentId.bind(taskRepo); + vi.spyOn(taskRepo, 'findByAgentId').mockImplementationOnce(async (id) => { + if (timing === 'before') await edit(); + const tasks = await readTasks(id); + if (timing === 'after') await edit(); + return tasks; + }); + + await expect(definitionService.readDraft(agent)).rejects.toThrow(ConflictError); + }, + ); + }); + afterAll(async () => { await testDb.terminate(); }); @@ -214,7 +523,7 @@ describe('AgentRepository', () => { }); describe('updateIntegrations', () => { - it('writes the integration columns and leaves everything else alone', async () => { + it('writes channel state and revision without changing the definition', async () => { const agent = await createAgent({ name: 'Original name', schema: { name: 'Original name', model: 'anthropic/claude-sonnet-4-5', instructions: 'Hi' }, @@ -224,7 +533,7 @@ describe('AgentRepository', () => { const written = await agentRepo.updateIntegrations( agent.id, [{ type: 'slack', credentialId: 'slack-1' }], - { versionId: 'version-1', activeVersionId: null }, + { revision: 0, versionId: 'version-1', activeVersionId: null }, 'version-2', ); @@ -232,6 +541,7 @@ describe('AgentRepository', () => { const reloaded = await agentRepo.findById(agent.id); expect(reloaded?.integrations).toEqual([{ type: 'slack', credentialId: 'slack-1' }]); expect(reloaded?.versionId).toBe('version-2'); + expect(reloaded?.revision).toBe(1); expect(reloaded?.name).toBe('Original name'); expect(reloaded?.schema).toEqual(agent.schema); }); @@ -252,7 +562,7 @@ describe('AgentRepository', () => { const written = await agentRepo.updateIntegrations( agent.id, [{ type: 'slack', credentialId: 'slack-1' }], - { versionId: 'version-1', activeVersionId: null }, + { revision: 0, versionId: 'version-1', activeVersionId: null }, 'version-2', ); @@ -268,7 +578,7 @@ describe('AgentRepository', () => { agentRepo.updateIntegrations( agent.id, [{ type: 'slack', credentialId: 'slack-1' }], - { versionId: 'version-1', activeVersionId: 'version-1' }, + { revision: 0, versionId: 'version-1', activeVersionId: 'version-1' }, 'version-2', ), ).resolves.toBe(true); @@ -282,7 +592,7 @@ describe('AgentRepository', () => { const written = await agentRepo.updateIntegrations( agent.id, [{ type: 'slack', credentialId: 'slack-1' }], - { versionId: 'version-1', activeVersionId: null }, + { revision: 0, versionId: 'version-1', activeVersionId: null }, 'version-2', ); @@ -292,13 +602,34 @@ describe('AgentRepository', () => { expect(reloaded?.versionId).toBe('version-9'); }); + it('rejects an integration delta after a draft save with the same version ID', async () => { + const agent = await createAgent({ versionId: 'draft-1' }); + const observed = { ...agent }; + agent.integrations = [{ type: 'linear', credentialId: 'linear-1' }]; + await expect(agentRepo.saveDraftFenced(agent)).resolves.toBe(true); + + await expect( + agentRepo.updateIntegrations( + agent.id, + [{ type: 'slack', credentialId: 'slack-1' }], + observed, + 'draft-2', + ), + ).resolves.toBe(false); + expect(await agentRepo.findById(agent.id)).toMatchObject({ + integrations: [{ type: 'linear', credentialId: 'linear-1' }], + versionId: 'draft-1', + revision: 1, + }); + }); + it('matches a null version, so a never-published draft can still be updated', async () => { const agent = await createAgent({ versionId: null }); const written = await agentRepo.updateIntegrations( agent.id, [{ type: 'slack', credentialId: 'slack-1' }], - { versionId: null, activeVersionId: null }, + { revision: 0, versionId: null, activeVersionId: null }, null, ); @@ -314,7 +645,7 @@ describe('AgentRepository', () => { const written = await agentRepo.updateIntegrations( agent.id, [{ type: 'slack', credentialId: 'slack-1' }], - { versionId: null, activeVersionId: null }, + { revision: 0, versionId: null, activeVersionId: null }, null, ); @@ -331,13 +662,13 @@ describe('AgentRepository', () => { const first = await agentRepo.updateIntegrations( agent.id, [{ type: 'slack', credentialId: 'slack-1' }], - { versionId: 'version-1', activeVersionId: null }, + { revision: 0, versionId: 'version-1', activeVersionId: null }, 'version-2', ); const second = await agentRepo.updateIntegrations( agent.id, [{ type: 'linear', credentialId: 'linear-1' }], - { versionId: 'version-1', activeVersionId: null }, + { revision: 0, versionId: 'version-1', activeVersionId: null }, 'version-3', ); @@ -353,7 +684,7 @@ describe('AgentRepository', () => { agentRepo.updateIntegrations( uuid(), [], - { versionId: 'version-1', activeVersionId: null }, + { revision: 0, versionId: 'version-1', activeVersionId: null }, 'version-2', ), ).resolves.toBe(false); From f26d7fbfe7082380d0a6b9e072f8b40bf0d8b2e2 Mon Sep 17 00:00:00 2001 From: Lorent Lempereur Date: Tue, 6 Oct 2026 09:07:32 +0000 Subject: [PATCH 05/61] refactor(core): Extract the handler failure path of the scheduler executor (no-changelog) (#40267) --- .../scheduler/src/core/executor/executor.ts | 76 ++++++++++--------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/packages/@n8n/scheduler/src/core/executor/executor.ts b/packages/@n8n/scheduler/src/core/executor/executor.ts index af3a9b4285f..90b6e54899a 100644 --- a/packages/@n8n/scheduler/src/core/executor/executor.ts +++ b/packages/@n8n/scheduler/src/core/executor/executor.ts @@ -274,36 +274,29 @@ export class Executor { await handler.execute(task, report); } catch (error) { await dispatchMark; - const errorMessage = ensureError(error).message; - const nextAttempts = task.attempts + 1; - if (nextAttempts >= task.maxAttempts) { - // If the handler had already handed off its effect (`dispatched()` ran, so - // `dispatchMark` is set) before throwing, the occurrence's work is done. - // On this last attempt, recording it failed would blame the scheduler for - // work that happened; complete it as succeeded instead, mirroring the - // reaper's post-dispatch branch. Only pre-dispatch failures are dead-lettered. - if (dispatchMark !== undefined) { - const rowsAffected = await this.store.completeTask(claim); - if (rowsAffected > 0) { - this.hooks.onFire?.(task.taskType, 'success'); - return { outcome: 'completed' }; - } - this.hooks.onLeaseLost?.(task.taskType); - return { outcome: 'skipped-not-owned', errorMessage }; - } - // A terminal write resolves 0 (it does not reject) when the row was - // reclaimed by the reaper after a lease overrun. The result is then no - // longer ours to record: report the fire as skipped, not as a state - // transition we did not make, and count only the lease-lost metric, - // not a fire outcome. Same on every terminal write below. - const rowsAffected = await this.store.failTaskTerminal(claim, errorMessage); - if (rowsAffected > 0) { - this.hooks.onFire?.(task.taskType, 'failure'); - return { outcome: 'dead-lettered', errorMessage }; - } - this.hooks.onLeaseLost?.(task.taskType); - return { outcome: 'skipped-not-owned', errorMessage }; - } + return await this.recordHandlerFailure(task, claim, error, dispatchMark !== undefined); + } + + markDispatched(); // A handler that returned without throwing is considered as dispatched + await dispatchMark; + const rowsAffected = await this.store.completeTask(claim); + if (rowsAffected > 0) { + this.hooks.onFire?.(task.taskType, 'success'); + return { outcome: 'completed' }; + } + this.hooks.onLeaseLost?.(task.taskType); + return { outcome: 'skipped-not-owned' }; + } + + private async recordHandlerFailure( + task: ClaimedTask, + claim: ClaimedTaskRef, + error: unknown, + dispatchWasReported: boolean, + ): Promise { + const errorMessage = ensureError(error).message; + const nextAttempts = task.attempts + 1; + if (nextAttempts < task.maxAttempts) { const rowsAffected = await this.store.rescheduleTask( claim, backoff(nextAttempts), @@ -317,15 +310,26 @@ export class Executor { return { outcome: 'skipped-not-owned', errorMessage }; } - markDispatched(); // A handler that returned without throwing is considered as dispatched - await dispatchMark; - const rowsAffected = await this.store.completeTask(claim); + // On the last attempt, complete work whose effect was handed off before the error. + // The report counts even if its marker write failed. + if (dispatchWasReported) { + const rowsAffected = await this.store.completeTask(claim); + if (rowsAffected > 0) { + this.hooks.onFire?.(task.taskType, 'success'); + return { outcome: 'completed' }; + } + this.hooks.onLeaseLost?.(task.taskType); + return { outcome: 'skipped-not-owned', errorMessage }; + } + + // Zero rows means the claim is gone. Report no transition that we did not make. + const rowsAffected = await this.store.failTaskTerminal(claim, errorMessage); if (rowsAffected > 0) { - this.hooks.onFire?.(task.taskType, 'success'); - return { outcome: 'completed' }; + this.hooks.onFire?.(task.taskType, 'failure'); + return { outcome: 'dead-lettered', errorMessage }; } this.hooks.onLeaseLost?.(task.taskType); - return { outcome: 'skipped-not-owned' }; + return { outcome: 'skipped-not-owned', errorMessage }; } /** Release a claim, reporting but swallowing failures: the reaper still recovers the row. */ From 39abed9124e43b2f9db88bddb855d2d6010ef481 Mon Sep 17 00:00:00 2001 From: Lorent Lempereur Date: Tue, 6 Oct 2026 09:07:33 +0000 Subject: [PATCH 06/61] refactor(core): Simplify the poll trigger task handler (no-changelog) (#40268) --- .../poll-trigger-task-handler.test.ts | 21 +++- .../poll-trigger-task-handler.ts | 99 +++++++------------ 2 files changed, 51 insertions(+), 69 deletions(-) diff --git a/packages/cli/src/scheduling/poll-trigger-node/__tests__/poll-trigger-task-handler.test.ts b/packages/cli/src/scheduling/poll-trigger-node/__tests__/poll-trigger-task-handler.test.ts index c6ea51207c2..9297797ea0c 100644 --- a/packages/cli/src/scheduling/poll-trigger-node/__tests__/poll-trigger-task-handler.test.ts +++ b/packages/cli/src/scheduling/poll-trigger-node/__tests__/poll-trigger-task-handler.test.ts @@ -477,13 +477,23 @@ describe('PollTriggerTaskHandler', () => { test('discards an abandoned poll that fails after the timeout', async () => { let rejectPoll: (error: Error) => void = () => {}; - triggersAndPollers.runPollFunction.mockReturnValue( - new Promise((_resolve, reject) => { - rejectPoll = reject; - }), + const pollPromise = new Promise((_resolve, reject) => { + rejectPoll = reject; + }); + // A plain stub, because a vi.fn() subscribes to the promise it returns and so + // would mark the late rejection as handled. + const plainHandler = new PollTriggerTaskHandler( + rootLogger, + triggerExecutionContextFactory, + mock({ runPollFunction: async () => await pollPromise }), + workflowRepository, + errorReporter, + pollBackoffService, + eventService, + globalConfig, ); - const executing = handler.execute(buildTask(), report); + const executing = plainHandler.execute(buildTask(), report); await vi.advanceTimersByTimeAsync(pollTimeoutMs); await executing; rejectPoll(new Error('poll source unreachable')); @@ -491,6 +501,7 @@ describe('PollTriggerTaskHandler', () => { // The tick was already reported as abandoned, so the late failure is dropped // rather than routed to the error workflow. + expect(eventService.emit).toHaveBeenCalledWith('poll-tick-timed-out', expect.anything()); expect(pollFunctions.__emitError).not.toHaveBeenCalled(); expect(onDispatch).not.toHaveBeenCalled(); }); diff --git a/packages/cli/src/scheduling/poll-trigger-node/poll-trigger-task-handler.ts b/packages/cli/src/scheduling/poll-trigger-node/poll-trigger-task-handler.ts index a454de7a559..9dc92b904ae 100644 --- a/packages/cli/src/scheduling/poll-trigger-node/poll-trigger-task-handler.ts +++ b/packages/cli/src/scheduling/poll-trigger-node/poll-trigger-task-handler.ts @@ -2,6 +2,7 @@ import { Logger } from '@n8n/backend-common'; import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; +import type { PollerFailureState } from '@n8n/db'; import { WorkflowRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import type { ClaimedTask, DispatchDecision, DispatchReporter, TaskHandler } from '@n8n/scheduler'; @@ -76,15 +77,13 @@ export class PollTriggerTaskHandler implements TaskHandler { // A setup failure here retries to N8N_SCHEDULER_MAX_ATTEMPTS then dead-letters, // unlike a `poll()` runtime failure below, which routes to the error workflow instead. const { workflowId, nodeId } = this.parsePayload(task); + const logContext = { taskId: task.id, jobId: task.jobId, workflowId, nodeId }; const now = new Date(); const state = await this.pollBackoffService.getState(workflowId, nodeId).catch(() => null); if (this.pollBackoffService.isBackingOff(state, now)) { this.logger.debug('Poll is backing off; skipping this occurrence', { - taskId: task.id, - jobId: task.jobId, - workflowId, - nodeId, + ...logContext, backoffUntil: state?.backoffUntil, }); return report.notDispatched(); @@ -96,12 +95,7 @@ export class PollTriggerTaskHandler implements TaskHandler { ); if (workflowData === null) { - this.logger.debug('Workflow has no published version. Skipping the occurrence.', { - taskId: task.id, - jobId: task.jobId, - workflowId, - nodeId, - }); + this.logger.debug('Workflow has no published version. Skipping the occurrence.', logContext); return report.notDispatched(); } @@ -113,7 +107,7 @@ export class PollTriggerTaskHandler implements TaskHandler { if (!this.hasHealthyNodeIds(workflowData)) { this.logger.debug( 'Published version has duplicate or missing node ids; skipping the occurrence until it is healed', - { taskId: task.id, jobId: task.jobId, workflowId, nodeId }, + logContext, ); return report.notDispatched(); } @@ -155,9 +149,6 @@ export class PollTriggerTaskHandler implements TaskHandler { // the poll window untouched for the next occurrence to cover. const deadline = timeoutAfter(this.pollTimeoutMs); const poll = this.triggersAndPollers.runPollFunction(workflow, node, pollFunctions); - // Deliberately not chained: keeps an abandoned poll's eventual rejection from - // surfacing as an unhandled rejection once the race has moved on. - poll.catch(() => {}); let pollResponse: Awaited; try { @@ -165,25 +156,13 @@ export class PollTriggerTaskHandler implements TaskHandler { if (outcome === TIMED_OUT) { this.eventService.emit('poll-tick-timed-out', { nodeType: node.type }); this.logger.warn('Poll exceeded its timeout and was abandoned', { - taskId: task.id, - jobId: task.jobId, - workflowId, - nodeId, + ...logContext, pollTimeoutMs: this.pollTimeoutMs, }); // Not routed to the error workflow: an abandoned poll produces no run, and // an error run is one. It does count as a poll failure, so a source that // keeps hanging is re-polled at a widening interval like any failing source. - const isActive = await this.workflowRepository.isActive(workflowId).catch(() => true); - if (isActive) { - await this.pollBackoffService.recordFailure({ - workflowId, - nodeId, - error: new PollTimeoutError(), - state, - now: new Date(), // Fresh clock, not the tick's - }); - } + await this.recordFailureIfActive(workflowId, nodeId, new PollTimeoutError(), state); return report.notDispatched(); } pollResponse = outcome; @@ -199,23 +178,16 @@ export class PollTriggerTaskHandler implements TaskHandler { // the workflow may have been deactivated while it was in flight. There is // no in-memory registration to check here, so re-read the stored active state. if (!(await this.workflowRepository.isActive(workflowId))) { - this.logger.debug('Workflow deactivated during poll; discarding the result', { - taskId: task.id, - jobId: task.jobId, - workflowId, - nodeId, - }); + this.logger.debug( + 'Workflow deactivated during poll; discarding the result', + logContext, + ); return report.notDispatched(); } // __emit saves the cursor and starts the run without waiting on it. pollFunctions.__emit(pollResponse); - this.logger.debug('Poll returned new data; handed off to a new execution', { - taskId: task.id, - jobId: task.jobId, - workflowId, - nodeId, - }); + this.logger.debug('Poll returned new data; handed off to a new execution', logContext); return report.dispatched(); } @@ -229,44 +201,25 @@ export class PollTriggerTaskHandler implements TaskHandler { // The poll itself succeeded, so a failed cursor write is logged rather // than routed to the error workflow. this.errorReporter.error(error, { - extra: { taskId: task.id, jobId: task.jobId, workflowId, nodeId }, + extra: logContext, }); this.logger.error( 'Failed to commit the poll cursor; the next poll repeats the same window', - { taskId: task.id, jobId: task.jobId, workflowId, nodeId, error }, + { ...logContext, error }, ); } - this.logger.debug('Poll returned no new data; nothing to hand off', { - taskId: task.id, - jobId: task.jobId, - workflowId, - nodeId, - }); + this.logger.debug('Poll returned no new data; nothing to hand off', logContext); return report.notDispatched(); } catch (error) { // Routed to the error workflow instead of rethrown, which would retry and // dead-letter without ever running it. __emitError commits no cursor, so // the cursor holds and the next tick retries the same window. if (!polled) { - const isActive = await this.workflowRepository.isActive(workflowId).catch(() => true); - if (isActive) { - await this.pollBackoffService.recordFailure({ - workflowId, - nodeId, - error, - state, - now: new Date(), // Fresh clock, not the tick's - }); - } + await this.recordFailureIfActive(workflowId, nodeId, error, state); } pollFunctions.__emitError(ensureError(error)); - this.logger.debug('Poll failed at runtime; routed to the error workflow', { - taskId: task.id, - jobId: task.jobId, - workflowId, - nodeId, - }); + this.logger.debug('Poll failed at runtime; routed to the error workflow', logContext); // The error was handed off, so this occurrence is handled and must not retry. return report.dispatched(); } finally { @@ -275,6 +228,24 @@ export class PollTriggerTaskHandler implements TaskHandler { }); } + private async recordFailureIfActive( + workflowId: string, + nodeId: string, + error: unknown, + state: PollerFailureState | null, + ): Promise { + const isActive = await this.workflowRepository.isActive(workflowId).catch(() => true); + if (isActive) { + await this.pollBackoffService.recordFailure({ + workflowId, + nodeId, + error, + state, + now: new Date(), // Use the failure time, not the tick start. + }); + } + } + /** The invariant the activation-time healer guarantees: every node id unique and non-empty. */ private hasHealthyNodeIds(workflowData: IWorkflowBase): boolean { const ids = workflowData.nodes.map((node) => node.id); From 4b69c32541f66c86e0c8ce66b6cc6f023d0a9f3c Mon Sep 17 00:00:00 2001 From: Lorent Lempereur Date: Tue, 6 Oct 2026 09:07:34 +0000 Subject: [PATCH 07/61] feat(core): Add a lease renewal query to the scheduled task repository (no-changelog) (#40269) --- .../repositories/scheduled-task.repository.ts | 16 +++++ .../scheduling/executor-repository.test.ts | 63 +++++++++++++++++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/@n8n/db/src/repositories/scheduled-task.repository.ts b/packages/@n8n/db/src/repositories/scheduled-task.repository.ts index 094ad6fa14e..3048668227f 100644 --- a/packages/@n8n/db/src/repositories/scheduled-task.repository.ts +++ b/packages/@n8n/db/src/repositories/scheduled-task.repository.ts @@ -493,6 +493,22 @@ export class ScheduledTaskRepository extends Repository { 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} `true` when the lease was extended, `false` when the + * claim is gone (reclaimed, finished or deleted) + */ + async renewLease(claim: HostedClaimedRef, expiresInMs: number): Promise { + 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 diff --git a/packages/cli/test/integration/scheduling/executor-repository.test.ts b/packages/cli/test/integration/scheduling/executor-repository.test.ts index 9db02fc6959..f0fea151939 100644 --- a/packages/cli/test/integration/scheduling/executor-repository.test.ts +++ b/packages/cli/test/integration/scheduling/executor-repository.test.ts @@ -677,6 +677,29 @@ describe('ScheduledTaskRepository executor methods', () => { expect((await reload(id)).startedAt).not.toBeNull(); }); + it('renewLease pushes the lease out for the claiming host, and refuses a non-claiming host, a stale epoch and a finished row', async () => { + const { id, epoch } = await claimOne(); + const leaseBefore = (await reload(id)).leaseExpiresAt!.getTime(); + + expect( + await taskRepository.renewLease({ host: HOST_B, id, claimedEpoch: epoch }, 120_000), + ).toBe(false); + expect( + await taskRepository.renewLease({ host: HOST_A, id, claimedEpoch: epoch - 1 }, 120_000), + ).toBe(false); + expect((await reload(id)).leaseExpiresAt!.getTime()).toBe(leaseBefore); + + expect( + await taskRepository.renewLease({ host: HOST_A, id, claimedEpoch: epoch }, 120_000), + ).toBe(true); + expect((await reload(id)).leaseExpiresAt!.getTime()).toBeGreaterThan(leaseBefore); + + await taskRepository.completeTask({ host: HOST_A, id, claimedEpoch: epoch }); + expect( + await taskRepository.renewLease({ host: HOST_A, id, claimedEpoch: epoch }, 120_000), + ).toBe(false); + }); + it('markDispatched stamps dispatchedAt for the owner, and is a no-op for a non-owner host', async () => { const { id, epoch } = await claimOne(); @@ -1025,10 +1048,9 @@ describe('ScheduledTaskRepository executor methods', () => { }); it('reaps a lease that expired a moment ago (boundary)', async () => { - // The WHERE clause is strictly `leaseExpiresAt < now()`. Expiring 1ms in the - // past keeps the lease unambiguously below the DB clock, so this pins the - // comparison to `<` rather than an off-by-one variant that would exclude it. - const task = await createExpiredRunning({ leaseExpiresAt: new Date(Date.now() - 1) }); + // Use the reaper's clock: the database and test host can differ by more than 1 ms. + const now = await taskRepository.readDbTime(); + const task = await createExpiredRunning({ leaseExpiresAt: new Date(now.getTime() - 1) }); const found = await taskRepository.findExpiredLeases(10); @@ -1144,6 +1166,39 @@ describe('ScheduledTaskRepository executor methods', () => { expect(row.leaseEpoch).toBe(2); // bumped once, not twice }); + it('is a no-op after the owner renewed the lease', async () => { + const task = await createExpiredRunning({ leaseEpoch: 1, attempts: 0 }); + + expect( + await taskRepository.renewLease({ host: HOST_A, id: task.id, claimedEpoch: 1 }, 60_000), + ).toBe(true); + expect( + await taskRepository.reclaimExpired({ id: task.id, claimedEpoch: 1 }, 30_000, 'x'), + ).toBe(0); + + const row = await reload(task.id); + expect(row.status).toBe('running'); + expect(row.leaseEpoch).toBe(1); + const dbNow = await taskRepository.readDbTime(); + expect(row.leaseExpiresAt!.getTime()).toBeGreaterThan(dbNow.getTime()); + }); + + it('makes a later renewal by the previous owner a no-op', async () => { + const task = await createExpiredRunning({ leaseEpoch: 1, attempts: 0 }); + + expect( + await taskRepository.reclaimExpired({ id: task.id, claimedEpoch: 1 }, 30_000, 'x'), + ).toBe(1); + expect( + await taskRepository.renewLease({ host: HOST_A, id: task.id, claimedEpoch: 1 }, 60_000), + ).toBe(false); + + const row = await reload(task.id); + expect(row.status).toBe('pending'); + expect(row.leaseEpoch).toBe(2); + expect(row.attempts).toBe(1); + }); + it('is a no-op on a dispatched row: a dispatched occurrence is completed, not reclaimed', async () => { // The pre-dispatch fence (`dispatchedAt IS NULL`) stops the reaper redelivering an // occurrence whose effect already happened, e.g. a marker that landed after the From f4c734e7e0f04cc24b504503aebd31a5fc90734d Mon Sep 17 00:00:00 2001 From: Lorent Lempereur Date: Tue, 6 Oct 2026 09:07:35 +0000 Subject: [PATCH 08/61] feat(core): Add a lease heartbeat to the durable scheduler (no-changelog) (#40270) --- .../core/__tests__/executor-lookahead.test.ts | 3 + packages/@n8n/scheduler/src/core/errors.ts | 8 + .../__tests__/lease-heartbeat.test.ts | 306 ++++++++++++++++++ .../src/core/executor/lease-constants.ts | 15 + .../src/core/executor/lease-heartbeat.ts | 115 +++++++ .../@n8n/scheduler/src/core/executor/store.ts | 9 + .../scheduler/src/observability/metrics.ts | 7 + .../scheduler-metrics.service.test.ts | 12 + .../prometheus/scheduler-metrics.service.ts | 13 + 9 files changed, 488 insertions(+) create mode 100644 packages/@n8n/scheduler/src/core/executor/__tests__/lease-heartbeat.test.ts create mode 100644 packages/@n8n/scheduler/src/core/executor/lease-constants.ts create mode 100644 packages/@n8n/scheduler/src/core/executor/lease-heartbeat.ts diff --git a/packages/@n8n/scheduler/src/core/__tests__/executor-lookahead.test.ts b/packages/@n8n/scheduler/src/core/__tests__/executor-lookahead.test.ts index bf3fd855db7..ee4f09c3228 100644 --- a/packages/@n8n/scheduler/src/core/__tests__/executor-lookahead.test.ts +++ b/packages/@n8n/scheduler/src/core/__tests__/executor-lookahead.test.ts @@ -71,6 +71,9 @@ describe('executor claims far enough ahead to fire on time', () => { async beginDispatch(): Promise { return await Promise.resolve(1); } + async renewLease(): Promise { + return await Promise.resolve(true); + } async markDispatched(): Promise { return await Promise.resolve(1); } diff --git a/packages/@n8n/scheduler/src/core/errors.ts b/packages/@n8n/scheduler/src/core/errors.ts index ed83dd22eb0..681806c7c3d 100644 --- a/packages/@n8n/scheduler/src/core/errors.ts +++ b/packages/@n8n/scheduler/src/core/errors.ts @@ -59,6 +59,14 @@ export class DuplicateTaskHandlerError extends Error { } } +/** Raised when a lease heartbeat is given a lease duration that is not a positive integer. */ +export class InvalidLeaseDurationError extends Error { + constructor(readonly leaseDurationMs: number) { + super('The lease duration must be a positive integer of milliseconds'); + this.name = 'InvalidLeaseDurationError'; + } +} + /** * Raised when a stored row is missing a column its `kind` guarantees should be * set (a corrupt or hand-edited row), while assembling its `Schedule` from the diff --git a/packages/@n8n/scheduler/src/core/executor/__tests__/lease-heartbeat.test.ts b/packages/@n8n/scheduler/src/core/executor/__tests__/lease-heartbeat.test.ts new file mode 100644 index 00000000000..7c7a07a1884 --- /dev/null +++ b/packages/@n8n/scheduler/src/core/executor/__tests__/lease-heartbeat.test.ts @@ -0,0 +1,306 @@ +import { InvalidLeaseDurationError } from '../../errors'; +import { LeaseHeartbeat } from '../lease-heartbeat'; + +const INTERVAL_MS = 5_000; +const LEASE_MS = 3 * INTERVAL_MS; + +const options = () => ({ + leaseDurationMs: LEASE_MS, + leaseSetAt: performance.now(), +}); + +describe('LeaseHeartbeat', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renews once per interval while running', async () => { + const renew = vi.fn().mockResolvedValue(true); + const onRenewal = vi.fn(); + const heartbeat = new LeaseHeartbeat(renew, options(), { onRenewal }); + + await vi.advanceTimersByTimeAsync(INTERVAL_MS - 1); + expect(renew).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1 + 2 * INTERVAL_MS); + expect(renew).toHaveBeenCalledTimes(3); + expect(renew).toHaveBeenCalledWith(LEASE_MS); + expect(onRenewal).toHaveBeenCalledTimes(3); + expect(onRenewal).toHaveBeenCalledWith('renewed'); + + heartbeat.stop(); + }); + + it('does not keep the process alive', async () => { + const renew = vi.fn().mockResolvedValue(true); + const heartbeat = new LeaseHeartbeat(renew, options()); + const alarms = heartbeat as unknown as Record< + 'beatAlarm' | 'expiryAlarm', + { timer: NodeJS.Timeout } + >; + expect(alarms.beatAlarm.timer.hasRef()).toBe(false); + expect(alarms.expiryAlarm.timer.hasRef()).toBe(false); + + await vi.advanceTimersByTimeAsync(INTERVAL_MS); + expect(renew).toHaveBeenCalledTimes(1); + expect(alarms.beatAlarm.timer.hasRef()).toBe(false); + expect(alarms.expiryAlarm.timer.hasRef()).toBe(false); + + heartbeat.stop(); + }); + + it('lands a third renewal before the lease expires when the first two fail', async () => { + const leaseDurationMs = 60_000; + const renew = vi + .fn() + .mockRejectedValueOnce(new Error('db down')) + .mockRejectedValueOnce(new Error('db down')) + .mockResolvedValue(true); + const onRenewal = vi.fn(); + const heartbeat = new LeaseHeartbeat( + renew, + { leaseDurationMs, leaseSetAt: performance.now() }, + { onRenewal }, + ); + + await vi.advanceTimersByTimeAsync(leaseDurationMs); + expect(onRenewal).toHaveBeenCalledWith('renewed'); + expect(onRenewal).not.toHaveBeenCalledWith('expired'); + + heartbeat.stop(); + }); + + it('lands a third renewal before the lease expires when the first two fail slowly', async () => { + const leaseDurationMs = 60_000; + const slowFailure = async () => + await new Promise((_, reject) => + setTimeout(() => reject(new Error('db down')), 10_000), + ); + const renew = vi + .fn() + .mockImplementationOnce(slowFailure) + .mockImplementationOnce(slowFailure) + .mockResolvedValue(true); + const onRenewal = vi.fn(); + const heartbeat = new LeaseHeartbeat( + renew, + { leaseDurationMs, leaseSetAt: performance.now() }, + { onRenewal }, + ); + + await vi.advanceTimersByTimeAsync(leaseDurationMs); + expect(onRenewal).toHaveBeenCalledWith('renewed'); + expect(onRenewal).not.toHaveBeenCalledWith('expired'); + + heartbeat.stop(); + }); + + it('waits at least five seconds between renewals, so a short lease expires before its first one', async () => { + const renew = vi.fn().mockResolvedValue(true); + const onRenewal = vi.fn(); + const heartbeat = new LeaseHeartbeat( + renew, + { leaseDurationMs: 3_000, leaseSetAt: performance.now() }, + { onRenewal }, + ); + + await vi.advanceTimersByTimeAsync(5_000 - 1); + expect(renew).not.toHaveBeenCalled(); + expect(onRenewal).toHaveBeenCalledExactlyOnceWith('expired'); + + await vi.advanceTimersByTimeAsync(1); + expect(renew).toHaveBeenCalledTimes(1); + + heartbeat.stop(); + }); + + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + 'rejects a lease duration of %s', + (leaseDurationMs) => { + expect( + () => new LeaseHeartbeat(vi.fn(), { leaseDurationMs, leaseSetAt: performance.now() }), + ).toThrow(InvalidLeaseDurationError); + }, + ); + + it('reports a lost claim once and stops renewing', async () => { + const renew = vi.fn().mockResolvedValue(false); + const onRenewal = vi.fn(); + const heartbeat = new LeaseHeartbeat(renew, options(), { onRenewal }); + + await vi.advanceTimersByTimeAsync(INTERVAL_MS); + + expect(onRenewal).toHaveBeenCalledExactlyOnceWith('lost'); + + await vi.advanceTimersByTimeAsync(5 * INTERVAL_MS); + expect(renew).toHaveBeenCalledTimes(1); + + heartbeat.stop(); + }); + + it('reports a failed renewal and tries again on the next beat', async () => { + const failure = new Error('db down'); + const renew = vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(true); + const onRenewalError = vi.fn(); + const heartbeat = new LeaseHeartbeat(renew, options(), { onRenewalError }); + + await vi.advanceTimersByTimeAsync(2 * INTERVAL_MS); + + expect(onRenewalError).toHaveBeenCalledWith(failure); + expect(renew).toHaveBeenCalledTimes(2); + + heartbeat.stop(); + }); + + it('reports an expired lease once a whole lease passes without a successful renewal', async () => { + const renew = vi.fn().mockRejectedValue(new Error('db down')); + const onRenewal = vi.fn(); + const heartbeat = new LeaseHeartbeat(renew, options(), { onRenewal }); + + await vi.advanceTimersByTimeAsync(LEASE_MS - 1); + expect(onRenewal).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(onRenewal).toHaveBeenCalledExactlyOnceWith('expired'); + + heartbeat.stop(); + }); + + it('keeps renewing after a missed lease, so the claim survives a short outage', async () => { + const renew = vi + .fn() + .mockRejectedValueOnce(new Error('db down')) + .mockRejectedValueOnce(new Error('db down')) + .mockResolvedValue(true); + const onRenewal = vi.fn(); + const heartbeat = new LeaseHeartbeat(renew, options(), { onRenewal }); + + await vi.advanceTimersByTimeAsync(LEASE_MS + INTERVAL_MS); + + expect(renew).toHaveBeenCalledTimes(4); + expect(onRenewal.mock.calls).toEqual([['expired'], ['renewed'], ['renewed']]); + + heartbeat.stop(); + }); + + it('reports an expired lease, then a lost claim once a renewal finds it gone', async () => { + const renew = vi + .fn() + .mockRejectedValueOnce(new Error('db down')) + .mockRejectedValueOnce(new Error('db down')) + .mockRejectedValueOnce(new Error('db down')) + .mockResolvedValue(false); + const onRenewal = vi.fn(); + const heartbeat = new LeaseHeartbeat(renew, options(), { onRenewal }); + + await vi.advanceTimersByTimeAsync(LEASE_MS + INTERVAL_MS); + + expect(renew).toHaveBeenCalledTimes(4); + expect(onRenewal.mock.calls).toEqual([['expired'], ['lost']]); + + heartbeat.stop(); + }); + + it('counts the lease from the start of the last successful renewal', async () => { + const renew = vi.fn().mockResolvedValueOnce(true).mockRejectedValue(new Error('db down')); + const onRenewal = vi.fn(); + const heartbeat = new LeaseHeartbeat(renew, options(), { onRenewal }); + + await vi.advanceTimersByTimeAsync(INTERVAL_MS + LEASE_MS - 1); + expect(onRenewal).not.toHaveBeenCalledWith('expired'); + + await vi.advanceTimersByTimeAsync(1); + expect(onRenewal).toHaveBeenCalledWith('expired'); + + heartbeat.stop(); + }); + + it('counts the first beat from the lease write, so a late start does not delay it', async () => { + const renew = vi.fn().mockResolvedValue(true); + const heartbeat = new LeaseHeartbeat(renew, { + leaseDurationMs: LEASE_MS, + leaseSetAt: performance.now() - INTERVAL_MS + 1, + }); + + expect(renew).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(renew).toHaveBeenCalledTimes(1); + + heartbeat.stop(); + }); + + it('measures the lease on a monotonic clock, so a wall-clock jump does not delay the expiry', async () => { + const renew = vi + .fn() + .mockImplementationOnce(async () => { + vi.setSystemTime(Date.now() - 60 * 60 * 1_000); + return true; + }) + .mockRejectedValue(new Error('db down')); + const onRenewal = vi.fn(); + const heartbeat = new LeaseHeartbeat(renew, options(), { onRenewal }); + + await vi.advanceTimersByTimeAsync(INTERVAL_MS + LEASE_MS); + expect(onRenewal).toHaveBeenCalledWith('expired'); + + heartbeat.stop(); + }); + + it('waits out a lease longer than the timer limit', async () => { + const day = 24 * 60 * 60 * 1_000; + const leaseDurationMs = 90 * day; + const renew = vi.fn().mockRejectedValue(new Error('db down')); + const onRenewal = vi.fn(); + const heartbeat = new LeaseHeartbeat( + renew, + { leaseDurationMs, leaseSetAt: performance.now() }, + { onRenewal }, + ); + + await vi.advanceTimersByTimeAsync(1_000); + expect(renew).not.toHaveBeenCalled(); + expect(onRenewal).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(leaseDurationMs - 1_000 - 1); + expect(onRenewal).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(onRenewal).toHaveBeenCalledExactlyOnceWith('expired'); + + heartbeat.stop(); + }); + + it('never renews after stop', async () => { + const renew = vi.fn().mockResolvedValue(true); + const heartbeat = new LeaseHeartbeat(renew, options()); + + heartbeat.stop(); + await vi.advanceTimersByTimeAsync(5 * INTERVAL_MS); + + expect(renew).not.toHaveBeenCalled(); + }); + + it('ignores a renewal that settles after stop, so a terminal write is not taken for a lost claim', async () => { + let settle!: (renewed: boolean) => void; + const renew = vi.fn( + async () => + await new Promise((resolve) => { + settle = resolve; + }), + ); + const onRenewal = vi.fn(); + const heartbeat = new LeaseHeartbeat(renew, options(), { onRenewal }); + + await vi.advanceTimersByTimeAsync(INTERVAL_MS); + heartbeat.stop(); + settle(false); + await vi.advanceTimersByTimeAsync(5 * INTERVAL_MS); + + expect(onRenewal).not.toHaveBeenCalled(); + expect(renew).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/@n8n/scheduler/src/core/executor/lease-constants.ts b/packages/@n8n/scheduler/src/core/executor/lease-constants.ts new file mode 100644 index 00000000000..4a07c8bc0fb --- /dev/null +++ b/packages/@n8n/scheduler/src/core/executor/lease-constants.ts @@ -0,0 +1,15 @@ +// These are constants, not environment variables, to keep this mechanism under +// control in its first version. A per-job timeout may come later. + +/** + * Renewals per lease: two may fail in a row before the lease expires. + * The cadence depends on the lease, not on how long a run takes. + */ +export const RENEWALS_PER_LEASE = 3; + +/** + * The shortest time between two renewals, so a short lease does not load the + * database. A lease shorter than {@link RENEWALS_PER_LEASE} plus one times this gets fewer + * renewals, and one this short or shorter expires before its first renewal. + */ +export const MIN_RENEWAL_INTERVAL_MS = 5_000; diff --git a/packages/@n8n/scheduler/src/core/executor/lease-heartbeat.ts b/packages/@n8n/scheduler/src/core/executor/lease-heartbeat.ts new file mode 100644 index 00000000000..3e94427d0b5 --- /dev/null +++ b/packages/@n8n/scheduler/src/core/executor/lease-heartbeat.ts @@ -0,0 +1,115 @@ +import { MIN_RENEWAL_INTERVAL_MS, RENEWALS_PER_LEASE } from './lease-constants'; +import { InvalidLeaseDurationError } from '../errors'; +import { Alarm } from '../lifecycle/alarm'; + +/** + * How the claim stands: + * - `renewed`: the lease was extended. + * - `lost`: a renewal found the claim gone. + * - `expired`: no renewal succeeded for a whole lease, so the reaper may hand the + * claim to another instance. + */ +export type LeaseRenewalResult = 'renewed' | 'lost' | 'expired'; + +export interface LeaseHeartbeatOptions { + /** How long a lease stays valid after the write that sets it. A positive integer. */ + leaseDurationMs: number; + /** `performance.now()` just before the write that last set the lease. */ + leaseSetAt: number; +} + +export interface LeaseHeartbeatHooks { + onRenewal?: (result: LeaseRenewalResult) => void; + /** A renewal write failed. The next beat tries again while the lease still holds. */ + onRenewalError?: (error: unknown) => void; +} + +/** + * Keeps a claim's lease alive while its handler runs. + * + * It calls `renew` {@link RENEWALS_PER_LEASE} times per lease, one beat at a time, + * with `leaseDurationMs` as the new lease length. + * It reports `lost` and stops when `renew` reports `false`. + * It reports `expired` when `leaseDurationMs` passes without a successful renewal, + * since the reaper may then hand the claim to another instance, + * but it keeps renewing: + * the claim survives if a renewal lands before the reaper sweeps. + * + * Call {@link stop} once the handler settles. + * + * @throws {InvalidLeaseDurationError} when `leaseDurationMs` is not a positive integer + */ +export class LeaseHeartbeat { + private readonly beatAlarm = new Alarm(() => performance.now()); + + private readonly expiryAlarm = new Alarm(() => performance.now()); + + private stopped = false; + + private readonly intervalMs: number; + + constructor( + private readonly renew: (expiresInMs: number) => Promise, + private readonly options: LeaseHeartbeatOptions, + private readonly hooks: LeaseHeartbeatHooks = {}, + ) { + if (!Number.isInteger(options.leaseDurationMs) || options.leaseDurationMs <= 0) { + throw new InvalidLeaseDurationError(options.leaseDurationMs); + } + // One spare interval, so the last renewal of a lease still lands before it expires. + this.intervalMs = Math.max( + MIN_RENEWAL_INTERVAL_MS, + Math.floor(options.leaseDurationMs / (RENEWALS_PER_LEASE + 1)), + ); + this.armExpiry(options.leaseSetAt); + this.scheduleBeat(options.leaseSetAt); + } + + stop(): void { + this.stopped = true; + this.beatAlarm.cancel(); + this.expiryAlarm.cancel(); + } + + private lose(): void { + this.stop(); + this.hooks.onRenewal?.('lost'); + } + + private armExpiry(leaseSetAt: number): void { + this.expiryAlarm.set(leaseSetAt + this.options.leaseDurationMs, () => + this.hooks.onRenewal?.('expired'), + ); + } + + private scheduleBeat(intervalStartAt: number): void { + this.beatAlarm.set(intervalStartAt + this.intervalMs, () => { + this.beat().catch((error: unknown) => this.hooks.onRenewalError?.(error)); + }); + } + + private async beat(): Promise { + const startedAt = performance.now(); + try { + const renewed = await this.renew(this.options.leaseDurationMs); + // The handler settled while the renewal was in flight, so a refusal here can + // be its own terminal write, not a lost claim. + if (this.stopped) { + return; + } + if (!renewed) { + this.lose(); + return; + } + this.armExpiry(startedAt); + this.hooks.onRenewal?.('renewed'); + } catch (error) { + if (this.stopped) { + return; + } + this.hooks.onRenewalError?.(error); + } + // Count the interval from this beat's start, so a slow renewal does not delay the next one. + this.scheduleBeat(startedAt); + } +} diff --git a/packages/@n8n/scheduler/src/core/executor/store.ts b/packages/@n8n/scheduler/src/core/executor/store.ts index 0b00292f366..20e65aeb935 100644 --- a/packages/@n8n/scheduler/src/core/executor/store.ts +++ b/packages/@n8n/scheduler/src/core/executor/store.ts @@ -56,6 +56,15 @@ export interface ExecutorTaskStore { */ beginDispatch(claim: ClaimedTaskRef, leaseMs: number): Promise; + /** + * Extend the lease of a claim whose handler is still running. + * + * @param {ClaimedTaskRef} claim the claim to renew + * @param {number} expiresInMs the new lease expiry, in milliseconds from the store's now + * @returns {Promise} `true` when the lease was extended, `false` when the claim is gone + */ + renewLease(claim: ClaimedTaskRef, expiresInMs: number): Promise; + /** * Stamp the effect-boundary marker (`dispatchedAt`) once the handler reports its * effect handed off. Guarded on the claim; 0 rows affected is a benign no-op. diff --git a/packages/@n8n/scheduler/src/observability/metrics.ts b/packages/@n8n/scheduler/src/observability/metrics.ts index 4c4c13ba4fb..5fab4be2f33 100644 --- a/packages/@n8n/scheduler/src/observability/metrics.ts +++ b/packages/@n8n/scheduler/src/observability/metrics.ts @@ -1,3 +1,4 @@ +import type { LeaseRenewalResult } from '../core/executor/lease-heartbeat'; import type { MisfireCount } from '../core/materializer/materialize'; /** @@ -20,6 +21,11 @@ export interface SchedulerMetrics { * have run the same occurrence concurrently. */ recordLeaseLost(taskType: string): void; + /** + * A running handler's lease was renewed, a renewal found its claim gone + * ('lost'), or no renewal succeeded for a whole lease ('expired'). + */ + recordLeaseRenewal(taskType: string, result: LeaseRenewalResult): void; /** Outcome of one materialization pass. */ recordMaterialized(occurrences: number, deferredJobs: number): void; @@ -48,6 +54,7 @@ export const noopMetrics: SchedulerMetrics = { recordRetry() {}, observeDispatchLagSeconds() {}, recordLeaseLost() {}, + recordLeaseRenewal() {}, recordMaterialized() {}, recordMisfired() {}, recordRetired() {}, diff --git a/packages/cli/src/metrics/prometheus/__tests__/scheduler-metrics.service.test.ts b/packages/cli/src/metrics/prometheus/__tests__/scheduler-metrics.service.test.ts index 9c66444813b..252f1fc9a09 100644 --- a/packages/cli/src/metrics/prometheus/__tests__/scheduler-metrics.service.test.ts +++ b/packages/cli/src/metrics/prometheus/__tests__/scheduler-metrics.service.test.ts @@ -128,6 +128,7 @@ describe('PrometheusSchedulerMetricsService', () => { 'n8n_scheduler_orphaned_jobs_deleted_total', 'n8n_scheduler_jobs_revived_total', 'n8n_scheduler_tasks_lease_lost_total', + 'n8n_scheduler_lease_renewals_total', ]), ); @@ -338,6 +339,16 @@ describe('PrometheusSchedulerMetricsService', () => { expect(inc).toHaveBeenCalledWith({ task_type: 'workflow:poll-trigger' }, 1); expect(inc).toHaveBeenCalledTimes(1); }); + + it('increments the lease-renewal counter by task type and result', () => { + service.recordLeaseRenewal('system-task', 'renewed'); + service.recordLeaseRenewal('system-task', 'lost'); + + const inc = counterIncFor('n8n_scheduler_lease_renewals_total'); + expect(inc).toHaveBeenCalledWith({ task_type: 'system-task', result: 'renewed' }, 1); + expect(inc).toHaveBeenCalledWith({ task_type: 'system-task', result: 'lost' }, 1); + expect(inc).toHaveBeenCalledTimes(2); + }); }); describe('push metrics before init', () => { @@ -357,6 +368,7 @@ describe('PrometheusSchedulerMetricsService', () => { service.recordPruned(1); service.recordReconciled(1, 1, 1); service.recordLeaseLost('workflow'); + service.recordLeaseRenewal('workflow', 'renewed'); expect(sharedCounterInc).not.toHaveBeenCalled(); expect(mockHistogramObserve).not.toHaveBeenCalled(); diff --git a/packages/cli/src/metrics/prometheus/scheduler-metrics.service.ts b/packages/cli/src/metrics/prometheus/scheduler-metrics.service.ts index 0de192d578d..51ae05b431b 100644 --- a/packages/cli/src/metrics/prometheus/scheduler-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/scheduler-metrics.service.ts @@ -43,6 +43,7 @@ export class PrometheusSchedulerMetricsService private orphanedJobsDeleted!: promClient.Counter; private jobsRevived!: promClient.Counter; private tasksLeaseLost!: promClient.Counter<'task_type'>; + private leaseRenewals!: promClient.Counter<'task_type' | 'result'>; private dispatchLagSeconds!: promClient.Histogram<'task_type'>; constructor( @@ -145,6 +146,12 @@ export class PrometheusSchedulerMetricsService labelNames: ['task_type'], }); + this.leaseRenewals = new promClient.Counter({ + name: `${prefix}scheduler_lease_renewals_total`, + help: 'Total number of lease renewals of running scheduler tasks by task type and result: renewed, lost when a renewal found the claim gone, or expired when no renewal succeeded for a whole lease.', + labelNames: ['task_type', 'result'], + }); + this.dispatchLagSeconds = new promClient.Histogram({ name: `${prefix}scheduler_dispatch_lag_seconds`, help: 'Delay in seconds between a task becoming due and being dispatched, by task type.', @@ -256,6 +263,12 @@ export class PrometheusSchedulerMetricsService } } + recordLeaseRenewal(taskType: string, result: 'renewed' | 'lost' | 'expired') { + if (this.initialized) { + this.leaseRenewals.inc({ task_type: taskType, result }, 1); + } + } + recordMaterialized(occurrences: number, deferredJobs: number) { if (this.initialized) { this.occurrencesMaterialized.inc(occurrences); From fe91413e5dd27c246f55db087ce1602a35107b0f Mon Sep 17 00:00:00 2001 From: Lorent Lempereur Date: Tue, 6 Oct 2026 09:07:36 +0000 Subject: [PATCH 09/61] feat(core): Renew the lease of a durable task while its handler runs (no-changelog) (#40271) Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Emilia <100027345+sovietspaceship@users.noreply.github.com> --- .../config/src/configs/scheduler.config.ts | 6 - packages/@n8n/scheduler/README.md | 22 +- .../src/core/__tests__/factory.test.ts | 112 +++++++ packages/@n8n/scheduler/src/core/errors.ts | 11 + .../core/executor/__tests__/executor.test.ts | 302 +++++++++++++++++- .../__tests__/lease-heartbeat.test.ts | 25 ++ .../scheduler/src/core/executor/executor.ts | 105 +++++- .../src/core/executor/lease-constants.ts | 7 + .../src/core/executor/task-handler.ts | 14 +- packages/@n8n/scheduler/src/core/factory.ts | 37 +++ packages/@n8n/scheduler/src/core/index.ts | 1 + .../__tests__/executor-tracing.test.ts | 13 +- .../src/observability/executor-tracing.ts | 8 +- .../__tests__/durable-scheduler.test.ts | 72 ----- .../cli/src/scheduling/durable-scheduler.ts | 21 -- .../__tests__/system-task-runner.test.ts | 18 +- .../scheduler-lease-renewal.test.ts | 89 ++++++ 17 files changed, 739 insertions(+), 124 deletions(-) create mode 100644 packages/cli/test/integration/scheduling/scheduler-lease-renewal.test.ts diff --git a/packages/@n8n/config/src/configs/scheduler.config.ts b/packages/@n8n/config/src/configs/scheduler.config.ts index 26edc4d288c..2b0ffc60c2a 100644 --- a/packages/@n8n/config/src/configs/scheduler.config.ts +++ b/packages/@n8n/config/src/configs/scheduler.config.ts @@ -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)) diff --git a/packages/@n8n/scheduler/README.md b/packages/@n8n/scheduler/README.md index 496301e4aee..60a52fa7639 100644 --- a/packages/@n8n/scheduler/README.md +++ b/packages/@n8n/scheduler/README.md @@ -401,7 +401,16 @@ The design leans on a few ideas working together. - **Lease.** A claim comes with an expiry (a *lease*). While the lease is valid the run belongs to that server. If the server dies, the lease lapses and the recovery pass can safely take the run back. Without leases a crashed server would strand - its runs forever. + its runs forever. While a handler runs, a heartbeat renews its lease every quarter + of the lease, but never more often than every five seconds, so a long run keeps + its claim while its renewals succeed. A lease of five seconds or less + expires before its first renewal, and the scheduler warns about it at startup. + If a renewal finds the claim gone, or no renewal succeeds for a whole lease, the + handler's signal aborts, unless the run is already recorded as dispatched. + A handler that stops on this abort uses up an attempt, as when the reaper takes + back an expired lease, even if a later renewal kept the claim. + A run still running after sixty leases, or after about 24 days if that comes + first, logs a warning, since it may be stuck. - **Fencing.** Each claim carries a version number (an *epoch*) that increases every time a run is claimed. Every final write ("mark succeeded", "mark failed") is guarded by that number. So if a slow server comes back from the dead after its @@ -544,8 +553,8 @@ A few things that are not obvious from the code but save a lot of confusion. All three default to no-ops, and a throwing hook is swallowed so a broken logger or exporter can never break the scheduling it was only meant to observe. -- **Two warnings tell an operator their timing is off.** Both arrive through the - event sink at `warn` level, so they land in the host's logs: +- **Three event-sink warnings tell an operator their timing is off.** All arrive + through the event sink at `warn` level, so they land in the host's logs: - a **clock-skew warning** at start-up. Due-ness and leases are judged on the clock the scheduler coordinates on (the host supplies it via `now`, e.g. the shared store's clock), but fire timers are armed on this instance's own clock. When @@ -559,6 +568,9 @@ A few things that are not obvious from the code but save a lot of confusion. `DEFAULT_DISPATCH_LAG_WARN_THRESHOLD_SECONDS` (default `30s`) past its scheduled time, once per late fire. This flags a genuinely late dispatch (a blocked event loop, a skewed clock), not routine sub-second jitter. + - a **short-lease warning** at start-up when the lease is + `MIN_RENEWAL_INTERVAL_MS` (`5s`) or shorter. Such a lease expires before its + first renewal, but it may still be renewed before the reaper reclaims it. - **Runs are recorded ahead of time, within a window.** Because upcoming runs are queued in advance, a frequent schedule does not need one planning pass per fire. @@ -578,10 +590,6 @@ A few things that are not obvious from the code but save a lot of confusion. `@n8n/scheduler-features`, would keep the integration concerns together on their own, and mirror the clean split this package already draws between algorithm and host. -- **Lease renewal for long handlers.** A claim's lease is fixed for now, so a - handler that runs longer than its lease risks being recovered and re-run. A - heartbeat that extends the lease while a handler is genuinely still working would - lift that constraint. ### Exploring the idea: a standalone `@n8n/scheduler-worker` diff --git a/packages/@n8n/scheduler/src/core/__tests__/factory.test.ts b/packages/@n8n/scheduler/src/core/__tests__/factory.test.ts index 269febb5ce7..b41665f687e 100644 --- a/packages/@n8n/scheduler/src/core/__tests__/factory.test.ts +++ b/packages/@n8n/scheduler/src/core/__tests__/factory.test.ts @@ -1780,6 +1780,118 @@ describe('createScheduler metrics', () => { expect(metrics.recordFireOutcome).not.toHaveBeenCalled(); }); + describe('lease renewal', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // A 15s lease renews every 5 seconds, the shortest interval. + const LEASE_SECONDS = 15; + const RENEWAL_INTERVAL_MS = 5_000; + + /** Fires one long handler that stays pending until `finish` is called. */ + const fireLongHandler = async (metrics: SchedulerMetrics) => { + const made = makeScheduler({ + metrics, + executor: { leaseSeconds: LEASE_SECONDS, lookaheadSeconds: 1 }, + }); + let finish!: () => void; + made.scheduler.registerTaskHandler('test-task', { + execute: async (_task, report) => { + await new Promise((resolve) => { + finish = resolve; + }); + return report.notDispatched(); + }, + }); + made.taskStore.claimDueTasks.mockResolvedValue([claimedTask()]); + made.taskStore.beginDispatch.mockResolvedValue(1); + made.taskStore.completeTask.mockResolvedValue(1); + await made.scheduler.execute(); + return { ...made, finish: () => finish() }; + }; + + it('maps renewals onto the renewal metric, and warns when the claim is lost', async () => { + const metrics = mock(); + const { taskStore, onEvent, finish } = await fireLongHandler(metrics); + taskStore.renewLease.mockResolvedValueOnce(true).mockResolvedValue(false); + + await vi.advanceTimersByTimeAsync(2 * RENEWAL_INTERVAL_MS); + + expect(metrics.recordLeaseRenewal).toHaveBeenCalledWith('test-task', 'renewed'); + expect(metrics.recordLeaseRenewal).toHaveBeenCalledWith('test-task', 'lost'); + expect(onEvent).toHaveBeenCalledWith({ + level: 'warn', + message: + 'Scheduler lost the claim of a running task; another instance may run it unless it was already dispatched', + context: { taskId: claimedTask().id, taskType: 'test-task' }, + }); + finish(); + }); + + it('maps a whole lease without a renewal onto the renewal metric, and warns', async () => { + const metrics = mock(); + const { taskStore, onEvent, finish } = await fireLongHandler(metrics); + taskStore.renewLease.mockRejectedValue(new Error('db down')); + + await vi.advanceTimersByTimeAsync(LEASE_SECONDS * 1_000); + + expect(metrics.recordLeaseRenewal).toHaveBeenCalledWith('test-task', 'expired'); + expect(onEvent).toHaveBeenCalledWith({ + level: 'warn', + message: + 'Scheduler could not renew the lease of a running task in time; another instance may run it unless it was already dispatched', + context: { taskId: claimedTask().id, taskType: 'test-task' }, + }); + finish(); + }); + + it('warns when a renewal write fails', async () => { + const metrics = mock(); + const { taskStore, onEvent, finish } = await fireLongHandler(metrics); + taskStore.renewLease.mockRejectedValue(new Error('db down')); + + await vi.advanceTimersByTimeAsync(RENEWAL_INTERVAL_MS); + + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'warn', + message: 'Scheduler could not renew the lease of a running task; retrying', + }), + ); + expect(metrics.recordLeaseRenewal).not.toHaveBeenCalled(); + finish(); + }); + + it('warns once when a run is still pending after sixty leases', async () => { + const { taskStore, onEvent, finish } = await fireLongHandler(mock()); + taskStore.renewLease.mockResolvedValue(true); + const stuckWarning = { + level: 'warn', + message: 'Scheduler task is still running after many leases; it may be stuck', + context: { + taskId: claimedTask().id, + taskType: 'test-task', + runningSeconds: 60 * LEASE_SECONDS, + }, + }; + + await vi.advanceTimersByTimeAsync(60 * LEASE_SECONDS * 1_000 - 1); + expect(onEvent).not.toHaveBeenCalledWith(stuckWarning); + + await vi.advanceTimersByTimeAsync(60_000); + const warnings = onEvent.mock.calls.filter( + ([event]) => event.message === stuckWarning.message, + ); + expect(warnings).toEqual([[stuckWarning]]); + finish(); + }); + }); + it('does not let a throwing metrics sink break a pass', async () => { const metrics = mock(); metrics.recordPruned.mockImplementation(() => { diff --git a/packages/@n8n/scheduler/src/core/errors.ts b/packages/@n8n/scheduler/src/core/errors.ts index 681806c7c3d..2d66596b01b 100644 --- a/packages/@n8n/scheduler/src/core/errors.ts +++ b/packages/@n8n/scheduler/src/core/errors.ts @@ -59,6 +59,17 @@ export class DuplicateTaskHandlerError extends Error { } } +/** + * The abort reason of a handler's signal when its claim is lost: the lease + * could not be renewed, so another instance may run the same occurrence. + */ +export class LeaseLostError extends Error { + constructor() { + super('The claim on this occurrence was lost'); + this.name = 'LeaseLostError'; + } +} + /** Raised when a lease heartbeat is given a lease duration that is not a positive integer. */ export class InvalidLeaseDurationError extends Error { constructor(readonly leaseDurationMs: number) { diff --git a/packages/@n8n/scheduler/src/core/executor/__tests__/executor.test.ts b/packages/@n8n/scheduler/src/core/executor/__tests__/executor.test.ts index 4da110adc52..8840d1be6df 100644 --- a/packages/@n8n/scheduler/src/core/executor/__tests__/executor.test.ts +++ b/packages/@n8n/scheduler/src/core/executor/__tests__/executor.test.ts @@ -1,5 +1,6 @@ import { mock } from 'vitest-mock-extended'; +import { LeaseLostError } from '../../errors'; import type { ClaimedTask } from '../../types'; import { backoff } from '../backoff'; import { Executor } from '../executor'; @@ -42,6 +43,7 @@ const setup = (options?: Partial) => { const timer = mock(); const hooks = { onLeaseShorterThanLookahead: vi.fn(), + onLeaseShorterThanRenewalInterval: vi.fn(), onMissingHandler: vi.fn(), onFireError: vi.fn(), onReleaseError: vi.fn(), @@ -49,6 +51,9 @@ const setup = (options?: Partial) => { onFire: vi.fn(), onRetry: vi.fn(), onLeaseLost: vi.fn(), + onLeaseRenewal: vi.fn(), + onLeaseRenewalError: vi.fn(), + onLongRunningTask: vi.fn(), } satisfies ExecutorHooks; // A see-through tracing hook that records its calls and just runs the fire. // The executor's only obligation is to route every fire through this hook; @@ -88,6 +93,22 @@ describe('Executor construction', () => { expect(hooks.onLeaseShorterThanLookahead).not.toHaveBeenCalled(); }); + + it('flags a lease that ends before its first renewal, once, at construction', () => { + const { hooks } = setup({ leaseSeconds: 5, lookaheadSeconds: 1 }); + + expect(hooks.onLeaseShorterThanRenewalInterval).toHaveBeenCalledTimes(1); + expect(hooks.onLeaseShorterThanRenewalInterval).toHaveBeenCalledWith({ + leaseMs: 5_000, + minRenewalIntervalMs: 5_000, + }); + }); + + it('stays silent when the lease renews before it ends', () => { + const { hooks } = setup({ leaseSeconds: 6, lookaheadSeconds: 1 }); + + expect(hooks.onLeaseShorterThanRenewalInterval).not.toHaveBeenCalled(); + }); }); describe('Executor.claimAndSchedule', () => { @@ -228,7 +249,7 @@ describe('Executor.fire', () => { const result = await executor.fire(HOST, task); expect(result).toEqual({ outcome: 'completed' }); - expect(handler.execute).toHaveBeenCalledWith(task, expect.any(Object)); + expect(handler.execute).toHaveBeenCalledWith(task, expect.any(Object), expect.any(AbortSignal)); expect(store.completeTask).toHaveBeenCalledWith({ host: HOST, id: task.id, @@ -762,6 +783,285 @@ describe('Executor.fire metrics hooks', () => { }); }); +describe('Executor.fire lease renewal', () => { + // A 15s lease renews every 5 seconds, the shortest interval. + const LEASE_SECONDS = 15; + const RENEWAL_INTERVAL_MS = 5_000; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + /** + * A handler that stays pending until `finish` is called, and exposes the signal it got. + * With `dispatchFirst` it reports its effect handed off before it waits. + */ + const longRunning = ({ dispatchFirst = false } = {}) => { + let finish!: () => void; + let signal!: AbortSignal; + const handler: TaskHandler = { + execute: vi.fn(async (_task: ClaimedTask, report: DispatchReporter, s: AbortSignal) => { + signal = s; + if (dispatchFirst) { + report.dispatched(); + } + await new Promise((resolve) => { + finish = resolve; + s.addEventListener('abort', () => resolve()); + }); + if (s.aborted) { + throw s.reason; + } + return report.notDispatched(); + }), + }; + return { handler, finish: () => finish(), signal: () => signal }; + }; + + it('renews the lease while the handler runs, and stops once it settles', async () => { + const { store, registry, hooks, executor } = setup({ leaseSeconds: LEASE_SECONDS }); + const { handler, finish } = longRunning(); + registry.resolve.mockReturnValue(handler); + store.beginDispatch.mockResolvedValue(1); + store.renewLease.mockResolvedValue(true); + store.completeTask.mockResolvedValue(1); + const task = claimedTask(); + + const firing = executor.fire(HOST, task); + await vi.advanceTimersByTimeAsync(2 * RENEWAL_INTERVAL_MS); + + expect(store.renewLease).toHaveBeenCalledTimes(2); + expect(store.renewLease).toHaveBeenCalledWith( + { host: HOST, id: task.id, claimedEpoch: task.leaseEpoch }, + LEASE_SECONDS * 1_000, + ); + expect(hooks.onLeaseRenewal).toHaveBeenCalledWith(task, 'renewed'); + + finish(); + await expect(firing).resolves.toEqual({ outcome: 'completed' }); + await vi.advanceTimersByTimeAsync(3 * RENEWAL_INTERVAL_MS); + expect(store.renewLease).toHaveBeenCalledTimes(2); + }); + + it('aborts the handler signal with a LeaseLostError when a renewal finds the claim gone', async () => { + const { store, registry, hooks, executor } = setup({ leaseSeconds: LEASE_SECONDS }); + const { handler, signal } = longRunning(); + registry.resolve.mockReturnValue(handler); + store.beginDispatch.mockResolvedValue(1); + store.renewLease.mockResolvedValue(false); + store.rescheduleTask.mockResolvedValue(0); + const task = claimedTask({ maxAttempts: 3 }); + + const firing = executor.fire(HOST, task); + await vi.advanceTimersByTimeAsync(RENEWAL_INTERVAL_MS); + + expect(signal().reason).toBeInstanceOf(LeaseLostError); + expect(hooks.onLeaseRenewal).toHaveBeenCalledWith(task, 'lost'); + // The reclaimed row rejects the handler's late outcome. + await expect(firing).resolves.toMatchObject({ outcome: 'skipped-not-owned' }); + expect(hooks.onLeaseLost).toHaveBeenCalledWith(task.taskType); + }); + + it('keeps a dispatched handler running when a renewal finds the claim gone', async () => { + const { store, registry, hooks, executor } = setup({ leaseSeconds: LEASE_SECONDS }); + const { handler, finish, signal } = longRunning({ dispatchFirst: true }); + registry.resolve.mockReturnValue(handler); + store.beginDispatch.mockResolvedValue(1); + store.renewLease.mockResolvedValue(false); + store.completeTask.mockResolvedValue(0); + const task = claimedTask(); + + const firing = executor.fire(HOST, task); + await vi.advanceTimersByTimeAsync(RENEWAL_INTERVAL_MS); + + // The reaper completes a dispatched occurrence, so no other run takes over. + expect(hooks.onLeaseRenewal).toHaveBeenCalledWith(task, 'lost'); + expect(signal().aborted).toBe(false); + + finish(); + await expect(firing).resolves.toEqual({ outcome: 'skipped-not-owned' }); + }); + + it.each([ + ['was rejected by the fence', async () => 0], + ['failed', async () => await Promise.reject(new Error('db down'))], + ])( + 'aborts a dispatched handler when the claim is gone and the marker write %s', + async (_case, markDispatched) => { + const { store, registry, executor } = setup({ leaseSeconds: LEASE_SECONDS }); + const { handler, signal } = longRunning({ dispatchFirst: true }); + registry.resolve.mockReturnValue(handler); + store.beginDispatch.mockResolvedValue(1); + store.markDispatched.mockImplementation(markDispatched); + store.renewLease.mockResolvedValue(false); + store.rescheduleTask.mockResolvedValue(0); + const task = claimedTask({ maxAttempts: 3 }); + + const firing = executor.fire(HOST, task); + await vi.advanceTimersByTimeAsync(RENEWAL_INTERVAL_MS); + + // Without a stored marker the reaper redelivers the occurrence to another run. + expect(signal().reason).toBeInstanceOf(LeaseLostError); + await expect(firing).resolves.toMatchObject({ outcome: 'skipped-not-owned' }); + }, + ); + + it('aborts at lease expiry while the marker write is still in flight', async () => { + const { store, registry, executor } = setup({ leaseSeconds: LEASE_SECONDS }); + const { handler, signal } = longRunning({ dispatchFirst: true }); + registry.resolve.mockReturnValue(handler); + store.beginDispatch.mockResolvedValue(1); + store.markDispatched.mockReturnValue(new Promise(() => {})); + store.renewLease.mockRejectedValue(new Error('db down')); + const task = claimedTask({ maxAttempts: 3 }); + + void executor.fire(HOST, task); + await vi.advanceTimersByTimeAsync(LEASE_SECONDS * 1_000); + + expect(signal().reason).toBeInstanceOf(LeaseLostError); + }); + + it('aborts the handler signal once renewals fail for a whole lease', async () => { + const { store, registry, hooks, executor } = setup({ leaseSeconds: LEASE_SECONDS }); + const { handler, signal } = longRunning(); + registry.resolve.mockReturnValue(handler); + store.beginDispatch.mockResolvedValue(1); + store.renewLease.mockRejectedValue(new Error('db down')); + store.rescheduleTask.mockResolvedValue(0); + const task = claimedTask({ maxAttempts: 3 }); + + const firing = executor.fire(HOST, task); + await vi.advanceTimersByTimeAsync(LEASE_SECONDS * 1_000); + + // The reaper of another instance may reclaim the expired lease and run it again. + expect(signal().reason).toBeInstanceOf(LeaseLostError); + expect(hooks.onLeaseRenewal).toHaveBeenCalledWith(task, 'expired'); + await expect(firing).resolves.toMatchObject({ outcome: 'skipped-not-owned' }); + }); + + it('includes the dispatch response delay in the elapsed lease time', async () => { + const { store, registry, executor } = setup({ leaseSeconds: LEASE_SECONDS }); + const { handler, signal } = longRunning(); + registry.resolve.mockReturnValue(handler); + let finishDispatch!: (rows: number) => void; + store.beginDispatch.mockReturnValue( + new Promise((resolve) => { + finishDispatch = resolve; + }), + ); + store.renewLease.mockReturnValue(new Promise(() => {})); + store.rescheduleTask.mockResolvedValue(1); + + const firing = executor.fire(HOST, claimedTask({ maxAttempts: 3 })); + const responseDelayMs = 4_000; + await vi.advanceTimersByTimeAsync(responseDelayMs); + finishDispatch(1); + await vi.advanceTimersByTimeAsync(LEASE_SECONDS * 1_000 - responseDelayMs - 1); + expect(signal().aborted).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(signal().reason).toBeInstanceOf(LeaseLostError); + await expect(firing).resolves.toMatchObject({ outcome: 'rescheduled' }); + expect(store.completeTask).not.toHaveBeenCalled(); + }); + + // The row can still be ours, so count the attempt as the reaper would for an expired lease. + it.each([ + { maxAttempts: 3, outcome: 'rescheduled' }, + { maxAttempts: 1, outcome: 'dead-lettered' }, + ])( + 'counts an attempt for a run aborted while it still owns the row (maxAttempts $maxAttempts)', + async ({ maxAttempts, outcome }) => { + const { store, registry, executor } = setup({ leaseSeconds: LEASE_SECONDS }); + const { handler } = longRunning(); + registry.resolve.mockReturnValue(handler); + store.beginDispatch.mockResolvedValue(1); + store.renewLease.mockRejectedValue(new Error('db down')); + store.rescheduleTask.mockResolvedValue(1); + store.failTaskTerminal.mockResolvedValue(1); + const task = claimedTask({ maxAttempts }); + + const firing = executor.fire(HOST, task); + await vi.advanceTimersByTimeAsync(LEASE_SECONDS * 1_000); + + await expect(firing).resolves.toMatchObject({ outcome }); + expect(store.releaseClaim).not.toHaveBeenCalled(); + }, + ); + + it('reports a run still pending after sixty leases, and not one that settled earlier', async () => { + const { store, registry, hooks, executor } = setup({ leaseSeconds: LEASE_SECONDS }); + const stuck = longRunning(); + const quick = longRunning(); + registry.resolve.mockReturnValueOnce(quick.handler).mockReturnValueOnce(stuck.handler); + store.beginDispatch.mockResolvedValue(1); + store.renewLease.mockResolvedValue(true); + store.completeTask.mockResolvedValue(1); + const quickTask = claimedTask({ id: 'quick' }); + const stuckTask = claimedTask({ id: 'stuck' }); + + const quickFiring = executor.fire(HOST, quickTask); + await vi.advanceTimersByTimeAsync(RENEWAL_INTERVAL_MS); + quick.finish(); + await quickFiring; + const stuckFiring = executor.fire(HOST, stuckTask); + await vi.advanceTimersByTimeAsync(60 * LEASE_SECONDS * 1_000 - 1); + expect(hooks.onLongRunningTask).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(hooks.onLongRunningTask).toHaveBeenCalledExactlyOnceWith(stuckTask, 60 * LEASE_SECONDS); + + stuck.finish(); + await stuckFiring; + }); + + it('reports the time actually waited when sixty leases exceed the timer limit', async () => { + const leaseSeconds = 24 * 60 * 60; + const { store, registry, hooks, executor } = setup({ leaseSeconds }); + const { handler, finish } = longRunning(); + registry.resolve.mockReturnValue(handler); + store.beginDispatch.mockResolvedValue(1); + store.renewLease.mockResolvedValue(true); + store.completeTask.mockResolvedValue(1); + const task = claimedTask(); + const maxTimeoutMs = 2 ** 31 - 1; + + const firing = executor.fire(HOST, task); + await vi.advanceTimersByTimeAsync(1_000); + expect(hooks.onLongRunningTask).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(maxTimeoutMs - 1_000); + expect(hooks.onLongRunningTask).toHaveBeenCalledExactlyOnceWith(task, maxTimeoutMs / 1_000); + + finish(); + await firing; + }); + + it('reports a failed renewal without aborting the handler', async () => { + const { store, registry, hooks, executor } = setup({ leaseSeconds: LEASE_SECONDS }); + const { handler, finish, signal } = longRunning(); + const failure = new Error('db down'); + registry.resolve.mockReturnValue(handler); + store.beginDispatch.mockResolvedValue(1); + store.renewLease.mockRejectedValue(failure); + store.completeTask.mockResolvedValue(1); + const task = claimedTask(); + + const firing = executor.fire(HOST, task); + await vi.advanceTimersByTimeAsync(RENEWAL_INTERVAL_MS); + + expect(hooks.onLeaseRenewalError).toHaveBeenCalledWith(task, failure); + expect(signal().aborted).toBe(false); + + finish(); + await expect(firing).resolves.toEqual({ outcome: 'completed' }); + }); +}); + describe('Executor.stop', () => { it('cancels all pending timers', async () => { const { timer, executor } = setup(); diff --git a/packages/@n8n/scheduler/src/core/executor/__tests__/lease-heartbeat.test.ts b/packages/@n8n/scheduler/src/core/executor/__tests__/lease-heartbeat.test.ts index 7c7a07a1884..3902f7ba3de 100644 --- a/packages/@n8n/scheduler/src/core/executor/__tests__/lease-heartbeat.test.ts +++ b/packages/@n8n/scheduler/src/core/executor/__tests__/lease-heartbeat.test.ts @@ -219,6 +219,31 @@ describe('LeaseHeartbeat', () => { heartbeat.stop(); }); + it('includes a successful renewal response delay in the elapsed lease time', async () => { + let finishRenewal!: (renewed: boolean) => void; + const renew = vi + .fn() + .mockReturnValueOnce( + new Promise((resolve) => { + finishRenewal = resolve; + }), + ) + .mockReturnValue(new Promise(() => {})); + const onRenewal = vi.fn(); + const heartbeat = new LeaseHeartbeat(renew, options(), { onRenewal }); + const responseDelayMs = 4_000; + + await vi.advanceTimersByTimeAsync(INTERVAL_MS + responseDelayMs); + finishRenewal(true); + await vi.advanceTimersByTimeAsync(LEASE_MS - responseDelayMs - 1); + expect(onRenewal).toHaveBeenCalledExactlyOnceWith('renewed'); + + await vi.advanceTimersByTimeAsync(1); + expect(onRenewal.mock.calls).toEqual([['renewed'], ['expired']]); + + heartbeat.stop(); + }); + it('counts the first beat from the lease write, so a late start does not delay it', async () => { const renew = vi.fn().mockResolvedValue(true); const heartbeat = new LeaseHeartbeat(renew, { diff --git a/packages/@n8n/scheduler/src/core/executor/executor.ts b/packages/@n8n/scheduler/src/core/executor/executor.ts index 90b6e54899a..fee41f199e5 100644 --- a/packages/@n8n/scheduler/src/core/executor/executor.ts +++ b/packages/@n8n/scheduler/src/core/executor/executor.ts @@ -1,18 +1,29 @@ -import { Time } from '@n8n/constants'; +import { MAX_INTEGER_32BITS_SIGNED, Time } from '@n8n/constants'; import { ensureError } from '@n8n/utils/errors/ensure-error'; import { backoff } from './backoff'; +import { LeaseLostError } from '../errors'; +import { LONG_RUN_THRESHOLD_IN_LEASES, MIN_RENEWAL_INTERVAL_MS } from './lease-constants'; +import { LeaseHeartbeat } from './lease-heartbeat'; +import type { LeaseRenewalResult } from './lease-heartbeat'; import { DEFAULT_EXECUTOR_OPTIONS, type ExecutorOptions } from './options'; import type { PrecisionTimer } from './precision-timer'; import type { ClaimedTaskRef, ClaimDueTasksBatch, ExecutorTaskStore } from './store'; import { createDispatchReporter } from './task-handler'; -import type { TaskHandlerRegistry } from './task-handler'; +import type { DispatchReporter, TaskHandler, TaskHandlerRegistry } from './task-handler'; import { noopExecutorTracing } from './tracing'; import type { ExecutorTracing, FireResult } from './tracing'; import type { ClaimedTask } from '../types'; type ClaimedEntry = { host: string; task: ClaimedTask }; +type HeartbeatRun = { + claim: ClaimedTaskRef; + /** `performance.now()` just before the write that last set the lease. */ + leaseSetAt: number; + isMarkedDispatched: () => boolean; +}; + /** * Observability callbacks for the executor's non-fatal incidents. Every one is * optional and already handled when it fires (released, left to the reaper); @@ -26,6 +37,15 @@ export interface ExecutorHooks { */ onLeaseShorterThanLookahead?: (context: { lookaheadMs: number; leaseMs: number }) => void; + /** + * The lease is too short to be renewed, so a task that runs longer than the + * lease may be stopped and run again. Raised once, at construction. + */ + onLeaseShorterThanRenewalInterval?: (context: { + leaseMs: number; + minRenewalIntervalMs: number; + }) => void; + /** * A claimed task's type had no handler at fire time (e.g. a rolling restart * deregistered it); the claim was released without counting an attempt. @@ -41,6 +61,12 @@ export interface ExecutorHooks { /** A best-effort claim release failed; the reaper still recovers the row. */ onReleaseError?: (taskId: string, error: unknown) => void; + /** A lease renewal write failed; the next renewal tries again. */ + onLeaseRenewalError?: (task: ClaimedTask, error: unknown) => void; + + /** A handler is still running after many leases, so it may be stuck. Fires once a run. */ + onLongRunningTask?: (task: ClaimedTask, runningSeconds: number) => void; + // Fire-path metrics hooks (the normal path), distinct from the incident hooks above. /** A claimed task was dispatched to its handler; `lagSeconds` is fire time minus its effective `runAt` (clamped >= 0). */ @@ -56,6 +82,12 @@ export interface ExecutorHooks { * overlap, surfaced so it can be counted. */ onLeaseLost?: (taskType: string) => void; + /** + * A running handler's lease was renewed, a renewal found its claim gone + * ('lost'), or no renewal succeeded for a whole lease ('expired'). + * Both of the latter abort the handler's signal before dispatch. + */ + onLeaseRenewal?: (task: ClaimedTask, result: LeaseRenewalResult) => void; } /** @@ -65,7 +97,8 @@ export interface ExecutorHooks { * handler the executor takes a pre-dispatch mutex ({@link ExecutorTaskStore.beginDispatch}): * an atomic compare-and-set that stamps `startedAt` and returns 1 for a single * winner, so the handler runs at most once per lease. That same write refreshes the - * lease, giving the handler a full lease for its execution window. + * lease, and a heartbeat renews it while the handler runs, so a long handler keeps + * its claim for as long as its instance is alive. * * The contract is at-least-once. Ownership only lasts as long as the lease: if an * owner is lost past it (crash or partition), the reaper reclaims the row, clears @@ -126,6 +159,13 @@ export class Executor { leaseMs: this.leaseMs, }); } + + if (this.leaseMs <= MIN_RENEWAL_INTERVAL_MS) { + this.hooks.onLeaseShorterThanRenewalInterval?.({ + leaseMs: this.leaseMs, + minRenewalIntervalMs: MIN_RENEWAL_INTERVAL_MS, + }); + } } /** @@ -235,6 +275,8 @@ export class Executor { // => the row is gone, was reclaimed (epoch bumped), or was already dispatched on // this lease; in every case don't run the handler. This compare-and-set, not the // later marker, is what keeps the executor from calling a handler twice per lease. + // Response time includes database latency and would overestimate the remaining lease. + const leaseSetAt = performance.now(); const won = await this.store.beginDispatch(claim, this.leaseMs); if (won === 0) { return { outcome: 'skipped-not-owned' }; @@ -259,9 +301,12 @@ export class Executor { // no-op, so an explicit `dispatched()` and the post-return fallback below collapse to // one write. let dispatchMark: Promise | undefined; + let isMarkedDispatched = false; const markDispatched = (): void => { dispatchMark ??= this.store.markDispatched(claim).then( - () => undefined, + (rowsAffected) => { + isMarkedDispatched = rowsAffected > 0; + }, (error: unknown) => this.hooks.onFireError?.(task, error), ); }; @@ -271,7 +316,11 @@ export class Executor { // handler failure. Such a failure propagates out (caught by the detached `.catch` // in claimAndSchedule) and leaves the row `running` for the reaper. try { - await handler.execute(task, report); + await this.executeWithHeartbeat(handler, task, report, { + claim, + leaseSetAt, + isMarkedDispatched: () => isMarkedDispatched, + }); } catch (error) { await dispatchMark; return await this.recordHandlerFailure(task, claim, error, dispatchMark !== undefined); @@ -332,6 +381,52 @@ export class Executor { return { outcome: 'skipped-not-owned', errorMessage }; } + /** + * Run the handler while a heartbeat renews the claim's lease, until it settles. + * A lost claim aborts the handler's signal unless the dispatch marker is stored. + */ + private async executeWithHeartbeat( + handler: TaskHandler, + task: ClaimedTask, + report: DispatchReporter, + { claim, leaseSetAt, isMarkedDispatched }: HeartbeatRun, + ): Promise { + const lease = new AbortController(); + const heartbeat = new LeaseHeartbeat( + async (expiresInMs) => await this.store.renewLease(claim, expiresInMs), + { leaseDurationMs: this.leaseMs, leaseSetAt }, + { + onRenewal: (result) => { + this.hooks.onLeaseRenewal?.(task, result); + // The reaper completes an occurrence whose marker is stored and never runs + // it again, so stopping its run would only leave the work half done. A + // marker write still in flight counts as not stored: the reaper may + // already redeliver the row, and that write may never finish. + if (result !== 'renewed' && !isMarkedDispatched()) { + lease.abort(new LeaseLostError()); + } + }, + onRenewalError: (error) => this.hooks.onLeaseRenewalError?.(task, error), + }, + ); + // `setTimeout` fires a longer delay at once. + const longRunMs = Math.min( + LONG_RUN_THRESHOLD_IN_LEASES * this.leaseMs, + MAX_INTEGER_32BITS_SIGNED, + ); + const longRunTimer = setTimeout( + () => this.hooks.onLongRunningTask?.(task, longRunMs / Time.seconds.toMilliseconds), + longRunMs, + ); + longRunTimer.unref(); + try { + await handler.execute(task, report, lease.signal); + } finally { + clearTimeout(longRunTimer); + heartbeat.stop(); + } + } + /** Release a claim, reporting but swallowing failures: the reaper still recovers the row. */ private async releaseClaimBestEffort(claim: ClaimedTaskRef): Promise { try { diff --git a/packages/@n8n/scheduler/src/core/executor/lease-constants.ts b/packages/@n8n/scheduler/src/core/executor/lease-constants.ts index 4a07c8bc0fb..0c4f768ec48 100644 --- a/packages/@n8n/scheduler/src/core/executor/lease-constants.ts +++ b/packages/@n8n/scheduler/src/core/executor/lease-constants.ts @@ -13,3 +13,10 @@ export const RENEWALS_PER_LEASE = 3; * renewals, and one this short or shorter expires before its first renewal. */ export const MIN_RENEWAL_INTERVAL_MS = 5_000; + +/** + * A run still pending after this many leases is reported as possibly stuck. Some + * system tasks run for 15 to 30 minutes, so with the default 60s lease this warns + * after one hour. + */ +export const LONG_RUN_THRESHOLD_IN_LEASES = 60; diff --git a/packages/@n8n/scheduler/src/core/executor/task-handler.ts b/packages/@n8n/scheduler/src/core/executor/task-handler.ts index 0d4316d86a0..76053f69b25 100644 --- a/packages/@n8n/scheduler/src/core/executor/task-handler.ts +++ b/packages/@n8n/scheduler/src/core/executor/task-handler.ts @@ -75,9 +75,21 @@ export function createDispatchReporter(onDispatch: () => void): DispatchReporter * - `report.notDispatched()` while no effect has been handed off yet, so a throw * after this point is still safe to retry — a redelivery that found the effect * already exists, or nothing done so far. + * + * A handler may run as long as it needs: the executor renews the claim's lease + * while `execute` is pending. `signal` aborts with a `LeaseLostError` when the + * claim is gone, or no renewal succeeded for a whole lease, and no dispatch + * marker is stored, so another instance may run the same occurrence. Honoring + * it is optional, but a handler that ignores it may overlap that run. Once + * `report.dispatched()` has stored the marker, the signal never aborts for a + * lost claim: no instance runs that occurrence again. */ export interface TaskHandler { - execute(task: ClaimedTask, report: DispatchReporter): Promise; + execute( + task: ClaimedTask, + report: DispatchReporter, + signal: AbortSignal, + ): Promise; } /** diff --git a/packages/@n8n/scheduler/src/core/factory.ts b/packages/@n8n/scheduler/src/core/factory.ts index 385382273a7..aa0486ca284 100644 --- a/packages/@n8n/scheduler/src/core/factory.ts +++ b/packages/@n8n/scheduler/src/core/factory.ts @@ -295,6 +295,13 @@ export function createScheduler(deps: SchedulerDeps): Scheduler & SchedulerPasse { ...context }, ); }, + onLeaseShorterThanRenewalInterval: (context) => { + emit( + 'warn', + 'Scheduler lease is too short to be renewed. A task that runs longer than the lease may be stopped and run again', + { ...context }, + ); + }, onMissingHandler: (task) => { emit('warn', 'Scheduler claimed a task with no registered handler; claim released', { taskId: task.id, @@ -313,6 +320,19 @@ export function createScheduler(deps: SchedulerDeps): Scheduler & SchedulerPasse error: described(error), }); }, + onLeaseRenewalError: (task, error) => { + emit('warn', 'Scheduler could not renew the lease of a running task; retrying', { + taskId: task.id, + error: described(error), + }); + }, + onLongRunningTask: (task, runningSeconds) => { + emit('warn', 'Scheduler task is still running after many leases; it may be stuck', { + taskId: task.id, + taskType: task.taskType, + runningSeconds, + }); + }, onDispatch: (taskType, lagSeconds) => { recordMetric(() => { metrics.recordDispatch(taskType); @@ -340,6 +360,23 @@ export function createScheduler(deps: SchedulerDeps): Scheduler & SchedulerPasse { taskType }, ); }, + onLeaseRenewal: (task, result) => { + recordMetric(() => metrics.recordLeaseRenewal(task.taskType, result)); + if (result === 'lost') { + emit( + 'warn', + 'Scheduler lost the claim of a running task; another instance may run it unless it was already dispatched', + { taskId: task.id, taskType: task.taskType }, + ); + } + if (result === 'expired') { + emit( + 'warn', + 'Scheduler could not renew the lease of a running task in time; another instance may run it unless it was already dispatched', + { taskId: task.id, taskType: task.taskType }, + ); + } + }, }, createExecutorTracing(tracer), ); diff --git a/packages/@n8n/scheduler/src/core/index.ts b/packages/@n8n/scheduler/src/core/index.ts index 95c8b0b9a2f..3caf8d4cf90 100644 --- a/packages/@n8n/scheduler/src/core/index.ts +++ b/packages/@n8n/scheduler/src/core/index.ts @@ -97,6 +97,7 @@ export { UnregisteredOwnerTypeError, InvalidOwnerIdError, InvalidOwnerMemberIdError, + LeaseLostError, } from './errors'; export type { RetentionOptions, RetentionSummary } from './retention'; diff --git a/packages/@n8n/scheduler/src/observability/__tests__/executor-tracing.test.ts b/packages/@n8n/scheduler/src/observability/__tests__/executor-tracing.test.ts index 1becf83f400..5e85d09b35a 100644 --- a/packages/@n8n/scheduler/src/observability/__tests__/executor-tracing.test.ts +++ b/packages/@n8n/scheduler/src/observability/__tests__/executor-tracing.test.ts @@ -150,11 +150,12 @@ describe('withHandoffTracing', () => { }; const task = claimedTask(); const report = mock(); + const signal = new AbortController().signal; - await withHandoffTracing(tracer, handler).execute(task, report); + await withHandoffTracing(tracer, handler).execute(task, report, signal); - // The wrapper adds a span, not semantics: the dispatch reporter flows through. - expect(handler.execute).toHaveBeenCalledWith(task, report); + // The wrapper adds a span, not semantics: the dispatch reporter and signal flow through. + expect(handler.execute).toHaveBeenCalledWith(task, report, signal); const options = tracer.startSpan.mock.calls[0][0]; expect(options.name).toBe('Scheduler handoff'); expect(options.op).toBe('scheduler.handoff'); @@ -171,7 +172,11 @@ describe('withHandoffTracing', () => { const handler: TaskHandler = { execute: vi.fn().mockRejectedValue(new Error('boom')) }; await expect( - withHandoffTracing(tracer, handler).execute(claimedTask(), mock()), + withHandoffTracing(tracer, handler).execute( + claimedTask(), + mock(), + new AbortController().signal, + ), ).rejects.toThrow('boom'); expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatus.error, message: 'boom' }); diff --git a/packages/@n8n/scheduler/src/observability/executor-tracing.ts b/packages/@n8n/scheduler/src/observability/executor-tracing.ts index 45433c40394..c40e0aa0306 100644 --- a/packages/@n8n/scheduler/src/observability/executor-tracing.ts +++ b/packages/@n8n/scheduler/src/observability/executor-tracing.ts @@ -66,9 +66,9 @@ export function createExecutorTracing(tracer: Tracer): ExecutorTracing { */ export function withHandoffTracing(tracer: Tracer, handler: TaskHandler): TaskHandler { return { - // The dispatch reporter is threaded through untouched: the wrapper adds a span, - // not semantics, so the handler's dispatch decision keeps flowing to the executor. - async execute(task, report) { + // The dispatch reporter and signal are threaded through untouched: the wrapper adds + // a span, not semantics, so the handler's dispatch decision keeps flowing to the executor. + async execute(task, report, signal) { return await tracer.startSpan( { name: 'Scheduler handoff', @@ -76,7 +76,7 @@ export function withHandoffTracing(tracer: Tracer, handler: TaskHandler): TaskHa attributes: pickSchedulerTaskIdentity(task), }, async (span) => { - const decision = await handler.execute(task, report); + const decision = await handler.execute(task, report, signal); span.setStatus({ code: SpanStatus.ok }); return decision; }, diff --git a/packages/cli/src/scheduling/__tests__/durable-scheduler.test.ts b/packages/cli/src/scheduling/__tests__/durable-scheduler.test.ts index ddb529c9c4f..82407af5351 100644 --- a/packages/cli/src/scheduling/__tests__/durable-scheduler.test.ts +++ b/packages/cli/src/scheduling/__tests__/durable-scheduler.test.ts @@ -203,78 +203,6 @@ describe('DurableScheduler', () => { }); }); - describe('poll timeout warning', () => { - it('warns when a poll may outlive the lease on its occurrence', () => { - const { logger } = makeScheduler({ - enabledForPollTriggers: true, - pollTimeoutSeconds: 120, - leaseDurationSeconds: 60, - }); - - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('poll timeout'), - expect.objectContaining({ pollTimeoutSeconds: 120, leaseDurationSeconds: 60 }), - ); - }); - - // The poll deadline starts after the occurrence's setup reads, so a timeout - // equal to the lease already lets a full-length poll outlive it. - it('warns when the timeout equals the lease', () => { - const { logger } = makeScheduler({ - enabledForPollTriggers: true, - pollTimeoutSeconds: 60, - leaseDurationSeconds: 60, - }); - - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('poll timeout'), - expect.objectContaining({ pollTimeoutSeconds: 60, leaseDurationSeconds: 60 }), - ); - }); - - it('does not warn when the timeout fits inside the lease', () => { - const { logger } = makeScheduler({ - enabledForPollTriggers: true, - pollTimeoutSeconds: 45, - leaseDurationSeconds: 60, - }); - - expect(logger.warn).not.toHaveBeenCalledWith( - expect.stringContaining('poll timeout'), - expect.anything(), - ); - }); - - it('does not warn when poll triggers do not use the durable scheduler', () => { - const { logger } = makeScheduler({ - enabledForPollTriggers: false, - pollTimeoutSeconds: 120, - leaseDurationSeconds: 60, - }); - - expect(logger.warn).not.toHaveBeenCalledWith( - expect.stringContaining('poll timeout'), - expect.anything(), - ); - }); - - // Without the publication service the durable poller chain is inactive and - // polls run on the legacy in-memory path, where the timeout does not apply. - it('does not warn when the workflow publication service is disabled', () => { - const { logger } = makeScheduler({ - enabledForPollTriggers: true, - pollTimeoutSeconds: 120, - leaseDurationSeconds: 60, - useWorkflowPublicationService: false, - }); - - expect(logger.warn).not.toHaveBeenCalledWith( - expect.stringContaining('poll timeout'), - expect.anything(), - ); - }); - }); - describe('tracer', () => { // A fire span is opened from inside a timer callback armed while the claim // span was active, so it needs a fresh trace instead of parenting under a diff --git a/packages/cli/src/scheduling/durable-scheduler.ts b/packages/cli/src/scheduling/durable-scheduler.ts index 71cf5b0e008..607798582ee 100644 --- a/packages/cli/src/scheduling/durable-scheduler.ts +++ b/packages/cli/src/scheduling/durable-scheduler.ts @@ -16,7 +16,6 @@ import { InstanceSettings, Tracing } from 'n8n-core'; import { PrometheusSchedulerMetricsService } from '@/metrics/prometheus/scheduler-metrics.service'; import { AgentScheduledJobOwner } from './agent-scheduled-job-owner'; -import { isDurablePollerChainEnabled } from './poll-trigger-node/durable-poller-chain'; import { PollTriggerTaskHandler } from './poll-trigger-node/poll-trigger-task-handler'; import { ScheduleTriggerTaskHandler } from './schedule-trigger-node/schedule-trigger-task-handler'; import { createScheduledJobOwnerRegistry } from './scheduled-job-owner-registry'; @@ -118,7 +117,6 @@ export class DurableScheduler implements Scheduler { if (enabled) { warnOnMisfireGrace(logger, config); warnOnDrainRate(logger, config); - warnOnPollTimeout(logger, globalConfig); } this.registerTaskHandler(scheduleTriggerTaskHandler.taskType, scheduleTriggerTaskHandler); this.registerTaskHandler(pollTriggerTaskHandler.taskType, pollTriggerTaskHandler); @@ -193,25 +191,6 @@ function warnOnDrainRate(logger: Logger, config: GlobalConfig['scheduler']): voi } } -/** - * Warn when a poll may still be in flight after the lease on its occurrence has - * expired: the reaper can then reclaim the occurrence and another instance can - * start the same poll while the first one is still running. Equality counts - * too, since the poll deadline only starts after the occurrence's setup reads. - */ -function warnOnPollTimeout(logger: Logger, globalConfig: GlobalConfig): void { - const { pollTimeoutSeconds, leaseDurationSeconds } = globalConfig.scheduler; - if ( - isDurablePollerChainEnabled(globalConfig.scheduler, globalConfig.workflows) && - pollTimeoutSeconds >= leaseDurationSeconds - ) { - logger.warn( - 'Scheduler poll timeout reaches the lease duration; a poll can still be running when its lease expires and another instance takes the run over', - { pollTimeoutSeconds, leaseDurationSeconds }, - ); - } -} - export function buildMaterializerTransaction( dataSource: DataSource, jobs: ScheduledJobRepository, diff --git a/packages/cli/src/scheduling/system-tasks/__tests__/system-task-runner.test.ts b/packages/cli/src/scheduling/system-tasks/__tests__/system-task-runner.test.ts index 726e124a03d..203832773e3 100644 --- a/packages/cli/src/scheduling/system-tasks/__tests__/system-task-runner.test.ts +++ b/packages/cli/src/scheduling/system-tasks/__tests__/system-task-runner.test.ts @@ -786,7 +786,11 @@ describe('SystemTaskRunner', () => { const [, handler] = durableScheduler.registerTaskHandler.mock.calls[0]; await expect( - handler.execute(mock(), createDispatchReporter(vi.fn())), + handler.execute( + mock(), + createDispatchReporter(vi.fn()), + new AbortController().signal, + ), ).rejects.toThrow(error); expect(errorReporter.error).toHaveBeenCalledWith(error, { @@ -811,7 +815,11 @@ describe('SystemTaskRunner', () => { await initRunner(runner); const [, handler] = durableScheduler.registerTaskHandler.mock.calls[0]; - const executing = handler.execute(mock(), createDispatchReporter(vi.fn())); + const executing = handler.execute( + mock(), + createDispatchReporter(vi.fn()), + new AbortController().signal, + ); expect(runSignal?.aborted).toBe(false); await runner.shutdown(); @@ -1314,7 +1322,11 @@ describe('SystemTaskRunner', () => { await initRunner(runner); const [, handler] = durableScheduler.registerTaskHandler.mock.calls[0]; - await handler.execute(mock(), createDispatchReporter(vi.fn())); + await handler.execute( + mock(), + createDispatchReporter(vi.fn()), + new AbortController().signal, + ); expect(eventService.emit).toHaveBeenCalledWith('system-task-run-started', { name: 'dummy', diff --git a/packages/cli/test/integration/scheduling/scheduler-lease-renewal.test.ts b/packages/cli/test/integration/scheduling/scheduler-lease-renewal.test.ts new file mode 100644 index 00000000000..847554393ab --- /dev/null +++ b/packages/cli/test/integration/scheduling/scheduler-lease-renewal.test.ts @@ -0,0 +1,89 @@ +import { testDb } from '@n8n/backend-test-utils'; +import { DataSource, ScheduledJobRepository, ScheduledTaskRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { createScheduler } from '@n8n/scheduler'; +import type { Scheduler, SchedulerPasses } from '@n8n/scheduler'; + +import { buildMaterializerTransaction } from '@/scheduling/durable-scheduler'; + +import { selfOwned } from './shared/job-factory'; + +/** + * A handler that runs longer than its lease keeps its claim: the executor's + * heartbeat renews the lease, so the reaper never hands the occurrence to + * another run while the first one still works. + */ +describe('scheduler lease renewal over the storage bindings', () => { + const TASK_TYPE = 'integration-lease-renewal-test'; + // Allow seven seconds for the first renewal, which starts after five seconds. + const LEASE_SECONDS = 12; + const RUN_MS = 15_000; + + let jobRepo: ScheduledJobRepository; + let taskRepo: ScheduledTaskRepository; + let scheduler: Scheduler & SchedulerPasses; + let runs = 0; + + beforeAll(async () => { + await testDb.init(); + jobRepo = Container.get(ScheduledJobRepository); + taskRepo = Container.get(ScheduledTaskRepository); + scheduler = createScheduler({ + hostId: 'main-lease-renewal-test', + materializerTransaction: buildMaterializerTransaction( + Container.get(DataSource), + jobRepo, + taskRepo, + ), + taskStore: taskRepo, + executor: { leaseSeconds: LEASE_SECONDS, lookaheadSeconds: 1 }, + }); + scheduler.registerTaskHandler(TASK_TYPE, { + execute: async (_task, report) => { + runs += 1; + await new Promise((resolve) => setTimeout(resolve, RUN_MS)); + return report.notDispatched(); + }, + }); + }); + + afterAll(async () => { + await scheduler.stop(); + await testDb.terminate(); + }); + + it('keeps the claim of a handler that runs longer than its lease', async () => { + const job = await jobRepo.save( + jobRepo.create({ + name: 'job-lease-renewal', + ...selfOwned('job-lease-renewal'), + taskType: TASK_TYPE, + payload: {}, + kind: 'interval', + intervalSeconds: 3600, + enabled: true, + nextRunAt: new Date(Date.now() - 1000), + maxAttempts: 3, + }), + ); + await scheduler.materialize(); + expect(await scheduler.execute()).toHaveLength(1); + + // Sweep like a busy reaper for the whole run: every sweep sees a live lease. + const reclaimed: number[] = []; + const deadline = Date.now() + RUN_MS + 5_000; + let task = await taskRepo.findOneByOrFail({ jobId: job.id }); + while (task.status === 'running' && Date.now() < deadline) { + const { reclaimed: count, deadLettered } = await scheduler.reap(); + reclaimed.push(count + deadLettered); + await new Promise((resolve) => setTimeout(resolve, 200)); + task = await taskRepo.findOneByOrFail({ jobId: job.id }); + } + + expect(reclaimed.every((count) => count === 0)).toBe(true); + expect(task.status).toBe('succeeded'); + expect(task.attempts).toBe(0); + expect(task.leaseEpoch).toBe(1); + expect(runs).toBe(1); + }, 30_000); +}); From e82087bcc8f8c2e8574a4f42b9019b06329be76d Mon Sep 17 00:00:00 2001 From: Lorent Lempereur Date: Tue, 6 Oct 2026 09:07:37 +0000 Subject: [PATCH 10/61] feat(core): Stop a durable system task run when its claim is lost (no-changelog) (#40272) --- .../decorators/src/system-task/system-task.ts | 10 +- packages/@n8n/scheduler/README.md | 6 +- .../maps/system-task-metrics.event-map.ts | 5 +- .../prometheus/system-task-metrics.service.ts | 2 +- .../__tests__/system-task-handler.test.ts | 110 ++++++++++++++---- .../system-task-run-observer.test.ts | 27 +++++ .../system-tasks/system-task-handler.ts | 15 ++- .../system-tasks/system-task-run-observer.ts | 27 +++-- 8 files changed, 158 insertions(+), 44 deletions(-) diff --git a/packages/@n8n/decorators/src/system-task/system-task.ts b/packages/@n8n/decorators/src/system-task/system-task.ts index e310dd49c2c..320137b63ae 100644 --- a/packages/@n8n/decorators/src/system-task/system-task.ts +++ b/packages/@n8n/decorators/src/system-task/system-task.ts @@ -74,10 +74,12 @@ 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; } diff --git a/packages/@n8n/scheduler/README.md b/packages/@n8n/scheduler/README.md index 60a52fa7639..7615ac3b484 100644 --- a/packages/@n8n/scheduler/README.md +++ b/packages/@n8n/scheduler/README.md @@ -407,8 +407,10 @@ The design leans on a few ideas working together. expires before its first renewal, and the scheduler warns about it at startup. If a renewal finds the claim gone, or no renewal succeeds for a whole lease, the handler's signal aborts, unless the run is already recorded as dispatched. - A handler that stops on this abort uses up an attempt, as when the reaper takes - back an expired lease, even if a later renewal kept the claim. + A handler that abandons work on this abort must reject. If its claim still + matches, the executor counts the failed attempt. It retries only while + attempts remain. If the claim no longer matches, the write changes nothing. + A clean return completes an occurrence that the executor still owns. A run still running after sixty leases, or after about 24 days if that comes first, logs a warning, since it may be stuck. - **Fencing.** Each claim carries a version number (an *epoch*) that increases every diff --git a/packages/cli/src/events/maps/system-task-metrics.event-map.ts b/packages/cli/src/events/maps/system-task-metrics.event-map.ts index 56f2d23b36a..f3e44549780 100644 --- a/packages/cli/src/events/maps/system-task-metrics.event-map.ts +++ b/packages/cli/src/events/maps/system-task-metrics.event-map.ts @@ -14,9 +14,10 @@ export type SystemTaskMode = 'leader_timer' | 'instance_timer' | 'durable'; /** * How a run settled. A rejection after the run's abort signal fired is the task - * honoring the abort, so it is `aborted`, not `failure`. + * honoring the abort, so it is `aborted`, not `failure`. A durable run stopped + * because its claim was lost is `lease_lost`. */ -export type SystemTaskRunResult = 'success' | 'failure' | 'aborted'; +export type SystemTaskRunResult = 'success' | 'failure' | 'aborted' | 'lease_lost'; /** * Why an occurrence did not run: the previous run was still going, another diff --git a/packages/cli/src/metrics/prometheus/system-task-metrics.service.ts b/packages/cli/src/metrics/prometheus/system-task-metrics.service.ts index 710efb65485..eb0c7c8802c 100644 --- a/packages/cli/src/metrics/prometheus/system-task-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/system-task-metrics.service.ts @@ -65,7 +65,7 @@ export class PrometheusSystemTaskMetricsService implements PrometheusMetricsColl const runDuration = new promClient.Histogram({ name: `${prefix}system_task_run_duration_seconds`, - help: 'Duration in seconds of a system task run, by task, mode (leader_timer, instance_timer, durable) and result (success, failure, aborted).', + help: 'Duration in seconds of a system task run, by task, mode (leader_timer, instance_timer, durable) and result (success, failure, aborted, lease_lost).', labelNames: ['task', 'mode', 'result'], buckets: DURATION_BUCKETS_SECONDS, }); diff --git a/packages/cli/src/scheduling/system-tasks/__tests__/system-task-handler.test.ts b/packages/cli/src/scheduling/system-tasks/__tests__/system-task-handler.test.ts index 068bef3e993..a1b787cd037 100644 --- a/packages/cli/src/scheduling/system-tasks/__tests__/system-task-handler.test.ts +++ b/packages/cli/src/scheduling/system-tasks/__tests__/system-task-handler.test.ts @@ -1,7 +1,7 @@ import type { EventService } from '@n8n/backend-services'; import { mockLogger } from '@n8n/backend-test-utils'; import type { ClaimedTask, DispatchReporter } from '@n8n/scheduler'; -import { createDispatchReporter } from '@n8n/scheduler'; +import { createDispatchReporter, LeaseLostError } from '@n8n/scheduler'; import { Tracing } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; @@ -18,6 +18,7 @@ describe('SystemTaskHandler', () => { const onRunError = vi.fn(); const eventService = mock(); const shutdownController = new AbortController(); + const leaseController = new AbortController(); const handler = new SystemTaskHandler( task, shutdownController.signal, @@ -33,34 +34,74 @@ describe('SystemTaskHandler', () => { onRunError, eventService, shutdownController, - shutdownSignal: shutdownController.signal, + leaseController, }; } it('runs the task', async () => { const { task, report, handler } = setup('idempotent'); - await handler.execute(claimed, report); + await handler.execute(claimed, report, new AbortController().signal); expect(task.runCount).toBe(1); }); - it('hands the shutdown signal to the run', async () => { - const { task, report, handler, shutdownSignal } = setup('idempotent'); - let seenSignal: AbortSignal | undefined; - task.onRun = async (signal) => { - seenSignal = signal; - }; + it.each(['shutdownController', 'leaseController'] as const)( + 'hands the run a signal that aborts with the %s', + async (source) => { + const context = setup('idempotent'); + let seenSignal: AbortSignal | undefined; + context.task.onRun = async (signal) => { + seenSignal = signal; + }; - await handler.execute(claimed, report); + await context.handler.execute(claimed, context.report, context.leaseController.signal); + expect(seenSignal?.aborted).toBe(false); + context[source].abort(); - expect(seenSignal).toBe(shutdownSignal); + expect(seenSignal?.aborted).toBe(true); + }, + ); + + it('does not report a run that rejects once the claim was lost', async () => { + const { task, report, handler, onRunError, leaseController } = setup('idempotent'); + task.onRun = async (signal) => + await new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted'))); + }); + + const executing = handler.execute(claimed, report, leaseController.signal); + leaseController.abort(); + + await expect(executing).rejects.toThrow(); + expect(onRunError).not.toHaveBeenCalled(); + }); + + it('propagates the lease error when the run resolves after its claim is lost', async () => { + const { task, report, handler, onRunError, eventService, leaseController } = + setup('idempotent'); + task.onRun = async (signal) => + await new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { once: true }); + }); + const error = new LeaseLostError(); + + const executing = handler.execute(claimed, report, leaseController.signal); + leaseController.abort(error); + + await expect(executing).rejects.toBe(error); + expect(report.dispatched).not.toHaveBeenCalled(); + expect(onRunError).not.toHaveBeenCalled(); + expect(eventService.emit).toHaveBeenCalledWith( + 'system-task-run-settled', + expect.objectContaining({ mode: 'durable', result: 'lease_lost' }), + ); }); it('leaves idempotent work retryable', async () => { const { report, handler } = setup('idempotent'); - await handler.execute(claimed, report); + await handler.execute(claimed, report, new AbortController().signal); expect(report.notDispatched).toHaveBeenCalled(); expect(report.dispatched).not.toHaveBeenCalled(); @@ -72,7 +113,7 @@ describe('SystemTaskHandler', () => { expect(report.dispatched).toHaveBeenCalled(); }; - await handler.execute(claimed, report); + await handler.execute(claimed, report, new AbortController().signal); expect(task.runCount).toBe(1); expect(report.notDispatched).not.toHaveBeenCalled(); @@ -94,7 +135,7 @@ describe('SystemTaskHandler', () => { vi.fn(), ); - const returned = await handler.execute(claimed, report); + const returned = await handler.execute(claimed, report, new AbortController().signal); expect(returned).toBe(report[decision]()); }); @@ -105,7 +146,9 @@ describe('SystemTaskHandler', () => { throw new Error('failed'); }; - await expect(handler.execute(claimed, report)).rejects.toThrow('failed'); + await expect(handler.execute(claimed, report, new AbortController().signal)).rejects.toThrow( + 'failed', + ); }); it.each(['idempotent', 'non-idempotent'] as const)( @@ -117,7 +160,9 @@ describe('SystemTaskHandler', () => { throw error; }; - await expect(handler.execute(claimed, report)).rejects.toThrow(error); + await expect(handler.execute(claimed, report, new AbortController().signal)).rejects.toThrow( + error, + ); expect(onRunError).toHaveBeenCalledWith(error); }, @@ -130,7 +175,7 @@ describe('SystemTaskHandler', () => { signal.addEventListener('abort', () => reject(new Error('aborted'))); }); - const executing = handler.execute(claimed, report); + const executing = handler.execute(claimed, report, new AbortController().signal); shutdownController.abort(); await expect(executing).rejects.toThrow(); @@ -143,7 +188,7 @@ describe('SystemTaskHandler', () => { throw new Error('sink'); }); - await handler.execute(claimed, report); + await handler.execute(claimed, report, new AbortController().signal); expect(task.runCount).toBe(1); expect(onRunError).not.toHaveBeenCalled(); @@ -152,7 +197,7 @@ describe('SystemTaskHandler', () => { it('does not report a run that succeeds', async () => { const { report, handler, onRunError } = setup('idempotent'); - await handler.execute(claimed, report); + await handler.execute(claimed, report, new AbortController().signal); expect(onRunError).not.toHaveBeenCalled(); }); @@ -172,7 +217,7 @@ describe('SystemTaskHandler', () => { await vi.advanceTimersByTimeAsync(250); }; - await handler.execute(claimed, report); + await handler.execute(claimed, report, new AbortController().signal); expect(eventService.emit.mock.calls).toEqual([ ['system-task-run-started', { name: 'dummy', mode: 'durable' }], @@ -189,7 +234,9 @@ describe('SystemTaskHandler', () => { throw new Error('failed'); }; - await expect(handler.execute(claimed, report)).rejects.toThrow('failed'); + await expect(handler.execute(claimed, report, new AbortController().signal)).rejects.toThrow( + 'failed', + ); expect(eventService.emit).toHaveBeenCalledWith( 'system-task-run-settled', @@ -204,7 +251,7 @@ describe('SystemTaskHandler', () => { signal.addEventListener('abort', () => resolve()); }); - const executing = handler.execute(claimed, report); + const executing = handler.execute(claimed, report, new AbortController().signal); shutdownController.abort(); await executing; @@ -221,7 +268,7 @@ describe('SystemTaskHandler', () => { signal.addEventListener('abort', () => reject(new Error('aborted'))); }); - const executing = handler.execute(claimed, report); + const executing = handler.execute(claimed, report, new AbortController().signal); shutdownController.abort(); await expect(executing).rejects.toThrow(); @@ -230,5 +277,22 @@ describe('SystemTaskHandler', () => { expect.objectContaining({ mode: 'durable', result: 'aborted' }), ); }); + + it('settles a run that rejects once its claim was lost as lease_lost', async () => { + const { task, report, handler, eventService, leaseController } = setup('idempotent'); + task.onRun = async (signal) => + await new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted'))); + }); + + const executing = handler.execute(claimed, report, leaseController.signal); + leaseController.abort(); + await expect(executing).rejects.toThrow(); + + expect(eventService.emit).toHaveBeenCalledWith( + 'system-task-run-settled', + expect.objectContaining({ mode: 'durable', result: 'lease_lost' }), + ); + }); }); }); diff --git a/packages/cli/src/scheduling/system-tasks/__tests__/system-task-run-observer.test.ts b/packages/cli/src/scheduling/system-tasks/__tests__/system-task-run-observer.test.ts index 0f0e3067223..fc4fc70deb8 100644 --- a/packages/cli/src/scheduling/system-tasks/__tests__/system-task-run-observer.test.ts +++ b/packages/cli/src/scheduling/system-tasks/__tests__/system-task-run-observer.test.ts @@ -167,4 +167,31 @@ describe('observeSystemTaskRun', () => { expect(span.setAttribute).toHaveBeenCalledWith('n8n.system_task.result', 'aborted'); expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatus.ok }); }); + + it.each([ + { aborted: ['lease'], result: 'lease_lost' }, + { aborted: ['shutdown'], result: 'aborted' }, + { aborted: ['shutdown', 'lease'], result: 'lease_lost' }, + ] as const)('settles a run stopped by $aborted as $result', async ({ aborted, result }) => { + const eventService = mock(); + const controllers = { shutdown: new AbortController(), lease: new AbortController() }; + const task = taskThat(async () => { + aborted.forEach((source) => controllers[source].abort()); + }); + + const outcome = await observeSystemTaskRun( + eventService, + setupTracing().tracing, + task, + 'durable', + controllers.shutdown.signal, + controllers.lease.signal, + ); + + expect(outcome.result).toBe(result); + expect(eventService.emit).toHaveBeenCalledWith( + 'system-task-run-settled', + expect.objectContaining({ result }), + ); + }); }); diff --git a/packages/cli/src/scheduling/system-tasks/system-task-handler.ts b/packages/cli/src/scheduling/system-tasks/system-task-handler.ts index 903c43274a0..615ee11c0bd 100644 --- a/packages/cli/src/scheduling/system-tasks/system-task-handler.ts +++ b/packages/cli/src/scheduling/system-tasks/system-task-handler.ts @@ -7,8 +7,8 @@ import type { Tracing } from 'n8n-core'; import { observeSystemTaskRun } from './system-task-run-observer'; /** - * Runs one durable occurrence of a system task, handing it `shutdownSignal` so - * it can stop early when the instance shuts down. + * Runs one durable occurrence of a system task. The signal aborts on shutdown. + * Lease loss or expiry also aborts it unless the dispatch marker is stored. * * Errors propagate: the executor is what retries the occurrence or gives up on * it, following the attempt limit carried by the occurrence's job row. @@ -23,7 +23,11 @@ export class SystemTaskHandler implements TaskHandler { private readonly onRunError: (error: unknown) => void, ) {} - async execute(task: ClaimedTask, report: DispatchReporter): Promise { + async execute( + task: ClaimedTask, + report: DispatchReporter, + leaseSignal: AbortSignal, + ): Promise { const decision = this.systemTask.effects === 'non-idempotent' ? report.dispatched() : report.notDispatched(); @@ -33,6 +37,7 @@ export class SystemTaskHandler implements TaskHandler { this.systemTask, 'durable', this.shutdownSignal, + leaseSignal, ); if (outcome.rejected) { // An aborted run is not reported, but its rejection still propagates so @@ -43,6 +48,10 @@ export class SystemTaskHandler implements TaskHandler { throw outcome.error; } + // A clean stop after lease loss must reject so the executor counts the failed attempt. + // A clean stop on shutdown still completes. + leaseSignal.throwIfAborted(); + this.logger.debug('Ran a system task occurrence', { name: this.systemTask.name, taskId: task.id, diff --git a/packages/cli/src/scheduling/system-tasks/system-task-run-observer.ts b/packages/cli/src/scheduling/system-tasks/system-task-run-observer.ts index 1deaed1dacf..aca7bdcc23c 100644 --- a/packages/cli/src/scheduling/system-tasks/system-task-run-observer.ts +++ b/packages/cli/src/scheduling/system-tasks/system-task-run-observer.ts @@ -25,14 +25,15 @@ async function startRun(task: Pick, signal: AbortSignal): Pro /** How one observed run ended. A rejected run carries its error. */ export type SystemTaskRunOutcome = - | { result: 'success' | 'aborted'; rejected: false } - | { result: 'failure' | 'aborted'; rejected: true; error: unknown }; + | { result: 'success' | 'aborted' | 'lease_lost'; rejected: false } + | { result: 'failure' | 'aborted' | 'lease_lost'; rejected: true; error: unknown }; /** * Run one occurrence of `task` with `signal`, emit its start and its settlement, - * paired by construction, and record one `system_task.run` span around it. Never - * rejects: a run that settles after `signal` aborted ends as `aborted`, otherwise - * a resolution ends as `success` and a rejection as `failure`. + * paired by construction, and record one `system_task.run` span around it. The + * run also stops when `leaseSignal` aborts. Never rejects: a run that settles + * after `leaseSignal` aborted ends as `lease_lost`, after `signal` aborted as + * `aborted`, otherwise a resolution ends as `success` and a rejection as `failure`. */ export async function observeSystemTaskRun( eventService: EventService, @@ -40,8 +41,16 @@ export async function observeSystemTaskRun( task: Pick, mode: SystemTaskMode, signal: AbortSignal, + leaseSignal?: AbortSignal, ): Promise { const { name } = task; + const runSignal = leaseSignal ? AbortSignal.any([signal, leaseSignal]) : signal; + const abortResult = (): 'lease_lost' | 'aborted' | undefined => { + if (leaseSignal?.aborted) { + return 'lease_lost'; + } + return signal.aborted ? 'aborted' : undefined; + }; const spanOptions = { name: 'System task run', op: 'system_task.run', @@ -59,13 +68,13 @@ export async function observeSystemTaskRun( return await startSpan(spanOptions, async (span: Span) => { const startedAt = performance.now(); emitSystemTaskMetric(eventService, 'system-task-run-started', { name, mode }); - const outcome = await startRun(task, signal).then( + const outcome = await startRun(task, runSignal).then( (): SystemTaskRunOutcome => ({ - result: signal.aborted ? 'aborted' : 'success', + result: abortResult() ?? 'success', rejected: false, }), (error: unknown): SystemTaskRunOutcome => ({ - result: signal.aborted ? 'aborted' : 'failure', + result: abortResult() ?? 'failure', rejected: true, error, }), @@ -77,7 +86,7 @@ export async function observeSystemTaskRun( durationMs: performance.now() - startedAt, }); span.setAttribute(SYSTEM_TASK_ATTRIBUTES.result, outcome.result); - // An abort is an expected shutdown, so only a real failure errors the span. + // A shutdown or a lost lease is an expected stop, so only a real failure errors the span. if (outcome.result === 'failure') { span.setStatus({ code: SpanStatus.error, message: ensureError(outcome.error).message }); } else { From 264a931de2e71914a416e4f22a49709ded4ed464 Mon Sep 17 00:00:00 2001 From: Lorent Lempereur Date: Tue, 6 Oct 2026 09:07:38 +0000 Subject: [PATCH 11/61] feat(core): Stop a poll trigger task when its claim is lost (no-changelog) (#40273) --- .../poll-trigger-task-handler.test.ts | 336 ++++++++++++++++-- .../poll-trigger-task-handler.ts | 62 ++-- .../trigger-execution-context.factory.test.ts | 8 +- .../trigger-execution-context.factory.ts | 15 +- 4 files changed, 347 insertions(+), 74 deletions(-) diff --git a/packages/cli/src/scheduling/poll-trigger-node/__tests__/poll-trigger-task-handler.test.ts b/packages/cli/src/scheduling/poll-trigger-node/__tests__/poll-trigger-task-handler.test.ts index 9297797ea0c..566b56542b0 100644 --- a/packages/cli/src/scheduling/poll-trigger-node/__tests__/poll-trigger-task-handler.test.ts +++ b/packages/cli/src/scheduling/poll-trigger-node/__tests__/poll-trigger-task-handler.test.ts @@ -3,7 +3,13 @@ import type { Logger } from '@n8n/backend-common'; import type { EventService } from '@n8n/backend-services'; import type { GlobalConfig } from '@n8n/config'; import type { PollerFullState, WorkflowRepository } from '@n8n/db'; -import { createDispatchReporter, type ClaimedTask } from '@n8n/scheduler'; +import { + createDispatchReporter, + createScheduler, + type ClaimedTask, + type SchedulerMetrics, + type SchedulerTaskStore, +} from '@n8n/scheduler'; import type { ErrorReporter, TriggersAndPollers } from 'n8n-core'; import type { INode, INodeExecutionData, IPollFunctions, IWorkflowBase } from 'n8n-workflow'; import { UnexpectedError, Workflow, WorkflowExpression } from 'n8n-workflow'; @@ -46,6 +52,7 @@ describe('PollTriggerTaskHandler', () => { const onDispatch = vi.fn(); const report = createDispatchReporter(onDispatch); + const leaseSignal = new AbortController().signal; const triggerNode: INode = { id: 'node-1', @@ -155,7 +162,7 @@ describe('PollTriggerTaskHandler', () => { }), ); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(triggersAndPollers.runPollFunction).not.toHaveBeenCalled(); expect(onDispatch).not.toHaveBeenCalled(); @@ -168,7 +175,7 @@ describe('PollTriggerTaskHandler', () => { }), ); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(triggersAndPollers.runPollFunction).not.toHaveBeenCalled(); expect(onDispatch).not.toHaveBeenCalled(); @@ -183,7 +190,7 @@ describe('PollTriggerTaskHandler', () => { describe('handoff', () => { test('runs poll() against the poll context the factory assembles for the node', async () => { - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(triggerExecutionContextFactory.createPollExecutionContext).toHaveBeenCalledWith( buildWorkflowData(), @@ -205,7 +212,7 @@ describe('PollTriggerTaskHandler', () => { backoffUntil: null, }); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(triggerExecutionContextFactory.createPollExecutionContext).toHaveBeenCalledWith( buildWorkflowData(), @@ -216,7 +223,7 @@ describe('PollTriggerTaskHandler', () => { }); test('reads workflow data fresh (non-cached) so the poll cursor is never stale', async () => { - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(triggerExecutionContextFactory.findPublishedWorkflowData).toHaveBeenCalledWith( 'wf-1', @@ -227,7 +234,7 @@ describe('PollTriggerTaskHandler', () => { }); test('hands off and reports a dispatch when poll() returns new data', async () => { - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(pollFunctions.__emit).toHaveBeenCalledWith(pollData); expect(onDispatch).toHaveBeenCalledTimes(1); @@ -236,7 +243,7 @@ describe('PollTriggerTaskHandler', () => { test('does not emit and reports no dispatch when poll() returns null', async () => { triggersAndPollers.runPollFunction.mockResolvedValue(null); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(pollFunctions.__emit).not.toHaveBeenCalled(); expect(onDispatch).not.toHaveBeenCalled(); @@ -244,10 +251,58 @@ describe('PollTriggerTaskHandler', () => { expect(releaseIsolate).toHaveBeenCalledTimes(1); }); + test.each([ + ['data', pollData], + ['no data', null], + ])('rejects after lease loss when the poll returns %s', async (_name, pollResult) => { + const lease = new AbortController(); + const reason = new Error('lease expired'); + triggersAndPollers.runPollFunction.mockImplementation(async () => { + lease.abort(reason); + return pollResult; + }); + + await expect(handler.execute(buildTask(), report, lease.signal)).rejects.toBe(reason); + + expect(pollFunctions.__emit).not.toHaveBeenCalled(); + expect(pollFunctions.__emitError).not.toHaveBeenCalled(); + expect(pollFunctions.__commitCursor).not.toHaveBeenCalled(); + expect(pollBackoffService.recordSuccess).not.toHaveBeenCalled(); + expect(pollBackoffService.recordFailure).not.toHaveBeenCalled(); + expect(onDispatch).not.toHaveBeenCalled(); + expect(releaseIsolate).toHaveBeenCalledTimes(1); + }); + + test.each([ + ['data', pollData], + ['no data', null], + ])( + 'rejects if the lease is lost during the active-state lookup with %s', + async (_name, pollResult) => { + const lease = new AbortController(); + const reason = new Error('lease expired'); + triggersAndPollers.runPollFunction.mockResolvedValue(pollResult); + workflowRepository.isActive.mockImplementationOnce(async () => { + lease.abort(reason); + return true; + }); + + await expect(handler.execute(buildTask(), report, lease.signal)).rejects.toBe(reason); + + expect(pollFunctions.__emit).not.toHaveBeenCalled(); + expect(pollFunctions.__emitError).not.toHaveBeenCalled(); + expect(pollFunctions.__commitCursor).not.toHaveBeenCalled(); + expect(pollBackoffService.recordFailure).not.toHaveBeenCalled(); + expect(errorReporter.error).not.toHaveBeenCalled(); + expect(onDispatch).not.toHaveBeenCalled(); + expect(releaseIsolate).toHaveBeenCalledTimes(1); + }, + ); + test('discards the result and reports no dispatch when the workflow was deactivated during poll()', async () => { workflowRepository.isActive.mockResolvedValue(false); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(pollFunctions.__emit).not.toHaveBeenCalled(); expect(onDispatch).not.toHaveBeenCalled(); @@ -257,7 +312,7 @@ describe('PollTriggerTaskHandler', () => { describe('isolate lifecycle', () => { test('acquires the isolate before running poll() and releases it after', async () => { - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(acquireIsolate).toHaveBeenCalledTimes(1); expect(releaseIsolate).toHaveBeenCalledTimes(1); @@ -270,7 +325,9 @@ describe('PollTriggerTaskHandler', () => { // nothing to release and propagates out for the executor to retry. acquireIsolate.mockRejectedValue(new Error('isolate unavailable')); - await expect(handler.execute(buildTask(), report)).rejects.toThrow('isolate unavailable'); + await expect(handler.execute(buildTask(), report, leaseSignal)).rejects.toThrow( + 'isolate unavailable', + ); expect(triggersAndPollers.runPollFunction).not.toHaveBeenCalled(); expect(releaseIsolate).not.toHaveBeenCalled(); @@ -284,7 +341,7 @@ describe('PollTriggerTaskHandler', () => { // Does not rethrow: rethrowing would let the executor retry and re-poll a // still-down source instead of running the error workflow. - await expect(handler.execute(buildTask(), report)).resolves.toBeDefined(); + await expect(handler.execute(buildTask(), report, leaseSignal)).resolves.toBeDefined(); // The cursor is not advanced (no __emit, so no saveStaticData); the error is // handed off to the error workflow via __emitError. @@ -295,12 +352,28 @@ describe('PollTriggerTaskHandler', () => { expect(releaseIsolate).toHaveBeenCalledTimes(1); }); + test('does not route a poll() error to the error workflow when the claim was lost during poll()', async () => { + const lease = new AbortController(); + const reason = new Error('lease expired'); + triggersAndPollers.runPollFunction.mockImplementation(async () => { + lease.abort(reason); + throw new Error('poll source unreachable'); + }); + + await expect(handler.execute(buildTask(), report, lease.signal)).rejects.toBe(reason); + + expect(pollFunctions.__emitError).not.toHaveBeenCalled(); + expect(pollBackoffService.recordFailure).not.toHaveBeenCalled(); + expect(onDispatch).not.toHaveBeenCalled(); + expect(releaseIsolate).toHaveBeenCalledTimes(1); + }); + test('logs a failing cursor commit instead of routing it to the error workflow', async () => { triggersAndPollers.runPollFunction.mockResolvedValue(null); const commitError = new Error('poller state write failed'); pollFunctions.__commitCursor.mockRejectedValue(commitError); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(pollFunctions.__emitError).not.toHaveBeenCalled(); expect(onDispatch).not.toHaveBeenCalled(); @@ -319,6 +392,27 @@ describe('PollTriggerTaskHandler', () => { }); describe('cursor commit', () => { + test.each([false, true])( + 'propagates lease loss during a cursor commit (write fails: %s)', + async (fails) => { + const lease = new AbortController(); + const reason = new Error('lease expired'); + triggersAndPollers.runPollFunction.mockResolvedValue(null); + pollFunctions.__commitCursor.mockImplementationOnce(async () => { + lease.abort(reason); + if (fails) throw new Error('cursor write failed'); + }); + + await expect(handler.execute(buildTask(), report, lease.signal)).rejects.toBe(reason); + + expect(pollFunctions.__emitError).not.toHaveBeenCalled(); + expect(pollBackoffService.recordFailure).not.toHaveBeenCalled(); + expect(errorReporter.error).not.toHaveBeenCalled(); + expect(onDispatch).not.toHaveBeenCalled(); + expect(releaseIsolate).toHaveBeenCalledTimes(1); + }, + ); + const cases: Array< [string, { poll: INodeExecutionData[][] | null | Error; active: boolean; commits: number }] > = [ @@ -345,7 +439,7 @@ describe('PollTriggerTaskHandler', () => { else triggersAndPollers.runPollFunction.mockResolvedValue(poll); workflowRepository.isActive.mockResolvedValue(active); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(pollFunctions.__commitCursor).toHaveBeenCalledTimes(commits); }); @@ -367,7 +461,7 @@ describe('PollTriggerTaskHandler', () => { scopeAtCommit = scope.getStore(); }); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(scopeAtCommit).toBe('staging'); }); @@ -378,7 +472,7 @@ describe('PollTriggerTaskHandler', () => { scopeAtEmit = scope.getStore(); }); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(scopeAtEmit).toBe('staging'); }); @@ -395,10 +489,68 @@ describe('PollTriggerTaskHandler', () => { vi.useRealTimers(); }); + test.each(['poll', 'active-state lookup', 'failure write'])( + 'rejects a timed-out poll if the lease was lost during the %s', + async (abortDuring) => { + const lease = new AbortController(); + const reason = new Error('lease expired'); + triggersAndPollers.runPollFunction.mockImplementationOnce(async () => { + if (abortDuring === 'poll') lease.abort(reason); + return await new Promise(() => {}); + }); + if (abortDuring === 'active-state lookup') { + workflowRepository.isActive.mockImplementationOnce(async () => { + lease.abort(reason); + return true; + }); + } + if (abortDuring === 'failure write') { + pollBackoffService.recordFailure.mockImplementationOnce(async () => { + lease.abort(reason); + }); + } + + const rejected = expect(handler.execute(buildTask(), report, lease.signal)).rejects.toBe( + reason, + ); + await vi.advanceTimersByTimeAsync(pollTimeoutMs); + await rejected; + + expect(pollBackoffService.recordFailure).toHaveBeenCalledTimes( + abortDuring === 'failure write' ? 1 : 0, + ); + expect(pollBackoffService.recordSuccess).not.toHaveBeenCalled(); + expect(pollFunctions.__emit).not.toHaveBeenCalled(); + expect(pollFunctions.__emitError).not.toHaveBeenCalled(); + expect(pollFunctions.__commitCursor).not.toHaveBeenCalled(); + expect(onDispatch).not.toHaveBeenCalled(); + expect(releaseIsolate).toHaveBeenCalledTimes(1); + }, + ); + + test('abandons a hanging poll as soon as the lease is lost', async () => { + const lease = new AbortController(); + const reason = new Error('lease expired'); + triggersAndPollers.runPollFunction.mockReturnValue(new Promise(() => {})); + + const rejected = expect(handler.execute(buildTask(), report, lease.signal)).rejects.toBe( + reason, + ); + await vi.advanceTimersByTimeAsync(1); + lease.abort(reason); + await rejected; + + expect(eventService.emit).not.toHaveBeenCalledWith('poll-tick-timed-out', expect.anything()); + expect(pollBackoffService.recordFailure).not.toHaveBeenCalled(); + expect(pollFunctions.__emitError).not.toHaveBeenCalled(); + expect(onDispatch).not.toHaveBeenCalled(); + expect(releaseIsolate).toHaveBeenCalledTimes(1); + }); + test('abandons a poll that outlives the timeout and reports no dispatch', async () => { triggersAndPollers.runPollFunction.mockReturnValue(new Promise(() => {})); - const executing = handler.execute(buildTask(), report); + const executing = handler.execute(buildTask(), report, leaseSignal); await vi.advanceTimersByTimeAsync(pollTimeoutMs); await expect(executing).resolves.toBeDefined(); @@ -427,7 +579,7 @@ describe('PollTriggerTaskHandler', () => { workflowRepository.isActive.mockResolvedValue(false); triggersAndPollers.runPollFunction.mockReturnValue(new Promise(() => {})); - const executing = handler.execute(buildTask(), report); + const executing = handler.execute(buildTask(), report, leaseSignal); await vi.advanceTimersByTimeAsync(pollTimeoutMs); await executing; @@ -442,7 +594,7 @@ describe('PollTriggerTaskHandler', () => { }), ); - const executing = handler.execute(buildTask(), report); + const executing = handler.execute(buildTask(), report, leaseSignal); await vi.advanceTimersByTimeAsync(pollTimeoutMs - 1); resolvePoll(pollData); await executing; @@ -463,7 +615,7 @@ describe('PollTriggerTaskHandler', () => { }), ); - const executing = handler.execute(buildTask(), report); + const executing = handler.execute(buildTask(), report, leaseSignal); await vi.advanceTimersByTimeAsync(pollTimeoutMs); await executing; resolvePoll(pollData); @@ -493,7 +645,7 @@ describe('PollTriggerTaskHandler', () => { globalConfig, ); - const executing = plainHandler.execute(buildTask(), report); + const executing = plainHandler.execute(buildTask(), report, leaseSignal); await vi.advanceTimersByTimeAsync(pollTimeoutMs); await executing; rejectPoll(new Error('poll source unreachable')); @@ -507,11 +659,73 @@ describe('PollTriggerTaskHandler', () => { }); }); + describe('lease cancellation through the executor', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test.each([ + { maxAttempts: 3, ownsClaim: true }, + { maxAttempts: 1, ownsClaim: true }, + { maxAttempts: 3, ownsClaim: false }, + ])( + 'counts a failed attempt only for a current claim (maxAttempts: $maxAttempts, owned: $ownsClaim)', + async ({ maxAttempts, ownsClaim }) => { + const task = buildTask({ maxAttempts, runAt: new Date() }); + const store = mock(); + const metrics = mock(); + store.claimDueTasks.mockResolvedValue([task]); + store.beginDispatch.mockResolvedValue(1); + store.markDispatched.mockResolvedValue(1); + store.completeTask.mockResolvedValue(1); + store.renewLease.mockRejectedValue(new Error('database unavailable')); + store.rescheduleTask.mockResolvedValue(ownsClaim ? 1 : 0); + store.failTaskTerminal.mockResolvedValue(ownsClaim ? 1 : 0); + let finishPoll!: (data: INodeExecutionData[][]) => void; + triggersAndPollers.runPollFunction.mockReturnValueOnce( + new Promise((resolve) => { + finishPoll = resolve; + }), + ); + const scheduler = createScheduler({ + hostId: 'test-host', + taskStore: store, + materializerTransaction: vi.fn(), + executor: { leaseSeconds: 15, lookaheadSeconds: 1 }, + metrics, + }); + scheduler.registerTaskHandler(POLL_TRIGGER_TASK_TYPE, handler); + + await scheduler.execute(); + await vi.advanceTimersByTimeAsync(15_001); + expect(metrics.recordLeaseRenewal).toHaveBeenCalledWith(POLL_TRIGGER_TASK_TYPE, 'expired'); + finishPoll(pollData); + await vi.advanceTimersByTimeAsync(0); + await scheduler.stop(); + + expect(store.markDispatched).not.toHaveBeenCalled(); + expect(store.completeTask).not.toHaveBeenCalled(); + expect(store.rescheduleTask).toHaveBeenCalledTimes(maxAttempts > 1 ? 1 : 0); + expect(store.failTaskTerminal).toHaveBeenCalledTimes(maxAttempts === 1 ? 1 : 0); + expect(metrics.recordRetry).toHaveBeenCalledTimes(ownsClaim && maxAttempts > 1 ? 1 : 0); + expect(metrics.recordDeadLettered).toHaveBeenCalledTimes(maxAttempts === 1 ? 1 : 0); + expect(metrics.recordLeaseLost).toHaveBeenCalledTimes(ownsClaim ? 0 : 1); + expect(pollFunctions.__emit).not.toHaveBeenCalled(); + expect(pollFunctions.__emitError).not.toHaveBeenCalled(); + expect(releaseIsolate).toHaveBeenCalledTimes(1); + }, + ); + }); + describe('failures', () => { test('rejects a task whose payload is missing workflowId or nodeId', async () => { const task = buildTask({ payload: { nodeId: 'node-1' } }); - await expect(handler.execute(task, report)).rejects.toThrow( + await expect(handler.execute(task, report, leaseSignal)).rejects.toThrow( 'Poll-trigger task payload is missing workflowId or nodeId', ); expect(triggerExecutionContextFactory.findPublishedWorkflowData).not.toHaveBeenCalled(); @@ -521,7 +735,7 @@ describe('PollTriggerTaskHandler', () => { test('reports no dispatch when the published workflow is gone', async () => { triggerExecutionContextFactory.findPublishedWorkflowData.mockResolvedValue(null); - const decision = await handler.execute(buildTask(), report); + const decision = await handler.execute(buildTask(), report, leaseSignal); // The workflow was unpublished after the claim: the occurrence completes as a // no-op instead of retrying to dead-letter while the owner retires the job. @@ -542,7 +756,7 @@ describe('PollTriggerTaskHandler', () => { buildWorkflowData({ nodes }), ); - await expect(handler.execute(buildTask(), report)).rejects.toThrow( + await expect(handler.execute(buildTask(), report, leaseSignal)).rejects.toThrow( 'missing or disabled in the published workflow', ); expect(triggersAndPollers.runPollFunction).not.toHaveBeenCalled(); @@ -555,7 +769,7 @@ describe('PollTriggerTaskHandler', () => { (pollFunctions as IPollFunctions).__commitCursor = undefined; triggersAndPollers.runPollFunction.mockResolvedValue(null); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(triggersAndPollers.runPollFunction).toHaveBeenCalledWith( workflow, @@ -577,6 +791,52 @@ describe('PollTriggerTaskHandler', () => { vi.useRealTimers(); }); + test('rejects if the lease is lost during the success write', async () => { + const lease = new AbortController(); + const reason = new Error('lease expired'); + pollBackoffService.recordSuccess.mockImplementationOnce(async () => { + lease.abort(reason); + }); + + await expect(handler.execute(buildTask(), report, lease.signal)).rejects.toBe(reason); + + expect(pollFunctions.__emit).not.toHaveBeenCalled(); + expect(pollFunctions.__emitError).not.toHaveBeenCalled(); + expect(pollBackoffService.recordFailure).not.toHaveBeenCalled(); + expect(onDispatch).not.toHaveBeenCalled(); + expect(releaseIsolate).toHaveBeenCalledTimes(1); + }); + + test.each(['active-state lookup', 'failure write'])( + 'rejects a poll error if the lease is lost during the %s', + async (abortDuring) => { + const lease = new AbortController(); + const reason = new Error('lease expired'); + triggersAndPollers.runPollFunction.mockRejectedValueOnce( + new Error('poll source unreachable'), + ); + if (abortDuring === 'active-state lookup') { + workflowRepository.isActive.mockImplementationOnce(async () => { + lease.abort(reason); + return true; + }); + } else { + pollBackoffService.recordFailure.mockImplementationOnce(async () => { + lease.abort(reason); + }); + } + + await expect(handler.execute(buildTask(), report, lease.signal)).rejects.toBe(reason); + + expect(pollBackoffService.recordFailure).toHaveBeenCalledTimes( + abortDuring === 'failure write' ? 1 : 0, + ); + expect(pollFunctions.__emitError).not.toHaveBeenCalled(); + expect(onDispatch).not.toHaveBeenCalled(); + expect(releaseIsolate).toHaveBeenCalledTimes(1); + }, + ); + test('skips the tick while backing off, without loading the workflow or polling', async () => { const state: PollerFullState = { cursor: {}, @@ -586,7 +846,7 @@ describe('PollTriggerTaskHandler', () => { pollBackoffService.getState.mockResolvedValue(state); pollBackoffService.isBackingOff.mockReturnValue(true); - const decision = await handler.execute(buildTask(), report); + const decision = await handler.execute(buildTask(), report, leaseSignal); expect(decision).toBe(report.notDispatched()); expect(triggerExecutionContextFactory.findPublishedWorkflowData).not.toHaveBeenCalled(); @@ -601,7 +861,7 @@ describe('PollTriggerTaskHandler', () => { const error = new Error('poll source unreachable'); triggersAndPollers.runPollFunction.mockRejectedValue(error); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(pollBackoffService.recordFailure).toHaveBeenCalledWith({ workflowId: 'wf-1', @@ -620,7 +880,7 @@ describe('PollTriggerTaskHandler', () => { new UnexpectedError('Node type does not have a poll function defined'), ); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(pollBackoffService.recordFailure).toHaveBeenCalledTimes(1); }); @@ -631,7 +891,7 @@ describe('PollTriggerTaskHandler', () => { const error = new Error('database unavailable'); workflowRepository.isActive.mockRejectedValue(error); - const decision = await handler.execute(buildTask(), report); + const decision = await handler.execute(buildTask(), report, leaseSignal); expect(triggersAndPollers.runPollFunction).toHaveBeenCalled(); expect(pollBackoffService.recordFailure).not.toHaveBeenCalled(); @@ -645,7 +905,7 @@ describe('PollTriggerTaskHandler', () => { triggersAndPollers.runPollFunction.mockResolvedValue(null); pollFunctions.__commitCursor.mockRejectedValue(new Error('poller state write failed')); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(pollBackoffService.recordSuccess).toHaveBeenCalledWith({ workflowId: 'wf-1', @@ -662,7 +922,7 @@ describe('PollTriggerTaskHandler', () => { pollBackoffService.getState.mockResolvedValue(state); triggersAndPollers.runPollFunction.mockResolvedValue(pollResult); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(pollBackoffService.recordSuccess).toHaveBeenCalledWith({ workflowId: 'wf-1', @@ -677,7 +937,7 @@ describe('PollTriggerTaskHandler', () => { pollBackoffService.getState.mockResolvedValue(state); workflowRepository.isActive.mockResolvedValue(false); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(pollBackoffService.recordSuccess).toHaveBeenCalledWith({ workflowId: 'wf-1', @@ -693,7 +953,7 @@ describe('PollTriggerTaskHandler', () => { triggersAndPollers.runPollFunction.mockRejectedValue(error); workflowRepository.isActive.mockResolvedValue(false); - const decision = await handler.execute(buildTask(), report); + const decision = await handler.execute(buildTask(), report, leaseSignal); expect(pollBackoffService.recordFailure).not.toHaveBeenCalled(); expect(pollFunctions.__emitError).toHaveBeenCalledWith(error); @@ -707,7 +967,7 @@ describe('PollTriggerTaskHandler', () => { triggersAndPollers.runPollFunction.mockRejectedValue(error); workflowRepository.isActive.mockRejectedValue(new Error('database unavailable')); - const decision = await handler.execute(buildTask(), report); + const decision = await handler.execute(buildTask(), report, leaseSignal); expect(pollBackoffService.recordFailure).toHaveBeenCalledWith({ workflowId: 'wf-1', @@ -723,7 +983,7 @@ describe('PollTriggerTaskHandler', () => { test('does not touch the failure counters when the published workflow is missing', async () => { triggerExecutionContextFactory.findPublishedWorkflowData.mockResolvedValue(null); - const decision = await handler.execute(buildTask(), report); + const decision = await handler.execute(buildTask(), report, leaseSignal); expect(decision).toBe(report.notDispatched()); expect(pollBackoffService.recordFailure).not.toHaveBeenCalled(); @@ -733,7 +993,7 @@ describe('PollTriggerTaskHandler', () => { test('does not read the failure state when the payload is invalid', async () => { const task = buildTask({ payload: { nodeId: 'node-1' } }); - await expect(handler.execute(task, report)).rejects.toThrow(); + await expect(handler.execute(task, report, leaseSignal)).rejects.toThrow(); expect(pollBackoffService.getState).not.toHaveBeenCalled(); }); @@ -742,7 +1002,7 @@ describe('PollTriggerTaskHandler', () => { const error = new Error('poller state read failed'); pollBackoffService.getState.mockRejectedValue(error); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); expect(triggersAndPollers.runPollFunction).toHaveBeenCalled(); expect(onDispatch).toHaveBeenCalledTimes(1); @@ -755,7 +1015,7 @@ describe('PollTriggerTaskHandler', () => { throw error; }); - await handler.execute(buildTask(), report); + await handler.execute(buildTask(), report, leaseSignal); const [, isBackingOffNow] = pollBackoffService.isBackingOff.mock.calls[0]; const { now: recordFailureNow } = pollBackoffService.recordFailure.mock.calls[0][0]; diff --git a/packages/cli/src/scheduling/poll-trigger-node/poll-trigger-task-handler.ts b/packages/cli/src/scheduling/poll-trigger-node/poll-trigger-task-handler.ts index 9dc92b904ae..3eba1095434 100644 --- a/packages/cli/src/scheduling/poll-trigger-node/poll-trigger-task-handler.ts +++ b/packages/cli/src/scheduling/poll-trigger-node/poll-trigger-task-handler.ts @@ -15,6 +15,7 @@ import { } from 'n8n-core'; import type { Failure, IWorkflowBase } from 'n8n-workflow'; import { OperationalError, UnexpectedError } from 'n8n-workflow'; +import { aborted } from 'node:util'; import { PollBackoffService } from '@/workflows/triggers/poll-backoff.service'; import { TriggerExecutionContextFactory } from '@/workflows/triggers/trigger-execution-context.factory'; @@ -26,9 +27,6 @@ import { type PollTriggerTaskPayload, } from './poll-trigger-task'; -/** Race sentinel: `poll()` can resolve to anything, so the deadline resolves to a symbol it cannot produce. */ -const TIMED_OUT = Symbol('poll timed out'); - /** Stands in for the error a hanging poll never threw, so backoff classifies the timeout as transient. */ class PollTimeoutError extends OperationalError { readonly failure: Failure = { cause: 'temporarily-unavailable' }; @@ -38,16 +36,6 @@ class PollTimeoutError extends OperationalError { } } -/** An unref'd, cancellable deadline that resolves to {@link TIMED_OUT} after `ms`. */ -function timeoutAfter(ms: number): { timedOut: Promise; cancel: () => void } { - let timer: ReturnType | undefined; - const timedOut = new Promise((resolve) => { - timer = setTimeout(() => resolve(TIMED_OUT), ms); - timer.unref(); - }); - return { timedOut, cancel: () => clearTimeout(timer) }; -} - /** * Runs a due poll occurrence's `poll()` once and dispatches only when it returns new data. * Carries no `deduplicationKey`: under the at-least-once scheduler contract an occurrence @@ -73,7 +61,11 @@ export class PollTriggerTaskHandler implements TaskHandler { this.pollTimeoutMs = globalConfig.scheduler.pollTimeoutSeconds * Time.seconds.toMilliseconds; } - async execute(task: ClaimedTask, report: DispatchReporter): Promise { + async execute( + task: ClaimedTask, + report: DispatchReporter, + leaseSignal: AbortSignal, + ): Promise { // A setup failure here retries to N8N_SCHEDULER_MAX_ATTEMPTS then dead-letters, // unlike a `poll()` runtime failure below, which routes to the error workflow instead. const { workflowId, nodeId } = this.parsePayload(task); @@ -142,18 +134,22 @@ export class PollTriggerTaskHandler implements TaskHandler { // poll() does count: it repeats every tick just like a failing source. let polled = false; try { + leaseSignal.throwIfAborted(); // `poll()` takes no abort signal, so the deadline abandons it rather than // cancelling it: the call keeps running until it settles on its own, and its // outcome is discarded. The cursor never moves on that path (it only moves // through the staged commit or __emit below), so an abandoned tick leaves // the poll window untouched for the next occurrence to cover. - const deadline = timeoutAfter(this.pollTimeoutMs); + const deadline = new AbortController(); + const deadlineTimer = setTimeout(() => deadline.abort(), this.pollTimeoutMs).unref(); + const abandon = AbortSignal.any([leaseSignal, deadline.signal]); const poll = this.triggersAndPollers.runPollFunction(workflow, node, pollFunctions); let pollResponse: Awaited; try { - const outcome = await Promise.race([poll, deadline.timedOut]); - if (outcome === TIMED_OUT) { + await Promise.race([poll, aborted(abandon, pollFunctions)]); + leaseSignal.throwIfAborted(); + if (deadline.signal.aborted) { this.eventService.emit('poll-tick-timed-out', { nodeType: node.type }); this.logger.warn('Poll exceeded its timeout and was abandoned', { ...logContext, @@ -162,22 +158,32 @@ export class PollTriggerTaskHandler implements TaskHandler { // Not routed to the error workflow: an abandoned poll produces no run, and // an error run is one. It does count as a poll failure, so a source that // keeps hanging is re-polled at a widening interval like any failing source. - await this.recordFailureIfActive(workflowId, nodeId, new PollTimeoutError(), state); + await this.recordFailureIfActive( + workflowId, + nodeId, + new PollTimeoutError(), + state, + leaseSignal, + ); + leaseSignal.throwIfAborted(); return report.notDispatched(); } - pollResponse = outcome; + pollResponse = await poll; } finally { - deadline.cancel(); + clearTimeout(deadlineTimer); } polled = true; await this.pollBackoffService.recordSuccess({ workflowId, nodeId, state }); + leaseSignal.throwIfAborted(); if (pollResponse !== null) { // poll() can run for a while (network I/O against the polled source), so // the workflow may have been deactivated while it was in flight. There is // no in-memory registration to check here, so re-read the stored active state. - if (!(await this.workflowRepository.isActive(workflowId))) { + const isActive = await this.workflowRepository.isActive(workflowId); + leaseSignal.throwIfAborted(); + if (!isActive) { this.logger.debug( 'Workflow deactivated during poll; discarding the result', logContext, @@ -195,9 +201,13 @@ export class PollTriggerTaskHandler implements TaskHandler { // its own. Active state is re-read first so a workflow deactivated mid-poll // doesn't get its cursor moved. try { - if (await this.workflowRepository.isActive(workflowId)) + const isActive = await this.workflowRepository.isActive(workflowId); + leaseSignal.throwIfAborted(); + if (isActive) { await commitStagedCursor(pollFunctions); + } } catch (error) { + leaseSignal.throwIfAborted(); // The poll itself succeeded, so a failed cursor write is logged rather // than routed to the error workflow. this.errorReporter.error(error, { @@ -209,15 +219,19 @@ export class PollTriggerTaskHandler implements TaskHandler { ); } + leaseSignal.throwIfAborted(); this.logger.debug('Poll returned no new data; nothing to hand off', logContext); return report.notDispatched(); } catch (error) { + // A clean return completes the occurrence, so cancellation must reject. + leaseSignal.throwIfAborted(); // Routed to the error workflow instead of rethrown, which would retry and // dead-letter without ever running it. __emitError commits no cursor, so // the cursor holds and the next tick retries the same window. if (!polled) { - await this.recordFailureIfActive(workflowId, nodeId, error, state); + await this.recordFailureIfActive(workflowId, nodeId, error, state, leaseSignal); } + leaseSignal.throwIfAborted(); pollFunctions.__emitError(ensureError(error)); this.logger.debug('Poll failed at runtime; routed to the error workflow', logContext); // The error was handed off, so this occurrence is handled and must not retry. @@ -233,8 +247,10 @@ export class PollTriggerTaskHandler implements TaskHandler { nodeId: string, error: unknown, state: PollerFailureState | null, + leaseSignal: AbortSignal, ): Promise { const isActive = await this.workflowRepository.isActive(workflowId).catch(() => true); + leaseSignal.throwIfAborted(); if (isActive) { await this.pollBackoffService.recordFailure({ workflowId, diff --git a/packages/cli/src/workflows/triggers/__tests__/trigger-execution-context.factory.test.ts b/packages/cli/src/workflows/triggers/__tests__/trigger-execution-context.factory.test.ts index 537b7b53904..4df56a09119 100644 --- a/packages/cli/src/workflows/triggers/__tests__/trigger-execution-context.factory.test.ts +++ b/packages/cli/src/workflows/triggers/__tests__/trigger-execution-context.factory.test.ts @@ -1291,12 +1291,12 @@ describe('TriggerExecutionContextFactory', () => { const fence = { taskId: 'task-1', leaseEpoch: 1 }; - // Budget = min(poll timeout, lease duration) minus a margin of - // max(20%, 5s), so a node that exhausts it still finishes its - // in-flight batch before the engine abandons the tick. + // Budget = poll timeout minus a margin of max(20%, 5s), so a node + // that exhausts it still finishes its in-flight batch before the + // engine abandons the tick. The lease is renewed, so it does not cap it. test.each([ { pollTimeoutSeconds: 45, leaseDurationSeconds: 60, expected: 36_000 }, - { pollTimeoutSeconds: 100, leaseDurationSeconds: 60, expected: 48_000 }, + { pollTimeoutSeconds: 100, leaseDurationSeconds: 60, expected: 80_000 }, // The margin never eats more than half the ceiling, so a tiny (but // schema-valid) timeout still yields a positive budget. { pollTimeoutSeconds: 4, leaseDurationSeconds: 60, expected: 2_000 }, diff --git a/packages/cli/src/workflows/triggers/trigger-execution-context.factory.ts b/packages/cli/src/workflows/triggers/trigger-execution-context.factory.ts index d6726f22bdc..823729dd6c3 100644 --- a/packages/cli/src/workflows/triggers/trigger-execution-context.factory.ts +++ b/packages/cli/src/workflows/triggers/trigger-execution-context.factory.ts @@ -311,15 +311,12 @@ export class TriggerExecutionContextFactory { prefetchedCursor?: PollerCursor, ): IGetExecutePollFunctions { return (workflow: Workflow, node: INode) => { - // A poll must finish inside both the handler's abandon deadline and the task - // lease; past either, its commits are fenced out or discarded. The margin — - // 20%, at least 5s, at most half the ceiling — leaves room for the trailing - // hand-off and cursor commit. + // A poll must finish inside the handler's abandon deadline; past it, its + // commits are discarded. The lease is renewed while the poll runs, so it + // does not bound the poll. The margin — 20%, at least 5s, at most half the + // ceiling — leaves room for the trailing hand-off and cursor commit. const ceilingMs = - Math.min( - this.globalConfig.scheduler.pollTimeoutSeconds, - this.globalConfig.scheduler.leaseDurationSeconds, - ) * Time.seconds.toMilliseconds; + this.globalConfig.scheduler.pollTimeoutSeconds * Time.seconds.toMilliseconds; const marginMs = Math.min(Math.max(0.2 * ceilingMs, 5_000), ceilingMs / 2); const pollBudgetMs = ceilingMs - marginMs; // A poll's staged snapshot lives in an async scope entered per poll, rather @@ -474,7 +471,7 @@ export class TriggerExecutionContextFactory { __commitCursor, __runPoll, resolveNodeStaticData, - // Only a leased (durable) poll is bounded by the timeout and lease; a + // Only a leased (durable) poll is bounded by the timeout; a // legacy in-memory poll keeps PollContext's generous default. fence ? () => pollBudgetMs : undefined, ); From 6ea9b1f3bbd55121219f8c8027ac605be5b8e358 Mon Sep 17 00:00:00 2001 From: Stephen Wright Date: Tue, 6 Oct 2026 09:11:16 +0000 Subject: [PATCH 12/61] refactor(Microsoft SharePoint Node): Move the pickers and helpers out of the v2 action node (no-changelog) (#39902) --- .../SharePoint/{v2 => }/drive/index.ts | 2 +- .../{v2 => }/helpers/graphSearch.ts | 2 +- .../SharePoint/{v2 => }/helpers/utils.ts | 0 .../SharePoint/{v2 => }/site/index.ts | 2 +- .../test/{v2 => }/drive/index.test.ts | 14 +- .../SharePoint/test/helpers/utils.test.ts | 190 ++++++------------ .../SharePoint/test/v1/helpers/utils.test.ts | 142 +++++++++++++ .../Microsoft/SharePoint/test/v2/file.test.ts | 2 +- .../SharePoint/test/v2/file/update.test.ts | 2 +- .../SharePoint/test/v2/file/upload.test.ts | 2 +- .../SharePoint/test/v2/folder.test.ts | 2 +- .../SharePoint/test/v2/helpers/utils.test.ts | 80 -------- .../SharePoint/test/v2/item/get.test.ts | 2 +- .../SharePoint/test/v2/item/getAll.test.ts | 2 +- .../SharePoint/test/v2/list/get.test.ts | 2 +- .../SharePoint/test/v2/list/getAll.test.ts | 2 +- .../Microsoft/SharePoint/test/v2/site.test.ts | 2 +- .../v2/actions/file/download.operation.ts | 2 +- .../v2/actions/file/update.operation.ts | 4 +- .../v2/actions/file/upload.operation.ts | 4 +- .../v2/actions/item/create.operation.ts | 4 +- .../v2/actions/item/delete.operation.ts | 4 +- .../v2/actions/item/get.operation.ts | 4 +- .../v2/actions/item/getAll.operation.ts | 4 +- .../v2/actions/item/update.operation.ts | 4 +- .../v2/actions/item/upsert.operation.ts | 4 +- .../v2/actions/list/get.operation.ts | 4 +- .../v2/actions/list/getAll.operation.ts | 4 +- .../Microsoft/SharePoint/v2/file/index.ts | 4 +- .../Microsoft/SharePoint/v2/folder/index.ts | 4 +- .../Microsoft/SharePoint/v2/item/index.ts | 6 +- .../Microsoft/SharePoint/v2/list/columns.ts | 4 +- .../Microsoft/SharePoint/v2/list/index.ts | 6 +- .../Microsoft/SharePoint/v2/methods/index.ts | 4 +- 34 files changed, 260 insertions(+), 260 deletions(-) rename packages/nodes-base/nodes/Microsoft/SharePoint/{v2 => }/drive/index.ts (99%) rename packages/nodes-base/nodes/Microsoft/SharePoint/{v2 => }/helpers/graphSearch.ts (98%) rename packages/nodes-base/nodes/Microsoft/SharePoint/{v2 => }/helpers/utils.ts (100%) rename packages/nodes-base/nodes/Microsoft/SharePoint/{v2 => }/site/index.ts (99%) rename packages/nodes-base/nodes/Microsoft/SharePoint/test/{v2 => }/drive/index.test.ts (89%) create mode 100644 packages/nodes-base/nodes/Microsoft/SharePoint/test/v1/helpers/utils.test.ts delete mode 100644 packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/helpers/utils.test.ts diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/drive/index.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/drive/index.ts similarity index 99% rename from packages/nodes-base/nodes/Microsoft/SharePoint/v2/drive/index.ts rename to packages/nodes-base/nodes/Microsoft/SharePoint/drive/index.ts index 04055d4c192..d442e7eda09 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/drive/index.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/drive/index.ts @@ -2,7 +2,7 @@ import type { INodeListSearchResult } from 'n8n-workflow'; import { type GraphSearchReply } from '../helpers/utils'; import { resolveSiteId } from '../site'; -import { microsoftApiRequest, type SharePointContext } from '../../transport'; +import { microsoftApiRequest, type SharePointContext } from '../transport'; type SharePointDrive = { id?: string; name?: string; system?: object }; type DriveSearchReply = GraphSearchReply; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/helpers/graphSearch.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/helpers/graphSearch.ts similarity index 98% rename from packages/nodes-base/nodes/Microsoft/SharePoint/v2/helpers/graphSearch.ts rename to packages/nodes-base/nodes/Microsoft/SharePoint/helpers/graphSearch.ts index 54f2dbb8e25..705bbb0e6a3 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/helpers/graphSearch.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/helpers/graphSearch.ts @@ -1,7 +1,7 @@ import type { IDataObject, ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow'; import { type GraphSearchReply } from './utils'; -import { microsoftApiRequest } from '../../transport'; +import { microsoftApiRequest } from '../transport'; // Caps how many pages a single dropdown request walks looking for matches. export const SEARCH_PAGE_LIMIT = 10; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/helpers/utils.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/helpers/utils.ts similarity index 100% rename from packages/nodes-base/nodes/Microsoft/SharePoint/v2/helpers/utils.ts rename to packages/nodes-base/nodes/Microsoft/SharePoint/helpers/utils.ts diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/site/index.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/site/index.ts similarity index 99% rename from packages/nodes-base/nodes/Microsoft/SharePoint/v2/site/index.ts rename to packages/nodes-base/nodes/Microsoft/SharePoint/site/index.ts index d783ea3e7ac..61072d63b70 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/site/index.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/site/index.ts @@ -13,7 +13,7 @@ import { microsoftApiRequest, SERVICE_PRINCIPAL_AUTH, type SharePointContext, -} from '../../transport'; +} from '../transport'; // The whole site-selection piece lives here — the field, the search behind // it, and the URL resolution — so later actions and the future trigger plug diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/drive/index.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/drive/index.test.ts similarity index 89% rename from packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/drive/index.test.ts rename to packages/nodes-base/nodes/Microsoft/SharePoint/test/drive/index.test.ts index 0f1b1108942..77dceda7318 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/drive/index.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/drive/index.test.ts @@ -3,14 +3,14 @@ import type { Mock } from 'vitest'; import type { DeepMockProxy } from 'vitest-mock-extended'; import { mock, mockDeep } from 'vitest-mock-extended'; -import { versionDescription } from '../../../v2/actions/versionDescription'; -import { getDrives } from '../../../v2/drive'; -import { MicrosoftSharePointV2 } from '../../../v2/MicrosoftSharePointV2.node'; -import * as transport from '../../../transport'; -import type * as _importType0 from '../../../transport'; +import { versionDescription } from '../../v2/actions/versionDescription'; +import { getDrives } from '../../drive'; +import { MicrosoftSharePointV2 } from '../../v2/MicrosoftSharePointV2.node'; +import * as transport from '../../transport'; +import type * as _importType0 from '../../transport'; -vi.mock('../../../transport', async () => { - const originalModule = await vi.importActual('../../../transport'); +vi.mock('../../transport', async () => { + const originalModule = await vi.importActual('../../transport'); return { ...originalModule, microsoftApiRequest: vi.fn(), diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/helpers/utils.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/helpers/utils.test.ts index 47f03919a8a..24e2117c181 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/helpers/utils.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/helpers/utils.test.ts @@ -1,142 +1,80 @@ -import type { Mock } from 'vitest'; -import type { MockProxy } from 'vitest-mock-extended'; +import type { INode } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import type { IBinaryData, IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow'; import { - downloadFilePostReceive, - escapeFilterValue, - itemColumnsPreSend, -} from '../../v1/helpers/utils'; -import { microsoftSharePointApiRequest } from '../../v1/transport'; + odataFieldEqualsClause, + SHAREPOINT_ILLEGAL_FILE_NAME_CHARS, + validateSharePointFileName, +} from '../../helpers/utils'; -vi.mock('../../v1/transport', () => ({ - microsoftSharePointApiRequest: vi.fn(), -})); +describe('Microsoft SharePoint v2 — validateSharePointFileName', () => { + const node = mock(); -describe('Microsoft SharePoint Node', () => { - let executeSingleFunctions: MockProxy; - - beforeEach(() => { - executeSingleFunctions = mock(); + it('accepts an ordinary file name', () => { + expect(() => validateSharePointFileName(node, 'report.pdf', 0)).not.toThrow(); }); - afterEach(() => { - vi.resetAllMocks(); - }); - - it('should download file post receive', async () => { - const mockResponse = { - body: '', - statusCode: 200, - headers: { - 'content-disposition': "attachment; filename*=UTF-8''encoded%20name.pdf", - 'content-type': 'application/pdf', - }, - }; - const mockPrepareBinaryData = vi.fn().mockReturnValueOnce({ - data: '', - mimeType: 'application/pdf', - fileName: 'encoded name.pdf', - } as IBinaryData); - executeSingleFunctions.helpers.prepareBinaryData = mockPrepareBinaryData; - - const result = await downloadFilePostReceive.call(executeSingleFunctions, [], mockResponse); - - expect(mockPrepareBinaryData).toHaveBeenCalledWith( - mockResponse.body, - 'encoded name.pdf', - 'application/pdf', + it.each([undefined, '', ' '])('rejects a missing or blank name (%j)', (fileName) => { + expect(() => validateSharePointFileName(node, fileName as string | undefined, 0)).toThrow( + 'File name must be set!', ); - expect(result).toEqual([ - { - json: {}, - binary: { - data: { - data: '', - mimeType: 'application/pdf', - fileName: 'encoded name.pdf', - }, - }, - }, - ]); }); - describe('escapeFilterValue', () => { - it('should escape single quotes', () => { - expect(escapeFilterValue("hello' there ''")).toEqual("hello'' there ''''"); - }); - it('should not escape double quotes', () => { - expect(escapeFilterValue('hello " there ""')).toEqual('hello " there ""'); - }); + it.each(SHAREPOINT_ILLEGAL_FILE_NAME_CHARS)('rejects a name containing %s', (char) => { + expect(() => validateSharePointFileName(node, `a${char}b.txt`, 0)).toThrow( + `contains characters that SharePoint doesn't allow: ${char}`, + ); }); - describe('itemColumnsPreSend', () => { - const apiRequest = microsoftSharePointApiRequest as Mock; - it('should keep a quote in a matching value inside the OData literal', async () => { - const params: Record = { - columns: { - mappingMode: 'defineBelow', - matchingColumns: ['Title'], - value: { Title: "O'Brien" }, - schema: [], - }, - operation: 'update', - site: 'site1', - list: 'list1', - }; - executeSingleFunctions.getNodeParameter.mockImplementation( - (name: string) => params[name] as never, - ); - apiRequest.mockResolvedValueOnce({ value: [{ id: 'item1' }] }); - const requestOptions: IHttpRequestOptions = { - method: 'PATCH', - url: '/sites/site1/lists/list1/items', - }; + it('names every offending character at once', () => { + expect(() => validateSharePointFileName(node, 'a:b*c.txt', 0)).toThrow( + "contains characters that SharePoint doesn't allow: * :", + ); + }); - await itemColumnsPreSend.call(executeSingleFunctions, requestOptions); + it('suggests a colon-free timestamp format only when a colon is present', () => { + try { + validateSharePointFileName(node, 'report 12:30.pdf', 0); + expect.unreachable('should have thrown'); + } catch (error) { + expect((error as { description?: string }).description).toContain('colon-free format'); + } - expect(apiRequest).toHaveBeenCalledWith( - 'GET', - '/sites/site1/lists/list1/items', - {}, - { $filter: "fields/Title eq 'O''Brien'" }, - { Prefer: 'HonorNonIndexedQueriesWarningMayFailRandomly' }, - ); - }); - - // The operations set `multiKeyMatch: false`, so the UI never sends a second - // matching column. Call the hook directly to cover the join. - it('should join two matching column clauses with a space', async () => { - const params: Record = { - columns: { - mappingMode: 'defineBelow', - matchingColumns: ['Title', 'Status'], - value: { Title: 'A', Status: 'B' }, - schema: [], - }, - operation: 'update', - site: 'site1', - list: 'list1', - }; - executeSingleFunctions.getNodeParameter.mockImplementation( - (name: string) => params[name] as never, - ); - apiRequest.mockResolvedValueOnce({ value: [{ id: 'item1' }] }); - const requestOptions: IHttpRequestOptions = { - method: 'PATCH', - url: '/sites/site1/lists/list1/items', - }; - - await itemColumnsPreSend.call(executeSingleFunctions, requestOptions); - - expect(apiRequest).toHaveBeenCalledWith( - 'GET', - '/sites/site1/lists/list1/items', - {}, - { $filter: "fields/Title eq 'A' and fields/Status eq 'B'" }, - { Prefer: 'HonorNonIndexedQueriesWarningMayFailRandomly' }, - ); - }); + try { + validateSharePointFileName(node, 'report?.pdf', 0); + expect.unreachable('should have thrown'); + } catch (error) { + expect((error as { description?: string }).description).not.toContain('colon-free format'); + } + }); +}); + +describe('Microsoft SharePoint v2 — odataFieldEqualsClause', () => { + it('quotes the value as a string literal', () => { + expect(odataFieldEqualsClause('Title', 'Report')).toBe("fields/Title eq 'Report'"); + }); + + it('doubles single quotes inside the value', () => { + expect(odataFieldEqualsClause('Author', "O'Brien")).toBe("fields/Author eq 'O''Brien'"); + expect(odataFieldEqualsClause('Note', "a'b'c")).toBe("fields/Note eq 'a''b''c'"); + }); + + it('keeps a value that is only quotes intact', () => { + expect(odataFieldEqualsClause('Note', "''")).toBe("fields/Note eq ''''''"); + }); + + it.each([null, undefined])('compares %s against the empty string, like v1', (value) => { + expect(odataFieldEqualsClause('Title', value)).toBe("fields/Title eq ''"); + }); + + it('stringifies non-string values', () => { + expect(odataFieldEqualsClause('Count', 3)).toBe("fields/Count eq '3'"); + expect(odataFieldEqualsClause('Done', false)).toBe("fields/Done eq 'false'"); + }); + + it('leaves characters the URL layer owns untouched', () => { + expect(odataFieldEqualsClause('Title', 'a & b % c + d')).toBe( + "fields/Title eq 'a & b % c + d'", + ); }); }); diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v1/helpers/utils.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v1/helpers/utils.test.ts new file mode 100644 index 00000000000..7bbacad5f0c --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v1/helpers/utils.test.ts @@ -0,0 +1,142 @@ +import type { Mock } from 'vitest'; +import type { MockProxy } from 'vitest-mock-extended'; +import { mock } from 'vitest-mock-extended'; +import type { IBinaryData, IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow'; + +import { + downloadFilePostReceive, + escapeFilterValue, + itemColumnsPreSend, +} from '../../../v1/helpers/utils'; +import { microsoftSharePointApiRequest } from '../../../v1/transport'; + +vi.mock('../../../v1/transport', () => ({ + microsoftSharePointApiRequest: vi.fn(), +})); + +describe('Microsoft SharePoint Node', () => { + let executeSingleFunctions: MockProxy; + + beforeEach(() => { + executeSingleFunctions = mock(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should download file post receive', async () => { + const mockResponse = { + body: '', + statusCode: 200, + headers: { + 'content-disposition': "attachment; filename*=UTF-8''encoded%20name.pdf", + 'content-type': 'application/pdf', + }, + }; + const mockPrepareBinaryData = vi.fn().mockReturnValueOnce({ + data: '', + mimeType: 'application/pdf', + fileName: 'encoded name.pdf', + } as IBinaryData); + executeSingleFunctions.helpers.prepareBinaryData = mockPrepareBinaryData; + + const result = await downloadFilePostReceive.call(executeSingleFunctions, [], mockResponse); + + expect(mockPrepareBinaryData).toHaveBeenCalledWith( + mockResponse.body, + 'encoded name.pdf', + 'application/pdf', + ); + expect(result).toEqual([ + { + json: {}, + binary: { + data: { + data: '', + mimeType: 'application/pdf', + fileName: 'encoded name.pdf', + }, + }, + }, + ]); + }); + + describe('escapeFilterValue', () => { + it('should escape single quotes', () => { + expect(escapeFilterValue("hello' there ''")).toEqual("hello'' there ''''"); + }); + it('should not escape double quotes', () => { + expect(escapeFilterValue('hello " there ""')).toEqual('hello " there ""'); + }); + }); + describe('itemColumnsPreSend', () => { + const apiRequest = microsoftSharePointApiRequest as Mock; + + it('should keep a quote in a matching value inside the OData literal', async () => { + const params: Record = { + columns: { + mappingMode: 'defineBelow', + matchingColumns: ['Title'], + value: { Title: "O'Brien" }, + schema: [], + }, + operation: 'update', + site: 'site1', + list: 'list1', + }; + executeSingleFunctions.getNodeParameter.mockImplementation( + (name: string) => params[name] as never, + ); + apiRequest.mockResolvedValueOnce({ value: [{ id: 'item1' }] }); + const requestOptions: IHttpRequestOptions = { + method: 'PATCH', + url: '/sites/site1/lists/list1/items', + }; + + await itemColumnsPreSend.call(executeSingleFunctions, requestOptions); + + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/sites/site1/lists/list1/items', + {}, + { $filter: "fields/Title eq 'O''Brien'" }, + { Prefer: 'HonorNonIndexedQueriesWarningMayFailRandomly' }, + ); + }); + + // The operations set `multiKeyMatch: false`, so the UI never sends a second + // matching column. Call the hook directly to cover the join. + it('should join two matching column clauses with a space', async () => { + const params: Record = { + columns: { + mappingMode: 'defineBelow', + matchingColumns: ['Title', 'Status'], + value: { Title: 'A', Status: 'B' }, + schema: [], + }, + operation: 'update', + site: 'site1', + list: 'list1', + }; + executeSingleFunctions.getNodeParameter.mockImplementation( + (name: string) => params[name] as never, + ); + apiRequest.mockResolvedValueOnce({ value: [{ id: 'item1' }] }); + const requestOptions: IHttpRequestOptions = { + method: 'PATCH', + url: '/sites/site1/lists/list1/items', + }; + + await itemColumnsPreSend.call(executeSingleFunctions, requestOptions); + + expect(apiRequest).toHaveBeenCalledWith( + 'GET', + '/sites/site1/lists/list1/items', + {}, + { $filter: "fields/Title eq 'A' and fields/Status eq 'B'" }, + { Prefer: 'HonorNonIndexedQueriesWarningMayFailRandomly' }, + ); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/file.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/file.test.ts index 30740f3c7c4..4f64732c850 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/file.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/file.test.ts @@ -5,7 +5,7 @@ import { mock, mockDeep } from 'vitest-mock-extended'; import { versionDescription } from '../../v2/actions/versionDescription'; import { fileRLC, getFiles } from '../../v2/file'; -import { SEARCH_PAGE_LIMIT } from '../../v2/helpers/graphSearch'; +import { SEARCH_PAGE_LIMIT } from '../../helpers/graphSearch'; import { MicrosoftSharePointV2 } from '../../v2/MicrosoftSharePointV2.node'; import * as transport from '../../transport'; import type * as _importType0 from '../../transport'; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/file/update.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/file/update.test.ts index 5d5cdb6f981..5bc07751e1a 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/file/update.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/file/update.test.ts @@ -8,7 +8,7 @@ import { execute } from '../../../v2/actions/file/update.operation'; import { MAX_SIMPLE_UPLOAD_BYTES, SHAREPOINT_ILLEGAL_FILE_NAME_CHARS, -} from '../../../v2/helpers/utils'; +} from '../../../helpers/utils'; import * as transport from '../../../transport'; import type * as _importType0 from '../../../transport'; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/file/upload.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/file/upload.test.ts index fddf41f5b58..0b965719d38 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/file/upload.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/file/upload.test.ts @@ -8,7 +8,7 @@ import { versionDescription } from '../../../v2/actions/versionDescription'; import { MAX_SIMPLE_UPLOAD_BYTES, SHAREPOINT_ILLEGAL_FILE_NAME_CHARS, -} from '../../../v2/helpers/utils'; +} from '../../../helpers/utils'; import { MicrosoftSharePointV2 } from '../../../v2/MicrosoftSharePointV2.node'; import * as transport from '../../../transport'; import type * as _importType0 from '../../../transport'; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/folder.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/folder.test.ts index 3e1161cbb4e..036c2018702 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/folder.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/folder.test.ts @@ -5,7 +5,7 @@ import { mock, mockDeep } from 'vitest-mock-extended'; import { versionDescription } from '../../v2/actions/versionDescription'; import { folderRLC, getFolders } from '../../v2/folder'; -import { SEARCH_PAGE_LIMIT } from '../../v2/helpers/graphSearch'; +import { SEARCH_PAGE_LIMIT } from '../../helpers/graphSearch'; import { MicrosoftSharePointV2 } from '../../v2/MicrosoftSharePointV2.node'; import * as transport from '../../transport'; import type * as _importType0 from '../../transport'; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/helpers/utils.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/helpers/utils.test.ts deleted file mode 100644 index 3dd69b01545..00000000000 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/helpers/utils.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { INode } from 'n8n-workflow'; -import { mock } from 'vitest-mock-extended'; - -import { - odataFieldEqualsClause, - SHAREPOINT_ILLEGAL_FILE_NAME_CHARS, - validateSharePointFileName, -} from '../../../v2/helpers/utils'; - -describe('Microsoft SharePoint v2 — validateSharePointFileName', () => { - const node = mock(); - - it('accepts an ordinary file name', () => { - expect(() => validateSharePointFileName(node, 'report.pdf', 0)).not.toThrow(); - }); - - it.each([undefined, '', ' '])('rejects a missing or blank name (%j)', (fileName) => { - expect(() => validateSharePointFileName(node, fileName as string | undefined, 0)).toThrow( - 'File name must be set!', - ); - }); - - it.each(SHAREPOINT_ILLEGAL_FILE_NAME_CHARS)('rejects a name containing %s', (char) => { - expect(() => validateSharePointFileName(node, `a${char}b.txt`, 0)).toThrow( - `contains characters that SharePoint doesn't allow: ${char}`, - ); - }); - - it('names every offending character at once', () => { - expect(() => validateSharePointFileName(node, 'a:b*c.txt', 0)).toThrow( - "contains characters that SharePoint doesn't allow: * :", - ); - }); - - it('suggests a colon-free timestamp format only when a colon is present', () => { - try { - validateSharePointFileName(node, 'report 12:30.pdf', 0); - expect.unreachable('should have thrown'); - } catch (error) { - expect((error as { description?: string }).description).toContain('colon-free format'); - } - - try { - validateSharePointFileName(node, 'report?.pdf', 0); - expect.unreachable('should have thrown'); - } catch (error) { - expect((error as { description?: string }).description).not.toContain('colon-free format'); - } - }); -}); - -describe('Microsoft SharePoint v2 — odataFieldEqualsClause', () => { - it('quotes the value as a string literal', () => { - expect(odataFieldEqualsClause('Title', 'Report')).toBe("fields/Title eq 'Report'"); - }); - - it('doubles single quotes inside the value', () => { - expect(odataFieldEqualsClause('Author', "O'Brien")).toBe("fields/Author eq 'O''Brien'"); - expect(odataFieldEqualsClause('Note', "a'b'c")).toBe("fields/Note eq 'a''b''c'"); - }); - - it('keeps a value that is only quotes intact', () => { - expect(odataFieldEqualsClause('Note', "''")).toBe("fields/Note eq ''''''"); - }); - - it.each([null, undefined])('compares %s against the empty string, like v1', (value) => { - expect(odataFieldEqualsClause('Title', value)).toBe("fields/Title eq ''"); - }); - - it('stringifies non-string values', () => { - expect(odataFieldEqualsClause('Count', 3)).toBe("fields/Count eq '3'"); - expect(odataFieldEqualsClause('Done', false)).toBe("fields/Done eq 'false'"); - }); - - it('leaves characters the URL layer owns untouched', () => { - expect(odataFieldEqualsClause('Title', 'a & b % c + d')).toBe( - "fields/Title eq 'a & b % c + d'", - ); - }); -}); diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/item/get.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/item/get.test.ts index c9d4fc7cc66..038ca0bbc4b 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/item/get.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/item/get.test.ts @@ -4,7 +4,7 @@ import type { DeepMockProxy } from 'vitest-mock-extended'; import { mock, mockDeep } from 'vitest-mock-extended'; import { versionDescription } from '../../../v2/actions/versionDescription'; -import { ITEM_SIMPLIFY_EXPAND, ITEM_SIMPLIFY_SELECT } from '../../../v2/helpers/utils'; +import { ITEM_SIMPLIFY_EXPAND, ITEM_SIMPLIFY_SELECT } from '../../../helpers/utils'; import { MicrosoftSharePointV2 } from '../../../v2/MicrosoftSharePointV2.node'; import * as transport from '../../../transport'; import type * as _importType0 from '../../../transport'; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/item/getAll.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/item/getAll.test.ts index fd98e93340a..7fce2f236d0 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/item/getAll.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/item/getAll.test.ts @@ -3,7 +3,7 @@ import type { DeepMockProxy } from 'vitest-mock-extended'; import { mock, mockDeep } from 'vitest-mock-extended'; import { versionDescription } from '../../../v2/actions/versionDescription'; -import { ITEM_SIMPLIFY_EXPAND, ITEM_SIMPLIFY_SELECT } from '../../../v2/helpers/utils'; +import { ITEM_SIMPLIFY_EXPAND, ITEM_SIMPLIFY_SELECT } from '../../../helpers/utils'; import { MicrosoftSharePointV2 } from '../../../v2/MicrosoftSharePointV2.node'; // microsoftApiRequestAllItems calls microsoftApiRequest as a same-module diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/list/get.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/list/get.test.ts index 826500b9e1f..d47a08cb9ed 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/list/get.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/list/get.test.ts @@ -5,7 +5,7 @@ import type { DeepMockProxy } from 'vitest-mock-extended'; import { mock, mockDeep } from 'vitest-mock-extended'; import { versionDescription } from '../../../v2/actions/versionDescription'; -import { LIST_SIMPLIFY_SELECT } from '../../../v2/helpers/utils'; +import { LIST_SIMPLIFY_SELECT } from '../../../helpers/utils'; import { MicrosoftSharePointV2 } from '../../../v2/MicrosoftSharePointV2.node'; import * as transport from '../../../transport'; import type * as _importType0 from '../../../transport'; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/list/getAll.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/list/getAll.test.ts index 1fd0ca0ffb1..2bb95768c2d 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/list/getAll.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/list/getAll.test.ts @@ -3,7 +3,7 @@ import type { DeepMockProxy } from 'vitest-mock-extended'; import { mock, mockDeep } from 'vitest-mock-extended'; import { versionDescription } from '../../../v2/actions/versionDescription'; -import { LIST_SIMPLIFY_SELECT } from '../../../v2/helpers/utils'; +import { LIST_SIMPLIFY_SELECT } from '../../../helpers/utils'; import { MicrosoftSharePointV2 } from '../../../v2/MicrosoftSharePointV2.node'; // microsoftApiRequestAllItems calls microsoftApiRequest as a same-module diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/site.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/site.test.ts index 71db612a9e9..e6634b42802 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/site.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/v2/site.test.ts @@ -6,7 +6,7 @@ import { mock, mockDeep } from 'vitest-mock-extended'; import { versionDescription } from '../../v2/actions/versionDescription'; import { MicrosoftSharePointV2 } from '../../v2/MicrosoftSharePointV2.node'; -import { getSites, resolveSiteId, SITE_ID_REGEX, siteRLC } from '../../v2/site'; +import { getSites, resolveSiteId, SITE_ID_REGEX, siteRLC } from '../../site'; import * as transport from '../../transport'; import type * as _importType0 from '../../transport'; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/download.operation.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/download.operation.ts index 2d866257bfa..323f4ab08da 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/download.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/download.operation.ts @@ -10,7 +10,7 @@ import { updateDisplayOptions } from '../../../../../../utils/utilities'; import { fileRLC } from '../../file'; import { untilFolderSelected, folderRLC } from '../../folder'; import { untilSiteSelected } from '../../list'; -import { resolveSiteId, siteRLC } from '../../site'; +import { resolveSiteId, siteRLC } from '../../../site'; import { microsoftApiRequest } from '../../../transport'; const properties: INodeProperties[] = [ diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/update.operation.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/update.operation.ts index 252ca9f2603..f62a49aa77d 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/update.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/update.operation.ts @@ -4,9 +4,9 @@ import { NodeOperationError } from 'n8n-workflow'; import { updateDisplayOptions } from '../../../../../../utils/utilities'; import { fileRLC } from '../../file'; import { untilFolderSelected, folderRLC } from '../../folder'; -import { getUploadBufferWithinCap, validateSharePointFileName } from '../../helpers/utils'; +import { getUploadBufferWithinCap, validateSharePointFileName } from '../../../helpers/utils'; import { untilSiteSelected } from '../../list'; -import { resolveSiteId, siteRLC } from '../../site'; +import { resolveSiteId, siteRLC } from '../../../site'; import { microsoftApiRequest } from '../../../transport'; const properties: INodeProperties[] = [ diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/upload.operation.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/upload.operation.ts index 315d85945fd..c848d64cb39 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/upload.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/file/upload.operation.ts @@ -3,9 +3,9 @@ import { NodeOperationError } from 'n8n-workflow'; import { updateDisplayOptions } from '../../../../../../utils/utilities'; import { folderRLC } from '../../folder'; -import { getUploadBufferWithinCap, validateSharePointFileName } from '../../helpers/utils'; +import { getUploadBufferWithinCap, validateSharePointFileName } from '../../../helpers/utils'; import { untilSiteSelected } from '../../list'; -import { resolveSiteId, siteRLC } from '../../site'; +import { resolveSiteId, siteRLC } from '../../../site'; import { microsoftApiRequest } from '../../../transport'; const properties: INodeProperties[] = [ diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/create.operation.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/create.operation.ts index fae0f288b70..3fd5677b6a2 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/create.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/create.operation.ts @@ -10,11 +10,11 @@ import { addUniqueConstraintHint, assertPathSegment, HYPERLINK_WRITE_HEADERS, -} from '../../helpers/utils'; +} from '../../../helpers/utils'; import { buildItemFieldsPayload, resolveItemMapperValues } from '../../item'; import { listRLC, untilSiteSelected } from '../../list'; import { itemColumns } from '../../list/columns'; -import { resolveSiteId, siteRLC } from '../../site'; +import { resolveSiteId, siteRLC } from '../../../site'; import { microsoftApiRequest } from '../../../transport'; const properties: INodeProperties[] = [ diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/delete.operation.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/delete.operation.ts index 4bdc3a5c707..5064f4cc71b 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/delete.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/delete.operation.ts @@ -1,10 +1,10 @@ import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow'; import { updateDisplayOptions } from '../../../../../../utils/utilities'; -import { assertPathSegment } from '../../helpers/utils'; +import { assertPathSegment } from '../../../helpers/utils'; import { itemRLC, untilListSelected } from '../../item'; import { listRLC, untilSiteSelected } from '../../list'; -import { resolveSiteId, siteRLC } from '../../site'; +import { resolveSiteId, siteRLC } from '../../../site'; import { microsoftApiRequest } from '../../../transport'; const properties: INodeProperties[] = [ diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/get.operation.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/get.operation.ts index 7704c2943d3..8182552a0a3 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/get.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/get.operation.ts @@ -6,10 +6,10 @@ import { ITEM_SIMPLIFY_EXPAND, ITEM_SIMPLIFY_SELECT, simplifyItem, -} from '../../helpers/utils'; +} from '../../../helpers/utils'; import { itemRLC, untilListSelected } from '../../item'; import { listRLC, untilSiteSelected } from '../../list'; -import { resolveSiteId, siteRLC } from '../../site'; +import { resolveSiteId, siteRLC } from '../../../site'; import { microsoftApiRequest } from '../../../transport'; const properties: INodeProperties[] = [ diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/getAll.operation.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/getAll.operation.ts index 0b82a7f7ed3..bb3e24a5706 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/getAll.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/getAll.operation.ts @@ -9,9 +9,9 @@ import { NON_INDEXED_QUERY_HEADERS, nonIndexedFilterThresholdError, simplifyItem, -} from '../../helpers/utils'; +} from '../../../helpers/utils'; import { listRLC, untilSiteSelected } from '../../list'; -import { resolveSiteId, siteRLC } from '../../site'; +import { resolveSiteId, siteRLC } from '../../../site'; import { microsoftApiRequestAllItems } from '../../../transport'; const properties: INodeProperties[] = [ diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/update.operation.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/update.operation.ts index 846882332cc..c9fbf799edf 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/update.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/update.operation.ts @@ -7,11 +7,11 @@ import type { import { NodeOperationError } from 'n8n-workflow'; import { updateDisplayOptions } from '../../../../../../utils/utilities'; -import { assertPathSegment } from '../../helpers/utils'; +import { assertPathSegment } from '../../../helpers/utils'; import { resolveItemMapperValues, resolveMatchedItemIds, updateItemFields } from '../../item'; import { listRLC, untilSiteSelected } from '../../list'; import { itemColumns } from '../../list/columns'; -import { resolveSiteId, siteRLC } from '../../site'; +import { resolveSiteId, siteRLC } from '../../../site'; const properties: INodeProperties[] = [ { diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/upsert.operation.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/upsert.operation.ts index 7fe75eaf5e1..3e659c42900 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/upsert.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/item/upsert.operation.ts @@ -8,11 +8,11 @@ import { NodeOperationError } from 'n8n-workflow'; import * as create from './create.operation'; import { updateDisplayOptions } from '../../../../../../utils/utilities'; -import { assertPathSegment } from '../../helpers/utils'; +import { assertPathSegment } from '../../../helpers/utils'; import { resolveItemMapperValues, resolveMatchedItemIds, updateItemFields } from '../../item'; import { listRLC, untilSiteSelected } from '../../list'; import { itemColumns } from '../../list/columns'; -import { resolveSiteId, siteRLC } from '../../site'; +import { resolveSiteId, siteRLC } from '../../../site'; const properties: INodeProperties[] = [ { diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/list/get.operation.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/list/get.operation.ts index b63b23db7ec..6a51db16681 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/list/get.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/list/get.operation.ts @@ -2,9 +2,9 @@ import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workfl import { NodeOperationError } from 'n8n-workflow'; import { updateDisplayOptions } from '../../../../../../utils/utilities'; -import { LIST_SIMPLIFY_SELECT } from '../../helpers/utils'; +import { LIST_SIMPLIFY_SELECT } from '../../../helpers/utils'; import { listRLC, untilSiteSelected } from '../../list'; -import { resolveSiteId, siteRLC } from '../../site'; +import { resolveSiteId, siteRLC } from '../../../site'; import { microsoftApiRequest } from '../../../transport'; const properties: INodeProperties[] = [ diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/list/getAll.operation.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/list/getAll.operation.ts index a9acfd8a9a2..b43198630dc 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/list/getAll.operation.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/actions/list/getAll.operation.ts @@ -2,8 +2,8 @@ import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workfl import { returnAllOrLimit } from '../../../../../../utils/descriptions'; import { updateDisplayOptions } from '../../../../../../utils/utilities'; -import { LIST_SIMPLIFY_SELECT } from '../../helpers/utils'; -import { resolveSiteId, siteRLC } from '../../site'; +import { LIST_SIMPLIFY_SELECT } from '../../../helpers/utils'; +import { resolveSiteId, siteRLC } from '../../../site'; import { microsoftApiRequestAllItems } from '../../../transport'; const properties: INodeProperties[] = [ diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/file/index.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/file/index.ts index a079e6d527d..66bc535af94 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/file/index.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/file/index.ts @@ -1,8 +1,8 @@ import type { ILoadOptionsFunctions, INodeListSearchResult, INodeProperties } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; -import { searchDriveItems } from '../helpers/graphSearch'; -import { resolveSiteId } from '../site'; +import { searchDriveItems } from '../../helpers/graphSearch'; +import { resolveSiteId } from '../../site'; // The whole file-selection piece lives here — the field and the search behind // it — mirroring the site and folder modules. diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/folder/index.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/folder/index.ts index 9bc18fac926..504456ba33e 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/folder/index.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/folder/index.ts @@ -1,7 +1,7 @@ import type { ILoadOptionsFunctions, INodeListSearchResult, INodeProperties } from 'n8n-workflow'; -import { searchDriveItems } from '../helpers/graphSearch'; -import { resolveSiteId } from '../site'; +import { searchDriveItems } from '../../helpers/graphSearch'; +import { resolveSiteId } from '../../site'; /** Hide gate: file fields stay hidden until a folder is chosen. */ export const untilFolderSelected = { folder: [''] }; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/item/index.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/item/index.ts index 722f2dece8f..8efbd3035dd 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/item/index.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/item/index.ts @@ -8,7 +8,7 @@ import type { } from 'n8n-workflow'; import { NodeOperationError, setSafeObjectProperty } from 'n8n-workflow'; -import { type CollectionSearchOptions, searchGraphCollection } from '../helpers/graphSearch'; +import { type CollectionSearchOptions, searchGraphCollection } from '../../helpers/graphSearch'; import { addUniqueConstraintHint, assertPathSegment, @@ -16,8 +16,8 @@ import { NON_INDEXED_QUERY_HEADERS, nonIndexedFilterThresholdError, odataFieldEqualsClause, -} from '../helpers/utils'; -import { resolveSiteId } from '../site'; +} from '../../helpers/utils'; +import { resolveSiteId } from '../../site'; import { microsoftApiRequest, microsoftApiRequestAllItems } from '../../transport'; /** Keeps the item field hidden until a list is chosen. */ diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/list/columns.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/list/columns.ts index 98535f8eb90..6310f9a4a7c 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/list/columns.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/list/columns.ts @@ -6,8 +6,8 @@ import type { ResourceMapperFields, } from 'n8n-workflow'; -import { assertPathSegment } from '../helpers/utils'; -import { resolveSiteId } from '../site'; +import { assertPathSegment } from '../../helpers/utils'; +import { resolveSiteId } from '../../site'; import { microsoftApiRequest } from '../../transport'; import { untilListSelected, untilSiteSelected } from './index'; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/list/index.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/list/index.ts index d10746b0218..fde237db4d7 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/list/index.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/list/index.ts @@ -1,7 +1,7 @@ import type { ILoadOptionsFunctions, INodeListSearchResult, INodeProperties } from 'n8n-workflow'; -import { type GraphSearchReply } from '../helpers/utils'; -import { resolveSiteId } from '../site'; +import { type GraphSearchReply } from '../../helpers/utils'; +import { resolveSiteId } from '../../site'; import { microsoftApiRequest } from '../../transport'; /** Hide gate copied from v1: the list field stays hidden until a site is chosen. */ @@ -10,7 +10,7 @@ export const untilSiteSelected = { site: [''] }; export const untilListSelected = { list: [''] }; // Colocated with getLists/resolveSiteId, mirroring how siteRLC lives next to -// getSites/resolveSiteId in v2/site/index.ts, rather than a separate registry. +// getSites/resolveSiteId in site/index.ts, rather than a separate registry. export const listRLC: INodeProperties = { displayName: 'List', name: 'list', diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/methods/index.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/methods/index.ts index 1acd8e2b6a6..a2df1b6acc5 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/v2/methods/index.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/v2/methods/index.ts @@ -1,10 +1,10 @@ -import { getDrives } from '../drive'; +import { getDrives } from '../../drive'; import { getFiles } from '../file'; import { getFolders } from '../folder'; import { getItems } from '../item'; import { getLists } from '../list'; import { getMappingColumns } from '../list/columns'; -import { getSites } from '../site'; +import { getSites } from '../../site'; export const listSearch = { getFiles, From 71ffb4c8ccef8e4ca45fe088d5f4561396823861 Mon Sep 17 00:00:00 2001 From: Stephen Wright Date: Tue, 6 Oct 2026 09:11:25 +0000 Subject: [PATCH 13/61] feat(Microsoft SharePoint Node): Add site and document library selection to the trigger (no-changelog) (#39943) --- .../MicrosoftSharePointTrigger.node.ts | 93 +++++++++++++++++++ .../MicrosoftSharePointTrigger.node.test.ts | 49 ++++++++++ 2 files changed, 142 insertions(+) diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/MicrosoftSharePointTrigger.node.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/MicrosoftSharePointTrigger.node.ts index 2a2d084325e..b9022baf798 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/MicrosoftSharePointTrigger.node.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/MicrosoftSharePointTrigger.node.ts @@ -6,8 +6,16 @@ import { NodeConnectionTypes, } from 'n8n-workflow'; +import { getDrives } from './drive'; +import { getSites, SITE_ID_REGEX } from './site'; import { SERVICE_PRINCIPAL_AUTH } from './transport'; +const GUID = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'; +// Rejects a bare GUID, which is a list ID: the one value likely to land in this +// field by mistake. Nothing further is asserted about a drive ID, because +// rejecting a legitimate one is the worse failure. +const DRIVE_ID_REGEX = `^(?!${GUID}$)\\S+$`; + export class MicrosoftSharePointTrigger implements INodeType { description: INodeTypeDescription = { displayName: 'Microsoft SharePoint Trigger', @@ -74,9 +82,94 @@ export class MicrosoftSharePointTrigger implements INodeType { ], default: 'microsoftOAuth2Api', }, + { + displayName: 'Site', + name: 'site', + type: 'resourceLocator', + required: true, + default: { mode: 'list', value: '' }, + description: 'The SharePoint site holding the library to watch', + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { searchListMethod: 'getSites', searchable: true }, + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'e.g. https://contoso.sharepoint.com/sites/mysite', + validation: [ + { + type: 'regex', + properties: { + regex: '^https://.+', + errorMessage: 'The URL must start with https://', + }, + }, + ], + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. contoso.sharepoint.com,5a58bb09-…,9f0d…', + validation: [ + { + type: 'regex', + properties: { + regex: SITE_ID_REGEX, + errorMessage: + 'Use the ID from the site picker or Graph (hostname,GUID,GUID), a site GUID, a hostname, or "root". For a site address, switch the field to URL mode.', + }, + }, + ], + }, + ], + }, + { + displayName: 'Document Library', + name: 'drive', + type: 'resourceLocator', + required: true, + default: { mode: 'list', value: '' }, + description: 'The document library to watch for changes', + displayOptions: { hide: { site: [''] } }, + typeOptions: { loadOptionsDependsOn: ['site.value'] }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { searchListMethod: 'getDrives', searchable: true }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. b!zXyF9k…', + validation: [ + { + type: 'regex', + properties: { + regex: DRIVE_ID_REGEX, + errorMessage: + "That looks like a list ID. This field needs the library's drive ID, which the picker supplies.", + }, + }, + ], + }, + ], + }, ], }; + methods = { + listSearch: { getSites, getDrives }, + }; + async poll(this: IPollFunctions): Promise { return null; } diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/MicrosoftSharePointTrigger.node.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/MicrosoftSharePointTrigger.node.test.ts index cc58841913f..b73cd4a573a 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/MicrosoftSharePointTrigger.node.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/MicrosoftSharePointTrigger.node.test.ts @@ -4,6 +4,8 @@ import { join } from 'path'; import { mock } from 'vitest-mock-extended'; import { MicrosoftSharePointTrigger } from '../MicrosoftSharePointTrigger.node'; +import { getDrives } from '../drive'; +import { getSites } from '../site'; import { microsoftApiRequest, SERVICE_PRINCIPAL_AUTH } from '../transport'; describe('Microsoft SharePoint Trigger', () => { @@ -80,6 +82,53 @@ describe('Microsoft SharePoint Trigger', () => { expect(codex.node).toBe('n8n-nodes-base.microsoftSharePointTrigger'); }); + describe('selection', () => { + const locator = (name: string) => description.properties.find((p) => p.name === name); + const modeNames = (name: string) => locator(name)?.modes?.map((m) => m.name); + const validationOf = (name: string, mode: string) => { + const found = locator(name)?.modes?.find((m) => m.name === mode); + const rule = found && 'validation' in found ? found.validation?.[0] : undefined; + return rule && 'properties' in rule + ? (rule.properties as { regex: string }).regex + : undefined; + }; + + it.each([ + ['site', ['list', 'url', 'id'], 'getSites'], + ['drive', ['list', 'id'], 'getDrives'], + ])('offers %s with a picker and a typed fallback', (name, modes, method) => { + expect(modeNames(name)).toEqual(modes); + const list = locator(name)?.modes?.find((m) => m.name === 'list'); + expect(list?.typeOptions?.searchListMethod).toBe(method); + }); + + it('registers both pickers on the node', () => { + const { methods } = new MicrosoftSharePointTrigger(); + + expect(methods.listSearch.getSites).toBe(getSites); + expect(methods.listSearch.getDrives).toBe(getDrives); + }); + + it('hides the library until a site is chosen', () => { + expect(locator('drive')?.displayOptions?.hide?.site).toEqual(['']); + expect(locator('drive')?.typeOptions?.loadOptionsDependsOn).toEqual(['site.value']); + }); + + it.each([ + ['b!zXyF9kabcdef-1234567890', true], + ['01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K', true], + // A bare GUID is a list ID in the wrong field + ['58a279af-1f06-4392-a5ed-2b37fa1d6c1d', false], + ['58A279AF-1F06-4392-A5ED-2B37FA1D6C1D', false], + // Only a bare one. A drive ID that merely starts GUID-shaped is fine. + ['58a279af-1f06-4392-a5ed-2b37fa1d6c1dXYZ', true], + ])('drive ID %s accepted: %s', (value, accepted) => { + const pattern = new RegExp(validationOf('drive', 'id') as string); + + expect(pattern.test(value)).toBe(accepted); + }); + }); + describe('authenticating a poll', () => { const pollContext = (authentication?: string) => { const ctx = mock(); From b7960d7a858a3e28c6a703a59721f5a029d8cc36 Mon Sep 17 00:00:00 2001 From: Stephen Wright Date: Tue, 6 Oct 2026 09:11:25 +0000 Subject: [PATCH 14/61] feat(Microsoft SharePoint Node): Add the event selector to the trigger (no-changelog) (#39987) --- .../MicrosoftSharePointTrigger.node.ts | 22 ++++++++ .../MicrosoftSharePointTrigger.node.test.ts | 53 ++++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/MicrosoftSharePointTrigger.node.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/MicrosoftSharePointTrigger.node.ts index b9022baf798..31350ea9ab8 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/MicrosoftSharePointTrigger.node.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/MicrosoftSharePointTrigger.node.ts @@ -163,6 +163,28 @@ export class MicrosoftSharePointTrigger implements INodeType { }, ], }, + { + displayName: 'Events', + name: 'events', + type: 'multiOptions', + required: true, + default: ['changed', 'deleted'], + description: 'Which changes in the library start the workflow', + options: [ + { + name: 'Changed', + value: 'changed', + description: + "A file was added, edited, renamed, or moved. Graph reports each item's latest state rather than every change, so a new file and an edited one arrive the same way.", + }, + { + name: 'Deleted', + value: 'deleted', + description: + 'A file was deleted. Graph drops most fields from a deletion entry, so it carries little beyond the ID.', + }, + ], + }, ], }; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/MicrosoftSharePointTrigger.node.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/MicrosoftSharePointTrigger.node.test.ts index b73cd4a573a..f994c9f2fb3 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/MicrosoftSharePointTrigger.node.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/MicrosoftSharePointTrigger.node.test.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'fs'; -import type { INode, IPollFunctions } from 'n8n-workflow'; +import type { INode, INodePropertyOptions, IPollFunctions } from 'n8n-workflow'; import { join } from 'path'; import { mock } from 'vitest-mock-extended'; @@ -129,6 +129,57 @@ describe('Microsoft SharePoint Trigger', () => { }); }); + describe('events', () => { + const events = description.properties.find((p) => p.name === 'events'); + const optionOf = (value: string) => + (events?.options ?? []).find((o) => 'value' in o && o.value === value) as + | INodePropertyOptions + | undefined; + + it('offers exactly Changed and Deleted, both on by default', () => { + expect(events?.type).toBe('multiOptions'); + expect(events?.required).toBe(true); + expect( + (events?.options ?? []).map((o) => ('value' in o ? [o.name, o.value] : undefined)), + ).toEqual([ + ['Changed', 'changed'], + ['Deleted', 'deleted'], + ]); + expect(events?.default).toEqual(['changed', 'deleted']); + }); + + it('warns that Changed cannot separate a new file from an edited one', () => { + expect(optionOf('changed')?.description).toMatch(/latest state/i); + }); + + it('warns that a deletion entry carries almost nothing', () => { + expect(optionOf('deleted')?.description).toMatch(/little beyond the ID/i); + }); + + it.each([ + // The loader prepends pollTimes to every polling node, so declaring one + // here would show the user two copies of the same field. + 'pollTimes', + // Entries are emitted as the feed sends them, so there is no shape to pick. + 'simplify', + // Graph cannot narrow either delta feed below the library root. + 'folder', + ])('declares no %s property', (name) => { + expect(description.properties.map((p) => p.name)).not.toContain(name); + }); + + it('promises no path field, which the drive delta feed never sends', () => { + const texts = description.properties.flatMap((property) => [ + property.description ?? '', + ...(property.options ?? []).map((option) => + 'description' in option ? (option.description ?? '') : '', + ), + ]); + + expect(texts.filter((text) => /\bpath\b/i.test(text))).toEqual([]); + }); + }); + describe('authenticating a poll', () => { const pollContext = (authentication?: string) => { const ctx = mock(); From 2d09135c6e5a5f68505c8245c5034d97480a9b7f Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Tue, 6 Oct 2026 09:14:52 +0000 Subject: [PATCH 15/61] feat(core): Persist trusted source metadata and discover it asynchronously (no-changelog) (#40048) Co-authored-by: Claude Fable 5.1 --- docs/generated/postgres-schema/README.md | 5 +- .../postgres-schema/public.trusted_source.md | 6 + .../public.trusted_source_identity.md | 3 + docs/generated/sqlite-schema/README.md | 5 +- .../generated/sqlite-schema/trusted_source.md | 8 +- .../sqlite-schema/trusted_source_identity.md | 3 + ...791276719782-AddMetadataToTrustedSource.ts | 28 ++ ...791276719782-AddMetadataToTrustedSource.ts | 11 + .../src/__tests__/trusted-source.test.ts | 184 +++++++- packages/@n8n/inbound-auth/src/contracts.ts | 9 +- .../@n8n/inbound-auth/src/trusted-source.ts | 76 ++-- .../inbound-auth-core.module.test.ts | 29 +- .../trusted-source-discovery.service.test.ts | 397 ++++++++++++++++++ .../trusted-source-discovery.task.test.ts | 25 ++ .../entities/trusted-source.entity.ts | 12 + .../repositories/trusted-source.repository.ts | 119 +++++- .../inbound-auth-core.module.ts | 30 ++ .../trusted-source-discovery.service.ts | 147 +++++++ .../trusted-source-discovery.task.ts | 28 ++ .../inbound-auth-core/trusted-source.store.ts | 125 ++++-- .../trusted-source.store.integration.test.ts | 216 +++++++++- 21 files changed, 1381 insertions(+), 85 deletions(-) create mode 100644 packages/@n8n/db/src/migrations/common/1791276719782-AddMetadataToTrustedSource.ts create mode 100644 packages/@n8n/db/src/migrations/sqlite/1791276719782-AddMetadataToTrustedSource.ts create mode 100644 packages/cli/src/modules/inbound-auth-core/__tests__/trusted-source-discovery.service.test.ts create mode 100644 packages/cli/src/modules/inbound-auth-core/__tests__/trusted-source-discovery.task.test.ts create mode 100644 packages/cli/src/modules/inbound-auth-core/trusted-source-discovery.service.ts create mode 100644 packages/cli/src/modules/inbound-auth-core/trusted-source-discovery.task.ts diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index abf8d2c0dce..d219fc46f2a 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -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 diff --git a/docs/generated/postgres-schema/public.trusted_source.md b/docs/generated/postgres-schema/public.trusted_source.md index 4f340f7b795..ef566fb0d72 100644 --- a/docs/generated/postgres-schema/public.trusted_source.md +++ b/docs/generated/postgres-schema/public.trusted_source.md @@ -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 diff --git a/docs/generated/postgres-schema/public.trusted_source_identity.md b/docs/generated/postgres-schema/public.trusted_source_identity.md index fa42dba349c..337610bac28 100644 --- a/docs/generated/postgres-schema/public.trusted_source_identity.md +++ b/docs/generated/postgres-schema/public.trusted_source_identity.md @@ -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 diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index d935caa4bc5..c59e4173d7a 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -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 diff --git a/docs/generated/sqlite-schema/trusted_source.md b/docs/generated/sqlite-schema/trusted_source.md index ea5221dfe21..60c4ba1ff8b 100644 --- a/docs/generated/sqlite-schema/trusted_source.md +++ b/docs/generated/sqlite-schema/trusted_source.md @@ -6,7 +6,7 @@ Table Definition ```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)) ``` @@ -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 diff --git a/docs/generated/sqlite-schema/trusted_source_identity.md b/docs/generated/sqlite-schema/trusted_source_identity.md index 2637d6acc24..5447514a68d 100644 --- a/docs/generated/sqlite-schema/trusted_source_identity.md +++ b/docs/generated/sqlite-schema/trusted_source_identity.md @@ -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 diff --git a/packages/@n8n/db/src/migrations/common/1791276719782-AddMetadataToTrustedSource.ts b/packages/@n8n/db/src/migrations/common/1791276719782-AddMetadataToTrustedSource.ts new file mode 100644 index 00000000000..d8da69c27cb --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1791276719782-AddMetadataToTrustedSource.ts @@ -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, + }); + } +} diff --git a/packages/@n8n/db/src/migrations/sqlite/1791276719782-AddMetadataToTrustedSource.ts b/packages/@n8n/db/src/migrations/sqlite/1791276719782-AddMetadataToTrustedSource.ts new file mode 100644 index 00000000000..89ad256ae90 --- /dev/null +++ b/packages/@n8n/db/src/migrations/sqlite/1791276719782-AddMetadataToTrustedSource.ts @@ -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; +} diff --git a/packages/@n8n/inbound-auth/src/__tests__/trusted-source.test.ts b/packages/@n8n/inbound-auth/src/__tests__/trusted-source.test.ts index f06f6eab5d1..ab3962c5be0 100644 --- a/packages/@n8n/inbound-auth/src/__tests__/trusted-source.test.ts +++ b/packages/@n8n/inbound-auth/src/__tests__/trusted-source.test.ts @@ -1,7 +1,10 @@ import { deriveCapabilities, + resolveOAuth2Endpoints, TrustedSourceMetadataSchema, type Capability, + type DiscoveryDocument, + type OAuth2Endpoints, type TrustedSourceMetadata, } from '../trusted-source'; import { @@ -29,6 +32,18 @@ const jwksDocument = { keys: [{ kid: 'k1', kty: 'RSA', use: 'sig', alg: 'RS256', n: 'AQAB', e: 'AQAB' }], }; +const config = ( + authentication: Record, + managedBy: ManagedBy = 'admin', +): TrustedSourceConfigLatest => + migrateToLatest( + trustedSourceConfigSchemaFor(managedBy).parse({ + version: 1, + authentication: { type: 'oauth2', ...authentication }, + surfaces: { 'public-api': {} }, + }), + ); + describe('TrustedSourceMetadataSchema', () => { it('accepts a v1 list with an OIDC document, an RFC 8414 document and a JWKS', () => { const parsed = TrustedSourceMetadataSchema.parse({ @@ -68,13 +83,31 @@ describe('TrustedSourceMetadataSchema', () => { }); }); + // The local server's documents carry http URLs in dev; https is the client's job at fetch time. + it.each([ + [ + 'an http issuer and jwks_uri in a metadata document', + [ + { + ...oidcDocument, + document: { + issuer: 'http://localhost:5678', + jwks_uri: 'http://localhost:5678/oauth2/jwks', + }, + }, + ], + ], + ['an http JWKS url', [{ ...jwksDocument, url: 'http://localhost:5678/oauth2/jwks' }]], + ])('accepts %s', (_label, documents) => { + expect(TrustedSourceMetadataSchema.safeParse({ version: 1, documents }).success).toBe(true); + }); + it.each([ ['an unknown document kind', [{ ...oidcDocument, kind: 'saml' }]], [ - 'a non-https jwks_uri in a metadata document', - [{ ...oidcDocument, document: { ...fullMetadata, jwks_uri: 'http://idp.example/keys' } }], + 'a jwks_uri that is not a URL', + [{ ...oidcDocument, document: { ...fullMetadata, jwks_uri: 'not a url' } }], ], - ['a non-https JWKS url', [{ ...jwksDocument, url: 'http://idp.example/keys' }]], ['a key without kty', [{ ...jwksDocument, keys: [{ kid: 'k1' }] }]], ['two documents of the same kind', [oidcDocument, oidcDocument]], ])('rejects %s', (_label, documents) => { @@ -89,18 +122,6 @@ describe('TrustedSourceMetadataSchema', () => { }); describe('deriveCapabilities', () => { - const config = ( - authentication: Record, - managedBy: ManagedBy = 'admin', - ): TrustedSourceConfigLatest => - migrateToLatest( - trustedSourceConfigSchemaFor(managedBy).parse({ - version: 1, - authentication: { type: 'oauth2', ...authentication }, - surfaces: { 'public-api': {} }, - }), - ); - type Fields = Partial>; const metadata = (documents: { oidc?: Fields; @@ -149,9 +170,15 @@ describe('deriveCapabilities', () => { ['verify-jwt'], ], [ - 'local-keystore grants verify-jwt without metadata', + 'local-keystore grants nothing without metadata', config({ keys: { kind: 'local-keystore' } }, 'system'), () => null, + [], + ], + [ + 'local-keystore with a discovered jwks_uri grants verify-jwt', + config({ keys: { kind: 'local-keystore' } }, 'system'), + () => metadata({ oauth2: { jwks_uri: `${issuer}/keys` } }), ['verify-jwt'], ], [ @@ -230,3 +257,128 @@ describe('deriveCapabilities', () => { expect([...deriveCapabilities(cfg, meta())].sort()).toEqual([...expected].sort()); }); }); + +describe('resolveOAuth2Endpoints', () => { + const oidc = (fields: Partial): DiscoveryDocument => ({ + kind: 'openid-configuration', + fetchedAt, + document: { issuer, ...fields }, + }); + const oauth2 = (fields: Partial): DiscoveryDocument => ({ + kind: 'oauth2-authorization-server', + fetchedAt, + document: { issuer, ...fields }, + }); + const manual = { + mode: 'manual', + jwksUri: `${issuer}/manual/keys`, + authorizationEndpoint: `${issuer}/manual/authorize`, + tokenEndpoint: `${issuer}/manual/token`, + }; + const allOidc = oidc({ + jwks_uri: `${issuer}/oidc/keys`, + authorization_endpoint: `${issuer}/oidc/authorize`, + token_endpoint: `${issuer}/oidc/token`, + }); + const allOauth2 = oauth2({ + jwks_uri: `${issuer}/rfc8414/keys`, + authorization_endpoint: `${issuer}/rfc8414/authorize`, + token_endpoint: `${issuer}/rfc8414/token`, + }); + + // Field by field: manual config wins, then the OIDC document, then the RFC 8414 document. + const cases: Array<[string, TrustedSourceConfigLatest, DiscoveryDocument[], OAuth2Endpoints]> = [ + [ + 'a manual jwksUri wins over both documents', + config({ discovery: { mode: 'manual', jwksUri: manual.jwksUri } }), + [allOidc, allOauth2], + { + jwksUri: manual.jwksUri, + authorizationEndpoint: `${issuer}/oidc/authorize`, + tokenEndpoint: `${issuer}/oidc/token`, + }, + ], + [ + 'a manual authorizationEndpoint wins over both documents', + config({ + discovery: { + mode: 'manual', + authorizationEndpoint: manual.authorizationEndpoint, + jwksUri: manual.jwksUri, + }, + }), + [allOidc, allOauth2], + { + jwksUri: manual.jwksUri, + authorizationEndpoint: manual.authorizationEndpoint, + tokenEndpoint: `${issuer}/oidc/token`, + }, + ], + [ + 'a manual tokenEndpoint wins over both documents', + config({ + discovery: { mode: 'manual', tokenEndpoint: manual.tokenEndpoint, jwksUri: manual.jwksUri }, + }), + [allOidc, allOauth2], + { + jwksUri: manual.jwksUri, + authorizationEndpoint: `${issuer}/oidc/authorize`, + tokenEndpoint: manual.tokenEndpoint, + }, + ], + [ + 'the OIDC document wins over the RFC 8414 document', + config({}), + [allOauth2, allOidc], + { + jwksUri: `${issuer}/oidc/keys`, + authorizationEndpoint: `${issuer}/oidc/authorize`, + tokenEndpoint: `${issuer}/oidc/token`, + }, + ], + [ + 'the RFC 8414 document fills what the OIDC document lacks', + config({}), + [oidc({ jwks_uri: `${issuer}/oidc/keys` }), allOauth2], + { + jwksUri: `${issuer}/oidc/keys`, + authorizationEndpoint: `${issuer}/rfc8414/authorize`, + tokenEndpoint: `${issuer}/rfc8414/token`, + }, + ], + [ + 'a field missing everywhere is undefined', + config({}), + [ + oidc({ jwks_uri: `${issuer}/oidc/keys` }), + oauth2({ token_endpoint: `${issuer}/rfc8414/token` }), + ], + { + jwksUri: `${issuer}/oidc/keys`, + authorizationEndpoint: undefined, + tokenEndpoint: `${issuer}/rfc8414/token`, + }, + ], + ['no documents and no manual config resolve to nothing', config({}), [], {}], + [ + 'a JWKS document carries no endpoints', + config({}), + [{ kind: 'jwks', fetchedAt, url: `${issuer}/keys`, keys: [] }], + {}, + ], + ]; + + it.each(cases)('%s', (_label, cfg, documents, expected) => { + expect(resolveOAuth2Endpoints(cfg, documents)).toEqual(expected); + }); + + it('resolves nothing for a non-oauth2 authentication', () => { + // Only `oauth2` exists today; a future member must not inherit OAuth2 endpoints. + const cfg = { + ...config({}), + authentication: { type: 'saml' }, + } as unknown as TrustedSourceConfigLatest; + + expect(resolveOAuth2Endpoints(cfg, [allOidc])).toEqual({}); + }); +}); diff --git a/packages/@n8n/inbound-auth/src/contracts.ts b/packages/@n8n/inbound-auth/src/contracts.ts index b4904bd86cc..ea95b864dc7 100644 --- a/packages/@n8n/inbound-auth/src/contracts.ts +++ b/packages/@n8n/inbound-auth/src/contracts.ts @@ -3,7 +3,7 @@ import type { AuthPrincipal, SecurityContext } from '@n8n/permissions'; import type { OAuthResourceGrant } from 'n8n-workflow'; import type { Extracted, Inbound, Result, Verified } from './pipeline'; -import type { TrustedSource } from './trusted-source'; +import type { AuthorizationServerMetadata, Jwk, TrustedSource } from './trusted-source'; import type { SurfaceId } from './trusted-source-config'; /** What a protected resource reads from the user. The `User` entity satisfies it. */ @@ -114,6 +114,13 @@ export abstract class TrustedSourceStore { abstract listBySurface(surface: SurfaceId): Promise; } +/** The running instance's own OAuth2 server, read in process so its source is discovered like any other. */ +export abstract class LocalAuthorizationServer { + abstract getMetadata(): Promise; + + abstract getJwks(): Promise<{ keys: Jwk[] }>; +} + export abstract class TrustedSourceGate { /** The worker path: re-take the sealed grant and the binding status without a token. */ abstract authorizeSealed(input: { diff --git a/packages/@n8n/inbound-auth/src/trusted-source.ts b/packages/@n8n/inbound-auth/src/trusted-source.ts index 56e63bde4b9..794e18e48a3 100644 --- a/packages/@n8n/inbound-auth/src/trusted-source.ts +++ b/packages/@n8n/inbound-auth/src/trusted-source.ts @@ -8,14 +8,8 @@ import type { export type TrustedSourceStatus = 'unchecked' | 'healthy' | 'error'; -// An admin-supplied endpoint is fetched server-side, so it must be https. -const HttpsUrl = z - .string() - .url() - .refine((url) => url.startsWith('https://'), { message: 'must be an https URL' }); - /** Only the fields the pipeline reads are typed; the rest passes through for later readers. */ -const JwkSchema = z +export const JwkSchema = z .object({ kid: z.string().optional(), kty: z.string().min(1), @@ -23,17 +17,20 @@ const JwkSchema = z alg: z.string().optional(), }) .passthrough(); +export type Jwk = z.infer; /** * RFC 8414 fields. OpenID Connect Discovery documents carry the same core fields plus OIDC ones, * so one schema reads both. Only what the pipeline reads is typed; the rest passes through. + * URLs are not restricted to https here: the local server's documents carry `http://localhost` + * URLs in dev and behind TLS termination. The client enforces https at fetch time. */ -const AuthorizationServerMetadataSchema = z +export const AuthorizationServerMetadataSchema = z .object({ issuer: z.string().min(1), - jwks_uri: HttpsUrl.optional(), - authorization_endpoint: HttpsUrl.optional(), - token_endpoint: HttpsUrl.optional(), + jwks_uri: z.string().url().optional(), + authorization_endpoint: z.string().url().optional(), + token_endpoint: z.string().url().optional(), }) .passthrough(); export type AuthorizationServerMetadata = z.infer; @@ -57,7 +54,7 @@ export const DiscoveryDocumentSchema = z.discriminatedUnion('kind', [ fetchedAt, document: AuthorizationServerMetadataSchema, }), - z.object({ kind: z.literal('jwks'), fetchedAt, url: HttpsUrl, keys: z.array(JwkSchema) }), + z.object({ kind: z.literal('jwks'), fetchedAt, url: z.string().url(), keys: z.array(JwkSchema) }), ]); export type DiscoveryDocument = z.infer; @@ -95,6 +92,39 @@ export type TrustedSource = { export type Capability = 'verify-jwt' | 'redirect-login'; +/** The OAuth2 endpoints a source resolves to, field by field: manual config, then OIDC, then RFC 8414. */ +export type OAuth2Endpoints = { + jwksUri?: string; + authorizationEndpoint?: string; + tokenEndpoint?: string; +}; + +export function resolveOAuth2Endpoints( + config: TrustedSourceConfigLatest, + documents: DiscoveryDocument[], +): OAuth2Endpoints { + const { authentication } = config; + if (authentication.type !== 'oauth2') return {}; + + const manual = authentication.discovery.mode === 'manual' ? authentication.discovery : undefined; + const metadataOf = (kind: 'openid-configuration' | 'oauth2-authorization-server') => + documents.find( + (document): document is Extract => + document.kind === kind, + )?.document; + const oidc = metadataOf('openid-configuration'); + const oauth2 = metadataOf('oauth2-authorization-server'); + + return { + jwksUri: manual?.jwksUri ?? oidc?.jwks_uri ?? oauth2?.jwks_uri, + authorizationEndpoint: + manual?.authorizationEndpoint ?? + oidc?.authorization_endpoint ?? + oauth2?.authorization_endpoint, + tokenEndpoint: manual?.tokenEndpoint ?? oidc?.token_endpoint ?? oauth2?.token_endpoint, + }; +} + /** Pure: capabilities come from the config and the discovered metadata, never from the network. */ export function deriveCapabilities( config: TrustedSourceConfigLatest, @@ -104,22 +134,12 @@ export function deriveCapabilities( const { authentication } = config; if (authentication.type !== 'oauth2') return capabilities; - const manual = authentication.discovery.mode === 'manual' ? authentication.discovery : undefined; - const metadataOf = (kind: 'openid-configuration' | 'oauth2-authorization-server') => - metadata?.documents.find( - (document): document is Extract => - document.kind === kind, - )?.document; - const oidc = metadataOf('openid-configuration'); - const oauth2 = metadataOf('oauth2-authorization-server'); - - // Field by field: manual config wins, then the OIDC document, then the RFC 8414 document. - const jwksUri = manual?.jwksUri ?? oidc?.jwks_uri ?? oauth2?.jwks_uri; - const authorizationEndpoint = - manual?.authorizationEndpoint ?? oidc?.authorization_endpoint ?? oauth2?.authorization_endpoint; - const tokenEndpoint = manual?.tokenEndpoint ?? oidc?.token_endpoint ?? oauth2?.token_endpoint; - - if (authentication.keys.kind === 'local-keystore' || jwksUri) capabilities.add('verify-jwt'); + const { jwksUri, authorizationEndpoint, tokenEndpoint } = resolveOAuth2Endpoints( + config, + metadata?.documents ?? [], + ); + // A local keystore is discovered like any other source, so it also needs a jwks_uri. + if (jwksUri) capabilities.add('verify-jwt'); if (authentication.client && authorizationEndpoint && tokenEndpoint) { capabilities.add('redirect-login'); } diff --git a/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts b/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts index 6d58d3edcfa..feb58d0638a 100644 --- a/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts @@ -1,18 +1,24 @@ -import { ModuleMetadata } from '@n8n/decorators'; +import { ModuleMetadata, type ModuleInterface } from '@n8n/decorators'; import { Container } from '@n8n/di'; import { AuthenticationService, IdentityService, + LocalAuthorizationServer, migrateToLatest, TrustedSourceConfigSchema, TrustedSourceGate, + TrustedSourceStore, type Extracted, type TrustedSource, type Verified, } from '@n8n/inbound-auth'; +import { OperationalError } from 'n8n-workflow'; +import { mock } from 'vitest-mock-extended'; // Importing the module runs the @BackendModule decorator, registering its metadata. import { InboundAuthCoreModule } from '../inbound-auth-core.module'; +import { TrustedSourceDiscoveryTask } from '../trusted-source-discovery.task'; +import { TrustedSourceDbStore } from '../trusted-source.store'; describe('InboundAuthCoreModule', () => { describe('init', () => { @@ -24,10 +30,24 @@ describe('InboundAuthCoreModule', () => { credential: { kind: 'bearer', token: 'a.b.c' }, } satisfies Extracted; + const dbStore = mock(); + beforeAll(async () => { + Container.set(TrustedSourceDbStore, dbStore); await new InboundAuthCoreModule().init(); }); + it('binds the TrustedSourceStore contract to the database store', () => { + expect(Container.get(TrustedSourceStore)).toBe(dbStore); + }); + + it('binds a LocalAuthorizationServer that rejects until the OAuth2 server registers itself', async () => { + const server = Container.get(LocalAuthorizationServer); + + await expect(server.getMetadata()).rejects.toThrow(OperationalError); + await expect(server.getJwks()).rejects.toThrow(OperationalError); + }); + it('binds an AuthenticationService that fails closed until a driver is registered', async () => { const result = await Container.get(AuthenticationService).authenticate(extracted); @@ -92,6 +112,13 @@ describe('InboundAuthCoreModule', () => { expect(entry?.licenseFlag).toBeUndefined(); }); + it('exposes the discovery task as its only system task', async () => { + const module: ModuleInterface = new InboundAuthCoreModule(); + + expect(module.systemTasks).toBeDefined(); + expect(await module.systemTasks?.()).toEqual([TrustedSourceDiscoveryTask]); + }); + it('exposes its entities so the datasource picks them up', async () => { const module = new InboundAuthCoreModule(); diff --git a/packages/cli/src/modules/inbound-auth-core/__tests__/trusted-source-discovery.service.test.ts b/packages/cli/src/modules/inbound-auth-core/__tests__/trusted-source-discovery.service.test.ts new file mode 100644 index 00000000000..8b2ced19bf6 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/trusted-source-discovery.service.test.ts @@ -0,0 +1,397 @@ +import type { Logger } from '@n8n/backend-common'; +import type { Jwk, OAuthDiscoveryClient } from '@n8n/backend-services'; +import { + migrateToLatest, + trustedSourceConfigSchemaFor, + type AuthorizationServerMetadata, + type LocalAuthorizationServer, + type ManagedBy, + type TrustedSource, +} from '@n8n/inbound-auth'; +import { mock } from 'vitest-mock-extended'; + +import { + CONCURRENCY, + DUE_BATCH, + RUN_DEADLINE_MS, + TrustedSourceDiscoveryService, +} from '../trusted-source-discovery.service'; +import type { TrustedSourceDbStore } from '../trusted-source.store'; + +const NOW = new Date('2026-10-01T12:00:00.000Z'); +const TOKEN = 'token-1'; +const issuer = 'https://idp.example'; + +const oidcMetadata: AuthorizationServerMetadata = { + issuer, + jwks_uri: `${issuer}/oidc/keys`, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, +}; +const oauth2Metadata: AuthorizationServerMetadata = { + issuer, + jwks_uri: `${issuer}/rfc8414/keys`, + token_endpoint: `${issuer}/token`, +}; +const keys: Jwk[] = [{ kid: 'k1', kty: 'RSA', use: 'sig', alg: 'RS256', n: 'AQAB', e: 'AQAB' }]; +const fetched = (document: AuthorizationServerMetadata) => ({ document, ttlSeconds: 3600 }); +const fetchedJwks = { keys, skipped: [], ttlSeconds: 3600 }; + +const logger = mock({ scoped: vi.fn().mockReturnThis() }); +const store = mock(); +const client = mock(); +const localServer = mock(); + +let service: TrustedSourceDiscoveryService; +let counter = 0; + +type SourceOverrides = Partial> & { + authentication?: Record; + managedBy?: ManagedBy; +}; + +function source(overrides: SourceOverrides = {}): TrustedSource { + const { authentication = {}, managedBy = 'admin', ...rest } = overrides; + counter += 1; + return { + id: `source-${counter}`, + name: `Source ${counter}`, + type: 'oauth2', + issuer, + managedBy, + status: 'unchecked', + lastError: null, + lastCheckedAt: null, + createdAt: '2026-09-30T10:00:00.000Z', + updatedAt: '2026-09-30T10:00:00.000Z', + config: migrateToLatest( + trustedSourceConfigSchemaFor(managedBy).parse({ + version: 1, + authentication: { type: 'oauth2', ...authentication }, + surfaces: { 'public-api': {} }, + }), + ), + metadata: null, + ...rest, + }; +} + +/** The third argument of the single `recordDiscovery` call. */ +function recorded() { + expect(store.recordDiscovery).toHaveBeenCalledTimes(1); + return store.recordDiscovery.mock.calls[0][2]; +} + +/** Lets every pending microtask and immediate run; only `Date` is faked. */ +const settle = async () => await new Promise((resolve) => setImmediate(resolve)); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); + store.claimDiscovery.mockResolvedValue(TOKEN); + store.recordDiscovery.mockResolvedValue(true); + client.fetchOpenIdConfiguration.mockResolvedValue(fetched(oidcMetadata)); + client.fetchOAuth2ServerMetadata.mockResolvedValue(fetched(oauth2Metadata)); + client.fetchJwks.mockResolvedValue(fetchedJwks); + service = new TrustedSourceDiscoveryService(logger, store, client, localServer); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('TrustedSourceDiscoveryService', () => { + describe('refresh', () => { + const refresh = async (src: TrustedSource) => { + store.getById.mockResolvedValue(src); + await service.refresh(src.id); + }; + + it('stores both metadata documents and the JWKS of an auto-discovered source as healthy', async () => { + const src = source(); + + await refresh(src); + + expect(store.claimDiscovery).toHaveBeenCalledWith(src); + expect(client.fetchOpenIdConfiguration).toHaveBeenCalledWith(issuer); + expect(client.fetchOAuth2ServerMetadata).toHaveBeenCalledWith({ issuer }); + // The OIDC document wins, so its jwks_uri is the one fetched. + expect(client.fetchJwks).toHaveBeenCalledWith(`${issuer}/oidc/keys`); + expect(store.recordDiscovery).toHaveBeenCalledWith(src, TOKEN, { + metadata: { + version: 1, + documents: [ + { kind: 'openid-configuration', fetchedAt: NOW.toISOString(), document: oidcMetadata }, + { + kind: 'oauth2-authorization-server', + fetchedAt: NOW.toISOString(), + document: oauth2Metadata, + }, + { kind: 'jwks', fetchedAt: NOW.toISOString(), url: `${issuer}/oidc/keys`, keys }, + ], + }, + status: 'healthy', + lastError: null, + }); + }); + + it('is healthy with the OIDC document alone when the issuer offers no RFC 8414 document', async () => { + client.fetchOAuth2ServerMetadata.mockResolvedValue(undefined); + const src = source(); + + await refresh(src); + + expect(recorded()).toMatchObject({ status: 'healthy', lastError: null }); + expect(recorded().metadata?.documents.map((document) => document.kind)).toEqual([ + 'openid-configuration', + 'jwks', + ]); + }); + + it('records an error and keeps the previous metadata when the issuer offers no document', async () => { + client.fetchOpenIdConfiguration.mockResolvedValue(undefined); + client.fetchOAuth2ServerMetadata.mockResolvedValue(undefined); + const src = source(); + + await refresh(src); + + expect(client.fetchJwks).not.toHaveBeenCalled(); + const result = recorded(); + expect(result).toEqual({ status: 'error', lastError: expect.stringContaining('offers no') }); + expect('metadata' in result).toBe(false); + }); + + it('fetches a manual metadataUrl as the RFC 8414 document and skips OIDC discovery', async () => { + const metadataUrl = `${issuer}/.well-known/custom`; + const src = source({ authentication: { discovery: { mode: 'manual', metadataUrl } } }); + + await refresh(src); + + expect(client.fetchOAuth2ServerMetadata).toHaveBeenCalledWith({ issuer, url: metadataUrl }); + expect(client.fetchOpenIdConfiguration).not.toHaveBeenCalled(); + expect(recorded()).toMatchObject({ status: 'healthy' }); + expect(recorded().metadata?.documents.map((document) => document.kind)).toEqual([ + 'oauth2-authorization-server', + 'jwks', + ]); + }); + + it('records an error when the manual metadataUrl answers 404', async () => { + client.fetchOAuth2ServerMetadata.mockResolvedValue(undefined); + const src = source({ + authentication: { discovery: { mode: 'manual', metadataUrl: `${issuer}/meta` } }, + }); + + await refresh(src); + + const result = recorded(); + expect(result).toMatchObject({ status: 'error', lastError: expect.any(String) }); + expect('metadata' in result).toBe(false); + }); + + it('records an error when no document and no manual config name a jwks_uri', async () => { + client.fetchOpenIdConfiguration.mockResolvedValue( + fetched({ issuer, token_endpoint: `${issuer}/token` }), + ); + client.fetchOAuth2ServerMetadata.mockResolvedValue(undefined); + const src = source(); + + await refresh(src); + + expect(client.fetchJwks).not.toHaveBeenCalled(); + const result = recorded(); + expect(result).toEqual({ + status: 'error', + lastError: expect.stringContaining('no jwks_uri'), + }); + expect('metadata' in result).toBe(false); + }); + + it('records the JWKS fetch failure and keeps the previous metadata', async () => { + client.fetchJwks.mockRejectedValue(new Error('JWKS at "x" has no usable signing keys')); + const src = source(); + + await refresh(src); + + const result = recorded(); + expect(result).toEqual({ + status: 'error', + lastError: expect.stringContaining('no usable signing keys'), + }); + expect('metadata' in result).toBe(false); + }); + + it('reads a local-keystore source from the local server instead of the network', async () => { + const local = { issuer: 'http://localhost:5678', jwks_uri: 'http://localhost:5678/jwks' }; + localServer.getMetadata.mockResolvedValue(local); + localServer.getJwks.mockResolvedValue({ keys }); + const src = source({ + managedBy: 'system', + issuer: local.issuer, + authentication: { keys: { kind: 'local-keystore' } }, + }); + + await refresh(src); + + expect(client.fetchOpenIdConfiguration).not.toHaveBeenCalled(); + expect(client.fetchOAuth2ServerMetadata).not.toHaveBeenCalled(); + expect(client.fetchJwks).not.toHaveBeenCalled(); + expect(store.recordDiscovery).toHaveBeenCalledWith(src, TOKEN, { + metadata: { + version: 1, + documents: [ + { kind: 'oauth2-authorization-server', fetchedAt: NOW.toISOString(), document: local }, + { kind: 'jwks', fetchedAt: NOW.toISOString(), url: local.jwks_uri, keys }, + ], + }, + status: 'healthy', + lastError: null, + }); + }); + + it('records an error when the local server names another issuer', async () => { + localServer.getMetadata.mockResolvedValue({ + issuer: 'http://localhost:5678', + jwks_uri: 'http://localhost:5678/jwks', + }); + const src = source({ + managedBy: 'system', + issuer: 'http://localhost:9999', + authentication: { keys: { kind: 'local-keystore' } }, + }); + + await refresh(src); + + expect(localServer.getJwks).not.toHaveBeenCalled(); + const result = recorded(); + expect(result).toEqual({ + status: 'error', + lastError: expect.stringContaining('names issuer "http://localhost:5678"'), + }); + expect('metadata' in result).toBe(false); + }); + + it('records an error when the local server metadata has no jwks_uri', async () => { + localServer.getMetadata.mockResolvedValue({ issuer: 'http://localhost:5678' }); + const src = source({ + managedBy: 'system', + issuer: 'http://localhost:5678', + authentication: { keys: { kind: 'local-keystore' } }, + }); + + await refresh(src); + + expect(localServer.getJwks).not.toHaveBeenCalled(); + const result = recorded(); + expect(result).toEqual({ + status: 'error', + lastError: expect.stringContaining('no jwks_uri'), + }); + expect('metadata' in result).toBe(false); + }); + + it('records an error when the local server is not available', async () => { + localServer.getMetadata.mockRejectedValue(new Error('No local authorization server')); + const src = source({ + managedBy: 'system', + authentication: { keys: { kind: 'local-keystore' } }, + }); + + await refresh(src); + + const result = recorded(); + expect(result).toEqual({ + status: 'error', + lastError: expect.stringContaining('No local authorization server'), + }); + expect('metadata' in result).toBe(false); + }); + + it('does nothing when another run holds the lease', async () => { + store.claimDiscovery.mockResolvedValue(null); + const src = source(); + + await refresh(src); + + expect(client.fetchOpenIdConfiguration).not.toHaveBeenCalled(); + expect(client.fetchOAuth2ServerMetadata).not.toHaveBeenCalled(); + expect(client.fetchJwks).not.toHaveBeenCalled(); + expect(store.recordDiscovery).not.toHaveBeenCalled(); + }); + }); + + describe('refreshDue', () => { + const signal = () => new AbortController().signal; + const claimedIds = () => store.claimDiscovery.mock.calls.map(([src]) => src.id); + + it('passes the batch size to listDue and processes sources in the returned order', async () => { + const sources = [source(), source(), source()]; + const ids = sources.map((src) => src.id); + store.listDue.mockResolvedValue(sources); + + await service.refreshDue(signal()); + + expect(store.listDue).toHaveBeenCalledWith(DUE_BATCH); + expect(claimedIds()).toEqual(ids); + expect(store.recordDiscovery).toHaveBeenCalledTimes(3); + }); + + it('refreshes at most CONCURRENCY sources at once', async () => { + const sources = Array.from({ length: 8 }, () => source()); + store.listDue.mockResolvedValue(sources); + const pending = sources.map(() => deferred>()); + let call = 0; + client.fetchOpenIdConfiguration.mockImplementation(async () => await pending[call++].promise); + + const run = service.refreshDue(signal()); + await settle(); + + expect(store.claimDiscovery).toHaveBeenCalledTimes(CONCURRENCY); + expect(client.fetchOpenIdConfiguration).toHaveBeenCalledTimes(CONCURRENCY); + + pending[0].resolve(fetched(oidcMetadata)); + await settle(); + + expect(store.claimDiscovery).toHaveBeenCalledTimes(CONCURRENCY + 1); + + for (const entry of pending) entry.resolve(fetched(oidcMetadata)); + await run; + + expect(store.recordDiscovery).toHaveBeenCalledTimes(8); + }); + + it('takes no further source once the run deadline has passed', async () => { + const first = source(); + const second = source(); + store.listDue.mockResolvedValue([first, second]); + // The first source's claim is slow enough to use up the whole run budget. + store.claimDiscovery.mockImplementationOnce(async () => { + vi.setSystemTime(NOW.getTime() + RUN_DEADLINE_MS + 1); + return TOKEN; + }); + + await service.refreshDue(signal()); + + expect(claimedIds()).toEqual([first.id]); + }); + + it('claims nothing when the signal is already aborted', async () => { + store.listDue.mockResolvedValue([source(), source()]); + const controller = new AbortController(); + controller.abort(); + + await service.refreshDue(controller.signal); + + expect(store.claimDiscovery).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/modules/inbound-auth-core/__tests__/trusted-source-discovery.task.test.ts b/packages/cli/src/modules/inbound-auth-core/__tests__/trusted-source-discovery.task.test.ts new file mode 100644 index 00000000000..2a7e4389aa7 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/trusted-source-discovery.task.test.ts @@ -0,0 +1,25 @@ +import { mock } from 'vitest-mock-extended'; + +import { TrustedSourceDiscoveryTask } from '../trusted-source-discovery.task'; +import type { TrustedSourceDiscoveryService } from '../trusted-source-discovery.service'; + +describe('TrustedSourceDiscoveryTask', () => { + const discovery = mock(); + const task = new TrustedSourceDiscoveryTask(discovery); + + it('declares a durable, idempotent, cluster-wide 60-second poll', () => { + expect(task.name).toBe('trusted-source-discovery'); + expect(task.schedule).toEqual({ kind: 'interval', intervalSeconds: 60 }); + expect(task.effects).toBe('idempotent'); + expect(task.placement).toEqual({ scope: 'cluster', durable: true }); + }); + + it('refreshes the due sources with the run signal', async () => { + const { signal } = new AbortController(); + + await task.run(signal); + + expect(discovery.refreshDue).toHaveBeenCalledTimes(1); + expect(discovery.refreshDue).toHaveBeenCalledWith(signal); + }); +}); diff --git a/packages/cli/src/modules/inbound-auth-core/database/entities/trusted-source.entity.ts b/packages/cli/src/modules/inbound-auth-core/database/entities/trusted-source.entity.ts index c5f7ca9461a..0923067ae7e 100644 --- a/packages/cli/src/modules/inbound-auth-core/database/entities/trusted-source.entity.ts +++ b/packages/cli/src/modules/inbound-auth-core/database/entities/trusted-source.entity.ts @@ -37,4 +37,16 @@ export class TrustedSourceEntity extends WithTimestampsAndStringId { /** Cipher.encryptV2(JSON.stringify(config)); the store decrypts, never the entity. */ @Column({ type: 'text' }) config: string; + + /** Plain JSON of `TrustedSourceMetadata`; discovery data is public, so it is not encrypted. */ + @Column({ type: 'text', nullable: true }) + metadata: string | null; + + /** Identity of the discovery run holding the lease; null when free. */ + @Column({ type: 'varchar', length: 36, nullable: true }) + discoveryClaimToken: string | null; + + /** When the lease was taken; only decides when a stale lease may be taken over. */ + @DateTimeColumn({ nullable: true }) + discoveryClaimedAt: Date | null; } diff --git a/packages/cli/src/modules/inbound-auth-core/database/repositories/trusted-source.repository.ts b/packages/cli/src/modules/inbound-auth-core/database/repositories/trusted-source.repository.ts index 592884d176a..8192bfeb07b 100644 --- a/packages/cli/src/modules/inbound-auth-core/database/repositories/trusted-source.repository.ts +++ b/packages/cli/src/modules/inbound-auth-core/database/repositories/trusted-source.repository.ts @@ -1,4 +1,11 @@ -import { BaseRepository, OperationContext, TransactionRunner } from '@n8n/db'; +import { DatabaseConfig } from '@n8n/config'; +import { + BaseRepository, + dbNowLiteral, + dbNowPlusMsLiteral, + OperationContext, + TransactionRunner, +} from '@n8n/db'; import { Service } from '@n8n/di'; import { DataSource } from '@n8n/typeorm'; @@ -25,18 +32,51 @@ export type NewTrustedSourceRow = Pick< | 'lastCheckedAt' | 'configVersion' | 'config' + | 'metadata' >; export type TrustedSourceRowChanges = Partial< - Pick + Pick< + TrustedSourceEntity, + | 'name' + | 'issuer' + | 'configVersion' + | 'config' + | 'metadata' + | 'status' + | 'lastError' + | 'lastCheckedAt' + | 'discoveryClaimToken' + | 'discoveryClaimedAt' + > >; +/** What a discovery run writes; `lastCheckedAt` and the lease columns are set by the query. */ +export type DiscoveryRowChanges = Pick< + TrustedSourceRowChanges, + 'metadata' | 'status' | 'lastError' +>; + +export type DueQuery = { + errorRetrySeconds: number; + healthyRefreshSeconds: number; + limit: number; +}; + @Service() export class TrustedSourceRepository { private readonly table: TrustedSourceTable; - constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + // The lease and `lastCheckedAt` use the database clock, so instances with skewed clocks agree. + private readonly isPostgres: boolean; + + constructor( + dataSource: DataSource, + transactionRunner: TransactionRunner, + config: DatabaseConfig, + ) { this.table = new TrustedSourceTable(dataSource, transactionRunner); + this.isPostgres = config.type === 'postgresdb'; } async findById(id: string, ctx: OperationContext = {}) { @@ -51,6 +91,33 @@ export class TrustedSourceRepository { return await this.table.managerFor(ctx).find(TrustedSourceEntity, { order: { name: 'ASC' } }); } + /** + * Sources whose last check is missing or older than the interval for their status. Unchecked + * sources come first, then those never checked, then the oldest check. The lease is not + * consulted here: the claim decides who refreshes a due source. + */ + async findDue( + { errorRetrySeconds, healthyRefreshSeconds, limit }: DueQuery, + ctx: OperationContext = {}, + ): Promise { + const errorCutoff = dbNowPlusMsLiteral(this.isPostgres, -errorRetrySeconds * 1000); + const healthyCutoff = dbNowPlusMsLiteral(this.isPostgres, -healthyRefreshSeconds * 1000); + return await this.table + .managerFor(ctx) + .createQueryBuilder(TrustedSourceEntity, 'source') + .where( + `source.status = :unchecked OR source.lastCheckedAt IS NULL + OR (source.status = :error AND source.lastCheckedAt < ${errorCutoff}) + OR (source.status = :healthy AND source.lastCheckedAt < ${healthyCutoff})`, + { unchecked: 'unchecked', error: 'error', healthy: 'healthy' }, + ) + .orderBy("CASE WHEN source.status = 'unchecked' THEN 0 ELSE 1 END", 'ASC') + .addOrderBy('CASE WHEN source.lastCheckedAt IS NULL THEN 0 ELSE 1 END', 'ASC') + .addOrderBy('source.lastCheckedAt', 'ASC') + .take(limit) + .getMany(); + } + /** `save`, not `insert`: the `@BeforeInsert` id generator only runs on an entity instance. */ async insertRow(row: NewTrustedSourceRow, ctx: OperationContext = {}) { return await this.table.managerFor(ctx).save(TrustedSourceEntity, this.table.create(row)); @@ -63,4 +130,50 @@ export class TrustedSourceRepository { async deleteById(id: string, ctx: OperationContext = {}) { await this.table.managerFor(ctx).delete(TrustedSourceEntity, { id }); } + + /** Takes the discovery lease for `token` when it is free or older than `leaseSeconds`. */ + async claimForDiscovery( + id: string, + token: string, + leaseSeconds: number, + ctx: OperationContext = {}, + ): Promise { + const staleBefore = dbNowPlusMsLiteral(this.isPostgres, -leaseSeconds * 1000); + const result = await this.table + .managerFor(ctx) + .createQueryBuilder() + .update(TrustedSourceEntity) + .set({ + discoveryClaimToken: token, + discoveryClaimedAt: () => dbNowLiteral(this.isPostgres), + }) + .where( + `id = :id AND ("discoveryClaimedAt" IS NULL OR "discoveryClaimedAt" < ${staleBefore})`, + { id }, + ) + .execute(); + return result.affected === 1; + } + + /** Writes the result and releases the lease, only while `token` still holds it. */ + async recordDiscovery( + id: string, + token: string, + changes: DiscoveryRowChanges, + ctx: OperationContext = {}, + ): Promise { + const result = await this.table + .managerFor(ctx) + .createQueryBuilder() + .update(TrustedSourceEntity) + .set({ + ...changes, + lastCheckedAt: () => dbNowLiteral(this.isPostgres), + discoveryClaimToken: null, + discoveryClaimedAt: null, + }) + .where('id = :id AND "discoveryClaimToken" = :token', { id, token }) + .execute(); + return result.affected === 1; + } } diff --git a/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts b/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts index 058fb96cc92..079dbf3f8c8 100644 --- a/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts +++ b/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts @@ -4,12 +4,17 @@ import { Container } from '@n8n/di'; import { AuthenticationService, IdentityService, + LocalAuthorizationServer, TrustedSourceGate, + TrustedSourceStore, + type AuthorizationServerMetadata, type Extracted, + type Jwk, type Result, type Verified, } from '@n8n/inbound-auth'; import type { SecurityContext } from '@n8n/permissions'; +import { OperationalError } from 'n8n-workflow'; // Fail-closed defaults: a consumer that runs before a real implementation is registered gets a // rejection, not a throw. The OAuth2 driver replaces the AuthenticationService binding. @@ -35,6 +40,17 @@ class UnregisteredTrustedSourceGate extends TrustedSourceGate { } } +// Discovery of the local source must fail, not succeed with made-up documents. +class UnregisteredLocalAuthorizationServer extends LocalAuthorizationServer { + async getMetadata(): Promise { + throw new OperationalError('No local authorization server is registered'); + } + + async getJwks(): Promise<{ keys: Jwk[] }> { + throw new OperationalError('No local authorization server is registered'); + } +} + /** * Owns the `trusted_source` and `trusted_source_identity` tables and binds the inbound-auth * contracts. Main, webhook and worker instances load it, because workers and webhook processes @@ -53,6 +69,20 @@ export class InboundAuthCoreModule implements ModuleInterface { if (!Container.has(TrustedSourceGate)) { Container.set(TrustedSourceGate, new UnregisteredTrustedSourceGate()); } + if (!Container.has(LocalAuthorizationServer)) { + Container.set(LocalAuthorizationServer, new UnregisteredLocalAuthorizationServer()); + } + + const { TrustedSourceDbStore } = await import('./trusted-source.store.js'); + if (!Container.has(TrustedSourceStore)) { + Container.set(TrustedSourceStore, Container.get(TrustedSourceDbStore)); + } + } + + // Ungated: the runner skips a cluster-scoped task on webhook and worker instances itself. + async systemTasks() { + const { TrustedSourceDiscoveryTask } = await import('./trusted-source-discovery.task.js'); + return [TrustedSourceDiscoveryTask]; } async entities() { diff --git a/packages/cli/src/modules/inbound-auth-core/trusted-source-discovery.service.ts b/packages/cli/src/modules/inbound-auth-core/trusted-source-discovery.service.ts new file mode 100644 index 00000000000..69046a06831 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/trusted-source-discovery.service.ts @@ -0,0 +1,147 @@ +import { Logger } from '@n8n/backend-common'; +import { OAuthDiscoveryClient } from '@n8n/backend-services'; +import { Time } from '@n8n/constants'; +import { Service } from '@n8n/di'; +import { + LocalAuthorizationServer, + resolveOAuth2Endpoints, + TrustedSourceMetadataSchema, + type DiscoveryDocument, + type TrustedSource, +} from '@n8n/inbound-auth'; +import { ensureError } from '@n8n/utils/errors/ensure-error'; +import { OperationalError } from 'n8n-workflow'; + +import { TrustedSourceDbStore, type DiscoveryResult } from './trusted-source.store'; + +/** Due sources taken per run; more than one run can finish this many in 40 s at concurrency 5. */ +export const DUE_BATCH = 200; +/** Sources refreshed at once in one run. */ +export const CONCURRENCY = 5; +/** A run stops taking new sources after this; the task fires every minute anyway. */ +export const RUN_DEADLINE_MS = 40 * Time.seconds.toMilliseconds; + +/** + * Fetches the OAuth2 metadata and JWKS of every trusted source and stores the result as the + * source's `metadata`. The store's lease keeps two instances from refreshing the same source. + */ +@Service() +export class TrustedSourceDiscoveryService { + constructor( + private readonly logger: Logger, + private readonly store: TrustedSourceDbStore, + private readonly client: OAuthDiscoveryClient, + private readonly localServer: LocalAuthorizationServer, + ) {} + + async refresh(sourceId: string): Promise { + const source = await this.store.getById(sourceId); + if (source) await this.refreshSource(source); + } + + async refreshDue(signal: AbortSignal): Promise { + const deadline = Date.now() + RUN_DEADLINE_MS; + const queue = await this.store.listDue(DUE_BATCH); + + const worker = async () => { + while (!signal.aborted && Date.now() < deadline) { + const source = queue.shift(); + if (source === undefined) return; + await this.refreshSource(source); + } + }; + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker)); + } + + /** Never throws: one source's failure must not end the run for the others. */ + private async refreshSource(source: TrustedSource): Promise { + try { + const token = await this.store.claimDiscovery(source); + if (token === null) return; + await this.store.recordDiscovery(source, token, await this.discover(source)); + } catch (error) { + this.logger.error('Could not write the discovery result of a trusted source', { + id: source.id, + error, + }); + } + } + + /** A failed discovery becomes the source's `error` status; the previous metadata stays. */ + private async discover(source: TrustedSource): Promise { + try { + const documents = await this.collect(source); + const metadata = TrustedSourceMetadataSchema.parse({ version: 1, documents }); + return { metadata, status: 'healthy', lastError: null }; + } catch (error) { + const lastError = ensureError(error).message; + this.logger.warn('Trusted source discovery failed', { id: source.id, reason: lastError }); + return { status: 'error', lastError }; + } + } + + /** All or nothing: a source is healthy only when every document it needs was fetched. */ + private async collect(source: TrustedSource): Promise { + const { issuer, config } = source; + const { authentication } = config; + const fetchedAt = new Date().toISOString(); + + if (authentication.keys.kind === 'local-keystore') { + const document = await this.localServer.getMetadata(); + // The same checks as the remote path: the document must name this issuer and a JWKS. + if (document.issuer !== issuer) { + throw new OperationalError( + `Local server metadata names issuer "${document.issuer}", expected "${issuer}"`, + ); + } + if (!document.jwks_uri) throw new OperationalError(`Issuer "${issuer}" has no jwks_uri`); + const { keys } = await this.localServer.getJwks(); + return [ + { kind: 'oauth2-authorization-server', fetchedAt, document }, + { kind: 'jwks', fetchedAt, url: document.jwks_uri, keys }, + ]; + } + + const documents: DiscoveryDocument[] = []; + const { discovery } = authentication; + if (discovery.mode === 'manual') { + if (discovery.metadataUrl) { + const fetched = await this.client.fetchOAuth2ServerMetadata({ + issuer, + url: discovery.metadataUrl, + }); + if (!fetched) { + throw new OperationalError(`Metadata URL "${discovery.metadataUrl}" offers no document`); + } + documents.push({ + kind: 'oauth2-authorization-server', + fetchedAt, + document: fetched.document, + }); + } + } else { + const [oidc, oauth2] = await Promise.all([ + this.client.fetchOpenIdConfiguration(issuer), + this.client.fetchOAuth2ServerMetadata({ issuer }), + ]); + if (oidc) + documents.push({ kind: 'openid-configuration', fetchedAt, document: oidc.document }); + if (oauth2) { + documents.push({ + kind: 'oauth2-authorization-server', + fetchedAt, + document: oauth2.document, + }); + } + if (documents.length === 0) { + throw new OperationalError(`Issuer "${issuer}" offers no OAuth2 metadata document`); + } + } + + const { jwksUri } = resolveOAuth2Endpoints(config, documents); + if (!jwksUri) throw new OperationalError(`Issuer "${issuer}" has no jwks_uri`); + const { keys } = await this.client.fetchJwks(jwksUri); + documents.push({ kind: 'jwks', fetchedAt, url: jwksUri, keys }); + return documents; + } +} diff --git a/packages/cli/src/modules/inbound-auth-core/trusted-source-discovery.task.ts b/packages/cli/src/modules/inbound-auth-core/trusted-source-discovery.task.ts new file mode 100644 index 00000000000..8f4ad89a5e0 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/trusted-source-discovery.task.ts @@ -0,0 +1,28 @@ +import { intervalFromSeconds, SystemTask } from '@n8n/decorators'; +import type { SystemTaskEffects, SystemTaskPlacement, SystemTaskSchedule } from '@n8n/decorators'; + +import { TrustedSourceDiscoveryService } from './trusted-source-discovery.service'; + +/** How often to look for sources whose discovery is due. */ +const DISCOVERY_POLL_INTERVAL_SECONDS = 60; + +/** + * Refreshes the discovered metadata of trusted sources whose refresh interval has lapsed, so + * JWT verification keeps working when an identity provider rotates its keys. + */ +@SystemTask() +export class TrustedSourceDiscoveryTask implements SystemTask { + readonly name = 'trusted-source-discovery'; + + readonly schedule: SystemTaskSchedule = intervalFromSeconds(DISCOVERY_POLL_INTERVAL_SECONDS); + + readonly effects: SystemTaskEffects = 'idempotent'; + + readonly placement: SystemTaskPlacement = { scope: 'cluster', durable: true }; + + constructor(private readonly discovery: TrustedSourceDiscoveryService) {} + + async run(signal: AbortSignal): Promise { + await this.discovery.refreshDue(signal); + } +} diff --git a/packages/cli/src/modules/inbound-auth-core/trusted-source.store.ts b/packages/cli/src/modules/inbound-auth-core/trusted-source.store.ts index e426ffd0319..3029a858808 100644 --- a/packages/cli/src/modules/inbound-auth-core/trusted-source.store.ts +++ b/packages/cli/src/modules/inbound-auth-core/trusted-source.store.ts @@ -4,20 +4,21 @@ import { NotFoundError } from '@n8n/errors'; import { migrateToLatest, trustedSourceConfigSchemaFor, + TrustedSourceStore, type SurfaceId, + type TrustedSource, type TrustedSourceConfigInput, type TrustedSourceConfigLatest, + type TrustedSourceMetadata, + TrustedSourceMetadataSchema, + type TrustedSourceStatus, } from '@n8n/inbound-auth'; import { ensureError } from '@n8n/utils/errors/ensure-error'; import { Cipher } from 'n8n-core'; import { jsonParse, UserError } from 'n8n-workflow'; +import { randomUUID } from 'node:crypto'; -import type { - TrustedSourceEntity, - TrustedSourceManagedBy, - TrustedSourceStatus, - TrustedSourceType, -} from './database/entities/trusted-source.entity'; +import type { TrustedSourceEntity } from './database/entities/trusted-source.entity'; import { TrustedSourceIdentityRepository } from './database/repositories/trusted-source-identity.repository'; import { TrustedSourceRepository, @@ -27,24 +28,6 @@ import { TransactionRunner } from '@n8n/db'; import { Time } from '@n8n/constants'; import { CacheService } from '@n8n/backend-services'; -/** - * A trusted source as consumers see it: decrypted, validated and migrated to the latest config - * version. Dates are ISO strings so the shape survives a Redis round-trip unchanged. - */ -export type TrustedSource = { - id: string; - name: string; - type: TrustedSourceType; - issuer: string; - managedBy: TrustedSourceManagedBy; - status: TrustedSourceStatus; - lastError: string | null; - lastCheckedAt: string | null; - createdAt: string; - updatedAt: string; - config: TrustedSourceConfigLatest; -}; - /** The store still validates `config`: the admin-vs-system rules depend on the row, not the type. */ export type CreateTrustedSourceInput = { name: string; @@ -65,9 +48,22 @@ const ALL_KEY = 'trusted-source:all'; // Reads are look-aside, so a read that started before a concurrent write can re-cache the old // row after the write invalidated it. We accept that; this TTL caps the stale window at 5 minutes. const CACHE_TTL = 5 * Time.minutes.toMilliseconds; +// A run that dies mid-discovery leaves its lease behind; the next run takes it over after this. +const DISCOVERY_LEASE_SECONDS = 60; +/** A failed source is retried soon; a healthy one is refreshed on a slow cadence. */ +export const ERROR_RETRY_SECONDS = 5 * Time.minutes.toSeconds; +// ponytail: 1 h staleness ceiling after key rotation. +export const HEALTHY_REFRESH_SECONDS = 1 * Time.hours.toSeconds; + +/** What one discovery run leaves behind. `metadata` omitted keeps the previous documents. */ +export type DiscoveryResult = { + metadata?: TrustedSourceMetadata; + status: TrustedSourceStatus; + lastError: string | null; +}; @Service() -export class TrustedSourceStore { +export class TrustedSourceDbStore extends TrustedSourceStore { constructor( private readonly logger: Logger, private readonly trustedSourceRepository: TrustedSourceRepository, @@ -75,7 +71,9 @@ export class TrustedSourceStore { private readonly cacheService: CacheService, private readonly trustedSourceIdentityRepository: TrustedSourceIdentityRepository, private readonly transactionRunner: TransactionRunner, - ) {} + ) { + super(); + } async getById(id: string): Promise { return await this.cacheService.get(idKey(id), { @@ -93,12 +91,52 @@ export class TrustedSourceStore { } async listBySurface(surface: SurfaceId): Promise { - const allSources = + const all = (await this.cacheService.get(ALL_KEY, { ttl: CACHE_TTL, refreshFn: async () => await this.loadAll(), })) ?? []; - return allSources.filter((source) => source.config.surfaces[surface] !== undefined); + return all.filter((source) => source.config.surfaces[surface] !== undefined); + } + + /** Sources due for discovery, most urgent first; not cached, the task reads it once a minute. */ + async listDue(limit: number): Promise { + const rows = await this.trustedSourceRepository.findDue({ + errorRetrySeconds: ERROR_RETRY_SECONDS, + healthyRefreshSeconds: HEALTHY_REFRESH_SECONDS, + limit, + }); + const sources = await Promise.all(rows.map(async (row) => await this.load(row))); + return sources.filter((source): source is TrustedSource => source !== undefined); + } + + /** Takes the discovery lease on `source`; the run's token, or `null` when another run holds it. */ + async claimDiscovery(source: TrustedSource): Promise { + const token = randomUUID(); + const claimed = await this.trustedSourceRepository.claimForDiscovery( + source.id, + token, + DISCOVERY_LEASE_SECONDS, + ); + if (!claimed) return null; + await this.invalidateCache(source); + return token; + } + + /** Writes the run's result and releases the lease; `false` when the lease was lost. */ + async recordDiscovery( + source: TrustedSource, + token: string, + result: DiscoveryResult, + ): Promise { + // Metadata is never cleared: an error keeps the last good documents readable. + const recorded = await this.trustedSourceRepository.recordDiscovery(source.id, token, { + status: result.status, + lastError: result.lastError, + ...(result.metadata && { metadata: JSON.stringify(result.metadata) }), + }); + if (recorded) await this.invalidateCache(source); + return recorded; } async create(input: CreateTrustedSourceInput): Promise { @@ -115,6 +153,7 @@ export class TrustedSourceStore { lastCheckedAt: null, configVersion: config.version, config: await this.cipher.encryptV2(config), + metadata: null, }); await this.invalidateCache(row); return this.toRuntime(row, config); @@ -136,6 +175,16 @@ export class TrustedSourceStore { changes.configVersion = config.version; changes.config = await this.cipher.encryptV2(config); } + // A new issuer or config starts from nothing: the old documents must not vouch for it, the + // source is due again, and a discovery run that started on the old configuration loses its + // lease, so it cannot record its result against the new one. + if (input.issuer !== undefined || input.config !== undefined) { + changes.metadata = null; + changes.status = 'unchecked'; + changes.lastError = null; + changes.discoveryClaimToken = null; + changes.discoveryClaimedAt = null; + } await this.transactionRunner.run({}, async (ctx) => { if (Object.keys(changes).length > 0) { await this.trustedSourceRepository.updateById(id, changes, ctx); @@ -162,7 +211,10 @@ export class TrustedSourceStore { } /** `row` is the state before the write, so `row.issuer` is the old issuer. */ - private async invalidateCache(row: TrustedSourceEntity, newIssuer = row.issuer) { + private async invalidateCache( + row: Pick, + newIssuer = row.issuer, + ) { await this.cacheService.deleteMany([ idKey(row.id), issuerKey(row.issuer), @@ -206,6 +258,21 @@ export class TrustedSourceStore { createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), config, + metadata: this.parseMetadata(row), }; } + + /** Discovery data is advisory: an unreadable column reads as "not discovered yet". */ + private parseMetadata(row: TrustedSourceEntity): TrustedSourceMetadata | null { + if (row.metadata === null) return null; + const parsed = TrustedSourceMetadataSchema.safeParse( + jsonParse(row.metadata, { fallbackValue: null }), + ); + if (parsed.success) return parsed.data; + this.logger.warn('Ignoring unreadable trusted source metadata', { + id: row.id, + reason: parsed.error.message, + }); + return null; + } } diff --git a/packages/cli/test/integration/inbound-auth-core/trusted-source.store.integration.test.ts b/packages/cli/test/integration/inbound-auth-core/trusted-source.store.integration.test.ts index c7410a53ed7..f2c4e1d1fd7 100644 --- a/packages/cli/test/integration/inbound-auth-core/trusted-source.store.integration.test.ts +++ b/packages/cli/test/integration/inbound-auth-core/trusted-source.store.integration.test.ts @@ -5,7 +5,9 @@ import { NotFoundError } from '@n8n/errors'; import { trustedSourceConfigSchemaFor, type SurfaceId, + type TrustedSource, type TrustedSourceConfigInput, + type TrustedSourceMetadata, } from '@n8n/inbound-auth'; import { DataSource, type Repository } from '@n8n/typeorm'; import { Cipher } from 'n8n-core'; @@ -14,14 +16,13 @@ import { TrustedSourceIdentityEntity } from '@/modules/inbound-auth-core/databas import { TrustedSourceEntity } from '@/modules/inbound-auth-core/database/entities/trusted-source.entity'; import { SystemTrustedSourceModificationError, - TrustedSourceStore, - type TrustedSource, + TrustedSourceDbStore, } from '@/modules/inbound-auth-core/trusted-source.store'; import { CacheService } from '@n8n/backend-services'; import { createOwner } from '../shared/db/users'; -let store: TrustedSourceStore; +let store: TrustedSourceDbStore; let cacheService: CacheService; let cipher: Cipher; /** Direct table access, so the tests do not depend on the repository under construction. */ @@ -56,11 +57,28 @@ async function seedRow(overrides: Partial = {}) { lastCheckedAt: null, configVersion: 1, config: await cipher.encryptV2(config), + metadata: null, + discoveryClaimToken: null, + discoveryClaimedAt: null, ...overrides, }), ); } +async function loadSource(id: string): Promise { + const source = await store.getById(id); + if (!source) throw new Error(`source ${id} did not load`); + return source; +} + +async function claim(source: TrustedSource): Promise { + const token = await store.claimDiscovery(source); + if (token === null) throw new Error(`lease on ${source.id} was not granted`); + return token; +} + +const secondsAgo = (seconds: number) => new Date(Date.now() - seconds * 1000); + async function encryptedAdminConfig( surfaces: Partial>, ) { @@ -73,7 +91,7 @@ beforeAll(async () => { await testModules.loadModules(['inbound-auth-core']); await testDb.init(); - store = Container.get(TrustedSourceStore); + store = Container.get(TrustedSourceDbStore); cacheService = Container.get(CacheService); cipher = Container.get(Cipher); rows = Container.get(DataSource).getRepository(TrustedSourceEntity); @@ -299,6 +317,54 @@ describe('TrustedSourceStore (integration)', () => { expect((await rows.findOneByOrFail({ id: a.id })).name).toBe('Re-keyed'); }); + const staleMetadata = JSON.stringify({ + version: 1, + documents: [ + { + kind: 'jwks', + fetchedAt: '2026-10-01T12:00:00.000Z', + url: 'https://old.example.com/keys', + keys: [{ kid: 'old', kty: 'RSA', n: 'AQAB', e: 'AQAB' }], + }, + ], + }); + + it('drops the old metadata of a changed issuer or config and voids a running lease', async () => { + const row = await seedRow({ status: 'error', lastError: 'old', metadata: staleMetadata }); + const source = await loadSource(row.id); + const token = await claim(source); + + await store.update(row.id, { issuer: 'https://moved.example.com' }); + + expect( + await store.recordDiscovery(source, token, { status: 'healthy', lastError: null }), + ).toBe(false); + expect(await rows.findOneByOrFail({ id: row.id })).toMatchObject({ + metadata: null, + status: 'unchecked', + lastError: null, + discoveryClaimToken: null, + discoveryClaimedAt: null, + }); + expect(await loadSource(row.id)).toMatchObject({ metadata: null, status: 'unchecked' }); + }); + + it('leaves the discovery state alone when only the name changes', async () => { + const row = await seedRow({ status: 'healthy', metadata: staleMetadata }); + const source = await loadSource(row.id); + const token = await claim(source); + + await store.update(row.id, { name: 'Renamed' }); + + expect(await rows.findOneByOrFail({ id: row.id })).toMatchObject({ + status: 'healthy', + metadata: staleMetadata, + }); + expect( + await store.recordDiscovery(source, token, { status: 'healthy', lastError: null }), + ).toBe(true); + }); + it('revalidates the config against the row schema', async () => { const row = await seedRow(); @@ -326,6 +392,148 @@ describe('TrustedSourceStore (integration)', () => { }); }); + describe('discovery lease', () => { + const metadata: TrustedSourceMetadata = { + version: 1, + documents: [ + { + kind: 'jwks', + fetchedAt: '2026-10-01T12:00:00.000Z', + url: 'https://issuer.example.com/keys', + keys: [{ kid: 'k1', kty: 'RSA', n: 'AQAB', e: 'AQAB' }], + }, + ], + }; + + it('grants the lease once and again after it expires', async () => { + const row = await seedRow(); + const source = await loadSource(row.id); + + const first = await claim(source); + expect(await store.claimDiscovery(source)).toBeNull(); + expect(await rows.findOneByOrFail({ id: row.id })).toMatchObject({ + discoveryClaimToken: first, + discoveryClaimedAt: expect.any(Date), + }); + + await rows.update({ id: row.id }, { discoveryClaimedAt: secondsAgo(61) }); + const second = await claim(source); + expect(second).not.toBe(first); + expect((await rows.findOneByOrFail({ id: row.id })).discoveryClaimToken).toBe(second); + }); + + it('rejects a record whose token does not hold the lease and leaves the row alone', async () => { + const row = await seedRow(); + const source = await loadSource(row.id); + const token = await claim(source); + + const recorded = await store.recordDiscovery(source, `${token}-other`, { + metadata, + status: 'healthy', + lastError: null, + }); + + expect(recorded).toBe(false); + expect(await rows.findOneByOrFail({ id: row.id })).toMatchObject({ + metadata: null, + status: 'unchecked', + lastCheckedAt: null, + discoveryClaimToken: token, + }); + expect(await loadSource(row.id)).toMatchObject({ metadata: null, status: 'unchecked' }); + }); + + it('records a healthy result, releases the lease and stamps lastCheckedAt from the database', async () => { + const row = await seedRow(); + const source = await loadSource(row.id); + const token = await claim(source); + + const recorded = await store.recordDiscovery(source, token, { + metadata, + status: 'healthy', + lastError: null, + }); + + expect(recorded).toBe(true); + expect(await loadSource(row.id)).toMatchObject({ + metadata, + status: 'healthy', + lastError: null, + }); + const updated = await rows.findOneByOrFail({ id: row.id }); + expect(updated.discoveryClaimToken).toBeNull(); + expect(updated.discoveryClaimedAt).toBeNull(); + expect(updated.lastCheckedAt).toBeInstanceOf(Date); + expect(Math.abs(Date.now() - (updated.lastCheckedAt?.getTime() ?? 0))).toBeLessThan(5000); + }); + + it('keeps the previous metadata when a later run records an error', async () => { + const row = await seedRow(); + const source = await loadSource(row.id); + const token = await claim(source); + expect( + await store.recordDiscovery(source, token, { + metadata, + status: 'healthy', + lastError: null, + }), + ).toBe(true); + const healthy = await loadSource(row.id); + const retry = await claim(healthy); + + expect(await store.recordDiscovery(healthy, retry, { status: 'error', lastError: 'x' })).toBe( + true, + ); + + expect(await loadSource(row.id)).toMatchObject({ + metadata, + status: 'error', + lastError: 'x', + }); + }); + + it('reads unparseable metadata as null and still returns the source', async () => { + const row = await seedRow({ metadata: 'not json' }); + + expect(await store.getById(row.id)).toMatchObject({ id: row.id, metadata: null }); + }); + }); + + describe('listDue', () => { + it('returns only the due sources, unchecked first, then never checked, then oldest', async () => { + const healthyStale = await seedRow({ + status: 'healthy', + lastCheckedAt: secondsAgo(2 * 3600), + }); + await seedRow({ status: 'error', lastCheckedAt: secondsAgo(60) }); + const errorStale = await seedRow({ status: 'error', lastCheckedAt: secondsAgo(6 * 60) }); + await seedRow({ status: 'healthy', lastCheckedAt: secondsAgo(30 * 60) }); + const neverChecked = await seedRow({ status: 'healthy', lastCheckedAt: null }); + const unchecked = await seedRow({ status: 'unchecked', lastCheckedAt: secondsAgo(10) }); + + const due = await store.listDue(10); + + expect(due.map((source) => source.id)).toEqual([ + unchecked.id, + neverChecked.id, + healthyStale.id, + errorStale.id, + ]); + }); + + it('stops at the limit and skips rows that do not load', async () => { + const unchecked = await seedRow(); + const neverChecked = await seedRow({ status: 'healthy', lastCheckedAt: null }); + await seedRow({ status: 'healthy', lastCheckedAt: secondsAgo(2 * 3600) }); + await seedRow({ status: 'unchecked', config: 'garbage' }); + + expect((await store.listDue(3)).map((source) => source.id)).toEqual([ + unchecked.id, + neverChecked.id, + ]); + }); + }); + describe('guards', () => { it.each(['update', 'delete'] as const)('%s refuses a system-managed row', async (action) => { const row = await seedRow({ managedBy: 'system' }); From 3c18f58ee09fd1f99c0a22df796b1daf86d52127 Mon Sep 17 00:00:00 2001 From: Matsu Date: Tue, 6 Oct 2026 09:16:03 +0000 Subject: [PATCH 16/61] ci: Reduce sync noise for sec-sync-bundle-branches (#40385) --- .github/WORKFLOWS.md | 10 ++++++---- .../workflows/sec-sync-bundle-branches.yml | 20 ++++--------------- .../workflows/sec-sync-public-to-private.yml | 11 ---------- 3 files changed, 10 insertions(+), 31 deletions(-) diff --git a/.github/WORKFLOWS.md b/.github/WORKFLOWS.md index 5396d475282..40a91304c54 100644 --- a/.github/WORKFLOWS.md +++ b/.github/WORKFLOWS.md @@ -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 diff --git a/.github/workflows/sec-sync-bundle-branches.yml b/.github/workflows/sec-sync-bundle-branches.yml index 7799b65387b..bdbb8e39537 100644 --- a/.github/workflows/sec-sync-bundle-branches.yml +++ b/.github/workflows/sec-sync-bundle-branches.yml @@ -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 }} diff --git a/.github/workflows/sec-sync-public-to-private.yml b/.github/workflows/sec-sync-public-to-private.yml index de01609c122..496fb92186d 100644 --- a/.github/workflows/sec-sync-public-to-private.yml +++ b/.github/workflows/sec-sync-public-to-private.yml @@ -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: From 958d23d81cd99d7b9fd63d2484b79382c2d20c23 Mon Sep 17 00:00:00 2001 From: Anne Aguirre Date: Tue, 6 Oct 2026 09:25:08 +0000 Subject: [PATCH 17/61] fix(editor): Small UI fixes to budget guardrail setup components (#40383) --- .../N8nInputNumber/InputNumber.test.ts | 14 +++++++ .../N8nInputNumber/InputNumber.types.ts | 2 + .../components/N8nInputNumber/InputNumber.vue | 13 ++++++ .../N8nInputNumber/component-input-number.md | 1 + .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../components/AgentBudgetMonthlyModal.vue | 4 +- .../components/AgentBudgetNoticeCard.vue | 4 +- .../agents/components/AgentBudgetPanel.vue | 42 ++++++++++++------- .../components/AgentBudgetSessionModal.vue | 4 +- 9 files changed, 66 insertions(+), 19 deletions(-) diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.test.ts index aba511eb237..929dfaf4fae 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.test.ts @@ -328,6 +328,20 @@ describe('components/N8nInputNumber', () => { }); describe('slots', () => { + it('should not render a prefix by default', () => { + const wrapper = render(InputNumber); + expect(wrapper.container.querySelector('.prefix')).not.toBeInTheDocument(); + }); + + it('should render prefix slot content', () => { + const wrapper = render(InputNumber, { + slots: { + prefix: '$', + }, + }); + expect(wrapper.getByText('$')).toBeInTheDocument(); + }); + it('should render custom increment slot', () => { const wrapper = render(InputNumber, { props: { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.types.ts b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.types.ts index 8debe0214fd..7c675785aed 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.types.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.types.ts @@ -30,6 +30,8 @@ export type InputNumberControlSlotProps = { }; export type InputNumberSlots = { + /** Content shown inside the input, before the number (e.g. a currency symbol). */ + prefix?: () => unknown; /** Fully custom increment control. Default: button with plus/chevron icon. */ increment?: (props: InputNumberControlSlotProps) => unknown; /** Fully custom decrement control. Default: button with minus/chevron icon. */ diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.vue b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.vue index 025fe9c140e..693e5ee88b0 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.vue @@ -191,6 +191,10 @@ const sizeClass = computed(() => sizes[props.size ?? 'medium']); + + + + sizes[props.size ?? 'medium']); padding-inline: var(--spacing--3xs); } +.prefix { + display: flex; + align-items: center; + flex-shrink: 0; + padding-left: var(--input--padding); + color: var(--color--text--shade-1); + opacity: 0.7; +} + .button { display: flex; align-items: center; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/component-input-number.md b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/component-input-number.md index 17d20ce191a..0b47b20a65a 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/component-input-number.md +++ b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/component-input-number.md @@ -47,6 +47,7 @@ Same contract as `N8nInput`, so `ParameterInput` can programmatically focus, blu **Slots** +- `prefix` - Content shown before the number, e.g. a currency symbol. - `increment` - Fully custom increment control. Scope: `{ ui: { class } }`. Default: button with plus/chevron icon. - `decrement` - Fully custom decrement control. Scope: `{ ui: { class } }`. Default: button with minus/chevron icon. diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 935811d05e1..d616f5325f7 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -8366,6 +8366,7 @@ "agents.builder.budget.session.suffix": "per session", "agents.builder.budget.session.stop": "The agent stops when a session reaches this cap.", "agents.builder.budget.notSet": "Not set", + "agents.builder.budget.currencyPrefix": "$", "agents.chat.budget.notNow": "Not now", "agents.chat.budget.increase": "Increase cap", "agents.chat.budget.monthly.title": "You've reached your monthly budget.", diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentBudgetMonthlyModal.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentBudgetMonthlyModal.vue index 5207262b192..4f556658643 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentBudgetMonthlyModal.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentBudgetMonthlyModal.vue @@ -90,7 +90,9 @@ function save() { :class="$style.amountInput" data-testid="agent-budget-monthly-amount" @update:model-value="onAmount" - /> + > + + {{ i18n.baseText('agents.builder.budget.monthly.suffix') }} diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentBudgetNoticeCard.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentBudgetNoticeCard.vue index 166abf4708f..6b584aa6297 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentBudgetNoticeCard.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentBudgetNoticeCard.vue @@ -90,7 +90,9 @@ function increase() { :disabled="pending" data-testid="agent-budget-notice-amount" @update:model-value="onAmount" - /> + > + +
{ - {{ monthlyValue }} - - {{ - i18n.baseText('agents.builder.budget.monthly.alert', { - interpolate: { percent: budget.alertThresholdPercent }, - }) - }} - +
+ {{ monthlyValue }} + + {{ + i18n.baseText('agents.builder.budget.monthly.alert', { + interpolate: { percent: budget.alertThresholdPercent }, + }) + }} + +
@@ -205,7 +207,9 @@ const sessionValue = computed(() => { - {{ sessionValue }} +
+ {{ sessionValue }} +
@@ -266,10 +270,16 @@ const sessionValue = computed(() => { } .rowValue { + display: flex; + align-items: center; + gap: var(--spacing--xs); +} + +.rowValueStack { display: flex; flex-direction: column; align-items: flex-end; - gap: var(--spacing--xs); + gap: var(--spacing--4xs); } .track { @@ -286,7 +296,7 @@ const sessionValue = computed(() => { .row { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: var(--spacing--sm); width: 100%; diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentBudgetSessionModal.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentBudgetSessionModal.vue index 3444b7ec370..153762132fc 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentBudgetSessionModal.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentBudgetSessionModal.vue @@ -62,7 +62,9 @@ function save() { :class="$style.amountInput" data-testid="agent-budget-session-amount" @update:model-value="onAmount" - /> + > + + {{ i18n.baseText('agents.builder.budget.session.suffix') }} From 8b68b0fcdd8c31e67057513dd90f5d9c1164d27f Mon Sep 17 00:00:00 2001 From: Benjamin Schroth <68321970+schrothbn@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:35:35 +0000 Subject: [PATCH 18/61] feat: Add the n8n Chat agent chat page, agents library and merged chat lists (no-changelog) (#40335) --- .../list-n8n-chat-threads-query.dto.test.ts | 37 ++ packages/@n8n/api-types/src/agents/dto.ts | 13 + packages/@n8n/api-types/src/agents/types.ts | 23 +- packages/@n8n/telemetry/src/events/agents.ts | 45 ++ .../src/auth/__tests__/auth.service.test.ts | 14 + packages/cli/src/auth/auth.service.ts | 5 +- .../__tests__/agent-chat.controller.test.ts | 152 ++++- ...agent-execution-update-broadcaster.test.ts | 21 + .../__tests__/agent-execution.service.test.ts | 131 ++++ .../agents/__tests__/agent.repository.test.ts | 54 ++ .../__tests__/agents-list.controller.test.ts | 106 ++++ .../agents/__tests__/agents.service.test.ts | 234 ++++++- .../modules/agents/agent-chat.controller.ts | 18 +- .../agent-execution-update-broadcaster.ts | 4 +- .../modules/agents/agent-execution.service.ts | 63 ++ .../agents/agent-push-recipients.service.ts | 11 +- .../agents/agent-threads.controller.ts | 3 +- .../modules/agents/agents-list.controller.ts | 57 +- .../cli/src/modules/agents/agents.service.ts | 86 ++- .../agent-execution-thread.repository.ts | 136 +++- .../agents/repositories/agent.repository.ts | 90 ++- .../__tests__/parse-thread-list-limit.test.ts | 31 + .../cli/src/modules/agents/utils/agent-ref.ts | 20 + .../agents/utils/agent-thread-access.ts | 2 +- .../agents/utils/parse-thread-list-limit.ts | 5 + .../agents/n8n-chat-page.api.test.ts | 217 +++++++ ...ecution-thread-n8n-chat.repository.test.ts | 374 +++++++++++ .../repositories/agent.repository.test.ts | 121 ++++ .../components/N8nMenuItem/MenuItem.test.ts | 12 + .../src/components/N8nMenuItem/MenuItem.vue | 16 +- .../frontend/@n8n/i18n/src/locales/en.json | 11 + .../frontend/@n8n/stores/src/constants.ts | 1 + .../src/app/constants/experiments.ts | 8 + .../frontend/editor-ui/src/app/router.test.ts | 4 - packages/frontend/editor-ui/src/app/router.ts | 33 +- .../src/app/stores/posthog.store.test.ts | 43 +- .../editor-ui/src/app/stores/posthog.store.ts | 20 + .../AgentChatMessageAttachments.test.ts | 19 +- .../agents/__tests__/AgentChatPanel.test.ts | 144 ++++- .../__tests__/AgentPreviewMoreMenu.test.ts | 1 + .../__tests__/AgentSessionsListView.spec.ts | 2 + .../__tests__/module.descriptor.test.ts | 122 ++++ .../agents/__tests__/useAgentApi.test.ts | 149 ++++- .../__tests__/useAgentChatStream.test.ts | 156 +++++ .../__tests__/useAgentPermissions.test.ts | 23 + .../__tests__/useAgentTelemetry.test.ts | 84 +++ .../AgentChatMessageAttachments.vue | 15 +- .../agents/components/AgentChatPanel.vue | 599 ++++++++++-------- .../components/AgentPreviewMoreMenu.vue | 3 + .../agents/components/AgentSessionsFilter.vue | 1 + .../components/agentChatInjectionKeys.ts | 8 + .../agents/composables/useAgentApi.ts | 163 ++++- .../agents/composables/useAgentChatStream.ts | 46 +- .../agents/composables/useAgentPermissions.ts | 3 +- .../agents/composables/useAgentTelemetry.ts | 51 ++ .../composables/useAgentsN8nChatFlag.ts | 28 +- .../agents/composables/useBackOrFallback.ts | 21 + .../src/features/agents/constants.ts | 15 + .../src/features/agents/module.descriptor.ts | 41 ++ .../agents/n8nChatPage/AgentN8nChatView.vue | 314 +++++++++ .../n8nChatPage/N8nChatAgentLibraryView.vue | 106 ++++ .../__tests__/AgentN8nChatView.router.test.ts | 125 ++++ .../__tests__/AgentN8nChatView.test.ts | 450 +++++++++++++ .../__tests__/N8nChatAgentLibraryView.test.ts | 210 ++++++ .../components/N8nChatAgentCard.vue | 90 +++ .../components/N8nChatAgentGrid.vue | 56 ++ .../components/N8nChatPageLayout.vue | 95 +++ .../components/N8nChatThreadHistory.vue | 158 +++++ .../n8nChatPage/components/RecentChatIcon.vue | 25 + .../__tests__/N8nChatAgentCard.test.ts | 83 +++ .../__tests__/N8nChatAgentGrid.test.ts | 53 ++ .../__tests__/N8nChatThreadHistory.test.ts | 168 +++++ .../__tests__/RecentChatIcon.test.ts | 54 ++ .../composables/useN8nChatAgents.test.ts | 230 +++++++ .../composables/useN8nChatAgents.ts | 76 +++ .../n8nChatPage/mergeRecentChats.test.ts | 126 ++++ .../agents/n8nChatPage/mergeRecentChats.ts | 93 +++ .../n8nChatPage/n8nChatThreads.store.test.ts | 154 +++++ .../n8nChatPage/n8nChatThreads.store.ts | 75 +++ .../n8nChatPage/useMergedChatHistory.test.ts | 238 +++++++ .../n8nChatPage/useMergedChatHistory.ts | 101 +++ .../usePagedN8nChatThreads.test.ts | 207 ++++++ .../n8nChatPage/usePagedN8nChatThreads.ts | 108 ++++ .../agents/n8nChatPage/useRecentChats.test.ts | 46 ++ .../agents/n8nChatPage/useRecentChats.ts | 43 ++ .../src/features/agents/utils/errors.ts | 9 + .../agents/views/AgentBuilderView.vue | 10 +- .../agents/views/AgentSessionTimelineView.vue | 25 +- .../agents/views/AgentSessionsListView.vue | 2 + .../chatHub/components/SkeletonAgentCard.vue | 10 +- .../ai/instanceAi/InstanceAiThreadsView.vue | 68 +- .../__tests__/InstanceAiEmptyView.test.ts | 2 +- .../__tests__/InstanceAiThreadsView.test.ts | 104 ++- .../components/InstanceAiThreadList.vue | 146 +++-- .../__tests__/InstanceAiThreadList.test.ts | 192 +++++- .../useInstanceAiThreadHistory.test.ts | 71 +++ .../composables/useInstanceAiThreadHistory.ts | 20 +- .../ai/instanceAi/instanceAi.threadRuntime.ts | 15 +- .../components/ChatHistoryDropdown.test.ts | 45 +- .../shared/components/ChatHistoryDropdown.vue | 39 +- .../components/ProjectNavigation.test.ts | 269 +++++++- .../projects/components/ProjectNavigation.vue | 98 ++- .../settings/context/context.utils.ts | 20 +- 103 files changed, 8033 insertions(+), 511 deletions(-) create mode 100644 packages/@n8n/api-types/src/agents/__tests__/list-n8n-chat-threads-query.dto.test.ts create mode 100644 packages/cli/src/modules/agents/utils/__tests__/parse-thread-list-limit.test.ts create mode 100644 packages/cli/src/modules/agents/utils/agent-ref.ts create mode 100644 packages/cli/src/modules/agents/utils/parse-thread-list-limit.ts create mode 100644 packages/cli/test/integration/agents/n8n-chat-page.api.test.ts create mode 100644 packages/cli/test/integration/database/repositories/agent-execution-thread-n8n-chat.repository.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/module.descriptor.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/components/agentChatInjectionKeys.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useBackOrFallback.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/AgentN8nChatView.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/N8nChatAgentLibraryView.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/__tests__/AgentN8nChatView.router.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/__tests__/AgentN8nChatView.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/__tests__/N8nChatAgentLibraryView.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatAgentCard.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatAgentGrid.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatPageLayout.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatThreadHistory.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/RecentChatIcon.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentCard.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentGrid.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatThreadHistory.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/RecentChatIcon.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nChatAgents.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nChatAgents.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/mergeRecentChats.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/mergeRecentChats.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/n8nChatThreads.store.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/n8nChatThreads.store.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/useMergedChatHistory.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/useMergedChatHistory.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/usePagedN8nChatThreads.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/usePagedN8nChatThreads.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/useRecentChats.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/n8nChatPage/useRecentChats.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/utils/errors.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiThreadHistory.test.ts diff --git a/packages/@n8n/api-types/src/agents/__tests__/list-n8n-chat-threads-query.dto.test.ts b/packages/@n8n/api-types/src/agents/__tests__/list-n8n-chat-threads-query.dto.test.ts new file mode 100644 index 00000000000..7f8aea4139d --- /dev/null +++ b/packages/@n8n/api-types/src/agents/__tests__/list-n8n-chat-threads-query.dto.test.ts @@ -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); + }); +}); diff --git a/packages/@n8n/api-types/src/agents/dto.ts b/packages/@n8n/api-types/src/agents/dto.ts index d5454f9a2ed..33a681ddb75 100644 --- a/packages/@n8n/api-types/src/agents/dto.ts +++ b/packages/@n8n/api-types/src/agents/dto.ts @@ -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 = [ @@ -114,6 +118,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(), }) {} diff --git a/packages/@n8n/api-types/src/agents/types.ts b/packages/@n8n/api-types/src/agents/types.ts index 19199e366f2..5417c708fd1 100644 --- a/packages/@n8n/api-types/src/agents/types.ts +++ b/packages/@n8n/api-types/src/agents/types.ts @@ -413,13 +413,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 +431,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; } diff --git a/packages/@n8n/telemetry/src/events/agents.ts b/packages/@n8n/telemetry/src/events/agents.ts index 163acc538cf..6e1d663e040 100644 --- a/packages/@n8n/telemetry/src/events/agents.ts +++ b/packages/@n8n/telemetry/src/events/agents.ts @@ -152,6 +152,11 @@ const agentPublishTrigger = z 'What caused the publish, as opposed to who performed it. channel_connect and slack_setup are historical values only.', ); +const n8nChatVariant = z + .string() + .nullable() + .describe('The 125_agents_n8n_chat PostHog variant; null when the user has no flag value'); + const agentPublish = { ...agentActorIdentity, ...agentCapabilityProfile, @@ -657,4 +662,44 @@ export const AGENTS_TELEMETRY = defineTelemetryEvents({ session_id: sessionId, }), }, + USER_SELECTED_N8N_CHAT_AGENT: { + name: 'User selected n8n chat agent', + description: + 'The user picked a published agent to chat with over n8n Chat, from a card or the agents library.', + properties: z.object({ + agent_id: z.string(), + source: z.enum(['card', 'library']), + variant: n8nChatVariant, + session_id: sessionId, + }), + }, + USER_SENT_MESSAGE_TO_N8N_CHAT_AGENT: { + name: 'User sent message to n8n chat agent', + description: 'The user sent a chat message to a published agent over n8n Chat.', + properties: z.object({ + agent_id: z.string(), + thread_id: z.string(), + is_new_thread: z.boolean(), + variant: n8nChatVariant, + session_id: sessionId, + }), + }, + USER_CLICKED_N8N_CHAT_SIDEBAR_ITEM: { + name: 'User clicked n8n chat sidebar item', + description: + 'The user clicked a "New chat" or existing-chat item in the sidebar\'s n8n Chat section.', + properties: z.discriminatedUnion('item', [ + z.object({ + item: z.literal('new_chat'), + variant: n8nChatVariant, + session_id: sessionId, + }), + z.object({ + item: z.literal('chat'), + chat_type: z.enum(['assistant', 'agent']), + variant: n8nChatVariant, + session_id: sessionId, + }), + ]), + }, }); diff --git a/packages/cli/src/auth/__tests__/auth.service.test.ts b/packages/cli/src/auth/__tests__/auth.service.test.ts index 63d6a4ac4b5..b33bd2a62b1 100644 --- a/packages/cli/src/auth/__tests__/auth.service.test.ts +++ b/packages/cli/src/auth/__tests__/auth.service.test.ts @@ -944,6 +944,20 @@ describe('AuthService', () => { expect(res.cookie).not.toHaveBeenCalled(); }); + it('should skip browserId check for n8n Chat attachment GET requests', async () => { + userRepository.findOne.mockResolvedValue(user); + const req = mock({ + browserId: 'another-browser', + method: 'GET', + baseUrl: '/rest/projects/9xbqXk3hZVlVlPsN/agents/v2', + route: { path: '/:agentId/n8n-chat/attachments/:attachmentId' }, + }); + + const result = await authService.resolveJwt(validToken, req, res); + expect(result).toEqual([user, { usedMfa: false }]); + expect(res.cookie).not.toHaveBeenCalled(); + }); + it('should not skip browserId check for other project-scoped agent routes', async () => { userRepository.findOne.mockResolvedValue(user); const req = mock({ diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index 96c5bae3114..0676855c56d 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -138,9 +138,10 @@ export class AuthService { // Agent chat attachments render via tags, which can't send the // browser-id header. The controller prefix carries a resolved - // :projectId in req.baseUrl, so this one needs a pattern. + // :projectId in req.baseUrl, so this one needs a pattern. It covers + // both the agent preview chat and n8n Chat routes. new RegExp( - `^/${escapeRegExp(restEndpoint)}/projects/[^/]+/agents/v2/:agentId/chat/attachments/:attachmentId$`, + `^/${escapeRegExp(restEndpoint)}/projects/[^/]+/agents/v2/:agentId/(?:n8n-)?chat/attachments/:attachmentId$`, ), ]; } diff --git a/packages/cli/src/modules/agents/__tests__/agent-chat.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-chat.controller.test.ts index 5a12d65f3d9..8945c468317 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-chat.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-chat.controller.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:events'; +import { PassThrough, Readable } from 'node:stream'; import type { SerializableAgentState } from '@n8n/agents'; import type { AgentsConfig } from '@n8n/config'; import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; @@ -38,6 +39,8 @@ import { expectProjectScopedAgentRoutes, getRoutesByHandlerName, } from './test-utils/controller-route-metadata'; +import { productionChatMemoryResourceId } from '../utils/agent-memory-scope'; +import { N8N_CHAT_PRODUCTION_SOURCE } from '../utils/agent-thread-access'; function makeController() { const agentsService = @@ -168,9 +171,12 @@ describe('AgentChatController route access scopes', () => { ['productionChatResume', 'agent:execute'], ['cancelProductionChatExecution', 'agent:execute'], ['cancelProductionChatRun', 'agent:execute'], - ['getProductionChatMessages', 'agent:read'], - ['getProductionChatAttachment', 'agent:read'], - ['getProductionQueuedMessages', 'agent:read'], + // The n8n Chat audience (`project:chatUser`) holds `agent:execute`, not + // `agent:read` — these three stay reachable to a chat-only member reading + // their own thread. See the ownership tests below `requireProductionThread`. + ['getProductionChatMessages', 'agent:execute'], + ['getProductionChatAttachment', 'agent:execute'], + ['getProductionQueuedMessages', 'agent:execute'], ['updateProductionQueuedMessage', 'agent:execute'], ['removeProductionQueuedMessage', 'agent:execute'], ['chat', 'agent:execute'], @@ -1262,3 +1268,143 @@ describe('AgentChatController production n8n Chat', () => { await expect(controller.removeProductionQueuedMessage(req)).rejects.toThrow(NotFoundError); }); }); + +// Scope was lowered from `agent:read` to `agent:execute` on these three routes +// so a chat-only member (`project:chatUser`, which holds `agent:execute` but +// not `agent:read`) can read their own n8n Chat history. Each route's own +// ownership check (not the scope) is what keeps a chat-user out of someone +// else's thread — these tests prove that check still runs. +describe('AgentChatController n8n Chat read-route ownership', () => { + const projectId = 'project-1'; + const agentId = 'agent-1'; + const userId = 'user-1'; + + describe('getProductionChatMessages', () => { + it("serves the requesting user's own thread", async () => { + const { controller, agentsService, agentExecutionOrchestratorService, agentsBuilderService } = + makeController(); + agentsService.isN8nChatPublished.mockResolvedValue(true); + agentExecutionOrchestratorService.getConversationHistory.mockResolvedValue({ + messages: [], + activeExecutionId: null, + } as never); + agentsBuilderService.findOpenCheckpointForThread.mockResolvedValue(null); + + const req = { params: { projectId, agentId, threadId: 'thread-1' }, user: { id: userId } }; + + await expect(controller.getProductionChatMessages(req as never)).resolves.toMatchObject({ + activeExecutionId: null, + }); + }); + + it("404s another user's thread instead of serving it", async () => { + const { controller, agentsService, agentExecutionService } = makeController(); + agentsService.isN8nChatPublished.mockResolvedValue(true); + agentExecutionService.canUseProductionChatThread.mockResolvedValue(false); + + const req = { + params: { projectId, agentId, threadId: 'foreign-thread' }, + user: { id: userId }, + }; + + await expect(controller.getProductionChatMessages(req as never)).rejects.toThrow( + NotFoundError, + ); + }); + }); + + describe('getProductionQueuedMessages', () => { + it("serves the requesting user's own queue", async () => { + const { controller, agentsService, messageQueue } = makeController(); + agentsService.isN8nChatPublished.mockResolvedValue(true); + messageQueue.listPending.mockResolvedValue({ items: [], steerableExecutionId: null }); + + const req = { params: { projectId, agentId, threadId: 'thread-1' }, user: { id: userId } }; + + await expect(controller.getProductionQueuedMessages(req as never)).resolves.toEqual({ + items: [], + steerableExecutionId: null, + }); + }); + + it("propagates the service's rejection of another user's thread instead of swallowing it", async () => { + const { controller, agentsService, messageQueue } = makeController(); + agentsService.isN8nChatPublished.mockResolvedValue(true); + messageQueue.listPending.mockRejectedValue(new NotFoundError('Session not found')); + + const req = { + params: { projectId, agentId, threadId: 'foreign-thread' }, + user: { id: userId }, + }; + + await expect(controller.getProductionQueuedMessages(req as never)).rejects.toThrow( + NotFoundError, + ); + }); + }); + + describe('getProductionChatAttachment', () => { + function makeAttachment(overrides: Record = {}) { + return { + id: 'att-1', + mimeType: 'text/plain', + fileName: 'notes.txt', + fileSizeBytes: 5, + threadId: 'thread-1', + source: N8N_CHAT_PRODUCTION_SOURCE, + resourceId: productionChatMemoryResourceId(userId), + ...overrides, + }; + } + + it("streams the requesting user's own attachment", async () => { + const { controller, agentsService, agentChatAttachmentService } = makeController(); + agentsService.isN8nChatPublished.mockResolvedValue(true); + agentChatAttachmentService.getForAgent.mockResolvedValue(makeAttachment() as never); + agentChatAttachmentService.getStream.mockResolvedValue(Readable.from(['hi']) as never); + + const req = { + params: { projectId, agentId, attachmentId: 'att-1' }, + user: { id: userId }, + } as never; + const res = Object.assign(new PassThrough(), { setHeader: vi.fn() }) as never; + + await controller.getProductionChatAttachment(req, res); + expect(agentChatAttachmentService.getStream).toHaveBeenCalled(); + }); + + it("404s another user's attachment instead of streaming it", async () => { + const { controller, agentsService, agentChatAttachmentService } = makeController(); + agentsService.isN8nChatPublished.mockResolvedValue(true); + agentChatAttachmentService.getForAgent.mockResolvedValue( + makeAttachment({ resourceId: productionChatMemoryResourceId('other-user') }) as never, + ); + + const req = { + params: { projectId, agentId, attachmentId: 'att-1' }, + user: { id: userId }, + } as never; + const res = { setHeader: vi.fn() } as never; + + await expect(controller.getProductionChatAttachment(req, res)).rejects.toThrow(NotFoundError); + expect(agentChatAttachmentService.getStream).not.toHaveBeenCalled(); + }); + + it("404s when the attachment belongs to the user's thread but that thread isn't theirs anymore", async () => { + const { controller, agentsService, agentChatAttachmentService, agentExecutionService } = + makeController(); + agentsService.isN8nChatPublished.mockResolvedValue(true); + agentChatAttachmentService.getForAgent.mockResolvedValue(makeAttachment() as never); + agentExecutionService.canUseProductionChatThread.mockResolvedValue(false); + + const req = { + params: { projectId, agentId, attachmentId: 'att-1' }, + user: { id: userId }, + } as never; + const res = { setHeader: vi.fn() } as never; + + await expect(controller.getProductionChatAttachment(req, res)).rejects.toThrow(NotFoundError); + expect(agentChatAttachmentService.getStream).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution-update-broadcaster.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution-update-broadcaster.test.ts index 8e4ea918f55..2134fb46ab5 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution-update-broadcaster.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution-update-broadcaster.test.ts @@ -98,6 +98,27 @@ describe('AgentExecutionUpdateBroadcaster', () => { for (const [, userIds] of push.sendToUsers.mock.calls) expect(userIds).toEqual(['user-2']); }); + it('resolves the owner of a private thread by agent:execute, so chat-only members get updates', async () => { + threadRepository.findOneBy.mockResolvedValue({ + ...thread, + accessScope: 'user', + ownerId: 'user-2', + }); + + broadcaster.notify(update); + + await vi.waitFor(() => expect(push.sendToUsers).toHaveBeenCalled()); + expect(roleService.rolesWithScope).toHaveBeenCalledWith('project', ['agent:execute']); + expect(roleService.rolesWithScope).not.toHaveBeenCalledWith('project', ['agent:read']); + }); + + it('resolves the readers of a shared project thread by agent:read', async () => { + broadcaster.notify(update); + + await vi.waitFor(() => expect(push.sendToUsers).toHaveBeenCalled()); + expect(roleService.rolesWithScope).toHaveBeenCalledWith('project', ['agent:read']); + }); + it.each(['isWorker', 'isMultiMain'] as const)( 'delivers parent task updates locally and relays them with %s', async (mode) => { diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts index 4eade5f0611..f9787e29c49 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts @@ -54,6 +54,26 @@ function makeThread(overrides: Partial = {}): AgentExecuti } as AgentExecutionThread; } +function makeThreadWithAgent( + overrides: Partial = {}, + schemaOverrides: Record = {}, +): AgentExecutionThread { + return makeThread({ + id: 'thread-1', + title: 'Refund status', + updatedAt: new Date('2025-01-02T00:00:00Z'), + agent: { + id: 'agent-1', + name: 'Support', + projectId: 'project-1', + activeVersion: { + schema: { name: 'Support', model: 'm', instructions: 'i', ...schemaOverrides }, + }, + }, + ...overrides, + } as never); +} + function makeMessageRecord(overrides: Partial = {}): MessageRecord { return { assistantResponse: 'Done', @@ -1605,6 +1625,117 @@ describe('AgentExecutionService', () => { }); }); + describe('findN8nChatThreadsForAgents', () => { + const userId = 'user-1'; + + it('forwards the agent ids and page request to the repository', async () => { + agentExecutionThreadRepository.findN8nChatThreadsForOwner.mockResolvedValue({ + threads: [], + nextCursor: null, + }); + + await service.findN8nChatThreadsForAgents(userId, ['agent-1'], 20, 'cursor-1'); + + expect(agentExecutionThreadRepository.findN8nChatThreadsForOwner).toHaveBeenCalledWith( + userId, + ['agent-1'], + 20, + 'cursor-1', + ); + }); + + it('maps each thread to the narrow chat-item shape, never the agent config', async () => { + agentExecutionThreadRepository.findN8nChatThreadsForOwner.mockResolvedValue({ + threads: [ + makeThreadWithAgent( + {}, + { personalisation: { icon: 'bot', gradient: { from: '#000000', to: '#FFFFFF' } } }, + ), + ], + nextCursor: 'next-cursor', + }); + + const result = await service.findN8nChatThreadsForAgents(userId, ['agent-1'], 20); + + expect(result).toEqual({ + data: [ + { + id: 'thread-1', + title: 'Refund status', + updatedAt: '2025-01-02T00:00:00.000Z', + agent: { + id: 'agent-1', + name: 'Support', + personalisation: { icon: 'bot', gradient: { from: '#000000', to: '#FFFFFF' } }, + projectId: 'project-1', + }, + }, + ], + nextCursor: 'next-cursor', + }); + expect(JSON.stringify(result)).not.toContain('schema'); + }); + + it('omits personalisation when the agent has none', async () => { + agentExecutionThreadRepository.findN8nChatThreadsForOwner.mockResolvedValue({ + threads: [ + makeThreadWithAgent({ + agent: { + id: 'agent-2', + name: 'Plain', + projectId: 'project-1', + activeVersion: { schema: { name: 'Plain', model: 'm', instructions: 'i' } }, + }, + } as never), + ], + nextCursor: null, + }); + + const result = await service.findN8nChatThreadsForAgents(userId, ['agent-2'], 20); + + expect(result.data[0].agent).not.toHaveProperty('personalisation'); + }); + }); + + describe('findN8nChatThreadForAgents', () => { + const userId = 'user-1'; + + it('forwards the agent ids and thread id to the repository', async () => { + agentExecutionThreadRepository.findN8nChatThreadForOwner.mockResolvedValue(null); + + await service.findN8nChatThreadForAgents(userId, ['agent-1'], 'thread-1'); + + expect(agentExecutionThreadRepository.findN8nChatThreadForOwner).toHaveBeenCalledWith( + userId, + ['agent-1'], + 'thread-1', + ); + }); + + it('maps the thread to the narrow chat-item shape, never the agent config', async () => { + agentExecutionThreadRepository.findN8nChatThreadForOwner.mockResolvedValue( + makeThreadWithAgent(), + ); + + const result = await service.findN8nChatThreadForAgents(userId, ['agent-1'], 'thread-1'); + + expect(result).toEqual({ + id: 'thread-1', + title: 'Refund status', + updatedAt: '2025-01-02T00:00:00.000Z', + agent: { id: 'agent-1', name: 'Support', projectId: 'project-1' }, + }); + }); + + it('returns null when the repository finds no matching thread', async () => { + agentExecutionThreadRepository.findN8nChatThreadForOwner.mockResolvedValue(null); + + await expect( + service.findN8nChatThreadForAgents(userId, ['agent-1'], 'thread-1'), + ).resolves.toBeNull(); + }); + }); + describe('canUseDraftThread', () => { it.each<{ name: string; diff --git a/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts b/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts index 58d57791fe3..abbfe59cd3e 100644 --- a/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts @@ -316,6 +316,60 @@ describe('AgentRepository', () => { }); }); + describe('findChatReachableById', () => { + const makeSingleQb = (result: Agent | null = null) => ({ + leftJoinAndSelect: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + andWhere: vi.fn().mockReturnThis(), + getOne: vi.fn().mockResolvedValue(result), + }); + + it('returns null without querying when projectIds is empty', async () => { + const createQueryBuilder = vi.spyOn(repository, 'createQueryBuilder'); + + const result = await repository.findChatReachableById('agent-1', []); + + expect(result).toBeNull(); + expect(createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('filters by id and the availableInChat predicate, joining project and activeVersion', async () => { + const agent = mock({ id: 'agent-1' }); + const mockQb = makeSingleQb(agent); + vi.spyOn(repository, 'createQueryBuilder').mockReturnValue(mockQb as never); + + const result = await repository.findChatReachableById('agent-1', ['project-1']); + + expect(mockQb.leftJoinAndSelect).toHaveBeenCalledWith('agent.activeVersion', 'activeVersion'); + expect(mockQb.leftJoinAndSelect).toHaveBeenCalledWith('agent.project', 'project'); + expect(mockQb.where).toHaveBeenCalledWith('agent.id = :id', { id: 'agent-1' }); + expect(mockQb.andWhere).toHaveBeenCalledWith('agent.projectId IN (:...projectIds)', { + projectIds: ['project-1'], + }); + expect(mockQb.andWhere).toHaveBeenCalledWith( + expect.stringContaining('json_each'), + expect.objectContaining({ n8nChatType: 'n8n_chat' }), + ); + expect(result).toBe(agent); + }); + + it('omits the project filter when project access is global', async () => { + const mockQb = makeSingleQb(); + vi.spyOn(repository, 'createQueryBuilder').mockReturnValue(mockQb as never); + + await repository.findChatReachableById('agent-1', null); + + expect(mockQb.andWhere).toHaveBeenCalledWith( + expect.stringContaining('json_each'), + expect.anything(), + ); + expect(mockQb.andWhere).not.toHaveBeenCalledWith( + 'agent.projectId IN (:...projectIds)', + expect.anything(), + ); + }); + }); + describe('findMcpAvailabilityCandidates', () => { it('omits the where clause when all agents are requested', async () => { const find = vi.spyOn(repository, 'find').mockResolvedValue([]); diff --git a/packages/cli/src/modules/agents/__tests__/agents-list.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agents-list.controller.test.ts index 32b09d0074d..edb4a7499aa 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-list.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-list.controller.test.ts @@ -1,6 +1,8 @@ import type { Response } from 'express'; import { mock } from 'vitest-mock-extended'; +import { NotFoundError } from '@n8n/errors'; + import type { AgentsService } from '../agents.service'; import { AgentsListController } from '../agents-list.controller'; @@ -67,4 +69,108 @@ describe('AgentsListController', () => { expect(res.json).toHaveBeenCalledWith(emptyResponse); }); + + describe('getChatAgent', () => { + it("returns the narrow chat item for the registry to wrap in { data } — it must not call res.json itself, or the registry's wrapping never runs", async () => { + const { controller, agentsService } = makeController(); + const item = { id: 'agent-1', name: 'Support', project: { id: 'p1', name: 'Team' } }; + agentsService.findChatReachableAgentForUser.mockResolvedValue(item as never); + + const result = await controller.getChatAgent(req, mock(), 'agent-1'); + + expect(agentsService.findChatReachableAgentForUser).toHaveBeenCalledWith('agent-1', user); + expect(result).toBe(item); + }); + + it('404s when the agent is not reachable over n8n Chat', async () => { + const { controller, agentsService } = makeController(); + agentsService.findChatReachableAgentForUser.mockResolvedValue(null); + + await expect(controller.getChatAgent(req, mock(), 'agent-1')).rejects.toThrow( + NotFoundError, + ); + }); + }); + + describe('listN8nChatThreads', () => { + it('defaults the limit to 20 and forwards the cursor', async () => { + const { controller, agentsService } = makeController(); + const res = mock(); + const response = { data: [], nextCursor: null } as never; + + agentsService.findN8nChatThreadsForUser.mockResolvedValue(response); + + await controller.listN8nChatThreads(req, res, { cursor: 'cursor-1' } as never); + + expect(agentsService.findN8nChatThreadsForUser).toHaveBeenCalledWith(user, { + limit: 20, + cursor: 'cursor-1', + }); + expect(res.json).toHaveBeenCalledWith(response); + }); + + it('clamps the requested limit to the 1-100 range', async () => { + const { controller, agentsService } = makeController(); + const res = mock(); + agentsService.findN8nChatThreadsForUser.mockResolvedValue({ + data: [], + nextCursor: null, + } as never); + + await controller.listN8nChatThreads(req, res, { limit: '500' } as never); + expect(agentsService.findN8nChatThreadsForUser).toHaveBeenCalledWith( + user, + expect.objectContaining({ limit: 100 }), + ); + + await controller.listN8nChatThreads(req, res, { limit: '-5' } as never); + expect(agentsService.findN8nChatThreadsForUser).toHaveBeenCalledWith( + user, + expect.objectContaining({ limit: 1 }), + ); + }); + + it('forwards an agentId filter', async () => { + const { controller, agentsService } = makeController(); + const res = mock(); + agentsService.findN8nChatThreadsForUser.mockResolvedValue({ + data: [], + nextCursor: null, + } as never); + + await controller.listN8nChatThreads(req, res, { agentId: 'agent-1' } as never); + + expect(agentsService.findN8nChatThreadsForUser).toHaveBeenCalledWith( + user, + expect.objectContaining({ agentId: 'agent-1' }), + ); + }); + }); + + describe('getN8nChatThread', () => { + it("returns the thread summary for the registry to wrap in { data } — it must not call res.json itself, or the registry's wrapping never runs", async () => { + const { controller, agentsService } = makeController(); + const summary = { + id: 'thread-1', + title: 'Refund status', + updatedAt: '2025-01-01T00:00:00.000Z', + agent: { id: 'agent-1', name: 'Support', projectId: 'project-1' }, + }; + agentsService.findN8nChatThreadForUser.mockResolvedValue(summary as never); + + const result = await controller.getN8nChatThread(req, mock(), 'thread-1'); + + expect(agentsService.findN8nChatThreadForUser).toHaveBeenCalledWith(user, 'thread-1'); + expect(result).toBe(summary); + }); + + it('404s when the thread is not found, not owned, or its agent is unreachable', async () => { + const { controller, agentsService } = makeController(); + agentsService.findN8nChatThreadForUser.mockResolvedValue(null); + + await expect(controller.getN8nChatThread(req, mock(), 'thread-1')).rejects.toThrow( + NotFoundError, + ); + }); + }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts index b837af89075..fdc663f36be 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -635,6 +635,7 @@ describe('AgentsService', () => { model: 'm', instructions: 'published instructions', personalisation: { icon: 'bot', gradient: { from: '#000000', to: '#FFFFFF' } }, + description: 'Answers billing questions.', }, }, } as never); @@ -673,11 +674,37 @@ describe('AgentsService', () => { expect(agentRepository.findByProjectIdsPaginated).toHaveBeenCalledWith( ['project-1'], options, - { withProject: true }, + { withProject: true, usageCounts: undefined }, ); expect(projectRelationRepository.findAllByUser).not.toHaveBeenCalled(); }); + it('fetches usage counts only when sorted by usage', async () => { + const { service, agentRepository, agentExecutionService, projectScopeService } = + makeService(); + projectScopeService.getProjectIds.mockResolvedValue(['project-1']); + agentRepository.findByProjectIdsPaginated.mockResolvedValue({ count: 0, data: [] }); + const counts = new Map([['agent-1', 3]]); + agentExecutionService.countN8nChatThreadsByAgent.mockResolvedValue(counts); + const options = { + skip: 0, + take: 10, + filter: { availableInChat: true }, + sortBy: 'usage:desc', + } as never; + + await service.findChatReachableByUserPaginated(user, options); + + expect(agentExecutionService.countN8nChatThreadsByAgent).toHaveBeenCalledWith('user-1', [ + 'project-1', + ]); + expect(agentRepository.findByProjectIdsPaginated).toHaveBeenCalledWith( + ['project-1'], + options, + { withProject: true, usageCounts: counts }, + ); + }); + it('answers with the narrow chat item, never the agent config', async () => { const { service, agentRepository, projectScopeService } = makeService(); projectScopeService.getProjectIds.mockResolvedValue(['project-1']); @@ -702,6 +729,7 @@ describe('AgentsService', () => { icon: 'bot', gradient: { from: '#000000', to: '#FFFFFF' }, }, + description: 'Answers billing questions.', project: { id: 'project-1', name: 'Support Team' }, }, ], @@ -738,6 +766,7 @@ describe('AgentsService', () => { } as never); expect(result.data[0]).not.toHaveProperty('personalisation'); + expect(result.data[0]).not.toHaveProperty('description'); }); it('passes a null project scope through, so a global role sees every project', async () => { @@ -750,10 +779,213 @@ describe('AgentsService', () => { expect(agentRepository.findByProjectIdsPaginated).toHaveBeenCalledWith(null, options, { withProject: true, + usageCounts: undefined, }); }); }); + describe('findChatReachableAgentForUser', () => { + const user = { id: 'user-1' } as unknown as User; + + function makeReachableAgent() { + return makeAgent({ + id: 'agent-1', + name: 'Support', + projectId: 'project-1', + project: { id: 'project-1', name: 'Support Team' }, + activeVersion: { + schema: { + name: 'Support', + model: 'm', + instructions: 'published instructions', + personalisation: { icon: 'bot', gradient: { from: '#000000', to: '#FFFFFF' } }, + description: 'Answers billing questions.', + }, + }, + } as never); + } + + it('scopes to agent:execute, the scope the production chat route requires', async () => { + const { service, agentRepository, projectScopeService } = makeService(); + projectScopeService.getProjectIds.mockResolvedValue(['project-1']); + agentRepository.findChatReachableById.mockResolvedValue(makeReachableAgent()); + + await service.findChatReachableAgentForUser('agent-1', user); + + expect(projectScopeService.getProjectIds).toHaveBeenCalledWith(user, ['agent:execute']); + expect(agentRepository.findChatReachableById).toHaveBeenCalledWith('agent-1', ['project-1']); + }); + + it('passes a null project scope through, so a global role reaches every project', async () => { + const { service, agentRepository, projectScopeService } = makeService(); + projectScopeService.getProjectIds.mockResolvedValue(null); + agentRepository.findChatReachableById.mockResolvedValue(makeReachableAgent()); + + await service.findChatReachableAgentForUser('agent-1', user); + + expect(agentRepository.findChatReachableById).toHaveBeenCalledWith('agent-1', null); + }); + + it('answers with the narrow chat item, never the agent config', async () => { + const { service, agentRepository, projectScopeService } = makeService(); + projectScopeService.getProjectIds.mockResolvedValue(['project-1']); + agentRepository.findChatReachableById.mockResolvedValue(makeReachableAgent()); + + const result = await service.findChatReachableAgentForUser('agent-1', user); + + expect(result).toEqual({ + id: 'agent-1', + name: 'Support', + personalisation: { icon: 'bot', gradient: { from: '#000000', to: '#FFFFFF' } }, + description: 'Answers billing questions.', + project: { id: 'project-1', name: 'Support Team' }, + }); + expect(result).not.toHaveProperty('schema'); + expect(JSON.stringify(result)).not.toContain('published instructions'); + }); + + it('returns null when the repository finds no reachable agent', async () => { + const { service, agentRepository, projectScopeService } = makeService(); + projectScopeService.getProjectIds.mockResolvedValue(['project-1']); + agentRepository.findChatReachableById.mockResolvedValue(null); + + await expect(service.findChatReachableAgentForUser('agent-1', user)).resolves.toBeNull(); + }); + }); + + describe('findN8nChatThreadsForUser', () => { + const user = { id: 'user-1' } as unknown as User; + + it('scopes agent reachability to agent:execute, then narrows to chat-reachable agents', async () => { + const { service, agentRepository, agentExecutionService, projectScopeService } = + makeService(); + projectScopeService.getProjectIds.mockResolvedValue(['project-1']); + agentRepository.findChatReachableIds.mockResolvedValue(['agent-1']); + const response = { data: [], nextCursor: null }; + agentExecutionService.findN8nChatThreadsForAgents.mockResolvedValue(response); + + const result = await service.findN8nChatThreadsForUser(user, { + limit: 20, + cursor: 'cursor-1', + }); + + expect(projectScopeService.getProjectIds).toHaveBeenCalledWith(user, ['agent:execute']); + expect(agentRepository.findChatReachableIds).toHaveBeenCalledWith(['project-1']); + expect(agentExecutionService.findN8nChatThreadsForAgents).toHaveBeenCalledWith( + 'user-1', + ['agent-1'], + 20, + 'cursor-1', + ); + expect(result).toBe(response); + }); + + it('never asks for threads under an agent the user can no longer reach over n8n Chat', async () => { + // A thread's agent dropping out of `findChatReachableIds` (project + // removed, channel unpublished, agent deleted) must drop its threads + // from the list too, even though nothing here filters per-thread. + const { service, agentRepository, agentExecutionService, projectScopeService } = + makeService(); + projectScopeService.getProjectIds.mockResolvedValue(['project-1']); + agentRepository.findChatReachableIds.mockResolvedValue([]); + agentExecutionService.findN8nChatThreadsForAgents.mockResolvedValue({ + data: [], + nextCursor: null, + }); + + await service.findN8nChatThreadsForUser(user, { limit: 20 }); + + expect(agentExecutionService.findN8nChatThreadsForAgents).toHaveBeenCalledWith( + 'user-1', + [], + 20, + undefined, + ); + }); + + it('narrows to one agent when agentId is reachable', async () => { + const { service, agentRepository, agentExecutionService, projectScopeService } = + makeService(); + projectScopeService.getProjectIds.mockResolvedValue(['project-1']); + agentRepository.findChatReachableById.mockResolvedValue(makeAgent({ id: 'agent-2' })); + agentExecutionService.findN8nChatThreadsForAgents.mockResolvedValue({ + data: [], + nextCursor: null, + }); + + await service.findN8nChatThreadsForUser(user, { limit: 20, agentId: 'agent-2' }); + + expect(agentRepository.findChatReachableById).toHaveBeenCalledWith('agent-2', ['project-1']); + expect(agentRepository.findChatReachableIds).not.toHaveBeenCalled(); + expect(agentExecutionService.findN8nChatThreadsForAgents).toHaveBeenCalledWith( + 'user-1', + ['agent-2'], + 20, + undefined, + ); + }); + + it('returns an empty page, not an error, when agentId is unreachable', async () => { + const { service, agentRepository, agentExecutionService, projectScopeService } = + makeService(); + projectScopeService.getProjectIds.mockResolvedValue(['project-1']); + agentRepository.findChatReachableById.mockResolvedValue(null); + agentExecutionService.findN8nChatThreadsForAgents.mockResolvedValue({ + data: [], + nextCursor: null, + }); + + await service.findN8nChatThreadsForUser(user, { limit: 20, agentId: 'agent-unreachable' }); + + expect(agentExecutionService.findN8nChatThreadsForAgents).toHaveBeenCalledWith( + 'user-1', + [], + 20, + undefined, + ); + }); + }); + + describe('findN8nChatThreadForUser', () => { + const user = { id: 'user-1' } as unknown as User; + + it('scopes agent reachability to agent:execute, then narrows to chat-reachable agents', async () => { + const { service, agentRepository, agentExecutionService, projectScopeService } = + makeService(); + projectScopeService.getProjectIds.mockResolvedValue(['project-1']); + agentRepository.findChatReachableIds.mockResolvedValue(['agent-1']); + const summary = { id: 'thread-1', title: 'Refund status' }; + agentExecutionService.findN8nChatThreadForAgents.mockResolvedValue(summary as never); + + const result = await service.findN8nChatThreadForUser(user, 'thread-1'); + + expect(projectScopeService.getProjectIds).toHaveBeenCalledWith(user, ['agent:execute']); + expect(agentRepository.findChatReachableIds).toHaveBeenCalledWith(['project-1']); + expect(agentExecutionService.findN8nChatThreadForAgents).toHaveBeenCalledWith( + 'user-1', + ['agent-1'], + 'thread-1', + ); + expect(result).toBe(summary); + }); + + it('returns null when the thread falls outside the reachable agents', async () => { + const { service, agentRepository, agentExecutionService, projectScopeService } = + makeService(); + projectScopeService.getProjectIds.mockResolvedValue(['project-1']); + agentRepository.findChatReachableIds.mockResolvedValue([]); + agentExecutionService.findN8nChatThreadForAgents.mockResolvedValue(null); + + await expect(service.findN8nChatThreadForUser(user, 'thread-1')).resolves.toBeNull(); + + expect(agentExecutionService.findN8nChatThreadForAgents).toHaveBeenCalledWith( + 'user-1', + [], + 'thread-1', + ); + }); + }); + describe('findByUserMembershipPaginated', () => { const user = { id: 'user-1' } as unknown as User; diff --git a/packages/cli/src/modules/agents/agent-chat.controller.ts b/packages/cli/src/modules/agents/agent-chat.controller.ts index b5eed4cd508..c54cf0c9027 100644 --- a/packages/cli/src/modules/agents/agent-chat.controller.ts +++ b/packages/cli/src/modules/agents/agent-chat.controller.ts @@ -546,8 +546,14 @@ export class AgentChatController { }; } + // The n8n Chat audience (`project:chatUser`) holds `agent:execute`, not + // `agent:read` — this route must stay reachable to a chat-only member, so it + // is scoped to `agent:execute`. `requireProductionThread` below (via + // `canUseProductionChatThread` → `canUseTopLevelDraftThread`) still 404s a + // thread that isn't this user's own, so the lower scope alone doesn't open + // another user's history. @Get('/:agentId/n8n-chat/:threadId/messages') - @ProjectScope('agent:read') + @ProjectScope('agent:execute') async getProductionChatMessages( req: AuthenticatedRequest<{ projectId: string; agentId: string; threadId: string }>, ): Promise { @@ -577,8 +583,11 @@ export class AgentChatController { }; } + // Same reasoning as `getProductionChatMessages`: the attachment's `resourceId` + // check and `requireProductionThread` below already restrict this to the + // requesting user's own thread, so `agent:execute` is safe for a chat-only member. @Get('/:agentId/n8n-chat/attachments/:attachmentId') - @ProjectScope('agent:read') + @ProjectScope('agent:execute') async getProductionChatAttachment( req: AuthenticatedRequest<{ projectId: string; agentId: string; attachmentId: string }>, res: Response, @@ -601,8 +610,11 @@ export class AgentChatController { await this.streamAttachment(attachment, res); } + // Same reasoning as `getProductionChatMessages`: `messageQueue.listPending` + // calls `assertUserChatAccess`, which 404s a thread that isn't this user's + // own, so `agent:execute` is safe for a chat-only member. @Get('/:agentId/n8n-chat/:threadId/queue') - @ProjectScope('agent:read') + @ProjectScope('agent:execute') async getProductionQueuedMessages( req: AuthenticatedRequest<{ projectId: string; agentId: string; threadId: string }>, ): Promise { diff --git a/packages/cli/src/modules/agents/agent-execution-update-broadcaster.ts b/packages/cli/src/modules/agents/agent-execution-update-broadcaster.ts index e528233ce28..bf03cf7f885 100644 --- a/packages/cli/src/modules/agents/agent-execution-update-broadcaster.ts +++ b/packages/cli/src/modules/agents/agent-execution-update-broadcaster.ts @@ -104,7 +104,9 @@ export class AgentExecutionUpdateBroadcaster { } private async getRecipients(thread: AgentExecutionThread): Promise { - const userIds = await this.recipients.getProjectReaders(thread.projectId); + // A private thread's owner needs only the scope to use it: a chat-only member has no agent:read. + const scope = thread.accessScope === 'user' ? 'agent:execute' : 'agent:read'; + const userIds = await this.recipients.getProjectUsersWithScope(thread.projectId, scope); return userIds.filter((id) => threadBelongsTo(thread, thread.projectId, thread.agentId, id)); } diff --git a/packages/cli/src/modules/agents/agent-execution.service.ts b/packages/cli/src/modules/agents/agent-execution.service.ts index 1f8eb6a21dc..82f29d50e20 100644 --- a/packages/cli/src/modules/agents/agent-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-execution.service.ts @@ -1,6 +1,8 @@ import type { AgentExecutionStatus, AgentMessageAuthor, + AgentN8nChatThreadSummary, + AgentN8nChatThreadsResponse, AgentSessionPreviewAccess, AgentSessionQueryFilters, AgentSessionStatus, @@ -32,6 +34,7 @@ import type { AgentMessageEntity, AgentMessageOrigin } from './entities/agent-me import { messageToDto } from './agent-message-mapper'; import { buildInboundUserMessage } from './utils/inbound-attachments'; import { buildAgentTurnMetrics } from './agent-telemetry'; +import { toAgentRef } from './utils/agent-ref'; import { AgentExecutionThread, type AgentThreadAccess, @@ -734,6 +737,55 @@ export class AgentExecutionService { return { ...page, threads: await this.toThreadListItems(page.threads, userId) }; } + /** + * The user's own n8n Chat threads across the given `agentIds`, newest + * first. The caller (`AgentsService`) has already narrowed `agentIds` to + * the agents the user can currently reach over n8n Chat, so a chat-only + * member never sees a thread under an agent they cannot talk to. + */ + async findN8nChatThreadsForAgents( + userId: string, + agentIds: string[], + limit: number, + cursor?: string, + ): Promise { + const { threads, nextCursor } = + await this.agentExecutionThreadRepository.findN8nChatThreadsForOwner( + userId, + agentIds, + limit, + cursor, + ); + return { data: threads.map(toN8nChatThreadSummary), nextCursor }; + } + + /** + * One of the user's own n8n Chat threads across the given `agentIds` + * (already narrowed to the agents the user can currently reach over n8n + * Chat). `null` when the thread doesn't exist, isn't the user's own, or + * falls outside `agentIds`. + */ + async findN8nChatThreadForAgents( + userId: string, + agentIds: string[], + threadId: string, + ): Promise { + const thread = await this.agentExecutionThreadRepository.findN8nChatThreadForOwner( + userId, + agentIds, + threadId, + ); + return thread ? toN8nChatThreadSummary(thread) : null; + } + + /** How many of the user's own n8n Chat threads reference each agent, for the usage sort. */ + async countN8nChatThreadsByAgent( + userId: string, + projectIds: string[] | null, + ): Promise> { + return await this.agentExecutionThreadRepository.countN8nChatThreadsByAgent(userId, projectIds); + } + private async toThreadListItems( threads: AgentExecutionThread[], userId: string, @@ -1249,3 +1301,14 @@ function executionStatus(record: MessageRecord): AgentExecution['status'] { if (record.finishReason === 'cancelled') return 'cancelled'; return 'success'; } + +/** Maps a thread (with `agent` and `agent.activeVersion` loaded) to the + * cross-agent n8n Chat thread list shape. */ +function toN8nChatThreadSummary(thread: AgentExecutionThread): AgentN8nChatThreadSummary { + return { + id: thread.id, + title: thread.title, + updatedAt: thread.updatedAt.toISOString(), + agent: { ...toAgentRef(thread.agent), projectId: thread.agent.projectId }, + }; +} diff --git a/packages/cli/src/modules/agents/agent-push-recipients.service.ts b/packages/cli/src/modules/agents/agent-push-recipients.service.ts index c25ab6b130d..6abc97cf94e 100644 --- a/packages/cli/src/modules/agents/agent-push-recipients.service.ts +++ b/packages/cli/src/modules/agents/agent-push-recipients.service.ts @@ -11,9 +11,16 @@ export class AgentPushRecipientsService { ) {} async getProjectReaders(projectId: string): Promise { + return await this.getProjectUsersWithScope(projectId, 'agent:read'); + } + + async getProjectUsersWithScope( + projectId: string, + scope: 'agent:read' | 'agent:execute', + ): Promise { const [globalRoleSlugs, projectRoleSlugs] = await Promise.all([ - this.roleService.rolesWithScope('global', ['agent:read']), - this.roleService.rolesWithScope('project', ['agent:read']), + this.roleService.rolesWithScope('global', [scope]), + this.roleService.rolesWithScope('project', [scope]), ]); return await this.userRepository.findIdsWithGlobalOrProjectRoles({ projectIds: [projectId], diff --git a/packages/cli/src/modules/agents/agent-threads.controller.ts b/packages/cli/src/modules/agents/agent-threads.controller.ts index 094881978d3..5c02deb88cf 100644 --- a/packages/cli/src/modules/agents/agent-threads.controller.ts +++ b/packages/cli/src/modules/agents/agent-threads.controller.ts @@ -8,6 +8,7 @@ import { NotFoundError } from '@n8n/errors'; import { AgentExecutionService } from './agent-execution.service'; import { AgentSessionLangSmithExportService } from './agent-session-langsmith-export.service'; import { canContinueThreadInPreview } from './utils/agent-thread-access'; +import { parseThreadListLimit } from './utils/parse-thread-list-limit'; @RestController('/projects/:projectId/agents/v2') export class AgentThreadsController { @@ -24,7 +25,7 @@ export class AgentThreadsController { @Query query: ListAgentSessionsQueryDto, ) { const { cursor, limit: requestedLimit, ...filters } = query; - const limit = Math.min(Math.max(Number(requestedLimit) || 20, 1), 100); + const limit = parseThreadListLimit(requestedLimit); return await this.agentExecutionService.getThreads( req.params.projectId, diff --git a/packages/cli/src/modules/agents/agents-list.controller.ts b/packages/cli/src/modules/agents/agents-list.controller.ts index b3e96b1edc1..7166f5ae61e 100644 --- a/packages/cli/src/modules/agents/agents-list.controller.ts +++ b/packages/cli/src/modules/agents/agents-list.controller.ts @@ -1,9 +1,11 @@ -import { ListAgentsQueryDto } from '@n8n/api-types'; +import { ListAgentsQueryDto, ListN8nChatThreadsQueryDto } from '@n8n/api-types'; import type { AuthenticatedRequest } from '@n8n/db'; -import { Get, Query, RestController } from '@n8n/decorators'; +import { Get, Param, Query, RestController } from '@n8n/decorators'; +import { NotFoundError } from '@n8n/errors'; import type { Response } from 'express'; import { AgentsService } from './agents.service'; +import { parseThreadListLimit } from './utils/parse-thread-list-limit'; /** * Global (cross-project) agents list endpoint. @@ -32,4 +34,55 @@ export class AgentsListController { : await this.agentsService.findChatReachableByUserPaginated(req.user, query), ); } + + /** + * One agent as the n8n Chat page needs it: a chat-only member holds + * `agent:execute` but not `agent:read`, so they cannot call the regular + * agent-by-id route to get its name and avatar for a direct link or a + * reload. 404s when the agent doesn't exist, isn't reachable by this user, + * or isn't published to n8n Chat — same response for all three, so the + * chat-only member learns nothing about agents they cannot reach. + */ + @Get('/n8n-chat/agents/:agentId') + async getChatAgent(req: AuthenticatedRequest, _res: Response, @Param('agentId') agentId: string) { + const agent = await this.agentsService.findChatReachableAgentForUser(agentId, req.user); + if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); + return agent; + } + + /** The user's own n8n Chat threads across every agent they can reach, for + * the chat page's cross-agent "recent chats" list. */ + @Get('/n8n-chat/threads') + async listN8nChatThreads( + req: AuthenticatedRequest, + res: Response, + @Query query: ListN8nChatThreadsQueryDto, + ) { + const limit = parseThreadListLimit(query.limit); + res.json( + await this.agentsService.findN8nChatThreadsForUser(req.user, { + limit, + cursor: query.cursor, + agentId: query.agentId, + }), + ); + } + + /** + * One of the user's own n8n Chat threads, for the chat page to read a + * thread's title when it falls outside the recent-threads page (e.g. after + * a reload on an older thread). 404s when the thread doesn't exist, isn't + * owned by this user, or its agent isn't reachable over n8n Chat anymore — + * same response for all three, same scoping as the list route. + */ + @Get('/n8n-chat/threads/:threadId') + async getN8nChatThread( + req: AuthenticatedRequest, + _res: Response, + @Param('threadId') threadId: string, + ) { + const thread = await this.agentsService.findN8nChatThreadForUser(req.user, threadId); + if (!thread) throw new NotFoundError(`Thread "${threadId}" not found`); + return thread; + } } diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index 248afda1e98..b09eb76735b 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -10,6 +10,8 @@ import { type AgentIntegrationConfig, type AgentJsonConfig, type AgentModelCredentialConfig, + type AgentN8nChatThreadSummary, + type AgentN8nChatThreadsResponse, type AgentSkill, type ListAgentsQueryDto, } from '@n8n/api-types'; @@ -38,6 +40,7 @@ import { Agent } from './entities/agent.entity'; import { ChatIntegrationService } from './integrations/chat-integration.service'; import { decomposeJsonConfig } from './json-config/agent-config-composition'; import { sanitizeUnknownAgentCredentials } from './json-config/sanitize-unknown-agent-credentials'; +import { toAgentRef } from './utils/agent-ref'; import { AgentTaskRepository } from './repositories/agent-task.repository'; import { AgentRepository, @@ -338,14 +341,83 @@ export class AgentsService { ): Promise { if (!(await this.settingsService.getEnabled())) return { count: 0, data: [] }; const projectIds = await this.projectScopeService.getProjectIds(user, ['agent:execute']); + // Usage counts are only meaningful (and only needed) for the usage sort. + const usageCounts = + options.sortBy === 'usage:desc' + ? await this.agentExecutionService.countN8nChatThreadsByAgent(user.id, projectIds) + : undefined; const { count, data } = await this.agentRepository.findByProjectIdsPaginated( projectIds, options, - { withProject: true }, + { withProject: true, usageCounts }, ); return { count, data: data.map(toChatListItem) }; } + /** + * One agent over n8n Chat (see the controller route's doc for the audience + * this serves). Scoped like {@link findChatReachableByUserPaginated}. + * Returns `null` when missing, unreachable, or unpublished to n8n Chat — + * the controller turns all three into the same 404. + */ + async findChatReachableAgentForUser( + agentId: string, + user: User, + ): Promise { + const projectIds = await this.projectScopeService.getProjectIds(user, ['agent:execute']); + const agent = await this.agentRepository.findChatReachableById(agentId, projectIds); + return agent ? toChatListItem(agent) : null; + } + + /** + * The user's own n8n Chat threads across every agent they can currently + * reach over n8n Chat: a project where they hold `agent:execute`, with a + * published config that carries the channel. Scoping by agent id (not just + * project) keeps a thread out of the list the moment its agent's channel is + * unpublished, even if the user still belongs to the project. + */ + async findN8nChatThreadsForUser( + user: User, + options: { limit: number; cursor?: string; agentId?: string }, + ): Promise { + const projectIds = await this.projectScopeService.getProjectIds(user, ['agent:execute']); + const agentIds = options.agentId + ? await this.reachableAgentIds(options.agentId, projectIds) + : await this.agentRepository.findChatReachableIds(projectIds); + return await this.agentExecutionService.findN8nChatThreadsForAgents( + user.id, + agentIds, + options.limit, + options.cursor, + ); + } + + /** + * Checks reachability for one agent directly, instead of loading every + * reachable id and filtering in memory. An unreachable `agentId` resolves + * to no agent ids, not an error — the caller already can't see that + * agent's threads either way. + */ + private async reachableAgentIds(agentId: string, projectIds: string[] | null): Promise { + const agent = await this.agentRepository.findChatReachableById(agentId, projectIds); + return agent ? [agent.id] : []; + } + + /** + * One of the user's own n8n Chat threads, scoped like + * {@link findN8nChatThreadsForUser}. Returns `null` when the thread doesn't + * exist, isn't owned by this user, or its agent isn't reachable over n8n + * Chat anymore — the controller turns all three into the same 404. + */ + async findN8nChatThreadForUser( + user: User, + threadId: string, + ): Promise { + const projectIds = await this.projectScopeService.getProjectIds(user, ['agent:execute']); + const agentIds = await this.agentRepository.findChatReachableIds(projectIds); + return await this.agentExecutionService.findN8nChatThreadForAgents(user.id, agentIds, threadId); + } + /** The agents overview list: every project the user belongs to, no scope check. */ async findByUserMembershipPaginated( user: User, @@ -540,16 +612,12 @@ export class AgentsService { } } -/** - * Keeps the chat list to what the page renders. The icon comes from the - * published snapshot, the same config the run would use. - */ +/** Keeps the chat list to what the page renders: icon and blurb from the published snapshot. */ function toChatListItem(agent: Agent): AgentChatListItem { - const personalisation = agent.activeVersion?.schema?.personalisation; + const description = agent.activeVersion?.schema?.description; return { - id: agent.id, - name: agent.name, - ...(personalisation ? { personalisation } : {}), + ...toAgentRef(agent), + ...(description ? { description } : {}), project: { id: agent.projectId, name: agent.project.name }, }; } diff --git a/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts b/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts index 13c26aea60d..6106d666114 100644 --- a/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts @@ -17,7 +17,11 @@ import { getDelegatedChildCheckpoints, type DelegatedChildCheckpoint, } from '../utils/delegated-child-checkpoints'; -import { PREVIEW_THREAD_SOURCES, type AgentSessionMode } from '../utils/agent-thread-access'; +import { + N8N_CHAT_PRODUCTION_SOURCE, + PREVIEW_THREAD_SOURCES, + type AgentSessionMode, +} from '../utils/agent-thread-access'; const CHECKPOINT_BATCH_SIZE = 400; @@ -146,25 +150,133 @@ export class AgentExecutionThreadRepository extends BaseRepository { - // SQLite timestamps can omit milliseconds, so compare them in one format. - const updatedAt = - this.manager.connection.options.type === 'postgres' - ? 'thread.updatedAt' - : "STRFTIME('%Y-%m-%d %H:%M:%f', thread.updatedAt)"; const query = this.createQueryBuilder('thread') .where('thread.projectId = :projectId', { projectId }) .andWhere('thread.agentId = :agentId', { agentId }) .andWhere( "(thread.accessScope = 'project' OR (thread.accessScope = 'user' AND thread.ownerId = :userId))", { userId }, - ) - .orderBy('thread.updatedAt', 'DESC') - .take(limit + 1); + ); + this.applyListFilters(query, filters, userId, this.updatedAtExpression()); + return await this.paginateByUpdatedAt(query, limit, cursor); + } - if (cursor) { - query.andWhere(`${updatedAt} < :cursor`, { cursor: new Date(cursor) }); + /** + * The owner's own top-level n8n Chat threads across the given `agentIds` + * (the agents the caller already resolved as currently reachable), newest + * `updatedAt` first. + * + * Selects only the columns the cross-agent thread list renders. + * `activeVersion.schema` loads in full — it is read for the published + * personalisation — but the draft schema, tools and skills never load. + */ + async findN8nChatThreadsForOwner( + userId: string, + agentIds: string[], + limit: number, + cursor?: string, + ): Promise { + const query = this.n8nChatThreadsQuery(userId, agentIds); + if (!query) return { threads: [], nextCursor: null }; + + return await this.paginateByUpdatedAt(query, limit, cursor); + } + + /** + * One of the owner's own n8n Chat threads, by id. Same reachability + * filters as {@link findN8nChatThreadsForOwner}, for the chat page to read + * a single thread's title outside the recent-threads page. + */ + async findN8nChatThreadForOwner( + userId: string, + agentIds: string[], + threadId: string, + ): Promise { + const query = this.n8nChatThreadsQuery(userId, agentIds); + if (!query) return null; + + return await query.andWhere('thread.id = :threadId', { threadId }).getOne(); + } + + /** Shared base query for {@link findN8nChatThreadsForOwner} and + * {@link findN8nChatThreadForOwner}. `null` when `agentIds` is empty, so + * callers skip the query instead of running one that can match nothing. */ + private n8nChatThreadsQuery( + userId: string, + agentIds: string[], + ): SelectQueryBuilder | null { + if (agentIds.length === 0) return null; + + const query = this.createQueryBuilder('thread') + .leftJoinAndSelect('thread.agent', 'agent') + .leftJoinAndSelect('agent.activeVersion', 'activeVersion') + .select([ + 'thread.id', + 'thread.title', + 'thread.updatedAt', + 'agent.id', + 'agent.name', + 'agent.projectId', + 'activeVersion.versionId', + 'activeVersion.schema', + ]) + .where('thread.ownerId = :userId', { userId }) + .andWhere("thread.accessScope = 'user'") + .andWhere('thread.agentId IN (:...agentIds)', { agentIds }); + // ponytail: the origin rule re-runs a correlated first-source subquery per + // thread row; fine for one user's threads, add a stored thread origin + // column if profiling shows it. + this.applyOriginFilter(query, N8N_CHAT_PRODUCTION_SOURCE); + + return query; + } + + /** + * How many of `userId`'s own n8n Chat threads reference each agent, for + * ranking an agent list by usage. `projectIds: null` means no restriction. + */ + async countN8nChatThreadsByAgent( + userId: string, + projectIds: string[] | null, + ): Promise> { + if (projectIds?.length === 0) return new Map(); + + const query = this.createQueryBuilder('thread') + .select('thread.agentId', 'agentId') + .addSelect('COUNT(*)', 'count') + .where('thread.ownerId = :userId', { userId }) + .andWhere("thread.accessScope = 'user'") + .groupBy('thread.agentId'); + // ponytail: the origin rule re-runs a correlated first-source subquery per + // thread row; fine for one user's threads, add a stored thread origin + // column if profiling shows it. + this.applyOriginFilter(query, N8N_CHAT_PRODUCTION_SOURCE); + + if (projectIds !== null) { + query.andWhere('thread.projectId IN (:...projectIds)', { projectIds }); + } + + const rows = await query.getRawMany<{ agentId: string; count: string }>(); + return new Map(rows.map((row) => [row.agentId, Number(row.count)])); + } + + /** SQLite timestamps can omit milliseconds, so compare them in one format. */ + private updatedAtExpression(): string { + return this.manager.connection.options.type === 'postgres' + ? 'thread.updatedAt' + : "STRFTIME('%Y-%m-%d %H:%M:%f', thread.updatedAt)"; + } + + /** Cursor pagination shared by every `thread.updatedAt DESC` listing. */ + private async paginateByUpdatedAt( + query: SelectQueryBuilder, + limit: number, + cursor?: string, + ): Promise { + query.orderBy('thread.updatedAt', 'DESC').take(limit + 1); + if (cursor) { + query.andWhere(`${this.updatedAtExpression()} < :cursor`, { cursor: new Date(cursor) }); } - this.applyListFilters(query, filters, userId, updatedAt); const threads = await query.getMany(); const hasMore = threads.length > limit; if (hasMore) threads.pop(); diff --git a/packages/cli/src/modules/agents/repositories/agent.repository.ts b/packages/cli/src/modules/agents/repositories/agent.repository.ts index 1dd43ea7b2f..38807c245ab 100644 --- a/packages/cli/src/modules/agents/repositories/agent.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent.repository.ts @@ -100,7 +100,10 @@ export class AgentRepository extends BaseRepository { async findByProjectIdsPaginated( projectIds: string[] | null, options: ListAgentsQueryDto, - { withProject = false }: { withProject?: boolean } = {}, + { + withProject = false, + usageCounts, + }: { withProject?: boolean; usageCounts?: Map } = {}, ): Promise { if (projectIds?.length === 0) return { count: 0, data: [] }; @@ -119,13 +122,63 @@ export class AgentRepository extends BaseRepository { query.where('agent.projectId IN (:...projectIds)', { projectIds }); } this.applyFilters(query, options.filter); - this.applySorting(query, options.sortBy); + this.applySorting(query, options.sortBy, usageCounts); query.skip(options.skip).take(options.take); const [data, count] = await query.getManyAndCount(); return { count, data }; } + /** + * Adds the shared n8n Chat reachability predicate to an already-started + * `agent` query: the given projects (or any, when `projectIds` is null) + * and {@link applyFilters}'s `availableInChat` predicate. Callers add their + * own `where` first — this only appends `andWhere` clauses, so it never + * discards a condition a caller already set. Shared by + * `findChatReachableIds` and `findChatReachableById` so both stay in + * lockstep with each other and with the chat agent list. + */ + private chatReachableQuery( + query: SelectQueryBuilder, + projectIds: string[] | null, + ): SelectQueryBuilder { + if (projectIds !== null) { + query.andWhere('agent.projectId IN (:...projectIds)', { projectIds }); + } + this.applyFilters(query, { availableInChat: true }); + return query; + } + + /** Ids of the agents the user can reach over n8n Chat: published config carries the channel. */ + async findChatReachableIds(projectIds: string[] | null): Promise { + if (projectIds?.length === 0) return []; + + // `availableInChat` reads `activeVersion.schema`, so the join must exist + // even though the select list drops it again. + const query = this.createQueryBuilder('agent') + .leftJoin('agent.activeVersion', 'activeVersion') + .select(['agent.id']); + + const rows = await this.chatReachableQuery(query, projectIds).getMany(); + return rows.map((row) => row.id); + } + + /** + * One agent reachable over n8n Chat: in one of the given projects (or any + * project, when `projectIds` is null) and its published config carries the + * channel. Loads `project` too — the chat page labels the agent with it. + */ + async findChatReachableById(id: string, projectIds: string[] | null): Promise { + if (projectIds?.length === 0) return null; + + const query = this.createQueryBuilder('agent') + .leftJoinAndSelect('agent.activeVersion', 'activeVersion') + .leftJoinAndSelect('agent.project', 'project') + .where('agent.id = :id', { id }); + + return await this.chatReachableQuery(query, projectIds).getOne(); + } + private applyFilters( query: SelectQueryBuilder, filter: ListAgentsQueryDto['filter'], @@ -170,6 +223,7 @@ export class AgentRepository extends BaseRepository { private applySorting( query: SelectQueryBuilder, sortBy?: ListAgentsQueryDto['sortBy'], + usageCounts?: Map, ): void { const [field = 'updatedAt', direction = 'desc'] = sortBy?.split(':') ?? []; const sortDirection = direction.toLowerCase() === 'asc' ? 'ASC' : 'DESC'; @@ -181,9 +235,41 @@ export class AgentRepository extends BaseRepository { return; } + if (field === 'usage') { + this.applyUsageSorting(query, usageCounts); + return; + } + query.orderBy(`agent.${field}`, sortDirection); } + /** + * Ranks by each agent's pre-counted n8n Chat thread usage, so the agents the + * chat user actually talks to rise to the top. Ties (including agents with + * no usage) fall back to `createdAt` DESC, then `id` DESC. + */ + private applyUsageSorting( + query: SelectQueryBuilder, + usageCounts?: Map, + ): void { + if (!usageCounts || usageCounts.size === 0) { + query.orderBy('agent.createdAt', 'DESC').addOrderBy('agent.id', 'DESC'); + return; + } + + const agentIds = [...usageCounts.keys()]; + const cases = agentIds.map((_, i) => `WHEN :usageAgent${i} THEN :usageCount${i}`).join(' '); + agentIds.forEach((id, i) => { + query.setParameter(`usageAgent${i}`, id).setParameter(`usageCount${i}`, usageCounts.get(id)); + }); + + query + .addSelect(`CASE agent.id ${cases} ELSE 0 END`, 'agent_usage_count') + .orderBy('agent_usage_count', 'DESC') + .addOrderBy('agent.createdAt', 'DESC') + .addOrderBy('agent.id', 'DESC'); + } + /** * Finds an agent by ID and project ID, eagerly loading its `activeVersion` relation. * diff --git a/packages/cli/src/modules/agents/utils/__tests__/parse-thread-list-limit.test.ts b/packages/cli/src/modules/agents/utils/__tests__/parse-thread-list-limit.test.ts new file mode 100644 index 00000000000..a31edad18dd --- /dev/null +++ b/packages/cli/src/modules/agents/utils/__tests__/parse-thread-list-limit.test.ts @@ -0,0 +1,31 @@ +import { parseThreadListLimit } from '../parse-thread-list-limit'; + +describe('parseThreadListLimit', () => { + it('defaults to 20 when the value is absent', () => { + expect(parseThreadListLimit(undefined)).toBe(20); + }); + + it('defaults to 20 when the value is not a number', () => { + expect(parseThreadListLimit('abc')).toBe(20); + }); + + it('defaults to 20 when the value is zero', () => { + expect(parseThreadListLimit('0')).toBe(20); + }); + + it('truncates a fractional value to an integer', () => { + expect(parseThreadListLimit('1.5')).toBe(1); + }); + + it('clamps values below 1 up to 1', () => { + expect(parseThreadListLimit('-5')).toBe(1); + }); + + it('clamps values above 100 down to 100', () => { + expect(parseThreadListLimit('500')).toBe(100); + }); + + it('passes through a valid integer within range', () => { + expect(parseThreadListLimit('42')).toBe(42); + }); +}); diff --git a/packages/cli/src/modules/agents/utils/agent-ref.ts b/packages/cli/src/modules/agents/utils/agent-ref.ts new file mode 100644 index 00000000000..8a3b2d05783 --- /dev/null +++ b/packages/cli/src/modules/agents/utils/agent-ref.ts @@ -0,0 +1,20 @@ +import type { AgentPersonalisation } from '@n8n/api-types'; + +import type { Agent } from '../entities/agent.entity'; + +export interface AgentRef { + id: string; + name: string; + personalisation?: AgentPersonalisation; +} + +/** Agent id, name and icon from the published snapshot — the minimum a chat + * surface may show a member who only holds `agent:execute`. */ +export function toAgentRef(agent: Pick): AgentRef { + const personalisation = agent.activeVersion?.schema?.personalisation; + return { + id: agent.id, + name: agent.name, + ...(personalisation ? { personalisation } : {}), + }; +} diff --git a/packages/cli/src/modules/agents/utils/agent-thread-access.ts b/packages/cli/src/modules/agents/utils/agent-thread-access.ts index f97c15b9d03..ec949f5848f 100644 --- a/packages/cli/src/modules/agents/utils/agent-thread-access.ts +++ b/packages/cli/src/modules/agents/utils/agent-thread-access.ts @@ -3,7 +3,7 @@ import type { AgentExecutionThread } from '../entities/agent-execution-thread.en export type AgentSessionMode = 'new' | 'existing'; export const PREVIEW_THREAD_SOURCES = ['', 'chat', 'n8n_chat'] as const; -export const N8N_CHAT_PRODUCTION_SOURCE = 'n8n_chat_production'; +export const N8N_CHAT_PRODUCTION_SOURCE = 'n8n_chat_production' as const; export function threadBelongsTo( thread: AgentExecutionThread, diff --git a/packages/cli/src/modules/agents/utils/parse-thread-list-limit.ts b/packages/cli/src/modules/agents/utils/parse-thread-list-limit.ts new file mode 100644 index 00000000000..446a436f284 --- /dev/null +++ b/packages/cli/src/modules/agents/utils/parse-thread-list-limit.ts @@ -0,0 +1,5 @@ +/** Clamps a query-string `limit` param to a sane page size, defaulting to 20. */ +export function parseThreadListLimit(value: string | undefined): number { + // Truncate so a fractional limit (e.g. "1.5") does not reach SQL LIMIT. + return Math.min(Math.max(Math.trunc(Number(value)) || 20, 1), 100); +} diff --git a/packages/cli/test/integration/agents/n8n-chat-page.api.test.ts b/packages/cli/test/integration/agents/n8n-chat-page.api.test.ts new file mode 100644 index 00000000000..eaa277c0b14 --- /dev/null +++ b/packages/cli/test/integration/agents/n8n-chat-page.api.test.ts @@ -0,0 +1,217 @@ +import { randomUUID } from 'node:crypto'; + +import { createTeamProject, linkUserToProject, testModules } from '@n8n/backend-test-utils'; +import { Container } from '@n8n/di'; + +import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; +import { AgentHistoryRepository } from '@/modules/agents/repositories/agent-history.repository'; +import { AgentExecutionThreadRepository } from '@/modules/agents/repositories/agent-execution-thread.repository'; +import { AgentExecutionRepository } from '@/modules/agents/repositories/agent-execution.repository'; + +import { createMember, createOwner } from '../shared/db/users'; +import { setupTestServer } from '../shared/utils'; + +beforeAll(async () => { + await testModules.loadModules(['agents']); +}); + +const server = setupTestServer({ endpointGroups: ['ai'] }); +const chatIntegration = { type: 'n8n_chat', credentialId: '' } as const; + +// These routes go through the controller registry, so they also check the handler signatures. +describe('n8n Chat page HTTP routes', () => { + async function createPublishedAgent(projectId: string, { withChat = true } = {}) { + const agentRepository = Container.get(AgentRepository); + const agent = await agentRepository.save( + agentRepository.create({ + id: randomUUID(), + name: 'Support agent', + projectId, + schema: { name: 'Support agent', model: 'openai:gpt-4o-mini', instructions: 'Help' }, + integrations: [chatIntegration], + tools: {}, + skills: {}, + versionId: randomUUID(), + }), + ); + const versionId = randomUUID(); + await Container.get(AgentHistoryRepository).saveVersion({ + versionId, + agentId: agent.id, + schema: { + name: 'Support agent', + description: 'Answers support questions', + model: 'openai:gpt-4o-mini', + instructions: 'Help', + integrations: withChat ? [chatIntegration] : [], + }, + tools: {}, + skills: {}, + publishedBy: 'test', + }); + await agentRepository.update({ id: agent.id }, { activeVersionId: versionId }); + return agent; + } + + async function createN8nChatThread(agentId: string, projectId: string, ownerId: string) { + const threadRepository = Container.get(AgentExecutionThreadRepository); + const thread = await threadRepository.save( + threadRepository.create({ + id: randomUUID(), + agentId, + agentName: 'Support agent', + projectId, + accessScope: 'user', + ownerId, + title: 'My chat', + sessionNumber: 1, + }), + ); + const executionRepository = Container.get(AgentExecutionRepository); + await executionRepository.save( + executionRepository.create({ + id: randomUUID(), + threadId: thread.id, + status: 'success', + userMessage: 'Hello', + source: 'n8n_chat_production', + }), + ); + return thread; + } + + async function setup() { + const owner = await createOwner(); + const chatUser = await createMember(); + const project = await createTeamProject('Support', owner); + await linkUserToProject(chatUser, project, 'project:chatUser'); + const agent = await createPublishedAgent(project.id); + return { owner, chatUser, project, agent }; + } + + it('returns one reachable agent in the chat list shape', async () => { + const { chatUser, project, agent } = await setup(); + + const response = await server + .authAgentFor(chatUser) + .get(`/agents/v2/n8n-chat/agents/${agent.id}`) + .expect(200); + + expect(response.body.data).toEqual({ + id: agent.id, + name: 'Support agent', + description: 'Answers support questions', + project: { id: project.id, name: 'Support' }, + }); + }); + + it('returns 404 for an agent without the published n8n Chat channel', async () => { + const { chatUser, project } = await setup(); + const agent = await createPublishedAgent(project.id, { withChat: false }); + + await server.authAgentFor(chatUser).get(`/agents/v2/n8n-chat/agents/${agent.id}`).expect(404); + }); + + it('lists chat agents sorted by usage', async () => { + const { chatUser, project, agent } = await setup(); + const busierAgent = await createPublishedAgent(project.id); + await createN8nChatThread(agent.id, project.id, chatUser.id); + await createN8nChatThread(busierAgent.id, project.id, chatUser.id); + await createN8nChatThread(busierAgent.id, project.id, chatUser.id); + + const response = await server + .authAgentFor(chatUser) + .get('/agents/v2') + .query({ filter: JSON.stringify({ availableInChat: true }), sortBy: 'usage:desc' }) + .expect(200); + + expect(response.body).toMatchObject({ + count: 2, + data: [{ id: busierAgent.id }, { id: agent.id }], + }); + }); + + it("lists the user's own n8n Chat threads", async () => { + const { owner, chatUser, project, agent } = await setup(); + const thread = await createN8nChatThread(agent.id, project.id, chatUser.id); + await createN8nChatThread(agent.id, project.id, owner.id); + + const response = await server + .authAgentFor(chatUser) + .get('/agents/v2/n8n-chat/threads') + .expect(200); + + expect(response.body).toMatchObject({ + data: [{ id: thread.id, title: 'My chat', agent: { id: agent.id, projectId: project.id } }], + nextCursor: null, + }); + }); + + it('narrows the thread list to one agent via the agentId filter', async () => { + const { chatUser, project, agent } = await setup(); + const thread = await createN8nChatThread(agent.id, project.id, chatUser.id); + const otherAgent = await createPublishedAgent(project.id); + await createN8nChatThread(otherAgent.id, project.id, chatUser.id); + + const response = await server + .authAgentFor(chatUser) + .get('/agents/v2/n8n-chat/threads') + .query({ agentId: agent.id }) + .expect(200); + + expect(response.body).toMatchObject({ + data: [{ id: thread.id, agent: { id: agent.id } }], + nextCursor: null, + }); + }); + + it('returns one of the own n8n Chat threads by id', async () => { + const { chatUser, project, agent } = await setup(); + const thread = await createN8nChatThread(agent.id, project.id, chatUser.id); + + const response = await server + .authAgentFor(chatUser) + .get(`/agents/v2/n8n-chat/threads/${thread.id}`) + .expect(200); + + expect(response.body.data).toMatchObject({ + id: thread.id, + title: 'My chat', + agent: { id: agent.id, projectId: project.id }, + }); + }); + + it("returns 404 for another user's thread", async () => { + const { owner, chatUser, project, agent } = await setup(); + const thread = await createN8nChatThread(agent.id, project.id, owner.id); + + await server.authAgentFor(chatUser).get(`/agents/v2/n8n-chat/threads/${thread.id}`).expect(404); + }); + + it('returns 404 when the thread belongs to an unreachable or unpublished agent', async () => { + const { chatUser, project } = await setup(); + const unpublishedAgent = await createPublishedAgent(project.id, { withChat: false }); + const thread = await createN8nChatThread(unpublishedAgent.id, project.id, chatUser.id); + + await server.authAgentFor(chatUser).get(`/agents/v2/n8n-chat/threads/${thread.id}`).expect(404); + }); + + it('returns 404 for an unknown thread id', async () => { + const { chatUser } = await setup(); + + await server + .authAgentFor(chatUser) + .get(`/agents/v2/n8n-chat/threads/${randomUUID()}`) + .expect(404); + }); + + it('lets a chat user read the history of their own thread', async () => { + const { chatUser, project, agent } = await setup(); + const thread = await createN8nChatThread(agent.id, project.id, chatUser.id); + + await server + .authAgentFor(chatUser) + .get(`/projects/${project.id}/agents/v2/${agent.id}/n8n-chat/${thread.id}/messages`) + .expect(200); + }); +}); diff --git a/packages/cli/test/integration/database/repositories/agent-execution-thread-n8n-chat.repository.test.ts b/packages/cli/test/integration/database/repositories/agent-execution-thread-n8n-chat.repository.test.ts new file mode 100644 index 00000000000..7c63235a0b2 --- /dev/null +++ b/packages/cli/test/integration/database/repositories/agent-execution-thread-n8n-chat.repository.test.ts @@ -0,0 +1,374 @@ +import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; +import { Container } from '@n8n/di'; +import { v4 as uuid } from 'uuid'; + +import type { Agent } from '@/modules/agents/entities/agent.entity'; +import type { AgentExecutionThread } from '@/modules/agents/entities/agent-execution-thread.entity'; +import { AgentHistoryRepository } from '@/modules/agents/repositories/agent-history.repository'; +import { AgentExecutionThreadRepository } from '@/modules/agents/repositories/agent-execution-thread.repository'; +import { AgentExecutionRepository } from '@/modules/agents/repositories/agent-execution.repository'; +import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; +import { N8N_CHAT_PRODUCTION_SOURCE } from '@/modules/agents/utils/agent-thread-access'; + +import { createMember } from '../../shared/db/users'; + +// The cross-agent "my n8n Chat threads" list and its usage-count sibling: +// exercised separately from the rest of `AgentExecutionThreadRepository`'s +// heavier execution-recording tests. +describe('AgentExecutionThreadRepository n8n Chat queries', () => { + let threadRepo: AgentExecutionThreadRepository; + let executionRepo: AgentExecutionRepository; + let agentRepo: AgentRepository; + let agentHistoryRepo: AgentHistoryRepository; + let projectId: string; + let agentId: string; + + beforeAll(async () => { + await testModules.loadModules(['agents']); + await testDb.init(); + threadRepo = Container.get(AgentExecutionThreadRepository); + executionRepo = Container.get(AgentExecutionRepository); + agentRepo = Container.get(AgentRepository); + agentHistoryRepo = Container.get(AgentHistoryRepository); + }); + + beforeEach(async () => { + const project = await createTeamProject(); + projectId = project.id; + const agent = await agentRepo.save( + agentRepo.create({ + id: uuid(), + name: 'Test Agent', + projectId, + integrations: [], + tools: {}, + skills: {}, + } as Partial), + ); + agentId = agent.id; + }); + + afterEach(async () => { + await executionRepo.delete({}); + await threadRepo.delete({}); + await agentHistoryRepo.delete({}); + await agentRepo.delete({}); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + async function createThread( + overrides: Partial = {}, + ): Promise { + return await threadRepo.save( + threadRepo.create({ + id: uuid(), + agentId, + agentName: 'Test Agent', + projectId, + accessScope: 'user', + ...overrides, + }), + ); + } + + async function createExecution(threadId: string, source: string | null): Promise { + await executionRepo.save( + executionRepo.create({ id: uuid(), threadId, status: 'success', source }), + ); + } + + describe('findN8nChatThreadsForOwner', () => { + async function listFor(userId: string, agentIds: string[] = [agentId], limit = 20) { + return await threadRepo.findN8nChatThreadsForOwner(userId, agentIds, limit); + } + + it("returns only the owner's n8n Chat thread", async () => { + const owner = await createMember(); + const other = await createMember(); + const ownThread = await createThread({ ownerId: owner.id }); + await createExecution(ownThread.id, N8N_CHAT_PRODUCTION_SOURCE); + const otherThread = await createThread({ ownerId: other.id }); + await createExecution(otherThread.id, N8N_CHAT_PRODUCTION_SOURCE); + + const { threads } = await listFor(owner.id); + + expect(threads.map((thread) => thread.id)).toEqual([ownThread.id]); + }); + + it('excludes a shared project-scope session even when it is otherwise an n8n Chat thread', async () => { + const owner = await createMember(); + const sharedThread = await createThread({ accessScope: 'project', ownerId: null }); + await createExecution(sharedThread.id, N8N_CHAT_PRODUCTION_SOURCE); + + const { threads } = await listFor(owner.id); + + expect(threads).toEqual([]); + }); + + it('excludes a thread with no n8n Chat execution', async () => { + const owner = await createMember(); + const previewThread = await createThread({ ownerId: owner.id }); + await createExecution(previewThread.id, 'chat'); + await createThread({ ownerId: owner.id }); + + const { threads } = await listFor(owner.id); + + expect(threads).toEqual([]); + }); + + it('excludes a delegated sub-thread (non top-level)', async () => { + const owner = await createMember(); + const root = await createThread({ ownerId: owner.id }); + await createExecution(root.id, N8N_CHAT_PRODUCTION_SOURCE); + const delegated = await createThread({ ownerId: owner.id, parentThreadId: root.id }); + await createExecution(delegated.id, N8N_CHAT_PRODUCTION_SOURCE); + + const { threads } = await listFor(owner.id); + + expect(threads.map((thread) => thread.id)).toEqual([root.id]); + }); + + it('excludes a thread whose agent is outside the given agent ids', async () => { + const owner = await createMember(); + const otherAgent = await agentRepo.save( + agentRepo.create({ + id: uuid(), + name: 'Other Agent', + projectId, + integrations: {}, + tools: {}, + skills: {}, + } as Partial), + ); + const inScope = await createThread({ ownerId: owner.id }); + await createExecution(inScope.id, N8N_CHAT_PRODUCTION_SOURCE); + const outOfScope = await createThread({ ownerId: owner.id, agentId: otherAgent.id }); + await createExecution(outOfScope.id, N8N_CHAT_PRODUCTION_SOURCE); + + // Mirrors a caller that already narrowed to the agents reachable over + // n8n Chat (`AgentRepository.findChatReachableIds`), which excludes + // `otherAgent`. + const { threads } = await listFor(owner.id, [agentId]); + + expect(threads.map((thread) => thread.id)).toEqual([inScope.id]); + }); + + it('joins the agent and its published personalisation', async () => { + const owner = await createMember(); + const versionId = uuid(); + await agentHistoryRepo.save({ + versionId, + agentId, + author: 'test', + schema: { + name: 'Test Agent', + model: 'm', + instructions: 'i', + personalisation: { icon: 'bot', gradient: { from: '#000000', to: '#FFFFFF' } }, + }, + tools: null, + skills: null, + }); + await agentRepo.update({ id: agentId }, { activeVersionId: versionId }); + const thread = await createThread({ ownerId: owner.id }); + await createExecution(thread.id, N8N_CHAT_PRODUCTION_SOURCE); + + const { threads } = await listFor(owner.id); + + expect(threads[0].agent).toMatchObject({ id: agentId, name: 'Test Agent', projectId }); + expect(threads[0].agent.activeVersion?.schema?.personalisation).toEqual({ + icon: 'bot', + gradient: { from: '#000000', to: '#FFFFFF' }, + }); + }); + + it('pages newest-updated first with a cursor', async () => { + const owner = await createMember(); + const older = await createThread({ ownerId: owner.id }); + await createExecution(older.id, N8N_CHAT_PRODUCTION_SOURCE); + await threadRepo.update({ id: older.id }, { updatedAt: new Date('2024-01-01T00:00:00Z') }); + const newer = await createThread({ ownerId: owner.id }); + await createExecution(newer.id, N8N_CHAT_PRODUCTION_SOURCE); + await threadRepo.update({ id: newer.id }, { updatedAt: new Date('2024-02-01T00:00:00Z') }); + + const firstPage = await threadRepo.findN8nChatThreadsForOwner(owner.id, [agentId], 1); + expect(firstPage.threads.map((thread) => thread.id)).toEqual([newer.id]); + expect(firstPage.nextCursor).not.toBeNull(); + + const secondPage = await threadRepo.findN8nChatThreadsForOwner( + owner.id, + [agentId], + 1, + firstPage.nextCursor!, + ); + expect(secondPage.threads.map((thread) => thread.id)).toEqual([older.id]); + expect(secondPage.nextCursor).toBeNull(); + }); + + it('returns an empty page without a query when no agent ids are given', async () => { + const owner = await createMember(); + await expect(listFor(owner.id, [])).resolves.toEqual({ threads: [], nextCursor: null }); + }); + }); + + describe('findN8nChatThreadForOwner', () => { + it("returns the owner's n8n Chat thread by id", async () => { + const owner = await createMember(); + const thread = await createThread({ ownerId: owner.id, title: 'Refund status' }); + await createExecution(thread.id, N8N_CHAT_PRODUCTION_SOURCE); + + const result = await threadRepo.findN8nChatThreadForOwner(owner.id, [agentId], thread.id); + + expect(result?.id).toBe(thread.id); + expect(result?.title).toBe('Refund status'); + }); + + it("returns null for another user's thread", async () => { + const owner = await createMember(); + const other = await createMember(); + const thread = await createThread({ ownerId: other.id }); + await createExecution(thread.id, N8N_CHAT_PRODUCTION_SOURCE); + + const result = await threadRepo.findN8nChatThreadForOwner(owner.id, [agentId], thread.id); + + expect(result).toBeNull(); + }); + + it('returns null when the thread is outside the given agent ids', async () => { + const owner = await createMember(); + const otherAgent = await agentRepo.save( + agentRepo.create({ + id: uuid(), + name: 'Other Agent', + projectId, + integrations: {}, + tools: {}, + skills: {}, + } as Partial), + ); + const thread = await createThread({ ownerId: owner.id, agentId: otherAgent.id }); + await createExecution(thread.id, N8N_CHAT_PRODUCTION_SOURCE); + + const result = await threadRepo.findN8nChatThreadForOwner(owner.id, [agentId], thread.id); + + expect(result).toBeNull(); + }); + + it('returns null without a query when no agent ids are given', async () => { + const owner = await createMember(); + const thread = await createThread({ ownerId: owner.id }); + await createExecution(thread.id, N8N_CHAT_PRODUCTION_SOURCE); + + const result = await threadRepo.findN8nChatThreadForOwner(owner.id, [], thread.id); + + expect(result).toBeNull(); + }); + + it('returns null for an unknown thread id', async () => { + const owner = await createMember(); + + const result = await threadRepo.findN8nChatThreadForOwner(owner.id, [agentId], uuid()); + + expect(result).toBeNull(); + }); + }); + + describe('countN8nChatThreadsByAgent', () => { + it("counts each agent's n8n Chat threads, grouped by agent", async () => { + const owner = await createMember(); + const secondAgent = await agentRepo.save( + agentRepo.create({ + id: uuid(), + name: 'Second Agent', + projectId, + integrations: [], + tools: {}, + skills: {}, + } as Partial), + ); + const lightThread = await createThread({ ownerId: owner.id }); + await createExecution(lightThread.id, N8N_CHAT_PRODUCTION_SOURCE); + for (let i = 0; i < 2; i++) { + const thread = await createThread({ ownerId: owner.id, agentId: secondAgent.id }); + await createExecution(thread.id, N8N_CHAT_PRODUCTION_SOURCE); + } + + const counts = await threadRepo.countN8nChatThreadsByAgent(owner.id, [projectId]); + + expect(counts).toEqual( + new Map([ + [agentId, 1], + [secondAgent.id, 2], + ]), + ); + }); + + it("ignores another user's n8n Chat threads", async () => { + const owner = await createMember(); + const other = await createMember(); + const ownThread = await createThread({ ownerId: owner.id }); + await createExecution(ownThread.id, N8N_CHAT_PRODUCTION_SOURCE); + const otherThread = await createThread({ ownerId: other.id }); + await createExecution(otherThread.id, N8N_CHAT_PRODUCTION_SOURCE); + + const counts = await threadRepo.countN8nChatThreadsByAgent(owner.id, [projectId]); + + expect(counts).toEqual(new Map([[agentId, 1]])); + }); + + it('ignores a thread with no n8n Chat execution (e.g. preview)', async () => { + const owner = await createMember(); + const previewThread = await createThread({ ownerId: owner.id }); + await createExecution(previewThread.id, 'chat'); + + const counts = await threadRepo.countN8nChatThreadsByAgent(owner.id, [projectId]); + + expect(counts).toEqual(new Map()); + }); + + it('ignores a shared project-scope thread', async () => { + const owner = await createMember(); + const sharedThread = await createThread({ accessScope: 'project', ownerId: null }); + await createExecution(sharedThread.id, N8N_CHAT_PRODUCTION_SOURCE); + + const counts = await threadRepo.countN8nChatThreadsByAgent(owner.id, [projectId]); + + expect(counts).toEqual(new Map()); + }); + + it('ignores a thread from a project outside the given scope', async () => { + const owner = await createMember(); + const otherProject = await createTeamProject(); + const otherAgent = await agentRepo.save( + agentRepo.create({ + id: uuid(), + name: 'Other Agent', + projectId: otherProject.id, + integrations: {}, + tools: {}, + skills: {}, + } as Partial), + ); + const inScope = await createThread({ ownerId: owner.id }); + await createExecution(inScope.id, N8N_CHAT_PRODUCTION_SOURCE); + const outOfScope = await createThread({ + ownerId: owner.id, + agentId: otherAgent.id, + projectId: otherProject.id, + }); + await createExecution(outOfScope.id, N8N_CHAT_PRODUCTION_SOURCE); + + const counts = await threadRepo.countN8nChatThreadsByAgent(owner.id, [projectId]); + + expect(counts).toEqual(new Map([[agentId, 1]])); + }); + + it('returns an empty map without a query when the project scope is empty', async () => { + const owner = await createMember(); + await expect(threadRepo.countN8nChatThreadsByAgent(owner.id, [])).resolves.toEqual(new Map()); + }); + }); +}); diff --git a/packages/cli/test/integration/database/repositories/agent.repository.test.ts b/packages/cli/test/integration/database/repositories/agent.repository.test.ts index 93b3e5a2f4a..3282a13e966 100644 --- a/packages/cli/test/integration/database/repositories/agent.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/agent.repository.test.ts @@ -902,4 +902,125 @@ describe('AgentRepository', () => { expect(page.data).toHaveLength(1); }); }); + + describe('findChatReachableIds', () => { + const chatChannel: AgentIntegrationConfig = { + type: N8N_CHAT_INTEGRATION_TYPE, + credentialId: '', + }; + + /** Publishes `snapshotIntegrations`, while the draft column keeps `overrides`. */ + async function createPublishedAgent( + snapshotIntegrations: AgentIntegrationConfig[], + overrides: Partial = {}, + ): Promise { + const versionId = uuid(); + const agent = await createAgent(overrides); + await agentHistoryRepo.save({ + versionId, + agentId: agent.id, + author: 'test', + schema: { + name: 'Published', + model: 'm', + instructions: 'i', + integrations: snapshotIntegrations, + }, + tools: null, + skills: null, + }); + await agentRepo.update({ id: agent.id }, { activeVersionId: versionId }); + return (await agentRepo.findById(agent.id)) as Agent; + } + + it('includes an agent whose published config carries the channel', async () => { + const agent = await createPublishedAgent([chatChannel]); + + const ids = await agentRepo.findChatReachableIds([projectId]); + + expect(ids).toEqual([agent.id]); + }); + + it('excludes an unpublished agent', async () => { + await createAgent({ + integrations: [chatChannel] as unknown as Agent['integrations'], + activeVersionId: null, + }); + + await expect(agentRepo.findChatReachableIds([projectId])).resolves.toEqual([]); + }); + + it('excludes an agent whose published config dropped the channel', async () => { + await createPublishedAgent([]); + + await expect(agentRepo.findChatReachableIds([projectId])).resolves.toEqual([]); + }); + + it('excludes a published agent in another project', async () => { + const otherProject = await createTeamProject(); + await createPublishedAgent([chatChannel], { projectId: otherProject.id }); + + await expect(agentRepo.findChatReachableIds([projectId])).resolves.toEqual([]); + }); + + it('ignores the project filter for a global scope (projectIds: null)', async () => { + const otherProject = await createTeamProject(); + const inProject = await createPublishedAgent([chatChannel]); + const inOtherProject = await createPublishedAgent([chatChannel], { + projectId: otherProject.id, + }); + + const ids = await agentRepo.findChatReachableIds(null); + + expect(ids.sort()).toEqual([inProject.id, inOtherProject.id].sort()); + }); + + it('returns no ids without a query when the project scope is empty', async () => { + await createPublishedAgent([chatChannel]); + + await expect(agentRepo.findChatReachableIds([])).resolves.toEqual([]); + }); + }); + + describe('findByProjectIdsPaginated - usage sort', () => { + async function listByUsage(usageCounts?: Map) { + return await agentRepo.findByProjectIdsPaginated( + [projectId], + { skip: 0, take: 10, sortBy: 'usage:desc' }, + { usageCounts }, + ); + } + + it('falls back to createdAt desc without usage counts', async () => { + const older = await createAgent({ + name: 'Older', + createdAt: new Date('2024-01-01T00:00:00Z'), + } as Partial); + const newer = await createAgent({ + name: 'Newer', + createdAt: new Date('2024-02-01T00:00:00Z'), + } as Partial); + + const { data, count } = await listByUsage(); + + expect(data.map((agent) => agent.id)).toEqual([newer.id, older.id]); + expect(count).toBe(2); + }); + + it('ranks agents by usage count, usage above none', async () => { + const unused = await createAgent({ name: 'Unused' }); + const lightlyUsed = await createAgent({ name: 'Lightly used' }); + const heavilyUsed = await createAgent({ name: 'Heavily used' }); + + const { data, count } = await listByUsage( + new Map([ + [heavilyUsed.id, 2], + [lightlyUsed.id, 1], + ]), + ); + + expect(data.map((agent) => agent.id)).toEqual([heavilyUsed.id, lightlyUsed.id, unused.id]); + expect(count).toBe(3); + }); + }); }); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.test.ts index 817bc24f45f..21da99a6941 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.test.ts @@ -111,6 +111,18 @@ describe('N8nMenuItem', () => { }); expect(getByText('🏠')).toBeVisible(); }); + + it('should render the icon slot instead of the item icon when given', () => { + const { getByTestId, queryByTestId } = render(N8nMenuItem, { + props: { + item: createMenuItem({ icon: 'house' }), + }, + slots: { icon: '
' }, + global: { stubs }, + }); + expect(getByTestId('custom-icon')).toBeVisible(); + expect(queryByTestId('menu-item')?.querySelector('svg')).toBeNull(); + }); }); describe('active prop', () => { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.vue b/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.vue index 40be015c6b0..e5b4bf92aa3 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.vue @@ -135,15 +135,17 @@ const tooltipPlacement = computed(() => { @click="handleClick" >
- {{ item.icon.value }} - + + {{ item.icon.value }} + +
{ test('waits for delayed flag hydration before allowing enrolled users through', async () => { const posthog = usePostHog(); - const hasPendingFeatureFlagsSpy = vi - .spyOn(posthog, 'hasPendingFeatureFlags') - .mockReturnValue(true); const waitForFeatureFlagsSpy = vi .spyOn(posthog, 'waitForFeatureFlags') .mockImplementation(async () => { @@ -283,7 +280,6 @@ describe('router', () => { expect(router.currentRoute.value.name).toBe(VIEWS.RESOURCE_CENTER); } finally { waitForFeatureFlagsSpy.mockRestore(); - hasPendingFeatureFlagsSpy.mockRestore(); } }); }); diff --git a/packages/frontend/editor-ui/src/app/router.ts b/packages/frontend/editor-ui/src/app/router.ts index 707edf8aedc..958274013c1 100644 --- a/packages/frontend/editor-ui/src/app/router.ts +++ b/packages/frontend/editor-ui/src/app/router.ts @@ -23,7 +23,7 @@ import { projectsRoutes } from '@/features/collaboration/projects/projects.route import { MfaRequiredError, setUnauthorizedHandler } from '@n8n/rest-api-client'; import { handleSessionExpired } from '@/app/utils/handleSessionExpired'; import { useRecentResources } from '@/features/shared/commandBar/composables/useRecentResources'; -import { usePostHog } from '@/app/stores/posthog.store'; +import { usePostHog, waitForFeatureFlagsWithTimeout } from '@/app/stores/posthog.store'; import { RESOURCE_CENTER_EXPERIMENT, TEMPLATE_SETUP_EXPERIENCE } from '@/app/constants/experiments'; import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials'; import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants'; @@ -141,21 +141,6 @@ function getTemplatesRedirect(defaultRedirect: VIEWS[keyof VIEWS]): { name: stri const RESOURCE_CENTER_FLAG_WAIT_TIMEOUT = 2000; -const waitForPendingFeatureFlags = async (posthogStore: ReturnType) => { - let timeoutId: number | undefined; - - await Promise.race([ - posthogStore.waitForFeatureFlags(), - new Promise((resolve) => { - timeoutId = window.setTimeout(resolve, RESOURCE_CENTER_FLAG_WAIT_TIMEOUT); - }), - ]); - - if (timeoutId !== undefined) { - window.clearTimeout(timeoutId); - } -}; - const allowResourceCenterRoute = ( posthogStore: ReturnType, next: NavigationGuardNext, @@ -323,14 +308,14 @@ export const routes: RouteRecordRaw[] = [ return; } - if (!posthogStore.hasPendingFeatureFlags()) { - next({ name: VIEWS.HOMEPAGE }); - return; - } - - void waitForPendingFeatureFlags(posthogStore).then(() => { - allowResourceCenterRoute(posthogStore, next); - }); + // `waitForFeatureFlagsWithTimeout` resolves immediately when nothing is + // pending, so re-checking the variant below still redirects right away + // for a user whose flags were already resolved as "off". + void waitForFeatureFlagsWithTimeout(posthogStore, RESOURCE_CENTER_FLAG_WAIT_TIMEOUT).then( + () => { + allowResourceCenterRoute(posthogStore, next); + }, + ); }, }, diff --git a/packages/frontend/editor-ui/src/app/stores/posthog.store.test.ts b/packages/frontend/editor-ui/src/app/stores/posthog.store.test.ts index e4312aef6b1..e2ef92d86b8 100644 --- a/packages/frontend/editor-ui/src/app/stores/posthog.store.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/posthog.store.test.ts @@ -1,5 +1,5 @@ import { createPinia, setActivePinia } from 'pinia'; -import { usePostHog } from '@/app/stores/posthog.store'; +import { usePostHog, waitForFeatureFlagsWithTimeout } from '@/app/stores/posthog.store'; import { useUsersStore } from '@n8n/stores/users.store'; import { useSettingsStore } from '@n8n/stores/settings.store'; import { useRootStore } from '@n8n/stores/useRootStore'; @@ -521,4 +521,45 @@ describe('Posthog store', () => { window.featureFlags = undefined; }); }); + + describe('waitForFeatureFlagsWithTimeout', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves once the flags settle, and clears its own timeout', async () => { + const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout'); + const store = { + waitForFeatureFlags: vi.fn().mockResolvedValue(undefined), + } as unknown as ReturnType; + + const promise = waitForFeatureFlagsWithTimeout(store, 3000); + await vi.advanceTimersByTimeAsync(0); + await promise; + + expect(store.waitForFeatureFlags).toHaveBeenCalled(); + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); + + it('resolves after timeoutMs when the flags never settle', async () => { + const store = { + waitForFeatureFlags: vi.fn(async () => await new Promise(() => {})), + } as unknown as ReturnType; + + let resolved = false; + void waitForFeatureFlagsWithTimeout(store, 3000).then(() => { + resolved = true; + }); + + await vi.advanceTimersByTimeAsync(2999); + expect(resolved).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(resolved).toBe(true); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/app/stores/posthog.store.ts b/packages/frontend/editor-ui/src/app/stores/posthog.store.ts index 29e9e73933b..43c95eef200 100644 --- a/packages/frontend/editor-ui/src/app/stores/posthog.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/posthog.store.ts @@ -349,3 +349,23 @@ export const usePostHog = defineStore('posthog', () => { overrides, }; }); + +/** + * Waits for a pending client-side flag evaluation, capped at `timeoutMs` so a + * route guard that depends on it never hangs a deep link. Takes the store + * instance (rather than calling `usePostHog()` itself) so callers that pass + * the same instance they hold elsewhere keep a single source of truth. + */ +export async function waitForFeatureFlagsWithTimeout( + posthogStore: ReturnType, + timeoutMs: number, +): Promise { + let timeoutId: number | undefined; + await Promise.race([ + posthogStore.waitForFeatureFlags(), + new Promise((resolve) => { + timeoutId = window.setTimeout(resolve, timeoutMs); + }), + ]); + if (timeoutId !== undefined) window.clearTimeout(timeoutId); +} diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageAttachments.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageAttachments.test.ts index 75e6e25d341..cdfbf9bb192 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageAttachments.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageAttachments.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, vi } from 'vitest'; import { mount } from '@vue/test-utils'; import type { ChatMessageAttachment } from '@/features/ai/shared/agentsChat/types'; import AgentChatMessageAttachments from '../components/AgentChatMessageAttachments.vue'; +import { AGENT_ATTACHMENT_URL_KEY } from '../components/agentChatInjectionKeys'; vi.mock('@n8n/stores/useRootStore', () => ({ useRootStore: () => ({ restApiContext: { baseUrl: '/rest' } }), @@ -12,9 +13,17 @@ vi.mock('@n8n/i18n', () => ({ useI18n: () => ({ baseText: (key: string) => key }), })); -function renderComponent(attachments: ChatMessageAttachment[]) { +function renderComponent( + attachments: ChatMessageAttachment[], + options: { attachmentUrlBuilder?: (attachmentId: string) => string } = {}, +) { return mount(AgentChatMessageAttachments, { props: { attachments, projectId: 'project-1', agentId: 'agent-1' }, + global: { + provide: options.attachmentUrlBuilder + ? { [AGENT_ATTACHMENT_URL_KEY as symbol]: options.attachmentUrlBuilder } + : {}, + }, }); } @@ -36,6 +45,14 @@ describe('AgentChatMessageAttachments', () => { expect(wrapper.find('a').attributes('href')).toBe(img.attributes('src')); }); + it("uses the panel's injected URL builder when one is provided", () => { + const wrapper = renderComponent([imageAttachment], { + attachmentUrlBuilder: (id) => `/rest/n8n-chat/${id}`, + }); + + expect(wrapper.find('img').attributes('src')).toBe('/rest/n8n-chat/att-1'); + }); + it('renders a file chip with a download link for non-image attachments', () => { const wrapper = renderComponent([ { fileId: 'att-2', fileName: 'report.pdf', mimeType: 'application/pdf', sizeBytes: 2048 }, diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts index fd5251bb87f..16fdf7eb9d0 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts @@ -42,6 +42,7 @@ const isStreamingMock = ref(false); const isSubmittingMock = ref(false); const isLoadingHistoryMock = ref(false); const trackSubmittedMessageMock = vi.fn(); +const trackSentMessageToN8nChatAgentMock = vi.fn(); const queuedMessagesMock = ref([]); const removeQueuedMessageMock = vi.fn(); const updateQueuedMessageMock = vi.fn(); @@ -64,6 +65,9 @@ vi.mock('../composables/useAgentBackgroundJobs', () => ({ isStopping: isStoppingMock, }), })); +vi.mock('@n8n/stores/useRootStore', () => ({ + useRootStore: () => ({ restApiContext: { baseUrl: '/rest' } }), +})); let onHistoryLoaded: ((count: number) => void) | undefined; const fatalErrorMock = ref<{ missing: string[] } | null>(null); @@ -234,6 +238,12 @@ vi.mock('../composables/useAgentChatStream', () => ({ useAgentChatStream: (options: { onHistoryLoaded: (count: number) => void }) => { onHistoryLoaded = options.onHistoryLoaded; return { + capabilities: computed(() => ({ + steer: true, + reorder: true, + backgroundTasks: true, + previewHistory: true, + })), messages: messagesMock, isStreaming: isStreamingMock, isSubmitting: isSubmittingMock, @@ -264,7 +274,10 @@ vi.mock('../composables/useAgentChatStream', () => ({ })); vi.mock('../composables/useAgentTelemetry', () => ({ - useAgentTelemetry: () => ({ trackSubmittedMessage: trackSubmittedMessageMock }), + useAgentTelemetry: () => ({ + trackSubmittedMessage: trackSubmittedMessageMock, + trackSentMessageToN8nChatAgent: trackSentMessageToN8nChatAgentMock, + }), })); vi.mock('../composables/agentTelemetry.utils', () => ({ @@ -313,6 +326,9 @@ describe('AgentChatPanel', () => { field: 'monthlyBudgetUsd' | 'sessionCostCapUsd'; amount: number; }) => Promise; + channel: 'chat' | 'n8n-chat'; + centerEmptyState: boolean; + newSession: boolean; }> = {}, attachTo?: HTMLElement, ) { @@ -341,6 +357,46 @@ describe('AgentChatPanel', () => { }); } + it('reports the first user message, for a title before the thread has one', async () => { + messagesMock.value = [ + { id: 'm1', role: 'assistant', content: 'Hi' } as ChatMessage, + { id: 'm2', role: 'user', content: 'Review my draft' } as ChatMessage, + ]; + const wrapper = mountPanel(); + await flushPromises(); + + expect(wrapper.emitted('first-user-message')?.at(-1)).toEqual(['Review my draft']); + wrapper.unmount(); + }); + + describe('centerEmptyState', () => { + const isCentered = (wrapper: ReturnType) => + wrapper + .get('aside') + .classes() + .some((c) => c.includes('centeredEmpty')); + + it('centers an empty new chat, which already carries a minted session id', () => { + expect( + isCentered( + mountPanel({ centerEmptyState: true, newSession: true, continueSessionId: 'minted' }), + ), + ).toBe(true); + }); + + it('stops centering once the chat has messages', () => { + messagesMock.value = [{ id: 'm1', role: 'user', content: 'hi' } as ChatMessage]; + expect(isCentered(mountPanel({ centerEmptyState: true, newSession: true }))).toBe(false); + }); + + it('does not center a continued thread or without the prop', () => { + expect(isCentered(mountPanel({ centerEmptyState: true, continueSessionId: 's1' }))).toBe( + false, + ); + expect(isCentered(mountPanel())).toBe(false); + }); + }); + it('keeps two pending messages in the composer below background tasks and removes them without adding conversation bubbles', async () => { queuedMessagesMock.value = [ { @@ -1684,6 +1740,92 @@ describe('AgentChatPanel', () => { expect(sendMessageMock).not.toHaveBeenCalled(); }); + it('tracks a sent message to the n8n Chat channel on the normal send path, as a new thread', async () => { + messagesMock.value = []; + const response = Promise.withResolvers<'sent'>(); + sendMessageMock.mockReturnValueOnce(response.promise); + const wrapper = mountPanel({ channel: 'n8n-chat', continueSessionId: 'thread-1' }); + + ( + wrapper.vm as unknown as { sendMessageFromOutside: (message: string) => void } + ).sendMessageFromOutside('Hi there'); + await flushPromises(); + sendMessageMock.mock.lastCall?.[2]?.(); + response.resolve('sent'); + await flushPromises(); + + expect(trackSentMessageToN8nChatAgentMock).toHaveBeenCalledWith({ + agentId: 'a1', + threadId: 'thread-1', + isNewThread: true, + }); + // The preview chat metric counts only builder test messages. + expect(trackSubmittedMessageMock).not.toHaveBeenCalled(); + expect(buildAgentConfigFingerprint).not.toHaveBeenCalled(); + wrapper.unmount(); + }); + + it('tracks a sent message to the n8n Chat channel as not-new when the thread already had messages', async () => { + messagesMock.value = [{ id: 'm1', role: 'user', content: 'hi', status: 'success' }]; + const response = Promise.withResolvers<'sent'>(); + sendMessageMock.mockReturnValueOnce(response.promise); + const wrapper = mountPanel({ channel: 'n8n-chat', continueSessionId: 'thread-2' }); + + ( + wrapper.vm as unknown as { sendMessageFromOutside: (message: string) => void } + ).sendMessageFromOutside('Hi again'); + await flushPromises(); + sendMessageMock.mock.lastCall?.[2]?.(); + response.resolve('sent'); + await flushPromises(); + + expect(trackSentMessageToN8nChatAgentMock).toHaveBeenCalledWith({ + agentId: 'a1', + threadId: 'thread-2', + isNewThread: false, + }); + wrapper.unmount(); + }); + + it('tracks a sent message to the n8n Chat channel on the cancel-and-steer path', async () => { + messagesMock.value = [openInteractiveMessage()]; + const wrapper = mountPanel({ channel: 'n8n-chat', continueSessionId: 'thread-3' }); + + ( + wrapper.vm as unknown as { sendMessageFromOutside: (message: string) => void } + ).sendMessageFromOutside('go another direction'); + await flushPromises(); + // Simulate `cancelAndSteer` accepting the message, same as `sendMessage`'s callback. + cancelAndSteerMock.mock.lastCall?.[1]?.(); + await flushPromises(); + + expect(trackSentMessageToN8nChatAgentMock).toHaveBeenCalledWith({ + agentId: 'a1', + threadId: 'thread-3', + // An open interactive question means the thread already had messages. + isNewThread: false, + }); + }); + + it('does not track a sent message when the channel is not n8n-chat', async () => { + messagesMock.value = []; + const response = Promise.withResolvers<'sent'>(); + sendMessageMock.mockReturnValueOnce(response.promise); + const wrapper = mountPanel({ continueSessionId: 'thread-4' }); + + ( + wrapper.vm as unknown as { sendMessageFromOutside: (message: string) => void } + ).sendMessageFromOutside('Hi there'); + await flushPromises(); + sendMessageMock.mock.lastCall?.[2]?.(); + response.resolve('sent'); + await flushPromises(); + + expect(trackSentMessageToN8nChatAgentMock).not.toHaveBeenCalled(); + expect(trackSubmittedMessageMock).toHaveBeenCalledOnce(); + wrapper.unmount(); + }); + it('keeps a steering draft after a busy rejection without retrying it', async () => { messagesMock.value = [openInteractiveMessage()]; cancelAndSteerMock.mockResolvedValueOnce('busy'); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPreviewMoreMenu.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPreviewMoreMenu.test.ts index 35c7ddd70b9..3ab6c01228a 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPreviewMoreMenu.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPreviewMoreMenu.test.ts @@ -389,6 +389,7 @@ describe('AgentPreviewMoreMenu', function describeMenu() { ['workflow', 'Workflow', 'workflow'], ['webhook', 'Webhook', 'webhook'], ['instance-ai', 'agentSessions.origin.instanceAi', 'sparkles'], + ['n8n_chat_production', 'agentSessions.origin.n8nChat', 'message-square'], ['chat', 'agentSessions.origin.preview', 'bolt-filled'], ['n8n_chat', 'agentSessions.origin.preview', 'bolt-filled'], [null, 'agentSessions.origin.preview', 'bolt-filled'], diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts index e6bb2791c21..450b774f82b 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts @@ -51,6 +51,7 @@ vi.mock('@n8n/i18n', () => ({ 'agentSessions.origin.subAgent': 'Sub-agent', 'agentSessions.origin.schedule': 'Schedule', 'agentSessions.origin.workflow': 'Workflow', + 'agentSessions.origin.n8nChat': 'n8n Chat', 'agentSessions.empty': 'No agent sessions', 'agentSessions.emptyDescription': 'Sessions will appear here after you preview your agent.', @@ -424,6 +425,7 @@ describe('AgentSessionsListView', () => { [{ source: null }, 'Preview', 'flask-conical'], [{ source: 'chat' }, 'Preview', 'flask-conical'], [{ source: 'n8n_chat' }, 'Preview', 'flask-conical'], + [{ source: 'n8n_chat_production' }, 'n8n Chat', 'message-square'], [{ source: 'workflow' }, 'Workflow', 'workflow'], [{ source: 'subagent' }, 'Sub-agent', 'bot'], [{ parentThreadId: 'parent-1', source: 'slack' }, 'Sub-agent', 'bot'], diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/module.descriptor.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/module.descriptor.test.ts new file mode 100644 index 00000000000..32feefb1279 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/module.descriptor.test.ts @@ -0,0 +1,122 @@ +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; +import { ref } from 'vue'; +import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router'; +import { VIEWS } from '@/app/constants'; +import { InstanceAiModule } from '@/features/ai/instanceAi/module.descriptor'; +import { INSTANCE_AI_VIEW, INSTANCE_AI_THREAD_VIEW } from '@/features/ai/instanceAi/constants'; +import { AgentsModule } from '../module.descriptor'; +import { AGENT_N8N_CHAT_VIEW, AGENT_N8N_CHAT_LIBRARY_VIEW } from '../constants'; + +let flagEnabled = true; +let instanceAiAvailable = true; + +vi.mock('../composables/useAgentsN8nChatFlag', () => ({ + isAgentsN8nChatFlagEnabledOnceEvaluated: async () => flagEnabled, +})); + +vi.mock('@/features/ai/instanceAi/composables/useInstanceAiAvailability', () => ({ + useInstanceAiAvailable: () => ref(instanceAiAvailable), +})); + +const stub = { render: () => null }; + +// Swap real lazy components for a stub so navigation doesn't pull the view tree. +function withStubbedComponents(route: RouteRecordRaw): RouteRecordRaw { + const clone = { ...route } as Record; + if (clone.component) clone.component = stub; + if (Array.isArray(clone.children)) { + clone.children = (clone.children as RouteRecordRaw[]).map(withStubbedComponents); + } + return clone as unknown as RouteRecordRaw; +} + +// The n8n Chat route is a standalone top-level route (not nested under +// `/assistant`), registered alongside instanceAi's own `/assistant` tree — +// both must coexist in the real router, so build one from both modules here. +function createTestRouter() { + const agentN8nChatRoutes = (AgentsModule.routes ?? []).filter( + (route) => route.name === AGENT_N8N_CHAT_VIEW || route.name === AGENT_N8N_CHAT_LIBRARY_VIEW, + ); + if (agentN8nChatRoutes.length !== 2) throw new Error('n8n Chat routes not found'); + const instanceAiRoutes = (InstanceAiModule.routes ?? []).filter((route) => + route.path.startsWith('/'), + ); + + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/home', name: VIEWS.HOMEPAGE, component: stub }, + ...agentN8nChatRoutes.map(withStubbedComponents), + ...instanceAiRoutes.map(withStubbedComponents), + ], + }); +} + +beforeEach(() => { + setActivePinia(createTestingPinia()); + flagEnabled = true; + instanceAiAvailable = true; +}); + +describe('AgentsModule n8n Chat route', () => { + it('ranks above the instanceAi /assistant/:threadId route', () => { + const router = createTestRouter(); + + expect(router.resolve('/assistant/agents/agent-1').name).toBe(AGENT_N8N_CHAT_VIEW); + expect(router.resolve('/assistant/agents/agent-1/thread-1').name).toBe(AGENT_N8N_CHAT_VIEW); + expect(router.resolve('/assistant/some-thread').name).toBe(INSTANCE_AI_THREAD_VIEW); + }); + + it('resolves /assistant/agents to the library, not the instanceAi /assistant/:threadId child', () => { + const router = createTestRouter(); + + expect(router.resolve('/assistant/agents').name).toBe(AGENT_N8N_CHAT_LIBRARY_VIEW); + }); + + it.each(['/assistant/agents', '/assistant/agents/agent-1'])( + 'redirects home when n8n Assistant is unavailable (%s)', + async (path) => { + instanceAiAvailable = false; + const router = createTestRouter(); + + await router.push(path); + + expect(router.currentRoute.value.name).toBe(VIEWS.HOMEPAGE); + }, + ); + + it.each(['/assistant/agents', '/assistant/agents/agent-1'])( + 'redirects to the Assistant view when the flag is off (%s)', + async (path) => { + flagEnabled = false; + const router = createTestRouter(); + + await router.push(path); + + expect(router.currentRoute.value.name).toBe(INSTANCE_AI_VIEW); + }, + ); + + it('requires the custom middleware (module availability check) on both routes', () => { + const agentN8nChatRoutes = (AgentsModule.routes ?? []).filter( + (route) => route.name === AGENT_N8N_CHAT_VIEW || route.name === AGENT_N8N_CHAT_LIBRARY_VIEW, + ); + expect(agentN8nChatRoutes).toHaveLength(2); + for (const route of agentN8nChatRoutes) { + expect(route.meta?.middleware).toContain('custom'); + } + }); + + it('resolves agentId and agentThreadId as route params once available and flagged on', async () => { + const router = createTestRouter(); + + await router.push('/assistant/agents/agent-1/thread-1'); + + expect(router.currentRoute.value.name).toBe(AGENT_N8N_CHAT_VIEW); + expect(router.currentRoute.value.params).toEqual({ + agentId: 'agent-1', + agentThreadId: 'thread-1', + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentApi.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentApi.test.ts index 111ed046b73..8c7d5c2879e 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentApi.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentApi.test.ts @@ -1,12 +1,15 @@ /* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getFullApiResponse, makeRestApiRequest } from '@n8n/rest-api-client'; +import { getFullApiResponse, makeRestApiRequest, request } from '@n8n/rest-api-client'; import { cancelAgentChatExecution, + cancelAgentChatRun, getAgentBackgroundJobs, getAgentBudgetSpend, getAgentChatQueue, + getN8nChatAgent, + getN8nChatThread, removeAgentQueuedMessage, steerAgentQueuedMessage, stopAgentBackgroundJobs, @@ -14,6 +17,8 @@ import { getChatMessages, listAgents, listAgentsPage, + listN8nChatAgents, + listN8nChatThreads, duplicateAgent, } from '../composables/useAgentApi'; import type { AgentResource, AgentJsonConfig } from '../types'; @@ -21,6 +26,7 @@ import type { AgentResource, AgentJsonConfig } from '../types'; vi.mock('@n8n/rest-api-client', () => ({ getFullApiResponse: vi.fn(), makeRestApiRequest: vi.fn(), + request: vi.fn(), })); const restApiContext = { baseUrl: '/rest', pushRef: 'push-ref' }; @@ -137,6 +143,8 @@ describe('useAgentApi', () => { await getChatMessages(restApiContext, 'project-1', 'agent-1', 'agent-1:chat:bot-1-2#1'); + // Relative to `restApiContext.baseUrl` — makeRestApiRequest prepends it itself, + // so an endpoint that already carried `/rest` would double it in production. expect(makeRestApiRequest).toHaveBeenCalledWith( restApiContext, 'GET', @@ -163,6 +171,145 @@ describe('useAgentApi', () => { ); }); + describe('n8n Chat channel', () => { + it('switches the queue, messages, and stop routes to n8n-chat', async () => { + const args = [restApiContext, 'project-1', 'agent-1', 'thread-1'] as const; + await getAgentChatQueue(...args, 'n8n-chat'); + await updateAgentQueuedMessage(...args, 'queue-1', { message: 'edited' }, 'n8n-chat'); + await removeAgentQueuedMessage(...args, 'queue-1', 'n8n-chat'); + await getChatMessages(...args, 'n8n-chat'); + await cancelAgentChatExecution(...args, 'execution-1', 'n8n-chat'); + await cancelAgentChatRun(restApiContext, 'project-1', 'agent-1', 'run-1', 'n8n-chat'); + + // Relative paths only: makeRestApiRequest prepends `restApiContext.baseUrl` itself. + const path = '/projects/project-1/agents/v2/agent-1/n8n-chat/thread-1'; + expect(vi.mocked(makeRestApiRequest).mock.calls).toEqual([ + [restApiContext, 'GET', `${path}/queue`], + [restApiContext, 'PATCH', `${path}/queue/queue-1`, { message: 'edited' }], + [restApiContext, 'DELETE', `${path}/queue/queue-1`], + [restApiContext, 'GET', `${path}/messages`], + [restApiContext, 'DELETE', `${path}/executions/execution-1`], + [restApiContext, 'DELETE', '/projects/project-1/agents/v2/agent-1/n8n-chat/runs/run-1'], + ]); + }); + }); + + describe('getN8nChatAgent', () => { + it('fetches one agent from the cross-project n8n Chat route', async () => { + const item = { id: 'agent-1', name: 'Support', project: { id: 'p1', name: 'Team' } }; + vi.mocked(makeRestApiRequest).mockResolvedValueOnce(item); + + const result = await getN8nChatAgent(restApiContext, 'agent/1'); + + expect(makeRestApiRequest).toHaveBeenCalledWith( + restApiContext, + 'GET', + '/agents/v2/n8n-chat/agents/agent%2F1', + ); + expect(result).toBe(item); + }); + }); + + describe('getN8nChatThread', () => { + it('fetches one thread from the cross-project n8n Chat route', async () => { + const summary = { + id: 'thread/1', + title: 'Refund status', + updatedAt: '2025-01-02T00:00:00.000Z', + agent: { id: 'agent-1', name: 'Support', projectId: 'project-1' }, + }; + vi.mocked(makeRestApiRequest).mockResolvedValueOnce(summary); + + const result = await getN8nChatThread(restApiContext, 'thread/1'); + + expect(makeRestApiRequest).toHaveBeenCalledWith( + restApiContext, + 'GET', + '/agents/v2/n8n-chat/threads/thread%2F1', + ); + expect(result).toBe(summary); + }); + }); + + describe('listN8nChatAgents', () => { + it('always filters by availableInChat, forwards paging and sort, and trims the query', async () => { + const response = { + count: 1, + data: [{ id: 'agent-1', name: 'Support', project: { id: 'p1', name: 'Team' } }], + }; + vi.mocked(getFullApiResponse).mockResolvedValueOnce(response); + + const result = await listN8nChatAgents(restApiContext, { + query: ' support ', + skip: 10, + take: 50, + sortBy: 'usage:desc', + }); + + expect(getFullApiResponse).toHaveBeenCalledWith(restApiContext, 'GET', '/agents/v2', { + filter: { availableInChat: true, query: 'support' }, + skip: 10, + take: 50, + sortBy: 'usage:desc', + }); + expect(result).toBe(response); + }); + + it('omits the query from the filter when blank — not given, empty, or whitespace-only', async () => { + vi.mocked(getFullApiResponse).mockResolvedValue({ count: 0, data: [] }); + + await listN8nChatAgents(restApiContext, { skip: 0, take: 50 }); + await listN8nChatAgents(restApiContext, { query: ' ', skip: 0, take: 50 }); + + expect(getFullApiResponse).toHaveBeenNthCalledWith(1, restApiContext, 'GET', '/agents/v2', { + filter: { availableInChat: true }, + skip: 0, + take: 50, + sortBy: undefined, + }); + expect(getFullApiResponse).toHaveBeenNthCalledWith(2, restApiContext, 'GET', '/agents/v2', { + filter: { availableInChat: true }, + skip: 0, + take: 50, + sortBy: undefined, + }); + }); + }); + + describe('listN8nChatThreads', () => { + it('reads the full response body, not just the data key, so nextCursor survives', async () => { + const response = { + data: [ + { + id: 'thread-1', + title: 'Support question', + updatedAt: '2025-01-02T00:00:00.000Z', + agent: { id: 'agent-1', name: 'Support', projectId: 'project-1' }, + }, + ], + nextCursor: '2025-01-02T00:00:00.000Z', + }; + vi.mocked(request).mockResolvedValueOnce(response); + + const result = await listN8nChatThreads(restApiContext, { limit: 20, cursor: 'cursor-1' }); + + expect(request).toHaveBeenCalledWith({ + method: 'GET', + baseURL: restApiContext.baseUrl, + endpoint: '/agents/v2/n8n-chat/threads', + headers: { 'push-ref': restApiContext.pushRef }, + data: { limit: 20, cursor: 'cursor-1', agentId: undefined }, + }); + expect(result).toBe(response); + }); + + it('throws on a response body with the wrong shape', async () => { + vi.mocked(request).mockResolvedValueOnce({ data: [], nextCursor: 123 }); + + await expect(listN8nChatThreads(restApiContext, { limit: 20 })).rejects.toThrow(); + }); + }); + describe('duplicateAgent', () => { it('fetches the source agent and config, then creates a clone with the new name', async () => { const sourceAgent = { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts index ef2019d8111..ea2828842c9 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts @@ -220,6 +220,7 @@ function buildHook( newSession?: Ref; onSessionCreated?: (sessionId: string) => void; budgetCards?: boolean; + channel?: Ref<'chat' | 'n8n-chat'>; } = {}, ) { const scope = effectScope(); @@ -616,6 +617,7 @@ describe('useAgentChatStream — SDK-aligned event handling', () => { 'p1', 'a1', 'run-approval', + 'chat', ); const assistant = hook.messages.value[1]; expect(assistant.toolCalls?.[0]).toMatchObject({ state: 'cancelled', canceled: true }); @@ -844,6 +846,7 @@ describe('useAgentChatStream — SDK-aligned event handling', () => { 'p1', 'a1', 'run-approval', + 'chat', ); expect(hook.messages.value[1].toolCalls?.[0].state).toBe('cancelled'); }); @@ -896,6 +899,7 @@ describe('useAgentChatStream — SDK-aligned event handling', () => { 'p1', 'a1', 'run-external', + 'chat', ); expect(hook.messages.value[1].status).toBe('success'); expect(hook.messages.value[1].toolCalls).toEqual([ @@ -2652,6 +2656,7 @@ describe('useAgentChatStream — loadHistory', () => { 'p1', 'a1', 'thread-1', + 'chat', ); const msg = hook.messages.value.at(-1)!; expect(msg.interactive?.toolName).toBe(N8N_CHAT_ACTION_TOOL_NAME); @@ -3540,6 +3545,7 @@ describe('useAgentChatStream — execution recovery', () => { 'a1', 'thread-1', 'exec-1', + 'chat', ); expect(second.isCancelling.value).toBe(true); expect(first.isStreaming.value).toBe(true); @@ -3664,6 +3670,7 @@ describe('useAgentChatStream — execution recovery', () => { 'a1', 'thread-1', 'exec-1', + 'chat', ); expect(vi.mocked(fetch).mock.calls[0][1]?.signal?.aborted).toBe(false); getChatMessagesMock.mockResolvedValue({ @@ -4470,6 +4477,7 @@ describe('useAgentChatStream — queued submissions', () => { 'thread-1', '1', { message: 'edited' }, + 'chat', ); stream!.emit([ { @@ -4683,3 +4691,151 @@ describe('useAgentChatStream — queued submissions', () => { expect(hook.messages.value).toEqual([]); }); }); + +describe('useAgentChatStream — n8n Chat channel', () => { + beforeEach(() => { + pushListeners.length = 0; + connectionState.isConnected = false; + getChatMessagesMock.mockReset().mockResolvedValue({ + messages: [], + openSuspensions: [], + activeExecutionId: null, + }); + getTestChatMessagesMock.mockReset(); + getAgentChatQueueMock.mockReset().mockResolvedValue({ items: [] }); + cancelAgentChatExecutionMock.mockReset().mockResolvedValue({ cancelRequested: true }); + cancelAgentChatRunMock.mockReset().mockResolvedValue({ cancelled: true }); + steerAgentQueuedMessageMock.mockReset().mockResolvedValue(undefined); + vi.stubGlobal('localStorage', { getItem: vi.fn(() => '') }); + }); + afterEach(() => vi.unstubAllGlobals()); + + function buildN8nChatHook(continueSessionId?: string) { + return buildHook(continueSessionId, { channel: ref('n8n-chat') }); + } + + it('posts a new message to n8n-chat instead of chat', async () => { + globalThis.fetch = vi.fn(async () => makeSseResponse([{ type: 'done' }])) as typeof fetch; + + const hook = buildN8nChatHook(); + await hook.sendMessage('hi'); + await flushPromises(); + + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:5678/projects/p1/agents/v2/a1/n8n-chat', + expect.anything(), + ); + }); + + it('posts a resume to n8n-chat/resume instead of chat/resume', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + makeSseResponse([ + { + type: 'tool-call', + toolCallId: 'tc-1', + toolName: 'calculator', + input: { input: '2 + 2' }, + }, + { + type: 'tool-call-suspended', + payload: { + toolCallId: 'tc-1', + runId: 'run-1', + toolName: 'calculator', + input: { type: 'approval', toolName: 'calculator', args: {} }, + }, + }, + { type: 'done' }, + ]), + ) + .mockResolvedValueOnce(makeSseResponse([{ type: 'done' }])); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const hook = buildN8nChatHook(); + await hook.sendMessage('calculate 2 + 2'); + await flushPromises(); + await hook.resume({ runId: 'run-1', toolCallId: 'tc-1', resumeData: { approved: true } }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'http://localhost:5678/projects/p1/agents/v2/a1/n8n-chat/resume', + expect.anything(), + ); + }); + + it('loads history and queue, and routes queue removal, through the n8n-chat channel', async () => { + const hook = buildN8nChatHook('thread-1'); + await hook.loadHistory(); + + expect(getChatMessagesMock).toHaveBeenCalledWith( + { baseUrl: 'http://localhost:5678' }, + 'p1', + 'a1', + 'thread-1', + 'n8n-chat', + ); + expect(getAgentChatQueueMock).toHaveBeenCalledWith( + { baseUrl: 'http://localhost:5678' }, + 'p1', + 'a1', + 'thread-1', + 'n8n-chat', + ); + + await hook.removeQueuedMessage('q1'); + expect(removeAgentQueuedMessageMock).toHaveBeenCalledWith( + { baseUrl: 'http://localhost:5678' }, + 'p1', + 'a1', + 'thread-1', + 'q1', + 'n8n-chat', + ); + }); + + it('never reorders and never calls the reorder route on the n8n-chat channel', async () => { + getAgentChatQueueMock.mockResolvedValue({ + items: [ + { id: 'q1', message: 'one', createdAt: new Date().toISOString() }, + { id: 'q2', message: 'two', createdAt: new Date().toISOString() }, + ], + }); + const hook = buildN8nChatHook('thread-1'); + await hook.loadHistory(); + + expect(hook.capabilities.value.reorder).toBe(false); + + await hook.reorderQueuedMessage('q1', 'q2', ['q1', 'q2']); + expect(reorderAgentQueuedMessageMock).not.toHaveBeenCalled(); + }); + + it('skips the history fetch entirely for a fresh n8n Chat with no thread', async () => { + const hook = buildN8nChatHook(); + await hook.loadHistory(); + + expect(getChatMessagesMock).not.toHaveBeenCalled(); + expect(getTestChatMessagesMock).not.toHaveBeenCalled(); + expect(hook.messages.value).toEqual([]); + }); + + it('never offers steer and never calls the steer route, even with a steerable execution', async () => { + getChatMessagesMock.mockResolvedValue({ + messages: [], + openSuspensions: [], + activeExecutionId: 'exec-1', + }); + getAgentChatQueueMock.mockResolvedValue({ + items: [{ id: 'q1', message: 'queued', createdAt: new Date().toISOString() }], + steerableExecutionId: 'exec-1', + }); + const hook = buildN8nChatHook('thread-1'); + await hook.loadHistory(); + + expect(hook.canSteer.value).toBe(false); + + await hook.steerQueuedMessage('q1'); + expect(steerAgentQueuedMessageMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentPermissions.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentPermissions.test.ts index ccc59ea530b..32db2fea7c5 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentPermissions.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentPermissions.test.ts @@ -125,6 +125,29 @@ describe('useAgentPermissions', () => { expect(useAgentPermissions(PROJECT_ID).canExecute.value).toBe(false); }); + // A chat-only member holds execute but not read — canRead must count global + // scopes too, so an instance owner sees the open-agent-page link even for a + // project they are not an explicit member of. + it('grants read from global scopes when no project is found', () => { + usersStore.currentUser = { globalScopes: ['agent:read'] } as never; + + expect(useAgentPermissions(PROJECT_ID).canRead.value).toBe(true); + }); + + it('withholds read when neither scope source allows it', () => { + projectsStore.myProjects = [makeProject(['agent:execute'])]; + usersStore.currentUser = { globalScopes: [] } as never; + + expect(useAgentPermissions(PROJECT_ID).canRead.value).toBe(false); + }); + + it('keeps read available in a read-only branch', () => { + projectsStore.myProjects = [makeProject(['agent:read'])]; + sourceControlStore.preferences = { branchReadOnly: true } as never; + + expect(useAgentPermissions(PROJECT_ID).canRead.value).toBe(true); + }); + // Running an agent writes no config, so a read-only branch is no reason to stop // it — unlike every mutating flag below. it('keeps execute available in a read-only branch', () => { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentTelemetry.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentTelemetry.test.ts index 2acb8d83a60..d26ddf6b72c 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentTelemetry.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentTelemetry.test.ts @@ -1,6 +1,8 @@ +import { AGENTS_N8N_CHAT_FLAG } from '@n8n/api-types'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { setActivePinia, createPinia } from 'pinia'; import { TELEMETRY_EVENT } from '@n8n/telemetry'; +import { usePostHog } from '@/app/stores/posthog.store'; import { useAgentTelemetry } from '../composables/useAgentTelemetry'; const trackMock = vi.fn(); @@ -134,4 +136,86 @@ describe('useAgentTelemetry', () => { }), ).not.toThrow(); }); + + it('trackSelectedN8nChatAgent fires with agent_id, source, and the current flag variant', () => { + usePostHog().overrides = { [AGENTS_N8N_CHAT_FLAG]: { value: 'variant-a' } }; + + useAgentTelemetry().trackSelectedN8nChatAgent({ agentId: 'agent-1', source: 'library' }); + + expect(trackMock).toHaveBeenCalledWith(TELEMETRY_EVENT.AGENTS.USER_SELECTED_N8N_CHAT_AGENT, { + agent_id: 'agent-1', + source: 'library', + variant: 'variant-a', + session_id: 'session-xyz', + }); + }); + + it('trackSelectedN8nChatAgent reports a null variant when the user has no flag value', () => { + useAgentTelemetry().trackSelectedN8nChatAgent({ agentId: 'agent-1', source: 'card' }); + + expect(trackMock).toHaveBeenCalledWith(TELEMETRY_EVENT.AGENTS.USER_SELECTED_N8N_CHAT_AGENT, { + agent_id: 'agent-1', + source: 'card', + variant: null, + session_id: 'session-xyz', + }); + }); + + it('trackSentMessageToN8nChatAgent fires with agent_id, thread_id, is_new_thread, and variant', () => { + usePostHog().overrides = { [AGENTS_N8N_CHAT_FLAG]: { value: 'variant-b' } }; + + useAgentTelemetry().trackSentMessageToN8nChatAgent({ + agentId: 'agent-1', + threadId: 'thread-1', + isNewThread: true, + }); + + expect(trackMock).toHaveBeenCalledWith( + TELEMETRY_EVENT.AGENTS.USER_SENT_MESSAGE_TO_N8N_CHAT_AGENT, + { + agent_id: 'agent-1', + thread_id: 'thread-1', + is_new_thread: true, + variant: 'variant-b', + session_id: 'session-xyz', + }, + ); + }); + + it('trackClickedSidebarItem fires "new_chat" with no chat_type and the current variant', () => { + usePostHog().overrides = { [AGENTS_N8N_CHAT_FLAG]: { value: 'variant-a' } }; + + useAgentTelemetry().trackClickedSidebarItem({ item: 'new_chat' }); + + expect(trackMock).toHaveBeenCalledWith( + TELEMETRY_EVENT.AGENTS.USER_CLICKED_N8N_CHAT_SIDEBAR_ITEM, + { + item: 'new_chat', + variant: 'variant-a', + session_id: 'session-xyz', + }, + ); + }); + + it('trackClickedSidebarItem fires "chat" with chat_type and the current variant', () => { + usePostHog().overrides = { [AGENTS_N8N_CHAT_FLAG]: { value: 'variant-a' } }; + + useAgentTelemetry().trackClickedSidebarItem({ item: 'chat', chatType: 'agent' }); + + expect(trackMock).toHaveBeenCalledWith( + TELEMETRY_EVENT.AGENTS.USER_CLICKED_N8N_CHAT_SIDEBAR_ITEM, + { + item: 'chat', + chat_type: 'agent', + variant: 'variant-a', + session_id: 'session-xyz', + }, + ); + }); + + it('trackClickedSidebarItem is a no-op with the flag off, so callers need not check it', () => { + useAgentTelemetry().trackClickedSidebarItem({ item: 'new_chat' }); + + expect(trackMock).not.toHaveBeenCalled(); + }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageAttachments.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageAttachments.vue index 64de31446b7..b7ca0bf9595 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageAttachments.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageAttachments.vue @@ -1,10 +1,12 @@ @@ -1395,6 +1467,15 @@ onBeforeUnmount(() => { min-width: 0; } +.centeredEmpty { + justify-content: center; +} + +.composer { + display: flex; + flex-direction: column; +} + .inputArea { flex-shrink: 0; padding: var(--spacing--xs) var(--spacing--sm); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewMoreMenu.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewMoreMenu.vue index 120059976b4..cf856d36bd9 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewMoreMenu.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewMoreMenu.vue @@ -123,6 +123,7 @@ const triggerLabel = computed(() => { return i18n.baseText('agentSessions.origin.preview'); } if (source === 'instance-ai') return i18n.baseText('agentSessions.origin.instanceAi'); + if (source === 'n8n_chat_production') return i18n.baseText('agentSessions.origin.n8nChat'); return source.charAt(0).toUpperCase() + source.slice(1); }); @@ -139,6 +140,8 @@ const triggerIcon = computed(() => { return sessionMetadata.value.source; case 'instance-ai': return 'sparkles'; + case 'n8n_chat_production': + return 'message-square'; default: return 'bolt-filled'; } diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentSessionsFilter.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentSessionsFilter.vue index 256667c47d6..b3f01b6f153 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentSessionsFilter.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentSessionsFilter.vue @@ -38,6 +38,7 @@ const origins: Array<{ id: AgentSessionOrigin | 'all'; name: string }> = [ { id: 'sub-agent', name: i18n.baseText('agentSessions.origin.subAgent') }, { id: 'schedule', name: i18n.baseText('agentSessions.origin.schedule') }, { id: 'workflow', name: i18n.baseText('agentSessions.origin.workflow') }, + { id: 'n8n_chat_production', name: i18n.baseText('agentSessions.origin.n8nChat') }, { id: 'slack', name: i18n.baseText('agentSessions.origin.slack') }, { id: 'telegram', name: i18n.baseText('agentSessions.origin.telegram') }, { id: 'linear', name: i18n.baseText('agentSessions.origin.linear') }, diff --git a/packages/frontend/editor-ui/src/features/agents/components/agentChatInjectionKeys.ts b/packages/frontend/editor-ui/src/features/agents/components/agentChatInjectionKeys.ts new file mode 100644 index 00000000000..7dd54b42298 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/components/agentChatInjectionKeys.ts @@ -0,0 +1,8 @@ +import type { InjectionKey } from 'vue'; + +/** Builds a chat attachment's download/thumbnail URL for the panel's channel. */ +export type AgentAttachmentUrlBuilder = (attachmentId: string) => string; + +/** Provided by `AgentChatPanel` so attachments don't need `projectId`/`agentId`/`channel` props. */ +export const AGENT_ATTACHMENT_URL_KEY: InjectionKey = + Symbol('agentAttachmentUrl'); diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts index ee4cce8be50..0d66ea5e23d 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts @@ -3,6 +3,8 @@ import type { AgentBudgetSpend, AgentBackgroundJobsResponse, AgentCapabilitySummary, + AgentChatListItem, + AgentChatListResponse, AgentChatMessagesResponse, AgentChatQueueResponse, AgentChatQueueUpdateDto, @@ -17,6 +19,8 @@ import type { AgentIntegrationConnectResponse, AgentIntegrationStatusResponse, AgentJsonVectorStoreConfig, + AgentN8nChatThreadSummary, + AgentN8nChatThreadsResponse, AgentSkill, AgentsSettingsDto, AgentSkillMutationResponse, @@ -29,8 +33,10 @@ import type { ChatIntegrationDescriptor, VectorStoreTestResult, } from '@n8n/api-types'; -import { getFullApiResponse, makeRestApiRequest } from '@n8n/rest-api-client'; +import { getFullApiResponse, makeRestApiRequest, request } from '@n8n/rest-api-client'; import type { IRestApiContext } from '@n8n/rest-api-client'; +import { isRecord } from '@n8n/utils/is-record'; +import { UnexpectedError } from 'n8n-workflow'; import type { AgentResource, AgentJsonConfig, CustomToolEntry } from '../types'; export async function getAgentsSettings(context: IRestApiContext): Promise { @@ -44,6 +50,38 @@ export async function updateAgentsSettings( return await makeRestApiRequest(context, 'PUT', '/agents/settings', settings); } +/** + * Which chat backend a request targets — the value is the URL segment + * itself. `'chat'` (default) is the agent builder's draft/test chat; + * `'n8n-chat'` is the published n8n Chat channel (see `useAgentChatStream`'s + * `capabilities` for what that channel cannot do). + */ +export type AgentChatChannel = 'chat' | 'n8n-chat'; + +/** + * Relative path for a chat route on either channel: `/projects//agents/v2//`. + * Use this for every `makeRestApiRequest` call — it prepends `context.baseUrl` itself, + * so passing an already-absolute URL there doubles it (e.g. `/rest/rest/...`). + */ +export const agentChatPath = ( + projectId: string, + agentId: string, + channel: AgentChatChannel = 'chat', +): string => + `/projects/${encodeURIComponent(projectId)}/agents/v2/${encodeURIComponent(agentId)}/${channel}`; + +/** + * Absolute URL for a chat route on either channel. Only for callers that need a full + * URL themselves — a `fetch()` call, or a download/`` URL — never pass this to + * `makeRestApiRequest`, which prepends `context.baseUrl` on its own. + */ +export const agentChatBaseUrl = ( + context: IRestApiContext, + projectId: string, + agentId: string, + channel: AgentChatChannel = 'chat', +): string => `${context.baseUrl}${agentChatPath(projectId, agentId, channel)}`; + export type ListAgentsSortBy = | 'name:asc' | 'name:desc' @@ -84,6 +122,99 @@ export const listAgentsPageGlobal = async ( return await getFullApiResponse(context, 'GET', '/agents/v2', options); }; +/** One agent as the n8n Chat page needs it (see the backend route's doc for why `getAgent` won't do). */ +export const getN8nChatAgent = async ( + context: IRestApiContext, + agentId: string, +): Promise => { + return await makeRestApiRequest( + context, + 'GET', + `/agents/v2/n8n-chat/agents/${encodeURIComponent(agentId)}`, + ); +}; + +/** Ranks by the requesting user's own n8n Chat usage, then newest created. */ +export type N8nChatAgentsSortBy = 'usage:desc'; + +export type ListN8nChatAgentsOptions = { + query?: string; + skip?: number; + take?: number; + sortBy?: N8nChatAgentsSortBy; +}; + +/** + * Agents available to chat with over n8n Chat — the n8n Chat page's agent library. + * Trims `query` and omits it when blank — the only place that does, so callers + * can pass the raw search input straight through. + */ +export const listN8nChatAgents = async ( + context: IRestApiContext, + options: ListN8nChatAgentsOptions, +): Promise => { + const { query, skip, take, sortBy } = options; + const trimmedQuery = query?.trim(); + return await getFullApiResponse(context, 'GET', '/agents/v2', { + filter: { availableInChat: true, ...(trimmedQuery ? { query: trimmedQuery } : {}) }, + skip, + take, + sortBy, + }); +}; + +/** Options for {@link listN8nChatThreads}. `cursor` is a thread's `updatedAt` ISO string. */ +export type ListN8nChatThreadsOptions = { + limit: number; + cursor?: string; + /** Filters threads to one agent. */ + agentId?: string; +}; + +/** Narrows the raw response body — `request` returns `unknown`, and this avoids an `as` cast. */ +function isN8nChatThreadsResponse(body: unknown): body is AgentN8nChatThreadsResponse { + return ( + isRecord(body) && + Array.isArray(body.data) && + (body.nextCursor === null || typeof body.nextCursor === 'string') + ); +} + +/** + * The user's own n8n Chat threads across every agent they can reach, newest first. + * The controller writes `{ data, nextCursor }` directly, without the usual `data`-key + * envelope — `makeRestApiRequest` would unwrap `data` and drop `nextCursor`, so this + * reads the full response instead (same reasoning as `evaluation.api.ts`'s raw `request` calls). + */ +export const listN8nChatThreads = async ( + context: IRestApiContext, + options: ListN8nChatThreadsOptions, +): Promise => { + const response: unknown = await request({ + method: 'GET', + baseURL: context.baseUrl, + endpoint: '/agents/v2/n8n-chat/threads', + headers: { 'push-ref': context.pushRef }, + data: { limit: options.limit, cursor: options.cursor, agentId: options.agentId }, + }); + if (!isN8nChatThreadsResponse(response)) { + throw new UnexpectedError('Unexpected n8n Chat threads response shape'); + } + return response; +}; + +/** One of the user's own n8n Chat threads, for the chat page to read a title outside the recent-threads page. */ +export const getN8nChatThread = async ( + context: IRestApiContext, + threadId: string, +): Promise => { + return await makeRestApiRequest( + context, + 'GET', + `/agents/v2/n8n-chat/threads/${encodeURIComponent(threadId)}`, + ); +}; + export const listAgents = async ( context: IRestApiContext, projectId: string, @@ -607,11 +738,12 @@ export const getAgentChatQueue = async ( projectId: string, agentId: string, threadId: string, + channel: AgentChatChannel = 'chat', ): Promise => { return await makeRestApiRequest( context, 'GET', - `/projects/${encodeURIComponent(projectId)}/agents/v2/${encodeURIComponent(agentId)}/chat/${encodeURIComponent(threadId)}/queue`, + `${agentChatPath(projectId, agentId, channel)}/${encodeURIComponent(threadId)}/queue`, ); }; @@ -622,11 +754,12 @@ export const updateAgentQueuedMessage = async ( threadId: string, queueId: string, payload: AgentChatQueueUpdateDto, + channel: AgentChatChannel = 'chat', ): Promise => { await makeRestApiRequest( context, 'PATCH', - `/projects/${encodeURIComponent(projectId)}/agents/v2/${encodeURIComponent(agentId)}/chat/${encodeURIComponent(threadId)}/queue/${encodeURIComponent(queueId)}`, + `${agentChatPath(projectId, agentId, channel)}/${encodeURIComponent(threadId)}/queue/${encodeURIComponent(queueId)}`, payload, ); }; @@ -653,11 +786,12 @@ export const removeAgentQueuedMessage = async ( agentId: string, threadId: string, queueId: string, + channel: AgentChatChannel = 'chat', ): Promise<{ removed: boolean }> => { return await makeRestApiRequest( context, 'DELETE', - `/projects/${encodeURIComponent(projectId)}/agents/v2/${encodeURIComponent(agentId)}/chat/${encodeURIComponent(threadId)}/queue/${encodeURIComponent(queueId)}`, + `${agentChatPath(projectId, agentId, channel)}/${encodeURIComponent(threadId)}/queue/${encodeURIComponent(queueId)}`, ); }; @@ -672,7 +806,7 @@ export const steerAgentQueuedMessage = async ( await makeRestApiRequest( context, 'POST', - `/projects/${encodeURIComponent(projectId)}/agents/v2/${encodeURIComponent(agentId)}/chat/${encodeURIComponent(threadId)}/queue/${encodeURIComponent(queueId)}/steer`, + `${agentChatPath(projectId, agentId)}/${encodeURIComponent(threadId)}/queue/${encodeURIComponent(queueId)}/steer`, payload, ); }; @@ -682,14 +816,25 @@ export const getChatMessages = async ( projectId: string, agentId: string, threadId: string, + channel: AgentChatChannel = 'chat', ): Promise => { return await makeRestApiRequest( context, 'GET', - `/projects/${encodeURIComponent(projectId)}/agents/v2/${encodeURIComponent(agentId)}/chat/${encodeURIComponent(threadId)}/messages`, + `${agentChatPath(projectId, agentId, channel)}/${encodeURIComponent(threadId)}/messages`, ); }; +/** Builds the download/thumbnail URL for a chat attachment on either channel. */ +export const getChatAttachmentUrl = ( + context: IRestApiContext, + projectId: string, + agentId: string, + attachmentId: string, + channel: AgentChatChannel = 'chat', +): string => + `${agentChatBaseUrl(context, projectId, agentId, channel)}/attachments/${encodeURIComponent(attachmentId)}`; + export const getTestChatMessages = async ( context: IRestApiContext, projectId: string, @@ -719,11 +864,12 @@ export const cancelAgentChatRun = async ( projectId: string, agentId: string, runId: string, + channel: AgentChatChannel = 'chat', ): Promise<{ cancelled: boolean }> => { return await makeRestApiRequest<{ cancelled: boolean }>( context, 'DELETE', - `/projects/${projectId}/agents/v2/${agentId}/chat/runs/${runId}`, + `${agentChatPath(projectId, agentId, channel)}/runs/${encodeURIComponent(runId)}`, ); }; @@ -733,11 +879,12 @@ export const cancelAgentChatExecution = async ( agentId: string, threadId: string, executionId: string, + channel: AgentChatChannel = 'chat', ): Promise<{ cancelRequested: boolean }> => { return await makeRestApiRequest( context, 'DELETE', - `/projects/${encodeURIComponent(projectId)}/agents/v2/${encodeURIComponent(agentId)}/chat/${encodeURIComponent(threadId)}/executions/${encodeURIComponent(executionId)}`, + `${agentChatPath(projectId, agentId, channel)}/${encodeURIComponent(threadId)}/executions/${encodeURIComponent(executionId)}`, ); }; diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentChatStream.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentChatStream.ts index 5b1c90d20ca..96ace6d6441 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentChatStream.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentChatStream.ts @@ -16,6 +16,7 @@ import { applyForwardedChildChunk, APPROVAL_TOOL_NAME, emptyChildTrace } from '@ import { useToast } from '@n8n/composables/useToast'; import { convertFileToBinaryData, resolveFileMimeType } from '@/app/utils/fileUtils'; import { + agentChatBaseUrl, cancelAgentChatExecution, cancelAgentChatRun, clearTestChatMessages, @@ -26,6 +27,7 @@ import { steerAgentQueuedMessage, reorderAgentQueuedMessage, getTestChatMessages, + type AgentChatChannel, } from './useAgentApi'; import { @@ -70,6 +72,12 @@ export interface UseAgentChatStreamParams { */ continueSessionId?: Ref; newSession?: Ref; + /** + * `'chat'` (default) talks to the builder's draft/test chat; `'n8n-chat'` + * talks to the published n8n Chat channel. See `capabilities` for what + * that channel cannot do. + */ + channel?: Ref; onHistoryLoaded?: (count: number) => void; onSessionCreated?: (sessionId: string) => void; /** Builder preview shows the budget stop and alert cards. Other chats ignore them. */ @@ -106,6 +114,18 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) { const rootStore = useRootStore(); const locale = useI18n(); const { showError } = useToast(); + const channel = params.channel ?? ref('chat'); + /** + * What the current channel supports. n8n Chat has no steer or + * background-task routes, and no single default thread to fall back to + * like the builder's test chat — `previewHistory` gates that fallback. + */ + const capabilities = computed(() => ({ + steer: channel.value !== 'n8n-chat', + backgroundTasks: channel.value !== 'n8n-chat', + previewHistory: channel.value !== 'n8n-chat', + reorder: channel.value !== 'n8n-chat', + })); const messages = ref([]); const isStreamOpen = ref(false); @@ -140,6 +160,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) { const isCancelling = ref(false); const canSteer = computed( () => + capabilities.value.steer && !!steerableExecutionId.value && steerableExecutionId.value === activeExecutionId.value && !isCancelling.value && @@ -222,10 +243,17 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) { params.projectId.value, params.agentId.value, continueId, + channel.value, ); dbMessages = envelope.messages; openSuspensions = envelope.openSuspensions; runningExecutionId = envelope.activeExecutionId; + } else if (!capabilities.value.previewHistory) { + // A fresh n8n Chat has no default thread to fall back to (unlike the + // builder's single test chat) — nothing to load until the first + // message mints a session. + dbMessages = []; + runningExecutionId = null; } else { const envelope = await getTestChatMessages( rootStore.restApiContext, @@ -334,6 +362,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) { params.projectId.value, params.agentId.value, threadId, + channel.value, ); if (!disposed && target === targetKey() && version === queueVersion) { queuedMessages.value = result.items.filter((item) => !consumedQueueIds.has(item.id)); @@ -361,6 +390,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) { threadId, queueId, { message }, + channel.value, ); if (disposed || target !== targetKey()) return 'failed'; queueVersion++; @@ -384,6 +414,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) { targetQueueId: string, expectedQueueIds: string[], ): Promise { + if (!capabilities.value.reorder) return; const threadId = params.continueSessionId?.value ?? acceptedSessionId.value; if (!threadId || disposed || isReorderingQueue.value) return; const target = targetKey(); @@ -457,6 +488,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) { params.agentId.value, threadId, queueId, + channel.value, ); if (disposed || target !== targetKey()) return; queueVersion++; @@ -1444,8 +1476,12 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) { userMessage?: ChatMessage, ) { const target = targetKey(); - const { baseUrl } = rootStore.restApiContext; - const url = `${baseUrl}/projects/${params.projectId.value}/agents/v2/${params.agentId.value}/chat`; + const url = agentChatBaseUrl( + rootStore.restApiContext, + params.projectId.value, + params.agentId.value, + channel.value, + ); const body: Record = { message }; const sessionId = params.continueSessionId?.value ?? acceptedSessionId.value; const newSession = params.newSession?.value === true && sessionId !== acknowledgedSessionId; @@ -1564,8 +1600,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) { }); } - const { baseUrl } = rootStore.restApiContext; - const url = `${baseUrl}/projects/${params.projectId.value}/agents/v2/${params.agentId.value}/chat/resume`; + const url = `${agentChatBaseUrl(rootStore.restApiContext, params.projectId.value, params.agentId.value, channel.value)}/resume`; const { outcome } = await postAndConsume( url, { runId: payload.runId, toolCallId: payload.toolCallId, resumeData }, @@ -1728,6 +1763,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) { params.agentId.value, threadId, executionId, + channel.value, ); if (!cancelRequested && target === targetKey() && stopTargetId === executionId) { isCancelling.value = false; @@ -1770,6 +1806,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) { params.projectId.value, params.agentId.value, openSuspension.runId, + channel.value, ); if (cancelled) markRunCancelled(openSuspension.runId); else await refreshHistory(); @@ -1784,6 +1821,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) { } return { + capabilities, queuedMessages, canSteer, steeringQueueIds, diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentPermissions.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentPermissions.ts index fb5e7ec4dbd..8e14524477b 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentPermissions.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentPermissions.ts @@ -5,7 +5,7 @@ import { useUsersStore } from '@n8n/stores/users.store'; import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store'; type AgentMutationKey = 'create' | 'update' | 'delete' | 'publish' | 'unpublish'; -type AgentPermissionKey = AgentMutationKey | 'execute'; +type AgentPermissionKey = AgentMutationKey | 'execute' | 'read'; export type AgentPermissions = Record<`can${Capitalize}`, ComputedRef>; @@ -49,5 +49,6 @@ export function useAgentPermissions( canPublish: pick('publish'), canUnpublish: pick('unpublish'), canExecute: computed(() => hasScope('execute')), + canRead: computed(() => hasScope('read')), }; } diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentTelemetry.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentTelemetry.ts index 009ef3a309f..5deb53d336b 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentTelemetry.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentTelemetry.ts @@ -1,17 +1,28 @@ +import { AGENTS_N8N_CHAT_FLAG } from '@n8n/api-types'; import { TELEMETRY_EVENT } from '@n8n/telemetry'; import type { InferTelemetryProps, TelemetryEventDef } from '@n8n/telemetry'; import { useTelemetry } from '@n8n/composables/useTelemetry'; import { useRootStore } from '@n8n/stores/useRootStore'; +import { usePostHog } from '@/app/stores/posthog.store'; +import { useAgentsN8nChatFlag } from './useAgentsN8nChatFlag'; import type { AgentConfigFingerprint, AgentTelemetryStatus } from './agentTelemetry.utils'; export type AgentCreateSource = 'button' | 'dropdown' | 'card'; +export type N8nChatAgentSource = 'card' | 'library'; export function useAgentTelemetry() { const telemetry = useTelemetry(); const rootStore = useRootStore(); + const isAgentsN8nChatFlag = useAgentsN8nChatFlag(); const common = () => ({ session_id: rootStore.pushRef }); + // Reports `null` when the user has no flag value for this experiment. + function currentN8nChatVariant(): string | null { + const variant = usePostHog().getVariant(AGENTS_N8N_CHAT_FLAG); + return typeof variant === 'string' ? variant : null; + } + // Telemetry is best-effort: every track call is wrapped so a RudderStack // failure can never surface to a caller (and never takes down a critical // path like publish or save). @@ -190,6 +201,43 @@ export function useAgentTelemetry() { } } + function trackSelectedN8nChatAgent(params: { agentId: string; source: N8nChatAgentSource }) { + safeTrack(TELEMETRY_EVENT.AGENTS.USER_SELECTED_N8N_CHAT_AGENT, { + agent_id: params.agentId, + source: params.source, + variant: currentN8nChatVariant(), + ...common(), + }); + } + + function trackSentMessageToN8nChatAgent(params: { + agentId: string; + threadId: string; + isNewThread: boolean; + }) { + safeTrack(TELEMETRY_EVENT.AGENTS.USER_SENT_MESSAGE_TO_N8N_CHAT_AGENT, { + agent_id: params.agentId, + thread_id: params.threadId, + is_new_thread: params.isNewThread, + variant: currentN8nChatVariant(), + ...common(), + }); + } + + // No-op with the flag off, so sidebar callers don't need their own flag check. + function trackClickedSidebarItem( + params: { item: 'new_chat' } | { item: 'chat'; chatType: 'assistant' | 'agent' }, + ) { + if (!isAgentsN8nChatFlag.value) return; + safeTrack(TELEMETRY_EVENT.AGENTS.USER_CLICKED_N8N_CHAT_SIDEBAR_ITEM, { + ...(params.item === 'new_chat' + ? { item: 'new_chat' as const } + : { item: 'chat' as const, chat_type: params.chatType }), + variant: currentN8nChatVariant(), + ...common(), + }); + } + return { trackClickedNewAgent, trackSubmittedMessage, @@ -205,5 +253,8 @@ export function useAgentTelemetry() { trackCheckedTeamsCredential, trackClickedDeployToAzure, trackDownloadedTeamsAppPackage, + trackSelectedN8nChatAgent, + trackSentMessageToN8nChatAgent, + trackClickedSidebarItem, }; } diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentsN8nChatFlag.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentsN8nChatFlag.ts index e94a7f4a2b9..759fc4e4bec 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentsN8nChatFlag.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentsN8nChatFlag.ts @@ -1,16 +1,28 @@ import { AGENTS_N8N_CHAT_FLAG } from '@n8n/api-types'; import { computed } from 'vue'; -import { usePostHog } from '@/app/stores/posthog.store'; +import { AGENTS_N8N_CHAT_EXPERIMENT } from '@/app/constants/experiments'; +import { usePostHog, waitForFeatureFlagsWithTimeout } from '@/app/stores/posthog.store'; + +function isAgentsN8nChatFlagEnabled(): boolean { + const variant = usePostHog().getVariant(AGENTS_N8N_CHAT_FLAG); + return typeof variant === 'string' && variant !== AGENTS_N8N_CHAT_EXPERIMENT.control; +} /** * Gates the agent builder's n8n Chat surfaces (channel row, modal, chip). The * flag is multivariate: every variant turns them on, `control` keeps them off. */ -export const useAgentsN8nChatFlag = () => { - const postHog = usePostHog(); - return computed(() => { - const variant = postHog.getVariant(AGENTS_N8N_CHAT_FLAG); - return typeof variant === 'string' && variant !== 'control'; - }); -}; +export const useAgentsN8nChatFlag = () => computed(isAgentsN8nChatFlagEnabled); + +const FLAG_WAIT_TIMEOUT_MS = 3000; + +/** + * Waits for a pending client-side flag evaluation before a deep link fails closed. + * `waitForFeatureFlagsWithTimeout` resolves immediately when nothing is pending, so + * this doesn't need its own `hasPendingFeatureFlags()` guard. + */ +export async function isAgentsN8nChatFlagEnabledOnceEvaluated(): Promise { + await waitForFeatureFlagsWithTimeout(usePostHog(), FLAG_WAIT_TIMEOUT_MS); + return isAgentsN8nChatFlagEnabled(); +} diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useBackOrFallback.ts b/packages/frontend/editor-ui/src/features/agents/composables/useBackOrFallback.ts new file mode 100644 index 00000000000..6098015327d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/composables/useBackOrFallback.ts @@ -0,0 +1,21 @@ +import { toValue, type MaybeRefOrGetter } from 'vue'; +import { useRouter, type RouteLocationRaw } from 'vue-router'; + +/** + * Back within the app when there is somewhere to go back to; otherwise + * navigates to `fallback`. A direct visit (no matched previous route) has + * nowhere to go back to, so it falls through to `fallback` instead. + */ +export function useBackOrFallback(fallback: MaybeRefOrGetter) { + const router = useRouter(); + + return function goBackOrFallback(): void { + const previousRoute = router.options.history.state.back; + const resolved = typeof previousRoute === 'string' ? router.resolve(previousRoute) : null; + if (resolved?.matched.length) { + router.back(); + return; + } + void router.push(toValue(fallback)); + }; +} diff --git a/packages/frontend/editor-ui/src/features/agents/constants.ts b/packages/frontend/editor-ui/src/features/agents/constants.ts index 51dc1e422a1..aa25a88a28f 100644 --- a/packages/frontend/editor-ui/src/features/agents/constants.ts +++ b/packages/frontend/editor-ui/src/features/agents/constants.ts @@ -11,6 +11,12 @@ export { export const AGENTS_MODULE_NAME = 'agents'; export const AGENTS_SETTINGS_VIEW = 'AgentsSettings'; +/** The shared "chat with a published agent over n8n Chat" screen. */ +export const AGENT_N8N_CHAT_VIEW = 'AgentN8nChatView'; + +/** The "browse agents available over n8n Chat" library screen. */ +export const AGENT_N8N_CHAT_LIBRARY_VIEW = 'AgentN8nChatLibraryView'; + export const AGENT_TOOLS_MODAL_KEY = 'agentToolsModal'; export const AGENT_TOOL_CONFIG_MODAL_KEY = 'agentToolConfigModal'; export const AGENT_SKILL_MODAL_KEY = 'agentSkillModal'; @@ -42,6 +48,15 @@ export const EXECUTIONS_SECTION_KEY = '__executions'; */ export const AGENT_EVAL_CASES_PAGE_SIZE = 250; +/** Agent n8n Chat threads fetched for the sidebar's "recent chats" list. */ +export const AGENT_N8N_CHAT_RECENT_THREADS_LIMIT = 10; + +/** Rows in the recent-chats lists: the sidebar and the "Chat history" dropdown. */ +export const RECENT_CHATS_LIMIT = 5; + +/** Page size for the agent side of the "All chats" view's infinite scroll. */ +export const AGENT_N8N_CHAT_HISTORY_PAGE_SIZE = 30; + export { CHAT_MESSAGE_STATUS, TOOL_CALL_STATE, diff --git a/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts b/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts index ea833b8b1ce..238885df632 100644 --- a/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts +++ b/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts @@ -2,16 +2,21 @@ import { VIEWS } from '@/app/constants'; import { defineFrontendModule } from '@n8n/frontend-module-sdk'; import { i18n } from '@n8n/i18n'; import { hasPermission } from '@/app/utils/rbac/permissions'; +import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants'; +import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability'; import { AGENTS_LIST_VIEW, AGENTS_SETTINGS_VIEW, AGENT_BUILDER_VIEW, + AGENT_N8N_CHAT_VIEW, + AGENT_N8N_CHAT_LIBRARY_VIEW, AGENT_PREVIEW_VIEW, AGENT_VIEW, AGENT_SESSIONS_LIST_VIEW, AGENT_SESSION_DETAIL_VIEW, PROJECT_AGENTS, } from '@/features/agents/constants'; +import { isAgentsN8nChatFlagEnabledOnceEvaluated } from '@/features/agents/composables/useAgentsN8nChatFlag'; import { AGENTS_MODALS } from '@/features/agents/modals'; const AgentsListView = async (): Promise => @@ -24,6 +29,17 @@ const AgentSessionsListView = async (): Promise => await import('@/features/agents/views/AgentSessionsListView.vue'); const AgentSessionTimelineView = async (): Promise => await import('@/features/agents/views/AgentSessionTimelineView.vue'); +const AgentN8nChatView = async (): Promise => + await import('@/features/agents/n8nChatPage/AgentN8nChatView.vue'); +const N8nChatAgentLibraryView = async (): Promise => + await import('@/features/agents/n8nChatPage/N8nChatAgentLibraryView.vue'); + +// Same availability gate as `/assistant` itself, then the PostHog flag — posthog +// loads flags asynchronously, so the guard waits for them before deciding. +const n8nChatRouteGuard = async () => { + if (!useInstanceAiAvailable().value) return { name: VIEWS.HOMEPAGE }; + return (await isAgentsN8nChatFlagEnabledOnceEvaluated()) ? true : { name: INSTANCE_AI_VIEW }; +}; export const AgentsModule = defineFrontendModule({ id: 'agents', @@ -93,6 +109,31 @@ export const AgentsModule = defineFrontendModule({ }, ], }, + { + name: AGENT_N8N_CHAT_LIBRARY_VIEW, + // A static path outranks `:agentId` and instanceAi's `/assistant/:threadId`, regardless of registration order. + path: '/assistant/agents', + component: N8nChatAgentLibraryView, + meta: { + layout: 'instanceAi', + middleware: ['authenticated', 'custom'], + }, + beforeEnter: n8nChatRouteGuard, + }, + { + name: AGENT_N8N_CHAT_VIEW, + // `agentThreadId`, not `threadId`: the sibling instanceAi routes read + // `route.params.threadId` for an n8n Assistant thread id, and the two + // param names must not collide on this route. + path: '/assistant/agents/:agentId/:agentThreadId?', + component: AgentN8nChatView, + props: true, + meta: { + layout: 'instanceAi', + middleware: ['authenticated', 'custom'], + }, + beforeEnter: n8nChatRouteGuard, + }, ], projectTabs: { overview: [ diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/AgentN8nChatView.vue b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/AgentN8nChatView.vue new file mode 100644 index 00000000000..f4f807a9f96 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/AgentN8nChatView.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/N8nChatAgentLibraryView.vue b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/N8nChatAgentLibraryView.vue new file mode 100644 index 00000000000..c99cad3cae3 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/N8nChatAgentLibraryView.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/__tests__/AgentN8nChatView.router.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/__tests__/AgentN8nChatView.router.test.ts new file mode 100644 index 00000000000..da6120583ee --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/__tests__/AgentN8nChatView.router.test.ts @@ -0,0 +1,125 @@ +/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern: @vue/test-utils and @pinia/testing are transitive devDeps */ +// A real memory-history router, unlike the rest of this view's tests: router.replace +// after a session is created really changes the `agentThreadId` prop, which is what +// exposed the regression where the sidebar refresh watcher reset itself on that +// navigation and swallowed the title refresh. +import { mount, flushPromises } from '@vue/test-utils'; +import { createTestingPinia } from '@pinia/testing'; +import { createRouter, createMemoryHistory, RouterView } from 'vue-router'; +import { i18nInstance } from '@n8n/i18n'; +import type { AgentChatListItem } from '@n8n/api-types'; + +import { mockedStore } from '@/__tests__/utils'; +import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; + +import { AGENT_N8N_CHAT_VIEW } from '../../constants'; +import { useAgentN8nChatThreadsStore } from '../n8nChatThreads.store'; +import AgentN8nChatView from '../AgentN8nChatView.vue'; + +const getN8nChatAgentMock = vi.fn(); +vi.mock('../../composables/useAgentApi', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getN8nChatAgent: (...args: unknown[]) => getN8nChatAgentMock(...args) }; +}); + +vi.mock('@n8n/composables/useToast', () => ({ + useToast: () => ({ showError: vi.fn() }), +})); + +vi.mock('../../components/AgentPersonalisationIcon.vue', () => ({ + default: { name: 'AgentPersonalisationIcon', template: '
' }, +})); + +const agentItem: AgentChatListItem = { + id: 'agent-1', + name: 'Support Agent', + description: 'Answers billing questions.', + personalisation: { + icon: 'bot', + gradient: { from: '#000000', to: '#FFFFFF', angle: 0, fromStop: 0, toStop: 100 }, + }, + project: { id: 'project-1', name: 'Marketing' }, +}; + +const AgentChatPanelStub = { + name: 'AgentChatPanel', + template: '
', + props: [ + 'projectId', + 'agentId', + 'mode', + 'continueSessionId', + 'newSession', + 'agentConfig', + 'agentStatus', + 'connectedTriggers', + 'channel', + ], + emits: ['session-created', 'update:streaming'], +}; + +async function renderOnRoute(path: string) { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/assistant/agents/:agentId/:agentThreadId?', + name: AGENT_N8N_CHAT_VIEW, + component: AgentN8nChatView, + props: true, + }, + ], + }); + await router.push(path); + await router.isReady(); + + const pinia = createTestingPinia(); + const projectsStore = mockedStore(useProjectsStore); + projectsStore.myProjects = []; + projectsStore.personalProject = null; + + const wrapper = mount(RouterView, { + global: { + plugins: [pinia, i18nInstance, router], + stubs: { AgentChatPanel: AgentChatPanelStub }, + }, + }); + return { wrapper, router }; +} + +describe('AgentN8nChatView with a real router', () => { + beforeEach(() => { + vi.clearAllMocks(); + getN8nChatAgentMock.mockResolvedValue(agentItem); + }); + + it('refreshes the sidebar on every turn, including the first, after router.replace updates agentThreadId', async () => { + const { wrapper } = await renderOnRoute('/assistant/agents/agent-1'); + await flushPromises(); + const threadsStore = mockedStore(useAgentN8nChatThreadsStore); + const panel = wrapper.findComponent(AgentChatPanelStub); + const mintedId = panel.props('continueSessionId') as string; + + // Mirrors the real flow: the panel reports the session, the view navigates to + // it, and the route (a real one, with `props: true`) really updates the prop. + await panel.vm.$emit('session-created', mintedId); + await flushPromises(); + expect(wrapper.findComponent(AgentChatPanelStub).props('continueSessionId')).toBe(mintedId); + // `newSession` derives from `!props.agentThreadId` — the route now carries one. + expect(wrapper.findComponent(AgentChatPanelStub).props('newSession')).toBe(false); + + const updatedPanel = wrapper.findComponent(AgentChatPanelStub); + await updatedPanel.vm.$emit('update:streaming', true); + await updatedPanel.vm.$emit('update:streaming', false); + await flushPromises(); + + expect(threadsStore.fetchRecent).toHaveBeenCalledTimes(1); + + // A later turn, now permanently on the thread URL, still refreshes. + await updatedPanel.vm.$emit('update:streaming', true); + await updatedPanel.vm.$emit('update:streaming', false); + await flushPromises(); + + expect(threadsStore.fetchRecent).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/__tests__/AgentN8nChatView.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/__tests__/AgentN8nChatView.test.ts new file mode 100644 index 00000000000..4698157c8a9 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/__tests__/AgentN8nChatView.test.ts @@ -0,0 +1,450 @@ +/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern: @vue/test-utils and @pinia/testing are transitive devDeps */ +import { reactive } from 'vue'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createTestingPinia } from '@pinia/testing'; +import { i18nInstance } from '@n8n/i18n'; +import type { AgentChatListItem } from '@n8n/api-types'; +import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; + +import { mockedStore, type MockedStore } from '@/__tests__/utils'; +import { + createProjectListItem, + createTestProject, +} from '@/features/collaboration/projects/__tests__/utils'; +import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; +import { ProjectTypes } from '@/features/collaboration/projects/projects.types'; +import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants'; + +import { AGENT_N8N_CHAT_VIEW } from '../../constants'; +import { useAgentN8nChatThreadsStore } from '../n8nChatThreads.store'; +import AgentN8nChatView from '../AgentN8nChatView.vue'; + +const getN8nChatAgentMock = vi.fn(); +vi.mock('../../composables/useAgentApi', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getN8nChatAgent: (...args: unknown[]) => getN8nChatAgentMock(...args) }; +}); + +// `useProjectsStore` calls `useRoute()` in its own setup to watch for a `projectId` +// param on whatever route is active. This view no longer carries one, but the +// store still needs a route object to read from. +const route = reactive<{ params: Record }>({ params: {} }); + +const showErrorMock = vi.fn(); +vi.mock('@n8n/composables/useToast', () => ({ + useToast: () => ({ showError: showErrorMock }), +})); + +vi.mock('../../components/AgentPersonalisationIcon.vue', () => ({ + default: { name: 'AgentPersonalisationIcon', template: '
' }, +})); + +const sendMessageFromOutsideMock = vi.fn(); +const pushMock = vi.fn(); +const replaceMock = vi.fn(); +const backMock = vi.fn(); +const resolveMock = vi.fn(() => ({ + href: '/resolved', + matched: [{}], +})); +const historyBack = { value: undefined as string | undefined }; + +vi.mock('vue-router', () => ({ + RouterLink: { template: '' }, + useRoute: () => route, + useRouter: () => ({ + push: pushMock, + replace: replaceMock, + back: backMock, + resolve: resolveMock, + options: { + history: { + get state() { + return { back: historyBack.value }; + }, + }, + }, + }), +})); + +function renderView( + props: { agentId?: string; agentThreadId?: string } = {}, + configureStore: (store: MockedStore) => void = () => {}, + // Runs before mount, so a preset `recentThreads` is in place for the view's + // immediate watcher — setting it only after `renderView` returns is too late. + configureThreadsStore: ( + store: MockedStore, + ) => void = () => {}, +) { + const pinia = createTestingPinia(); + const projectsStore = mockedStore(useProjectsStore); + projectsStore.myProjects = []; + projectsStore.personalProject = null; + configureStore(projectsStore); + configureThreadsStore(mockedStore(useAgentN8nChatThreadsStore)); + + return mount(AgentN8nChatView, { + props: { agentId: 'agent-1', ...props }, + global: { + plugins: [pinia, i18nInstance], + stubs: { + AgentChatPanel: { + name: 'AgentChatPanel', + template: ` +
+
+
+
+ `, + props: [ + 'projectId', + 'agentId', + 'mode', + 'continueSessionId', + 'newSession', + 'agentConfig', + 'agentStatus', + 'connectedTriggers', + 'channel', + 'centerEmptyState', + ], + emits: ['session-created', 'update:streaming', 'first-user-message'], + methods: { sendMessageFromOutside: sendMessageFromOutsideMock }, + }, + }, + }, + }); +} + +const agentItem: AgentChatListItem = { + id: 'agent-1', + name: 'Support Agent', + description: 'Answers billing questions.', + personalisation: { + icon: 'bot', + gradient: { from: '#000000', to: '#FFFFFF', angle: 0, fromStop: 0, toStop: 100 }, + }, + project: { id: 'project-1', name: 'Marketing' }, +}; + +describe('AgentN8nChatView', () => { + beforeEach(() => { + vi.clearAllMocks(); + historyBack.value = undefined; + getN8nChatAgentMock.mockResolvedValue(agentItem); + }); + + it('shows the chat history button only once the agent has loaded', async () => { + let resolveAgent: (value: AgentChatListItem) => void = () => {}; + getN8nChatAgentMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveAgent = resolve; + }), + ); + const wrapper = renderView(); + await flushPromises(); + + expect(wrapper.find('[data-test-id="agent-n8n-chat-history-toggle"]').exists()).toBe(false); + + resolveAgent(agentItem); + await flushPromises(); + + expect(wrapper.find('[data-test-id="agent-n8n-chat-history-toggle"]').exists()).toBe(true); + }); + + describe('thread title on the history button', () => { + const toggleText = (wrapper: ReturnType) => + wrapper.get('[data-test-id="agent-n8n-chat-history-toggle"]').text(); + const recentThread = (title: string | null) => ({ + id: 'thread-9', + title, + updatedAt: '2026-01-01T00:00:00.000Z', + agent: { id: 'agent-1', name: 'Support Agent', projectId: 'project-1' }, + }); + + it('shows the open thread title instead of "Chat history"', async () => { + const wrapper = renderView({ agentThreadId: 'thread-9' }); + mockedStore(useAgentN8nChatThreadsStore).recentThreads = [recentThread('Budget questions')]; + await flushPromises(); + + expect(toggleText(wrapper)).toBe('Budget questions'); + }); + + it('falls back to "New conversation" for an untitled thread', async () => { + const wrapper = renderView({ agentThreadId: 'thread-9' }); + mockedStore(useAgentN8nChatThreadsStore).recentThreads = [recentThread(null)]; + await flushPromises(); + + expect(toggleText(wrapper)).toBe('New conversation'); + }); + + it('uses the first user message, cut to 60 characters, until the thread has a title', async () => { + const wrapper = renderView({ agentThreadId: 'thread-9' }); + await flushPromises(); + const message = 'Please review this offer letter for the senior engineer role in Berlin'; + wrapper.findComponent({ name: 'AgentChatPanel' }).vm.$emit('first-user-message', message); + await flushPromises(); + + expect(toggleText(wrapper)).toBe(`${message.slice(0, 60)}…`); + }); + + it('keeps "Chat history" on a new chat', async () => { + const wrapper = renderView(); + mockedStore(useAgentN8nChatThreadsStore).recentThreads = [recentThread('Budget questions')]; + await flushPromises(); + + expect(toggleText(wrapper)).toBe('Chat history'); + }); + }); + + describe('loading a thread missing from recentThreads', () => { + it("fetches it when the open thread isn't in the list", async () => { + renderView({ agentThreadId: 'thread-9' }); + await flushPromises(); + + expect(mockedStore(useAgentN8nChatThreadsStore).loadThread).toHaveBeenCalledWith('thread-9'); + }); + + it('does not fetch it when the thread is already in the list', async () => { + renderView({ agentThreadId: 'thread-9' }, undefined, (store) => { + store.recentThreads = [ + { + id: 'thread-9', + title: 'Budget questions', + updatedAt: '2026-01-01T00:00:00.000Z', + agent: { id: 'agent-1', name: 'Support Agent', projectId: 'project-1' }, + }, + ]; + }); + await flushPromises(); + + expect(mockedStore(useAgentN8nChatThreadsStore).loadThread).not.toHaveBeenCalled(); + }); + + it('does not fetch anything on a new chat', async () => { + renderView(); + await flushPromises(); + + expect(mockedStore(useAgentN8nChatThreadsStore).loadThread).not.toHaveBeenCalled(); + }); + }); + + it('renders the agent avatar, name, description, and the placeholder-driving name', async () => { + const wrapper = renderView(); + await flushPromises(); + + expect(getN8nChatAgentMock).toHaveBeenCalledWith(expect.anything(), 'agent-1'); + expect(wrapper.find('[data-testid="stub-avatar"]').exists()).toBe(true); + expect(wrapper.text()).toContain('Support Agent'); + expect(wrapper.text()).toContain('Answers billing questions.'); + const panel = wrapper.findComponent({ name: 'AgentChatPanel' }); + expect(panel.props('projectId')).toBe('project-1'); + expect(panel.props('agentConfig')).toMatchObject({ name: 'Support Agent' }); + expect(panel.props('channel')).toBe('n8n-chat'); + }); + + it('shows "Personal" with a project icon for the personal project', async () => { + const personalProject = createTestProject({ id: 'project-1', type: ProjectTypes.Personal }); + const wrapper = renderView({}, (store) => { + store.personalProject = personalProject; + }); + await flushPromises(); + + const footer = wrapper.get('[data-testid="agent-n8n-chat-project"]'); + expect(footer.text()).toContain('Personal'); + }); + + it('shows the project name for a team project', async () => { + const wrapper = renderView(); + await flushPromises(); + + const footer = wrapper.get('[data-testid="agent-n8n-chat-project"]'); + expect(footer.text()).toContain('Marketing'); + }); + + it('shows the agent page link only with agent:read in that project', async () => { + const withRead = renderView({}, (store) => { + store.myProjects = [{ ...createProjectListItem(), id: 'project-1', scopes: ['agent:read'] }]; + }); + await flushPromises(); + expect(withRead.find('[data-testid="agent-n8n-chat-open-page"]').exists()).toBe(true); + }); + + it('hides the agent page link without agent:read in that project', async () => { + const withoutRead = renderView({}, (store) => { + store.myProjects = [{ ...createProjectListItem(), id: 'project-1', scopes: [] }]; + }); + await flushPromises(); + expect(withoutRead.find('[data-testid="agent-n8n-chat-open-page"]').exists()).toBe(false); + }); + + it('shows an unavailable empty state on a 404', async () => { + getN8nChatAgentMock.mockRejectedValue({ httpStatusCode: 404 }); + const wrapper = renderView(); + await flushPromises(); + + expect(wrapper.find('[data-testid="agent-n8n-chat-unavailable"]').exists()).toBe(true); + expect(wrapper.text()).toContain('This agent is not available'); + expect(wrapper.find('[data-testid="chat-panel-stub"]').exists()).toBe(false); + }); + + it('drops a load failure that settles after unmount — no toast', async () => { + const response = createDeferredPromise(); + getN8nChatAgentMock.mockReturnValueOnce(response.promise); + const wrapper = renderView(); + await flushPromises(); + + wrapper.unmount(); + response.reject(new Error('network down')); + await flushPromises(); + + expect(showErrorMock).not.toHaveBeenCalled(); + }); + + it('fetches the new agent and clears the unavailable state when agentId changes after a 404', async () => { + getN8nChatAgentMock.mockRejectedValueOnce({ httpStatusCode: 404 }); + const wrapper = renderView(); + await flushPromises(); + expect(wrapper.find('[data-testid="agent-n8n-chat-unavailable"]').exists()).toBe(true); + + getN8nChatAgentMock.mockResolvedValueOnce({ ...agentItem, id: 'agent-2', name: 'Other Agent' }); + await wrapper.setProps({ agentId: 'agent-2' }); + await flushPromises(); + + expect(getN8nChatAgentMock).toHaveBeenLastCalledWith(expect.anything(), 'agent-2'); + expect(wrapper.find('[data-testid="agent-n8n-chat-unavailable"]').exists()).toBe(false); + expect(wrapper.text()).toContain('Other Agent'); + }); + + it('toasts on a non-404 load failure instead of showing the unavailable state', async () => { + getN8nChatAgentMock.mockRejectedValue(new Error('network down')); + const wrapper = renderView(); + await flushPromises(); + + expect(showErrorMock).toHaveBeenCalled(); + expect(wrapper.find('[data-testid="agent-n8n-chat-unavailable"]').exists()).toBe(false); + }); + + it('shows a retryable error state on a non-404 load failure and reloads on retry', async () => { + getN8nChatAgentMock.mockRejectedValue(new Error('network down')); + const wrapper = renderView(); + await flushPromises(); + + expect(wrapper.find('[data-testid="agent-n8n-chat-load-error"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="chat-panel-stub"]').exists()).toBe(false); + const errorState = wrapper.get('[data-testid="agent-n8n-chat-load-error"]'); + + getN8nChatAgentMock.mockResolvedValueOnce(agentItem); + await errorState.get('button').trigger('click'); + await flushPromises(); + + expect(getN8nChatAgentMock).toHaveBeenCalledTimes(2); + expect(wrapper.find('[data-testid="agent-n8n-chat-load-error"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="chat-panel-stub"]').exists()).toBe(true); + }); + + it('loads history via the agentThreadId prop and marks the session as continued', async () => { + const wrapper = renderView({ agentThreadId: 'thread-99' }); + await flushPromises(); + + const panel = wrapper.findComponent({ name: 'AgentChatPanel' }); + expect(panel.props('continueSessionId')).toBe('thread-99'); + expect(panel.props('newSession')).toBe(false); + }); + + it('mints a session id and reports it as new when there is no agentThreadId prop', async () => { + const wrapper = renderView(); + await flushPromises(); + + const panel = wrapper.findComponent({ name: 'AgentChatPanel' }); + expect(panel.props('continueSessionId')).toEqual(expect.any(String)); + expect(panel.props('newSession')).toBe(true); + }); + + it('replaces the route with the new thread id when the panel reports a session was created', async () => { + const wrapper = renderView(); + await flushPromises(); + + const panel = wrapper.findComponent({ name: 'AgentChatPanel' }); + const mintedId = panel.props('continueSessionId') as string; + + panel.vm.$emit('session-created', mintedId); + await flushPromises(); + + expect(replaceMock).toHaveBeenCalledWith({ + name: AGENT_N8N_CHAT_VIEW, + params: { agentId: 'agent-1', agentThreadId: mintedId }, + }); + }); + + it('mints a new session id for the same agent once the route drops back to no thread id', async () => { + const wrapper = renderView({ agentThreadId: 'thread-99' }); + await flushPromises(); + + await wrapper.setProps({ agentThreadId: undefined }); + await flushPromises(); + + const panel = wrapper.findComponent({ name: 'AgentChatPanel' }); + expect(panel.props('continueSessionId')).toEqual(expect.any(String)); + expect(panel.props('continueSessionId')).not.toBe('thread-99'); + expect(panel.props('newSession')).toBe(true); + }); + + it('keeps the minted id present when landing on a thread URL and navigating back to no thread', async () => { + const wrapper = renderView({ agentThreadId: 'thread-1' }); + await flushPromises(); + + await wrapper.setProps({ agentThreadId: undefined }); + await flushPromises(); + + const panel = wrapper.findComponent({ name: 'AgentChatPanel' }); + expect(panel.props('continueSessionId')).toEqual(expect.any(String)); + }); + + it('goes to the n8n Assistant page, not back in history, even with in-app history', async () => { + historyBack.value = '/some/previous/route'; + const wrapper = renderView(); + await flushPromises(); + + await wrapper.get('[data-testid="n8n-chat-back"]').trigger('click'); + expect(pushMock).toHaveBeenCalledWith({ name: INSTANCE_AI_VIEW }); + expect(backMock).not.toHaveBeenCalled(); + }); + + describe('refreshing the n8n Chat threads store', () => { + it('does not refresh when a session is created, only when a turn finishes streaming', async () => { + const wrapper = renderView(); + await flushPromises(); + const threadsStore = mockedStore(useAgentN8nChatThreadsStore); + const panel = wrapper.findComponent({ name: 'AgentChatPanel' }); + const mintedId = panel.props('continueSessionId') as string; + + panel.vm.$emit('session-created', mintedId); + await flushPromises(); + expect(threadsStore.fetchRecent).not.toHaveBeenCalled(); + + panel.vm.$emit('update:streaming', true); + panel.vm.$emit('update:streaming', false); + await flushPromises(); + expect(threadsStore.fetchRecent).toHaveBeenCalledTimes(1); + + // Every later turn refreshes too — order and title can change on any of them. + panel.vm.$emit('update:streaming', true); + panel.vm.$emit('update:streaming', false); + await flushPromises(); + expect(threadsStore.fetchRecent).toHaveBeenCalledTimes(2); + }); + + it('does not refresh while still streaming or on an unmatched streaming toggle', async () => { + const wrapper = renderView({ agentThreadId: 'thread-99' }); + await flushPromises(); + const threadsStore = mockedStore(useAgentN8nChatThreadsStore); + const panel = wrapper.findComponent({ name: 'AgentChatPanel' }); + + panel.vm.$emit('update:streaming', true); + await flushPromises(); + + expect(threadsStore.fetchRecent).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/__tests__/N8nChatAgentLibraryView.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/__tests__/N8nChatAgentLibraryView.test.ts new file mode 100644 index 00000000000..b49d00b3c97 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/__tests__/N8nChatAgentLibraryView.test.ts @@ -0,0 +1,210 @@ +/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern: @vue/test-utils is a transitive devDep */ +import { ref, type Ref } from 'vue'; +import { mount } from '@vue/test-utils'; +import { i18nInstance } from '@n8n/i18n'; +import { N8nPagination } from '@n8n/design-system'; +import type { AgentChatListItem } from '@n8n/api-types'; + +import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants'; +import N8nChatAgentLibraryView from '../N8nChatAgentLibraryView.vue'; +import N8nChatAgentGrid from '../components/N8nChatAgentGrid.vue'; + +type Options = { query: Ref; page: Ref; pageSize: Ref }; + +const useN8nChatAgentsMock = vi.fn(); +const retryMock = vi.fn(); +vi.mock('../composables/useN8nChatAgents', () => ({ + useN8nChatAgents: (options: Options) => useN8nChatAgentsMock(options), +})); + +// A real card needs an installed router (for `RouterLink`) and an active Pinia +// instance (for telemetry) — out of scope for this view-level test, which only +// cares that the grid receives the right agents. +vi.mock('../components/N8nChatAgentCard.vue', () => ({ + default: { + name: 'N8nChatAgentCard', + props: ['agent', 'source'], + template: '
{{ agent.name }}
', + }, +})); + +const pushMock = vi.fn(); +const backMock = vi.fn(); +const historyBack = { value: undefined as string | undefined }; +vi.mock('vue-router', () => ({ + useRouter: () => ({ + push: pushMock, + back: backMock, + resolve: vi.fn((to: unknown) => ({ href: JSON.stringify(to), matched: [{}] })), + options: { + history: { + get state() { + return { back: historyBack.value }; + }, + }, + }, + }), +})); + +const agentA: AgentChatListItem = { id: 'a1', name: 'One', project: { id: 'p', name: 'P' } }; +const agentB: AgentChatListItem = { id: 'a2', name: 'Two', project: { id: 'p', name: 'P' } }; + +function setup(result: { + agents: AgentChatListItem[]; + count: number; + isLoading: boolean; + loadFailed?: boolean; +}) { + useN8nChatAgentsMock.mockReturnValue({ + agents: ref(result.agents), + count: ref(result.count), + isLoading: ref(result.isLoading), + loadFailed: ref(result.loadFailed ?? false), + retry: retryMock, + }); + + return mount(N8nChatAgentLibraryView, { + global: { + plugins: [i18nInstance], + stubs: { + N8nInput: { + props: ['modelValue'], + emits: ['update:modelValue'], + template: + '', + }, + }, + }, + }); +} + +describe('N8nChatAgentLibraryView', () => { + beforeEach(() => { + vi.clearAllMocks(); + historyBack.value = undefined; + }); + + it('renders the title and the agent grid', () => { + const wrapper = setup({ agents: [agentA, agentB], count: 2, isLoading: false }); + + expect(wrapper.text()).toContain('Agents library'); + const grid = wrapper.findComponent(N8nChatAgentGrid); + expect(grid.props('agents')).toEqual([agentA, agentB]); + expect(grid.props('source')).toBe('library'); + }); + + it('caps the search input at 128 characters', () => { + const wrapper = setup({ agents: [], count: 0, isLoading: false }); + + expect(wrapper.get('[data-testid="n8n-chat-library-search"]').attributes('maxlength')).toBe( + '128', + ); + }); + + it('shows the no-agents empty state when there is no search and no agents', () => { + const wrapper = setup({ agents: [], count: 0, isLoading: false }); + + expect(wrapper.find('[data-testid="n8n-chat-library-empty"]').exists()).toBe(true); + expect(wrapper.text()).toContain('No agents available to chat with yet'); + }); + + it('shows a no-results empty state once a search is active and nothing matches', async () => { + const wrapper = setup({ agents: [], count: 0, isLoading: false }); + + await wrapper.get('[data-testid="n8n-chat-library-search"]').setValue('zzz'); + await vi.waitFor(() => { + expect(useN8nChatAgentsMock.mock.calls[0][0].query.value).toBe('zzz'); + }); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain('No agents match your search'); + }); + + it('shows a retryable error state instead of the empty state on a failed fetch', () => { + const wrapper = setup({ agents: [], count: 0, isLoading: false, loadFailed: true }); + + expect(wrapper.find('[data-testid="n8n-chat-library-error"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="n8n-chat-library-empty"]').exists()).toBe(false); + expect(wrapper.findComponent(N8nChatAgentGrid).exists()).toBe(false); + }); + + it('shows the error state even with stale results, and retries on click', async () => { + const wrapper = setup({ agents: [agentA], count: 1, isLoading: false, loadFailed: true }); + + const errorState = wrapper.get('[data-testid="n8n-chat-library-error"]'); + expect(wrapper.findComponent(N8nChatAgentGrid).exists()).toBe(false); + + await errorState.get('button').trigger('click'); + expect(retryMock).toHaveBeenCalledOnce(); + }); + + it('resets to page 1 once the search query actually changes', async () => { + const wrapper = setup({ agents: [], count: 0, isLoading: false }); + const options = useN8nChatAgentsMock.mock.calls[0][0] as Options; + options.page.value = 3; + + await wrapper.get('[data-testid="n8n-chat-library-search"]').setValue('support'); + await vi.waitFor(() => { + expect(options.query.value).toBe('support'); + }); + + expect(options.page.value).toBe(1); + }); + + it('keeps the current page when the debounced query is unchanged after trimming', async () => { + vi.useFakeTimers(); + try { + const wrapper = setup({ agents: [], count: 0, isLoading: false }); + const options = useN8nChatAgentsMock.mock.calls[0][0] as Options; + const search = wrapper.get('[data-testid="n8n-chat-library-search"]'); + + await search.setValue('support'); + await vi.advanceTimersByTimeAsync(300); + expect(options.query.value).toBe('support'); + + // Change the page within the debounce window, then let a value that + // trims to the same query debounce through — the page must not reset. + options.page.value = 3; + await search.setValue(' support '); + await vi.advanceTimersByTimeAsync(300); + + expect(options.query.value).toBe('support'); + expect(options.page.value).toBe(3); + } finally { + vi.useRealTimers(); + } + }); + + it('shows pagination with a total and page sizes once there are agents', () => { + const empty = setup({ agents: [], count: 0, isLoading: false }); + expect(empty.findComponent(N8nPagination).exists()).toBe(false); + + const pagination = setup({ agents: [agentA], count: 1, isLoading: false }).findComponent( + N8nPagination, + ); + expect(pagination.exists()).toBe(true); + expect(pagination.props('pageSizes')).toEqual([10, 25, 50, 100]); + expect(pagination.props('itemsPerPage')).toBe(50); + }); + + it('goes back to the first page when the page size changes', async () => { + const wrapper = setup({ agents: [agentA], count: 120, isLoading: false }); + const options = useN8nChatAgentsMock.mock.lastCall?.[0] as Options; + options.page.value = 3; + + wrapper.findComponent(N8nPagination).vm.$emit('update:itemsPerPage', 25); + await wrapper.vm.$nextTick(); + + expect(options.pageSize.value).toBe(25); + expect(options.page.value).toBe(1); + }); + + it('goes to the n8n Assistant page, not back in history, even with in-app history', async () => { + historyBack.value = '/some/previous/route'; + const wrapper = setup({ agents: [], count: 0, isLoading: false }); + + await wrapper.get('[data-testid="n8n-chat-back"]').trigger('click'); + expect(pushMock).toHaveBeenCalledWith({ name: INSTANCE_AI_VIEW }); + expect(backMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatAgentCard.vue b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatAgentCard.vue new file mode 100644 index 00000000000..f37b167d8f9 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatAgentCard.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatAgentGrid.vue b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatAgentGrid.vue new file mode 100644 index 00000000000..b5b34a83b14 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatAgentGrid.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatPageLayout.vue b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatPageLayout.vue new file mode 100644 index 00000000000..08e834b4333 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatPageLayout.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatThreadHistory.vue b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatThreadHistory.vue new file mode 100644 index 00000000000..d393f1b9431 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/N8nChatThreadHistory.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/RecentChatIcon.vue b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/RecentChatIcon.vue new file mode 100644 index 00000000000..2b0a93211e8 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/RecentChatIcon.vue @@ -0,0 +1,25 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentCard.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentCard.test.ts new file mode 100644 index 00000000000..3fd272b50ec --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentCard.test.ts @@ -0,0 +1,83 @@ +/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern: @vue/test-utils is a transitive devDep */ +import { mount } from '@vue/test-utils'; +import { createMemoryHistory, createRouter } from 'vue-router'; +import { i18nInstance } from '@n8n/i18n'; +import type { AgentChatListItem } from '@n8n/api-types'; + +import { AGENT_N8N_CHAT_VIEW } from '../../../constants'; +import N8nChatAgentCard from '../N8nChatAgentCard.vue'; + +const trackSelectedN8nChatAgentMock = vi.fn(); +vi.mock('../../../composables/useAgentTelemetry', () => ({ + useAgentTelemetry: () => ({ trackSelectedN8nChatAgent: trackSelectedN8nChatAgentMock }), +})); + +const stub = { template: '
' }; +const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/assistant/agents/:agentId/:agentThreadId?', + name: AGENT_N8N_CHAT_VIEW, + component: stub, + }, + ], +}); + +const agent: AgentChatListItem = { + id: 'agent-1', + name: 'Support Agent', + description: 'Answers billing questions for the whole team, in great detail.', + project: { id: 'project-1', name: 'Marketing' }, +}; + +function renderCard(props: Partial['$props']> = {}) { + return mount(N8nChatAgentCard, { + props: { agent, source: 'card', ...props }, + global: { plugins: [router, i18nInstance] }, + }); +} + +describe('N8nChatAgentCard', () => { + beforeEach(() => { + trackSelectedN8nChatAgentMock.mockClear(); + }); + + it('links to the agent n8n Chat view', async () => { + const wrapper = renderCard(); + await router.isReady(); + + expect(wrapper.get('a').attributes('href')).toBe('/assistant/agents/agent-1'); + }); + + it('renders the name and description', () => { + const wrapper = renderCard(); + + expect(wrapper.text()).toContain('Support Agent'); + expect(wrapper.text()).toContain('Answers billing questions'); + }); + + it('renders the name as bold text rather than a heading, so the library page stays at one h1', () => { + const wrapper = renderCard(); + + const name = wrapper.get('[data-test-id="n8n-chat-agent-card-name"]'); + expect(name.element.tagName.toLowerCase()).not.toMatch(/^h[1-6]$/); + }); + + it('omits the description when the agent has none', () => { + const wrapper = renderCard({ agent: { ...agent, description: undefined } }); + + expect(wrapper.find('[data-test-id="n8n-chat-agent-card-description"]').exists()).toBe(false); + }); + + it('tracks selection with the given source on click', async () => { + const wrapper = renderCard({ source: 'library' }); + + await wrapper.get('a').trigger('click'); + + expect(trackSelectedN8nChatAgentMock).toHaveBeenCalledWith({ + agentId: 'agent-1', + source: 'library', + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentGrid.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentGrid.test.ts new file mode 100644 index 00000000000..f1f4d3f0bdf --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatAgentGrid.test.ts @@ -0,0 +1,53 @@ +/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern: @vue/test-utils is a transitive devDep */ +import { mount } from '@vue/test-utils'; +import { createMemoryHistory, createRouter } from 'vue-router'; +import { i18nInstance } from '@n8n/i18n'; +import type { AgentChatListItem } from '@n8n/api-types'; + +import { AGENT_N8N_CHAT_VIEW } from '../../../constants'; +import SkeletonAgentCard from '@/features/ai/chatHub/components/SkeletonAgentCard.vue'; +import N8nChatAgentCard from '../N8nChatAgentCard.vue'; +import N8nChatAgentGrid from '../N8nChatAgentGrid.vue'; + +vi.mock('../../../composables/useAgentTelemetry', () => ({ + useAgentTelemetry: () => ({ trackSelectedN8nChatAgent: vi.fn() }), +})); + +const stub = { template: '
' }; +const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/assistant/agents/:agentId/:agentThreadId?', + name: AGENT_N8N_CHAT_VIEW, + component: stub, + }, + ], +}); + +const agents: AgentChatListItem[] = [ + { id: 'agent-1', name: 'One', project: { id: 'p', name: 'P' } }, + { id: 'agent-2', name: 'Two', project: { id: 'p', name: 'P' } }, +]; + +function renderGrid(props: { agents?: AgentChatListItem[]; loading?: boolean } = {}) { + return mount(N8nChatAgentGrid, { + props: { agents, source: 'library', ...props }, + global: { plugins: [router, i18nInstance] }, + }); +} + +describe('N8nChatAgentGrid', () => { + it('renders one card per agent', () => { + const wrapper = renderGrid(); + + expect(wrapper.findAllComponents(N8nChatAgentCard)).toHaveLength(2); + }); + + it('renders skeleton placeholders instead of cards while loading', () => { + const wrapper = renderGrid({ loading: true }); + + expect(wrapper.findAllComponents(N8nChatAgentCard)).toHaveLength(0); + expect(wrapper.findAllComponents(SkeletonAgentCard).length).toBeGreaterThan(0); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatThreadHistory.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatThreadHistory.test.ts new file mode 100644 index 00000000000..edcbf516760 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/N8nChatThreadHistory.test.ts @@ -0,0 +1,168 @@ +import { reactive } from 'vue'; +import { fireEvent, waitFor } from '@testing-library/vue'; +import { createTestingPinia } from '@pinia/testing'; +import type { AgentN8nChatThreadSummary } from '@n8n/api-types'; + +import { createComponentRenderer } from '@/__tests__/render'; +import { AGENT_N8N_CHAT_VIEW } from '../../../constants'; +import N8nChatThreadHistory from '../N8nChatThreadHistory.vue'; + +const listN8nChatThreadsMock = vi.fn(); +vi.mock('../../../composables/useAgentApi', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listN8nChatThreads: (...args: unknown[]) => listN8nChatThreadsMock(...args), + }; +}); + +const pushMock = vi.fn(); +const route = reactive<{ params: Record }>({ params: {} }); +vi.mock('vue-router', async (importOriginal) => ({ + ...(await importOriginal()), + useRoute: () => route, + useRouter: () => ({ push: pushMock }), +})); + +function thread(id: string, overrides: Partial = {}) { + return { + id, + title: `Thread ${id}`, + updatedAt: '2026-01-01T00:00:00.000Z', + agent: { id: 'agent-1', name: 'Support', projectId: 'project-1' }, + ...overrides, + } satisfies AgentN8nChatThreadSummary; +} + +const renderHistory = createComponentRenderer(N8nChatThreadHistory); + +async function renderAndOpen(props: { agentId?: string } = {}) { + const pinia = createTestingPinia(); + const result = renderHistory({ + props: { agentId: 'agent-1', ...props }, + global: { plugins: [pinia] }, + }); + await fireEvent.click(result.getByTestId('agent-n8n-chat-history-toggle')); + return result; +} + +describe('N8nChatThreadHistory', () => { + beforeEach(() => { + vi.clearAllMocks(); + route.params = {}; + listN8nChatThreadsMock.mockResolvedValue({ data: [], nextCursor: null }); + }); + + it('loads threads scoped to agentId when the dropdown opens', async () => { + listN8nChatThreadsMock.mockResolvedValue({ + data: [thread('t1'), thread('t2')], + nextCursor: null, + }); + + const { getAllByTestId } = await renderAndOpen(); + + await waitFor(() => { + expect(listN8nChatThreadsMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ agentId: 'agent-1' }), + ); + }); + await waitFor(() => { + expect(getAllByTestId('agent-n8n-chat-history-item')).toHaveLength(2); + }); + }); + + it('shows a "New chat" entry that routes with only agentId', async () => { + const { getByTestId } = await renderAndOpen(); + + await fireEvent.click(getByTestId('agent-n8n-chat-history-new')); + + expect(pushMock).toHaveBeenCalledWith({ + name: AGENT_N8N_CHAT_VIEW, + params: { agentId: 'agent-1' }, + }); + }); + + it('highlights the currently open thread', async () => { + route.params = { agentThreadId: 't2' }; + listN8nChatThreadsMock.mockResolvedValue({ + data: [thread('t1'), thread('t2')], + nextCursor: null, + }); + + const { getAllByTestId } = await renderAndOpen(); + + await waitFor(() => { + expect(getAllByTestId('agent-n8n-chat-history-item')).toHaveLength(2); + }); + const rows = getAllByTestId('agent-n8n-chat-history-item'); + expect(rows[1].querySelector('[data-icon="check"]')).not.toBeNull(); + expect(rows[0].querySelector('[data-icon="check"]')).toBeNull(); + }); + + it('selecting a thread routes to it and closes the dropdown', async () => { + listN8nChatThreadsMock.mockResolvedValue({ data: [thread('t1')], nextCursor: null }); + + const { getAllByTestId, queryByTestId } = await renderAndOpen(); + await waitFor(() => expect(getAllByTestId('agent-n8n-chat-history-item')).toHaveLength(1)); + + await fireEvent.click(getAllByTestId('agent-n8n-chat-history-item')[0]); + + expect(pushMock).toHaveBeenCalledWith({ + name: AGENT_N8N_CHAT_VIEW, + params: { agentId: 'agent-1', agentThreadId: 't1' }, + }); + await waitFor(() => { + expect(queryByTestId('agent-n8n-chat-history-list')).not.toBeInTheDocument(); + }); + }); + + it('loads more threads using the cursor', async () => { + listN8nChatThreadsMock.mockResolvedValueOnce({ data: [thread('t1')], nextCursor: 'cursor-1' }); + + const { getByTestId, getAllByTestId } = await renderAndOpen(); + await waitFor(() => expect(getByTestId('agent-n8n-chat-history-load-more')).toBeTruthy()); + + listN8nChatThreadsMock.mockResolvedValueOnce({ data: [thread('t2')], nextCursor: null }); + await fireEvent.click(getByTestId('agent-n8n-chat-history-load-more')); + + await waitFor(() => expect(getAllByTestId('agent-n8n-chat-history-item')).toHaveLength(2)); + expect(listN8nChatThreadsMock).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ cursor: 'cursor-1' }), + ); + }); + + it('drops a stale response from a previous open', async () => { + let resolveFirst: (value: { data: AgentN8nChatThreadSummary[]; nextCursor: null }) => void = + () => {}; + listN8nChatThreadsMock.mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + + const { getAllByTestId, getByTestId } = await renderAndOpen(); + // Close and reopen before the first request resolves — a second, newer request starts. + await fireEvent.keyDown(document, { key: 'Escape' }); + listN8nChatThreadsMock.mockResolvedValueOnce({ data: [thread('fresh')], nextCursor: null }); + await fireEvent.click(getByTestId('agent-n8n-chat-history-toggle')); + + resolveFirst({ data: [thread('stale')], nextCursor: null }); + await waitFor(() => expect(getAllByTestId('agent-n8n-chat-history-item')).toHaveLength(1)); + expect(getAllByTestId('agent-n8n-chat-history-item')[0].textContent).toContain('fresh'); + }); + + it('shows an error with retry', async () => { + listN8nChatThreadsMock.mockRejectedValueOnce(new Error('network down')); + + const { getByTestId } = await renderAndOpen(); + await waitFor(() => expect(getByTestId('agent-n8n-chat-history-retry')).toBeTruthy()); + + listN8nChatThreadsMock.mockResolvedValueOnce({ data: [thread('t1')], nextCursor: null }); + await fireEvent.click(getByTestId('agent-n8n-chat-history-retry')); + + await waitFor(() => expect(getByTestId('agent-n8n-chat-history-item')).toBeTruthy()); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/RecentChatIcon.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/RecentChatIcon.test.ts new file mode 100644 index 00000000000..c5f58db01fd --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/components/__tests__/RecentChatIcon.test.ts @@ -0,0 +1,54 @@ +/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern: @vue/test-utils is a transitive devDep */ +import { mount } from '@vue/test-utils'; + +import RecentChatIcon from '../RecentChatIcon.vue'; +import AgentPersonalisationIcon from '../../../components/AgentPersonalisationIcon.vue'; +import type { RecentChatItem } from '../../mergeRecentChats'; + +const assistantItem: RecentChatItem = { + kind: 'assistant', + thread: { + id: 't1', + title: 'Chat', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, +}; +const personalisation = { + icon: 'robot' as const, + gradient: { from: '#111111', to: '#222222', angle: 45, fromStop: 10, toStop: 90 }, +}; +const agentItem: RecentChatItem = { + kind: 'agent', + thread: { + id: 'g1', + title: null, + updatedAt: '2026-01-01T00:00:00.000Z', + agent: { id: 'agent-1', name: 'Support', projectId: 'project-1', personalisation }, + }, +}; + +describe('RecentChatIcon', () => { + it('shows the AI sparkle for an Assistant thread', () => { + expect( + mount(RecentChatIcon, { props: { item: assistantItem } }) + .find('[data-icon="sparkles"]') + .exists(), + ).toBe(true); + }); + + it('shows the agent avatar for an agent thread', () => { + const wrapper = mount(RecentChatIcon, { props: { item: agentItem } }); + expect(wrapper.find('[data-test-id="agent-personalisation-icon-tile"]').exists()).toBe(true); + }); + + it('forwards the agent personalisation to the avatar', () => { + const wrapper = mount(RecentChatIcon, { props: { item: agentItem } }); + const avatar = wrapper.findComponent(AgentPersonalisationIcon); + expect(avatar.props('personalisation')).toEqual(personalisation); + }); + + it('renders nothing without an item', () => { + expect(mount(RecentChatIcon).html()).toBe(''); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nChatAgents.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nChatAgents.test.ts new file mode 100644 index 00000000000..bb14caff542 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nChatAgents.test.ts @@ -0,0 +1,230 @@ +/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern: @vue/test-utils is a transitive devDep */ +import { effectScope, ref } from 'vue'; +import { flushPromises } from '@vue/test-utils'; +import type { AgentChatListResponse } from '@n8n/api-types'; +import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; + +import { useN8nChatAgents } from './useN8nChatAgents'; + +const listN8nChatAgentsMock = vi.fn(); +vi.mock('../../composables/useAgentApi', () => ({ + listN8nChatAgents: (...args: unknown[]) => listN8nChatAgentsMock(...args), +})); + +vi.mock('@n8n/stores/useRootStore', () => ({ + useRootStore: () => ({ restApiContext: { baseUrl: '/rest', pushRef: 'push-ref' } }), +})); + +const showErrorMock = vi.fn(); +vi.mock('@n8n/composables/useToast', () => ({ + useToast: () => ({ showError: showErrorMock }), +})); + +describe('useN8nChatAgents', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fetches on mount with the given query, page, and pageSize, and a fixed sortBy', async () => { + listN8nChatAgentsMock.mockResolvedValue({ count: 0, data: [] }); + const query = ref(''); + const page = ref(1); + const pageSize = 50; + + useN8nChatAgents({ query, page, pageSize }); + await flushPromises(); + + expect(listN8nChatAgentsMock).toHaveBeenCalledWith(expect.anything(), { + query: '', + skip: 0, + take: 50, + sortBy: 'usage:desc', + }); + }); + + it('refetches with an updated skip when the page changes', async () => { + listN8nChatAgentsMock.mockResolvedValue({ count: 0, data: [] }); + const query = ref(''); + const page = ref(1); + const pageSize = 50; + + useN8nChatAgents({ query, page, pageSize }); + await flushPromises(); + + page.value = 2; + await flushPromises(); + + expect(listN8nChatAgentsMock).toHaveBeenLastCalledWith(expect.anything(), { + query: '', + skip: 50, + take: 50, + sortBy: 'usage:desc', + }); + }); + + it('refetches with the new take and skip when the page size changes', async () => { + listN8nChatAgentsMock.mockResolvedValue({ count: 0, data: [] }); + const query = ref(''); + const page = ref(2); + const pageSize = ref(50); + + useN8nChatAgents({ query, page, pageSize }); + await flushPromises(); + + pageSize.value = 25; + await flushPromises(); + + expect(listN8nChatAgentsMock).toHaveBeenLastCalledWith(expect.anything(), { + query: '', + skip: 25, + take: 25, + sortBy: 'usage:desc', + }); + }); + + it('passes the query through untrimmed — trimming is `listN8nChatAgents`’ job', async () => { + listN8nChatAgentsMock.mockResolvedValue({ count: 0, data: [] }); + const query = ref(''); + const page = ref(1); + const pageSize = 50; + + useN8nChatAgents({ query, page, pageSize }); + await flushPromises(); + + query.value = ' support '; + await flushPromises(); + + expect(listN8nChatAgentsMock).toHaveBeenLastCalledWith(expect.anything(), { + query: ' support ', + skip: 0, + take: 50, + sortBy: 'usage:desc', + }); + }); + + it('drops a stale response that resolves after a newer request', async () => { + const first = createDeferredPromise(); + const second = createDeferredPromise(); + listN8nChatAgentsMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const query = ref(''); + const page = ref(1); + const pageSize = 50; + + const { agents, count } = useN8nChatAgents({ query, page, pageSize }); + await flushPromises(); + + page.value = 2; + await flushPromises(); + + // Newer request (page 2) resolves first; older (page 1) resolves after. + second.resolve({ + count: 1, + data: [{ id: 'a2', name: 'Two', project: { id: 'p', name: 'P' } }], + }); + await flushPromises(); + first.resolve({ count: 1, data: [{ id: 'a1', name: 'One', project: { id: 'p', name: 'P' } }] }); + await flushPromises(); + + expect(agents.value).toEqual([{ id: 'a2', name: 'Two', project: { id: 'p', name: 'P' } }]); + expect(count.value).toBe(1); + }); + + it('drops a stale rejection that arrives after a newer request already resolved', async () => { + const first = createDeferredPromise(); + const second = createDeferredPromise(); + listN8nChatAgentsMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const query = ref(''); + const page = ref(1); + const pageSize = 50; + + const { agents, loadFailed } = useN8nChatAgents({ query, page, pageSize }); + await flushPromises(); + + page.value = 2; + await flushPromises(); + + // Newer request (page 2) resolves first; older (page 1) fails after. + second.resolve({ + count: 1, + data: [{ id: 'a2', name: 'Two', project: { id: 'p', name: 'P' } }], + }); + await flushPromises(); + first.reject(new Error('network down')); + await flushPromises(); + + expect(showErrorMock).not.toHaveBeenCalled(); + expect(loadFailed.value).toBe(false); + expect(agents.value).toEqual([{ id: 'a2', name: 'Two', project: { id: 'p', name: 'P' } }]); + }); + + it('toasts a translated error, clears results, flags loadFailed, and stops loading on failure', async () => { + listN8nChatAgentsMock + .mockResolvedValueOnce({ + count: 1, + data: [{ id: 'a1', name: 'One', project: { id: 'p', name: 'P' } }], + }) + .mockRejectedValueOnce(new Error('network down')); + const query = ref(''); + const page = ref(1); + const pageSize = 50; + + const { agents, count, isLoading, loadFailed } = useN8nChatAgents({ query, page, pageSize }); + await flushPromises(); + expect(agents.value).toHaveLength(1); + expect(loadFailed.value).toBe(false); + + page.value = 2; + await flushPromises(); + + expect(showErrorMock).toHaveBeenCalledWith( + expect.any(Error), + "Couldn't load agents. Try again.", + ); + expect(isLoading.value).toBe(false); + expect(loadFailed.value).toBe(true); + // Stale results/count must not linger once the fetch has failed. + expect(agents.value).toEqual([]); + expect(count.value).toBe(0); + }); + + it('drops a response that settles after the owning scope is stopped — no toast, no state mutation', async () => { + const pending = createDeferredPromise(); + listN8nChatAgentsMock.mockReturnValueOnce(pending.promise); + const query = ref(''); + const page = ref(1); + const pageSize = 50; + + const scope = effectScope(); + const { agents, loadFailed } = scope.run(() => useN8nChatAgents({ query, page, pageSize }))!; + await flushPromises(); + + scope.stop(); + pending.reject(new Error('network down')); + await flushPromises(); + + expect(showErrorMock).not.toHaveBeenCalled(); + expect(agents.value).toEqual([]); + expect(loadFailed.value).toBe(false); + }); + + it('retry() re-fetches and clears loadFailed on success', async () => { + listN8nChatAgentsMock.mockRejectedValueOnce(new Error('network down')).mockResolvedValueOnce({ + count: 1, + data: [{ id: 'a1', name: 'One', project: { id: 'p', name: 'P' } }], + }); + const query = ref(''); + const page = ref(1); + const pageSize = 50; + + const { agents, loadFailed, retry } = useN8nChatAgents({ query, page, pageSize }); + await flushPromises(); + expect(loadFailed.value).toBe(true); + + retry(); + await flushPromises(); + + expect(listN8nChatAgentsMock).toHaveBeenCalledTimes(2); + expect(loadFailed.value).toBe(false); + expect(agents.value).toHaveLength(1); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nChatAgents.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nChatAgents.ts new file mode 100644 index 00000000000..2df00b263e5 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/composables/useN8nChatAgents.ts @@ -0,0 +1,76 @@ +import { getCurrentScope, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'; +import type { AgentChatListItem } from '@n8n/api-types'; +import { useI18n } from '@n8n/i18n'; +import { useToast } from '@n8n/composables/useToast'; +import { useRootStore } from '@n8n/stores/useRootStore'; + +import { listN8nChatAgents } from '../../composables/useAgentApi'; + +export interface UseN8nChatAgentsOptions { + query: MaybeRefOrGetter; + page: MaybeRefOrGetter; + pageSize: MaybeRefOrGetter; +} + +/** + * Loads one page of the n8n Chat agent library, ranked by the user's own usage. + * Refetches whenever `query`, `page` or `pageSize` change, and drops a stale response via a + * request counter (no AbortController hook on `makeRestApiRequest`). + */ +export function useN8nChatAgents(options: UseN8nChatAgentsOptions) { + const rootStore = useRootStore(); + const toast = useToast(); + const i18n = useI18n(); + + const agents = ref([]); + const count = ref(0); + const isLoading = ref(false); + const loadFailed = ref(false); + + let requestVersion = 0; + // Bump the version on unmount, so a response that settles after it is dropped by the + // existing version guards below instead of mutating state or toasting on the next page. + if (getCurrentScope()) onScopeDispose(() => requestVersion++); + + async function fetchAgents(): Promise { + const version = ++requestVersion; + isLoading.value = true; + loadFailed.value = false; + const query = toValue(options.query); + const page = toValue(options.page); + const pageSize = toValue(options.pageSize); + try { + const result = await listN8nChatAgents(rootStore.restApiContext, { + query, + skip: (page - 1) * pageSize, + take: pageSize, + sortBy: 'usage:desc', + }); + if (version !== requestVersion) return; + agents.value = result.data; + count.value = result.count; + } catch (error) { + if (version !== requestVersion) return; + agents.value = []; + count.value = 0; + loadFailed.value = true; + toast.showError(error, i18n.baseText('agents.n8nChatPage.library.loadError')); + } finally { + if (version === requestVersion) isLoading.value = false; + } + } + + function retry(): void { + void fetchAgents(); + } + + watch( + [() => toValue(options.query), () => toValue(options.page), () => toValue(options.pageSize)], + () => { + void fetchAgents(); + }, + { immediate: true }, + ); + + return { agents, count, isLoading, loadFailed, retry }; +} diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/mergeRecentChats.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/mergeRecentChats.test.ts new file mode 100644 index 00000000000..84eda25225d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/mergeRecentChats.test.ts @@ -0,0 +1,126 @@ +import type { InstanceAiThreadSummary, AgentN8nChatThreadSummary } from '@n8n/api-types'; + +import { mergeRecentChats, mergeChatHistoryPages, type RecentChatItem } from './mergeRecentChats'; + +const assistantThread = (id: string, updatedAt: string): InstanceAiThreadSummary => ({ + id, + title: `Assistant ${id}`, + createdAt: updatedAt, + updatedAt, +}); + +const agentThread = (id: string, updatedAt: string): AgentN8nChatThreadSummary => ({ + id, + title: `Agent ${id}`, + updatedAt, + agent: { id: 'agent-1', name: 'Support', projectId: 'project-1' }, +}); + +describe('mergeRecentChats', () => { + it('interleaves both sources by updatedAt, newest first', () => { + const assistant = [assistantThread('a1', '2026-01-03T00:00:00.000Z')]; + const agent = [ + agentThread('g1', '2026-01-04T00:00:00.000Z'), + agentThread('g2', '2026-01-02T00:00:00.000Z'), + ]; + + const result = mergeRecentChats(assistant, agent, { limit: 5 }); + + expect(result).toEqual([ + { kind: 'agent', thread: agent[0] }, + { kind: 'assistant', thread: assistant[0] }, + { kind: 'agent', thread: agent[1] }, + ]); + }); + + it('caps the result at limit', () => { + // Newest first, as the real (already-sorted) API responses are. + const assistant = [2, 1, 0].map((i) => + assistantThread(`a${i}`, `2026-01-0${i + 1}T00:00:00.000Z`), + ); + const agent = [2, 1, 0].map((i) => agentThread(`g${i}`, `2026-01-1${i}T00:00:00.000Z`)); + + const result = mergeRecentChats(assistant, agent, { limit: 2 }); + + expect(result).toHaveLength(2); + expect(result.map((item) => item.thread.id)).toEqual(['g2', 'g1']); + }); + + it('keeps the open thread when it falls outside the limit', () => { + const assistant = [assistantThread('old', '2026-01-01T00:00:00.000Z')]; + // Newest first, as the real (already-sorted) API responses are. + const agent = [4, 3, 2, 1, 0].map((i) => agentThread(`g${i}`, `2026-01-1${i}T00:00:00.000Z`)); + + const result = mergeRecentChats(assistant, agent, { limit: 5, openThreadId: 'old' }); + + expect(result).toHaveLength(5); + expect(result.at(-1)).toEqual({ kind: 'assistant', thread: assistant[0] }); + }); + + it('does not duplicate the open thread when it is already within the limit', () => { + const assistant = [assistantThread('a1', '2026-01-05T00:00:00.000Z')]; + const result = mergeRecentChats(assistant, [], { limit: 5, openThreadId: 'a1' }); + + expect(result).toEqual([{ kind: 'assistant', thread: assistant[0] }]); + }); + + it('skips an open thread id that is not among the loaded threads, without fetching', () => { + const assistant = [assistantThread('a1', '2026-01-05T00:00:00.000Z')]; + const result = mergeRecentChats(assistant, [], { limit: 5, openThreadId: 'not-loaded' }); + + expect(result).toEqual([{ kind: 'assistant', thread: assistant[0] }]); + }); +}); + +describe('mergeChatHistoryPages', () => { + const item = (kind: RecentChatItem['kind'], id: string, updatedAt: string): RecentChatItem => + kind === 'assistant' + ? { kind, thread: assistantThread(id, updatedAt) } + : { kind, thread: agentThread(id, updatedAt) }; + + it('merges and sorts every loaded item when no source has more pages', () => { + const result = mergeChatHistoryPages([ + { items: [item('assistant', 'a1', '2026-01-02T00:00:00.000Z')], hasMore: false }, + { items: [item('agent', 'g1', '2026-01-03T00:00:00.000Z')], hasMore: false }, + ]); + + expect(result.map((i) => i.thread.id)).toEqual(['g1', 'a1']); + }); + + it('hides items older than the oldest-loaded item of a source that still has more', () => { + const result = mergeChatHistoryPages([ + // Exhausted — every item is trustworthy. + { + items: [ + item('assistant', 'a1', '2026-01-10T00:00:00.000Z'), + item('assistant', 'a2', '2026-01-01T00:00:00.000Z'), + ], + hasMore: false, + }, + // Still paging — anything older than its own oldest loaded (01-05) is unsafe to show. + { items: [item('agent', 'g1', '2026-01-05T00:00:00.000Z')], hasMore: true }, + ]); + + // a2 (01-01) is older than the agent source's cutoff (01-05) and is hidden. + expect(result.map((i) => i.thread.id)).toEqual(['a1', 'g1']); + }); + + it('takes the stricter (newer) cutoff when multiple sources still have more pages', () => { + const result = mergeChatHistoryPages([ + { items: [item('assistant', 'a1', '2026-01-05T00:00:00.000Z')], hasMore: true }, + { items: [item('agent', 'g1', '2026-01-08T00:00:00.000Z')], hasMore: true }, + ]); + + // The agent source's cutoff (01-08) is stricter than assistant's (01-05); a1 is hidden. + expect(result.map((i) => i.thread.id)).toEqual(['g1']); + }); + + it('hides everything from a still-paging source with nothing loaded yet', () => { + const result = mergeChatHistoryPages([ + { items: [item('assistant', 'a1', '2026-01-05T00:00:00.000Z')], hasMore: false }, + { items: [], hasMore: true }, + ]); + + expect(result).toEqual([]); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/mergeRecentChats.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/mergeRecentChats.ts new file mode 100644 index 00000000000..38ee2bd1d75 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/mergeRecentChats.ts @@ -0,0 +1,93 @@ +import type { InstanceAiThreadSummary, AgentN8nChatThreadSummary } from '@n8n/api-types'; +import type { useI18n } from '@n8n/i18n'; +import { INSTANCE_AI_THREAD_VIEW } from '@/features/ai/instanceAi/constants'; +import { AGENT_N8N_CHAT_VIEW } from '../constants'; + +export type RecentChatItem = + | { kind: 'assistant'; thread: InstanceAiThreadSummary } + | { kind: 'agent'; thread: AgentN8nChatThreadSummary }; + +/** Route to open a sidebar or history chat item on its own page. */ +export function chatItemRoute(item: RecentChatItem) { + if (item.kind === 'assistant') { + return { name: INSTANCE_AI_THREAD_VIEW, params: { threadId: item.thread.id } }; + } + return { + name: AGENT_N8N_CHAT_VIEW, + params: { agentId: item.thread.agent.id, agentThreadId: item.thread.id }, + }; +} + +/** Display title for a chat item, falling back to a generic label for an untitled agent thread. */ +export function chatItemTitle(item: RecentChatItem, i18n: ReturnType): string { + if (item.kind === 'assistant') return item.thread.title; + return item.thread.title ?? i18n.baseText('commandBar.instanceAi.newThread'); +} + +export type MergeRecentChatsOptions = { + limit: number; + /** The currently open thread's id, kept visible past `limit` if already loaded. */ + openThreadId?: string; +}; + +/** + * Single-pass merge of two already newest-first lists into one recency-ordered list for + * the sidebar, stopping at `limit`. + */ +export function mergeRecentChats( + assistantThreads: InstanceAiThreadSummary[], + agentThreads: AgentN8nChatThreadSummary[], + options: MergeRecentChatsOptions, +): RecentChatItem[] { + const assistantItems = assistantThreads.map((thread) => ({ kind: 'assistant' as const, thread })); + const agentItems = agentThreads.map((thread) => ({ kind: 'agent' as const, thread })); + + const merged: RecentChatItem[] = []; + let a = 0; + let g = 0; + while (merged.length < options.limit && (a < assistantItems.length || g < agentItems.length)) { + const useAssistant = + g >= agentItems.length || + (a < assistantItems.length && + assistantItems[a].thread.updatedAt >= agentItems[g].thread.updatedAt); + merged.push(useAssistant ? assistantItems[a++] : agentItems[g++]); + } + + const openId = options.openThreadId; + if (!openId || merged.some((item) => item.thread.id === openId)) return merged; + + const openItem = [...assistantItems, ...agentItems].find((item) => item.thread.id === openId); + if (!openItem) return merged; + + return [...merged.slice(0, Math.max(options.limit - 1, 0)), openItem]; +} + +/** One source's cursor-paged, already-loaded chat items for {@link mergeChatHistoryPages}. */ +export type ChatHistoryPageSource = { + /** Loaded so far, sorted newest first. */ + items: RecentChatItem[]; + /** Whether this source has further pages it hasn't loaded yet. */ + hasMore: boolean; +}; + +/** + * Merges several cursor-paged chat history sources into one recency-ordered list for the + * "All chats" view's infinite scroll. Stops at the oldest point every still-paging source + * has loaded down to, so nothing shows out of order while a page is still missing. + */ +export function mergeChatHistoryPages(sources: ChatHistoryPageSource[]): RecentChatItem[] { + const stillPaging = sources.filter((source) => source.hasMore); + const cutoff = stillPaging.length + ? Math.max( + ...stillPaging.map((source) => { + const oldest = source.items.at(-1); + return oldest ? Date.parse(oldest.thread.updatedAt) : Infinity; + }), + ) + : -Infinity; + + return sources + .flatMap((source) => source.items) + .filter((item) => Date.parse(item.thread.updatedAt) >= cutoff) + .sort((a, b) => Date.parse(b.thread.updatedAt) - Date.parse(a.thread.updatedAt)); +} diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/n8nChatThreads.store.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/n8nChatThreads.store.test.ts new file mode 100644 index 00000000000..787ddcaf2f4 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/n8nChatThreads.store.test.ts @@ -0,0 +1,154 @@ +import { setActivePinia, createPinia } from 'pinia'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { AgentN8nChatThreadSummary } from '@n8n/api-types'; + +import { useAgentN8nChatThreadsStore } from './n8nChatThreads.store'; + +const mockListN8nChatThreads = vi.fn(); +const mockGetN8nChatThread = vi.fn(); + +vi.mock('@n8n/stores/useRootStore', () => ({ + useRootStore: () => ({ restApiContext: { baseUrl: '/rest', pushRef: 'push-1' } }), +})); + +vi.mock('../composables/useAgentApi', () => ({ + listN8nChatThreads: (...args: unknown[]) => mockListN8nChatThreads(...args), + getN8nChatThread: (...args: unknown[]) => mockGetN8nChatThread(...args), +})); + +const makeThread = (id: string, updatedAt: string): AgentN8nChatThreadSummary => ({ + id, + title: `Thread ${id}`, + updatedAt, + agent: { id: 'agent-1', name: 'Support', projectId: 'project-1' }, +}); + +describe('useAgentN8nChatThreadsStore', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + describe('fetchRecent', () => { + it('loads the first page into recentThreads', async () => { + const threads = [makeThread('1', '2025-01-02T00:00:00.000Z')]; + mockListN8nChatThreads.mockResolvedValueOnce({ data: threads, nextCursor: null }); + const store = useAgentN8nChatThreadsStore(); + + await store.fetchRecent(10); + + expect(mockListN8nChatThreads).toHaveBeenCalledWith( + { baseUrl: '/rest', pushRef: 'push-1' }, + { limit: 10 }, + ); + expect(store.recentThreads).toEqual(threads); + }); + + it('drops a response from an older call that resolves after a newer one', async () => { + const store = useAgentN8nChatThreadsStore(); + let resolveFirst: (value: { data: AgentN8nChatThreadSummary[]; nextCursor: null }) => void; + mockListN8nChatThreads.mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const firstCall = store.fetchRecent(10); + + const secondPage = [makeThread('2', '2025-01-03T00:00:00.000Z')]; + mockListN8nChatThreads.mockResolvedValueOnce({ data: secondPage, nextCursor: null }); + await store.fetchRecent(10); + expect(store.recentThreads).toEqual(secondPage); + + resolveFirst!({ data: [makeThread('1', '2025-01-02T00:00:00.000Z')], nextCursor: null }); + await firstCall; + + expect(store.recentThreads).toEqual(secondPage); + }); + + it('keeps the previous list and does not throw when the request fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const threads = [makeThread('1', '2025-01-02T00:00:00.000Z')]; + mockListN8nChatThreads.mockResolvedValueOnce({ data: threads, nextCursor: null }); + const store = useAgentN8nChatThreadsStore(); + await store.fetchRecent(10); + + mockListN8nChatThreads.mockRejectedValueOnce(new Error('network down')); + await expect(store.fetchRecent(10)).resolves.toBeUndefined(); + + expect(store.recentThreads).toEqual(threads); + }); + }); + + describe('knownThreads', () => { + it('sorts every known thread newest updatedAt first', async () => { + mockListN8nChatThreads.mockResolvedValueOnce({ + data: [makeThread('old', '2025-01-01T00:00:00.000Z')], + nextCursor: null, + }); + mockGetN8nChatThread.mockResolvedValueOnce(makeThread('opened', '2025-01-03T00:00:00.000Z')); + const store = useAgentN8nChatThreadsStore(); + await store.fetchRecent(10); + await store.loadThread('opened'); + + expect(store.knownThreads.map((thread) => thread.id)).toEqual(['opened', 'old']); + }); + }); + + describe('loadThread', () => { + it('fetches a thread missing from recentThreads and adds it', async () => { + const thread = makeThread('1', '2025-01-02T00:00:00.000Z'); + mockGetN8nChatThread.mockResolvedValueOnce(thread); + const store = useAgentN8nChatThreadsStore(); + + await store.loadThread('1'); + + expect(mockGetN8nChatThread).toHaveBeenCalledWith( + { baseUrl: '/rest', pushRef: 'push-1' }, + '1', + ); + expect(store.threadsById.get('1')).toEqual(thread); + expect(store.recentThreads).toEqual([]); + }); + + it('keeps a loaded thread when a later fetchRecent replaces the recent list', async () => { + const opened = makeThread('old', '2024-01-01T00:00:00.000Z'); + mockGetN8nChatThread.mockResolvedValueOnce(opened); + mockListN8nChatThreads.mockResolvedValueOnce({ + data: [makeThread('new', '2025-01-02T00:00:00.000Z')], + nextCursor: null, + }); + const store = useAgentN8nChatThreadsStore(); + + await store.loadThread('old'); + await store.fetchRecent(10); + + expect(store.threadsById.get('old')).toEqual(opened); + }); + + it('does not fetch a thread already in recentThreads', async () => { + const thread = makeThread('1', '2025-01-02T00:00:00.000Z'); + mockListN8nChatThreads.mockResolvedValueOnce({ data: [thread], nextCursor: null }); + const store = useAgentN8nChatThreadsStore(); + await store.fetchRecent(10); + + await store.loadThread('1'); + + expect(mockGetN8nChatThread).not.toHaveBeenCalled(); + }); + + it('swallows errors, keeping recentThreads unchanged — a missing title must not break the page', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const thread1 = makeThread('1', '2025-01-02T00:00:00.000Z'); + mockListN8nChatThreads.mockResolvedValueOnce({ data: [thread1], nextCursor: null }); + mockGetN8nChatThread.mockRejectedValueOnce(new Error('not found')); + const store = useAgentN8nChatThreadsStore(); + await store.fetchRecent(10); + + await expect(store.loadThread('2')).resolves.toBeUndefined(); + + expect(store.recentThreads).toEqual([thread1]); + expect(store.threadsById.has('2')).toBe(false); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/n8nChatThreads.store.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/n8nChatThreads.store.ts new file mode 100644 index 00000000000..ec302b52c29 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/n8nChatThreads.store.ts @@ -0,0 +1,75 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; +import { STORES } from '@n8n/stores/constants'; +import { useRootStore } from '@n8n/stores/useRootStore'; +import type { AgentN8nChatThreadSummary } from '@n8n/api-types'; + +import { getN8nChatThread, listN8nChatThreads } from '../composables/useAgentApi'; + +/** + * The user's own n8n Chat threads across every agent they can reach, for the sidebar's + * "recent chats" list. The "All chats" view's own cursor-paged list lives in + * {@link useMergedChatHistory}, not here, since only that view ever pages through it. + */ +export const useAgentN8nChatThreadsStore = defineStore(STORES.AGENT_N8N_CHAT_THREADS, () => { + const rootStore = useRootStore(); + + const recentThreads = ref([]); + // Threads opened by id that the recent list doesn't hold. Kept apart, so a later + // `fetchRecent` (which replaces `recentThreads`) can't drop them. + const openedThreads = ref([]); + /** Every known thread by id; a fresher `recentThreads` entry wins. */ + const threadsById = computed( + () => + new Map( + [...openedThreads.value, ...recentThreads.value].map((thread) => [thread.id, thread]), + ), + ); + /** Every known thread, newest `updatedAt` first — the single source for callers that + * merge this store's threads into a recency-ordered list (the sidebar, the dropdown). */ + const knownThreads = computed(() => + [...threadsById.value.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)), + ); + + // Dropped responses from an older call never clobber a newer one. + let requestVersion = 0; + + async function fetchRecent(limit: number): Promise { + const version = ++requestVersion; + try { + const result = await listN8nChatThreads(rootStore.restApiContext, { limit }); + if (version !== requestVersion) return; + recentThreads.value = result.data; + } catch (error) { + // Best-effort: keep whatever was already shown rather than clearing the sidebar. + if (version !== requestVersion) return; + console.error('Failed to load n8n Chat threads', error); + } + } + + /** + * Fetch a thread the store does not know yet, e.g. an older one opened + * directly by URL, so the chat history button can still show its title. + * Best-effort: a missing title must not break the page, so this swallows + * every error instead of throwing. + */ + async function loadThread(threadId: string): Promise { + if (threadsById.value.has(threadId)) return; + try { + const thread = await getN8nChatThread(rootStore.restApiContext, threadId); + if (threadsById.value.has(thread.id)) return; + openedThreads.value.push(thread); + } catch (error) { + console.error('Failed to load n8n Chat thread', error); + } + } + + return { + recentThreads, + openedThreads, + threadsById, + knownThreads, + fetchRecent, + loadThread, + }; +}); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/useMergedChatHistory.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/useMergedChatHistory.test.ts new file mode 100644 index 00000000000..4975d194ac9 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/useMergedChatHistory.test.ts @@ -0,0 +1,238 @@ +/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern: @vue/test-utils is a transitive devDep */ +import { defineComponent, ref } from 'vue'; +import { mount } from '@vue/test-utils'; +import { setActivePinia, createPinia } from 'pinia'; + +import { useMergedChatHistory } from './useMergedChatHistory'; + +// A real ref, not a plain object — the composable's `watch(agentThreadsActive, ...)` must +// react to this changing after mount, which a plain `{ value }` object cannot trigger. +const n8nChatFlag = ref(true); +vi.mock('@/features/agents/composables/useAgentsN8nChatFlag', () => ({ + useAgentsN8nChatFlag: () => n8nChatFlag, +})); + +vi.mock('@n8n/stores/useRootStore', () => ({ + useRootStore: () => ({ restApiContext: { baseUrl: '/rest', pushRef: 'push-1' } }), +})); + +const listN8nChatThreadsMock = vi.fn(); +vi.mock('../composables/useAgentApi', () => ({ + listN8nChatThreads: (...args: unknown[]) => listN8nChatThreadsMock(...args), +})); + +const assistantLoadMore = vi.fn(); +const assistantHistory = ref({ + search: '', + threads: [] as Array<{ id: string; title: string; createdAt: string; updatedAt: string }>, + hasMore: false, + loading: false, + error: false, +}); +let capturedSentinelLoadMore: (() => void) | undefined; +let capturedIsExtraSourceLoading: (() => boolean) | undefined; +vi.mock('@/features/ai/instanceAi/composables/useInstanceAiThreadHistory', () => ({ + useInstanceAiThreadHistory: (options?: { + onSentinelLoadMore?: () => void; + isExtraSourceLoading?: () => boolean; + }) => { + capturedSentinelLoadMore = options?.onSentinelLoadMore; + capturedIsExtraSourceLoading = options?.isExtraSourceLoading; + return { + history: assistantHistory, + search: ref(''), + listRef: ref(null), + sentinelRef: ref(null), + loadMore: assistantLoadMore, + }; + }, +})); + +const agentThread = (id: string, updatedAt: string) => ({ + id, + title: `Agent ${id}`, + updatedAt, + agent: { id: 'agent-1', name: 'Support', projectId: 'project-1' }, +}); + +// Lifecycle hooks (onMounted/onBeforeUnmount) only fire inside a real component +// instance, so the composable is exercised through a tiny host component. +function mountComposable() { + const TestComponent = defineComponent({ + setup() { + return useMergedChatHistory(); + }, + template: '
', + }); + return mount(TestComponent); +} + +describe('useMergedChatHistory', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + n8nChatFlag.value = true; + capturedSentinelLoadMore = undefined; + assistantHistory.value = { + search: '', + threads: [], + hasMore: false, + loading: false, + error: false, + }; + // nextCursor non-null by default — hasMore stays true so a follow-up loadMore has + // something to do; tests that need the source exhausted override this. + listN8nChatThreadsMock.mockResolvedValue({ data: [], nextCursor: 'cursor-1' }); + }); + + it('fetches the first agent page on mount', async () => { + mountComposable(); + await vi.waitFor(() => expect(listN8nChatThreadsMock).toHaveBeenCalledTimes(1)); + expect(listN8nChatThreadsMock).toHaveBeenCalledWith( + { baseUrl: '/rest', pushRef: 'push-1' }, + { limit: 30, cursor: undefined }, + ); + }); + + it('starts loading agent threads once the flag turns on after mount', async () => { + n8nChatFlag.value = false; + const wrapper = mountComposable(); + await wrapper.vm.$nextTick(); + expect(listN8nChatThreadsMock).not.toHaveBeenCalled(); + + n8nChatFlag.value = true; + await vi.waitFor(() => expect(listN8nChatThreadsMock).toHaveBeenCalledTimes(1)); + }); + + it('the scroll sentinel loads both sources through the merged loadMore', async () => { + mountComposable(); + await vi.waitFor(() => expect(listN8nChatThreadsMock).toHaveBeenCalledTimes(1)); + listN8nChatThreadsMock.mockClear(); + assistantLoadMore.mockClear(); + assistantHistory.value = { ...assistantHistory.value, hasMore: true }; + + capturedSentinelLoadMore?.(); + + expect(assistantLoadMore).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(listN8nChatThreadsMock).toHaveBeenCalledTimes(1)); + }); + + it('still shows assistant threads and surfaces an error when the agent fetch fails', async () => { + assistantHistory.value = { + ...assistantHistory.value, + threads: [ + { id: 'a1', title: 'Assistant chat', createdAt: '2026-01-01', updatedAt: '2026-01-01' }, + ], + }; + listN8nChatThreadsMock.mockRejectedValueOnce(new Error('network down')); + + const wrapper = mountComposable(); + await vi.waitFor(() => expect(wrapper.vm.error).toBe(true)); + + expect(wrapper.vm.items.map((item: { thread: { id: string } }) => item.thread.id)).toEqual([ + 'a1', + ]); + expect(wrapper.vm.hasMore).toBe(false); + }); + + it('reports the agent fetch as loading so the sentinel re-arms after it', async () => { + let resolveFetch: (() => void) | undefined; + listN8nChatThreadsMock.mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveFetch = () => resolve({ data: [], nextCursor: null }); + }), + ); + + mountComposable(); + await vi.waitFor(() => expect(capturedIsExtraSourceLoading?.()).toBe(true)); + + resolveFetch?.(); + await vi.waitFor(() => expect(capturedIsExtraSourceLoading?.()).toBe(false)); + }); + + it('shows agent threads while the assistant list is empty, still paging or failed', async () => { + assistantHistory.value = { ...assistantHistory.value, hasMore: true, error: true }; + listN8nChatThreadsMock.mockResolvedValueOnce({ + data: [agentThread('g1', '2026-01-02T00:00:00.000Z')], + nextCursor: null, + }); + + const wrapper = mountComposable(); + + await vi.waitFor(() => + expect(wrapper.vm.items.map((item: { thread: { id: string } }) => item.thread.id)).toEqual([ + 'g1', + ]), + ); + }); + + it('makes one agent request per source for concurrent loadMore calls', async () => { + let resolveFetch: (() => void) | undefined; + listN8nChatThreadsMock.mockImplementation( + async () => + await new Promise((resolve) => { + resolveFetch = () => resolve({ data: [], nextCursor: null }); + }), + ); + + // The initial mount's own fetch is already in flight — these must not start more. + const wrapper = mountComposable(); + wrapper.vm.loadMore(); + wrapper.vm.loadMore(); + expect(listN8nChatThreadsMock).toHaveBeenCalledTimes(1); + + resolveFetch?.(); + await vi.waitFor(() => expect(listN8nChatThreadsMock).toHaveBeenCalledTimes(1)); + }); + + it('de-duplicates agent threads by id across pages', async () => { + listN8nChatThreadsMock + .mockResolvedValueOnce({ + data: [agentThread('g1', '2026-01-02T00:00:00.000Z')], + nextCursor: '2026-01-02T00:00:00.000Z', + }) + .mockResolvedValueOnce({ + // Same thread resurfaces on the next page (e.g. activity moved its cursor position). + data: [ + agentThread('g1', '2026-01-02T00:00:00.000Z'), + agentThread('g2', '2026-01-01T00:00:00.000Z'), + ], + nextCursor: null, + }); + + const wrapper = mountComposable(); + await vi.waitFor(() => expect(listN8nChatThreadsMock).toHaveBeenCalledTimes(1)); + wrapper.vm.loadMore(); + await vi.waitFor(() => expect(listN8nChatThreadsMock).toHaveBeenCalledTimes(2)); + + const ids = wrapper.vm.items.map((item: { thread: { id: string } }) => item.thread.id); + expect(ids).toEqual(['g1', 'g2']); + }); + + it('hides already-loaded agent threads once an assistant search is active', async () => { + listN8nChatThreadsMock.mockResolvedValueOnce({ + data: [agentThread('g1', '2026-01-02T00:00:00.000Z')], + nextCursor: null, + }); + const wrapper = mountComposable(); + await vi.waitFor(() => + expect(wrapper.vm.items.map((item: { thread: { id: string } }) => item.thread.id)).toEqual([ + 'g1', + ]), + ); + + assistantHistory.value = { + ...assistantHistory.value, + search: 'x', + threads: [ + { id: 'a1', title: 'Assistant chat', createdAt: '2026-01-01', updatedAt: '2026-01-01' }, + ], + }; + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.items.map((item: { thread: { id: string } }) => item.thread.id)).toEqual([ + 'a1', + ]); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/useMergedChatHistory.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/useMergedChatHistory.ts new file mode 100644 index 00000000000..d4cc82ec11a --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/useMergedChatHistory.ts @@ -0,0 +1,101 @@ +import { computed, onMounted, ref, watch } from 'vue'; + +import { useAgentsN8nChatFlag } from '@/features/agents/composables/useAgentsN8nChatFlag'; +import { useInstanceAiThreadHistory } from '@/features/ai/instanceAi/composables/useInstanceAiThreadHistory'; +import { AGENT_N8N_CHAT_HISTORY_PAGE_SIZE } from '../constants'; +import { mergeChatHistoryPages, type RecentChatItem } from './mergeRecentChats'; +import { usePagedN8nChatThreads } from './usePagedN8nChatThreads'; + +/** + * Loads both chat history sources for the "All chats" view and merges them into one + * recency-ordered, infinite-scrolled list. Assistant paging stays in + * {@link useInstanceAiThreadHistory}; agent paging is the shared + * {@link usePagedN8nChatThreads}, since only this view ever pages through every agent's + * threads at once. + */ +export function useMergedChatHistory() { + const isAgentsN8nChatFlag = useAgentsN8nChatFlag(); + const agent = usePagedN8nChatThreads({ pageSize: AGENT_N8N_CHAT_HISTORY_PAGE_SIZE }); + + const assistant = useInstanceAiThreadHistory({ + onSentinelLoadMore: () => loadMore(), + isExtraSourceLoading: () => agent.isLoading.value, + }); + + const agentThreadsActive = computed( + () => isAgentsN8nChatFlag.value && !assistant.history.value.search, + ); + + // Flips once the agent source's first fetch attempt (success or failure) has + // completed, so the merge cutoff below doesn't treat it as still paging before + // it's even started — which would hide the assistant list behind an infinite + // cutoff (see `mergeChatHistoryPages`). + const agentLoaded = ref(false); + watch(agent.isLoading, (loading) => { + if (!loading) agentLoaded.value = true; + }); + + function fetchNextAgentPage(): void { + if (agentThreadsActive.value) agent.loadNext(); + } + + const items = computed(() => { + const assistantItems: RecentChatItem[] = assistant.history.value.threads.map((thread) => ({ + kind: 'assistant', + thread, + })); + if (!agentThreadsActive.value) return assistantItems; + + const agentItems: RecentChatItem[] = agent.items.value.map((thread) => ({ + kind: 'agent', + thread, + })); + return mergeChatHistoryPages([ + // An unloaded or failed assistant list must not hide the agent threads. + { + items: assistantItems, + hasMore: + assistant.history.value.hasMore && + !assistant.history.value.error && + assistantItems.length > 0, + }, + // Only contributes to the cutoff once its own first page has landed. + { items: agentItems, hasMore: agentLoaded.value && agent.hasMore.value }, + ]); + }); + + const hasMore = computed( + () => assistant.history.value.hasMore || (agentThreadsActive.value && agent.hasMore.value), + ); + const isLoading = computed( + () => assistant.history.value.loading || (agentThreadsActive.value && agent.isLoading.value), + ); + const error = computed( + () => assistant.history.value.error || (agentThreadsActive.value && agent.error.value), + ); + + function loadMore(): void { + assistant.loadMore(); + fetchNextAgentPage(); + } + + // The flag can turn on after mount (a client-evaluated PostHog flag) — start paging + // agent threads the moment it does, rather than only on the initial mount. + watch(agentThreadsActive, (active) => { + if (active && !agentLoaded.value) fetchNextAgentPage(); + }); + + onMounted(fetchNextAgentPage); + + return { + items, + hasMore, + isLoading, + error, + history: assistant.history, + search: assistant.search, + listRef: assistant.listRef, + sentinelRef: assistant.sentinelRef, + loadMore, + }; +} diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/usePagedN8nChatThreads.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/usePagedN8nChatThreads.test.ts new file mode 100644 index 00000000000..d82ef98e2b2 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/usePagedN8nChatThreads.test.ts @@ -0,0 +1,207 @@ +import { defineComponent, ref } from 'vue'; +import { mount } from '@vue/test-utils'; +import type { AgentN8nChatThreadSummary } from '@n8n/api-types'; +import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; + +import { usePagedN8nChatThreads } from './usePagedN8nChatThreads'; + +vi.mock('@n8n/stores/useRootStore', () => ({ + useRootStore: () => ({ restApiContext: { baseUrl: '/rest', pushRef: 'push-1' } }), +})); + +const listN8nChatThreadsMock = vi.fn(); +vi.mock('../composables/useAgentApi', () => ({ + listN8nChatThreads: (...args: unknown[]) => listN8nChatThreadsMock(...args), +})); + +const thread = (id: string, updatedAt = '2026-01-01T00:00:00.000Z') => ({ + id, + title: `Thread ${id}`, + updatedAt, + agent: { id: 'agent-1', name: 'Support', projectId: 'project-1' }, +}); + +// `watch` (used for the `agentId` reset) needs a live effect scope, so the composable +// is exercised through a tiny host component, same pattern as `useMergedChatHistory.test.ts`. +function mountComposable(agentId?: () => string | undefined) { + const TestComponent = defineComponent({ + setup() { + return usePagedN8nChatThreads({ agentId, pageSize: 2 }); + }, + template: '
', + }); + return mount(TestComponent); +} + +describe('usePagedN8nChatThreads', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('loads the first page with no cursor', async () => { + listN8nChatThreadsMock.mockResolvedValue({ data: [thread('t1')], nextCursor: null }); + const wrapper = mountComposable(); + + wrapper.vm.loadNext(); + await vi.waitFor(() => expect(wrapper.vm.items).toEqual([thread('t1')])); + + expect(listN8nChatThreadsMock).toHaveBeenCalledWith(expect.anything(), { + limit: 2, + cursor: undefined, + agentId: undefined, + }); + expect(wrapper.vm.hasMore).toBe(false); + }); + + it('pages using the returned cursor and de-dupes by id', async () => { + listN8nChatThreadsMock + .mockResolvedValueOnce({ data: [thread('t1')], nextCursor: 'cursor-1' }) + .mockResolvedValueOnce({ + // t1 resurfaces (activity moved its cursor position) alongside a new thread. + data: [thread('t1'), thread('t2')], + nextCursor: null, + }); + const wrapper = mountComposable(); + + wrapper.vm.loadNext(); + await vi.waitFor(() => expect(wrapper.vm.items).toHaveLength(1)); + wrapper.vm.loadNext(); + await vi.waitFor(() => expect(wrapper.vm.items).toHaveLength(2)); + + expect(listN8nChatThreadsMock).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ cursor: 'cursor-1' }), + ); + expect(wrapper.vm.items.map((t: { id: string }) => t.id)).toEqual(['t1', 't2']); + }); + + it('does not start a second request while one is in flight', async () => { + let resolveFetch: (() => void) | undefined; + listN8nChatThreadsMock.mockImplementation( + async () => + await new Promise((resolve) => { + resolveFetch = () => resolve({ data: [], nextCursor: null }); + }), + ); + const wrapper = mountComposable(); + + wrapper.vm.loadNext(); + wrapper.vm.loadNext(); + expect(listN8nChatThreadsMock).toHaveBeenCalledTimes(1); + + resolveFetch?.(); + await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); + expect(listN8nChatThreadsMock).toHaveBeenCalledTimes(1); + }); + + it('retrying a failed page re-requests that same page and keeps items already loaded', async () => { + listN8nChatThreadsMock + .mockResolvedValueOnce({ data: [thread('t1')], nextCursor: 'cursor-1' }) + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce({ data: [thread('t2')], nextCursor: null }); + const wrapper = mountComposable(); + + wrapper.vm.loadNext(); + await vi.waitFor(() => expect(wrapper.vm.items).toHaveLength(1)); + wrapper.vm.loadNext(); + await vi.waitFor(() => expect(wrapper.vm.error).toBe(true)); + // The failure didn't drop the first page. + expect(wrapper.vm.items).toEqual([thread('t1')]); + + wrapper.vm.loadNext(); + await vi.waitFor(() => expect(wrapper.vm.items).toHaveLength(2)); + + expect(listN8nChatThreadsMock).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ cursor: 'cursor-1' }), + ); + expect(wrapper.vm.error).toBe(false); + }); + + it('drops a stale response from before a reset', async () => { + let resolveFirst: + | ((value: { data: Array>; nextCursor: null }) => void) + | undefined; + listN8nChatThreadsMock.mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const wrapper = mountComposable(); + wrapper.vm.loadNext(); + + wrapper.vm.reset(); + listN8nChatThreadsMock.mockResolvedValueOnce({ data: [thread('fresh')], nextCursor: null }); + wrapper.vm.loadNext(); + + resolveFirst?.({ data: [thread('stale')], nextCursor: null }); + await vi.waitFor(() => expect(wrapper.vm.items).toEqual([thread('fresh')])); + }); + + it('clears isLoading immediately when reset during a pending request', async () => { + const response = createDeferredPromise<{ + data: AgentN8nChatThreadSummary[]; + nextCursor: string | null; + }>(); + listN8nChatThreadsMock.mockReturnValueOnce(response.promise); + const wrapper = mountComposable(); + wrapper.vm.loadNext(); + await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(true)); + + wrapper.vm.reset(); + expect(wrapper.vm.isLoading).toBe(false); + + // The stale request's own completion must not flip it back on. + response.resolve({ data: [], nextCursor: null }); + await Promise.resolve(); + expect(wrapper.vm.isLoading).toBe(false); + }); + + it('reset keeps the previous items until the fresh first page replaces them', async () => { + listN8nChatThreadsMock.mockResolvedValueOnce({ data: [thread('old')], nextCursor: null }); + const wrapper = mountComposable(); + wrapper.vm.loadNext(); + await vi.waitFor(() => expect(wrapper.vm.items).toEqual([thread('old')])); + + let resolveReload: + | ((value: { data: Array>; nextCursor: null }) => void) + | undefined; + listN8nChatThreadsMock.mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveReload = resolve; + }), + ); + wrapper.vm.reset(); + wrapper.vm.loadNext(); + // Still showing the stale list while the reload is in flight. + expect(wrapper.vm.items).toEqual([thread('old')]); + + resolveReload?.({ data: [thread('new')], nextCursor: null }); + await vi.waitFor(() => expect(wrapper.vm.items).toEqual([thread('new')])); + }); + + it("resets paging and clears the previous agent's items when agentId changes", async () => { + const agentId = ref('agent-1'); + listN8nChatThreadsMock.mockResolvedValue({ data: [thread('t1')], nextCursor: 'cursor-1' }); + const wrapper = mountComposable(() => agentId.value); + wrapper.vm.loadNext(); + await vi.waitFor(() => expect(wrapper.vm.items).toHaveLength(1)); + + agentId.value = 'agent-2'; + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.items).toEqual([]); + expect(wrapper.vm.hasMore).toBe(true); + + listN8nChatThreadsMock.mockClear(); + listN8nChatThreadsMock.mockResolvedValueOnce({ data: [thread('t2')], nextCursor: null }); + wrapper.vm.loadNext(); + await vi.waitFor(() => expect(wrapper.vm.items).toEqual([thread('t2')])); + expect(listN8nChatThreadsMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ cursor: undefined, agentId: 'agent-2' }), + ); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/usePagedN8nChatThreads.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/usePagedN8nChatThreads.ts new file mode 100644 index 00000000000..0c8e421cc93 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/usePagedN8nChatThreads.ts @@ -0,0 +1,108 @@ +import { ref, toValue, watch, type MaybeRefOrGetter } from 'vue'; +import type { AgentN8nChatThreadSummary } from '@n8n/api-types'; +import { useRootStore } from '@n8n/stores/useRootStore'; + +import { listN8nChatThreads } from '../composables/useAgentApi'; + +export type UsePagedN8nChatThreadsOptions = { + /** Narrows every page to one agent's threads. Changing it resets paging. */ + agentId?: MaybeRefOrGetter; + pageSize: number; +}; + +/** + * Cursor-paged, de-duplicated n8n Chat thread list shared by the agent chat page's + * "all chats" merge ({@link useMergedChatHistory}) and its per-agent history dropdown + * (`N8nChatThreadHistory`). + */ +export function usePagedN8nChatThreads(options: UsePagedN8nChatThreadsOptions) { + const rootStore = useRootStore(); + + const items = ref([]); + const hasMore = ref(true); + const isLoading = ref(false); + const error = ref(false); + + let cursor: string | undefined; + let requestVersion = 0; + let inFlight = false; + // Set by reset(), until the next successful page lands — that page replaces + // `items` outright instead of appending, so a reopen can keep showing the + // stale list while the fresh first page loads in the background. + let replaceOnNextPage = false; + + async function fetchPage(version: number): Promise { + if (inFlight) return; + inFlight = true; + isLoading.value = true; + const requestCursor = cursor; + const replacing = replaceOnNextPage; + try { + const result = await listN8nChatThreads(rootStore.restApiContext, { + limit: options.pageSize, + cursor: requestCursor, + agentId: toValue(options.agentId), + }); + if (version !== requestVersion) return; + const base = replacing ? [] : items.value; + const seenIds = new Set(base.map((thread) => thread.id)); + const freshItems = result.data.filter((thread) => !seenIds.has(thread.id)); + items.value = [...base, ...freshItems]; + cursor = result.nextCursor ?? undefined; + hasMore.value = result.nextCursor !== null; + error.value = false; + replaceOnNextPage = false; + } catch { + if (version !== requestVersion) return; + error.value = true; + // A source that failed isn't "still paging" to a caller merging it with others + // (e.g. `useMergedChatHistory`) — `loadNext()` below still allows a retry despite + // this, and the cursor is left untouched, so that retry re-requests this same page. + hasMore.value = false; + } finally { + // Guarded like `isLoading`: a stale request's own completion must not touch + // `inFlight` once `reset()` has moved on, or it could clear it out from under + // the newer request that `reset()` unblocked. + if (version === requestVersion) { + inFlight = false; + isLoading.value = false; + } + } + } + + /** Loads the next page, or retries the last one after it failed. No-op while a page is already loading or none remain (a failure never counts as "none remain" — it still allows a retry). */ + function loadNext(): void { + if (inFlight || (!hasMore.value && !error.value)) return; + void fetchPage(requestVersion); + } + + /** + * Drops any in-flight response and rearms for a fresh first page — call + * `loadNext()` after it to actually fetch. Does not clear `items`: the + * caller can keep showing the previous list until the fresh page replaces it. + */ + function reset(): void { + requestVersion += 1; + cursor = undefined; + hasMore.value = true; + error.value = false; + replaceOnNextPage = true; + inFlight = false; + // A pending request's own `finally` is version-guarded and skips clearing this, + // so clear it here or a reset during a fetch leaves `isLoading` stuck true. + isLoading.value = false; + } + + if (options.agentId !== undefined) { + watch( + () => toValue(options.agentId), + () => { + reset(); + // Unlike a reopen, another agent's threads must not show while the new page loads. + items.value = []; + }, + ); + } + + return { items, hasMore, isLoading, error, loadNext, reset }; +} diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/useRecentChats.test.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/useRecentChats.test.ts new file mode 100644 index 00000000000..4866cd8b4f3 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/useRecentChats.test.ts @@ -0,0 +1,46 @@ +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; +import { effectScope } from 'vue'; +import type { AgentN8nChatThreadSummary } from '@n8n/api-types'; + +import { useInstanceAiStore } from '@/features/ai/instanceAi/instanceAi.store'; +import { useAgentN8nChatThreadsStore } from './n8nChatThreads.store'; +import { useRecentChats } from './useRecentChats'; + +vi.mock('vue-router', async () => { + const actual = await vi.importActual('vue-router'); + return { ...actual, useRoute: () => ({ name: undefined, params: {} }) }; +}); + +vi.mock('../composables/useAgentsN8nChatFlag', () => ({ + useAgentsN8nChatFlag: () => ({ value: true }), +})); + +const makeThread = (id: string, updatedAt: string): AgentN8nChatThreadSummary => ({ + id, + title: `Thread ${id}`, + updatedAt, + agent: { id: 'agent-1', name: 'Support', projectId: 'project-1' }, +}); + +describe('useRecentChats', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })); + }); + + it('includes a thread opened by URL that only lives in openedThreads, not recentThreads', () => { + const instanceAiStore = useInstanceAiStore(); + instanceAiStore.threads = []; + const agentThreadsStore = useAgentN8nChatThreadsStore(); + agentThreadsStore.recentThreads = [makeThread('recent', '2025-01-02T00:00:00.000Z')]; + agentThreadsStore.openedThreads = [makeThread('opened', '2025-01-03T00:00:00.000Z')]; + + const scope = effectScope(); + const { recentChats } = scope.run(() => useRecentChats())!; + + const ids = recentChats.value.map((item) => item.thread.id); + expect(ids).toEqual(['opened', 'recent']); + + scope.stop(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/useRecentChats.ts b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/useRecentChats.ts new file mode 100644 index 00000000000..2d1a068712b --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/useRecentChats.ts @@ -0,0 +1,43 @@ +import { computed } from 'vue'; +import { useRoute } from 'vue-router'; + +import { INSTANCE_AI_THREAD_VIEW } from '@/features/ai/instanceAi/constants'; +import { useInstanceAiStore } from '@/features/ai/instanceAi/instanceAi.store'; +import { useAgentsN8nChatFlag } from '../composables/useAgentsN8nChatFlag'; +import { AGENT_N8N_CHAT_VIEW, RECENT_CHATS_LIMIT } from '../constants'; +import { useAgentN8nChatThreadsStore } from './n8nChatThreads.store'; +import { mergeRecentChats, type RecentChatItem } from './mergeRecentChats'; + +const asRouteParam = (value: unknown): string | undefined => + typeof value === 'string' ? value : undefined; + +/** + * The sidebar's recent-chats list: Instance AI threads only when the n8n Chat flag is + * off, merged with agent n8n Chat threads when it's on — one place for callers (the + * sidebar) to read instead of branching on the flag themselves. + */ +export function useRecentChats() { + const route = useRoute(); + const isAgentsN8nChatFlag = useAgentsN8nChatFlag(); + const instanceAiStore = useInstanceAiStore(); + const agentThreadsStore = useAgentN8nChatThreadsStore(); + + const openThreadId = computed(() => { + if (route.name === INSTANCE_AI_THREAD_VIEW) return asRouteParam(route.params.threadId); + if (route.name === AGENT_N8N_CHAT_VIEW) return asRouteParam(route.params.agentThreadId); + return undefined; + }); + + const recentChats = computed(() => + mergeRecentChats( + instanceAiStore.threads, + isAgentsN8nChatFlag.value ? agentThreadsStore.knownThreads : [], + { + limit: RECENT_CHATS_LIMIT, + openThreadId: openThreadId.value, + }, + ), + ); + + return { recentChats }; +} diff --git a/packages/frontend/editor-ui/src/features/agents/utils/errors.ts b/packages/frontend/editor-ui/src/features/agents/utils/errors.ts new file mode 100644 index 00000000000..8af95b2d4ce --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/utils/errors.ts @@ -0,0 +1,9 @@ +/** True for a `ResponseError`-shaped 404, as the agent REST endpoints throw it. */ +export function isNotFoundError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'httpStatusCode' in error && + error.httpStatusCode === 404 + ); +} diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue index 1ba65ba56be..3d24c403149 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue @@ -149,6 +149,7 @@ import { type BudgetAmountField, } from '../utils/budget-config'; import { hasBlockingIssues } from '../utils/validationIssues'; +import { isNotFoundError } from '../utils/errors'; const props = withDefaults( defineProps<{ @@ -2681,15 +2682,6 @@ watch( { immediate: true }, ); -function isNotFoundError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'httpStatusCode' in error && - error.httpStatusCode === 404 - ); -} - const pendingPreviewValidations = new Set(); async function ensurePreviewSessionAvailable(sessionId: string) { if (previewSessionsLoading.value || currentSessionIsLocallyMinted.value) return; diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue index 78e2ec004dc..59d51e86566 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue @@ -25,6 +25,7 @@ import { useAgentExecutionUpdates } from '@/features/agents/composables/useAgent import { getAgent } from '@/features/agents/composables/useAgentApi'; import { useAgentConfig } from '@/features/agents/composables/useAgentConfig'; import { useAgentPermissions } from '@/features/agents/composables/useAgentPermissions'; +import { useBackOrFallback } from '@/features/agents/composables/useBackOrFallback'; import type { AgentResource } from '@/features/agents/types'; import { useRootStore } from '@n8n/stores/useRootStore'; import { useI18n } from '@n8n/i18n'; @@ -101,6 +102,8 @@ const triggerIcon = computed((): IconName => { return 'slack'; case 'instance-ai': return 'sparkles'; + case 'n8n_chat_production': + return 'message-square'; default: return 'bolt-filled'; } @@ -116,6 +119,9 @@ const triggerLabel = computed((): string => { if (source === 'instance-ai') { return i18n.baseText('agentSessions.origin.instanceAi'); } + if (source === 'n8n_chat_production') { + return i18n.baseText('agentSessions.origin.n8nChat'); + } return source.charAt(0).toUpperCase() + source.slice(1); }); @@ -304,22 +310,9 @@ function formatDate(fullDate: string): string { return `${date} ${time}`; } -function closeTimeline() { - /** - * Get the last visited route from Vue router so we return to the correct starting point (e.g Preview) - * If no state is available, it's most likey because the link was visited directly. - * Here we fallback to default Agents view. - */ - const previousRoute = router.options.history.state.back; - const resolvedPreviousRoute = - typeof previousRoute === 'string' ? router.resolve(previousRoute) : null; - - if (resolvedPreviousRoute?.matched.length) { - router.back(); - return; - } - void router.push(agentExecutionsRoute.value); -} +// Returns to the correct starting point (e.g. Preview) when there is one; +// otherwise falls back to the agent's executions tab, e.g. for a direct visit. +const closeTimeline = useBackOrFallback(agentExecutionsRoute); function onBreadcrumbSelect(item: PathItem) { if (item.id === projectId.value) { diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue index 4b9e5035b92..13255190a50 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue @@ -151,6 +151,8 @@ function originPresentation(thread: AgentExecutionThread): OriginPresentation { return { icon: 'flask-conical', label: i18n.baseText('agentSessions.origin.mcp') }; case 'workflow': return { icon: 'workflow', label: i18n.baseText('agentSessions.origin.workflow') }; + case 'n8n_chat_production': + return { icon: 'message-square', label: i18n.baseText('agentSessions.origin.n8nChat') }; case 'slack': case 'telegram': case 'linear': diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/SkeletonAgentCard.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/SkeletonAgentCard.vue index 02e449dddae..ea8e686c658 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/SkeletonAgentCard.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/SkeletonAgentCard.vue @@ -1,3 +1,7 @@ + + @@ -39,8 +43,8 @@ --animation--skeleton-pulse--opacity-start: 0.6; --animation--skeleton-pulse--opacity-end: 0.3; - width: 24px; - height: 24px; + width: var(--skeleton-agent-card-avatar-size, var(--spacing--lg)); + height: var(--skeleton-agent-card-avatar-size, var(--spacing--lg)); border-radius: 50%; flex-shrink: 0; background: var(--color--foreground); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadsView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadsView.vue index 943e52b2872..f9b732f56a3 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadsView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadsView.vue @@ -17,19 +17,37 @@ import type { InstanceAiThreadSummary } from '@n8n/api-types'; import PageViewLayout from '@/app/components/layouts/PageViewLayout.vue'; import { useDocumentTitle } from '@/app/composables/useDocumentTitle'; import { formatTimeAgo } from '@/app/utils/formatters/dateFormatter'; +import { useAgentsN8nChatFlag } from '@/features/agents/composables/useAgentsN8nChatFlag'; +import RecentChatIcon from '@/features/agents/n8nChatPage/components/RecentChatIcon.vue'; +import { useMergedChatHistory } from '@/features/agents/n8nChatPage/useMergedChatHistory'; +import { + chatItemRoute, + chatItemTitle, + type RecentChatItem, +} from '@/features/agents/n8nChatPage/mergeRecentChats'; import { clearPendingThreadHandoff } from './composables/useInstanceAiHandoff'; -import { useInstanceAiThreadHistory } from './composables/useInstanceAiThreadHistory'; -import { INSTANCE_AI_THREAD_VIEW, INSTANCE_AI_VIEW } from './constants'; +import { INSTANCE_AI_VIEW } from './constants'; import { useInstanceAiStore } from './instanceAi.store'; const store = useInstanceAiStore(); const i18n = useI18n(); const router = useRouter(); const toast = useToast(); +const isAgentsN8nChatFlag = useAgentsN8nChatFlag(); useDocumentTitle().set(i18n.baseText('instanceAi.sidebar.chatHistory')); -const { history, search, listRef, sentinelRef, loadMore } = useInstanceAiThreadHistory(); +const { + items: mergedThreads, + hasMore, + history, + isLoading, + error, + search, + listRef, + sentinelRef, + loadMore, +} = useMergedChatHistory(); const editingThreadId = ref(null); const editingTitle = ref(''); const renameInput = ref(null); @@ -39,6 +57,12 @@ const threadActions: Array> = [ { id: 'delete', label: i18n.baseText('instanceAi.sidebar.deleteThread'), icon: 'trash-2' }, ]; +function itemTitle(item: RecentChatItem): string { + return chatItemTitle(item, i18n); +} + +const itemRoute = chatItemRoute; + function startRename(thread: InstanceAiThreadSummary) { editingThreadId.value = thread.id; editingTitle.value = thread.title; @@ -111,38 +135,44 @@ async function handleThreadAction(action: string, thread: InstanceAiThreadSummar