diff --git a/.boundaries-baseline.json b/.boundaries-baseline.json index ee2a2d16fb2..ee5357a106f 100644 --- a/.boundaries-baseline.json +++ b/.boundaries-baseline.json @@ -1,3 +1,3 @@ { - "issues": 141 + "issues": 141 } diff --git a/.devcontainer/codespaces/README.md b/.devcontainer/codespaces/README.md index 4723dc1ecd3..ffa1509631e 100644 --- a/.devcontainer/codespaces/README.md +++ b/.devcontainer/codespaces/README.md @@ -2,7 +2,7 @@ Run long-lived, human-steered agent sessions (Claude Code / OpenCode) on a GitHub Codespace instead of your laptop: start a task, close the lid, steer it -from anywhere with a terminal, resume tomorrow. +from anywhere, resume tomorrow. This is a separate devcontainer config from the laptop one in `.devcontainer/` — it ships both agent CLIs, `tmux` for session persistence, @@ -11,17 +11,61 @@ Playwright system deps, and Docker-in-Docker (for testcontainers and ## One-time setup (~5 min) -1. Add provider keys at [github.com/settings/codespaces](https://github.com/settings/codespaces). - Add `ANTHROPIC_API_KEY` for Claude Code. Add `OPENROUTER_API_KEY` for OpenCode. - Give both secrets access to `n8n-io/n8n`. - (Alternative for Max subscriptions: `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`.) -2. Give the GitHub CLI the codespace scope: +1. Give the GitHub CLI the codespace scope: ```bash gh auth refresh -h github.com -s codespace ``` -## Daily flow +2. Add provider keys at [github.com/settings/codespaces](https://github.com/settings/codespaces) + if your client needs them. Give each secret access to `n8n-io/n8n`. + - `ANTHROPIC_API_KEY` for Claude Code in the terminal (`pnpm session`). + Alternative for Max subscriptions: `CLAUDE_CODE_OAUTH_TOKEN` from + `claude setup-token`. The desktop app does not need either secret. + - `OPENROUTER_API_KEY` for OpenCode. + +## Choose a client + +All clients run the agent, its tools, and builds in the Codespace. Your laptop +shows the conversation only. + +| Client | Command | Use it when | +|---|---|---| +| Claude Code desktop app | `pnpm session ssh-config` once | You want the full desktop experience. | +| Claude Code in VS Code | Open the codespace in VS Code or the browser | You already work in VS Code. | +| Claude Code in a terminal | `pnpm session` | You want a plain terminal, or you are on a remote machine. | +| OpenCode | `pnpm session:opencode` | You use OpenCode. See [Local OpenCode clients](#local-opencode-clients). | + +### Claude Code desktop app + +1. From a local checkout, add an SSH host for your codespace: + + ```bash + pnpm session ssh-config + ``` + + The command creates or starts the codespace. It writes the host + `n8n-codespace` to `~/.ssh/n8n-codespace.conf` and includes that file from + `~/.ssh/config`. +2. In the desktop app, add an SSH connection to the host `n8n-codespace`. +3. Open the folder `/workspaces/n8n`, or a worktree under `/workspaces`. + +The desktop app installs its own Claude Code on the codespace and signs in +with your desktop account. Sessions continue when you close the app. They stop +when the codespace stops. + +- **Run `pnpm session ssh-config` again after you recreate the codespace.** +- **If the connection times out**, the codespace is probably starting. Run + `pnpm session ssh-config`, then connect again. +- **Test the connection** with `ssh n8n-codespace`. + +### Claude Code in VS Code + +The dev container installs the Claude Code extension. Open the codespace in +VS Code or the browser, then open the Claude Code panel. Sign in from the +extension if you did not add `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`. + +### Claude Code in a terminal ```bash pnpm session # attach Claude Code (creates everything on first run) @@ -33,6 +77,7 @@ pnpm session:opencode --legacy # use the remote TUI in tmux pnpm session fix-flaky # Claude Code in a separate worktree pnpm session ls # what's running pnpm session tunnel # forward n8n ports (default 5678, 8080); Ctrl-C to stop +pnpm session ssh-config # add the n8n-codespace SSH host for the desktop app pnpm session stop # end of day: billing stops, disk survives pnpm session rm # delete the codespace ``` @@ -332,11 +377,15 @@ session rarely needs a cold `pnpm install` or a full `pnpm build`. Both are slow Claude sessions and the headless OpenCode worker get the `flaky` MCP server automatically: Currents flaky/quarantine data, the `qa_*` BigQuery dataset, Sentry RCA, live Linear, and repo investigation. The worker keeps the token in its environment and puts -only an environment reference in the OpenCode config. Claude login registers -the same server without writing the token to disk. Forks have no secrets and +only an environment reference in the OpenCode config. Forks have no secrets and skip it. Tell the agent to call `get_flaky_context` first — it returns the rules the tools assume. +`post-start.mjs` registers the server for Claude Code on each container start. +Its `headersHelper` reads the token from the secrets file on each connect, so +the server works in every client. The token is not copied into the Claude Code +MCP configuration. + ## Quality and security skills (Claude plugins) Claude sessions can also load the private skills from the @@ -398,7 +447,8 @@ to pull the skills into context. ## Viewing the dev UI locally -Two terminal windows: +`pnpm session tunnel` works with every client. With the terminal client, use +two terminal windows: ```bash pnpm session # window 1: attach the agent session @@ -448,10 +498,10 @@ After a stop, `pnpm session ` restarts the codespace (~30–60 s); run agent injects them into VS Code sessions only; they're delivered base64-encoded to `/workspaces/.codespaces/shared/.env-secrets`. The image sources `/usr/local/lib/codespaces-env.sh` in login shells (profile.d), in - interactive shells (bashrc), and in the `pnpm session` prelude. If Claude - Code shows `Missing environment variables: FLAKY_MCP_TOKEN`, the shell that - started Claude did not source the file. Run - `. /usr/local/lib/codespaces-env.sh` and start Claude again. + interactive shells (bashrc), and in the `pnpm session` prelude. Desktop app + sessions do not source it. `git`, `gh`, `scripts/codespace-env.mjs`, and the + `flaky` MCP entry read the file when they run, so they work everywhere. New + code that needs a secret must do the same. - **Do not read `CODESPACE_NAME` or `GITHUB_USER` from the process env** — use `scripts/codespace-env.mjs`. Codespaces gives these variables to VS Code sessions only. Other processes read them from `codespaces-env.sh`, and a @@ -460,7 +510,7 @@ After a stop, `pnpm session ` restarts the codespace (~30–60 s); run polled correctly as its owner while `dev:up` in the same session saw an empty box name, printed the localhost URL, and did not share the port. The helper reads `/workspaces/.codespaces/shared`, which is always correct. -- **You cannot paste images into a remote Claude session.** Image paste reads +- **You cannot paste images into a terminal Claude session.** Image paste reads the clipboard of the machine where `claude` runs — the codespace, not your laptop. Drag the file into the VS Code explorer (or `gh codespace cp shot.png remote:/workspaces/n8n/`) and give Claude the diff --git a/.devcontainer/codespaces/codespaces-secrets.sh b/.devcontainer/codespaces/codespaces-secrets.sh index 4333a1d3ea8..e5eb4d7f382 100644 --- a/.devcontainer/codespaces/codespaces-secrets.sh +++ b/.devcontainer/codespaces/codespaces-secrets.sh @@ -12,14 +12,6 @@ '{"hasCompletedOnboarding":true,"theme":"dark","projects":{"/workspaces/n8n":{"hasTrustDialogAccepted":true}}}' \ >"$HOME/.claude.json" -# Register the Flaky MCP server for Claude Code. The config keeps a literal -# ${FLAKY_MCP_TOKEN}; Claude Code expands it at connect time, so the token is -# not written to disk. Forks have no repo secrets and skip this. -if [ -n "$FLAKY_MCP_URL" ] && ! grep -qs '"flaky"' "$HOME/.claude.json" && command -v claude >/dev/null 2>&1; then - claude mcp add --scope user --transport http flaky "$FLAKY_MCP_URL" \ - --header 'Authorization: Bearer ${FLAKY_MCP_TOKEN}' >/dev/null 2>&1 || true -fi - # Register the credential helper in the user config, because Codespaces # regenerates the managed /etc/gitconfig. When the env token is missing, the # system helper exits 0 without output, and git falls through to ours. diff --git a/.devcontainer/codespaces/devcontainer.json b/.devcontainer/codespaces/devcontainer.json index 125ec2839b5..d2ce63fde5d 100644 --- a/.devcontainer/codespaces/devcontainer.json +++ b/.devcontainer/codespaces/devcontainer.json @@ -18,9 +18,13 @@ "ghcr.io/devcontainers/features/docker-in-docker:2": {} }, "forwardPorts": [8080, 5678], - // Grant the Codespace token read access to the private skills and harness repositories. - // Each user authorizes this access once when they create the Codespace. "customizations": { + // Claude Code in VS Code or the browser editor. It runs in the codespace. + "vscode": { + "extensions": ["anthropic.claude-code"] + }, + // Grant the Codespace token read access to the private skills and harness repositories. + // Each user authorizes this access once when they create the Codespace. "codespaces": { "repositories": { "n8n-io/n8n-agent-skills": { "permissions": { "contents": "read" } }, diff --git a/.devcontainer/codespaces/post-start.mjs b/.devcontainer/codespaces/post-start.mjs index 041f542282e..2e49c44d6a7 100644 --- a/.devcontainer/codespaces/post-start.mjs +++ b/.devcontainer/codespaces/post-start.mjs @@ -1,10 +1,11 @@ #!/usr/bin/env node // Runs on each Codespace start. Installs the skills and harness, then starts the worker. import { execFileSync } from 'node:child_process'; -import { rmSync, writeFileSync } from 'node:fs'; +import { readFileSync, rmSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { installAgentHarness } from '../../scripts/agent-harness.mjs'; +import { codespaceSecret } from '../../scripts/codespace-env.mjs'; import { MARKETPLACE, PLUGINS } from './plugins.mjs'; const STATUS_FILE = '/tmp/post-start-status.json'; @@ -49,6 +50,42 @@ function addMarketplace() { return add('marketplace add (retry)'); } +// Claude Code reads the Flaky token when it connects. An env reference such as +// ${FLAKY_MCP_TOKEN} fails in processes that did not load the secrets file, for +// example the desktop app's SSH server. The helper reads the file on each connect, +// so it also gets a rotated token. The token is not copied into ~/.claude.json. +const FLAKY_HEADERS_HELPER = + '. /usr/local/lib/codespaces-env.sh; printf %s "{\\"Authorization\\":\\"Bearer $FLAKY_MCP_TOKEN\\"}"'; + +function registerFlakyMcp() { + // Forks have no repository secrets. + const url = codespaceSecret('FLAKY_MCP_URL'); + if (!url || !codespaceSecret('FLAKY_MCP_TOKEN')) return; + + let current; + try { + current = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8')).mcpServers?.flaky; + } catch { + // No readable config yet: register the server below. + } + if (current?.url === url && current.headersHelper === FLAKY_HEADERS_HELPER && !current.headers) { + return; + } + + if (current) tryRun('flaky mcp remove', 'claude', ['mcp', 'remove', '--scope', 'user', 'flaky']); + const config = { type: 'http', url, headersHelper: FLAKY_HEADERS_HELPER }; + tryRun('flaky mcp add', 'claude', [ + 'mcp', + 'add-json', + '--scope', + 'user', + 'flaky', + JSON.stringify(config), + ]); +} + +registerFlakyMcp(); + tryRun('skills repo reachable', 'git', ['ls-remote', `https://github.com/${MARKETPLACE}`, 'HEAD']); const installed = []; diff --git a/.devcontainer/codespaces/ssh-config.test.mjs b/.devcontainer/codespaces/ssh-config.test.mjs new file mode 100644 index 00000000000..c60b7c752a9 --- /dev/null +++ b/.devcontainer/codespaces/ssh-config.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { aliasHostConfig, withInclude } from '../../scripts/cloud-session-ssh-config.mjs'; + +const GH_CONFIG = `Host cs.ominous-doodle.master + User node + ProxyCommand gh cs ssh -c ominous-doodle --stdio + UserKnownHostsFile=/dev/null +`; + +test('replaces the gh host name with the fixed alias', () => { + assert.equal( + aliasHostConfig(GH_CONFIG), + GH_CONFIG.replace('cs.ominous-doodle.master', 'n8n-codespace'), + ); +}); + +test('rejects gh output without exactly one proxied host', () => { + assert.throws(() => aliasHostConfig('')); + assert.throws(() => aliasHostConfig(`${GH_CONFIG}Host other\n`)); + assert.throws(() => aliasHostConfig('Host cs.x\n\tUser node\n')); +}); + +test('adds the Include line at the top once', () => { + const include = 'Include ~/.ssh/n8n-codespace.conf'; + assert.equal(withInclude(''), `${include}\n`); + + const updated = withInclude('Host github.com\n\tUser git\n'); + assert.equal(updated, `${include}\n\nHost github.com\n\tUser git\n`); + assert.equal(withInclude(updated), undefined); +}); diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 837d17cb7e1..89d63020876 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -23,6 +23,7 @@ on: paths: - '**/package.json' - '**/turbo.json' + - 'packages/cli/scripts/build.mjs' - '.github/workflows/build-windows.yml' - '.github/actions/setup-nodejs/**' diff --git a/.gitignore b/.gitignore index edcd2941646..8b7b6488987 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ build.log tcr-dry.log .agent-setup/ .agent-recordings/ +.vitest-profile/ .pi-subagents/ sbom-source.cdx.json *.junit.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 8773d5f32ed..57d1558a9a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,132 @@ +# [2.42.0](https://github.com/n8n-io/n8n/compare/n8n@2.41.0...n8n@2.42.0) (2026-09-29) + + +### Bug Fixes + +* **AI Agent Node:** Let an AI Agent Tool use its own tools under a pre-v3 parent agent ([#39160](https://github.com/n8n-io/n8n/issues/39160)) ([60d0598](https://github.com/n8n-io/n8n/commit/60d0598a69ca75ebddb203163fbaa0e2fcb78ae0)) +* Align MCP workflow headings and remove outdated notice ([#39463](https://github.com/n8n-io/n8n/issues/39463)) ([b096147](https://github.com/n8n-io/n8n/commit/b096147894f6d77b771fe3edc14c6ccd18e26c50)) +* **API:** Accept workflow descriptions on creation ([#39705](https://github.com/n8n-io/n8n/issues/39705)) ([6c1edac](https://github.com/n8n-io/n8n/commit/6c1edac204abb4d241850fbeef95ea1ea720fc5c)) +* **API:** Allow extendsCredential in workflow create/update ([#39243](https://github.com/n8n-io/n8n/issues/39243)) ([9e7bc66](https://github.com/n8n-io/n8n/commit/9e7bc6691c2feee43fbe549010462e3f40ee6261)) +* **API:** Validate OpenAPI path parameter names ([#39067](https://github.com/n8n-io/n8n/issues/39067)) ([a2a2638](https://github.com/n8n-io/n8n/commit/a2a26384611b72fd682439036b11bd2b1d78924f)) +* **Azure OpenAI Chat Model Node:** Make the Azure Entra credential sign in as the app ([#39377](https://github.com/n8n-io/n8n/issues/39377)) ([2f98090](https://github.com/n8n-io/n8n/commit/2f98090707df0366543d79bddc8040b7d0c0a9d7)) +* **core:** Close Chat integration runtime on shutdown and let followers send ([#38958](https://github.com/n8n-io/n8n/issues/38958)) ([3628dc1](https://github.com/n8n-io/n8n/commit/3628dc1f04d5134d9a8eb861b8ba440e464fee2d)) +* **core:** Count a database ping as successful when its reply arrives during event-loop lag ([#39645](https://github.com/n8n-io/n8n/issues/39645)) ([bda9453](https://github.com/n8n-io/n8n/commit/bda94539539e77fce4c42a6086a8c187ee7b8de3)) +* **core:** Enforce one instance report per day with a unique index ([#39536](https://github.com/n8n-io/n8n/issues/39536)) ([20807d2](https://github.com/n8n-io/n8n/commit/20807d2d3d6b25d6df2e4b5b502c18bca784180a)) +* **core:** Gate the AI Assistant project list on the team-projects license ([#39235](https://github.com/n8n-io/n8n/issues/39235)) ([dec7c71](https://github.com/n8n-io/n8n/commit/dec7c712c7b9f565b3ba0176c3ca65ec7dfdea04)) +* **core:** Keep one failed binary-data dir from aborting the delete batch ([#39194](https://github.com/n8n-io/n8n/issues/39194)) ([34f77c0](https://github.com/n8n-io/n8n/commit/34f77c059b5a1176e5299c225fc3979125fba6d0)) +* **core:** Keep OpenTelemetry export working after a restart when Sentry is enabled ([#38480](https://github.com/n8n-io/n8n/issues/38480)) ([dbefd92](https://github.com/n8n-io/n8n/commit/dbefd920b5d82ecd9900fb4e129e1fe342078045)) +* **core:** Link MCP workflow access errors to workflow settings ([#39738](https://github.com/n8n-io/n8n/issues/39738)) ([9f5c752](https://github.com/n8n-io/n8n/commit/9f5c752cd179d52a91459dc98871c026fce49b51)) +* **core:** Record live n8n Assistant test runs as verification evidence ([#39361](https://github.com/n8n-io/n8n/issues/39361)) ([fb35797](https://github.com/n8n-io/n8n/commit/fb357977b5dfc7b1a88890a2b1bc76618a792983)) +* **core:** Release the expression isolate when the webhook response ends ([#39138](https://github.com/n8n-io/n8n/issues/39138)) ([82cd04c](https://github.com/n8n-io/n8n/commit/82cd04c9a6af23483728c6dd3c8ca34d8520974d)) +* **core:** Remove checkpoints when deleting agent sessions ([#39175](https://github.com/n8n-io/n8n/issues/39175)) ([6a678f0](https://github.com/n8n-io/n8n/commit/6a678f02a011c033afea3d7a71c847b8854417d0)) +* **core:** Remove feature flag for MCP Registry in AI Assistant ([#39018](https://github.com/n8n-io/n8n/issues/39018)) ([ecfec19](https://github.com/n8n-io/n8n/commit/ecfec194b481568d1d1de954068de320b5cf803b)) +* **core:** Restore pubsub subscriptions after Redis reconnect and detect half-open subscriber connections ([#39174](https://github.com/n8n-io/n8n/issues/39174)) ([3f76dcc](https://github.com/n8n-io/n8n/commit/3f76dccc32a204a48422fbc40af7f951d4c367f5)) +* **core:** Resume only private sessions in Preview ([#39151](https://github.com/n8n-io/n8n/issues/39151)) ([dda9a56](https://github.com/n8n-io/n8n/commit/dda9a56cb7faa6610d7f36b3870015a08f091ae9)) +* **core:** Retry each file removal in the byte store's delete ([#39193](https://github.com/n8n-io/n8n/issues/39193)) ([7bebbe4](https://github.com/n8n-io/n8n/commit/7bebbe403358313f4de843d8237ad5dceec9fd9f)) +* **core:** Retry instance reports that cross the UTC midnight boundary ([#39290](https://github.com/n8n-io/n8n/issues/39290)) ([f914770](https://github.com/n8n-io/n8n/commit/f9147709a639cb01cc209d3157945d53673216ca)) +* **core:** Retry the recursive removal of an execution's binary-data directory ([#39185](https://github.com/n8n-io/n8n/issues/39185)) ([3a31c38](https://github.com/n8n-io/n8n/commit/3a31c3875146cd5d728ad31d3e01d13a7ca8c0cb)) +* **core:** Return test webhook response when the call lands on another main ([#39382](https://github.com/n8n-io/n8n/issues/39382)) ([73bdae5](https://github.com/n8n-io/n8n/commit/73bdae55c655c4500a3054469c3443813469c7b4)) +* **core:** Stop retrying instance reports the receiver rejects with 400 or 413 ([#39502](https://github.com/n8n-io/n8n/issues/39502)) ([d9c9d6e](https://github.com/n8n-io/n8n/commit/d9c9d6e616eb3ef63e9077de142556508aa853df)) +* **core:** Store queue job results only for executions this process enqueued ([#39712](https://github.com/n8n-io/n8n/issues/39712)) ([5d508ec](https://github.com/n8n-io/n8n/commit/5d508ec1e1e75d51919bd38d32344846fff8ea9e)) +* **core:** Use Sessions tab terminology in n8n Assistant ([#39265](https://github.com/n8n-io/n8n/issues/39265)) ([43d7cc7](https://github.com/n8n-io/n8n/commit/43d7cc755ee5702ceb9aa41b26692fd5dc2ed482)) +* **core:** Validate token payload before resolving the user ([#39078](https://github.com/n8n-io/n8n/issues/39078)) ([7d1bae5](https://github.com/n8n-io/n8n/commit/7d1bae51fcbe1e93828a8cd5ef951f4a36749c57)) +* **editor:** Fetch publication status when a new workflow becomes saved ([#38382](https://github.com/n8n-io/n8n/issues/38382)) ([d658d18](https://github.com/n8n-io/n8n/commit/d658d182e4d58e716c7a57fd7177716a75b35cb9)) +* **editor:** Improve Agent layout and sub-agent guidance ([#39791](https://github.com/n8n-io/n8n/issues/39791)) ([3daa34d](https://github.com/n8n-io/n8n/commit/3daa34dba0ce883c7d31f8c4733afbb242bd89a9)) +* **editor:** Keep agent builder chat messages within the panel ([#39233](https://github.com/n8n-io/n8n/issues/39233)) ([9142b47](https://github.com/n8n-io/n8n/commit/9142b47bdf34ed96462e4ebb1c3ae752cfec606f)) +* **editor:** Keep agent capability chips within card width ([#39532](https://github.com/n8n-io/n8n/issues/39532)) ([9329d1f](https://github.com/n8n-io/n8n/commit/9329d1f7526195f1611fd838744b9b055590edec)) +* **editor:** Keep quick-connect OAuth credentials out of lists until the user authorizes ([#39297](https://github.com/n8n-io/n8n/issues/39297)) ([4c2f220](https://github.com/n8n-io/n8n/commit/4c2f2209712ff6892206946c2964d96d45cf528c)) +* **editor:** Keep typed text in the AI Assistant chat rename input ([#39570](https://github.com/n8n-io/n8n/issues/39570)) ([42dc399](https://github.com/n8n-io/n8n/commit/42dc3991c103711e1d1d7b502238613c13edea76)) +* **editor:** Make agent configuration header more compact ([#39172](https://github.com/n8n-io/n8n/issues/39172)) ([1c82574](https://github.com/n8n-io/n8n/commit/1c82574f86532271789c995c3c656b77c05f0df6)) +* **editor:** Move agent AI entry point below header ([#39318](https://github.com/n8n-io/n8n/issues/39318)) ([afa5fc3](https://github.com/n8n-io/n8n/commit/afa5fc3b1a216a7863222556a43e3953031f8186)) +* **editor:** Prefill the server URL in the Mistral Vibe connector link ([#39518](https://github.com/n8n-io/n8n/issues/39518)) ([d4999a2](https://github.com/n8n-io/n8n/commit/d4999a2b2f46c93c0fe1f48a451d09b6ca5b245d)) +* **editor:** Remove half-checked permissions from system instance roles and restore the Member preset ([#38652](https://github.com/n8n-io/n8n/issues/38652)) ([c114a18](https://github.com/n8n-io/n8n/commit/c114a18c877944036517106e1fccb8894fa40c1b)) +* **editor:** Shorten the Assistant name in navigation and settings ([#39288](https://github.com/n8n-io/n8n/issues/39288)) ([5cc6640](https://github.com/n8n-io/n8n/commit/5cc6640ae8ab0e46659051b53ec5aae2a3c762b1)) +* **editor:** Show a cached role at once and align the personal space block in the instance role editor ([#39394](https://github.com/n8n-io/n8n/issues/39394)) ([e648610](https://github.com/n8n-io/n8n/commit/e648610eae8c182e507681029202798fe41e3423)) +* **editor:** Show Agent Builder activity on prompt submission ([#39501](https://github.com/n8n-io/n8n/issues/39501)) ([8a0d833](https://github.com/n8n-io/n8n/commit/8a0d833080d4fced718f354a4f4e8bfc2b310176)) +* **editor:** Show project icons in Agent breadcrumbs ([#39649](https://github.com/n8n-io/n8n/issues/39649)) ([1fe19db](https://github.com/n8n-io/n8n/commit/1fe19db437e785aae631d8c4c4d1191497d9884f)) +* **editor:** Show when update available for community packages if only verified packages are enabled ([#39424](https://github.com/n8n-io/n8n/issues/39424)) ([070d9fe](https://github.com/n8n-io/n8n/commit/070d9fea4e3d731cdddc006db8abebd17ff9698d)) +* **editor:** Sorting data table rows by unknown column returns raw database error ([#39733](https://github.com/n8n-io/n8n/issues/39733)) ([b8cda55](https://github.com/n8n-io/n8n/commit/b8cda55e5713eec92328abc22dc794afe3c17bfc)) +* **Google Cloud Storage Node:** Preserve request headers on object create ([#35218](https://github.com/n8n-io/n8n/issues/35218)) ([bc9faf2](https://github.com/n8n-io/n8n/commit/bc9faf230a1b97f6c052d7e32a344d2d967fdc5a)) +* **googleFirebaseCloudFirestore Node:** Preserve field types for numeric strings and falsy values ([#27399](https://github.com/n8n-io/n8n/issues/27399)) ([b3d4cfe](https://github.com/n8n-io/n8n/commit/b3d4cfe311be36911c9c0ddc94310a0775431c7d)) +* **LinkedIn Node:** Validate post text ([#39201](https://github.com/n8n-io/n8n/issues/39201)) ([d1cd34b](https://github.com/n8n-io/n8n/commit/d1cd34b375a37f03e4ae2c266f0d8fbf49793d2d)) +* Make n8n-node release work with release-it 21 ([#39378](https://github.com/n8n-io/n8n/issues/39378)) ([d23f949](https://github.com/n8n-io/n8n/commit/d23f949801d5838d53a0c9aac0f6aefd267a5047)) +* **MCP Client Node:** Terminate session before closing the client ([#39526](https://github.com/n8n-io/n8n/issues/39526)) ([11acaa3](https://github.com/n8n-io/n8n/commit/11acaa3f2bf9d33260c94b1fff3a07a4662fe24c)) +* **Microsoft Teams Node:** Correct the v2 resource-locator copy ([#39443](https://github.com/n8n-io/n8n/issues/39443)) ([a924724](https://github.com/n8n-io/n8n/commit/a9247247af0bd3501455a929789058153b36deb8)) +* **Mistral Cloud Node:** Support citation reference chunks ([#38952](https://github.com/n8n-io/n8n/issues/38952)) ([a9bc9c5](https://github.com/n8n-io/n8n/commit/a9bc9c57e7f9cde1894decf7e8161daaaae712aa)) +* **Notion Node:** Clarify simplified output for AI workflow generation ([#39051](https://github.com/n8n-io/n8n/issues/39051)) ([67b70ea](https://github.com/n8n-io/n8n/commit/67b70ea814fc9ff32722b35057f1271c424ca1cb)) +* Preserve tables in AI workflow builder web fetch ([#38879](https://github.com/n8n-io/n8n/issues/38879)) ([07a741b](https://github.com/n8n-io/n8n/commit/07a741b59578ba6110d70bd3eb4e571f203b2c8e)) +* Preserve tool arguments in reconstructed history ([#39153](https://github.com/n8n-io/n8n/issues/39153)) ([fe0fad5](https://github.com/n8n-io/n8n/commit/fe0fad574337a5fa875a0e8650c0dbcc49a1c272)) +* Recover running preview chat after reload ([#39311](https://github.com/n8n-io/n8n/issues/39311)) ([98f012e](https://github.com/n8n-io/n8n/commit/98f012e26830bc1e926a4322aa217c93b7feb7a6)) +* Validate node codex categories during linting ([#39543](https://github.com/n8n-io/n8n/issues/39543)) ([a3aa6c0](https://github.com/n8n-io/n8n/commit/a3aa6c00749ac0905ac51a438b6d63b0d7295cea)) +* **Wise Node:** Use current sandbox API ([#39255](https://github.com/n8n-io/n8n/issues/39255)) ([3c48cef](https://github.com/n8n-io/n8n/commit/3c48cefad4bbc6e9f69a9fd1c186660adc29cb3b)) +* **Xero Node:** Send tenant header on every page when returning all items ([#39431](https://github.com/n8n-io/n8n/issues/39431)) ([58e55b5](https://github.com/n8n-io/n8n/commit/58e55b58985fccb2b37640edcdc1e84d45753f5e)) +* **Zoho CRM Node:** Update logo to current Zoho branding ([#39305](https://github.com/n8n-io/n8n/issues/39305)) ([be12181](https://github.com/n8n-io/n8n/commit/be121813f06414f0f4d0b23adfd29767cec0adc1)) + + +### Features + +* Add an n8n Assistant onboarding thread for new Cloud signups ([#39730](https://github.com/n8n-io/n8n/issues/39730)) ([d765bbd](https://github.com/n8n-io/n8n/commit/d765bbd68e9d4ee8aef219b4a68bc33bb3afaece)) +* Add credential descriptions behind an instance flag ([#39177](https://github.com/n8n-io/n8n/issues/39177)) ([57d654f](https://github.com/n8n-io/n8n/commit/57d654f9726c779989f752b15fa02e276e8782bd)) +* Add Gateway credits option to Agent web search ([#39169](https://github.com/n8n-io/n8n/issues/39169)) ([4dbe8de](https://github.com/n8n-io/n8n/commit/4dbe8de82d070a34f4c87b34716e9457cbd5f0f0)) +* Add pinned Apply and Apply Continue to the promotion CLI commands ([#39597](https://github.com/n8n-io/n8n/issues/39597)) ([6c57533](https://github.com/n8n-io/n8n/commit/6c57533958da912e1da163f7ac1f60447fb11a76)) +* **ai-builder:** Add approval settings for agent channel actions ([#39059](https://github.com/n8n-io/n8n/issues/39059)) ([d9f6568](https://github.com/n8n-io/n8n/commit/d9f65684f8bbfd6b08c9e9e3d29842f61f663070)) +* **ai-builder:** Confirm template selections before finishing the build ([#39517](https://github.com/n8n-io/n8n/issues/39517)) ([80e4084](https://github.com/n8n-io/n8n/commit/80e408442066bb5697d4012d1f2ce835afd637c4)) +* **ai-builder:** Prompt suggestions and intro on empty agent builder aia ([#39098](https://github.com/n8n-io/n8n/issues/39098)) ([9ee6a8f](https://github.com/n8n-io/n8n/commit/9ee6a8f88b0f47a2b92c511c7c549e1af5a6c41b)) +* **Anthropic Node:** Add prompt caching to the Message operation ([#35804](https://github.com/n8n-io/n8n/issues/35804)) ([6b9c48f](https://github.com/n8n-io/n8n/commit/6b9c48fe4dba5785958f2167549a2c64033ffaf6)) +* **API:** Create projects with source IDs and package metadata ([#39218](https://github.com/n8n-io/n8n/issues/39218)) ([8012f01](https://github.com/n8n-io/n8n/commit/8012f017bc80a35cef0c3012033e9089c9db8e03)) +* **API:** Migrate GET /users endpoints to the decorator pattern ([#38599](https://github.com/n8n-io/n8n/issues/38599)) ([6433934](https://github.com/n8n-io/n8n/commit/6433934db70b10073026235263c35687c5d3f239)) +* **API:** Migrate users write endpoints to the decorator pattern ([#39120](https://github.com/n8n-io/n8n/issues/39120)) ([428bab3](https://github.com/n8n-io/n8n/commit/428bab3201ad5c1cbfac1e7dc2ca793f9226b770)) +* **Azure OpenAI Chat Model Node:** Add the project input and the deployment list call ([#39375](https://github.com/n8n-io/n8n/issues/39375)) ([4e3d5c1](https://github.com/n8n-io/n8n/commit/4e3d5c10f1480db790d695c8c91dc626ba02c78d)) +* **Azure OpenAI Chat Model Node:** Rename to Azure AI Foundry Chat Model ([#39603](https://github.com/n8n-io/n8n/issues/39603)) ([ee9dfd1](https://github.com/n8n-io/n8n/commit/ee9dfd1a377e815667641b21920a16534e0d3dd4)) +* **core:** Add browser OAuth2 flow for n8n User Auth webhooks ([#39332](https://github.com/n8n-io/n8n/issues/39332)) ([38c92f4](https://github.com/n8n-io/n8n/commit/38c92f40116cdfb3b24d9daeecbf939812dcbe59)) +* **core:** Add feature flag for granular credential sharing work ([#39293](https://github.com/n8n-io/n8n/issues/39293)) ([fc68bb6](https://github.com/n8n-io/n8n/commit/fc68bb6a8f1bbfb521e2f3bbaf06ec0cf1bbc28c)) +* **core:** Add shared Agent context access ([#38930](https://github.com/n8n-io/n8n/issues/38930)) ([8a0ca48](https://github.com/n8n-io/n8n/commit/8a0ca48bb72a8fbdcba32bcdc94723d43421f991)) +* **core:** Add the durable agent message queue foundation ([#39435](https://github.com/n8n-io/n8n/issues/39435)) ([6c592bd](https://github.com/n8n-io/n8n/commit/6c592bdb3306a3cfc29e25872135486c4a45940c)) +* **core:** Allows custom roles to have view/use/manage access to instance credentials ([#39306](https://github.com/n8n-io/n8n/issues/39306)) ([72a14b0](https://github.com/n8n-io/n8n/commit/72a14b078cd8308c87e1f3e458bd7fce903042f5)) +* **core:** Answer lastNode webhooks from execution responses ([#38959](https://github.com/n8n-io/n8n/issues/38959)) ([dc3283d](https://github.com/n8n-io/n8n/commit/dc3283da192df70bc8fe7e610b1e4401b87a0301)) +* **core:** Attach the publishing user to triggered executions ([#39466](https://github.com/n8n-io/n8n/issues/39466)) ([6c4135d](https://github.com/n8n-io/n8n/commit/6c4135ddfb59f4ce3feec71559e343289e5c0b36)) +* **core:** Authenticate instance reports with the license certificate ([#39217](https://github.com/n8n-io/n8n/issues/39217)) ([a182724](https://github.com/n8n-io/n8n/commit/a1827241362b0440689b1919af259bda6b5e0d42)) +* **core:** Check credentials against the person running the workflow ([#39685](https://github.com/n8n-io/n8n/issues/39685)) ([4a01756](https://github.com/n8n-io/n8n/commit/4a01756ea453bc7cbdbc869968315934f76e800b)) +* **core:** Connect Preview and integrations to the agent queue ([#39468](https://github.com/n8n-io/n8n/issues/39468)) ([5ca15b3](https://github.com/n8n-io/n8n/commit/5ca15b37930e6ce41d10fa0eed2a1af3f026b34b)) +* **core:** Enable Agents by default ([#39312](https://github.com/n8n-io/n8n/issues/39312)) ([94b9627](https://github.com/n8n-io/n8n/commit/94b9627f75f5851cecf396af73d308504039155d)) +* **core:** Propagate project span attributes to node spans ([#39420](https://github.com/n8n-io/n8n/issues/39420)) ([ea1e4ef](https://github.com/n8n-io/n8n/commit/ea1e4ef249ad858fc3dce3fc9564fa6758478999)) +* **core:** Report crashed executions to OTel through an execution-crashed event ([#38853](https://github.com/n8n-io/n8n/issues/38853)) ([5131af5](https://github.com/n8n-io/n8n/commit/5131af5ab79ede69564792784cdc5997c2e1d3d2)) +* **core:** Scope agent preview sessions to their owner ([#39100](https://github.com/n8n-io/n8n/issues/39100)) ([5ba266d](https://github.com/n8n-io/n8n/commit/5ba266db25b794b254922cf68f821bf595751872)) +* **core:** Set OS process titles for n8n processes ([#39362](https://github.com/n8n-io/n8n/issues/39362)) ([53dc251](https://github.com/n8n-io/n8n/commit/53dc2515081cb534cf224349dca4f1111413591a)) +* **Databricks Node:** Show the Databricks Trigger in the node picker ([#39176](https://github.com/n8n-io/n8n/issues/39176)) ([27bb095](https://github.com/n8n-io/n8n/commit/27bb095b4580ed775227a5b50c75bdfb3968a670)) +* **editor:** Add a write lock to the agent builder ([#38758](https://github.com/n8n-io/n8n/issues/38758)) ([3b3ac39](https://github.com/n8n-io/n8n/commit/3b3ac393ba80ba66d4d891cc3ce0b478089ea9dd)) +* **editor:** Add adaptive comment input to the review activity feed ([#39583](https://github.com/n8n-io/n8n/issues/39583)) ([dda1166](https://github.com/n8n-io/n8n/commit/dda1166326595d0b5af65c6d16f0a5174af527de)) +* **editor:** Add destination binding dialog ([#39214](https://github.com/n8n-io/n8n/issues/39214)) ([af838bc](https://github.com/n8n-io/n8n/commit/af838bc8ea2b161110330946e4dcd63ff43b63ff)) +* **editor:** Add expanded N8nMarkdownEditor view ([#39296](https://github.com/n8n-io/n8n/issues/39296)) ([9f89eca](https://github.com/n8n-io/n8n/commit/9f89ecab7eb8dadf01f6b298009d597918f4e513)) +* **editor:** Add Mistral Vibe to the MCP connect-client picker ([#39183](https://github.com/n8n-io/n8n/issues/39183)) ([183d769](https://github.com/n8n-io/n8n/commit/183d769841bace1c600cb0bb349e69ccfecec8c1)) +* **editor:** Add queued message controls to agent Preview chat ([#39487](https://github.com/n8n-io/n8n/issues/39487)) ([8a4fd13](https://github.com/n8n-io/n8n/commit/8a4fd13423331e4059472cc568ed6a3e408b3bde)) +* **editor:** Animate height changes in the MCP callback URLs dialog ([#39490](https://github.com/n8n-io/n8n/issues/39490)) ([8d48d90](https://github.com/n8n-io/n8n/commit/8d48d90d7296ba432a2752720eff9ade67f46751)) +* **editor:** Create credentials and variables from promotion bindings ([#39342](https://github.com/n8n-io/n8n/issues/39342)) ([2b891f1](https://github.com/n8n-io/n8n/commit/2b891f17a54f41f4aa6b83f4c24e7e2f778f31a9)) +* **editor:** Displays instance admins (and owner) in all project user lists ([#39419](https://github.com/n8n-io/n8n/issues/39419)) ([96735b2](https://github.com/n8n-io/n8n/commit/96735b20d9ca9e591f765dc9437630305f41e370)) +* **editor:** Group canvas toolbar buttons in a single container ([#39587](https://github.com/n8n-io/n8n/issues/39587)) ([a96980b](https://github.com/n8n-io/n8n/commit/a96980bcc278400d8e2e34fff61f8ef031d639dc)) +* **editor:** Group the node credential picker by where each credential comes from ([#39646](https://github.com/n8n-io/n8n/issues/39646)) ([9af0f02](https://github.com/n8n-io/n8n/commit/9af0f02a74021e882762bf1ab1c3420fa9d5857b)) +* **editor:** Implement Reka UI based context menu for design system ([#37925](https://github.com/n8n-io/n8n/issues/37925)) ([22d18a7](https://github.com/n8n-io/n8n/commit/22d18a7bc9ec714027a6756ea3d6e79f0811535f)) +* **editor:** Improve Agent Builder intro interactions ([#39590](https://github.com/n8n-io/n8n/issues/39590)) ([b09676e](https://github.com/n8n-io/n8n/commit/b09676e34adfeadf796e5d469967c1ffe81b1386)) +* **editor:** Make SSO the primary option on the sign-in page when SSO is enabled ([#39268](https://github.com/n8n-io/n8n/issues/39268)) ([0827eb8](https://github.com/n8n-io/n8n/commit/0827eb8eda9ee2436763d5add1154c574a7be6ec)) +* **editor:** Preview own connected clients on the MCP settings overview ([#39489](https://github.com/n8n-io/n8n/issues/39489)) ([fdc6b9d](https://github.com/n8n-io/n8n/commit/fdc6b9d8921b002ac77b061e3c41f7cf50a8e470)) +* **editor:** Restyle AI Assistant tab bar and add a tab hover card ([#39625](https://github.com/n8n-io/n8n/issues/39625)) ([88d8390](https://github.com/n8n-io/n8n/commit/88d839022296630c6e74e41fcf7426bc4e5a3359)) +* **editor:** Revamp the Agent skills modal ([#39545](https://github.com/n8n-io/n8n/issues/39545)) ([4263117](https://github.com/n8n-io/n8n/commit/4263117b480a36510c2491aae0114e93175900b0)) +* **editor:** Route assistant credit CTAs to top-up for UBB accounts ([#39416](https://github.com/n8n-io/n8n/issues/39416)) ([dd4e7f6](https://github.com/n8n-io/n8n/commit/dd4e7f62b2ed0503b6d6fe550d4ccfc572b8ac1a)) +* **editor:** Show a restricted node panel in the NDV with contact-admin and replace paths ([#38869](https://github.com/n8n-io/n8n/issues/38869)) ([d42b762](https://github.com/n8n-io/n8n/commit/d42b7621deddaf13bd6353b242dbd817e6167b6f)) +* **editor:** Show each policy violation with its scope when a save, publish or execution is refused ([#39263](https://github.com/n8n-io/n8n/issues/39263)) ([2ec6ac7](https://github.com/n8n-io/n8n/commit/2ec6ac7c9cd0a73490b7a083b42ef3462db4de8e)) +* **editor:** Standardize Agent Builder modals ([#39220](https://github.com/n8n-io/n8n/issues/39220)) ([042d167](https://github.com/n8n-io/n8n/commit/042d16718ef74f0f73505f0ebfc868c480261ef5)) +* **editor:** Suggest Gateway credits after credential errors ([#38818](https://github.com/n8n-io/n8n/issues/38818)) ([7af6741](https://github.com/n8n-io/n8n/commit/7af6741bf29605d6db50cb60fa005bfeef6206a8)) +* **editor:** Use test label and icon for agent preview ([#38939](https://github.com/n8n-io/n8n/issues/38939)) ([b6b4e54](https://github.com/n8n-io/n8n/commit/b6b4e542515d32e395e8f854da06cc86610c7d1e)) +* **Microsoft Teams Node:** Add online meeting attendees on Create, Create or Get and Update ([#38850](https://github.com/n8n-io/n8n/issues/38850)) ([eb5eef3](https://github.com/n8n-io/n8n/commit/eb5eef341f068824c68f30e9ce8573e546f88e14)) +* **Microsoft Teams Node:** Send activity feed notifications to a user ([#38954](https://github.com/n8n-io/n8n/issues/38954)) ([dfe014a](https://github.com/n8n-io/n8n/commit/dfe014a2ba414efc5b37f20da3f2414db7716f4c)) +* Suggest Gateway credit services during node discovery ([#39631](https://github.com/n8n-io/n8n/issues/39631)) ([2b0719d](https://github.com/n8n-io/n8n/commit/2b0719dc70e1859701352a8589c544fdb0692e6c)) + + +### Performance Improvements + +* **core:** Avoid terminal outbox scans during publication reconciliation ([#39267](https://github.com/n8n-io/n8n/issues/39267)) ([6272d46](https://github.com/n8n-io/n8n/commit/6272d465df4d4e5455f9223b6649e6228dcb0a59)) + + # [2.41.0](https://github.com/n8n-io/n8n/compare/n8n@2.40.0...n8n@2.41.0) (2026-09-22) diff --git a/OWNERS b/OWNERS index 9f6606b0f7b..eb12fec83a2 100644 --- a/OWNERS +++ b/OWNERS @@ -231,6 +231,7 @@ packages/@n8n/mcp-browser-extension/ @n8n-io/nodes packages/@n8n/permissions/ @n8n-io/iam packages/@n8n/client-oauth2/ @n8n-io/iam +packages/@n8n/inbound-auth/ @n8n-io/iam # LiGo diff --git a/codecov.yml b/codecov.yml index 69bad3858ad..ce45fc5a93c 100644 --- a/codecov.yml +++ b/codecov.yml @@ -89,6 +89,7 @@ component_management: - packages/@n8n/expression-runtime/** - packages/@n8n/extension-sdk/** - packages/@n8n/imap/** + - packages/@n8n/inbound-auth/** - packages/@n8n/local-gateway/** - packages/@n8n/permissions/** - packages/@n8n/scan-community-package/** diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index 58d6ecf65f1..2d191e89431 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -19,12 +19,12 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand. | [public.agent_eval_rating](public.agent_eval_rating.md) | 8 | | BASE TABLE | | [public.agent_eval_result](public.agent_eval_result.md) | 15 | | BASE TABLE | | [public.agent_eval_run](public.agent_eval_run.md) | 14 | | BASE TABLE | -| [public.agent_execution](public.agent_execution.md) | 22 | | BASE TABLE | +| [public.agent_execution](public.agent_execution.md) | 23 | | BASE TABLE | | [public.agent_execution_message_links](public.agent_execution_message_links.md) | 5 | | BASE TABLE | | [public.agent_execution_threads](public.agent_execution_threads.md) | 19 | | BASE TABLE | | [public.agent_files](public.agent_files.md) | 10 | | BASE TABLE | | [public.agent_history](public.agent_history.md) | 9 | | BASE TABLE | -| [public.agent_message_queue](public.agent_message_queue.md) | 7 | | BASE TABLE | +| [public.agent_message_queue](public.agent_message_queue.md) | 9 | | BASE TABLE | | [public.agent_task_definition](public.agent_task_definition.md) | 8 | | BASE TABLE | | [public.agent_task_run_lock](public.agent_task_run_lock.md) | 6 | | BASE TABLE | | [public.agent_task_snapshot](public.agent_task_snapshot.md) | 9 | | BASE TABLE | @@ -129,6 +129,8 @@ 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_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 | | [public.type_availability_policy_scope](public.type_availability_policy_scope.md) | 8 | | BASE TABLE | @@ -205,7 +207,9 @@ erDiagram "public.agent_files" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE" "public.agent_history" }o--o| "public.user" : "FOREIGN KEY (#quot;publishedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL" "public.agent_history" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE" +"public.agent_message_queue" }o--|| "public.agents_messages" : "FOREIGN KEY (#quot;messageId#quot;) REFERENCES agents_messages(id) ON DELETE CASCADE" "public.agent_message_queue" }o--o| "public.agent_execution" : "FOREIGN KEY (#quot;executionId#quot;) REFERENCES agent_execution(id)" +"public.agent_message_queue" }o--o| "public.agent_execution" : "FOREIGN KEY (#quot;steeringExecutionId#quot;) REFERENCES agent_execution(id)" "public.agent_message_queue" }o--|| "public.agent_execution_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES agent_execution_threads(id) ON DELETE CASCADE" "public.agent_task_definition" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE" "public.agent_task_run_lock" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE" @@ -349,6 +353,8 @@ erDiagram "public.test_run" }o--o| "public.evaluation_config" : "FOREIGN KEY (#quot;evaluationConfigId#quot;) REFERENCES evaluation_config(id) ON DELETE SET NULL" "public.test_run" }o--o| "public.evaluation_collection" : "FOREIGN KEY (#quot;collectionId#quot;) REFERENCES evaluation_collection(id) ON DELETE SET NULL" "public.trusted_key" }o--|| "public.trusted_key_source" : "FOREIGN KEY (#quot;sourceId#quot;) REFERENCES trusted_key_source(id) ON DELETE CASCADE" +"public.trusted_source_identity" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" +"public.trusted_source_identity" }o--|| "public.trusted_source" : "FOREIGN KEY (#quot;sourceId#quot;) REFERENCES trusted_source(id) ON DELETE CASCADE" "public.type_availability_policy_attachment" }o--|| "public.type_availability_policy" : "FOREIGN KEY (#quot;policyId#quot;) REFERENCES type_availability_policy(id) ON DELETE RESTRICT" "public.type_availability_policy_attachment" }o--|| "public.type_availability_policy_scope" : "FOREIGN KEY (#quot;scopeId#quot;) REFERENCES type_availability_policy_scope(id) ON DELETE CASCADE" "public.type_availability_policy_scope" }o--o| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" @@ -529,6 +535,7 @@ erDiagram timestamp_3__with_time_zone updatedAt } "public.agent_execution" { + boolean acceptsSteering json attachments json author integer completionTokens @@ -607,8 +614,10 @@ erDiagram timestamp_3__with_time_zone createdAt varchar_36_ executionId FK bigint id + varchar_36_ messageId FK json payload - varchar_32_ source + varchar_36_ steeringExecutionId FK + integer steeringOrder varchar_128_ threadId FK timestamp_3__with_time_zone updatedAt } @@ -1600,6 +1609,30 @@ erDiagram varchar_32_ type timestamp_3__with_time_zone updatedAt } +"public.trusted_source" { + text config + integer configVersion + timestamp_3__with_time_zone createdAt + varchar_36_ id + varchar issuer + timestamp_3__with_time_zone lastCheckedAt + text lastError + varchar_16_ managedBy + varchar_128_ name + varchar_16_ status + varchar_32_ type + timestamp_3__with_time_zone updatedAt +} +"public.trusted_source_identity" { + timestamp_3__with_time_zone createdAt + timestamp_3__with_time_zone lastSeenAt + varchar_32_ provenance + varchar_36_ sourceId FK + varchar_16_ status + varchar subject + timestamp_3__with_time_zone updatedAt + uuid userId FK +} "public.type_availability_policy" { timestamp_3__with_time_zone createdAt varchar_36_ id diff --git a/docs/generated/postgres-schema/public.agent_execution.md b/docs/generated/postgres-schema/public.agent_execution.md index c48414745ae..7e34d217382 100644 --- a/docs/generated/postgres-schema/public.agent_execution.md +++ b/docs/generated/postgres-schema/public.agent_execution.md @@ -4,6 +4,7 @@ | Name | Type | Default | Nullable | Children | Parents | Comment | | ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| acceptsSteering | boolean | false | false | | | Accept input until the runtime closes admission | | attachments | json | | true | | | Metadata of files attached to the user turn ({id, fileName, mimeType, sizeBytes}[]); bytes live in BinaryDataService | | author | json | | true | | | Chat platform user who wrote the turn as {id, name}; null for runs outside chat integrations | | completionTokens | integer | | true | | | | @@ -36,6 +37,7 @@ | CHK_agent_execution_storedAt | CHECK | CHECK ((("storedAt")::text = ANY ((ARRAY['db'::character varying, 'fs'::character varying, 's3'::character varying, 'az'::character varying])::text[]))) | | FK_add2432fb6034cc18b6af299dce | FOREIGN KEY | FOREIGN KEY ("threadId") REFERENCES agent_execution_threads(id) ON DELETE CASCADE | | PK_ba438acc8532addc12d1ef17049 | PRIMARY KEY | PRIMARY KEY (id) | +| agent_execution_acceptsSteering_not_null | n | NOT NULL "acceptsSteering" | | agent_execution_createdAt_not_null | n | NOT NULL "createdAt" | | agent_execution_duration_not_null | n | NOT NULL duration | | agent_execution_id_not_null | n | NOT NULL id | @@ -59,9 +61,11 @@ erDiagram "public.agent_execution_message_links" }o--|| "public.agent_execution" : "FOREIGN KEY (#quot;executionId#quot;) REFERENCES agent_execution(id) ON DELETE CASCADE" "public.agent_message_queue" }o--o| "public.agent_execution" : "FOREIGN KEY (#quot;executionId#quot;) REFERENCES agent_execution(id)" +"public.agent_message_queue" }o--o| "public.agent_execution" : "FOREIGN KEY (#quot;steeringExecutionId#quot;) REFERENCES agent_execution(id)" "public.agent_execution" }o--|| "public.agent_execution_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES agent_execution_threads(id) ON DELETE CASCADE" "public.agent_execution" { + boolean acceptsSteering json attachments json author integer completionTokens @@ -96,8 +100,10 @@ erDiagram timestamp_3__with_time_zone createdAt varchar_36_ executionId FK bigint id + varchar_36_ messageId FK json payload - varchar_32_ source + varchar_36_ steeringExecutionId FK + integer steeringOrder varchar_128_ threadId FK timestamp_3__with_time_zone updatedAt } diff --git a/docs/generated/postgres-schema/public.agent_execution_message_links.md b/docs/generated/postgres-schema/public.agent_execution_message_links.md index fc563f5b028..eff53fc8d56 100644 --- a/docs/generated/postgres-schema/public.agent_execution_message_links.md +++ b/docs/generated/postgres-schema/public.agent_execution_message_links.md @@ -48,6 +48,7 @@ erDiagram integer position } "public.agent_execution" { + boolean acceptsSteering json attachments json author integer completionTokens diff --git a/docs/generated/postgres-schema/public.agent_execution_threads.md b/docs/generated/postgres-schema/public.agent_execution_threads.md index a00f6936dd4..07e8b12b2c5 100644 --- a/docs/generated/postgres-schema/public.agent_execution_threads.md +++ b/docs/generated/postgres-schema/public.agent_execution_threads.md @@ -107,6 +107,7 @@ erDiagram varchar_36_ versionId } "public.agent_execution" { + boolean acceptsSteering json attachments json author integer completionTokens @@ -134,8 +135,10 @@ erDiagram timestamp_3__with_time_zone createdAt varchar_36_ executionId FK bigint id + varchar_36_ messageId FK json payload - varchar_32_ source + varchar_36_ steeringExecutionId FK + integer steeringOrder varchar_128_ threadId FK timestamp_3__with_time_zone updatedAt } diff --git a/docs/generated/postgres-schema/public.agent_message_queue.md b/docs/generated/postgres-schema/public.agent_message_queue.md index 7354483d848..b7ef79e5217 100644 --- a/docs/generated/postgres-schema/public.agent_message_queue.md +++ b/docs/generated/postgres-schema/public.agent_message_queue.md @@ -7,8 +7,10 @@ | createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | | executionId | varchar(36) | | true | | [public.agent_execution](public.agent_execution.md) | Current execution; NULL means pending | | id | bigint | | false | | | Acceptance order; IDs are not reused | -| payload | json | | false | | | Input, attachment references, identity, and reply context | -| source | varchar(32) | | false | | | Preview or integration source | +| messageId | varchar(36) | | false | | [public.agents_messages](public.agents_messages.md) | Canonical input created when the queue accepts it | +| payload | json | | false | | | Dispatch, authorization, and reply context. Input is stored on the message | +| steeringExecutionId | varchar(36) | | true | | [public.agent_execution](public.agent_execution.md) | Execution reserved to consume this input | +| steeringOrder | integer | | true | | | Acceptance order among outstanding steers | | threadId | varchar(128) | | false | | [public.agent_execution_threads](public.agent_execution_threads.md) | | | updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | @@ -18,11 +20,13 @@ | ---- | ---- | ---------- | | FK_2349d84b4f2a660fc264f38fef3 | FOREIGN KEY | FOREIGN KEY ("executionId") REFERENCES agent_execution(id) | | FK_a74f9154a59430986112d70cb16 | FOREIGN KEY | FOREIGN KEY ("threadId") REFERENCES agent_execution_threads(id) ON DELETE CASCADE | +| FK_agent_message_queue_messageId | FOREIGN KEY | FOREIGN KEY ("messageId") REFERENCES agents_messages(id) ON DELETE CASCADE | +| FK_eff54787927c2968dc24495c911 | FOREIGN KEY | FOREIGN KEY ("steeringExecutionId") REFERENCES agent_execution(id) | | PK_733d8c959a6057f04f4da5ab721 | PRIMARY KEY | PRIMARY KEY (id) | | agent_message_queue_createdAt_not_null | n | NOT NULL "createdAt" | | agent_message_queue_id_not_null | n | NOT NULL id | +| agent_message_queue_messageId_not_null | n | NOT NULL "messageId" | | agent_message_queue_payload_not_null | n | NOT NULL payload | -| agent_message_queue_source_not_null | n | NOT NULL source | | agent_message_queue_threadId_not_null | n | NOT NULL "threadId" | | agent_message_queue_updatedAt_not_null | n | NOT NULL "updatedAt" | @@ -31,6 +35,8 @@ | Name | Definition | | ---- | ---------- | | IDX_2349d84b4f2a660fc264f38fef | CREATE INDEX "IDX_2349d84b4f2a660fc264f38fef" ON public.agent_message_queue USING btree ("executionId") | +| IDX_agent_message_queue_messageId | CREATE UNIQUE INDEX "IDX_agent_message_queue_messageId" ON public.agent_message_queue USING btree ("messageId") | +| IDX_agent_message_queue_steeringExecutionId_steeringOrder | CREATE UNIQUE INDEX "IDX_agent_message_queue_steeringExecutionId_steeringOrder" ON public.agent_message_queue USING btree ("steeringExecutionId", "steeringOrder") WHERE ("steeringExecutionId" IS NOT NULL) | | IDX_agent_message_queue_threadId | CREATE UNIQUE INDEX "IDX_agent_message_queue_threadId" ON public.agent_message_queue USING btree ("threadId") WHERE ("executionId" IS NOT NULL) | | IDX_c1db6ea2d031cc49100535e6c6 | CREATE INDEX "IDX_c1db6ea2d031cc49100535e6c6" ON public.agent_message_queue USING btree ("threadId", id) | | PK_733d8c959a6057f04f4da5ab721 | CREATE UNIQUE INDEX "PK_733d8c959a6057f04f4da5ab721" ON public.agent_message_queue USING btree (id) | @@ -41,18 +47,23 @@ erDiagram "public.agent_message_queue" }o--o| "public.agent_execution" : "FOREIGN KEY (#quot;executionId#quot;) REFERENCES agent_execution(id)" +"public.agent_message_queue" }o--|| "public.agents_messages" : "FOREIGN KEY (#quot;messageId#quot;) REFERENCES agents_messages(id) ON DELETE CASCADE" +"public.agent_message_queue" }o--o| "public.agent_execution" : "FOREIGN KEY (#quot;steeringExecutionId#quot;) REFERENCES agent_execution(id)" "public.agent_message_queue" }o--|| "public.agent_execution_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES agent_execution_threads(id) ON DELETE CASCADE" "public.agent_message_queue" { timestamp_3__with_time_zone createdAt varchar_36_ executionId FK bigint id + varchar_36_ messageId FK json payload - varchar_32_ source + varchar_36_ steeringExecutionId FK + integer steeringOrder varchar_128_ threadId FK timestamp_3__with_time_zone updatedAt } "public.agent_execution" { + boolean acceptsSteering json attachments json author integer completionTokens @@ -76,6 +87,20 @@ erDiagram timestamp_3__with_time_zone updatedAt text userMessage } +"public.agents_messages" { + json author + json content + timestamp_3__with_time_zone createdAt + varchar_36_ id + json modelContent + timestamp_3__with_time_zone modelContextAt + json origin + varchar_255_ resourceId + varchar_36_ role + varchar_255_ threadId FK + varchar_36_ type + timestamp_3__with_time_zone updatedAt +} "public.agent_execution_threads" { varchar_16_ accessScope varchar_36_ agentId FK diff --git a/docs/generated/postgres-schema/public.agents_messages.md b/docs/generated/postgres-schema/public.agents_messages.md index c60d77fa57e..e32db1edfec 100644 --- a/docs/generated/postgres-schema/public.agents_messages.md +++ b/docs/generated/postgres-schema/public.agents_messages.md @@ -7,7 +7,7 @@ | author | json | | true | | | Original message author supplied by the chat platform | | content | json | | false | | | | | createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | -| id | varchar(36) | | false | [public.agent_execution_message_links](public.agent_execution_message_links.md) [public.agents_memory_entry_candidates](public.agents_memory_entry_candidates.md) | | | +| id | varchar(36) | | false | [public.agent_execution_message_links](public.agent_execution_message_links.md) [public.agent_message_queue](public.agent_message_queue.md) [public.agents_memory_entry_candidates](public.agents_memory_entry_candidates.md) | | | | modelContent | json | | true | | | Enriched model input when it differs from the original content | | modelContextAt | timestamp(3) with time zone | | true | | | Runtime ordering timestamp. NULL inputs are excluded from model context | | origin | json | | true | | | Source identifiers and transcript visibility. NULL for legacy or SDK-only messages | @@ -47,6 +47,7 @@ erDiagram "public.agent_execution_message_links" }o--|| "public.agents_messages" : "FOREIGN KEY (#quot;messageId#quot;) REFERENCES agents_messages(id) ON DELETE CASCADE" +"public.agent_message_queue" }o--|| "public.agents_messages" : "FOREIGN KEY (#quot;messageId#quot;) REFERENCES agents_messages(id) ON DELETE CASCADE" "public.agents_memory_entry_candidates" }o--o| "public.agents_messages" : "FOREIGN KEY (#quot;sourceMessageId#quot;) REFERENCES agents_messages(id) ON DELETE SET NULL" "public.agents_messages" }o--|| "public.agents_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES agents_threads(id) ON DELETE CASCADE" @@ -71,6 +72,17 @@ erDiagram varchar_36_ messageId FK integer position } +"public.agent_message_queue" { + timestamp_3__with_time_zone createdAt + varchar_36_ executionId FK + bigint id + varchar_36_ messageId FK + json payload + varchar_36_ steeringExecutionId FK + integer steeringOrder + varchar_128_ threadId FK + timestamp_3__with_time_zone updatedAt +} "public.agents_memory_entry_candidates" { varchar_36_ agentId FK smallint attemptCount diff --git a/docs/generated/postgres-schema/public.trusted_source.md b/docs/generated/postgres-schema/public.trusted_source.md new file mode 100644 index 00000000000..4f340f7b795 --- /dev/null +++ b/docs/generated/postgres-schema/public.trusted_source.md @@ -0,0 +1,79 @@ +# public.trusted_source + +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| config | text | | false | | | | +| configVersion | integer | | false | | | | +| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| 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 | | | | +| name | varchar(128) | | false | | | | +| status | varchar(16) | | false | | | | +| type | varchar(32) | | false | | | | +| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| PK_2eea0a99b54a2022620817b1ffb | PRIMARY KEY | PRIMARY KEY (id) | +| trusted_source_configVersion_not_null | n | NOT NULL "configVersion" | +| trusted_source_config_not_null | n | NOT NULL config | +| trusted_source_createdAt_not_null | n | NOT NULL "createdAt" | +| trusted_source_id_not_null | n | NOT NULL id | +| trusted_source_issuer_not_null | n | NOT NULL issuer | +| trusted_source_managedBy_not_null | n | NOT NULL "managedBy" | +| trusted_source_name_not_null | n | NOT NULL name | +| trusted_source_status_not_null | n | NOT NULL status | +| trusted_source_type_not_null | n | NOT NULL type | +| trusted_source_updatedAt_not_null | n | NOT NULL "updatedAt" | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| IDX_519e3445a1740f2ddbab2749b5 | CREATE UNIQUE INDEX "IDX_519e3445a1740f2ddbab2749b5" ON public.trusted_source USING btree (issuer) | +| IDX_d3055524099cd341a2af4b8eb9 | CREATE UNIQUE INDEX "IDX_d3055524099cd341a2af4b8eb9" ON public.trusted_source USING btree (name) | +| PK_2eea0a99b54a2022620817b1ffb | CREATE UNIQUE INDEX "PK_2eea0a99b54a2022620817b1ffb" ON public.trusted_source USING btree (id) | + +## Relations + +```mermaid +erDiagram + +"public.trusted_source_identity" }o--|| "public.trusted_source" : "FOREIGN KEY (#quot;sourceId#quot;) REFERENCES trusted_source(id) ON DELETE CASCADE" + +"public.trusted_source" { + text config + integer configVersion + timestamp_3__with_time_zone createdAt + varchar_36_ id + varchar issuer + timestamp_3__with_time_zone lastCheckedAt + text lastError + varchar_16_ managedBy + varchar_128_ name + varchar_16_ status + varchar_32_ type + timestamp_3__with_time_zone updatedAt +} +"public.trusted_source_identity" { + timestamp_3__with_time_zone createdAt + timestamp_3__with_time_zone lastSeenAt + varchar_32_ provenance + varchar_36_ sourceId FK + varchar_16_ status + varchar subject + timestamp_3__with_time_zone updatedAt + uuid userId FK +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/postgres-schema/public.trusted_source_identity.md b/docs/generated/postgres-schema/public.trusted_source_identity.md new file mode 100644 index 00000000000..fa42dba349c --- /dev/null +++ b/docs/generated/postgres-schema/public.trusted_source_identity.md @@ -0,0 +1,91 @@ +# public.trusted_source_identity + +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| lastSeenAt | timestamp(3) with time zone | | true | | | | +| provenance | varchar(32) | | false | | | | +| sourceId | varchar(36) | | false | | [public.trusted_source](public.trusted_source.md) | | +| status | varchar(16) | | false | | | | +| subject | varchar | | false | | | | +| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| userId | uuid | | false | | [public.user](public.user.md) | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| PK_aaeab0a64d04b4c5620140f0221 | PRIMARY KEY | PRIMARY KEY ("sourceId", subject) | +| trusted_source_identity_createdAt_not_null | n | NOT NULL "createdAt" | +| trusted_source_identity_provenance_not_null | n | NOT NULL provenance | +| trusted_source_identity_sourceId_foreign | FOREIGN KEY | FOREIGN KEY ("sourceId") REFERENCES trusted_source(id) ON DELETE CASCADE | +| trusted_source_identity_sourceId_not_null | n | NOT NULL "sourceId" | +| trusted_source_identity_status_not_null | n | NOT NULL status | +| trusted_source_identity_subject_not_null | n | NOT NULL subject | +| trusted_source_identity_updatedAt_not_null | n | NOT NULL "updatedAt" | +| trusted_source_identity_userId_foreign | FOREIGN KEY | FOREIGN KEY ("userId") REFERENCES "user"(id) ON DELETE CASCADE | +| trusted_source_identity_userId_not_null | n | NOT NULL "userId" | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| IDX_bcc1e89f2ab58755614b601168 | CREATE INDEX "IDX_bcc1e89f2ab58755614b601168" ON public.trusted_source_identity USING btree ("userId") | +| PK_aaeab0a64d04b4c5620140f0221 | CREATE UNIQUE INDEX "PK_aaeab0a64d04b4c5620140f0221" ON public.trusted_source_identity USING btree ("sourceId", subject) | + +## Relations + +```mermaid +erDiagram + +"public.trusted_source_identity" }o--|| "public.trusted_source" : "FOREIGN KEY (#quot;sourceId#quot;) REFERENCES trusted_source(id) ON DELETE CASCADE" +"public.trusted_source_identity" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" + +"public.trusted_source_identity" { + timestamp_3__with_time_zone createdAt + timestamp_3__with_time_zone lastSeenAt + varchar_32_ provenance + varchar_36_ sourceId FK + varchar_16_ status + varchar subject + timestamp_3__with_time_zone updatedAt + uuid userId FK +} +"public.trusted_source" { + text config + integer configVersion + timestamp_3__with_time_zone createdAt + varchar_36_ id + varchar issuer + timestamp_3__with_time_zone lastCheckedAt + text lastError + varchar_16_ managedBy + varchar_128_ name + varchar_16_ status + varchar_32_ type + timestamp_3__with_time_zone updatedAt +} +"public.user" { + timestamp_3__with_time_zone createdAt + boolean disabled + varchar_255_ email + varchar_32_ firstName + uuid id + date lastActiveAt + varchar_32_ lastName + boolean mfaEnabled + text mfaRecoveryCodes + text mfaSecret + varchar_255_ password + json personalizationAnswers + varchar_128_ roleSlug FK + json settings + timestamp_3__with_time_zone updatedAt +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/postgres-schema/public.user.md b/docs/generated/postgres-schema/public.user.md index c824c552f0c..3e80250c69e 100644 --- a/docs/generated/postgres-schema/public.user.md +++ b/docs/generated/postgres-schema/public.user.md @@ -8,7 +8,7 @@ | disabled | boolean | false | false | | | | | email | varchar(255) | | true | | | | | firstName | varchar(32) | | true | | | | -| id | uuid | gen_random_uuid() | false | [public.activity_event](public.activity_event.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_eval_rating](public.agent_eval_rating.md) [public.agent_eval_run](public.agent_eval_run.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_history](public.agent_history.md) [public.ai_preference](public.ai_preference.md) [public.auth_identity](public.auth_identity.md) [public.chat_hub_agents](public.chat_hub_agents.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.chat_hub_tools](public.chat_hub_tools.md) [public.dynamic_credential_user_entry](public.dynamic_credential_user_entry.md) [public.evaluation_collection](public.evaluation_collection.md) [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) [public.instance_ai_thread_tabs](public.instance_ai_thread_tabs.md) [public.oauth_access_tokens](public.oauth_access_tokens.md) [public.oauth_authorization_codes](public.oauth_authorization_codes.md) [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) [public.oauth_user_consents](public.oauth_user_consents.md) [public.project](public.project.md) [public.project_relation](public.project_relation.md) [public.user_api_keys](public.user_api_keys.md) [public.user_favorites](public.user_favorites.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_review_activity](public.workflow_review_activity.md) [public.workflow_review_activity_comment](public.workflow_review_activity_comment.md) [public.workflow_review_request](public.workflow_review_request.md) [public.workflow_review_request_authors](public.workflow_review_request_authors.md) [public.workflow_review_request_reviewers](public.workflow_review_request_reviewers.md) | | | +| id | uuid | gen_random_uuid() | false | [public.activity_event](public.activity_event.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_eval_rating](public.agent_eval_rating.md) [public.agent_eval_run](public.agent_eval_run.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_history](public.agent_history.md) [public.ai_preference](public.ai_preference.md) [public.auth_identity](public.auth_identity.md) [public.chat_hub_agents](public.chat_hub_agents.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.chat_hub_tools](public.chat_hub_tools.md) [public.dynamic_credential_user_entry](public.dynamic_credential_user_entry.md) [public.evaluation_collection](public.evaluation_collection.md) [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) [public.instance_ai_thread_tabs](public.instance_ai_thread_tabs.md) [public.oauth_access_tokens](public.oauth_access_tokens.md) [public.oauth_authorization_codes](public.oauth_authorization_codes.md) [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) [public.oauth_user_consents](public.oauth_user_consents.md) [public.project](public.project.md) [public.project_relation](public.project_relation.md) [public.trusted_source_identity](public.trusted_source_identity.md) [public.user_api_keys](public.user_api_keys.md) [public.user_favorites](public.user_favorites.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_review_activity](public.workflow_review_activity.md) [public.workflow_review_activity_comment](public.workflow_review_activity_comment.md) [public.workflow_review_request](public.workflow_review_request.md) [public.workflow_review_request_authors](public.workflow_review_request_authors.md) [public.workflow_review_request_reviewers](public.workflow_review_request_reviewers.md) | | | | lastActiveAt | date | | true | | | | | lastName | varchar(32) | | true | | | | | mfaEnabled | boolean | false | false | | | | @@ -71,6 +71,7 @@ erDiagram "public.oauth_user_consents" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" "public.project" }o--o| "public.user" : "FOREIGN KEY (#quot;creatorId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL" "public.project_relation" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" +"public.trusted_source_identity" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" "public.user_api_keys" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" "public.user_favorites" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" "public.workflow_builder_session" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" @@ -353,6 +354,16 @@ erDiagram timestamp_3__with_time_zone updatedAt uuid userId FK } +"public.trusted_source_identity" { + timestamp_3__with_time_zone createdAt + timestamp_3__with_time_zone lastSeenAt + varchar_32_ provenance + varchar_36_ sourceId FK + varchar_16_ status + varchar subject + timestamp_3__with_time_zone updatedAt + uuid userId FK +} "public.user_api_keys" { varchar apiKey varchar audience diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index 34c3eb17f70..37b341a3886 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -19,12 +19,12 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand. | [agent_eval_rating](agent_eval_rating.md) | 8 | | table | | [agent_eval_result](agent_eval_result.md) | 15 | | table | | [agent_eval_run](agent_eval_run.md) | 14 | | table | -| [agent_execution](agent_execution.md) | 22 | | table | +| [agent_execution](agent_execution.md) | 23 | | table | | [agent_execution_message_links](agent_execution_message_links.md) | 5 | | table | | [agent_execution_threads](agent_execution_threads.md) | 19 | | table | | [agent_files](agent_files.md) | 10 | | table | | [agent_history](agent_history.md) | 9 | | table | -| [agent_message_queue](agent_message_queue.md) | 7 | | table | +| [agent_message_queue](agent_message_queue.md) | 9 | | table | | [agent_task_definition](agent_task_definition.md) | 8 | | table | | [agent_task_run_lock](agent_task_run_lock.md) | 6 | | table | | [agent_task_snapshot](agent_task_snapshot.md) | 9 | | table | @@ -129,6 +129,8 @@ 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_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 | | [type_availability_policy_scope](type_availability_policy_scope.md) | 8 | | table | @@ -187,8 +189,10 @@ erDiagram "agent_files" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "agent_history" }o--o| "user" : "FOREIGN KEY (publishedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE" "agent_history" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" -"agent_message_queue" }o--o| "agent_execution" : "FOREIGN KEY (executionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE" +"agent_message_queue" }o--o| "agent_execution" : "FOREIGN KEY (steeringExecutionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE" +"agent_message_queue" }o--|| "agents_messages" : "FOREIGN KEY (messageId) REFERENCES agents_messages (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "agent_message_queue" }o--|| "agent_execution_threads" : "FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"agent_message_queue" }o--o| "agent_execution" : "FOREIGN KEY (executionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE" "agent_task_definition" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "agent_task_run_lock" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "agent_task_snapshot" |o--|| "agent_history" : "FOREIGN KEY (versionId) REFERENCES agent_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" @@ -333,6 +337,8 @@ erDiagram "test_run" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "test_run" }o--o| "evaluation_config" : "FOREIGN KEY (evaluationConfigId) REFERENCES evaluation_config (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE" "trusted_key" |o--|| "trusted_key_source" : "FOREIGN KEY (sourceId) REFERENCES trusted_key_source (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"trusted_source_identity" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"trusted_source_identity" |o--|| "trusted_source" : "FOREIGN KEY (sourceId) REFERENCES trusted_source (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "type_availability_policy_attachment" |o--|| "type_availability_policy" : "FOREIGN KEY (policyId) REFERENCES type_availability_policy (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE" "type_availability_policy_attachment" |o--|| "type_availability_policy_scope" : "FOREIGN KEY (scopeId) REFERENCES type_availability_policy_scope (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "type_availability_policy_scope" }o--o| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" @@ -515,6 +521,7 @@ erDiagram datetime_3_ updatedAt } "agent_execution" { + BOOLEAN acceptsSteering TEXT attachments TEXT author INTEGER completionTokens @@ -593,8 +600,10 @@ erDiagram datetime_3_ createdAt varchar_36_ executionId FK INTEGER id + varchar_36_ messageId FK TEXT payload - varchar_32_ source + varchar_36_ steeringExecutionId FK + INTEGER steeringOrder varchar_128_ threadId FK datetime_3_ updatedAt } @@ -1588,6 +1597,30 @@ erDiagram varchar_32_ type datetime_3_ updatedAt } +"trusted_source" { + TEXT config + INTEGER configVersion + datetime_3_ createdAt + varchar_36_ id PK + varchar issuer + datetime_3_ lastCheckedAt + TEXT lastError + varchar_16_ managedBy + varchar_128_ name + varchar_16_ status + varchar_32_ type + datetime_3_ updatedAt +} +"trusted_source_identity" { + datetime_3_ createdAt + datetime_3_ lastSeenAt + varchar_32_ provenance + varchar_36_ sourceId PK + varchar_16_ status + varchar subject PK + datetime_3_ updatedAt + varchar userId FK +} "type_availability_policy" { datetime_3_ createdAt varchar_36_ id PK diff --git a/docs/generated/sqlite-schema/agent_execution.md b/docs/generated/sqlite-schema/agent_execution.md index ec7393ac62f..be4a76accce 100644 --- a/docs/generated/sqlite-schema/agent_execution.md +++ b/docs/generated/sqlite-schema/agent_execution.md @@ -6,7 +6,7 @@ Table Definition ```sql -CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId" varchar(128) NOT NULL, "status" varchar(16) NOT NULL, "startedAt" datetime(3), "stoppedAt" datetime(3), "duration" integer NOT NULL DEFAULT (0), "userMessage" text, "model" varchar(255), "promptTokens" integer, "completionTokens" integer, "totalTokens" integer, "cost" real, "timeline" text, "error" text, "hitlStatus" varchar(16), "source" varchar(32), "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')), "storedAt" varchar(2) NOT NULL DEFAULT ('db'), "attachments" text, "failureSummary" text, "author" text, CONSTRAINT "CHK_agent_execution_storedAt" CHECK ((("storedAt" IN ('db', 'fs', 's3', 'az')))), CONSTRAINT "CHK_agent_execution_hitlStatus" CHECK (((((("hitlStatus" IN ('suspended', 'resumed'))))))), CONSTRAINT "CHK_agent_execution_status" CHECK (("status" IN ('running', 'success', 'error', 'cancelled', 'interrupted'))), CONSTRAINT "FK_add2432fb6034cc18b6af299dce" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION) +CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId" varchar(128) NOT NULL, "status" varchar(16) NOT NULL, "startedAt" datetime(3), "stoppedAt" datetime(3), "duration" integer NOT NULL DEFAULT (0), "userMessage" text, "model" varchar(255), "promptTokens" integer, "completionTokens" integer, "totalTokens" integer, "cost" real, "timeline" text, "error" text, "hitlStatus" varchar(16), "source" varchar(32), "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')), "storedAt" varchar(2) NOT NULL DEFAULT ('db'), "attachments" text, "failureSummary" text, "author" text, "acceptsSteering" BOOLEAN NOT NULL DEFAULT FALSE, CONSTRAINT "CHK_agent_execution_storedAt" CHECK ((("storedAt" IN ('db', 'fs', 's3', 'az')))), CONSTRAINT "CHK_agent_execution_hitlStatus" CHECK (((((("hitlStatus" IN ('suspended', 'resumed'))))))), CONSTRAINT "CHK_agent_execution_status" CHECK (("status" IN ('running', 'success', 'error', 'cancelled', 'interrupted'))), CONSTRAINT "FK_add2432fb6034cc18b6af299dce" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION) ``` @@ -15,6 +15,7 @@ CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId | Name | Type | Default | Nullable | Children | Parents | Comment | | ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| acceptsSteering | BOOLEAN | FALSE | false | | | | | attachments | TEXT | | true | | | | | author | TEXT | | true | | | | | completionTokens | INTEGER | | true | | | | @@ -63,10 +64,12 @@ CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId erDiagram "agent_execution_message_links" |o--|| "agent_execution" : "FOREIGN KEY (executionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"agent_message_queue" }o--o| "agent_execution" : "FOREIGN KEY (steeringExecutionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE" "agent_message_queue" }o--o| "agent_execution" : "FOREIGN KEY (executionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE" "agent_execution" }o--|| "agent_execution_threads" : "FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "agent_execution" { + BOOLEAN acceptsSteering TEXT attachments TEXT author INTEGER completionTokens @@ -101,8 +104,10 @@ erDiagram datetime_3_ createdAt varchar_36_ executionId FK INTEGER id + varchar_36_ messageId FK TEXT payload - varchar_32_ source + varchar_36_ steeringExecutionId FK + INTEGER steeringOrder varchar_128_ threadId FK datetime_3_ updatedAt } diff --git a/docs/generated/sqlite-schema/agent_execution_message_links.md b/docs/generated/sqlite-schema/agent_execution_message_links.md index 8be37cce2e2..1c4b7e04013 100644 --- a/docs/generated/sqlite-schema/agent_execution_message_links.md +++ b/docs/generated/sqlite-schema/agent_execution_message_links.md @@ -56,6 +56,7 @@ erDiagram INTEGER position } "agent_execution" { + BOOLEAN acceptsSteering TEXT attachments TEXT author INTEGER completionTokens diff --git a/docs/generated/sqlite-schema/agent_execution_threads.md b/docs/generated/sqlite-schema/agent_execution_threads.md index 81d951aaaff..7cc1f6a6361 100644 --- a/docs/generated/sqlite-schema/agent_execution_threads.md +++ b/docs/generated/sqlite-schema/agent_execution_threads.md @@ -107,6 +107,7 @@ erDiagram varchar_36_ versionId } "agent_execution" { + BOOLEAN acceptsSteering TEXT attachments TEXT author INTEGER completionTokens @@ -134,8 +135,10 @@ erDiagram datetime_3_ createdAt varchar_36_ executionId FK INTEGER id + varchar_36_ messageId FK TEXT payload - varchar_32_ source + varchar_36_ steeringExecutionId FK + INTEGER steeringOrder varchar_128_ threadId FK datetime_3_ updatedAt } diff --git a/docs/generated/sqlite-schema/agent_message_queue.md b/docs/generated/sqlite-schema/agent_message_queue.md index db3fd0b8af7..53112839dbc 100644 --- a/docs/generated/sqlite-schema/agent_message_queue.md +++ b/docs/generated/sqlite-schema/agent_message_queue.md @@ -6,7 +6,7 @@ Table Definition ```sql -CREATE TABLE "agent_message_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "threadId" varchar(128) NOT NULL, "source" varchar(32) NOT NULL, "payload" text NOT NULL, "executionId" varchar(36), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "FK_a74f9154a59430986112d70cb16" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE, CONSTRAINT "FK_2349d84b4f2a660fc264f38fef3" FOREIGN KEY ("executionId") REFERENCES "agent_execution" ("id") ON DELETE NO ACTION) +CREATE TABLE "agent_message_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "threadId" varchar(128) NOT NULL, "payload" text NOT NULL, "executionId" varchar(36), "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')), "messageId" varchar(36) NOT NULL, "steeringExecutionId" varchar(36), "steeringOrder" integer, CONSTRAINT "FK_2349d84b4f2a660fc264f38fef3" FOREIGN KEY ("executionId") REFERENCES "agent_execution" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_a74f9154a59430986112d70cb16" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_agent_message_queue_messageId" FOREIGN KEY ("messageId") REFERENCES "agents_messages" ("id") ON DELETE CASCADE, CONSTRAINT "FK_eff54787927c2968dc24495c911" FOREIGN KEY ("steeringExecutionId") REFERENCES "agent_execution" ("id") ON DELETE NO ACTION) ``` @@ -18,8 +18,10 @@ CREATE TABLE "agent_message_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT N | createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | | executionId | varchar(36) | | true | | [agent_execution](agent_execution.md) | | | id | INTEGER | | false | | | | +| messageId | varchar(36) | | false | | [agents_messages](agents_messages.md) | | | payload | TEXT | | false | | | | -| source | varchar(32) | | false | | | | +| steeringExecutionId | varchar(36) | | true | | [agent_execution](agent_execution.md) | | +| steeringOrder | INTEGER | | true | | | | | threadId | varchar(128) | | false | | [agent_execution_threads](agent_execution_threads.md) | | | updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | @@ -27,8 +29,10 @@ CREATE TABLE "agent_message_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT N | Name | Type | Definition | | ---- | ---- | ---------- | -| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (executionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE | -| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | +| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (steeringExecutionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE | +| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (messageId) REFERENCES agents_messages (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | +| - (Foreign key ID: 2) | FOREIGN KEY | FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | +| - (Foreign key ID: 3) | FOREIGN KEY | FOREIGN KEY (executionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE | | id | PRIMARY KEY | PRIMARY KEY (id) | ## Indexes @@ -36,6 +40,8 @@ CREATE TABLE "agent_message_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT N | Name | Definition | | ---- | ---------- | | IDX_2349d84b4f2a660fc264f38fef | CREATE INDEX "IDX_2349d84b4f2a660fc264f38fef" ON "agent_message_queue" ("executionId") | +| IDX_agent_message_queue_messageId | CREATE UNIQUE INDEX "IDX_agent_message_queue_messageId" ON "agent_message_queue" ("messageId") | +| IDX_agent_message_queue_steeringExecutionId_steeringOrder | CREATE UNIQUE INDEX "IDX_agent_message_queue_steeringExecutionId_steeringOrder" ON "agent_message_queue" ("steeringExecutionId", "steeringOrder") WHERE "steeringExecutionId" IS NOT NULL | | IDX_agent_message_queue_threadId | CREATE UNIQUE INDEX "IDX_agent_message_queue_threadId" ON "agent_message_queue" ("threadId") WHERE "executionId" IS NOT NULL | | IDX_c1db6ea2d031cc49100535e6c6 | CREATE INDEX "IDX_c1db6ea2d031cc49100535e6c6" ON "agent_message_queue" ("threadId", "id") | @@ -45,18 +51,23 @@ CREATE TABLE "agent_message_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT N erDiagram "agent_message_queue" }o--o| "agent_execution" : "FOREIGN KEY (executionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE" +"agent_message_queue" }o--|| "agents_messages" : "FOREIGN KEY (messageId) REFERENCES agents_messages (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"agent_message_queue" }o--o| "agent_execution" : "FOREIGN KEY (steeringExecutionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE" "agent_message_queue" }o--|| "agent_execution_threads" : "FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "agent_message_queue" { datetime_3_ createdAt varchar_36_ executionId FK INTEGER id + varchar_36_ messageId FK TEXT payload - varchar_32_ source + varchar_36_ steeringExecutionId FK + INTEGER steeringOrder varchar_128_ threadId FK datetime_3_ updatedAt } "agent_execution" { + BOOLEAN acceptsSteering TEXT attachments TEXT author INTEGER completionTokens @@ -80,6 +91,20 @@ erDiagram datetime_3_ updatedAt TEXT userMessage } +"agents_messages" { + TEXT author + TEXT content + datetime_3_ createdAt + varchar_36_ id PK + TEXT modelContent + DATETIME modelContextAt + TEXT origin + varchar_255_ resourceId + varchar_36_ role + varchar_255_ threadId FK + varchar_36_ type + datetime_3_ updatedAt +} "agent_execution_threads" { varchar_16_ accessScope varchar_36_ agentId FK diff --git a/docs/generated/sqlite-schema/agents_messages.md b/docs/generated/sqlite-schema/agents_messages.md index f3cedce58bd..5c116ce4a3d 100644 --- a/docs/generated/sqlite-schema/agents_messages.md +++ b/docs/generated/sqlite-schema/agents_messages.md @@ -18,7 +18,7 @@ CREATE TABLE "agents_messages" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId | author | TEXT | | true | | | | | content | TEXT | | false | | | | | createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | -| id | varchar(36) | | false | [agent_execution_message_links](agent_execution_message_links.md) [agents_memory_entry_candidates](agents_memory_entry_candidates.md) | | | +| id | varchar(36) | | false | [agent_execution_message_links](agent_execution_message_links.md) [agent_message_queue](agent_message_queue.md) [agents_memory_entry_candidates](agents_memory_entry_candidates.md) | | | | modelContent | TEXT | | true | | | | | modelContextAt | DATETIME | | true | | | | | origin | TEXT | | true | | | | @@ -52,6 +52,7 @@ CREATE TABLE "agents_messages" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId erDiagram "agent_execution_message_links" |o--|| "agents_messages" : "FOREIGN KEY (messageId) REFERENCES agents_messages (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"agent_message_queue" }o--|| "agents_messages" : "FOREIGN KEY (messageId) REFERENCES agents_messages (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "agents_memory_entry_candidates" }o--o| "agents_messages" : "FOREIGN KEY (sourceMessageId) REFERENCES agents_messages (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE" "agents_messages" }o--|| "agents_threads" : "FOREIGN KEY (threadId) REFERENCES agents_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" @@ -76,6 +77,17 @@ erDiagram varchar_36_ messageId PK INTEGER position } +"agent_message_queue" { + datetime_3_ createdAt + varchar_36_ executionId FK + INTEGER id + varchar_36_ messageId FK + TEXT payload + varchar_36_ steeringExecutionId FK + INTEGER steeringOrder + varchar_128_ threadId FK + datetime_3_ updatedAt +} "agents_memory_entry_candidates" { varchar_36_ agentId FK smallint attemptCount diff --git a/docs/generated/sqlite-schema/trusted_source.md b/docs/generated/sqlite-schema/trusted_source.md new file mode 100644 index 00000000000..ea5221dfe21 --- /dev/null +++ b/docs/generated/sqlite-schema/trusted_source.md @@ -0,0 +1,81 @@ +# trusted_source + +## Description + +
+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'))) +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| config | TEXT | | false | | | | +| configVersion | INTEGER | | false | | | | +| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| 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 | | | | +| name | varchar(128) | | false | | | | +| status | varchar(16) | | false | | | | +| type | varchar(32) | | false | | | | +| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| id | PRIMARY KEY | PRIMARY KEY (id) | +| sqlite_autoindex_trusted_source_1 | PRIMARY KEY | PRIMARY KEY (id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| IDX_519e3445a1740f2ddbab2749b5 | CREATE UNIQUE INDEX "IDX_519e3445a1740f2ddbab2749b5" ON "trusted_source" ("issuer") | +| IDX_d3055524099cd341a2af4b8eb9 | CREATE UNIQUE INDEX "IDX_d3055524099cd341a2af4b8eb9" ON "trusted_source" ("name") | +| sqlite_autoindex_trusted_source_1 | PRIMARY KEY (id) | + +## Relations + +```mermaid +erDiagram + +"trusted_source_identity" |o--|| "trusted_source" : "FOREIGN KEY (sourceId) REFERENCES trusted_source (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" + +"trusted_source" { + TEXT config + INTEGER configVersion + datetime_3_ createdAt + varchar_36_ id PK + varchar issuer + datetime_3_ lastCheckedAt + TEXT lastError + varchar_16_ managedBy + varchar_128_ name + varchar_16_ status + varchar_32_ type + datetime_3_ updatedAt +} +"trusted_source_identity" { + datetime_3_ createdAt + datetime_3_ lastSeenAt + varchar_32_ provenance + varchar_36_ sourceId PK + varchar_16_ status + varchar subject PK + datetime_3_ updatedAt + varchar userId FK +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/sqlite-schema/trusted_source_identity.md b/docs/generated/sqlite-schema/trusted_source_identity.md new file mode 100644 index 00000000000..2637d6acc24 --- /dev/null +++ b/docs/generated/sqlite-schema/trusted_source_identity.md @@ -0,0 +1,97 @@ +# trusted_source_identity + +## Description + +
+Table Definition + +```sql +CREATE TABLE "trusted_source_identity" ("sourceId" varchar(36) NOT NULL, "subject" varchar NOT NULL, "userId" varchar NOT NULL, "provenance" varchar(32) NOT NULL, "status" varchar(16) NOT NULL, "lastSeenAt" datetime(3), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "trusted_source_identity_sourceId_foreign" FOREIGN KEY ("sourceId") REFERENCES "trusted_source" ("id") ON DELETE CASCADE, CONSTRAINT "trusted_source_identity_userId_foreign" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE, PRIMARY KEY ("sourceId", "subject")) +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| lastSeenAt | datetime(3) | | true | | | | +| provenance | varchar(32) | | false | | | | +| sourceId | varchar(36) | | false | | [trusted_source](trusted_source.md) | | +| status | varchar(16) | | false | | | | +| subject | varchar | | false | | | | +| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| userId | varchar | | false | | [user](user.md) | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | +| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (sourceId) REFERENCES trusted_source (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | +| sourceId | PRIMARY KEY | PRIMARY KEY (sourceId) | +| sqlite_autoindex_trusted_source_identity_1 | PRIMARY KEY | PRIMARY KEY (sourceId, subject) | +| subject | PRIMARY KEY | PRIMARY KEY (subject) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| IDX_bcc1e89f2ab58755614b601168 | CREATE INDEX "IDX_bcc1e89f2ab58755614b601168" ON "trusted_source_identity" ("userId") | +| sqlite_autoindex_trusted_source_identity_1 | PRIMARY KEY (sourceId, subject) | + +## Relations + +```mermaid +erDiagram + +"trusted_source_identity" |o--|| "trusted_source" : "FOREIGN KEY (sourceId) REFERENCES trusted_source (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"trusted_source_identity" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" + +"trusted_source_identity" { + datetime_3_ createdAt + datetime_3_ lastSeenAt + varchar_32_ provenance + varchar_36_ sourceId PK + varchar_16_ status + varchar subject PK + datetime_3_ updatedAt + varchar userId FK +} +"trusted_source" { + TEXT config + INTEGER configVersion + datetime_3_ createdAt + varchar_36_ id PK + varchar issuer + datetime_3_ lastCheckedAt + TEXT lastError + varchar_16_ managedBy + varchar_128_ name + varchar_16_ status + varchar_32_ type + datetime_3_ updatedAt +} +"user" { + datetime_3_ createdAt + boolean disabled + varchar_255_ email + varchar_32_ firstName + varchar id PK + date lastActiveAt + varchar_32_ lastName + boolean mfaEnabled + TEXT mfaRecoveryCodes + TEXT mfaSecret + varchar password + TEXT personalizationAnswers + varchar_128_ roleSlug FK + TEXT settings + datetime_3_ updatedAt +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/sqlite-schema/user.md b/docs/generated/sqlite-schema/user.md index 224c564ae96..d065f1df34f 100644 --- a/docs/generated/sqlite-schema/user.md +++ b/docs/generated/sqlite-schema/user.md @@ -19,7 +19,7 @@ CREATE TABLE "user" ("id" varchar PRIMARY KEY, "email" varchar(255), "firstName" | disabled | boolean | FALSE | false | | | | | email | varchar(255) | | true | | | | | firstName | varchar(32) | | true | | | | -| id | varchar | | true | [activity_event](activity_event.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_eval_rating](agent_eval_rating.md) [agent_eval_run](agent_eval_run.md) [agent_execution_threads](agent_execution_threads.md) [agent_history](agent_history.md) [ai_preference](ai_preference.md) [auth_identity](auth_identity.md) [chat_hub_agents](chat_hub_agents.md) [chat_hub_sessions](chat_hub_sessions.md) [chat_hub_tools](chat_hub_tools.md) [dynamic_credential_user_entry](dynamic_credential_user_entry.md) [evaluation_collection](evaluation_collection.md) [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) [instance_ai_thread_tabs](instance_ai_thread_tabs.md) [oauth_access_tokens](oauth_access_tokens.md) [oauth_authorization_codes](oauth_authorization_codes.md) [oauth_refresh_tokens](oauth_refresh_tokens.md) [oauth_user_consents](oauth_user_consents.md) [project](project.md) [project_relation](project_relation.md) [user_api_keys](user_api_keys.md) [user_favorites](user_favorites.md) [workflow_builder_session](workflow_builder_session.md) [workflow_publish_history](workflow_publish_history.md) [workflow_review_activity](workflow_review_activity.md) [workflow_review_activity_comment](workflow_review_activity_comment.md) [workflow_review_request](workflow_review_request.md) [workflow_review_request_authors](workflow_review_request_authors.md) [workflow_review_request_reviewers](workflow_review_request_reviewers.md) | | | +| id | varchar | | true | [activity_event](activity_event.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_eval_rating](agent_eval_rating.md) [agent_eval_run](agent_eval_run.md) [agent_execution_threads](agent_execution_threads.md) [agent_history](agent_history.md) [ai_preference](ai_preference.md) [auth_identity](auth_identity.md) [chat_hub_agents](chat_hub_agents.md) [chat_hub_sessions](chat_hub_sessions.md) [chat_hub_tools](chat_hub_tools.md) [dynamic_credential_user_entry](dynamic_credential_user_entry.md) [evaluation_collection](evaluation_collection.md) [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) [instance_ai_thread_tabs](instance_ai_thread_tabs.md) [oauth_access_tokens](oauth_access_tokens.md) [oauth_authorization_codes](oauth_authorization_codes.md) [oauth_refresh_tokens](oauth_refresh_tokens.md) [oauth_user_consents](oauth_user_consents.md) [project](project.md) [project_relation](project_relation.md) [trusted_source_identity](trusted_source_identity.md) [user_api_keys](user_api_keys.md) [user_favorites](user_favorites.md) [workflow_builder_session](workflow_builder_session.md) [workflow_publish_history](workflow_publish_history.md) [workflow_review_activity](workflow_review_activity.md) [workflow_review_activity_comment](workflow_review_activity_comment.md) [workflow_review_request](workflow_review_request.md) [workflow_review_request_authors](workflow_review_request_authors.md) [workflow_review_request_reviewers](workflow_review_request_reviewers.md) | | | | lastActiveAt | date | | true | | | | | lastName | varchar(32) | | true | | | | | mfaEnabled | boolean | FALSE | false | | | | @@ -77,6 +77,7 @@ erDiagram "oauth_user_consents" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "project" }o--o| "user" : "FOREIGN KEY (creatorId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE" "project_relation" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"trusted_source_identity" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "user_api_keys" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "user_favorites" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "workflow_builder_session" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" @@ -359,6 +360,16 @@ erDiagram datetime_3_ updatedAt varchar userId PK } +"trusted_source_identity" { + datetime_3_ createdAt + datetime_3_ lastSeenAt + varchar_32_ provenance + varchar_36_ sourceId PK + varchar_16_ status + varchar subject PK + datetime_3_ updatedAt + varchar userId FK +} "user_api_keys" { varchar apiKey varchar audience diff --git a/knip.ts b/knip.ts index 445f0d8bfa5..df6e7b37c2d 100644 --- a/knip.ts +++ b/knip.ts @@ -93,6 +93,10 @@ const config: KnipConfig = { '@types/psl', ], }), + 'packages/@n8n/backend-services': pkg({ + // ioredis-mock is loaded with vi.importActual, which knip does not link. + ignoreDependencies: ['ioredis-mock'], + }), 'packages/@n8n/instance-ai': pkg({ // psl is loaded with a dynamic import, which knip does not link to @types. ignoreDependencies: ['@types/psl'], diff --git a/package.json b/package.json index c51e303cccc..4e2211cbe0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "2.41.0", + "version": "2.42.0", "private": true, "engines": { "node": ">=24.0.0", @@ -54,6 +54,9 @@ "agent:test": "node scripts/agent-check.mjs test", "agent:playwright": "node scripts/agent-check.mjs playwright", "agent:harness": "node scripts/agent-harness.mjs", + "profile:vitest": "node scripts/vitest-profile.mjs", + "profile:ci-tests": "node scripts/ci-test-phase-profile.mjs", + "test:inventory": "node scripts/test-inventory.mjs", "format:check": "turbo run format:check", "lint": "turbo run lint", "lint:styles": "turbo run lint:styles", diff --git a/packages/@n8n/agents/package.json b/packages/@n8n/agents/package.json index 393e6d442fa..b10ec97b1d2 100644 --- a/packages/@n8n/agents/package.json +++ b/packages/@n8n/agents/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/agents", - "version": "0.26.0", + "version": "0.27.0", "description": "AI agent SDK for n8n's code-first execution engine", "engines": { "node": ">=24.0.0" diff --git a/packages/@n8n/agents/src/index.ts b/packages/@n8n/agents/src/index.ts index cd237cce5dd..a7b3e9cf85a 100644 --- a/packages/@n8n/agents/src/index.ts +++ b/packages/@n8n/agents/src/index.ts @@ -52,6 +52,7 @@ export type { EpisodicMemoryMethods, EpisodicMemoryPrompts, EpisodicMemoryReflectFn, + EpisodicMemoryReflectResult, EpisodicMemoryReflection, EpisodicMemoryReflectionApply, EpisodicMemoryReflectionApplyMerge, @@ -75,9 +76,12 @@ export type { McpVerifyResult, ModelConfig, ExecutionOptions, + AgentInputBoundary, SmoothStreamOptions, TokenUsage, AgentExecutionCounter, + SideCallTask, + SideCallUsageReport, PersistedExecutionOptions, AnthropicPromptCachingConfig, OpenAIPromptCachingConfig, @@ -93,8 +97,10 @@ export type { ObservationLogEntry, ObservationLogMarker, ObservationLogMerge, + ObservationLogObserveResult, ObservationLogReadOptions, ObservationLogReflection, + ObservationLogReflectResult, ObservationLogReflectionResult, ObservationLogScope, ObservationLogStatus, @@ -369,6 +375,7 @@ export { export type { RunEpisodicMemoryCandidateProcessorOpts, RunEpisodicMemoryCandidateProcessorResult, + EpisodicMemoryUsageReport, } from './runtime/memory/episodic-memory-capture'; export { DEFAULT_EPISODIC_MEMORY_CAPTURE_TOOL_INSTRUCTION, diff --git a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts index 00128c327ce..779fd1a0ea8 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts @@ -16,7 +16,7 @@ import type { RuntimeSkillSource } from '../../skills/types'; import type { CheckpointStore, ModelConfig, SerializableAgentState } from '../../types'; import { AgentEvent } from '../../types/runtime/event'; import type { AgentEventData } from '../../types/runtime/event'; -import type { StreamChunk } from '../../types/sdk/agent'; +import type { ExecutionOptions, StreamChunk } from '../../types/sdk/agent'; import type { AgentDbMessage, ContentToolCall, Message } from '../../types/sdk/message'; import type { BuiltTool, InterruptibleToolContext, ToolContext } from '../../types/sdk/tool'; import type { BuiltTelemetry } from '../../types/telemetry'; @@ -173,7 +173,9 @@ async function collectChunks(stream: ReadableStream): Promise { runtime: AgentRuntime, mode: 'generate' | 'stream', messages: Message[] | string = [structuredClone(input)], - abortSignal?: AbortSignal, + options: Pick = {}, ) { if (mode === 'generate') { - const result = await runtime.generate(messages, { persistence, abortSignal }); + const result = await runtime.generate(messages, { persistence, ...options }); return result.error; } - const result = await runtime.stream(messages, { persistence, abortSignal }); + const result = await runtime.stream(messages, { persistence, ...options }); const chunks = await collectChunks(result.stream); return chunks.find((chunk) => chunk.type === 'error'); } @@ -608,7 +610,9 @@ describe('AgentRuntime — rejected attachments', () => { await deleteMessages(ids); }); rejectModel(); - await run(buildRuntime(memory), mode, [structuredClone(input)], controller.signal); + await run(buildRuntime(memory), mode, [structuredClone(input)], { + abortSignal: controller.signal, + }); expect(await memory.getMessages(persistence.threadId)).toEqual([]); }, ); @@ -637,37 +641,79 @@ describe('AgentRuntime — rejected attachments', () => { }, ); - it.each(['generate', 'stream'] as const)('%s preserves input after a tool call', async (mode) => { - const memory = new InMemoryMemory(); - const remove = vi.spyOn(memory, 'deleteMessages'); - const handler = vi.fn().mockResolvedValue('Done'); - const tool: BuiltTool = { - name: 'search', - description: 'Search', - inputSchema: z.object({}), - handler, - }; - const response = makeGenerateWithToolCalls([ - { toolCallId: 'search-1', toolName: 'search', args: {} }, - ]); - generateText.mockResolvedValueOnce(response); - streamText.mockReturnValueOnce({ - ...makeStreamSuccess(), - stream: makeChunkStream([{ type: 'tool-call', ...response.toolCalls[0] }]), - finishReason: Promise.resolve('tool-calls'), - response: Promise.resolve(response.response), - toolCalls: Promise.resolve(response.toolCalls), - }); - rejectModel(); - await run(buildRuntime(memory, [tool]), mode); - expect(handler).toHaveBeenCalledOnce(); - expect(remove).not.toHaveBeenCalled(); - expect(await memory.getMessages(persistence.threadId)).toEqual( - expect.arrayContaining([expect.objectContaining({ id: input.id })]), - ); - }); + it.each([ + { mode: 'generate', steer: false }, + { mode: 'generate', steer: true }, + { mode: 'stream', steer: false }, + { mode: 'stream', steer: true }, + ] as const)( + '$mode preserves accepted input after a tool call (steer: $steer)', + async ({ mode, steer }) => { + const memory = new InMemoryMemory(); + const remove = vi.spyOn(memory, 'deleteMessages'); + const steered: AgentDbMessage = { + ...structuredClone(input), + id: 'steered-input', + createdAt: new Date(Date.now() + 100), + }; + steered.content[0] = { type: 'text', text: 'Steered image' }; + const handler = vi.fn().mockResolvedValue('Done'); + const tool: BuiltTool = { + name: 'search', + description: 'Search', + inputSchema: z.object({}), + handler, + }; + const response = makeGenerateWithToolCalls([ + { toolCallId: 'search-1', toolName: 'search', args: {} }, + ]); + generateText.mockResolvedValueOnce(response); + streamText.mockReturnValueOnce({ + ...makeStreamSuccess(), + stream: makeChunkStream([{ type: 'tool-call', ...response.toolCalls[0] }]), + finishReason: Promise.resolve('tool-calls'), + response: Promise.resolve(response.response), + toolCalls: Promise.resolve(response.toolCalls), + }); + rejectModel(); + const onInputBoundary = vi.fn(async () => { + if (!steer || handler.mock.calls.length === 0) return []; + await memory.saveMessages({ ...persistence, messages: [steered] }); + return [steered]; + }); + const runtime = buildRuntime(memory, [tool]); + expect(await run(runtime, mode, [structuredClone(input)], { onInputBoundary })).toBeDefined(); + expect(handler).toHaveBeenCalledOnce(); + if (steer) { + expect(remove).toHaveBeenCalledExactlyOnceWith([steered.id]); + expect(runtime.getState().messageList.inputIds).toEqual([input.id]); + } else expect(remove).not.toHaveBeenCalled(); + expect(await memory.getMessages(persistence.threadId)).toEqual( + expect.arrayContaining([expect.objectContaining({ id: input.id })]), + ); + expect(await memory.getMessages(persistence.threadId)).not.toEqual( + expect.arrayContaining([expect.objectContaining({ id: steered.id })]), + ); + generateText.mockResolvedValueOnce(makeGenerateSuccess()); + streamText.mockReturnValueOnce(makeStreamSuccess()); + expect(await run(buildRuntime(memory), mode, 'Continue')).toBeUndefined(); + const model = mode === 'generate' ? generateText : streamText; + expect(model.mock.calls[2][0].messages).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + content: expect.arrayContaining([{ type: 'text', text: 'Steered image' }]), + }), + ]), + ); + }, + ); - it.each(['generate', 'stream'] as const)('%s leaves resumed input unchanged', async (mode) => { + it.each([ + { mode: 'generate', steer: false }, + { mode: 'generate', steer: true }, + { mode: 'stream', steer: false }, + { mode: 'stream', steer: true }, + ] as const)('$mode preserves resumed input (steer: $steer)', async ({ mode, steer }) => { const memory = new InMemoryMemory(); const remove = vi.spyOn(memory, 'deleteMessages'); const runtime = buildRuntime(memory, [makeInterruptibleTool()]); @@ -678,20 +724,265 @@ describe('AgentRuntime — rejected attachments', () => { ); const first = await runtime.generate([structuredClone(input)], { persistence }); const { runId, toolCallId } = first.pendingSuspend![0]; + const steered: AgentDbMessage = { + ...structuredClone(input), + id: 'steered-input', + createdAt: new Date(Date.now() + 100), + }; + const onInputBoundary = async () => { + if (!steer) return []; + await memory.saveMessages({ ...persistence, messages: [steered] }); + return [steered]; + }; rejectModel(); if (mode === 'generate') { - await runtime.resume('generate', { approved: true }, { runId, toolCallId }); + await runtime.resume('generate', { approved: true }, { runId, toolCallId, onInputBoundary }); } else { - const resumed = await runtime.resume('stream', { approved: true }, { runId, toolCallId }); + const resumed = await runtime.resume( + 'stream', + { approved: true }, + { runId, toolCallId, onInputBoundary }, + ); await collectChunks(resumed.stream); } - expect(remove).not.toHaveBeenCalled(); + if (steer) expect(remove).toHaveBeenCalledExactlyOnceWith([steered.id]); + else expect(remove).not.toHaveBeenCalled(); + expect(runtime.getState().messageList.inputIds).toEqual([input.id]); expect(await memory.getMessages(persistence.threadId)).toEqual( expect.arrayContaining([expect.objectContaining({ id: input.id })]), ); + expect(await memory.getMessages(persistence.threadId)).not.toEqual( + expect.arrayContaining([expect.objectContaining({ id: steered.id })]), + ); }); }); +describe('AgentRuntime — additional input', () => { + beforeEach(() => { + generateText.mockReset(); + streamText.mockReset(); + }); + + function input(text: string): AgentDbMessage { + return { + id: crypto.randomUUID(), + role: 'user', + content: [{ type: 'text', text }], + createdAt: new Date(Date.now() + 100), + }; + } + + it('waits for the complete tool batch before applying input in order', async () => { + const started = createDeferredPromise(); + const release = createDeferredPromise(); + const handler = vi.fn(async () => { + started.resolve(); + await release.promise; + return 'tool result'; + }); + const tool: BuiltTool = { + name: 'search', + description: 'Search', + inputSchema: z.object({}), + handler, + }; + const response = makeGenerateWithToolCall('search-1', 'search', {}); + streamText + .mockReturnValueOnce({ + ...makeStreamSuccess(), + stream: makeChunkStream([{ type: 'tool-call', ...response.toolCalls[0] }]), + finishReason: Promise.resolve('tool-calls'), + response: Promise.resolve(response.response), + toolCalls: Promise.resolve(response.toolCalls), + }) + .mockReturnValue(makeStreamSuccess('after')); + const runtime = new AgentRuntime({ + name: 'test', + model: 'openai/gpt-4o-mini', + instructions: 'Test', + tools: [tool], + eventBus: new AgentEventBus(), + }); + const pending = [input('C'), input('D')]; + let available = false; + const onInputBoundary = vi.fn(async () => { + if (!available) return []; + available = false; + return pending; + }); + const counter = makeExecutionCounter(); + const result = await runtime.stream('A', { onInputBoundary, executionCounter: counter }); + const chunksPromise = collectChunks(result.stream); + await started.promise; + available = true; + expect(onInputBoundary).toHaveBeenCalledTimes(1); + expect(streamText).toHaveBeenCalledTimes(1); + release.resolve(); + const chunks = await chunksPromise; + const messages = streamText.mock.calls[1][0].messages as Array<{ + role: string; + content: unknown; + }>; + expect(messages.filter(({ role }) => role === 'user').map(({ content }) => content)).toEqual([ + [{ type: 'text', text: 'A' }], + [{ type: 'text', text: 'C' }], + [{ type: 'text', text: 'D' }], + ]); + expect(messages.findIndex(({ role }) => role === 'tool')).toBeLessThan( + messages.findIndex(({ content }) => JSON.stringify(content).includes('"C"')), + ); + expect(chunks.filter((chunk) => chunk.type === 'input').map(({ message }) => message)).toEqual( + pending, + ); + expect(counter.incrementMessageCount).toHaveBeenCalledTimes(1); + expect(counter.incrementToolCallCount).toHaveBeenCalledTimes(1); + }); + + it.each([ + { maxIterations: 1, finishReason: 'stop' }, + { maxIterations: 2, finishReason: 'stop' }, + { maxIterations: 1, finishReason: 'error' }, + { maxIterations: 2, finishReason: 'error' }, + ])( + 'handles $finishReason completion with a $maxIterations-call limit', + async ({ maxIterations, finishReason }) => { + streamText.mockReturnValue(makeStreamSuccess('response')); + const error = new Error('Model stream failed'); + if (finishReason === 'error') { + streamText.mockReturnValueOnce({ + ...makeStreamSuccess('partial response'), + stream: makeChunkStream([ + { type: 'text-delta', id: 'text-1', text: 'partial response' }, + { type: 'error', error }, + ]), + finishReason: Promise.resolve('error'), + }); + } + const pending = input('C'); + let consumed = false; + const onInputBoundary = vi.fn( + async ({ completing, canContinue }: { completing: boolean; canContinue: boolean }) => { + if (!completing || !canContinue || consumed) return []; + consumed = true; + return [pending]; + }, + ); + const { runtime } = createRuntime(); + const chunks = await collectChunks( + (await runtime.stream('A', { maxIterations, onInputBoundary })).stream, + ); + expect(streamText).toHaveBeenCalledTimes(finishReason === 'error' ? 1 : maxIterations); + expect(consumed).toBe(finishReason === 'stop' && maxIterations === 2); + expect(chunks.filter((chunk) => chunk.type === 'input')).toHaveLength(consumed ? 1 : 0); + if (finishReason === 'error') { + expect(onInputBoundary).toHaveBeenCalledTimes(1); + expect(chunks).toContainEqual({ type: 'error', error }); + } else { + expect(onInputBoundary).toHaveBeenLastCalledWith( + expect.objectContaining({ completing: true, canContinue: false }), + ); + } + expect(chunks.at(-1)).toMatchObject({ type: 'finish', finishReason }); + }, + ); + + it('applies the attachment budget to earlier input and steered input together', async () => { + const bytes = new Uint8Array(40 * 1024 * 1024); + const runtime = new AgentRuntime({ + name: 'attachment-test', + model: 'openai/gpt-4o-mini', + instructions: 'Test', + fileStore: { load: vi.fn().mockResolvedValue(bytes) }, + }); + const earlier = input('A'); + const steered = input('C'); + for (const message of [earlier, steered]) { + if ('role' in message) { + message.content.push({ + type: 'file', + mediaType: 'image/png', + fileRef: { id: message.id, sizeBytes: bytes.byteLength }, + }); + } + } + generateText.mockResolvedValue(makeGenerateSuccess()); + const onInputBoundary = vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([steered]) + .mockResolvedValue([]); + + const result = await runtime.generate([earlier], { maxIterations: 2, onInputBoundary }); + + expect(result.error).toBeUndefined(); + expect(generateText).toHaveBeenCalledTimes(2); + const messages: aiModule.ModelMessage[] = generateText.mock.calls[1][0].messages ?? []; + const files = messages.flatMap((message) => { + if (!Array.isArray(message.content)) return []; + return message.content.filter((part) => part.type === 'file'); + }); + expect(files).toHaveLength(1); + expect(files[0].data).toBe(bytes); + }); + + it('discards earlier structured output when steered input reaches the iteration limit', async () => { + const tool: BuiltTool = { + name: 'search', + description: 'Search', + inputSchema: z.object({}), + handler: async () => 'tool result', + }; + const response = makeGenerateWithToolCall('search-1', 'search', {}); + streamText + .mockReturnValueOnce(makeStreamSuccessWithOutput({ answer: 'A', score: 1 })) + .mockReturnValueOnce({ + ...makeStreamSuccess(), + stream: makeChunkStream([{ type: 'tool-call', ...response.toolCalls[0] }]), + finishReason: Promise.resolve('tool-calls'), + response: Promise.resolve(response.response), + toolCalls: Promise.resolve(response.toolCalls), + }); + const onInputBoundary = vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([input('C')]) + .mockResolvedValue([]); + const { runtime } = createStructuredRuntime({ tools: [tool] }); + const chunks = await collectChunks( + (await runtime.stream('A', { maxIterations: 2, onInputBoundary })).stream, + ); + + expect(streamText).toHaveBeenCalledTimes(2); + expect(chunks.at(-1)).toMatchObject({ + type: 'finish', + finishReason: 'max-iterations', + }); + expect(chunks.at(-1)).not.toHaveProperty('structuredOutput'); + }); + + it.each(['abort', 'disconnect'] as const)( + 'releases an unacknowledged boundary on %s', + async (action) => { + streamText.mockReturnValue(makeStreamSuccess()); + const onInputBoundary = vi.fn().mockResolvedValue([]); + const controller = new AbortController(); + const { runtime } = createRuntime(); + const result = await runtime.stream('A', { abortSignal: controller.signal, onInputBoundary }); + const reader = result.stream.getReader(); + expect((await reader.read()).value).toMatchObject({ type: 'input-boundary' }); + expect(onInputBoundary).not.toHaveBeenCalled(); + if (action === 'disconnect') await reader.cancel(); + else { + controller.abort(); + while (!(await reader.read()).done) { + /* Drain terminal chunks. */ + } + } + expect(streamText).not.toHaveBeenCalled(); + }, + ); +}); + describe('AgentRuntime — execution counters', () => { beforeEach(() => { generateText.mockReset(); diff --git a/packages/@n8n/agents/src/runtime/__tests__/episodic-memory.test.ts b/packages/@n8n/agents/src/runtime/__tests__/episodic-memory.test.ts index a3a608ce1c7..c6078459094 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/episodic-memory.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/episodic-memory.test.ts @@ -577,7 +577,12 @@ describe('agent-directed episodic capture', () => { scope: { resourceId: 'user-1', threadId: 'thread-1' }, now: new Date('2026-05-12T11:00:00.000Z'), }), - ).resolves.toEqual({ status: 'ran', entriesWritten: 1, candidatesProcessed: 1 }); + ).resolves.toEqual({ + status: 'ran', + entriesWritten: 1, + candidatesProcessed: 1, + usageReports: expect.any(Array), + }); // Reflection only runs for corrections; a plain preference must not pay for it. expect(reflect).not.toHaveBeenCalled(); @@ -767,4 +772,101 @@ describe('agent-directed episodic capture', () => { ), ).resolves.toHaveLength(1); }); + + it('never fails candidate processing when the embed onUsage throws or rejects', async () => { + // Pricing is best-effort: a misbehaving onUsage callback (sync throw or + // rejected promise) at the embed forwarding site must not abort + // candidate processing or surface an unhandled rejection. Entries are + // still saved. + const syncThrow = vi.fn(() => { + throw new Error('pricing sync boom'); + }); + const rejecting = vi.fn(async () => { + throw new Error('pricing async boom'); + }); + + for (const onUsage of [syncThrow, rejecting]) { + const memory = new InMemoryMemory(); + await enqueueCandidate(memory); + mockedEmbedMany.mockResolvedValue({ + embeddings: [[1, 0]], + usage: { tokens: 6 }, + } as never); + + const result = await runEpisodicMemoryCandidateProcessor({ + memory, + config: { embedder: fakeEmbedder }, + scope: { resourceId: 'user-1', threadId: 'thread-1' }, + onUsage, + }); + + expect(result).toMatchObject({ status: 'ran' }); + expect(onUsage).toHaveBeenCalledTimes(1); + await expect( + memory.episodic.searchEntries( + { resourceId: 'user-1', threadId: 'thread-1' }, + 'concise reports', + ), + ).resolves.toHaveLength(1); + } + }); + + it('never fails reflection when the reflect onUsage throws or rejects', async () => { + // The reflect forwarding site must not abort reflection when onUsage + // throws or rejects. The reflection's merge still applies. + const syncThrow = vi.fn(() => { + throw new Error('pricing sync boom'); + }); + const rejecting = vi.fn(async () => { + throw new Error('pricing async boom'); + }); + + for (const onUsage of [syncThrow, rejecting]) { + const memory = new InMemoryMemory(); + const legacy = await saveEpisodicEntry(memory, { + resourceId: 'user-1', + content: 'User planned SQLite for local-first memory storage.', + embedding: [0, 1], + embeddingModel: 'openai/text-embedding-3-small', + }); + await enqueueCandidate(memory, 'call-1', 'run-1', { + content: 'User switched memory storage from SQLite to Postgres.', + kind: 'correction', + }); + mockedEmbedMany.mockResolvedValue({ + embeddings: [ + [1, 0], + [1, 1], + ], + usage: { tokens: 6 }, + } as never); + + const result = await runEpisodicMemoryCandidateProcessor({ + memory, + config: { + embedder: fakeEmbedder, + reflect: async ({ seedEntryIds }) => + await Promise.resolve({ + reflection: { + drop: [], + merge: [ + { + supersedes: [legacy.id, seedEntryIds[0]], + content: 'User replaced the SQLite plan with Postgres.', + }, + ], + }, + usage: { promptTokens: 20, completionTokens: 5, totalTokens: 25 }, + model: 'openai/gpt-4o-mini', + }), + }, + scope: { resourceId: 'user-1', threadId: 'thread-1' }, + onUsage, + }); + + expect(result).toMatchObject({ status: 'ran' }); + // Embed + reflect + merge-embed = three forwarding calls per run. + expect(onUsage).toHaveBeenCalledTimes(3); + } + }); }); diff --git a/packages/@n8n/agents/src/runtime/__tests__/file-parts.test.ts b/packages/@n8n/agents/src/runtime/__tests__/file-parts.test.ts index 334c123b73c..cf0d74e5e2a 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/file-parts.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/file-parts.test.ts @@ -96,20 +96,37 @@ describe('hydrateFileParts', () => { expect(store.load).toHaveBeenCalledWith(block.fileRef, { threadId: 'thread-1' }); }); - it('hydrates only the newest parts up to the count cap', async () => { + it('keeps only the newest parts within the count cap across hydration calls', async () => { const blocks = Array.from({ length: MAX_HYDRATED_FILE_PARTS + 2 }, (_, index) => refFileBlock({ fileRef: { id: `att-${index}`, sizeBytes: 1 } }), ); const store: BuiltFileStore = { load: vi.fn().mockResolvedValue(bytes) }; + await hydrateFileParts([userMessage(blocks.slice(0, MAX_HYDRATED_FILE_PARTS))], store); await hydrateFileParts([userMessage(blocks)], store); - expect(store.load).toHaveBeenCalledTimes(MAX_HYDRATED_FILE_PARTS); + expect(store.load).toHaveBeenCalledTimes(MAX_HYDRATED_FILE_PARTS + 2); expect(blocks[0].data).toBeUndefined(); expect(blocks[1].data).toBeUndefined(); expect(blocks.at(-1)!.data).toBe(bytes); }); + it.each([bytes, bytes.buffer, 'AQID'])( + 'charges already-loaded data against the byte budget (%s)', + async (data) => { + const old = refFileBlock({ data }); + const newest = refFileBlock({ fileRef: { id: 'att-new' } }); + const large = new Uint8Array(MAX_HYDRATED_FILE_BYTES - 2); + const store: BuiltFileStore = { load: vi.fn().mockResolvedValue(large) }; + + await hydrateFileParts([userMessage([old, newest])], store); + + expect(old.data).toBeUndefined(); + expect(old.fileRef?.id).toBe('att-1'); + expect(newest.data).toBe(large); + }, + ); + it('spends the byte budget newest-first and skips parts that would exceed it', async () => { const oldSmall = refFileBlock({ fileRef: { id: 'att-old', sizeBytes: 3 } }); const hugeDeclared = refFileBlock({ diff --git a/packages/@n8n/agents/src/runtime/__tests__/memory-orchestrator-persist-input.test.ts b/packages/@n8n/agents/src/runtime/__tests__/memory-orchestrator-persist-input.test.ts index 9187b783b9b..43a8f0b765f 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/memory-orchestrator-persist-input.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/memory-orchestrator-persist-input.test.ts @@ -46,7 +46,7 @@ function textsOf(messages: AgentDbMessage[]): string[] { } describe('MemoryOrchestrator.discardRejectedInput', () => { - it('removes input from storage, deltas, and serialized state without changing history', async () => { + it('removes selected input from storage and state without changing history or accepted input', async () => { const store = new InMemoryMemory(); await store.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID }); const history: AgentDbMessage = { @@ -57,25 +57,29 @@ describe('MemoryOrchestrator.discardRejectedInput', () => { await store.saveMessages({ threadId: THREAD_ID, resourceId: RESOURCE_ID, messages: [history] }); const list = new AgentMessageList(); list.addHistory([history]); - list.addInput([userMsg('Rejected message'), userMsg('Additional input')]); + list.addInput([ + { ...userMsg('Accepted message'), id: 'accepted', createdAt: new Date() }, + { ...userMsg('Rejected message'), id: 'rejected', createdAt: new Date() }, + ]); + const accepted = list.inputDelta()[0]; const orchestrator = buildOrchestrator(store); await orchestrator.persistInputMessages(list, PERSIST); - await orchestrator.discardRejectedInput(list, PERSIST); - await orchestrator.discardRejectedInput(list, PERSIST); + await orchestrator.discardRejectedInput(list, ['rejected'], PERSIST); + await orchestrator.discardRejectedInput(list, ['rejected'], PERSIST); await orchestrator.persistInputMessages(list, PERSIST); await orchestrator.persistTurnDelta(list, PERSIST); await orchestrator.saveToMemory(list, PERSIST); - expect(list.inputDelta()).toEqual([]); - expect(list.turnDelta()).toEqual([]); + expect(list.inputDelta()).toEqual([accepted]); + expect(list.turnDelta()).toEqual([accepted]); expect(list.serialize()).toMatchObject({ - messages: [history], - inputIds: [], + messages: [history, accepted], + inputIds: ['accepted'], historyIds: ['history'], }); - expect(AgentMessageList.deserialize(list.serialize()).turnDelta()).toEqual([]); - expect(await store.getMessages(THREAD_ID)).toEqual([history]); + expect(AgentMessageList.deserialize(list.serialize()).turnDelta()).toEqual([accepted]); + expect(await store.getMessages(THREAD_ID)).toEqual([history, accepted]); }); }); diff --git a/packages/@n8n/agents/src/runtime/__tests__/memory-orchestrator-side-call.test.ts b/packages/@n8n/agents/src/runtime/__tests__/memory-orchestrator-side-call.test.ts new file mode 100644 index 00000000000..2dc75c7215f --- /dev/null +++ b/packages/@n8n/agents/src/runtime/__tests__/memory-orchestrator-side-call.test.ts @@ -0,0 +1,182 @@ +import type * as AiImport from 'ai'; + +import type { ObservationalMemoryConfig } from '../../types'; +import type { ExecutionOptions, RunOptions, SideCallUsageReport } from '../../types/sdk/agent'; +import type { AgentMessage } from '../../types/sdk/message'; +import type { AgentRuntimeConfig } from '../loop/agent-runtime'; +import { MemoryOrchestrator } from '../memory/memory-orchestrator'; +import { InMemoryMemory } from '../memory/memory-store'; +import type { ScopedMemoryTaskEvent } from '../memory/scoped-memory-task-runner'; +import { AgentMessageList } from '../model/message-list'; +import { BackgroundTaskTracker } from '../state/background-task-tracker'; +import { AgentEventBus } from '../state/event-bus'; +import { RuntimeTelemetry } from '../telemetry/runtime-telemetry'; + +const THREAD_ID = 'thread-1'; +const RESOURCE_ID = 'user-1'; +const OBSERVER_MODEL = 'openai/gpt-4o'; + +const { mockGetModelCost } = vi.hoisted(() => ({ + mockGetModelCost: vi.fn<(...args: [string]) => Promise>(), +})); + +vi.mock('ai', async () => { + const actual = await vi.importActual('ai'); + return { ...actual }; +}); + +vi.mock('../../sdk/catalog', async (importOriginal) => ({ + ...(await importOriginal<{}>()), + getModelCost: mockGetModelCost, +})); + +function userMsg(text: string): AgentMessage { + return { role: 'user', content: [{ type: 'text', text }] }; +} + +function assistantMsg(text: string): AgentMessage { + return { role: 'assistant', content: [{ type: 'text', text }] }; +} + +function runOptions( + onSideCallUsage?: (report: SideCallUsageReport) => void, +): RunOptions & ExecutionOptions { + return { + persistence: { threadId: THREAD_ID, resourceId: RESOURCE_ID }, + onSideCallUsage, + }; +} + +function buildOrchestrator(observationalMemory: ObservationalMemoryConfig): { + orchestrator: MemoryOrchestrator; + events: ScopedMemoryTaskEvent[]; +} { + const events: ScopedMemoryTaskEvent[] = []; + const config = { + name: 'side-call-agent', + memory: new InMemoryMemory(), + observationalMemory, + onMemoryTaskEvent: (event: ScopedMemoryTaskEvent) => events.push(event), + } as unknown as AgentRuntimeConfig; + const tracker = new BackgroundTaskTracker(); + const orchestrator = new MemoryOrchestrator( + config, + tracker, + new AgentEventBus(), + new RuntimeTelemetry(config), + async (text) => await Promise.resolve(text.length), + ); + return { orchestrator, events }; +} + +describe('MemoryOrchestrator side-call cost forwarding', () => { + beforeEach(() => { + mockGetModelCost.mockReset(); + }); + + it('forwards a priced observer usage report to onSideCallUsage', async () => { + mockGetModelCost.mockResolvedValue({ input: 5, output: 15 }); + const { orchestrator } = buildOrchestrator({ + observerThresholdTokens: 10, + observationLogTailLimit: 20, + observe: async () => + await Promise.resolve({ + text: '* CRITICAL (14:30) User set up mid-run observation.', + usage: { promptTokens: 80, completionTokens: 20, totalTokens: 100 }, + model: OBSERVER_MODEL, + }), + }); + + const reports: SideCallUsageReport[] = []; + const list = new AgentMessageList(); + list.addInput([userMsg('a user message crossing the threshold')]); + list.addResponse([assistantMsg('an assistant reply with more work')]); + + await orchestrator.maybeObserveMidRun( + list, + runOptions((report) => reports.push(report)), + ); + + expect(reports).toHaveLength(1); + const report = reports[0]; + expect(report.task).toBe('observer'); + expect(report.model).toBe(OBSERVER_MODEL); + expect(report.usage.promptTokens).toBe(80); + expect(report.usage.completionTokens).toBe(20); + // 80 input at $5/M = $0.0004; 20 output at $15/M = $0.0003. + expect(report.cost).toBeCloseTo(0.0007, 7); + expect(report.reportId).toEqual(expect.any(String)); + }); + + it('does not forward a report when the observer returns no usage', async () => { + mockGetModelCost.mockResolvedValue({ input: 5, output: 15 }); + const { orchestrator } = buildOrchestrator({ + observerThresholdTokens: 10, + observationLogTailLimit: 20, + observe: async () => await Promise.resolve('* CRITICAL (14:30) No usage reported.'), + }); + + const reports: SideCallUsageReport[] = []; + const list = new AgentMessageList(); + list.addInput([userMsg('a user message crossing the threshold')]); + list.addResponse([assistantMsg('an assistant reply with more work')]); + + await orchestrator.maybeObserveMidRun( + list, + runOptions((report) => reports.push(report)), + ); + expect(reports).toHaveLength(0); + }); + + it('does not forward a report when the model has no catalog pricing', async () => { + mockGetModelCost.mockResolvedValue(undefined); + const { orchestrator } = buildOrchestrator({ + observerThresholdTokens: 10, + observationLogTailLimit: 20, + observe: async () => + await Promise.resolve({ + text: '* CRITICAL (14:30) Unpriced observer call.', + usage: { promptTokens: 80, completionTokens: 20, totalTokens: 100 }, + model: 'unknown/model', + }), + }); + + const reports: SideCallUsageReport[] = []; + const list = new AgentMessageList(); + list.addInput([userMsg('a user message crossing the threshold')]); + list.addResponse([assistantMsg('an assistant reply with more work')]); + + await orchestrator.maybeObserveMidRun( + list, + runOptions((report) => reports.push(report)), + ); + expect(reports).toHaveLength(0); + }); + + it('does not break the run when onSideCallUsage throws', async () => { + mockGetModelCost.mockResolvedValue({ input: 5, output: 15 }); + const { orchestrator } = buildOrchestrator({ + observerThresholdTokens: 10, + observationLogTailLimit: 20, + observe: async () => + await Promise.resolve({ + text: '* CRITICAL (14:30) Observer call.', + usage: { promptTokens: 80, completionTokens: 20, totalTokens: 100 }, + model: OBSERVER_MODEL, + }), + }); + + const list = new AgentMessageList(); + list.addInput([userMsg('a user message crossing the threshold')]); + list.addResponse([assistantMsg('an assistant reply with more work')]); + + await expect( + orchestrator.maybeObserveMidRun( + list, + runOptions(() => { + throw new Error('host callback exploded'); + }), + ), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/@n8n/agents/src/runtime/__tests__/observation-log-observer.test.ts b/packages/@n8n/agents/src/runtime/__tests__/observation-log-observer.test.ts index 171c5eefaa6..1b4cc01be4b 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/observation-log-observer.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/observation-log-observer.test.ts @@ -109,7 +109,10 @@ describe('observation-log observer defaults', () => { executionCounter: counter, }); - expect(result).toContain('CRITICAL'); + // The default observe fn returns { text, usage, model }; bare-string returns + // stay supported for custom observe fns. + const observeText = typeof result === 'string' ? result : result.text; + expect(observeText).toContain('CRITICAL'); expect(counter.incrementTokenCount).toHaveBeenCalledWith(17); expect(counter.incrementMessageCount).not.toHaveBeenCalled(); expect(counter.incrementToolCallCount).not.toHaveBeenCalled(); @@ -477,6 +480,39 @@ describe('runObservationLogObserver', () => { }); }); + it('never fails observation when onUsage throws synchronously or rejects', async () => { + // Pricing is best-effort: a misbehaving onUsage callback (sync throw or + // rejected promise) must not abort the observer or surface an unhandled + // rejection. Observation still completes and writes entries. + const syncThrow = vi.fn(() => { + throw new Error('pricing sync boom'); + }); + const rejecting = vi.fn(async () => { + throw new Error('pricing async boom'); + }); + + for (const onUsage of [syncThrow, rejecting]) { + const store = new InMemoryMemory(); + await store.saveThread({ id: 'thread-1', resourceId: 'user-1' }); + await store.saveMessages({ + threadId: 'thread-1', + resourceId: 'user-1', + messages: [message('m1', 'user', 'I need this remembered.', new Date(2026, 4, 12, 14, 30))], + }); + const result = await runObservationLogObserver({ + memory: store, + observationScopeId: 'thread-1', + observationLogTailLimit: 20, + tokenCounter: async () => await Promise.resolve(5), + now: new Date(2026, 4, 12, 14, 31), + observe: async () => await Promise.resolve('* CRITICAL (14:31) Durable fact.'), + onUsage, + }); + expect(result).toMatchObject({ status: 'ran', observationsWritten: 1 }); + expect(onUsage).toHaveBeenCalledTimes(1); + } + }); + it.each([ '', ' \nnot a bullet line\n', diff --git a/packages/@n8n/agents/src/runtime/__tests__/observation-log-reflector.test.ts b/packages/@n8n/agents/src/runtime/__tests__/observation-log-reflector.test.ts index 8f324aa82e1..863eb341536 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/observation-log-reflector.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/observation-log-reflector.test.ts @@ -229,12 +229,18 @@ describe('observation-log reflector defaults', () => { }); await vi.waitFor(() => expect(mockGenerateText).toHaveBeenCalledTimes(2)); secondResponse.resolve({ text: '{"drop":["1"],"merge":[]}' }); - expect(parseObservationLogReflectionJson(await secondCall)).toEqual({ + // The default reflect fn returns { text, usage, model }; extract the text + // before parsing the reflection JSON. + const secondResult = await secondCall; + const secondText = typeof secondResult === 'string' ? secondResult : secondResult.text; + expect(parseObservationLogReflectionJson(secondText)).toEqual({ drop: [second.id], merge: [], }); firstResponse.resolve({ text: '{"drop":["1"],"merge":[]}' }); - expect(parseObservationLogReflectionJson(await firstCall)).toEqual({ + const firstResult = await firstCall; + const firstText = typeof firstResult === 'string' ? firstResult : firstResult.text; + expect(parseObservationLogReflectionJson(firstText)).toEqual({ drop: [first.id], merge: [], }); @@ -279,7 +285,10 @@ describe('observation-log reflector defaults', () => { executionCounter: counter, }); - expect(result).toBe('{"drop":[],"merge":[]}'); + // The default reflect fn returns { text, usage, model }; bare-string returns + // stay supported for custom reflect fns. + const reflectText = typeof result === 'string' ? result : result.text; + expect(reflectText).toBe('{"drop":[],"merge":[]}'); expect(counter.incrementTokenCount).toHaveBeenCalledWith(19); expect(counter.incrementMessageCount).not.toHaveBeenCalled(); expect(counter.incrementToolCallCount).not.toHaveBeenCalled(); @@ -608,6 +617,39 @@ describe('runObservationLogReflector', () => { ).resolves.toMatchObject([{ tokenCount: 6 }]); }); + it('never fails reflection when onUsage throws synchronously or rejects', async () => { + // Pricing is best-effort: a misbehaving onUsage callback must not abort + // the reflector or surface an unhandled rejection. Reflection still + // applies its drop instruction. + const syncThrow = vi.fn(() => { + throw new Error('pricing sync boom'); + }); + const rejecting = vi.fn(async () => { + throw new Error('pricing async boom'); + }); + + for (const onUsage of [syncThrow, rejecting]) { + const store = new InMemoryMemory(); + const [stale] = await store.appendObservationLogEntries([ + { + observationScopeId: 'thread-1', + marker: 'info', + text: 'Tiny aside', + tokenCount: 12, + }, + ]); + const result = await runObservationLogReflector({ + memory: store, + observationScopeId: 'thread-1', + reflectorThresholdTokens: 10, + reflect: async () => await Promise.resolve(JSON.stringify({ drop: [stale.id], merge: [] })), + onUsage, + }); + expect(result).toMatchObject({ status: 'ran' }); + expect(onUsage).toHaveBeenCalledTimes(1); + } + }); + it('warns but still applies reflection output that remains over budget', async () => { const store = new InMemoryMemory(); const [critical, stale] = await store.appendObservationLogEntries([ diff --git a/packages/@n8n/agents/src/runtime/__tests__/title-generation-side-call.test.ts b/packages/@n8n/agents/src/runtime/__tests__/title-generation-side-call.test.ts new file mode 100644 index 00000000000..600eb848fbf --- /dev/null +++ b/packages/@n8n/agents/src/runtime/__tests__/title-generation-side-call.test.ts @@ -0,0 +1,192 @@ +import type * as AiImport from 'ai'; +import type { LanguageModel } from 'ai'; + +import type { SideCallUsageReport } from '../../types/sdk/agent'; +import type { BuiltMemory } from '../../types'; +import { generateThreadTitle } from '../memory/title-generation'; +import { InMemoryMemory } from '../memory/memory-store'; + +type GenerateTextResult = { + text: string; + usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; + providerMetadata?: Record; +}; + +const { mockGenerateText, mockGetModelCost } = vi.hoisted(() => ({ + mockGenerateText: vi.fn<(...args: unknown[]) => Promise>(), + mockGetModelCost: vi.fn<(...args: [string]) => Promise>(), +})); + +vi.mock('ai', async () => { + const actual = await vi.importActual('ai'); + return { + ...actual, + generateText: async (...args: unknown[]) => await mockGenerateText(...args), + }; +}); + +vi.mock('../model/model-factory', () => ({ + createModel: () => ({}) as LanguageModel, +})); + +vi.mock('../../sdk/catalog', async (importOriginal) => ({ + ...(await importOriginal<{}>()), + getModelCost: mockGetModelCost, +})); + +const THREAD_ID = 'thread-1'; +const RESOURCE_ID = 'user-1'; +const MODEL_ID = 'openai/gpt-4o'; + +async function runTitle( + memory: BuiltMemory, + onSideCallUsage?: (report: SideCallUsageReport) => void, +): Promise { + const reports: SideCallUsageReport[] = []; + await generateThreadTitle({ + memory, + threadId: THREAD_ID, + resourceId: RESOURCE_ID, + titleConfig: { model: MODEL_ID }, + agentModel: MODEL_ID, + turnDelta: [ + { + id: 'm1', + role: 'user', + content: [{ type: 'text', text: 'Build a daily Berlin rain alert workflow' }], + createdAt: new Date(), + } as never, + ], + onSideCallUsage: onSideCallUsage ?? ((report) => reports.push(report)), + }); + return reports; +} + +describe('generateThreadTitle side-call cost', () => { + beforeEach(() => { + mockGenerateText.mockReset(); + mockGetModelCost.mockReset(); + }); + + it('forwards a priced title usage report to onSideCallUsage', async () => { + mockGenerateText.mockResolvedValue({ + text: '{"title":"Berlin rain alert","emoji":"rain"}', + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + }); + mockGetModelCost.mockResolvedValue({ input: 5, output: 15 }); + + const memory = new InMemoryMemory(); + await memory.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID }); + + const reports = await runTitle(memory); + + expect(reports).toHaveLength(1); + const report = reports[0]; + expect(report.task).toBe('title'); + expect(report.model).toBe(MODEL_ID); + expect(report.usage.promptTokens).toBe(100); + expect(report.usage.completionTokens).toBe(50); + // 100 input tokens at $5/M = $0.0005; 50 output at $15/M = $0.00075. + expect(report.cost).toBeCloseTo(0.00125, 7); + expect(report.reportId).toEqual(expect.any(String)); + }); + + it('forwards a priced title usage report even when the model returns no usable title', async () => { + // The model was called and billed, but produced empty text. + mockGenerateText.mockResolvedValue({ + text: '', + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + }); + mockGetModelCost.mockResolvedValue({ input: 5, output: 15 }); + + const memory = new InMemoryMemory(); + await memory.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID }); + + const reports = await runTitle(memory); + + expect(reports).toHaveLength(1); + expect(reports[0].task).toBe('title'); + // The thread is not retitled when no title could be extracted. + const thread = await memory.getThread(THREAD_ID); + expect(thread?.title).toBeFalsy(); + }); + + it('forwards a priced title usage report even when saveThread throws', async () => { + mockGenerateText.mockResolvedValue({ + text: '{"title":"Berlin rain alert","emoji":"rain"}', + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + }); + mockGetModelCost.mockResolvedValue({ input: 5, output: 15 }); + + const memory = new InMemoryMemory(); + await memory.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID }); + // Force persistence to fail after the model call has already been billed. + vi.spyOn(memory, 'saveThread').mockRejectedValue(new Error('storage down')); + + const reports = await runTitle(memory); + + // The report is captured before persistence, so the billed turn is + // still priced even though saving the title failed. + expect(reports).toHaveLength(1); + expect(reports[0].task).toBe('title'); + }); + + it('does not forward a report when the model has no catalog pricing', async () => { + mockGenerateText.mockResolvedValue({ + text: '{"title":"Berlin rain alert","emoji":"rain"}', + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + }); + mockGetModelCost.mockResolvedValue(undefined); + + const memory = new InMemoryMemory(); + await memory.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID }); + + const reports = await runTitle(memory); + expect(reports).toHaveLength(0); + }); + + it('does not forward a report when the LLM reports no usage', async () => { + mockGenerateText.mockResolvedValue({ text: '{"title":"Berlin rain alert","emoji":"rain"}' }); + mockGetModelCost.mockResolvedValue({ input: 5, output: 15 }); + + const memory = new InMemoryMemory(); + await memory.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID }); + + const reports = await runTitle(memory); + expect(reports).toHaveLength(0); + }); + + it('mints a unique reportId for each title call', async () => { + mockGenerateText.mockResolvedValue({ + text: '{"title":"Berlin rain alert","emoji":"rain"}', + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + }); + mockGetModelCost.mockResolvedValue({ input: 5, output: 15 }); + + const memory = new InMemoryMemory(); + await memory.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID }); + + const first = await runTitle(memory); + // A second thread so the title is not skipped as already set. + const memory2 = new InMemoryMemory(); + await memory2.saveThread({ id: 'thread-2', resourceId: RESOURCE_ID }); + const second = await runTitle(memory2); + + expect(first[0].reportId).not.toBe(second[0].reportId); + }); + + it('skips the cost report when the thread already has a title', async () => { + mockGenerateText.mockResolvedValue({ + text: '{"title":"Berlin rain alert","emoji":"rain"}', + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + }); + mockGetModelCost.mockResolvedValue({ input: 5, output: 15 }); + + const memory = new InMemoryMemory(); + await memory.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID, title: 'Existing title' }); + + const reports = await runTitle(memory); + expect(reports).toHaveLength(0); + expect(mockGenerateText).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts index 3ffbb85c51a..cea39498640 100644 --- a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts +++ b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts @@ -53,6 +53,7 @@ import { getModelIdString } from '../../utils/model'; import { removeToolResultRun } from '../../workspace'; import { createFilteredLogger } from '../logger'; import { MemoryOrchestrator } from '../memory/memory-orchestrator'; +import { sanitizeOffloadedToolResultsForMemory } from '../memory/tool-result-memory'; import { generateThreadTitle } from '../memory/title-generation'; import { AgentMessageList, type SerializedMessageList } from '../model/message-list'; import { createModelTokenCounter } from '../model/model-token-counter'; @@ -107,7 +108,7 @@ interface PreparedLoopContext extends LoopContext { staticContext: StaticLoopContext; instructionProviderOptions: ProviderOptions | undefined; runTelemetry: BuiltTelemetry | undefined; - canDiscardRejectedInput: boolean; + acceptedInputIds: Set; } interface LoopState { @@ -690,6 +691,8 @@ export class AgentRuntime { modelFetch: this.config.modelFetch, turnDelta: list.turnDelta(), executionCounter: options.executionCounter, + onSideCallUsage: options.onSideCallUsage, + promptCaching: this.config.promptCaching, }); this.backgroundTasks.track(titlePromise); if (this.config.titleGeneration.sync) { @@ -720,11 +723,17 @@ export class AgentRuntime { } for (; state.iterationCount < state.maxIterations; state.iterationCount++) { + const hasInput = await this.consumeAdditionalInput(prepared, sink, state); + if (state.reachedStopCondition && !hasInput) break; + state.reachedStopCondition = false; const settlement = await this.runLoopIteration(prepared, sink, state); if (settlement.suspended) return settlement.result; - if (state.reachedStopCondition) break; + if (state.reachedStopCondition && !prepared.options?.onInputBoundary) break; } + if (state.iterationCount >= state.maxIterations) { + await this.consumeAdditionalInput(prepared, sink, state); + } if (!state.reachedStopCondition && state.iterationCount >= state.maxIterations) { state.lastFinishReason = 'max-iterations'; } @@ -737,6 +746,35 @@ export class AgentRuntime { }); } + private async consumeAdditionalInput( + ctx: PreparedLoopContext, + sink: RunOutputSink, + state: LoopState, + ): Promise { + const receive = ctx.options?.onInputBoundary; + if (!receive || state.lastFinishReason === 'error') return false; + this.assertNotAborted(ctx.abortScope); + await sink.inputBoundary?.(ctx.abortScope.signal); + this.assertNotAborted(ctx.abortScope); + const messages = await receive({ + messages: sanitizeOffloadedToolResultsForMemory(ctx.list.turnDelta()), + lastCreatedAt: ctx.list.messages().at(-1)?.createdAt.getTime() ?? 0, + completing: state.reachedStopCondition, + canContinue: state.iterationCount < state.maxIterations, + }); + // Only committed input enters the live list. Its IDs and timestamps stay stable. + ctx.list.addInput(messages); + for (const message of messages) await sink.emitInput?.(message); + this.assertNotAborted(ctx.abortScope); + if (messages.length > 0) { + state.structuredOutput = undefined; + await hydrateFileParts(ctx.list.messages(), this.config.fileStore, { + threadId: ctx.options?.persistence?.threadId, + }); + } + return messages.length > 0; + } + private async prepareLoop(ctx: LoopContext): Promise<{ prepared: PreparedLoopContext; state: LoopState; @@ -767,28 +805,32 @@ export class AgentRuntime { runTelemetry, staticContext, instructionProviderOptions, - canDiscardRejectedInput: this.canDiscardRejectedInput(ctx), + acceptedInputIds: new Set( + ctx.isFreshRun ? [] : list.inputDelta().map((message) => message.id), + ), }, state, }; } - private canDiscardRejectedInput(ctx: LoopContext): boolean { - const inputMessages = new Set(ctx.list.inputDelta()); - const inputIds = new Set([...inputMessages].map((message) => message.id)); - return ( - ctx.isFreshRun === true && - [...inputMessages].some( - (message) => - 'role' in message && - message.role === 'user' && - Array.isArray(message.content) && - message.content.some((part) => part.type === 'file' && part.data !== undefined), - ) && - !ctx.list - .messages() - .some((message) => !inputMessages.has(message) && inputIds.has(message.id)) + private getRejectableInputIds(ctx: PreparedLoopContext): string[] { + const inputMessages = new Set( + ctx.list.inputDelta().filter((message) => !ctx.acceptedInputIds.has(message.id)), ); + const inputIds = new Set([...inputMessages].map((message) => message.id)); + const hasFiles = [...inputMessages].some( + (message) => + 'role' in message && + message.role === 'user' && + Array.isArray(message.content) && + message.content.some((part) => part.type === 'file' && part.data !== undefined), + ); + if (!hasFiles) return []; + if ( + ctx.list.messages().some((message) => !inputMessages.has(message) && inputIds.has(message.id)) + ) + return []; + return [...inputIds]; } private buildToolBatchContext( @@ -844,9 +886,10 @@ export class AgentRuntime { ): Promise> { this.assertNotAborted(ctx.abortScope); this.eventBus.emit({ type: AgentEvent.TurnStart }); - const { toolMap, modelCallContext } = await this.prepareModelCall(ctx, state.iterationCount); + const { toolMap, modelCallContext } = await this.prepareModelCall(ctx); const turn = await this.callModelWithRetries(ctx, sink, state, modelCallContext); this.assertNotAborted(ctx.abortScope); + for (const message of ctx.list.inputDelta()) ctx.acceptedInputIds.add(message.id); state.lastFinishReason = turn.finishReason; if (!isReasoningOnlyStop(turn)) ctx.list.addResponse(turn.newMessages); @@ -878,7 +921,7 @@ export class AgentRuntime { return settlement; } - private async prepareModelCall(ctx: PreparedLoopContext, iterationCount: number) { + private async prepareModelCall(ctx: PreparedLoopContext) { const { list, options, abortScope, staticContext } = ctx; for (const toolName of this.activeSkills?.toolDependencies() ?? []) { this.deferredToolManager?.load(toolName); @@ -909,19 +952,19 @@ export class AgentRuntime { outputSpec: staticContext.outputSpec, maxOutputTokens: staticContext.maxOutputTokens, aiSdkOptions: this.buildAiSdkOptions(tools.toolMap, options), - onInputRejected: this.createInputRejectionHandler(ctx, iterationCount), + onInputRejected: this.createInputRejectionHandler(ctx), }; return { toolMap: tools.toolMap, modelCallContext }; } private createInputRejectionHandler( ctx: PreparedLoopContext, - iterationCount: number, ): ModelCallContext['onInputRejected'] { - if (!ctx.canDiscardRejectedInput || iterationCount !== 0) return undefined; + const messageIds = this.getRejectableInputIds(ctx); + if (messageIds.length === 0) return undefined; return async () => { if (ctx.abortScope.isAborted) return; - await this.memory.discardRejectedInput(ctx.list, ctx.options); + await this.memory.discardRejectedInput(ctx.list, messageIds, ctx.options); this.updateState({ messageList: ctx.list.serialize() }); }; } diff --git a/packages/@n8n/agents/src/runtime/loop/hydrate-file-parts.ts b/packages/@n8n/agents/src/runtime/loop/hydrate-file-parts.ts index 44ec4b152cd..081600a5cbc 100644 --- a/packages/@n8n/agents/src/runtime/loop/hydrate-file-parts.ts +++ b/packages/@n8n/agents/src/runtime/loop/hydrate-file-parts.ts @@ -3,7 +3,7 @@ import type { AgentDbMessage, ContentFile } from '../../types/sdk/message'; /** Only the newest file parts are hydrated; older ones stay reference-only. */ export const MAX_HYDRATED_FILE_PARTS = 20; -/** Cumulative cap on hydrated bytes per turn. */ +/** Cumulative cap on hydrated bytes in the live message list. */ export const MAX_HYDRATED_FILE_BYTES = 50 * 1024 * 1024; /** @@ -32,25 +32,31 @@ export async function hydrateFileParts( for (const message of messages) { if (!('content' in message) || !Array.isArray(message.content)) continue; for (const block of message.content) { - if (block.type !== 'file' || !block.fileRef || block.data !== undefined) continue; + if (block.type !== 'file' || !block.fileRef) continue; if (fileStore.isMediaTypeSupported && !fileStore.isMediaTypeSupported(block.mediaType)) { + delete block.data; continue; } blocks.push(block); } } + for (const block of blocks.slice(0, -MAX_HYDRATED_FILE_PARTS)) delete block.data; let budget = MAX_HYDRATED_FILE_BYTES; const newestFirst = blocks.slice(-MAX_HYDRATED_FILE_PARTS).reverse(); for (const block of newestFirst) { - if (budget <= 0) break; + const existingData = block.data; + delete block.data; + if (budget <= 0) continue; const declaredSize = block.fileRef!.sizeBytes; - if (declaredSize !== undefined && declaredSize > budget) continue; + if (existingData === undefined && declaredSize !== undefined && declaredSize > budget) continue; try { - const data = await fileStore.load(block.fileRef!, scope); - if (!data || data.byteLength > budget) continue; + const data = existingData ?? (await fileStore.load(block.fileRef!, scope)); + if (!data) continue; + const size = typeof data === 'string' ? Buffer.byteLength(data, 'base64') : data.byteLength; + if (size > budget) continue; block.data = data; - budget -= data.byteLength; + budget -= size; } catch { // Leave the block reference-only; the model sees its text metadata. } diff --git a/packages/@n8n/agents/src/runtime/loop/side-call-cost.ts b/packages/@n8n/agents/src/runtime/loop/side-call-cost.ts new file mode 100644 index 00000000000..bebfb3fa130 --- /dev/null +++ b/packages/@n8n/agents/src/runtime/loop/side-call-cost.ts @@ -0,0 +1,23 @@ +import { computeCost, getModelCost } from '../../sdk/catalog'; +import type { PromptCachingConfig, TokenUsage } from '../../types/sdk/agent'; +import { getEffectiveAnthropicCacheTtl } from '../model/prompt-cache'; + +/** + * Compute the estimated USD cost of a side-call model turn (title + * generation, observation-log observer/reflector, episodic-memory model + * call) from models.dev pricing. Returns `undefined` when the catalog is + * unavailable or the model has no pricing, so callers can skip the increment + * instead of billing zero. `promptCaching` only affects Anthropic cache-write + * pricing; it is a no-op for non-Anthropic side-call models. + */ +export async function computeSideCallCost( + modelId: string, + usage: TokenUsage, + promptCaching?: PromptCachingConfig, +): Promise { + const modelCost = await getModelCost(modelId); + if (!modelCost) return undefined; + return computeCost(usage, modelCost, { + anthropicCacheTtl: getEffectiveAnthropicCacheTtl(promptCaching, modelId), + }); +} diff --git a/packages/@n8n/agents/src/runtime/loop/stream-sink.ts b/packages/@n8n/agents/src/runtime/loop/stream-sink.ts index 4d633fe04a2..ffef0313b46 100644 --- a/packages/@n8n/agents/src/runtime/loop/stream-sink.ts +++ b/packages/@n8n/agents/src/runtime/loop/stream-sink.ts @@ -1,4 +1,6 @@ import type { StreamTextTransform, TextStreamPart, ToolSet } from 'ai'; +import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; +import { raceWithAbort } from '../../sdk/abort'; import { finalizeRun } from './run-output-sink'; import type { @@ -11,7 +13,7 @@ import type { } from '../../types/runtime/agent-loop'; import { classifyModelTurnError, mergeUsage } from './runtime-helpers'; import type { ExecutionOptions, TokenUsage } from '../../types/sdk/agent'; -import type { AgentMessage } from '../../types/sdk/message'; +import type { AgentDbMessage, AgentMessage } from '../../types/sdk/message'; import { isAttachmentValidationError } from '../model/attachment-validation-error'; import { loadAi } from '../model/lazy-ai'; import { fromAiFinishReason, fromAiMessages } from '../model/messages'; @@ -53,6 +55,25 @@ const STALL_RETRY_SAFE_CHUNK_TYPES = new Set([ * chunks. Owns the smooth-stream transform option. */ export class StreamSink implements RunOutputSink { + async inputBoundary(signal: AbortSignal): Promise { + const acknowledged = createDeferredPromise(); + await raceWithAbort( + async () => { + await this.guard.write({ + type: 'input-boundary', + acknowledge: () => acknowledged.resolve(), + }); + if (this.guard.isClosed) throw new Error('Agent stream closed before input consumption'); + await acknowledged.promise; + }, + AbortSignal.any([signal, this.guard.closedSignal]), + ); + } + + async emitInput(message: AgentDbMessage): Promise { + await this.guard.write({ type: 'input', message }); + } + private lastUsage: TokenUsage | undefined; // Reads the in-flight turn's usage from the provider's raw stream events so an // aborted run can still be billed (the SDK reports no usage on abort). The diff --git a/packages/@n8n/agents/src/runtime/memory/episodic-memory-capture.ts b/packages/@n8n/agents/src/runtime/memory/episodic-memory-capture.ts index ab4449d46fc..0fb88dfff75 100644 --- a/packages/@n8n/agents/src/runtime/memory/episodic-memory-capture.ts +++ b/packages/@n8n/agents/src/runtime/memory/episodic-memory-capture.ts @@ -11,6 +11,7 @@ import { } from './episodic-memory'; import { DEFAULT_EPISODIC_MEMORY_CAPTURE_TOOL_INSTRUCTION } from './episodic-memory-defaults'; import { normalizeFlatReflectionActions } from './memory-lifecycle'; +import { reportSideCallUsage } from './forward-usage'; import { saveMessagesToThread } from './memory-store'; import { redactText } from '../../sdk/guardrails'; import { Tool } from '../../sdk/tool'; @@ -26,7 +27,11 @@ import type { EpisodicMemoryScope, RetrievedEpisodicMemoryEntry, } from '../../types'; -import type { AgentExecutionCounter, AgentPersistenceOptions } from '../../types/sdk/agent'; +import type { + AgentExecutionCounter, + AgentPersistenceOptions, + TokenUsage, +} from '../../types/sdk/agent'; import type { AgentDbMessage, Message } from '../../types/sdk/message'; import { incrementTokenCountFromUsage } from '../loop/execution-counter'; import type { AgentMessageList } from '../model/message-list'; @@ -153,11 +158,31 @@ export interface RunEpisodicMemoryCandidateProcessorOpts { executionCounter?: AgentExecutionCounter; telemetry?: BuiltTelemetry; agentName?: string; + /** + * Receives each episodic model call's usage the moment it resolves — once + * per embed call and once per reflect call — before any persistence. + * Forwarding it here (rather than only on the success return) keeps a + * billed embed/reflect turn priced even when later persistence throws. + * Fire-and-forget from the caller's perspective. + */ + onUsage?: (model: string | undefined, usage: TokenUsage | undefined) => void | Promise; } +export type EpisodicMemoryUsageReport = { + task: 'episodic'; + model: string; + usage: TokenUsage; +}; + export type RunEpisodicMemoryCandidateProcessorResult = | { status: 'skipped' } - | { status: 'ran'; entriesWritten: number; candidatesProcessed: number }; + | { + status: 'ran'; + entriesWritten: number; + candidatesProcessed: number; + /** One usage report per embed/reflect model call, so each is priced separately. */ + usageReports: EpisodicMemoryUsageReport[]; + }; /** * Flagged candidates become entries as written: the agent already phrased the @@ -177,9 +202,12 @@ export async function runEpisodicMemoryCandidateProcessor( const now = opts.now ?? new Date(); const candidateIds = candidates.map((candidate) => candidate.id); + const usageReports: EpisodicMemoryUsageReport[] = []; let savedEntries: EpisodicMemoryEntry[]; try { - savedEntries = await saveCandidateEntries(opts, config, candidates, now); + const saved = await saveCandidateEntries(opts, config, candidates, now); + savedEntries = saved.entries; + if (saved.usageReport) usageReports.push(saved.usageReport); await opts.memory.episodic.completeCaptureCandidates(candidateIds); } catch (error) { await opts.memory.episodic.recordCaptureCandidateFailure( @@ -194,12 +222,20 @@ export async function runEpisodicMemoryCandidateProcessor( config.reflect && candidates.some((candidate) => candidate.kind === 'correction') ) { - await runEpisodicMemoryReflection(opts, config, savedEntries, candidates, now); + const reflectReports = await runEpisodicMemoryReflection( + opts, + config, + savedEntries, + candidates, + now, + ); + usageReports.push(...reflectReports); } return { status: 'ran', entriesWritten: savedEntries.length, candidatesProcessed: candidates.length, + usageReports, }; } @@ -284,13 +320,25 @@ async function embedTexts( config: NormalizedEpisodicMemoryConfig, values: string[], executionCounter: AgentExecutionCounter | undefined, -): Promise { +): Promise<{ embeddings: number[][]; report?: EpisodicMemoryUsageReport }> { const { embedMany } = await import('ai'); const { embeddings, usage } = await withEmbeddingErrorContext( async () => await embedMany({ model: config.embedder, values }), ); incrementTokenCountFromUsage(executionCounter, usage); - return embeddings; + // Embedding model usage only reports a single `tokens` count (no + // input/output split), so build the TokenUsage directly instead of + // routing through `toTokenUsage`, which expects a chat-model usage shape. + const tokenUsage: TokenUsage | undefined = usage + ? { promptTokens: usage.tokens, completionTokens: 0, totalTokens: usage.tokens } + : undefined; + return { + embeddings, + report: + tokenUsage !== undefined + ? { task: 'episodic', model: config.embeddingModel, usage: tokenUsage } + : undefined, + }; } async function saveCandidateEntries( @@ -298,8 +346,9 @@ async function saveCandidateEntries( config: NormalizedEpisodicMemoryConfig, candidates: EpisodicMemoryCaptureCandidate[], now: Date, -): Promise { +): Promise<{ entries: EpisodicMemoryEntry[]; usageReport?: EpisodicMemoryUsageReport }> { const savedEntries: EpisodicMemoryEntry[] = []; + let usageReport: EpisodicMemoryUsageReport | undefined; await withMemorySpan( 'save_memory', opts.agentName ?? 'agent', @@ -310,11 +359,19 @@ async function saveCandidateEntries( ...inferMemoryStoreAttributes(opts.memory), }), async () => { - const embeddings = await embedTexts( + const embedded = await embedTexts( config, candidates.map((candidate) => candidate.content), opts.executionCounter, ); + usageReport = embedded.report; + // Forward the embed usage before the save loop, so a billed embed + // call is priced even when a later saveEntryWithSources throws. The + // callback is best-effort: a throw or rejection never fails candidate + // processing. + if (embedded.report) + reportSideCallUsage(opts.onUsage, embedded.report.model, embedded.report.usage); + const embeddings = embedded.embeddings; for (const [index, candidate] of candidates.entries()) { const saved = await opts.memory.episodic.saveEntryWithSources( { @@ -346,7 +403,7 @@ async function saveCandidateEntries( }; }, ); - return savedEntries; + return { entries: savedEntries, usageReport }; } async function runEpisodicMemoryReflection( @@ -355,28 +412,52 @@ async function runEpisodicMemoryReflection( savedEntries: EpisodicMemoryEntry[], candidates: EpisodicMemoryCaptureCandidate[], now: Date, -): Promise { - if (!config.reflect) return; +): Promise { + if (!config.reflect) return []; const cluster = await buildReflectionCluster(opts, config, savedEntries, candidates); - if (cluster.length === 0) return; + if (cluster.length === 0) return []; const sources = await opts.memory.episodic.getEntrySources(cluster.map((entry) => entry.id)); - const reflection = normalizeEpisodicMemoryReflection( - cluster, - await config.reflect({ - scope: opts.scope, - now, - seedEntryIds: savedEntries.map((entry) => entry.id), - entries: cluster, - sources, - executionCounter: opts.executionCounter, - }), - ); - if (reflection.drop.length === 0 && reflection.merge.length === 0) return; + const reflectResult = await config.reflect({ + scope: opts.scope, + now, + seedEntryIds: savedEntries.map((entry) => entry.id), + entries: cluster, + sources, + executionCounter: opts.executionCounter, + }); + const reflectionResult = + 'reflection' in reflectResult + ? reflectResult + : { reflection: reflectResult, usage: undefined, model: '' }; + const reports: EpisodicMemoryUsageReport[] = []; + if (reflectionResult.usage !== undefined) { + reports.push({ + task: 'episodic', + model: reflectionResult.model, + usage: reflectionResult.usage, + }); + // Forward the reflect usage before applyReflection, so a billed + // reflect call is priced even when a later merge embed or + // applyReflection throws. The callback is best-effort: a throw or + // rejection never fails reflection. + reportSideCallUsage(opts.onUsage, reflectionResult.model, reflectionResult.usage); + } + const reflection = normalizeEpisodicMemoryReflection(cluster, reflectionResult.reflection); + if (reflection.drop.length === 0 && reflection.merge.length === 0) return reports; const mergeContents = reflection.merge.map((entry) => entry.content); - const mergeEmbeddings = - mergeContents.length > 0 ? await embedTexts(config, mergeContents, opts.executionCounter) : []; + let mergeEmbeddings: number[][] = []; + if (mergeContents.length > 0) { + const mergeEmbedded = await embedTexts(config, mergeContents, opts.executionCounter); + if (mergeEmbedded.report) { + reports.push(mergeEmbedded.report); + // Forward the merge-embed usage before applyReflection. Best-effort: + // a callback throw or rejection never fails reflection. + reportSideCallUsage(opts.onUsage, mergeEmbedded.report.model, mergeEmbedded.report.usage); + } + mergeEmbeddings = mergeEmbedded.embeddings; + } await opts.memory.episodic.applyReflection(opts.scope, { drop: reflection.drop, merge: reflection.merge.map((merge, index) => ({ @@ -392,6 +473,7 @@ async function runEpisodicMemoryReflection( }, })), }); + return reports; } async function buildReflectionCluster( diff --git a/packages/@n8n/agents/src/runtime/memory/episodic-memory-defaults.ts b/packages/@n8n/agents/src/runtime/memory/episodic-memory-defaults.ts index f237dc1ec8c..f442e6e7f7f 100644 --- a/packages/@n8n/agents/src/runtime/memory/episodic-memory-defaults.ts +++ b/packages/@n8n/agents/src/runtime/memory/episodic-memory-defaults.ts @@ -2,14 +2,16 @@ import { z } from 'zod'; import type { EpisodicMemoryEntrySource, - EpisodicMemoryReflection, + EpisodicMemoryReflectResult, EpisodicMemoryReflectorInput, EpisodicMemoryReflectFn, ModelConfig, RetrievedEpisodicMemoryEntry, } from '../../types'; +import { getModelIdString } from '../../utils/model'; import { incrementTokenCountFromUsage } from '../loop/execution-counter'; import { createModel } from '../model/model-factory'; +import { toTokenUsage } from '../streaming/stream'; export const DEFAULT_EPISODIC_MEMORY_EMBEDDING_MODEL = 'openai/text-embedding-3-small'; export const DEFAULT_EPISODIC_MEMORY_TOP_K = 5; @@ -65,7 +67,7 @@ export function createEpisodicMemoryReflectFn( model: ModelConfig, options: CreateEpisodicMemoryReflectFnOptions = {}, ): EpisodicMemoryReflectFn { - return async (input): Promise => { + return async (input): Promise => { const { generateText, Output } = await import('ai'); const response = await generateText({ model: createModel(model), @@ -74,7 +76,11 @@ export function createEpisodicMemoryReflectFn( output: Output.object({ schema: EpisodicMemoryReflectionSchema }), }); incrementTokenCountFromUsage(input.executionCounter, response.usage); - return response.output; + return { + reflection: response.output, + usage: toTokenUsage(response.usage, response.providerMetadata), + model: getModelIdString(model), + }; }; } diff --git a/packages/@n8n/agents/src/runtime/memory/forward-usage.ts b/packages/@n8n/agents/src/runtime/memory/forward-usage.ts new file mode 100644 index 00000000000..e7585dbfed3 --- /dev/null +++ b/packages/@n8n/agents/src/runtime/memory/forward-usage.ts @@ -0,0 +1,25 @@ +import type { TokenUsage } from '../../types/sdk/agent'; + +/** + * Invoke a side-call usage callback (observer/reflector/episodic `onUsage`) + * without letting it fail memory processing. Both failure modes are + * swallowed: a synchronous throw is caught, and a rejected promise is + * caught on the promise chain. Pricing is best-effort and must never abort a + * memory task or surface an unhandled rejection. + */ +export function reportSideCallUsage( + onUsage: + | ((model: string | undefined, usage: TokenUsage | undefined) => void | Promise) + | undefined, + model: string | undefined, + usage: TokenUsage | undefined, +): void { + if (!onUsage) return; + try { + void Promise.resolve(onUsage(model, usage)).catch(() => { + // best-effort: never surface a rejected pricing callback + }); + } catch { + // best-effort: never let a synchronous pricing throw fail memory processing + } +} diff --git a/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts b/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts index 0b5de894227..b9eca602920 100644 --- a/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts +++ b/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts @@ -30,10 +30,18 @@ import type { EpisodicMemoryTaskLockMethods, } from '../../types'; import { AgentEvent } from '../../types/runtime/event'; -import type { AgentPersistenceOptions, ExecutionOptions, RunOptions } from '../../types/sdk/agent'; +import type { + AgentPersistenceOptions, + ExecutionOptions, + RunOptions, + SideCallTask, + SideCallUsageReport, + TokenUsage, +} from '../../types/sdk/agent'; import type { AgentDbMessage } from '../../types/sdk/message'; import type { ObservationLogScope, ObservationLogTaskKind } from '../../types/sdk/observation-log'; import type { AgentRuntimeConfig } from '../../types/runtime/agent-runtime'; +import { computeSideCallCost } from '../loop/side-call-cost'; import type { AgentMessageList } from '../model/message-list'; import { estimateObservationTokens, type TokenCounter } from '../model/model-token-counter'; import type { BackgroundTaskTracker } from '../state/background-task-tracker'; @@ -51,6 +59,9 @@ const DEFAULT_MEMORY_TASK_LOCK_TTL_MS = 30_000; const MID_RUN_SOFT_THRESHOLD_RATIO = 0.7; const logger = createFilteredLogger(); +/** Host callback that receives a priced side-call usage report to add to the session total. */ +type OnSideCallUsage = (report: SideCallUsageReport) => void | Promise; + function stringifyForBudget(value: unknown): string { try { return JSON.stringify(value) ?? ''; @@ -222,7 +233,12 @@ export class MemoryOrchestrator { this.resetRunState(); if (this.config.memory && options?.persistence?.threadId) { const telemetry = this.runtimeTelemetry.resolve(options); - this.scheduleEpisodicMemoryJob(options.persistence, options.executionCounter, telemetry); + this.scheduleEpisodicMemoryJob( + options.persistence, + options.executionCounter, + telemetry, + options.onSideCallUsage, + ); const memMessages = await this.loadHistoryMessages(options.persistence, telemetry); if (memMessages.length > 0) { @@ -289,10 +305,10 @@ export class MemoryOrchestrator { async discardRejectedInput( list: AgentMessageList, + messageIds: string[], options: (RunOptions & ExecutionOptions) | undefined, ): Promise { - const messageIds = list.inputDelta().map((message) => message.id); - list.removeInput(); + list.removeInput(messageIds); if (!this.config.memory || !options?.persistence || messageIds.length === 0) return; try { await this.config.memory.deleteMessages(messageIds); @@ -438,9 +454,15 @@ export class MemoryOrchestrator { options.persistence, options.executionCounter, telemetry, + options.onSideCallUsage, ); if (hasSuccessfulMemoryFlag(list)) { - this.scheduleEpisodicMemoryJob(options.persistence, options.executionCounter, telemetry); + this.scheduleEpisodicMemoryJob( + options.persistence, + options.executionCounter, + telemetry, + options.onSideCallUsage, + ); } } @@ -554,6 +576,7 @@ export class MemoryOrchestrator { options.persistence, options.executionCounter, telemetry, + options.onSideCallUsage, ); if (!handle) return; const result = await handle.done; @@ -570,6 +593,7 @@ export class MemoryOrchestrator { options.persistence, options.executionCounter, telemetry, + options.onSideCallUsage, ); if (!handle) return; const entry: MidRunObserverTask = { handle }; @@ -649,6 +673,7 @@ export class MemoryOrchestrator { persistence: AgentPersistenceOptions, executionCounter?: AgentExecutionCounter, telemetry?: BuiltTelemetry, + onSideCallUsage?: OnSideCallUsage, ): ScopedMemoryTaskHandle | undefined { const run = this.observerRun; if (run.disabled) return undefined; @@ -674,6 +699,14 @@ export class MemoryOrchestrator { tokenCounter: this.tokenCounter, executionCounter, telemetry, + // Usage is forwarded inside the observer right after the model + // call (before persistence), so a billed observer turn is + // priced even when post-processing throws. + onUsage: onSideCallUsage + ? (model, usage) => { + void this.forwardSideCallUsage(onSideCallUsage, 'observer', model, usage); + } + : undefined, }); if (result.status === 'ran' && !result.cursorAdvanced) run.disabled = true; return result; @@ -690,6 +723,7 @@ export class MemoryOrchestrator { persistence: AgentPersistenceOptions, executionCounter?: AgentExecutionCounter, telemetry?: BuiltTelemetry, + onSideCallUsage?: OnSideCallUsage, ): Promise { const { memory, observationalMemory } = this.config; if (!memory || !observationalMemory || !hasObservationLogStore(memory)) return; @@ -709,7 +743,7 @@ export class MemoryOrchestrator { void midRunTask.handle.done.then(() => { if (this.midRunObserverTask === midRunTask) this.midRunObserverTask = undefined; }); - this.scheduleObserverTask(persistence, executionCounter, telemetry); + this.scheduleObserverTask(persistence, executionCounter, telemetry, onSideCallUsage); } else if ( this.cursorAdvancedThisRun || (midRunTask?.result !== undefined && didAdvanceCursor(midRunTask.result)) @@ -717,29 +751,34 @@ export class MemoryOrchestrator { // Mid-run advanced the cursor this run — either activated at a boundary // or settled after the last one: observe the tail regardless of the // visible-window budget, which the mask shrank below threshold. - this.scheduleObserverTask(persistence, executionCounter, telemetry); + this.scheduleObserverTask(persistence, executionCounter, telemetry, onSideCallUsage); } else if (await this.shouldScheduleObserver(list, persistence.threadId)) { - this.scheduleObserverTask(persistence, executionCounter, telemetry); + this.scheduleObserverTask(persistence, executionCounter, telemetry, onSideCallUsage); } const reflect = observationalMemory.reflect; const reflectorThresholdTokens = observationalMemory.reflectorThresholdTokens; if (reflect && reflectorThresholdTokens !== undefined) { - void this.scheduleMemoryTask( - runner, - scope, - 'reflector', - async () => - await runObservationLogReflector({ - memory, - ...scope, - reflectorThresholdTokens, - reflect, - tokenCounter: this.tokenCounter, - executionCounter, - telemetry, - }), - ); + void this.scheduleMemoryTask(runner, scope, 'reflector', async () => { + const result = await runObservationLogReflector({ + memory, + ...scope, + reflectorThresholdTokens, + reflect, + tokenCounter: this.tokenCounter, + executionCounter, + telemetry, + // Usage is forwarded inside the reflector right after the model + // call (before persistence), so a billed reflector turn is + // priced even when post-processing throws. + onUsage: onSideCallUsage + ? (model, usage) => { + void this.forwardSideCallUsage(onSideCallUsage, 'reflector', model, usage); + } + : undefined, + }); + return result; + }); } } @@ -767,6 +806,7 @@ export class MemoryOrchestrator { persistence: AgentPersistenceOptions, executionCounter?: AgentExecutionCounter, telemetry?: BuiltTelemetry, + onSideCallUsage?: OnSideCallUsage, ): void { const capture = resolveEpisodicMemoryCapture(this.config, persistence); if (!capture) return; @@ -779,6 +819,15 @@ export class MemoryOrchestrator { executionCounter, telemetry, agentName: this.config.name, + // Each embed/reflect usage is forwarded inside the processor + // the moment its model call resolves, before persistence, so a + // billed turn is priced even when a later save or applyReflection + // throws. Returning usageReports here would double-count. + onUsage: onSideCallUsage + ? (model, usage) => { + void this.forwardSideCallUsage(onSideCallUsage, 'episodic', model, usage); + } + : undefined, }); } while (result.status === 'ran'); }); @@ -888,4 +937,25 @@ export class MemoryOrchestrator { observationScopeId: persistence.threadId, }; } + + /** + * Price a side-call model turn and forward it to the host so it can add the + * USD cost to the execution and thread totals. Best-effort: a catalog + * lookup or host failure is logged and never fails the memory task. + */ + private async forwardSideCallUsage( + onSideCallUsage: OnSideCallUsage | undefined, + task: SideCallTask, + model: string | undefined, + usage: TokenUsage | undefined, + ): Promise { + if (!onSideCallUsage || !usage || !model) return; + try { + const cost = await computeSideCallCost(model, usage, this.config.promptCaching); + if (cost === undefined) return; + await onSideCallUsage({ task, model, usage, cost, reportId: crypto.randomUUID() }); + } catch (error) { + logger.warn('Failed to report side-call usage', { error, task }); + } + } } diff --git a/packages/@n8n/agents/src/runtime/memory/observation-log-defaults.ts b/packages/@n8n/agents/src/runtime/memory/observation-log-defaults.ts index ec4d6fc06f4..0d681820c00 100644 --- a/packages/@n8n/agents/src/runtime/memory/observation-log-defaults.ts +++ b/packages/@n8n/agents/src/runtime/memory/observation-log-defaults.ts @@ -158,19 +158,18 @@ export function createObservationLogObserveFn( }); incrementTokenCountFromUsage(input.executionCounter, usage); - if (options.onUsage) { - const tokenUsage = toTokenUsage(usage, providerMetadata); - if (tokenUsage) { - await options.onUsage({ - task: 'observer', - model: getModelIdString(model), - usage: tokenUsage, - reportId: crypto.randomUUID(), - }); - } + const tokenUsage = toTokenUsage(usage, providerMetadata); + const modelId = getModelIdString(model); + if (options.onUsage && tokenUsage) { + await options.onUsage({ + task: 'observer', + model: modelId, + usage: tokenUsage, + reportId: crypto.randomUUID(), + }); } - return text.trim(); + return { text: text.trim(), usage: tokenUsage, model: modelId }; }; } @@ -326,16 +325,15 @@ export function createObservationLogReflectFn( }); incrementTokenCountFromUsage(input.executionCounter, usage); - if (options.onUsage) { - const tokenUsage = toTokenUsage(usage, providerMetadata); - if (tokenUsage) { - await options.onUsage({ - task: 'reflector', - model: getModelIdString(model), - usage: tokenUsage, - reportId: crypto.randomUUID(), - }); - } + const tokenUsage = toTokenUsage(usage, providerMetadata); + const modelId = getModelIdString(model); + if (options.onUsage && tokenUsage) { + await options.onUsage({ + task: 'reflector', + model: modelId, + usage: tokenUsage, + reportId: crypto.randomUUID(), + }); } const reflection = parseObservationLogReflectionJson(text); @@ -344,15 +342,19 @@ export function createObservationLogReflectFn( if (id === undefined) throw new Error(`Unknown observation reference: ${reference}`); return id; }; - return JSON.stringify({ - drop: reflection.drop.map(resolveId), - merge: reflection.merge.map((merge) => ({ - ...merge, - supersedes: merge.supersedes.map(resolveId), - ...(merge.parentId !== undefined && { - parentId: merge.parentId === null ? null : resolveId(merge.parentId), - }), - })), - }); + return { + text: JSON.stringify({ + drop: reflection.drop.map(resolveId), + merge: reflection.merge.map((merge) => ({ + ...merge, + supersedes: merge.supersedes.map(resolveId), + ...(merge.parentId !== undefined && { + parentId: merge.parentId === null ? null : resolveId(merge.parentId), + }), + })), + }), + usage: tokenUsage, + model: modelId, + }; }; } diff --git a/packages/@n8n/agents/src/runtime/memory/observation-log-observer.ts b/packages/@n8n/agents/src/runtime/memory/observation-log-observer.ts index ca92205287a..daad3409529 100644 --- a/packages/@n8n/agents/src/runtime/memory/observation-log-observer.ts +++ b/packages/@n8n/agents/src/runtime/memory/observation-log-observer.ts @@ -1,8 +1,9 @@ import { isSensitiveKey } from '@n8n/utils/redaction/sensitive-key'; import { renderObservationLog } from './observation-log-renderer'; +import { reportSideCallUsage } from './forward-usage'; import { redactText } from '../../sdk/guardrails'; -import type { AgentExecutionCounter } from '../../types/sdk/agent'; +import type { AgentExecutionCounter, TokenUsage } from '../../types/sdk/agent'; import type { BuiltMemory } from '../../types/sdk/memory'; import type { AgentDbMessage, ContentToolCall, Message } from '../../types/sdk/message'; import type { ObservationCursor } from '../../types/sdk/observation'; @@ -83,6 +84,13 @@ export interface RunObservationLogObserverOpts { onMalformedLine?: (line: string) => void; executionCounter?: AgentExecutionCounter; telemetry?: BuiltTelemetry; + /** + * Receives the observer model call's usage the moment it resolves, before + * any parsing or persistence. Forwarding it here (rather than only on the + * success return) keeps a billed observer call priced even when later + * post-processing throws. Fire-and-forget from the caller's perspective. + */ + onUsage?: (model: string | undefined, usage: TokenUsage | undefined) => void | Promise; } export type RunObservationLogObserverResult = @@ -93,6 +101,10 @@ export type RunObservationLogObserverResult = cursorAdvanced: boolean; tokenCount: number; skippedLines: string[]; + /** Normalized token usage from the observer LLM call, when the provider reports it. */ + usage?: TokenUsage; + /** Stable model id string of the model that produced the observations. */ + model?: string; }; export function parseObservationLogMarkdown(markdown: string): ParseObservationLogMarkdownResult { @@ -217,7 +229,7 @@ export async function runObservationLogObserver( ).reverse(); const now = opts.now ?? new Date(); const renderedObservationLogTail = renderObservationLog(observationLogTail); - const markdown = await opts.observe({ + const observeResult = await opts.observe({ observationScopeId, now, deltaMessages: observable, @@ -228,6 +240,14 @@ export async function runObservationLogObserver( executionCounter: opts.executionCounter, telemetry: opts.telemetry, }); + const markdown = typeof observeResult === 'string' ? observeResult : observeResult.text; + const observeUsage = typeof observeResult === 'string' ? undefined : observeResult.usage; + const observeModel = typeof observeResult === 'string' ? undefined : observeResult.model; + // Forward usage immediately after the model call, before parsing or + // persistence, so a billed observer turn is priced even when the + // post-processing below throws. Fire-and-forget: never block on pricing, + // and never let a callback throw or rejection abort observation. + reportSideCallUsage(opts.onUsage, observeModel, observeUsage); const noObservations = markdown.trim() === 'NO_OBSERVATIONS'; const parsed = noObservations @@ -248,6 +268,8 @@ export async function runObservationLogObserver( cursorAdvanced: false, tokenCount, skippedLines: parsed.skippedLines, + usage: observeUsage, + model: observeModel, }; } @@ -298,6 +320,8 @@ export async function runObservationLogObserver( cursorAdvanced, tokenCount, skippedLines: parsed.skippedLines, + usage: observeUsage, + model: observeModel, }; } diff --git a/packages/@n8n/agents/src/runtime/memory/observation-log-reflector.ts b/packages/@n8n/agents/src/runtime/memory/observation-log-reflector.ts index f12bb92e82f..471033b881a 100644 --- a/packages/@n8n/agents/src/runtime/memory/observation-log-reflector.ts +++ b/packages/@n8n/agents/src/runtime/memory/observation-log-reflector.ts @@ -2,8 +2,9 @@ import { extractJsonCandidate } from '@n8n/ai-utilities/llm-output'; import { isRecord } from '@n8n/utils/is-record'; import { uniqueStrings } from './memory-lifecycle'; +import { reportSideCallUsage } from './forward-usage'; import { redactText } from '../../sdk/guardrails'; -import type { AgentExecutionCounter } from '../../types/sdk/agent'; +import type { AgentExecutionCounter, TokenUsage } from '../../types/sdk/agent'; import type { BuiltObservationLogStore, ObservationLogEntry, @@ -49,6 +50,13 @@ export interface RunObservationLogReflectorOpts { onWarning?: (warning: ObservationLogReflectorWarning) => void; executionCounter?: AgentExecutionCounter; telemetry?: BuiltTelemetry; + /** + * Receives the reflector model call's usage the moment it resolves, before + * any parsing or persistence. Forwarding it here (rather than only on the + * success return) keeps a billed reflector call priced even when later + * post-processing throws. Fire-and-forget from the caller's perspective. + */ + onUsage?: (model: string | undefined, usage: TokenUsage | undefined) => void | Promise; } export type RunObservationLogReflectorResult = @@ -60,6 +68,10 @@ export type RunObservationLogReflectorResult = overBudgetAfterReflection: boolean; reflection: ObservationLogReflection; result: ObservationLogReflectionResult; + /** Normalized token usage from the reflector LLM call, when the provider reports it. */ + usage?: TokenUsage; + /** Stable model id string of the model that produced the reflection. */ + model?: string; }; export function parseObservationLogReflectionJson(output: string): ObservationLogReflection { @@ -180,7 +192,7 @@ export async function runObservationLogReflector( const now = opts.now ?? new Date(); const renderedObservationLog = renderObservationLogForReflection(activeObservationLog); - const output = await opts.reflect({ + const reflectResult = await opts.reflect({ observationScopeId, now, activeObservationLog, @@ -190,6 +202,14 @@ export async function runObservationLogReflector( executionCounter: opts.executionCounter, telemetry: opts.telemetry, }); + const output = typeof reflectResult === 'string' ? reflectResult : reflectResult.text; + const reflectUsage = typeof reflectResult === 'string' ? undefined : reflectResult.usage; + const reflectModel = typeof reflectResult === 'string' ? undefined : reflectResult.model; + // Forward usage immediately after the model call, before parsing or + // persistence, so a billed reflector turn is priced even when the + // post-processing below throws. Fire-and-forget: never block on pricing, + // and never let a callback throw or rejection abort reflection. + reportSideCallUsage(opts.onUsage, reflectModel, reflectUsage); const normalized = normalizeObservationLogReflection( activeObservationLog, withCreatedAt(parseObservationLogReflectionJson(output), now), @@ -225,6 +245,8 @@ export async function runObservationLogReflector( overBudgetAfterReflection, reflection, result, + usage: reflectUsage, + model: reflectModel, }; } diff --git a/packages/@n8n/agents/src/runtime/memory/title-generation.ts b/packages/@n8n/agents/src/runtime/memory/title-generation.ts index c6370f997c1..edda32afaa1 100644 --- a/packages/@n8n/agents/src/runtime/memory/title-generation.ts +++ b/packages/@n8n/agents/src/runtime/memory/title-generation.ts @@ -1,12 +1,21 @@ import type { LanguageModel } from 'ai'; import type { BuiltMemory, BuiltTelemetry, TitleGenerationConfig } from '../../types'; -import type { AgentExecutionCounter, ModelConfig } from '../../types/sdk/agent'; +import type { + AgentExecutionCounter, + ModelConfig, + PromptCachingConfig, + SideCallUsageReport, + TokenUsage, +} from '../../types/sdk/agent'; import type { AgentDbMessage } from '../../types/sdk/message'; +import { getModelIdString } from '../../utils/model'; import { createFilteredLogger } from '../logger'; import { incrementTokenCountFromUsage } from '../loop/execution-counter'; +import { computeSideCallCost } from '../loop/side-call-cost'; import { loadAi } from '../model/lazy-ai'; import { createModel, type FetchFn } from '../model/model-factory'; +import { toTokenUsage } from '../streaming/stream'; import { buildAiSdkTelemetry } from '../telemetry/telemetry-options'; const logger = createFilteredLogger(); @@ -144,13 +153,16 @@ export async function generateTitleFromMessage( * it; falls back to treating the whole response as a plain title if the model * ignores the JSON format. * - * Returns `null` on empty/trivial input or empty LLM output. + * Returns `null` only on empty/trivial input (no model call is made). When the + * model is called, returns an object with `usage` and `title` set to `null` + * when no usable title could be extracted, so the caller can still price the + * billed turn. */ export async function generateTitleAndEmojiFromMessage( model: LanguageModel, userMessage: string, opts?: { instructions?: string; executionCounter?: AgentExecutionCounter }, -): Promise<{ title: string; emoji?: string } | null> { +): Promise<{ title: string | null; emoji?: string; usage?: TokenUsage } | null> { const trimmed = userMessage.trim(); if (!trimmed) return null; @@ -170,13 +182,14 @@ ${trimmed} messages: [{ role: 'user', content: wrappedMessage }], }); incrementTokenCountFromUsage(opts?.executionCounter, result.usage); + const usage = toTokenUsage(result.usage, result.providerMetadata); let text = result.text?.trim(); - if (!text) return null; + if (!text) return { title: null, usage }; // Strip ... blocks (e.g. from DeepSeek R1) before JSON parsing. text = text.replace(/[\s\S]*?<\/think>/g, '').trim(); - if (!text) return null; + if (!text) return { title: null, usage }; let rawTitle = ''; let emoji: string | undefined; @@ -197,9 +210,9 @@ ${trimmed} } const title = sanitizeTitle(rawTitle); - if (!title) return null; + if (!title) return { title: null, emoji, usage }; - return { title, emoji }; + return { title, emoji, usage }; } /** @@ -221,6 +234,13 @@ export async function generateThreadTitle(opts: { /** Messages from the current turn, used to find the first user message. */ turnDelta: AgentDbMessage[]; executionCounter?: AgentExecutionCounter; + /** + * Host callback that receives the priced title-generation usage so it can + * add the USD cost to the execution and thread totals. Best-effort. + */ + onSideCallUsage?: (report: SideCallUsageReport) => void | Promise; + /** Agent prompt-caching config, used to resolve Anthropic cache-write pricing for the title model. */ + promptCaching?: PromptCachingConfig; }): Promise { try { const thread = await opts.memory.getThread(opts.threadId); @@ -243,7 +263,27 @@ export async function generateThreadTitle(opts: { }); if (!generated) return; - const { title, emoji } = generated; + const { title, emoji, usage } = generated; + + // Price the title model turn and forward it to the host so it adds the + // cost to the execution and thread totals. Report immediately after the + // model call — independent of whether a usable title was extracted or + // later persistence succeeds — so a billed turn is never left unpriced. + // Best-effort: a catalog lookup or host failure is logged and never + // breaks title generation. Awaited (not fire-and-forget) so the cost is + // captured deterministically before generateThreadTitle resolves, while + // still best-effort: reportTitleUsage swallows its own errors. + if (usage && opts.onSideCallUsage) { + await reportTitleUsage( + opts.onSideCallUsage, + getModelIdString(titleModelId), + usage, + opts.promptCaching, + ); + } + + // No usable title — the model call was still billed (reported above). + if (!title) return; // Store emoji in thread metadata const metadata = { ...(thread?.metadata ?? {}), ...(emoji && { emoji }) }; @@ -258,3 +298,28 @@ export async function generateThreadTitle(opts: { logger.warn('Failed to generate thread title', { error }); } } + +/** + * Price a title-generation model turn and forward it to the host. Best-effort: + * a catalog lookup or host failure is logged and never breaks title generation. + */ +async function reportTitleUsage( + onSideCallUsage: (report: SideCallUsageReport) => void | Promise, + modelId: string, + usage: TokenUsage, + promptCaching?: PromptCachingConfig, +): Promise { + try { + const cost = await computeSideCallCost(modelId, usage, promptCaching); + if (cost === undefined) return; + await onSideCallUsage({ + task: 'title', + model: modelId, + usage, + cost, + reportId: crypto.randomUUID(), + }); + } catch (error) { + logger.warn('Failed to report title generation usage', { error }); + } +} diff --git a/packages/@n8n/agents/src/runtime/model/message-list.ts b/packages/@n8n/agents/src/runtime/model/message-list.ts index 59b448a3346..093651e8c59 100644 --- a/packages/@n8n/agents/src/runtime/model/message-list.ts +++ b/packages/@n8n/agents/src/runtime/model/message-list.ts @@ -426,9 +426,13 @@ export class AgentMessageList { return this.all.filter((m) => this.inputSet.has(m)); } - removeInput(): void { - this.all = this.all.filter((message) => !this.inputSet.has(message)); - this.inputSet.clear(); + removeInput(messageIds: readonly string[]): void { + const ids = new Set(messageIds); + this.all = this.all.filter((message) => { + if (!this.inputSet.has(message) || !ids.has(message.id)) return true; + this.inputSet.delete(message); + return false; + }); } /** All messages currently in the list, as live references. */ diff --git a/packages/@n8n/agents/src/runtime/streaming/__tests__/stream-writer-guard.test.ts b/packages/@n8n/agents/src/runtime/streaming/__tests__/stream-writer-guard.test.ts new file mode 100644 index 00000000000..dfc9d477b32 --- /dev/null +++ b/packages/@n8n/agents/src/runtime/streaming/__tests__/stream-writer-guard.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import type { StreamChunk } from '../../../types'; +import { StreamWriterGuard } from '../stream-writer-guard'; + +describe('StreamWriterGuard', () => { + it.each(['close', 'abort'] as const)( + 'marks the stream closed after writer.%s()', + async (action) => { + const writer = new WritableStream().getWriter(); + const guard = new StreamWriterGuard(writer); + + expect(guard.isClosed).toBe(false); + expect(guard.closedSignal.aborted).toBe(false); + + await Promise.allSettled([writer[action](), writer.closed]); + + expect(guard.isClosed).toBe(true); + expect(guard.closedSignal.aborted).toBe(true); + }, + ); +}); diff --git a/packages/@n8n/agents/src/runtime/streaming/stream-writer-guard.ts b/packages/@n8n/agents/src/runtime/streaming/stream-writer-guard.ts index 54907389083..7353805ec67 100644 --- a/packages/@n8n/agents/src/runtime/streaming/stream-writer-guard.ts +++ b/packages/@n8n/agents/src/runtime/streaming/stream-writer-guard.ts @@ -11,15 +11,20 @@ import type { TokenUsage } from '../../types/sdk/agent'; */ export class StreamWriterGuard { private closed = false; + private readonly closure = new AbortController(); private errorWritten = false; constructor(private readonly writer: WritableStreamDefaultWriter) { - writer.closed - .then(() => { - this.closed = true; - }) - .catch(() => {}); + const close = () => { + this.closed = true; + this.closure.abort(new Error('Agent stream closed')); + }; + void writer.closed.then(close, close); + } + + get closedSignal(): AbortSignal { + return this.closure.signal; } get isClosed(): boolean { diff --git a/packages/@n8n/agents/src/types/index.ts b/packages/@n8n/agents/src/types/index.ts index 57bc6360d43..575561eb1db 100644 --- a/packages/@n8n/agents/src/types/index.ts +++ b/packages/@n8n/agents/src/types/index.ts @@ -46,8 +46,11 @@ export type { ModelConfig, RunOptions, ExecutionOptions, + AgentInputBoundary, SmoothStreamOptions, AgentExecutionCounter, + SideCallTask, + SideCallUsageReport, PersistedExecutionOptions, ResumeOptions, GenerateResult, @@ -91,6 +94,7 @@ export type { EpisodicMemoryMethods, EpisodicMemoryPrompts, EpisodicMemoryReflectFn, + EpisodicMemoryReflectResult, EpisodicMemoryReflection, EpisodicMemoryReflectionApply, EpisodicMemoryReflectionApplyMerge, @@ -138,9 +142,11 @@ export type { ObservationLogMerge, MemoryTaskUsageReport, ObservationLogObserveFn, + ObservationLogObserveResult, ObservationLogObserverInput, ObservationLogReadOptions, ObservationLogReflectFn, + ObservationLogReflectResult, ObservationLogReflectorInput, ObservationLogReflection, ObservationLogReflectionResult, diff --git a/packages/@n8n/agents/src/types/runtime/agent-loop.ts b/packages/@n8n/agents/src/types/runtime/agent-loop.ts index fdc85340232..4d28eadc57b 100644 --- a/packages/@n8n/agents/src/types/runtime/agent-loop.ts +++ b/packages/@n8n/agents/src/types/runtime/agent-loop.ts @@ -11,7 +11,7 @@ import type { import type { FinishReason, SerializableAgentState, TokenUsage } from '../index'; import type { ExecutionOptions, RunOptions } from '../sdk/agent'; -import type { AgentMessage } from '../sdk/message'; +import type { AgentDbMessage, AgentMessage } from '../sdk/message'; import type { JSONObject } from '../utils/json'; import type { AgentMessageList } from '../../runtime/model/message-list'; import type { ToolCallBatchResult, ToolCallInput, ToolCallSuspension } from './tool-execution'; @@ -113,6 +113,9 @@ export interface RunServices { * `StreamChunk`s through the stream writer guard. */ export interface RunOutputSink { + /** Wait until the host has recorded preceding output before it commits input. */ + inputBoundary?(signal: AbortSignal): Promise; + emitInput?(message: AgentDbMessage): Promise; /** Run one LLM turn. Streaming implementations also emit text/tool chunks here. */ callModel(ctx: ModelCallContext): Promise; /** diff --git a/packages/@n8n/agents/src/types/sdk/agent.ts b/packages/@n8n/agents/src/types/sdk/agent.ts index 32762cea5c4..5944f2fe7fb 100644 --- a/packages/@n8n/agents/src/types/sdk/agent.ts +++ b/packages/@n8n/agents/src/types/sdk/agent.ts @@ -7,7 +7,7 @@ import type { } from 'ai'; import type { JsonSchema7Type } from 'zod-to-json-schema'; -import type { AgentMessage, ContentMetadata } from './message'; +import type { AgentDbMessage, AgentMessage, ContentMetadata } from './message'; import type { ProviderId, ProviderCredentials } from '../../runtime/model/provider-credentials'; import type { AgentEvent, @@ -90,6 +90,8 @@ export interface AgentResult { export type StreamChunk = ContentMetadata & ( + | { type: 'input-boundary'; acknowledge: () => void } + | { type: 'input'; message: AgentDbMessage } | { type: 'start-step' } | { type: 'finish-step' } | { type: 'text-start'; id: string } @@ -184,7 +186,40 @@ export interface AgentExecutionCounter { incrementTokenCount(tokenCount: number): void; } +/** + * Kind of background model call whose cost is added to the session total + * outside the main agent loop. Title generation, observation-log + * observer/reflector, and episodic-memory model calls all bill here. + */ +export type SideCallTask = 'title' | 'observer' | 'reflector' | 'episodic'; + +/** + * Reported after a side-call model turn completes, so the host can add the + * estimated USD cost to the execution and thread totals. The `reportId` is + * minted once per call so hosts can dedupe idempotently. + */ +export interface SideCallUsageReport { + task: SideCallTask; + /** Stable model id string (e.g. 'anthropic/claude-sonnet-4-5'). */ + model: string; + usage: TokenUsage; + /** Estimated cost in USD, computed from models.dev pricing. */ + cost: number; + /** Stable per-LLM-call id, generated once at the call site for idempotent billing. */ + reportId: string; +} + +export interface AgentInputBoundary { + /** Current turn messages, ready for durable storage. */ + messages: AgentDbMessage[]; + lastCreatedAt: number; + completing: boolean; + canContinue: boolean; +} + export interface ExecutionOptions { + /** Commit additional input between model calls. Stream consumers must acknowledge input-boundary chunks. */ + onInputBoundary?: (boundary: AgentInputBoundary) => Promise; maxIterations?: number; abortSignal?: AbortSignal; providerOptions?: ProviderOptions; @@ -224,6 +259,14 @@ export interface ExecutionOptions { telemetry?: BuiltTelemetry; /** Inherited execution counter from the host runtime. Used for aggregate heartbeat telemetry. */ executionCounter?: AgentExecutionCounter; + /** + * Reported after a side-call model turn (title generation, observation-log + * observer/reflector, episodic-memory model calls) completes, so the host + * can add the estimated USD cost to the execution and thread totals. The + * runtime computes the cost via the model catalog; the host only adds it. + * Best-effort: a host failure here must not break the run. + */ + onSideCallUsage?: (report: SideCallUsageReport) => void | Promise; onStepStart?: (event: GenerateTextStepStartEvent) => void | Promise; onStepEnd?: (event: GenerateTextStepEndEvent) => void | Promise; /** @deprecated Use `onStepEnd` instead. */ diff --git a/packages/@n8n/agents/src/types/sdk/memory.ts b/packages/@n8n/agents/src/types/sdk/memory.ts index fcad9a9ef85..6b34c3eb918 100644 --- a/packages/@n8n/agents/src/types/sdk/memory.ts +++ b/packages/@n8n/agents/src/types/sdk/memory.ts @@ -1,6 +1,11 @@ import type { EmbeddingModel } from 'ai'; -import type { AgentExecutionCounter, ModelConfig, SerializableAgentState } from './agent'; +import type { + AgentExecutionCounter, + ModelConfig, + SerializableAgentState, + TokenUsage, +} from './agent'; import type { AgentDbMessage } from './message'; import type { BuiltObservationLogStore, @@ -256,9 +261,23 @@ export interface EpisodicMemoryReflectorInput { executionCounter?: AgentExecutionCounter; } +export interface EpisodicMemoryReflectResult { + reflection: EpisodicMemoryReflection; + /** Normalized token usage from the reflector LLM call, when the provider reports it. */ + usage?: TokenUsage; + /** Stable model id string of the model that produced the reflection. */ + model: string; +} + +/** + * Reflect a cluster of episodic entries. Returns the reflection, or a + * `{ reflection, usage, model }` result that also carries token usage for the + * host to price. Bare `EpisodicMemoryReflection` returns are accepted for + * backward compatibility. + */ export type EpisodicMemoryReflectFn = ( input: EpisodicMemoryReflectorInput, -) => Promise; +) => Promise; export interface EpisodicMemoryReflectionApplyMerge { supersedes: string[]; diff --git a/packages/@n8n/agents/src/types/sdk/observation-log.ts b/packages/@n8n/agents/src/types/sdk/observation-log.ts index 20ba757aefc..f5836bef11d 100644 --- a/packages/@n8n/agents/src/types/sdk/observation-log.ts +++ b/packages/@n8n/agents/src/types/sdk/observation-log.ts @@ -88,7 +88,22 @@ export interface ObservationLogObserverInput { } /** Return observation bullets, or exactly NO_OBSERVATIONS when the batch has no new facts. */ -export type ObservationLogObserveFn = (input: ObservationLogObserverInput) => Promise; +export interface ObservationLogObserveResult { + text: string; + /** Normalized token usage from the observer LLM call, when the provider reports it. */ + usage?: TokenUsage; + /** Stable model id string of the model that produced the result. */ + model: string; +} + +/** + * Observe the transcript delta. Returns the observation markdown, or a + * `{ text, usage, model }` result that also carries token usage for the host + * to price. String returns are accepted for backward compatibility. + */ +export type ObservationLogObserveFn = ( + input: ObservationLogObserverInput, +) => Promise; export interface ObservationLogReflectorInput { observationScopeId: string; @@ -101,7 +116,23 @@ export interface ObservationLogReflectorInput { telemetry?: BuiltTelemetry; } -export type ObservationLogReflectFn = (input: ObservationLogReflectorInput) => Promise; +export interface ObservationLogReflectResult { + /** JSON string with `drop` and `merge` arrays, using the references supplied in the input. */ + text: string; + /** Normalized token usage from the reflector LLM call, when the provider reports it. */ + usage?: TokenUsage; + /** Stable model id string of the model that produced the result. */ + model: string; +} + +/** + * Reflect the active observation log. Returns the reflection JSON, or a + * `{ text, usage, model }` result that also carries token usage for the host + * to price. String returns are accepted for backward compatibility. + */ +export type ObservationLogReflectFn = ( + input: ObservationLogReflectorInput, +) => Promise; /** Reported after an observation-log observer/reflector LLM call completes, for hosts that meter usage. */ export interface MemoryTaskUsageReport { diff --git a/packages/@n8n/agents/vitest.integration.config.mjs b/packages/@n8n/agents/vitest.integration.config.mjs index 9a90dc4efff..3e0edc98329 100644 --- a/packages/@n8n/agents/vitest.integration.config.mjs +++ b/packages/@n8n/agents/vitest.integration.config.mjs @@ -2,6 +2,7 @@ import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; import { config as loadEnv } from 'dotenv'; import { defineConfig } from 'vitest/config'; +import { profilingReporters } from '@n8n/vitest-config/node'; const __dirname = dirname(fileURLToPath(import.meta.url)); loadEnv({ path: resolve(__dirname, '.env'), quiet: true }); @@ -10,6 +11,7 @@ export default defineConfig({ test: { include: ['src/__tests__/integration/**/*.test.ts'], setupFiles: ['vitest.integration.setup.ts'], + reporters: profilingReporters(['default']), testTimeout: 120_000, hookTimeout: 30_000, }, diff --git a/packages/@n8n/ai-node-sdk/package.json b/packages/@n8n/ai-node-sdk/package.json index aa34e9477f5..2eb7f19bcf8 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.31.0", + "version": "0.32.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/eslint.config.mjs b/packages/@n8n/ai-utilities/eslint.config.mjs index 953fed1cb9c..2bb94d5ccc1 100644 --- a/packages/@n8n/ai-utilities/eslint.config.mjs +++ b/packages/@n8n/ai-utilities/eslint.config.mjs @@ -5,14 +5,58 @@ export default defineConfig( backendConfig, globalIgnores(['scripts/**']), { + files: ['src/utils/n8n-binary-loader.ts'], + rules: { 'no-case-declarations': 'warn' }, + }, + { + files: [ + 'src/__tests__/adapters/langchain-chat-model.test.ts', + 'src/__tests__/suppliers/supplyModel.test.ts', + 'src/__tests__/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.test.ts', + 'src/__tests__/utils/n8n-llm-tracing.test.ts', + 'src/adapters/langchain-chat-model.ts', + 'src/types/message.ts', + 'src/types/tool.ts', + 'src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.ts', + 'src/utils/log-wrapper.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/__tests__/*.test.ts', + ], rules: { '@typescript-eslint/no-explicit-any': 'warn', - 'no-case-declarations': 'warn', }, }, { - // Debt: the base layer enforces kebab-case filenames and this package has - // 31 files that predate it. Rename them, then delete this block. + files: [ + 'src/__tests__/suppliers/supplyModel.test.ts', + 'src/__tests__/utils/failed-attempt-handler/n8nDefaultFailedAttemptHandler.test.ts', + 'src/__tests__/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.test.ts', + 'src/suppliers/supplyMemory.ts', + 'src/suppliers/supplyModel.ts', + 'src/utils/failed-attempt-handler/n8nDefaultFailedAttemptHandler.ts', + 'src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.ts', + 'src/utils/vector-store/MemoryManager/MemoryCalculator.ts', + 'src/utils/vector-store/MemoryManager/MemoryVectorStoreManager.ts', + 'src/utils/vector-store/MemoryManager/StoreCleanupService.ts', + 'src/utils/vector-store/MemoryManager/test/MemoryCalculator.test.ts', + 'src/utils/vector-store/MemoryManager/test/MemoryVectorStoreManager.test.ts', + 'src/utils/vector-store/MemoryManager/test/StoreCleanupService.test.ts', + 'src/utils/vector-store/createVectorStoreNode/createVectorStoreNode.test.ts', + 'src/utils/vector-store/createVectorStoreNode/createVectorStoreNode.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/__tests__/insertOperation.test.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/__tests__/loadOperation.test.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/__tests__/operationHandlers.test.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/__tests__/retrieveAsToolExecuteOperation.test.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/__tests__/retrieveAsToolOperation.test.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/__tests__/retrieveOperation.test.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/__tests__/updateOperation.test.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/insertOperation.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/loadOperation.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/retrieveAsToolExecuteOperation.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/retrieveAsToolOperation.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/retrieveOperation.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/updateOperation.ts', + 'src/utils/vector-store/processDocuments.ts', + ], rules: { 'unicorn/filename-case': 'off', }, diff --git a/packages/@n8n/ai-utilities/oxlint.config.mts b/packages/@n8n/ai-utilities/oxlint.config.mts new file mode 100644 index 00000000000..c43748c06fb --- /dev/null +++ b/packages/@n8n/ai-utilities/oxlint.config.mts @@ -0,0 +1,63 @@ +import { backendConfig } from '@n8n/oxlint-config/backend'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [backendConfig], + options: { typeAware: true }, + ignorePatterns: ['scripts/**'], + overrides: [ + { + files: ['src/utils/n8n-binary-loader.ts'], + rules: { 'no-case-declarations': 'warn' }, + }, + { + files: [ + 'src/__tests__/adapters/langchain-chat-model.test.ts', + 'src/__tests__/suppliers/supplyModel.test.ts', + 'src/__tests__/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.test.ts', + 'src/__tests__/utils/n8n-llm-tracing.test.ts', + 'src/adapters/langchain-chat-model.ts', + 'src/types/message.ts', + 'src/types/tool.ts', + 'src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.ts', + 'src/utils/log-wrapper.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/__tests__/*.test.ts', + ], + rules: { 'typescript/no-explicit-any': 'warn' }, + }, + { + files: [ + 'src/__tests__/suppliers/supplyModel.test.ts', + 'src/__tests__/utils/failed-attempt-handler/n8nDefaultFailedAttemptHandler.test.ts', + 'src/__tests__/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.test.ts', + 'src/suppliers/supplyMemory.ts', + 'src/suppliers/supplyModel.ts', + 'src/utils/failed-attempt-handler/n8nDefaultFailedAttemptHandler.ts', + 'src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.ts', + 'src/utils/vector-store/MemoryManager/MemoryCalculator.ts', + 'src/utils/vector-store/MemoryManager/MemoryVectorStoreManager.ts', + 'src/utils/vector-store/MemoryManager/StoreCleanupService.ts', + 'src/utils/vector-store/MemoryManager/test/MemoryCalculator.test.ts', + 'src/utils/vector-store/MemoryManager/test/MemoryVectorStoreManager.test.ts', + 'src/utils/vector-store/MemoryManager/test/StoreCleanupService.test.ts', + 'src/utils/vector-store/createVectorStoreNode/createVectorStoreNode.test.ts', + 'src/utils/vector-store/createVectorStoreNode/createVectorStoreNode.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/__tests__/insertOperation.test.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/__tests__/loadOperation.test.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/__tests__/operationHandlers.test.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/__tests__/retrieveAsToolExecuteOperation.test.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/__tests__/retrieveAsToolOperation.test.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/__tests__/retrieveOperation.test.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/__tests__/updateOperation.test.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/insertOperation.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/loadOperation.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/retrieveAsToolExecuteOperation.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/retrieveAsToolOperation.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/retrieveOperation.ts', + 'src/utils/vector-store/createVectorStoreNode/operations/updateOperation.ts', + 'src/utils/vector-store/processDocuments.ts', + ], + rules: { 'unicorn/filename-case': 'off' }, + }, + ], +}); diff --git a/packages/@n8n/ai-utilities/package.json b/packages/@n8n/ai-utilities/package.json index bc3313a6562..bd6cde14b12 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.34.0", + "version": "0.35.0", "description": "Utilities for building AI nodes in n8n", "types": "dist/esm/index.d.ts", "module": "dist/esm/index.js", @@ -122,8 +122,8 @@ "build:unchecked": "tsc --build tsconfig.build.esm.json tsconfig.build.cjs.json --noCheck && tsc-alias -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.cjs.json && pnpm copy-tokenizer-json dist/cjs && pnpm copy-tokenizer-json dist/esm", "format": "biome format --write .", "format:check": "biome ci .", - "lint": "eslint . --quiet", - "lint:fix": "eslint . --fix", + "lint": "oxlint --quiet", + "lint:fix": "oxlint --fix", "watch": "tsc --build tsconfig.build.esm.json tsconfig.build.cjs.json --watch", "test": "vitest run", "test:unit": "vitest run", @@ -133,6 +133,7 @@ "dist" ], "devDependencies": { + "@n8n/oxlint-config": "workspace:*", "@types/json-schema": "catalog:", "@n8n/vitest-config": "workspace:*", "@vitest/coverage-v8": "catalog:", @@ -145,7 +146,9 @@ "tsx": "catalog:", "typescript": "catalog:typescript", "axios": "catalog:", - "nock": "catalog:" + "nock": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:" }, "dependencies": { "zod": "catalog:", diff --git a/packages/@n8n/ai-workflow-builder.ee/oxlint.config.mts b/packages/@n8n/ai-workflow-builder.ee/oxlint.config.mts new file mode 100644 index 00000000000..404190344a0 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/oxlint.config.mts @@ -0,0 +1,26 @@ +import { backendConfig } from '@n8n/oxlint-config/backend'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [backendConfig], + options: { typeAware: true }, + ignorePatterns: ['coverage/**', 'vitest.config.*.ts', 'evaluations/programmatic/python/.venv/**'], + rules: { + complexity: 'error', + }, + overrides: [ + { + files: ['./src/test/**/*.ts', './**/*.test.ts'], + rules: { + 'typescript/no-unsafe-assignment': 'warn', + }, + }, + { + files: ['./evaluations/**/*.ts'], + jsPlugins: ['@n8n/oxlint-config/import-x-alias'], + rules: { + 'import-x-alias/no-extraneous-dependencies': ['error', { devDependencies: true }], + }, + }, + ], +}); diff --git a/packages/@n8n/ai-workflow-builder.ee/package.json b/packages/@n8n/ai-workflow-builder.ee/package.json index 7c5c112697a..44a369f49be 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.41.0", + "version": "1.42.0", "scripts": { "clean": "rimraf dist .turbo", "typecheck": "tsc --noEmit", @@ -21,8 +21,8 @@ "test:eval:coverage": "vitest run evaluations --coverage", "test:no-eval": "vitest run --exclude '**/evaluations/**'", "test:no-eval:coverage": "vitest run --exclude '**/evaluations/**' --coverage", - "lint": "eslint . --quiet", - "lint:fix": "eslint . --fix", + "lint": "oxlint --quiet", + "lint:fix": "oxlint --fix", "watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\"", "deps:graph": "madge src/index.ts --image deps-graph.svg", "deps:graph:service": "madge src/ai-workflow-builder-agent.service.ts --image deps-service.svg", @@ -85,6 +85,7 @@ "zod": "catalog:" }, "devDependencies": { + "@n8n/oxlint-config": "workspace:*", "@n8n/typescript-config": "workspace:*", "@n8n/vitest-config": "workspace:*", "@types/jsdom": "catalog:", @@ -92,6 +93,8 @@ "@types/turndown": "catalog:", "@vitest/coverage-v8": "catalog:", "madge": "^8.0.0", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", "tsx": "catalog:", "typescript": "catalog:typescript", "vite": "catalog:", diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 9a89f5667e1..82756470753 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.41.0", + "version": "1.42.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/api-types/src/agent-sse.ts b/packages/@n8n/api-types/src/agent-sse.ts index e803783693d..eec472c7b50 100644 --- a/packages/@n8n/api-types/src/agent-sse.ts +++ b/packages/@n8n/api-types/src/agent-sse.ts @@ -9,9 +9,9 @@ * - `start-step` / `finish-step` mark LLM iteration boundaries. * * The frontend groups deltas by these ids and uses `start-step` / `finish-step` - * to decide when a new ChatMessage cursor should open. There is no - * server-minted `messageId` — the FE generates its own UUID per ChatMessage - * for v-for keys only. + * to decide when a new ChatMessage cursor should open. The frontend assigns + * display IDs to assistant messages. `execution-started` and `message-steered` + * carry canonical input IDs for reconciliation with history. * * `runId` is included on `ToolSuspendedPayload` and echoed back by the * frontend on resume. The SDK stores `runId` on each `PendingToolCall` and @@ -25,7 +25,7 @@ * */ -import type { AgentPersistedMessageContentPart } from './agents'; +import type { AgentPersistedMessageContentPart, AgentPersistedMessageDto } from './agents'; export interface ToolSuspendedPayload { toolCallId: string; @@ -73,6 +73,12 @@ export type ForwardedChildChunkWire = }; export type AgentSseEvent = + | { + type: 'message-steered'; + queueId: string; + executionId: string; + message: AgentPersistedMessageDto; + } | { type: 'message-queued'; queueId: string; sessionId: string } | { type: 'execution-started'; diff --git a/packages/@n8n/api-types/src/agents/__tests__/agent-chat-message.dto.test.ts b/packages/@n8n/api-types/src/agents/__tests__/agent-chat-message.dto.test.ts index 7755cae118f..2d4be1d31c5 100644 --- a/packages/@n8n/api-types/src/agents/__tests__/agent-chat-message.dto.test.ts +++ b/packages/@n8n/api-types/src/agents/__tests__/agent-chat-message.dto.test.ts @@ -21,6 +21,22 @@ describe('AgentChatMessageDto', () => { expect(result.success).toBe(true); }); + it('requires a session when a valid client message UUID is supplied', () => { + const messageId = 'C4B02D7B-2088-41CE-9C6B-FAF8C7B83D8A'; + const payload = { message: 'Hello', sessionId: 'session-1', messageId }; + expect(AgentChatMessageDto.parse(payload)).toEqual(payload); + expect(new AgentChatMessageDto(payload)).toMatchObject(payload); + for (const invalid of [ + { ...payload, sessionId: undefined }, + { ...payload, sessionId: '' }, + { ...payload, messageId: 'invalid' }, + { ...payload, messageId: '' }, + ]) { + expect(AgentChatMessageDto.safeParse(invalid).success).toBe(false); + expect(() => AgentChatMessageDto.parse(invalid)).toThrow(); + } + }); + it('rejects a payload with neither message text nor attachments', () => { expect(AgentChatMessageDto.safeParse({ message: '' }).success).toBe(false); expect(AgentChatMessageDto.safeParse({ message: ' ' }).success).toBe(false); diff --git a/packages/@n8n/api-types/src/agents/__tests__/slack-manager-credential.test.ts b/packages/@n8n/api-types/src/agents/__tests__/slack-manager-credential.test.ts new file mode 100644 index 00000000000..87c7f89ae6f --- /dev/null +++ b/packages/@n8n/api-types/src/agents/__tests__/slack-manager-credential.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { isSlackManagerCredentialReady } from '../slack/manager-credential'; + +describe('isSlackManagerCredentialReady', () => { + it.each([ + [{ connected: true, reconnectRequired: false }, true], + [{ connected: true, reconnectRequired: true }, false], + [{ connected: false, reconnectRequired: false }, false], + ])('returns the readiness of %o', (credential, expected) => { + expect(isSlackManagerCredentialReady(credential)).toBe(expected); + }); +}); diff --git a/packages/@n8n/api-types/src/agents/dto.ts b/packages/@n8n/api-types/src/agents/dto.ts index b62c14ec086..137756463a2 100644 --- a/packages/@n8n/api-types/src/agents/dto.ts +++ b/packages/@n8n/api-types/src/agents/dto.ts @@ -97,13 +97,15 @@ export class ListAgentSessionsQueryDto extends Z.class({ previewOnly: booleanFromString.optional(), status: z.enum(AGENT_SESSION_STATUSES).optional(), origin: z.enum(AGENT_SESSION_ORIGINS).optional(), + /** `mine` keeps only the sessions the requesting user owns. */ + scope: z.enum(['all', 'mine']).optional(), updatedAfter: z.coerce.date().optional(), updatedBefore: z.coerce.date().optional(), }) {} export type AgentSessionQueryFilters = Pick< ListAgentSessionsQueryDto, - 'status' | 'origin' | 'updatedAfter' | 'updatedBefore' | 'previewOnly' + 'status' | 'origin' | 'scope' | 'updatedAfter' | 'updatedBefore' | 'previewOnly' >; export class AgentProviderModelsQueryDto extends Z.class({ @@ -227,6 +229,7 @@ const agentChatMessageShape = { // (attachment-only sends) — see the schema-level refinement below. message: z.string(), sessionId: z.string().min(1).optional(), + messageId: z.string().uuid().optional(), newSession: z.literal(true).optional(), attachments: z .array(agentChatAttachmentSchema) @@ -239,6 +242,10 @@ const agentChatMessageSchema = z .refine((value) => value.message.trim().length > 0 || (value.attachments?.length ?? 0) > 0, { message: 'Message text or at least one attachment is required', path: ['message'], + }) + .refine((value) => !value.messageId || !!value.sessionId, { + message: 'A session ID is required with a message ID', + path: ['sessionId'], }); /** @@ -265,6 +272,10 @@ export class AgentChatQueueUpdateDto extends Z.class({ message: z.string(), }) {} +export class AgentChatQueueSteerDto extends Z.class({ + executionId: z.string().min(1).max(36), +}) {} + export class AgentChatResumeDto extends Z.class({ runId: z.string().min(1), toolCallId: z.string().min(1), diff --git a/packages/@n8n/api-types/src/agents/slack/index.ts b/packages/@n8n/api-types/src/agents/slack/index.ts index 986213b43af..f510af5f9d9 100644 --- a/packages/@n8n/api-types/src/agents/slack/index.ts +++ b/packages/@n8n/api-types/src/agents/slack/index.ts @@ -1,2 +1,3 @@ export * from './dto'; +export * from './manager-credential'; export type * from './types'; diff --git a/packages/@n8n/api-types/src/agents/slack/manager-credential.ts b/packages/@n8n/api-types/src/agents/slack/manager-credential.ts new file mode 100644 index 00000000000..98008e9a974 --- /dev/null +++ b/packages/@n8n/api-types/src/agents/slack/manager-credential.ts @@ -0,0 +1,8 @@ +import type { SlackManagerCredentialSummary } from './types'; + +/** True when n8n can use this Slack manager credential to create and install Slack apps. */ +export function isSlackManagerCredentialReady( + credential: Pick, +): boolean { + return credential.connected && !credential.reconnectRequired; +} diff --git a/packages/@n8n/api-types/src/agents/types.ts b/packages/@n8n/api-types/src/agents/types.ts index ddb56e05254..ab62cfa2d42 100644 --- a/packages/@n8n/api-types/src/agents/types.ts +++ b/packages/@n8n/api-types/src/agents/types.ts @@ -383,6 +383,7 @@ export interface AgentBuilderOpenSuspension { export interface AgentChatQueueItem { id: string; + steeringExecutionId: string | null; message: string; attachments?: Array<{ id: string; fileName: string; mimeType: string; sizeBytes: number }>; createdAt: string; @@ -390,6 +391,7 @@ export interface AgentChatQueueItem { export interface AgentChatQueueResponse { items: AgentChatQueueItem[]; + steerableExecutionId: string | null; } /** Chat history envelope returned by the agent chat messages endpoints. */ diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index d0fda76bd05..d215bbbb6bd 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -588,6 +588,10 @@ export { type OtlpProtocol, } from './otel/update-otel-settings.dto'; export { TestOtelTraceDto } from './otel/test-otel-trace.dto'; +export { + GetOtelSettingsQueryPublicDto, + OtelSettingsPublicDto, +} from './otel/otel-settings-public.dto'; export { PromotionChangesDto, diff --git a/packages/@n8n/api-types/src/dto/instance-ai/__tests__/instance-ai-thread-tabs-request.dto.test.ts b/packages/@n8n/api-types/src/dto/instance-ai/__tests__/instance-ai-thread-tabs-request.dto.test.ts new file mode 100644 index 00000000000..5519a5f80c0 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/instance-ai/__tests__/instance-ai-thread-tabs-request.dto.test.ts @@ -0,0 +1,31 @@ +import { InstanceAiThreadTabsRequestDto } from '../instance-ai-thread-tabs-request.dto'; + +describe('InstanceAiThreadTabsRequestDto', () => { + const state = { + tabs: [{ type: 'workflow' as const, id: 'wf-1', name: 'Digest' }], + closedTabs: [], + activeTab: { type: 'workflow' as const, id: 'wf-1' }, + }; + + // The save endpoint stores the parsed payload as it is, so it must hold only schema keys. + it('strips unknown keys, also inside the tabs', () => { + const result = InstanceAiThreadTabsRequestDto.safeParse({ + ...state, + unknownKey: 'dropped', + tabs: [{ ...state.tabs[0], unknownKey: 'dropped' }], + }); + + expect(result.success).toBe(true); + expect(result.data).toEqual(state); + }); + + it('keeps previewOpen when it is sent, and leaves it out when it is not', () => { + expect(InstanceAiThreadTabsRequestDto.safeParse({ ...state, previewOpen: false }).data).toEqual( + { + ...state, + previewOpen: false, + }, + ); + expect(InstanceAiThreadTabsRequestDto.safeParse(state).data).not.toHaveProperty('previewOpen'); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/otel/otel-settings-public.dto.ts b/packages/@n8n/api-types/src/dto/otel/otel-settings-public.dto.ts new file mode 100644 index 00000000000..76274e0d2df --- /dev/null +++ b/packages/@n8n/api-types/src/dto/otel/otel-settings-public.dto.ts @@ -0,0 +1,31 @@ +import '../../openapi-extend'; + +import { z } from 'zod'; + +import { + otelSettingsPublicDescription, + otelSettingsPublicFieldDocs as docs, +} from './otel-settings-public.openapi'; +import { OTLP_PROTOCOLS } from './update-otel-settings.dto'; +import { Z } from '../../zod-class'; + +export class OtelSettingsPublicDto extends Z.class( + { + enabled: z.boolean().openapi(docs.enabled), + exporterProtocol: z.string().openapi({ ...docs.exporterProtocol, enum: [...OTLP_PROTOCOLS] }), + exporterEndpoint: z.string().openapi(docs.exporterEndpoint), + exporterTracingPath: z.string().openapi(docs.exporterTracingPath), + exporterServiceName: z.string().openapi(docs.exporterServiceName), + exporterHeaders: z.string().openapi(docs.exporterHeaders), + tracesSampleRate: z.number().openapi(docs.tracesSampleRate), + startupConnectivityTimeoutMs: z.number().openapi(docs.startupConnectivityTimeoutMs), + includeNodeSpans: z.boolean().openapi(docs.includeNodeSpans), + injectOutbound: z.boolean().openapi(docs.injectOutbound), + productionExecutionsOnly: z.boolean().openapi(docs.productionExecutionsOnly), + }, + { strict: true }, +) { + static schema = super.schema.openapi({ description: otelSettingsPublicDescription }); +} + +export class GetOtelSettingsQueryPublicDto extends Z.class({}, { strict: true }) {} diff --git a/packages/@n8n/api-types/src/dto/otel/otel-settings-public.openapi.ts b/packages/@n8n/api-types/src/dto/otel/otel-settings-public.openapi.ts new file mode 100644 index 00000000000..c96e6ff15e6 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/otel/otel-settings-public.openapi.ts @@ -0,0 +1,77 @@ +import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi'; + +export const otelSettingsPublicDescription = + 'The OpenTelemetry configuration, matching the fields exposed in the UI. On a write this is a ' + + 'full replacement: every field must be provided, except `exporterProtocol`, which defaults to ' + + '`http/protobuf` when omitted. Fields managed declaratively via environment variables are ' + + 'returned with their effective value. A write can repeat these values, but changing them returns 409.'; + +export const otelSettingsPublicFieldDocs = { + enabled: { description: 'Whether OpenTelemetry tracing is enabled.', example: true }, + exporterProtocol: { + default: 'http/protobuf', + description: + 'The wire protocol used to export spans. Collectors conventionally serve OTLP/HTTP on port ' + + '4318 and OTLP/gRPC on port 4317. The endpoint scheme (`http://` or `https://`) controls ' + + 'TLS for both protocols. Optional: a write that omits it selects `http/protobuf`, but an ' + + 'instance that sets this field to another value with an environment variable rejects that ' + + 'write with 409.', + example: 'http/protobuf', + }, + exporterEndpoint: { + format: 'uri', + description: + 'The base URL of the OTLP collector to export traces to. The value must be an `http://` or ' + + '`https://` URL, because the scheme selects TLS — for gRPC, `https://` uses SSL and ' + + '`http://` is plaintext.', + example: 'http://localhost:4318', + }, + exporterTracingPath: { + description: + 'The path appended to the endpoint for the OTLP traces signal. Ignored when ' + + '`exporterProtocol` is `grpc`, which takes no URL path.', + example: '/v1/traces', + }, + exporterServiceName: { + minLength: 1, + description: 'The `service.name` resource attribute reported on every span.', + example: 'n8n', + }, + exporterHeaders: { + description: + 'Additional headers sent to the OTLP collector, as a single string of comma-separated ' + + '`key=value` pairs (e.g. `authorization=Bearer my-token,x-tenant-id=acme`). Whitespace ' + + 'around each key and value is trimmed; a value may contain spaces but not commas. Use an ' + + 'empty string when unused.', + example: 'authorization=Bearer my-token,x-tenant-id=acme', + }, + tracesSampleRate: { + minimum: 0, + maximum: 1, + description: 'The ratio of traces to sample, between 0 (none) and 1 (all).', + example: 1, + }, + startupConnectivityTimeoutMs: { + type: 'integer', + minimum: 0, + description: + 'How long, in milliseconds, to wait when checking the collector is reachable. Also used as ' + + 'the timeout for the test-trace endpoint.', + example: 2000, + }, + includeNodeSpans: { + description: 'Whether to emit a span for each node execution in addition to the workflow span.', + example: true, + }, + injectOutbound: { + description: + 'Whether to inject trace context headers into outbound HTTP requests made by nodes.', + example: true, + }, + productionExecutionsOnly: { + description: + 'When true, only production executions of published (active) workflows are traced, not ' + + 'manual/test runs.', + example: true, + }, +} as const satisfies Record; diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 7237d8b19a2..6de7cc93b09 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -320,7 +320,7 @@ export { export type { UsageState } from './schemas/usage.schema'; export type { - BreakingChangeRuleSeverity, + BreakingChangeRuleImpact, BreakingChangeRecommendation, BreakingChangeAffectedWorkflow, BreakingChangeInstanceIssue, @@ -708,6 +708,13 @@ export type { InstanceAiEvalSeedFolder, } from './schemas/instance-ai.schema'; +export { + instanceAiSetupCredentialSelectionKey, + instanceAiSetupCredentialAppliedKey, + readPendingInstanceAiSetupCredentialSelections, + type InstanceAiSetupCredentialSelection, +} from './schemas/instance-ai-setup-credential-selection'; + export type { McpRegistryServerStatus, McpRegistryServerIconResponse, diff --git a/packages/@n8n/api-types/src/schemas/__tests__/instance-ai-setup-credential-selection.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/instance-ai-setup-credential-selection.test.ts new file mode 100644 index 00000000000..0c7c2a35cf2 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/__tests__/instance-ai-setup-credential-selection.test.ts @@ -0,0 +1,42 @@ +import { + instanceAiSetupCredentialAppliedKey, + instanceAiSetupCredentialSelectionKey, + readPendingInstanceAiSetupCredentialSelections, + type InstanceAiSetupCredentialSelection, +} from '../instance-ai-setup-credential-selection'; + +const selection: InstanceAiSetupCredentialSelection = { + selectionId: 'choice-1', + credentialType: 'slackApi', + credentialId: 'credential-1', +}; +const itemId = 'workflow-1:credential:slackApi'; + +describe('readPendingInstanceAiSetupCredentialSelections', () => { + it('reads only valid selections for the requested workflow', () => { + expect( + readPendingInstanceAiSetupCredentialSelections( + { + [instanceAiSetupCredentialSelectionKey(itemId)]: selection, + [instanceAiSetupCredentialSelectionKey('workflow-2:credential:slackApi')]: selection, + [instanceAiSetupCredentialSelectionKey('workflow-1:credential:gmailOAuth2')]: selection, + [instanceAiSetupCredentialSelectionKey(`${itemId}:invalid`)]: { credentialId: 'id' }, + }, + 'workflow-1', + ), + ).toEqual([{ itemId, selection }]); + }); + + it('keeps a newer choice pending when an earlier save finishes', () => { + const metadata = { + [instanceAiSetupCredentialSelectionKey(itemId)]: selection, + [instanceAiSetupCredentialAppliedKey(itemId, 'older-choice')]: true, + }; + expect(readPendingInstanceAiSetupCredentialSelections(metadata, 'workflow-1')).toEqual([ + { itemId, selection }, + ]); + metadata[instanceAiSetupCredentialAppliedKey(itemId, selection.selectionId)] = true; + metadata[instanceAiSetupCredentialAppliedKey(itemId, 'older-choice')] = true; + expect(readPendingInstanceAiSetupCredentialSelections(metadata, 'workflow-1')).toEqual([]); + }); +}); diff --git a/packages/@n8n/api-types/src/schemas/breaking-changes.schema.ts b/packages/@n8n/api-types/src/schemas/breaking-changes.schema.ts index a90b36401bb..0e63cbfdbfb 100644 --- a/packages/@n8n/api-types/src/schemas/breaking-changes.schema.ts +++ b/packages/@n8n/api-types/src/schemas/breaking-changes.schema.ts @@ -1,8 +1,22 @@ import { z } from 'zod'; // Enums -export const breakingChangeRuleSeveritySchema = z.enum(['low', 'medium', 'critical']); -export type BreakingChangeRuleSeverity = z.infer; + +/** + * What happens if the user does not fix a breaking change before the update. + * + * - `upgradeBlocked`: the instance does not start, or the update cannot proceed. + * - `executionsFail`: affected executions error. + * - `behaviorChanges`: executions keep running, but with a different result. + * - `capabilityRemoved`: no runtime impact, a removed capability is no longer available. + */ +export const breakingChangeRuleImpactSchema = z.enum([ + 'executionsFail', + 'behaviorChanges', + 'capabilityRemoved', + 'upgradeBlocked', +]); +export type BreakingChangeRuleImpact = z.infer; export const breakingChangeIssueLevelSchema = z.enum(['info', 'warning', 'error']); @@ -74,7 +88,7 @@ const ruleResultBaseSchema = z.object({ ruleId: z.string(), ruleTitle: z.string(), ruleDescription: z.string(), - ruleSeverity: breakingChangeRuleSeveritySchema, + ruleImpact: breakingChangeRuleImpactSchema, ruleDocumentationUrl: z.string().optional(), recommendations: z.array(recommendationSchema), // True when an automated migration is registered for this rule, so the UI diff --git a/packages/@n8n/api-types/src/schemas/instance-ai-setup-credential-selection.ts b/packages/@n8n/api-types/src/schemas/instance-ai-setup-credential-selection.ts new file mode 100644 index 00000000000..fb5229e0c32 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/instance-ai-setup-credential-selection.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; + +const instanceAiSetupCredentialSelectionSchema = z.object({ + selectionId: z.string().min(1), + credentialType: z.string().min(1), + credentialId: z.string().min(1), + nodeNames: z.array(z.string().min(1)).min(1).optional(), +}); + +export type InstanceAiSetupCredentialSelection = z.infer< + typeof instanceAiSetupCredentialSelectionSchema +>; + +const SELECTION_KEY_PREFIX = 'instanceAiSetupCredentialSelection:'; + +export function instanceAiSetupCredentialSelectionKey(itemId: string): string { + return `${SELECTION_KEY_PREFIX}${itemId}`; +} + +/** A marker for each choice keeps reordered saves independent. */ +export function instanceAiSetupCredentialAppliedKey(itemId: string, selectionId: string): string { + return `instanceAiSetupCredentialApplied:${itemId}:${selectionId}`; +} + +export function readPendingInstanceAiSetupCredentialSelections( + metadata: Record | undefined, + workflowId: string, +): Array<{ itemId: string; selection: InstanceAiSetupCredentialSelection }> { + const pending: Array<{ itemId: string; selection: InstanceAiSetupCredentialSelection }> = []; + const workflowPrefix = `${workflowId}:credential:`; + for (const [key, value] of Object.entries(metadata ?? {})) { + if (!key.startsWith(`${SELECTION_KEY_PREFIX}${workflowPrefix}`)) continue; + const parsed = instanceAiSetupCredentialSelectionSchema.safeParse(value); + if (!parsed.success) continue; + const itemId = key.slice(SELECTION_KEY_PREFIX.length); + const typePrefix = `${workflowPrefix}${parsed.data.credentialType}`; + if (itemId !== typePrefix && !itemId.startsWith(`${typePrefix}:`)) continue; + if (metadata?.[instanceAiSetupCredentialAppliedKey(itemId, parsed.data.selectionId)] === true) { + continue; + } + pending.push({ itemId, selection: parsed.data }); + } + // Apply node-specific choices after choices for the whole service. + return pending.sort( + (a, b) => + Number(a.selection.nodeNames !== undefined) - Number(b.selection.nodeNames !== undefined), + ); +} diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index 7fe9b4f5a19..781d7751e7d 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -1174,6 +1174,7 @@ export const setupItemSchema = z.discriminatedUnion('kind', [ kind: z.literal('credential'), credentialType: z.string(), appDisplayName: z.string().optional(), + preferNew: z.boolean().optional(), nodeBindings: z.array(z.object({ nodeName: z.string() })).optional(), setupHint: credentialSetupHintSchema.optional(), /** Why the app is needed, e.g. "for the docs search". */ @@ -1758,6 +1759,7 @@ export class InstanceAiCorrectTaskRequest extends Z.class({ * - `canvas_action_button` — Instance AI button on the workflow canvas * - `canvas_choice_prompt` — empty-canvas choice prompt that opens Instance AI * - `node_error_view` — "Ask AI" from a node error / failed-execution view + * - `workflow_error_nudge` — nudge on a workflow error toast that opens the Assistant to fix the run * - `credential_edit` — credential setup help from the credential edit modal * - `credentials_list` — credential setup help from the credentials list * - `agent_builder_page` — Instance AI hand-off from the agent builder @@ -1776,6 +1778,7 @@ export const INSTANCE_AI_THREAD_SOURCES = [ 'canvas_action_button', 'canvas_choice_prompt', 'node_error_view', + 'workflow_error_nudge', // Experiment cleanup (119_surface_assistant_on_workflow_error) 'credential_edit', 'credentials_list', 'agent_builder_page', @@ -2877,6 +2880,9 @@ export class InstanceAiEvalExecutionRequest extends Z.class({ * budget can exceed the 15 minutes a plain run takes. */ timeoutMs: z.number().int().min(30_000).max(3_600_000).optional(), + /** Data tables the caller reseeded with this scenario's rows. A Data Table read + * bound to one of them reads the table instead of pinned rows. */ + seededDataTableIds: z.array(z.string().min(1)).max(20).optional(), }) {} // --------------------------------------------------------------------------- diff --git a/packages/@n8n/backend-common/package.json b/packages/@n8n/backend-common/package.json index 8bc779724f6..26e7204d4be 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.41.0", + "version": "1.42.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts index 3a2f251418b..808c4ed6921 100644 --- a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts +++ b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts @@ -88,6 +88,7 @@ describe('eligibleModules', () => { 'workflow-reviews', 'instance-ai', 'agents', + 'inbound-auth-core', ]); }); @@ -126,6 +127,7 @@ describe('eligibleModules', () => { 'workflow-reviews', 'instance-ai', 'agents', + 'inbound-auth-core', 'type-availability-policies', ]); }); @@ -253,6 +255,26 @@ describe('initModules', () => { expect(ModuleClass.init).not.toHaveBeenCalled(); }); + it('should init only the listed modules when given a list', async () => { + const ListedModule = { init: vi.fn() }; + const OtherModule = { init: vi.fn() }; + const moduleMetadata = mock({ + getEntries: vi.fn().mockReturnValue([ + ['insights', { class: ListedModule }], + ['mcp', { class: OtherModule }], + ]), + }); + Container.get = vi.fn().mockImplementation((moduleClass: unknown) => moduleClass); + + const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock(), mock()); + + await moduleRegistry.initModules('main', ['insights']); + + expect(ListedModule.init).toHaveBeenCalled(); + expect(OtherModule.init).not.toHaveBeenCalled(); + expect(moduleRegistry.getActiveModules()).toEqual(['insights']); + }); + it('should accept module without `init` method', async () => { const ModuleClass = {}; const moduleMetadata = mock({ diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index cde0db14c21..85321ad7d7e 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -78,6 +78,7 @@ export class ModuleRegistry { 'workflow-reviews', 'instance-ai', 'agents', + 'inbound-auth-core', ]; private readonly activeModules: string[] = []; @@ -159,9 +160,15 @@ export class ModuleRegistry { * specific setup. * * `ModuleRegistry.loadModules` must have been called before. + * + * @param only Init only these modules, for a one-off command that needs a few of them. */ - async initModules(instanceType: InstanceType) { + async initModules(instanceType: InstanceType, only?: ModuleName[]) { + const selected = only ? new Set(only) : undefined; + for (const [moduleName, moduleEntry] of this.moduleMetadata.getEntries()) { + if (selected && !selected.has(moduleName)) continue; + const { licenseFlag, instanceTypes, class: ModuleClass } = moduleEntry; if (licenseFlag !== undefined && !this.licenseState.isLicensed(licenseFlag)) { diff --git a/packages/@n8n/backend-common/src/modules/modules.config.ts b/packages/@n8n/backend-common/src/modules/modules.config.ts index 9d1fdab883e..4d4cf5ffe4c 100644 --- a/packages/@n8n/backend-common/src/modules/modules.config.ts +++ b/packages/@n8n/backend-common/src/modules/modules.config.ts @@ -40,6 +40,7 @@ export const MODULE_NAMES = [ 'policy-infrastructure', 'type-availability-policies', 'instance-reporting', + 'inbound-auth-core', ] as const; export type ModuleName = (typeof MODULE_NAMES)[number]; diff --git a/packages/@n8n/backend-network/package.json b/packages/@n8n/backend-network/package.json index 3b3ed1161e5..53265e2a043 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.15.0", + "version": "1.16.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/backend-services/README.md b/packages/@n8n/backend-services/README.md index afa92982956..b16b1c2b53d 100644 --- a/packages/@n8n/backend-services/README.md +++ b/packages/@n8n/backend-services/README.md @@ -27,9 +27,34 @@ and infrastructure services that need the persistence layer or that only ## What lives here -The package contains `UrlService`. The next PRs move the response errors, -`CacheService`, `RedisClientService`, `ProtectedResourceRegistry`, `EventService`, -`RoleService`, the finder services and the scope checks here, one area at a time. +- `EventService`: the shared, typed event bus. +- `UrlService`: instance and webhook URLs. +- `CacheService`: application cache access. +- `RedisClientService`: shared Redis connections and support code. + +The next PRs move `ProtectedResourceRegistry`, `RoleService`, the finder services +and the scope checks here, one area at a time. + +### Register event payloads + +`EventMap` is an open interface. Each event owner augments it with its payload +types. This keeps domain dependencies out of the shared event bus. + +```typescript +import type {} from '@n8n/backend-services'; + +declare module '@n8n/backend-services' { + interface EventMap { + 'example-completed': { id: string }; + } +} +``` + +Include the augmentation file in the owner's TypeScript project. If another +package uses these events, include the augmentation in the owner's exported +types. The CLI registers its existing maps in `src/events/event-map.ts`. +Augmentation needs no runtime registration. All consumers import `EventService` +from `@n8n/backend-services` and use the same DI token. ## Compatibility with cli diff --git a/packages/@n8n/backend-services/eslint.config.mjs b/packages/@n8n/backend-services/eslint.config.mjs index 766d97365e7..d964293b6df 100644 --- a/packages/@n8n/backend-services/eslint.config.mjs +++ b/packages/@n8n/backend-services/eslint.config.mjs @@ -34,8 +34,6 @@ export default defineConfig( { files: ['./test/**/*.ts', './src/**/__tests__/**/*.ts'], rules: { - 'n8n-local-rules/misplaced-n8n-typeorm-import': 'off', - 'n8n-local-rules/no-type-unsafe-event-emitter': 'off', // `vi.importActual('x')` needs inline import types. '@typescript-eslint/consistent-type-imports': ['error', { disallowTypeAnnotations: false }], }, diff --git a/packages/@n8n/backend-services/oxlint.config.mts b/packages/@n8n/backend-services/oxlint.config.mts index e3175003eba..5a11f5a4451 100644 --- a/packages/@n8n/backend-services/oxlint.config.mts +++ b/packages/@n8n/backend-services/oxlint.config.mts @@ -30,15 +30,4 @@ export default defineConfig({ ], 'n8n-local-rules/no-type-unsafe-event-emitter': 'error', }, - overrides: [ - { - files: ['./test/**/*.ts', './src/**/__tests__/**/*.ts'], - // An override that names a jsPlugin rule must re-declare the plugin. - jsPlugins: ['@n8n/eslint-config/plugin'], - rules: { - 'n8n-local-rules/misplaced-n8n-typeorm-import': 'off', - 'n8n-local-rules/no-type-unsafe-event-emitter': 'off', - }, - }, - ], }); diff --git a/packages/@n8n/backend-services/package.json b/packages/@n8n/backend-services/package.json index 439c8ab5f53..f481d943421 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.1.0", + "version": "1.2.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", @@ -23,15 +23,27 @@ "dist/**/*" ], "dependencies": { + "@n8n/backend-common": "workspace:*", "@n8n/config": "workspace:*", - "@n8n/di": "workspace:*" + "@n8n/constants": "workspace:*", + "@n8n/decorators": "workspace:*", + "@n8n/di": "workspace:*", + "@n8n/errors": "workspace:*", + "@n8n/utils": "workspace:*", + "cache-manager": "catalog:", + "ioredis": "5.3.2", + "n8n-workflow": "workspace:*" }, "devDependencies": { "@n8n/eslint-config": "workspace:*", "@n8n/oxlint-config": "workspace:*", "@n8n/typescript-config": "workspace:*", "@n8n/vitest-config": "workspace:*", + "@types/lodash": "catalog:", "@vitest/coverage-v8": "catalog:", + "eslint": "catalog:", + "ioredis-mock": "^8.8.1", + "lodash": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", "typescript": "catalog:typescript", diff --git a/packages/cli/src/errors/cache-errors/uncacheable-value.error.ts b/packages/@n8n/backend-services/src/errors/cache-errors/uncacheable-value.error.ts similarity index 81% rename from packages/cli/src/errors/cache-errors/uncacheable-value.error.ts rename to packages/@n8n/backend-services/src/errors/cache-errors/uncacheable-value.error.ts index 83f859e99de..c7bf537daac 100644 --- a/packages/cli/src/errors/cache-errors/uncacheable-value.error.ts +++ b/packages/@n8n/backend-services/src/errors/cache-errors/uncacheable-value.error.ts @@ -1,4 +1,4 @@ -import { UnexpectedError } from 'n8n-workflow'; +import { UnexpectedError } from '@n8n/errors'; export class UncacheableValueError extends UnexpectedError { constructor(key: string) { diff --git a/packages/@n8n/backend-services/src/events/__tests__/event.service.test.ts b/packages/@n8n/backend-services/src/events/__tests__/event.service.test.ts new file mode 100644 index 00000000000..735d4c9d782 --- /dev/null +++ b/packages/@n8n/backend-services/src/events/__tests__/event.service.test.ts @@ -0,0 +1,54 @@ +import { Container, Service } from '@n8n/di'; +import { expectTypeOf } from 'vitest'; + +import { EventService, type EventMap } from '../../index'; + +declare module '../../index' { + interface EventMap { + 'test-event': { id: string }; + } +} + +@Service() +class EventConsumer { + constructor(readonly events: EventService) {} +} + +describe('EventService', () => { + afterEach(() => { + Container.reset(); + }); + + it('shares the event bus with injected consumers', () => { + const events = Container.get(EventService); + const consumer = Container.get(EventConsumer); + const listener = vi.fn(); + consumer.events.on('test-event', listener); + + events.emit('test-event', { id: 'first' }); + consumer.events.off('test-event', listener); + events.emit('test-event', { id: 'second' }); + + expect(consumer.events).toBe(events); + expect(listener).toHaveBeenCalledExactlyOnceWith({ id: 'first' }); + }); + + it('uses augmented event names and payload types', () => { + const events = new EventService(); + expectTypeOf().toEqualTypeOf<{ id: string }>(); + expectTypeOf(events.emit<'test-event'>).parameters.toEqualTypeOf< + [eventName: 'test-event', payload?: { id: string }] + >(); + expectTypeOf(events.on<'test-event'>) + .parameter(1) + .toEqualTypeOf<(payload: { id: string }) => void>(); + expectTypeOf(events.once<'test-event'>) + .parameter(1) + .toEqualTypeOf<(payload: { id: string }) => void>(); + expectTypeOf(events.off<'test-event'>) + .parameter(1) + .toEqualTypeOf<(payload: { id: string }) => void>(); + // @ts-expect-error Only registered event names are valid. + expectTypeOf(events.emit<'unregistered-event'>); + }); +}); diff --git a/packages/@n8n/backend-services/src/events/event.service.ts b/packages/@n8n/backend-services/src/events/event.service.ts new file mode 100644 index 00000000000..d5156fa47f8 --- /dev/null +++ b/packages/@n8n/backend-services/src/events/event.service.ts @@ -0,0 +1,8 @@ +import { TypedEmitter } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; + +/** Each event owner adds its payload types through module augmentation. */ +export interface EventMap {} + +@Service() +export class EventService extends TypedEmitter {} diff --git a/packages/@n8n/backend-services/src/index.ts b/packages/@n8n/backend-services/src/index.ts index 55802fa1992..b7e636552f0 100644 --- a/packages/@n8n/backend-services/src/index.ts +++ b/packages/@n8n/backend-services/src/index.ts @@ -1 +1,6 @@ +export { EventService, type EventMap } from './events/event.service'; +export { UncacheableValueError } from './errors/cache-errors/uncacheable-value.error'; +export { CacheService } from './services/cache/cache.service'; +export { RedisClientService } from './services/redis-client.service'; +export type { RedisClientType } from './services/redis.types'; export { UrlService } from './services/url.service'; diff --git a/packages/cli/src/services/__tests__/redis-client.service.test.ts b/packages/@n8n/backend-services/src/services/__tests__/redis-client.service.test.ts similarity index 97% rename from packages/cli/src/services/__tests__/redis-client.service.test.ts rename to packages/@n8n/backend-services/src/services/__tests__/redis-client.service.test.ts index 7f70189bd4a..1e7ef7ae5cf 100644 --- a/packages/cli/src/services/__tests__/redis-client.service.test.ts +++ b/packages/@n8n/backend-services/src/services/__tests__/redis-client.service.test.ts @@ -1,10 +1,10 @@ -import { Logger } from '@n8n/backend-common'; -import { mockInstance } from '@n8n/backend-test-utils'; -import { GlobalConfig } from '@n8n/config'; +import type { Logger } from '@n8n/backend-common'; +import type { GlobalConfig } from '@n8n/config'; import Redis from 'ioredis'; import type { Mock } from 'vitest'; +import { mock } from 'vitest-mock-extended'; -import { RedisClientService } from '@/services/redis-client.service'; +import { RedisClientService } from '../redis-client.service'; type EventHandler = (...args: unknown[]) => void; @@ -27,10 +27,10 @@ vi.mock('ioredis', () => { }); describe('RedisClientService', () => { - const logger = mockInstance(Logger); + const logger = mock(); logger.scoped.mockReturnValue(logger); - const globalConfig = mockInstance(GlobalConfig, { + const globalConfig = mock({ queue: { bull: { redis: { diff --git a/packages/cli/src/services/cache/__tests__/cache-mock.service.test.ts b/packages/@n8n/backend-services/src/services/cache/__tests__/cache-mock.service.test.ts similarity index 97% rename from packages/cli/src/services/cache/__tests__/cache-mock.service.test.ts rename to packages/@n8n/backend-services/src/services/cache/__tests__/cache-mock.service.test.ts index be492fa6d5a..9b124495003 100644 --- a/packages/cli/src/services/cache/__tests__/cache-mock.service.test.ts +++ b/packages/@n8n/backend-services/src/services/cache/__tests__/cache-mock.service.test.ts @@ -1,7 +1,7 @@ import { Container } from '@n8n/di'; import { mock } from 'vitest-mock-extended'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '../cache.service'; const cacheService = Container.get(CacheService); const store = mock['store']>({ isCacheable: () => true }); diff --git a/packages/cli/src/services/cache/__tests__/cache.service.test.ts b/packages/@n8n/backend-services/src/services/cache/__tests__/cache.service.test.ts similarity index 98% rename from packages/cli/src/services/cache/__tests__/cache.service.test.ts rename to packages/@n8n/backend-services/src/services/cache/__tests__/cache.service.test.ts index f91bac793fa..e44c84f8c7c 100644 --- a/packages/cli/src/services/cache/__tests__/cache.service.test.ts +++ b/packages/@n8n/backend-services/src/services/cache/__tests__/cache.service.test.ts @@ -3,11 +3,12 @@ import { Container } from '@n8n/di'; import { sleep } from '@n8n/utils/sleep'; import random from 'lodash/random'; -import config from '@/config'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '../cache.service'; -vi.mock('ioredis', () => { - const Redis = require('ioredis-mock'); +vi.mock('ioredis', async () => { + const { default: Redis } = await vi.importActual<{ + default: new (...args: unknown[]) => unknown; + }>('ioredis-mock'); return { // Must be a function expression (not method shorthand) so it is @@ -33,7 +34,6 @@ for (const backend of ['memory', 'redis'] as const) { afterEach(async () => { await cacheService.reset(); - config.load(config.default); }); describe('init', () => { diff --git a/packages/cli/src/services/cache/cache.constants.ts b/packages/@n8n/backend-services/src/services/cache/cache.constants.ts similarity index 100% rename from packages/cli/src/services/cache/cache.constants.ts rename to packages/@n8n/backend-services/src/services/cache/cache.constants.ts diff --git a/packages/cli/src/services/cache/cache.service.ts b/packages/@n8n/backend-services/src/services/cache/cache.service.ts similarity index 94% rename from packages/cli/src/services/cache/cache.service.ts rename to packages/@n8n/backend-services/src/services/cache/cache.service.ts index 53ce6fd1c9a..d95b5680302 100644 --- a/packages/cli/src/services/cache/cache.service.ts +++ b/packages/@n8n/backend-services/src/services/cache/cache.service.ts @@ -2,18 +2,14 @@ import { TypedEmitter } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { Container, Service } from '@n8n/di'; +import { isRecord } from '@n8n/utils/is-record'; import { caching } from 'cache-manager'; import { jsonStringify, UserError } from 'n8n-workflow'; -import { UncacheableValueError } from '@/errors/cache-errors/uncacheable-value.error'; -import { REDIS_TTL_KEY_MISSING } from '@/services/cache/cache.constants'; -import type { - TaggedRedisCache, - TaggedMemoryCache, - MaybeHash, - Hash, -} from '@/services/cache/cache.types'; -import { isObject } from '@/utils'; +import { UncacheableValueError } from '../../errors/cache-errors/uncacheable-value.error'; + +import { REDIS_TTL_KEY_MISSING } from './cache.constants'; +import type { TaggedRedisCache, TaggedMemoryCache, MaybeHash, Hash } from './cache.types'; type CacheEvents = { 'metrics.cache.hit': never; @@ -29,7 +25,8 @@ export class CacheService extends TypedEmitter { super(); } - private cache: TaggedRedisCache | TaggedMemoryCache; + // Set by `init()`, which every public method awaits before use. + private cache!: TaggedRedisCache | TaggedMemoryCache; async init() { const { backend } = this.globalConfig.cache; @@ -54,7 +51,7 @@ export class CacheService extends TypedEmitter { extraOptions: { keyPrefix: prefix }, }); - const { redisStoreUsingClient } = await import('@/services/cache/redis.cache-manager.js'); + const { redisStoreUsingClient } = await import('./redis.cache-manager.js'); const redisStore = redisStoreUsingClient(redisClient, { ttl: this.globalConfig.cache.redis.ttl, }); @@ -379,7 +376,7 @@ export class CacheService extends TypedEmitter { } const isEmptyArray = Array.isArray(value) && value.length === 0; - const isEmptyObject = isObject(value) && Object.keys(value).length === 0; + const isEmptyObject = isRecord(value) && Object.keys(value).length === 0; if (isEmptyArray || isEmptyObject) { // Redis adapters may return [] or {} for missing string or hash keys after restart. const keyExists = await this.doesRedisKeyExist(key); diff --git a/packages/cli/src/services/cache/cache.types.ts b/packages/@n8n/backend-services/src/services/cache/cache.types.ts similarity index 79% rename from packages/cli/src/services/cache/cache.types.ts rename to packages/@n8n/backend-services/src/services/cache/cache.types.ts index 8a798d355d0..3ba73641334 100644 --- a/packages/cli/src/services/cache/cache.types.ts +++ b/packages/@n8n/backend-services/src/services/cache/cache.types.ts @@ -1,6 +1,6 @@ import type { MemoryCache } from 'cache-manager'; -import type { RedisCache } from '@/services/cache/redis.cache-manager'; +import type { RedisCache } from './redis.cache-manager'; export type TaggedRedisCache = RedisCache & { kind: 'redis' }; diff --git a/packages/cli/src/services/cache/redis.cache-manager.ts b/packages/@n8n/backend-services/src/services/cache/redis.cache-manager.ts similarity index 100% rename from packages/cli/src/services/cache/redis.cache-manager.ts rename to packages/@n8n/backend-services/src/services/cache/redis.cache-manager.ts diff --git a/packages/cli/src/services/redis-client.service.ts b/packages/@n8n/backend-services/src/services/redis-client.service.ts similarity index 99% rename from packages/cli/src/services/redis-client.service.ts rename to packages/@n8n/backend-services/src/services/redis-client.service.ts index 50588135075..166a8d06bd5 100644 --- a/packages/cli/src/services/redis-client.service.ts +++ b/packages/@n8n/backend-services/src/services/redis-client.service.ts @@ -5,7 +5,7 @@ import { Service } from '@n8n/di'; import ioRedis from 'ioredis'; import type { Cluster, ClusterOptions, RedisOptions } from 'ioredis'; -import type { RedisClientType } from '../scaling/redis/redis.types'; +import type { RedisClientType } from './redis.types'; type RedisEventMap = { 'connection-lost': number; diff --git a/packages/cli/src/scaling/redis/redis.types.ts b/packages/@n8n/backend-services/src/services/redis.types.ts similarity index 100% rename from packages/cli/src/scaling/redis/redis.types.ts rename to packages/@n8n/backend-services/src/services/redis.types.ts diff --git a/packages/@n8n/backend-services/test/setup-test-folder.ts b/packages/@n8n/backend-services/test/setup-test-folder.ts deleted file mode 100644 index 6ec4522433a..00000000000 --- a/packages/@n8n/backend-services/test/setup-test-folder.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; - -// Same instance setup as packages/cli/test/setup-test-folder.ts, so a moved test finds it. -process.env.N8N_ENCRYPTION_KEY = 'test_key'; - -const baseDir = join(tmpdir(), 'n8n-tests/'); -mkdirSync(baseDir, { recursive: true }); - -const testDir = mkdtempSync(baseDir); -mkdirSync(join(testDir, '.n8n')); -process.env.N8N_USER_FOLDER = testDir; -process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'true'; - -writeFileSync( - join(testDir, '.n8n/config'), - JSON.stringify({ encryptionKey: 'test_key', instanceId: '123' }), - { - encoding: 'utf-8', - mode: 0o600, - }, -); diff --git a/packages/@n8n/backend-services/vitest.config.ts b/packages/@n8n/backend-services/vitest.config.ts index de9e7e14a17..aa666866549 100644 --- a/packages/@n8n/backend-services/vitest.config.ts +++ b/packages/@n8n/backend-services/vitest.config.ts @@ -5,7 +5,6 @@ export default mergeConfig(createVitestConfigWithDecorators(), { test: { // One fork per file: tests register mocks in the shared `@n8n/di` Container. pool: 'forks', - setupFiles: ['./test/setup-test-folder.ts'], // Vitest's default exclude does not cover dist; stale compiled tests there fail as CJS. exclude: [...configDefaults.exclude, '**/dist/**'], }, diff --git a/packages/@n8n/backend-test-utils/oxlint.config.mts b/packages/@n8n/backend-test-utils/oxlint.config.mts new file mode 100644 index 00000000000..b50c92512db --- /dev/null +++ b/packages/@n8n/backend-test-utils/oxlint.config.mts @@ -0,0 +1,7 @@ +import { backendConfig } from '@n8n/oxlint-config/backend'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [backendConfig], + options: { typeAware: true }, +}); diff --git a/packages/@n8n/backend-test-utils/package.json b/packages/@n8n/backend-test-utils/package.json index 5dba2e3d485..2d5eac6d51d 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.41.0", + "version": "1.42.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", @@ -9,8 +9,8 @@ "build:unchecked": "tsc -p tsconfig.build.json --noCheck", "format": "biome format --write .", "format:check": "biome ci .", - "lint": "eslint . --quiet", - "lint:fix": "eslint . --fix", + "lint": "oxlint --quiet", + "lint:fix": "oxlint --fix", "watch": "tsc -p tsconfig.build.json --watch", "test": "node -e \"process.exit(0)\"", "test:dev": "echo \"WARNING: no test specified\" && exit 0" @@ -36,7 +36,10 @@ }, "devDependencies": { "@n8n/eslint-config": "workspace:*", + "@n8n/oxlint-config": "workspace:*", "@n8n/typescript-config": "workspace:*", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", "typescript": "catalog:typescript" }, "license": "LicenseRef-n8n-sustainable-use" diff --git a/packages/@n8n/benchmark/Dockerfile b/packages/@n8n/benchmark/Dockerfile index 0f438370023..1439f67b813 100644 --- a/packages/@n8n/benchmark/Dockerfile +++ b/packages/@n8n/benchmark/Dockerfile @@ -46,9 +46,7 @@ ENV DOCKER_BUILD=true RUN pnpm install --frozen-lockfile # TS config files -COPY --chown=node:node ./packages/@n8n/typescript-config/tsconfig.common.json /app/packages/@n8n/typescript-config/tsconfig.common.json -COPY --chown=node:node ./packages/@n8n/typescript-config/tsconfig.build.json /app/packages/@n8n/typescript-config/tsconfig.build.json -COPY --chown=node:node ./packages/@n8n/typescript-config/tsconfig.backend.json /app/packages/@n8n/typescript-config/tsconfig.backend.json +COPY --chown=node:node ./packages/@n8n/typescript-config /app/packages/@n8n/typescript-config COPY --chown=node:node ./packages/@n8n/benchmark/tsconfig.json /app/packages/@n8n/benchmark/tsconfig.json COPY --chown=node:node ./packages/@n8n/benchmark/tsconfig.build.json /app/packages/@n8n/benchmark/tsconfig.build.json diff --git a/packages/@n8n/benchmark/oxlint.config.mts b/packages/@n8n/benchmark/oxlint.config.mts new file mode 100644 index 00000000000..642d077122b --- /dev/null +++ b/packages/@n8n/benchmark/oxlint.config.mts @@ -0,0 +1,11 @@ +import { backendConfig } from '@n8n/oxlint-config/backend'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [backendConfig], + options: { typeAware: true }, + ignorePatterns: ['scenarios/**', 'scripts/**'], + rules: { + complexity: 'error', + }, +}); diff --git a/packages/@n8n/benchmark/package.json b/packages/@n8n/benchmark/package.json index 2b343fdd5c1..d3b71a150ca 100644 --- a/packages/@n8n/benchmark/package.json +++ b/packages/@n8n/benchmark/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-benchmark", - "version": "2.22.0", + "version": "2.23.0", "description": "Cli for running benchmark tests for n8n", "main": "dist/index", "scripts": { @@ -9,8 +9,8 @@ "build:unchecked": "tsc -p tsconfig.build.json --noCheck && tsc-alias -p tsconfig.build.json", "format": "biome format --write .", "format:check": "biome ci .", - "lint": "eslint . --quiet", - "lint:fix": "eslint . --fix", + "lint": "oxlint --quiet", + "lint:fix": "oxlint --fix", "start": "./bin/n8n-benchmark", "test": "node -e \"process.exit(0)\"", "typecheck": "tsc --noEmit", @@ -41,8 +41,11 @@ "zx": "^8.8.5" }, "devDependencies": { + "@n8n/oxlint-config": "workspace:*", "@n8n/typescript-config": "workspace:*", "@types/k6": "^0.52.0", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", "typescript": "catalog:typescript" }, "bin": { diff --git a/packages/@n8n/blob-storage/package.json b/packages/@n8n/blob-storage/package.json index 3a0e723bf5d..a9ede9620b7 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.9.0", + "version": "1.10.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 8cf7a45273f..cd18446f84f 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.34.0", + "version": "1.35.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/cli/oxlint.config.mts b/packages/@n8n/cli/oxlint.config.mts new file mode 100644 index 00000000000..b50c92512db --- /dev/null +++ b/packages/@n8n/cli/oxlint.config.mts @@ -0,0 +1,7 @@ +import { backendConfig } from '@n8n/oxlint-config/backend'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [backendConfig], + options: { typeAware: true }, +}); diff --git a/packages/@n8n/cli/package.json b/packages/@n8n/cli/package.json index 96b63f338d5..48d644cfee5 100644 --- a/packages/@n8n/cli/package.json +++ b/packages/@n8n/cli/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/cli", - "version": "0.20.0", + "version": "0.21.0", "description": "Client CLI for n8n — manage workflows, executions, and credentials from the terminal", "license": "LicenseRef-n8n-sustainable-use", "bin": { @@ -21,8 +21,8 @@ "build:unchecked": "tsc -p tsconfig.build.json --noCheck", "format": "biome format --write .", "format:check": "biome ci .", - "lint": "eslint src --quiet", - "lint:fix": "eslint src --fix", + "lint": "oxlint src --quiet", + "lint:fix": "oxlint src --fix", "watch": "tsc -p tsconfig.build.json --watch", "test": "vitest run", "test:unit": "vitest run" @@ -84,9 +84,12 @@ "@oclif/core": "^4.5.2" }, "devDependencies": { + "@n8n/oxlint-config": "workspace:*", "@n8n/typescript-config": "workspace:*", "@n8n/vitest-config": "workspace:*", "@types/node": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", "typescript": "catalog:typescript", "vitest": "catalog:" } diff --git a/packages/@n8n/client-oauth2/package.json b/packages/@n8n/client-oauth2/package.json index 77125e2f19f..0bb93ed9bfc 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.23.0", + "version": "1.24.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/computer-use/package.json b/packages/@n8n/computer-use/package.json index b0aa3211bcc..e013ba1b1e2 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.24.0", + "version": "0.25.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 bf9b0bce237..b916a41f832 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "2.39.0", + "version": "2.40.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/constants/package.json b/packages/@n8n/constants/package.json index f3435457d5f..d0c1f81e7c1 100644 --- a/packages/@n8n/constants/package.json +++ b/packages/@n8n/constants/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/constants", - "version": "0.39.0", + "version": "0.40.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/crdt/package.json b/packages/@n8n/crdt/package.json index 5438c1ad9ff..229343e4978 100644 --- a/packages/@n8n/crdt/package.json +++ b/packages/@n8n/crdt/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/crdt", - "version": "0.11.0", + "version": "0.12.0", "description": "CRDT abstraction layer for n8n collaborative editing", "scripts": { "clean": "rimraf dist .turbo", diff --git a/packages/@n8n/create-node/package.json b/packages/@n8n/create-node/package.json index bc75fcb1e25..43c3d5e39e4 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.49.0", + "version": "0.50.0", "description": "Official CLI to create new community nodes for n8n", "bin": { "create-node": "bin/create-node.cjs" diff --git a/packages/@n8n/db/eslint.config.mjs b/packages/@n8n/db/eslint.config.mjs index 156e71f3b73..0a557551e50 100644 --- a/packages/@n8n/db/eslint.config.mjs +++ b/packages/@n8n/db/eslint.config.mjs @@ -21,12 +21,16 @@ export default defineConfig( 'error', { acknowledged: acknowledgedProjectOwnedEntities }, ], - - '@typescript-eslint/no-base-to-string': 'warn', - '@typescript-eslint/no-restricted-types': 'warn', - 'no-useless-escape': 'warn', }, }, + { + files: ['src/migrations/sqlite/1681134145996-AddUserActivatedProperty.ts'], + rules: { '@typescript-eslint/no-base-to-string': 'warn' }, + }, + { + files: ['src/migrations/sqlite/1646992772331-CreateUserManagement.ts'], + rules: { 'no-useless-escape': 'warn' }, + }, { files: ['**/*.test.ts', '**/__tests__/**/*.ts'], rules: { diff --git a/packages/@n8n/db/oxlint.config.mts b/packages/@n8n/db/oxlint.config.mts new file mode 100644 index 00000000000..e82f4f9b7d1 --- /dev/null +++ b/packages/@n8n/db/oxlint.config.mts @@ -0,0 +1,40 @@ +import { backendConfig } from '@n8n/oxlint-config/backend'; +import { defineConfig } from 'oxlint'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const ownershipTransferManifest = require('../../cli/src/services/ownership-transfer/ownership-transfer.manifest.json'); +const acknowledgedProjectOwnedEntities = [ + ...ownershipTransferManifest.transferred, + ...ownershipTransferManifest.notTransferred, +].map(({ name, path }) => ({ name, path })); + +export default defineConfig({ + extends: [backendConfig], + options: { typeAware: true }, + ignorePatterns: ['scripts/**'], + rules: { + 'n8n-local-rules/project-owned-entity-transfer': [ + 'error', + { acknowledged: acknowledgedProjectOwnedEntities }, + ], + }, + overrides: [ + { + files: ['src/migrations/sqlite/1681134145996-AddUserActivatedProperty.ts'], + rules: { 'typescript/no-base-to-string': 'warn' }, + }, + { + files: ['src/migrations/sqlite/1646992772331-CreateUserManagement.ts'], + rules: { 'no-useless-escape': 'warn' }, + }, + { + files: ['**/*.test.ts', '**/__tests__/**/*.ts'], + rules: { 'typescript/no-unsafe-return': 'warn' }, + }, + { + files: ['./src/migrations/**/*.ts'], + rules: { 'unicorn/filename-case': 'off' }, + }, + ], +}); diff --git a/packages/@n8n/db/package.json b/packages/@n8n/db/package.json index 79fda1ce664..32b8b1719fa 100644 --- a/packages/@n8n/db/package.json +++ b/packages/@n8n/db/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/db", - "version": "1.41.0", + "version": "1.42.0", "scripts": { "clean": "rimraf dist .turbo .tmp-schema-docs", "gen:migration-index": "node scripts/generate-migration-index.mjs", @@ -10,8 +10,8 @@ "build:unchecked": "pnpm gen:migration-index && tsc -p tsconfig.build.json --noCheck", "format": "biome format --write .", "format:check": "biome ci .", - "lint": "pnpm gen:migration-index && eslint . --quiet", - "lint:fix": "pnpm gen:migration-index && eslint . --fix", + "lint": "pnpm gen:migration-index && oxlint --quiet", + "lint:fix": "pnpm gen:migration-index && oxlint --fix", "watch": "pnpm gen:migration-index && tsc -p tsconfig.build.json --watch", "test": "pnpm gen:migration-index && vitest run", "test:unit": "pnpm gen:migration-index && vitest run", @@ -53,6 +53,7 @@ }, "devDependencies": { "@n8n/eslint-config": "workspace:*", + "@n8n/oxlint-config": "workspace:*", "@n8n/typescript-config": "workspace:*", "@n8n/vitest-config": "workspace:*", "@types/express": "catalog:", @@ -61,6 +62,8 @@ "@vitest/coverage-v8": "catalog:", "express": "catalog:", "n8n-containers": "workspace:*", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", "testcontainers": "catalog:", "@typescript/native": "catalog:typescript-tooling", "typescript": "catalog:typescript-tooling", diff --git a/packages/@n8n/db/src/entities/types-db.ts b/packages/@n8n/db/src/entities/types-db.ts index db10a6ba346..5df11876669 100644 --- a/packages/@n8n/db/src/entities/types-db.ts +++ b/packages/@n8n/db/src/entities/types-db.ts @@ -159,7 +159,7 @@ export interface CredentialUsedByWorkflow { id: string; name: string; type?: string; - currentUserHasAccess: boolean; + currentUserCanUse: boolean; homeProject: SlimProject | null; sharedWithProjects: SlimProject[]; } diff --git a/packages/@n8n/db/src/migrations/common/1790672501346-CreateTrustedSourceTables.ts b/packages/@n8n/db/src/migrations/common/1790672501346-CreateTrustedSourceTables.ts new file mode 100644 index 00000000000..bd53e0aa838 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1790672501346-CreateTrustedSourceTables.ts @@ -0,0 +1,55 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const sourceTable = 'trusted_source'; +const identityTable = 'trusted_source_identity'; +const userTable = 'user'; +const identitySourceFk = 'trusted_source_identity_sourceId_foreign'; +const identityUserFk = 'trusted_source_identity_userId_foreign'; + +export class CreateTrustedSourceTables1790672501346 implements ReversibleMigration { + async up({ schemaBuilder: { createTable, column } }: MigrationContext) { + await createTable(sourceTable) + .withColumns( + column('id').varchar(36).primary, + column('name').varchar(128).notNull, + column('type').varchar(32).notNull, + column('issuer').varchar().notNull, + column('managedBy').varchar(16).notNull, + column('status').varchar(16).notNull, + column('lastError').text, + column('lastCheckedAt').timestampTimezone(3), + column('configVersion').int.notNull, + column('config').text.notNull, + ) + .withTimestamps.withIndexOn('name', true) + .withIndexOn('issuer', true); + + await createTable(identityTable) + .withColumns( + column('sourceId').varchar(36).primary, + column('subject').varchar().primary, + column('userId').uuid.notNull, + column('provenance').varchar(32).notNull, + column('status').varchar(16).notNull, + column('lastSeenAt').timestampTimezone(3), + ) + .withTimestamps.withIndexOn('userId') + .withForeignKey('sourceId', { + tableName: sourceTable, + columnName: 'id', + onDelete: 'CASCADE', + name: identitySourceFk, + }) + .withForeignKey('userId', { + tableName: userTable, + columnName: 'id', + onDelete: 'CASCADE', + name: identityUserFk, + }); + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable(identityTable); + await dropTable(sourceTable); + } +} diff --git a/packages/@n8n/db/src/migrations/common/1790683443934-AddAgentMessageSteering.ts b/packages/@n8n/db/src/migrations/common/1790683443934-AddAgentMessageSteering.ts new file mode 100644 index 00000000000..f190e79df33 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1790683443934-AddAgentMessageSteering.ts @@ -0,0 +1,57 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +export class AddAgentMessageSteering1790683443934 implements ReversibleMigration { + async up({ schemaBuilder, escape, runQuery, isPostgres }: MigrationContext) { + const { addColumns, addForeignKey, createIndex, column } = schemaBuilder; + const executionTable = escape.tableName('agent_execution'); + const acceptsSteering = escape.columnName('acceptsSteering'); + // Native ALTER TABLE avoids copying execution history on SQLite. + await runQuery( + `ALTER TABLE ${executionTable} ADD COLUMN ${acceptsSteering} BOOLEAN NOT NULL DEFAULT FALSE`, + ); + if (isPostgres) { + await runQuery( + `COMMENT ON COLUMN ${executionTable}.${acceptsSteering} IS 'Accept input until the runtime closes admission'`, + ); + } + await addColumns( + 'agent_message_queue', + [ + column('steeringExecutionId') + .varchar(36) + .comment('Execution reserved to consume this input'), + column('steeringOrder').int.comment('Acceptance order among outstanding steers'), + ], + { recreatesOnSqlite: true }, + ); + await addForeignKey( + 'agent_message_queue', + 'steeringExecutionId', + ['agent_execution', 'id'], + undefined, + 'NO ACTION', + ); + await createIndex( + 'agent_message_queue', + ['steeringExecutionId', 'steeringOrder'], + true, + undefined, + `${escape.columnName('steeringExecutionId')} IS NOT NULL`, + ); + } + + async down({ + schemaBuilder: { dropIndex, dropForeignKey, dropColumns }, + escape, + runQuery, + }: MigrationContext) { + await dropIndex('agent_message_queue', ['steeringExecutionId', 'steeringOrder']); + await dropForeignKey('agent_message_queue', 'steeringExecutionId', ['agent_execution', 'id']); + await dropColumns('agent_message_queue', ['steeringExecutionId', 'steeringOrder'], { + recreatesOnSqlite: true, + }); + await runQuery( + `ALTER TABLE ${escape.tableName('agent_execution')} DROP COLUMN ${escape.columnName('acceptsSteering')}`, + ); + } +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/1790605116208-AddAgentQueueMessageReferences.ts b/packages/@n8n/db/src/migrations/postgresdb/1790605116208-AddAgentQueueMessageReferences.ts new file mode 100644 index 00000000000..2858d0b43ce --- /dev/null +++ b/packages/@n8n/db/src/migrations/postgresdb/1790605116208-AddAgentQueueMessageReferences.ts @@ -0,0 +1,239 @@ +import { UserError } from 'n8n-workflow'; +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; + +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const attachmentSchema = z.object({ + id: z.string().min(1), + fileName: z.string(), + mimeType: z.string(), + sizeBytes: z.number(), +}); +const inputSchema = z.object({ + message: z.string(), + resourceId: z.string().min(1), + attachments: z.array(attachmentSchema).optional(), +}); +const payloadSchema = z.discriminatedUnion('kind', [ + inputSchema.extend({ kind: z.literal('preview') }).passthrough(), + inputSchema + .extend({ + kind: z.literal('integration'), + modelMessage: z.string(), + author: z.record(z.unknown()), + platformThreadId: z.string().min(1), + messageContext: z + .object({ + platform: z.string(), + integrationConnectionId: z.string().min(1), + messageId: z.string().optional(), + }) + .passthrough(), + }) + .passthrough(), +]); +type QueuePayload = z.infer; + +interface QueueRow { + id: string; + threadId: string; + source: string; + payload: unknown; + executionId: string | null; + createdAt: Date | string; + updatedAt: Date | string; + inputMessageId: string | null; + inputResourceId: string | null; +} + +export class AddAgentQueueMessageReferences1790605116208 implements ReversibleMigration { + async up(ctx: MigrationContext) { + const { + schemaBuilder: { addColumns, column, addNotNull, dropColumns, addForeignKey, createIndex }, + escape, + runQuery, + } = ctx; + await addColumns( + 'agent_message_queue', + [ + column('messageId') + .varchar(36) + .comment('Canonical input created when the queue accepts it'), + ], + { recreatesOnSqlite: true }, + ); + await this.backfillQueue(ctx); + await dropColumns('agent_message_queue', ['source'], { recreatesOnSqlite: true }); + await addNotNull('agent_message_queue', 'messageId', { recreatesOnSqlite: true }); + await addForeignKey( + 'agent_message_queue', + 'messageId', + ['agents_messages', 'id'], + 'FK_agent_message_queue_messageId', + 'CASCADE', + ); + await runQuery(`COMMENT ON COLUMN ${escape.tableName('agent_message_queue')}.${escape.columnName('payload')} + IS 'Dispatch, authorization, and reply context. Input is stored on the message'`); + await createIndex('agent_message_queue', ['messageId'], true); + } + + protected async backfillQueue(ctx: MigrationContext) { + const { escape, runInBatches } = ctx; + await runInBatches( + `SELECT CAST(queue.${escape.columnName('id')} AS TEXT) AS ${escape.columnName('id')}, + queue.${escape.columnName('threadId')}, queue.${escape.columnName('source')}, + queue.${escape.columnName('payload')}, queue.${escape.columnName('executionId')}, + queue.${escape.columnName('createdAt')}, queue.${escape.columnName('updatedAt')}, + message.${escape.columnName('id')} AS ${escape.columnName('inputMessageId')}, + message.${escape.columnName('resourceId')} AS ${escape.columnName('inputResourceId')} + FROM ${escape.tableName('agent_message_queue')} queue + LEFT JOIN ${escape.tableName('agent_execution_message_links')} link + ON link.${escape.columnName('executionId')} = queue.${escape.columnName('executionId')} + AND link.${escape.columnName('direction')} = 'input' AND link.${escape.columnName('position')} = 0 + LEFT JOIN ${escape.tableName('agents_messages')} message + ON message.${escape.columnName('id')} = link.${escape.columnName('messageId')} + AND message.${escape.columnName('threadId')} = queue.${escape.columnName('threadId')} + ORDER BY queue.${escape.columnName('id')}`, + async (rows) => { + for (const row of rows) await this.backfillItem(ctx, row); + }, + ); + } + + private async backfillItem(ctx: MigrationContext, row: QueueRow) { + const { escape, runQuery } = ctx; + let payload: QueuePayload; + try { + payload = payloadSchema.parse(ctx.parseJson(row.payload)); + } catch { + // An incomplete conversion must roll back instead of losing an accepted message. + throw new UserError(`Cannot migrate queued agent message ${row.id}: invalid payload`); + } + let messageId = row.inputMessageId; + if (row.executionId !== null) { + if (!messageId || row.inputResourceId !== payload.resourceId) { + throw new UserError( + `Cannot migrate queued agent message ${row.id}: input link is missing or invalid`, + ); + } + } else { + messageId = await this.createInput(ctx, row, payload); + } + await runQuery( + `UPDATE ${escape.tableName('agent_message_queue')} + SET ${escape.columnName('messageId')} = :messageId, ${escape.columnName('payload')} = :payload + WHERE ${escape.columnName('id')} = :id`, + { id: row.id, messageId, payload: JSON.stringify(this.dispatchPayload(payload)) }, + ); + } + + private async createInput(ctx: MigrationContext, row: QueueRow, payload: QueuePayload) { + const { escape, runQuery } = ctx; + const id = randomUUID(); + const parameters = { + id, + threadId: row.threadId, + resourceId: payload.resourceId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + await runQuery( + `INSERT INTO ${escape.tableName('agents_resources')} + (${['id', 'createdAt', 'updatedAt'].map(escape.columnName).join(', ')}) + VALUES (:resourceId, :createdAt, :updatedAt) ON CONFLICT (${escape.columnName('id')}) DO NOTHING`, + parameters, + ); + await runQuery( + `INSERT INTO ${escape.tableName('agents_threads')} + (${['id', 'resourceId', 'createdAt', 'updatedAt'].map(escape.columnName).join(', ')}) + VALUES (:threadId, :resourceId, :createdAt, :updatedAt) ON CONFLICT (${escape.columnName('id')}) DO NOTHING`, + parameters, + ); + const origin: Record = { source: row.source }; + if (payload.kind === 'integration') { + origin.integrationConnectionId = payload.messageContext.integrationConnectionId; + origin.platformMessageId = payload.messageContext.messageId; + origin.platformThreadId = payload.platformThreadId; + } + const modelContent = + payload.kind === 'integration' && payload.modelMessage !== payload.message + ? JSON.stringify(this.messageContent(payload.modelMessage, payload.attachments ?? [])) + : null; + await runQuery( + `INSERT INTO ${escape.tableName('agents_messages')} + (${['id', 'threadId', 'resourceId', 'role', 'type', 'content', 'author', 'origin', 'modelContent', 'modelContextAt', 'createdAt', 'updatedAt'].map(escape.columnName).join(', ')}) + VALUES (:id, :threadId, :resourceId, 'user', NULL, :content, :author, :origin, :modelContent, NULL, :createdAt, :updatedAt)`, + { + ...parameters, + content: JSON.stringify(this.messageContent(payload.message, payload.attachments ?? [])), + author: payload.kind === 'integration' ? JSON.stringify(payload.author) : null, + origin: JSON.stringify(origin), + modelContent, + }, + ); + return id; + } + + private messageContent(text: string, attachments: Array>) { + const files = attachments.map(({ mimeType, ...fileRef }) => ({ + type: 'file', + mediaType: mimeType, + fileRef, + })); + return { role: 'user', content: [...(text ? [{ type: 'text', text }] : []), ...files] }; + } + + private dispatchPayload(payload: QueuePayload) { + if (payload.kind === 'preview') return { kind: 'preview' }; + const { + message: _message, + resourceId: _resourceId, + attachments: _attachments, + modelMessage: _modelMessage, + author: _author, + platformThreadId: _platformThreadId, + messageContext: fullContext, + ...dispatch + } = payload; + const { + platform: _platform, + integrationConnectionId: _connectionId, + messageId: _messageId, + ...messageContext + } = fullContext; + return { ...dispatch, messageContext }; + } + + async down(ctx: MigrationContext) { + const { + schemaBuilder: { dropIndex, dropForeignKey, dropColumns, addColumns, column }, + escape, + runQuery, + } = ctx; + await this.requireEmptyQueue(ctx); + await dropIndex('agent_message_queue', ['messageId']); + await dropForeignKey( + 'agent_message_queue', + 'messageId', + ['agents_messages', 'id'], + 'FK_agent_message_queue_messageId', + ); + await dropColumns('agent_message_queue', ['messageId'], { recreatesOnSqlite: true }); + await addColumns( + 'agent_message_queue', + [column('source').varchar(32).notNull.comment('Preview or integration source')], + { recreatesOnSqlite: true }, + ); + await runQuery(`COMMENT ON COLUMN ${escape.tableName('agent_message_queue')}.${escape.columnName('payload')} + IS 'Input, attachment references, identity, and reply context'`); + } + + protected async requireEmptyQueue({ runQuery, escape }: MigrationContext) { + const rows = await runQuery>( + `SELECT ${escape.columnName('id')} FROM ${escape.tableName('agent_message_queue')} LIMIT 1`, + ); + if (rows.length) + throw new UserError('The agent message queue must be empty to change its message references'); + } +} diff --git a/packages/@n8n/db/src/migrations/sqlite/1790605116208-AddAgentQueueMessageReferences.ts b/packages/@n8n/db/src/migrations/sqlite/1790605116208-AddAgentQueueMessageReferences.ts new file mode 100644 index 00000000000..eacda21f7a9 --- /dev/null +++ b/packages/@n8n/db/src/migrations/sqlite/1790605116208-AddAgentQueueMessageReferences.ts @@ -0,0 +1,65 @@ +import type { MigrationContext } from '../migration-types'; +import { AddAgentQueueMessageReferences1790605116208 as BaseMigration } from '../postgresdb/1790605116208-AddAgentQueueMessageReferences'; + +export class AddAgentQueueMessageReferences1790605116208 extends BaseMigration { + async up(ctx: MigrationContext) { + const { + schemaBuilder: { addNotNull, addForeignKey, createIndex }, + escape, + runQuery, + } = ctx; + // SQLite rebuilds the table for NOT NULL and otherwise forgets deleted queue IDs. + const sequence = await this.readQueueSequence(ctx); + await runQuery(`ALTER TABLE ${escape.tableName('agent_message_queue')} + ADD COLUMN ${escape.columnName('messageId')} varchar(36)`); + await this.backfillQueue(ctx); + await runQuery( + `ALTER TABLE ${escape.tableName('agent_message_queue')} DROP COLUMN ${escape.columnName('source')}`, + ); + await addNotNull('agent_message_queue', 'messageId', { recreatesOnSqlite: true }); + await addForeignKey( + 'agent_message_queue', + 'messageId', + ['agents_messages', 'id'], + 'FK_agent_message_queue_messageId', + 'CASCADE', + ); + await createIndex('agent_message_queue', ['messageId'], true); + await this.restoreQueueSequence(ctx, sequence); + } + + async down(ctx: MigrationContext) { + const { + schemaBuilder: { dropIndex, dropColumns }, + escape, + runQuery, + } = ctx; + await this.requireEmptyQueue(ctx); + const sequence = await this.readQueueSequence(ctx); + await dropIndex('agent_message_queue', ['messageId']); + await dropColumns('agent_message_queue', ['messageId'], { recreatesOnSqlite: true }); + await runQuery( + `ALTER TABLE ${escape.tableName('agent_message_queue')} ADD COLUMN ${escape.columnName('source')} varchar(32) NOT NULL`, + ); + await this.restoreQueueSequence(ctx, sequence); + } + + private async readQueueSequence({ runQuery, tablePrefix }: MigrationContext) { + const [sequence] = await runQuery>( + 'SELECT CAST(seq AS TEXT) AS seq FROM sqlite_sequence WHERE name = :tableName', + { tableName: `${tablePrefix}agent_message_queue` }, + ); + return sequence?.seq; + } + + private async restoreQueueSequence( + { runQuery, tablePrefix }: MigrationContext, + sequence: string | undefined, + ) { + if (sequence === undefined) return; + await runQuery('UPDATE sqlite_sequence SET seq = :seq WHERE name = :tableName', { + seq: sequence, + tableName: `${tablePrefix}agent_message_queue`, + }); + } +} diff --git a/packages/@n8n/db/src/migrations/sqlite/1790683443934-AddAgentMessageSteering.ts b/packages/@n8n/db/src/migrations/sqlite/1790683443934-AddAgentMessageSteering.ts new file mode 100644 index 00000000000..58fcfe598d8 --- /dev/null +++ b/packages/@n8n/db/src/migrations/sqlite/1790683443934-AddAgentMessageSteering.ts @@ -0,0 +1,28 @@ +import { AddAgentMessageSteering1790683443934 as BaseMigration } from '../common/1790683443934-AddAgentMessageSteering'; +import type { MigrationContext } from '../migration-types'; + +export class AddAgentMessageSteering1790683443934 extends BaseMigration { + async up(context: MigrationContext) { + await this.preserveQueueSequence(context, async () => await super.up(context)); + } + + async down(context: MigrationContext) { + await this.preserveQueueSequence(context, async () => await super.down(context)); + } + + private async preserveQueueSequence(context: MigrationContext, migrate: () => Promise) { + const name = `${context.tablePrefix}agent_message_queue`; + const [sequence] = await context.runQuery>( + 'SELECT CAST("seq" AS TEXT) AS "seq" FROM "sqlite_sequence" WHERE "name" = :name', + { name }, + ); + await migrate(); + if (!sequence) return; + // Table recreation resets AUTOINCREMENT to the largest retained ID. Keep retired IDs reserved. + await context.runQuery('DELETE FROM "sqlite_sequence" WHERE "name" = :name', { name }); + await context.runQuery('INSERT INTO "sqlite_sequence" ("name", "seq") VALUES (:name, :seq)', { + name, + seq: sequence.seq, + }); + } +} diff --git a/packages/@n8n/db/src/repositories/__tests__/agent-eval-result.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/agent-eval-result.repository.test.ts index bb347f8f51d..7995b03da08 100644 --- a/packages/@n8n/db/src/repositories/__tests__/agent-eval-result.repository.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/agent-eval-result.repository.test.ts @@ -97,8 +97,7 @@ describe('AgentEvalResultRepository', () => { const callArgs = entityManager.update.mock.calls[0]; expect(callArgs?.[1]).toBe('res-1'); - expect(callArgs?.[2]).toMatchObject({ status: 'running' }); - expect((callArgs?.[2] as { runAt: Date }).runAt).toBeInstanceOf(Date); + expect(callArgs?.[2]).toMatchObject({ status: 'running', runAt: expect.any(Date) }); }); }); @@ -110,8 +109,7 @@ describe('AgentEvalResultRepository', () => { const callArgs = entityManager.update.mock.calls[0]; expect(callArgs?.[1]).toBe('res-1'); - expect(callArgs?.[2]).toMatchObject({ status: 'cancelled' }); - expect((callArgs?.[2] as { completedAt: Date }).completedAt).toBeInstanceOf(Date); + expect(callArgs?.[2]).toMatchObject({ status: 'cancelled', completedAt: expect.any(Date) }); }); }); diff --git a/packages/@n8n/db/src/repositories/__tests__/agent-eval-run.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/agent-eval-run.repository.test.ts index 0a5f0b08e6e..f2350855914 100644 --- a/packages/@n8n/db/src/repositories/__tests__/agent-eval-run.repository.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/agent-eval-run.repository.test.ts @@ -53,8 +53,11 @@ describe('AgentEvalRunRepository', () => { const callArgs = entityManager.update.mock.calls[0]; expect(callArgs?.[1]).toBe('run-1'); - expect(callArgs?.[2]).toMatchObject({ status: 'running', runningInstanceId: 'main-2' }); - expect((callArgs?.[2] as { runAt: Date }).runAt).toBeInstanceOf(Date); + expect(callArgs?.[2]).toMatchObject({ + status: 'running', + runningInstanceId: 'main-2', + runAt: expect.any(Date), + }); }); it('nulls the running instance when none is given', async () => { @@ -63,7 +66,7 @@ describe('AgentEvalRunRepository', () => { await repo.markAsRunning('run-1'); const callArgs = entityManager.update.mock.calls[0]; - expect((callArgs?.[2] as { runningInstanceId: string | null }).runningInstanceId).toBeNull(); + expect(callArgs?.[2]).toMatchObject({ runningInstanceId: null }); }); }); diff --git a/packages/@n8n/db/src/repositories/__tests__/project-relation.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/project-relation.repository.test.ts index 86d21b3170a..38348b2b8bd 100644 --- a/packages/@n8n/db/src/repositories/__tests__/project-relation.repository.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/project-relation.repository.test.ts @@ -1,9 +1,9 @@ import { Container } from '@n8n/di'; import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; -import type { SelectQueryBuilder } from '@n8n/typeorm'; +import { In, type SelectQueryBuilder } from '@n8n/typeorm'; import { mock } from 'vitest-mock-extended'; -import { ProjectRelation } from '../../entities'; +import { ProjectRelation, Role } from '../../entities'; import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager'; import { ProjectRelationRepository } from '../project-relation.repository'; @@ -54,6 +54,86 @@ describe('ProjectRelationRepository', () => { }); }); + describe('findAllByUser', () => { + beforeEach(() => { + entityManager.find.mockReset(); + }); + + const relation = (projectId: string, roleSlug: string) => + ({ userId: 'user1', projectId, role: { slug: roleSlug } }) as ProjectRelation; + const role = (slug: string, ...scopeSlugs: string[]) => + ({ slug, scopes: scopeSlugs.map((scopeSlug) => ({ slug: scopeSlug })) }) as Role; + + it('loads the relations without the eager role scopes', async () => { + entityManager.find.mockResolvedValueOnce([relation('project1', 'project:admin')]); + entityManager.find.mockResolvedValueOnce([role('project:admin', 'workflow:read')]); + + await projectRelationRepository.findAllByUser('user1'); + + expect(entityManager.find).toHaveBeenNthCalledWith(1, ProjectRelation, { + where: { userId: 'user1' }, + relations: { role: true, project: false }, + loadEagerRelations: false, + }); + }); + + it('joins the project only when asked to', async () => { + entityManager.find.mockResolvedValueOnce([relation('project1', 'project:admin')]); + entityManager.find.mockResolvedValueOnce([role('project:admin', 'workflow:read')]); + + await projectRelationRepository.findAllByUser('user1', { withProject: true }); + + expect(entityManager.find).toHaveBeenNthCalledWith( + 1, + ProjectRelation, + expect.objectContaining({ relations: { role: true, project: true } }), + ); + }); + + it('attaches the scopes of each role from one query over the distinct roles', async () => { + entityManager.find.mockResolvedValueOnce([ + relation('project1', 'project:admin'), + relation('project2', 'project:editor'), + relation('project3', 'project:admin'), + ]); + entityManager.find.mockResolvedValueOnce([ + role('project:admin', 'workflow:read', 'workflow:update'), + role('project:editor', 'workflow:read'), + ]); + + const result = await projectRelationRepository.findAllByUser('user1'); + + expect(entityManager.find).toHaveBeenCalledTimes(2); + expect(entityManager.find).toHaveBeenNthCalledWith(2, Role, { + where: { slug: In(['project:admin', 'project:editor']) }, + relations: ['scopes'], + }); + expect(result.map((r) => r.role.scopes.map((s) => s.slug))).toEqual([ + ['workflow:read', 'workflow:update'], + ['workflow:read'], + ['workflow:read', 'workflow:update'], + ]); + }); + + it('leaves the scopes empty for a role the query did not return', async () => { + entityManager.find.mockResolvedValueOnce([relation('project1', 'project:custom')]); + entityManager.find.mockResolvedValueOnce([]); + + const [result] = await projectRelationRepository.findAllByUser('user1'); + + expect(result.role.scopes).toEqual([]); + }); + + it('does not query the roles when the user has no relations', async () => { + entityManager.find.mockResolvedValueOnce([]); + + const result = await projectRelationRepository.findAllByUser('user1'); + + expect(result).toEqual([]); + expect(entityManager.find).toHaveBeenCalledTimes(1); + }); + }); + describe('findProjectIdsByUserIds', () => { beforeEach(() => { entityManager.find.mockReset(); diff --git a/packages/@n8n/db/src/repositories/credentials.repository.ts b/packages/@n8n/db/src/repositories/credentials.repository.ts index 7e062a98eac..60e76f62f2e 100644 --- a/packages/@n8n/db/src/repositories/credentials.repository.ts +++ b/packages/@n8n/db/src/repositories/credentials.repository.ts @@ -4,6 +4,7 @@ import type { Scope } from '@n8n/permissions'; import type { FindManyOptions, FindOptionsWhere, SelectQueryBuilder } from '@n8n/typeorm'; import { DataSource, In, IsNull, LessThan, Like, Not, QueryFailedError } from '@n8n/typeorm'; import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; +import { generateNanoId } from '@n8n/utils/generate-nano-id'; import { UnexpectedError, UserError } from 'n8n-workflow'; @@ -259,7 +260,9 @@ export class CredentialsRepository extends BaseRepository { credentialSubject({ id: content.id ?? null, type: content.type }), ); - const result = await this.managerFor(ctx).upsert(CredentialsEntity, content, ['id']); + // A plain object skips the entity's `@BeforeInsert` id generation, and the column has no default. + const row = { ...content, id: content.id ?? generateNanoId() }; + const result = await this.managerFor(ctx).upsert(CredentialsEntity, row, ['id']); const id = result.identifiers.at(0)?.id; if (typeof id !== 'string') { diff --git a/packages/@n8n/db/src/repositories/project-relation.repository.ts b/packages/@n8n/db/src/repositories/project-relation.repository.ts index aea635fbd90..adbbca87c45 100644 --- a/packages/@n8n/db/src/repositories/project-relation.repository.ts +++ b/packages/@n8n/db/src/repositories/project-relation.repository.ts @@ -2,7 +2,7 @@ import { Service } from '@n8n/di'; import { PROJECT_OWNER_ROLE_SLUG, type ProjectRole } from '@n8n/permissions'; import { DataSource, In, Repository } from '@n8n/typeorm'; -import { ProjectRelation } from '../entities'; +import { ProjectRelation, Role } from '../entities'; import { chunkIds } from '../utils/chunk-ids'; @Service() @@ -92,13 +92,42 @@ export class ProjectRelationRepository extends Repository { return [...new Set(rows.map((r) => r.userId))]; } - async findAllByUser(userId: string) { - return await this.find({ - where: { - userId, - }, - relations: { role: true }, + /** + * Every relation of a user, with its role and the role's scopes, and with the + * project when `withProject` is set. + * + * `Role.scopes` is eager, so a plain `find` with `role` joins every scope of every + * role: the database returns (relations x scopes of the role) rows, about 70 per + * project for `project:admin`. A user in hundreds of projects pays tens of + * thousands of rows to transfer and hydrate on every call. Load the relations + * without the eager join and attach the scopes from one query over the distinct + * roles instead, so the row count follows the number of relations. + */ + async findAllByUser( + userId: string, + { withProject = false }: { withProject?: boolean } = {}, + ): Promise { + const relations = await this.find({ + where: { userId }, + relations: { role: true, project: withProject }, + loadEagerRelations: false, }); + await this.attachRoleScopes(relations); + return relations; + } + + private async attachRoleScopes(relations: ProjectRelation[]): Promise { + const slugs = [...new Set(relations.map((relation) => relation.role.slug))]; + if (slugs.length === 0) return; + + const roles = await this.manager.find(Role, { + where: { slug: In(slugs) }, + relations: ['scopes'], + }); + const scopesBySlug = new Map(roles.map((role) => [role.slug, role.scopes])); + for (const relation of relations) { + relation.role.scopes = scopesBySlug.get(relation.role.slug) ?? []; + } } /** diff --git a/packages/@n8n/decorators/package.json b/packages/@n8n/decorators/package.json index 88b48fe0246..f8c8b3f6fff 100644 --- a/packages/@n8n/decorators/package.json +++ b/packages/@n8n/decorators/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/decorators", - "version": "1.41.0", + "version": "1.42.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/engine/eslint.config.mjs b/packages/@n8n/engine/eslint.config.mjs index 1ebfcb7ef61..7eecf28500c 100644 --- a/packages/@n8n/engine/eslint.config.mjs +++ b/packages/@n8n/engine/eslint.config.mjs @@ -5,8 +5,10 @@ export default defineConfig( { ignores: ['compiled/**', 'vitest.integration.config.ts'] }, backendConfig, { - // Debt: the base layer enforces kebab-case filenames and this package has - // 2 files that predate it. Rename them, then delete this block. + files: [ + 'src/database/migrations/1778529600000-CreateWorkflowExecution.ts', + 'src/database/migrations/1784890100000-CreateWorkflowStepExecution.ts', + ], rules: { 'unicorn/filename-case': 'off', }, diff --git a/packages/@n8n/engine/oxlint.config.mts b/packages/@n8n/engine/oxlint.config.mts new file mode 100644 index 00000000000..7b3132807c2 --- /dev/null +++ b/packages/@n8n/engine/oxlint.config.mts @@ -0,0 +1,17 @@ +import { backendConfig } from '@n8n/oxlint-config/backend'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [backendConfig], + options: { typeAware: true }, + ignorePatterns: ['compiled/**', 'vitest.integration.config.ts'], + overrides: [ + { + files: [ + 'src/database/migrations/1778529600000-CreateWorkflowExecution.ts', + 'src/database/migrations/1784890100000-CreateWorkflowStepExecution.ts', + ], + rules: { 'unicorn/filename-case': 'off' }, + }, + ], +}); diff --git a/packages/@n8n/engine/package.json b/packages/@n8n/engine/package.json index 9f4f4a68ab9..c576644385c 100644 --- a/packages/@n8n/engine/package.json +++ b/packages/@n8n/engine/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/engine", - "version": "0.21.0", + "version": "0.22.0", "description": "n8n workflow execution engine (v2)", "scripts": { "clean": "rimraf dist .turbo compiled", @@ -11,8 +11,8 @@ "start": "node dist/serve.js", "format": "biome format --write src", "format:check": "biome ci src", - "lint": "eslint . --quiet", - "lint:fix": "eslint . --fix", + "lint": "oxlint --quiet", + "lint:fix": "oxlint --fix", "test": "vitest run --passWithNoTests", "test:dev": "vitest --watch", "test:integration": "vitest run --config vitest.integration.config.ts", @@ -37,6 +37,8 @@ "zod": "catalog:" }, "devDependencies": { + "@n8n/eslint-config": "workspace:*", + "@n8n/oxlint-config": "workspace:*", "@n8n/typescript-config": "workspace:*", "@n8n/vitest-config": "workspace:*", "@testcontainers/postgresql": "catalog:", @@ -45,6 +47,8 @@ "@types/pg": "^8.15.6", "@types/supertest": "^6.0.3", "n8n-containers": "workspace:*", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", "supertest": "^7.1.1", "typescript": "catalog:typescript", "vitest": "catalog:" diff --git a/packages/@n8n/engine/src/database/__tests__/workflow-execution.integration.test.ts b/packages/@n8n/engine/src/database/__tests__/workflow-execution.integration.test.ts index 5e0fa696529..2d8cfe39012 100644 --- a/packages/@n8n/engine/src/database/__tests__/workflow-execution.integration.test.ts +++ b/packages/@n8n/engine/src/database/__tests__/workflow-execution.integration.test.ts @@ -43,6 +43,7 @@ describe('workflow_execution table (integration)', () => { workflow: {}, triggerOutputs: [{ foo: 'bar' }], callerContext: { hostMode: 'trigger' }, + responseExpectation: { kind: 'stepResponse' }, finishedAt: null, }); await repo.save(created); @@ -58,6 +59,7 @@ describe('workflow_execution table (integration)', () => { expect(found.mode).toBe('production'); expect(found.triggerOutputs).toEqual([{ foo: 'bar' }]); expect(found.callerContext).toEqual({ hostMode: 'trigger' }); + expect(found.responseExpectation).toEqual({ kind: 'stepResponse' }); expect(found.finishedAt).toBeNull(); expect(found.createdAt).toBeInstanceOf(Date); expect(found.updatedAt).toBeInstanceOf(Date); @@ -75,6 +77,7 @@ describe('workflow_execution table (integration)', () => { workflow: sampleWorkflow, triggerOutputs: [{ foo: 'bar' }], callerContext: { hostMode: 'manual' }, + responseExpectation: { kind: 'none' }, finishedAt, }); await repo.save(created); @@ -110,6 +113,7 @@ describe('workflow_execution table (integration)', () => { workflow: sampleWorkflow, triggerOutputs: [{ foo: 'bar' }], callerContext: { hostMode: 'trigger' }, + responseExpectation: { kind: 'runEnd' }, finishedAt: null, }); await repo.save(created); @@ -125,6 +129,7 @@ describe('workflow_execution table (integration)', () => { graph: { nodes: [], edges: [] }, triggerOutputs: [{ foo: 'bar' }], callerContext: { hostMode: 'trigger' }, + responseExpectation: { kind: 'runEnd' }, }); }); @@ -155,6 +160,7 @@ describe('workflow_execution table (integration)', () => { workflow: {}, triggerOutputs: null, callerContext: { hostMode: 'trigger' }, + responseExpectation: { kind: 'none' }, finishedAt: null, }), ); @@ -168,6 +174,7 @@ describe('workflow_execution table (integration)', () => { workflow: {}, triggerOutputs: null, callerContext: { hostMode: 'trigger' }, + responseExpectation: { kind: 'none' }, finishedAt: new Date(), }), ); @@ -191,6 +198,7 @@ describe('workflow_execution table (integration)', () => { graph: { nodes: [], edges: [] }, workflow: {}, triggerOutputs: null, + responseExpectation: { kind: 'none' }, finishedAt: null, }), ); @@ -218,6 +226,7 @@ describe('workflow_execution table (integration)', () => { graph: { nodes: [], edges: [] }, workflow: {}, triggerOutputs: null, + responseExpectation: { kind: 'none' }, finishedAt, }), ); @@ -245,6 +254,7 @@ describe('workflow_execution table (integration)', () => { graph: { nodes: [], edges: [] }, workflow: {}, triggerOutputs: null, + responseExpectation: { kind: 'none' }, finishedAt: null, }), ); diff --git a/packages/@n8n/engine/src/database/__tests__/workflow-step-execution.integration.test.ts b/packages/@n8n/engine/src/database/__tests__/workflow-step-execution.integration.test.ts index cb48be207cf..201af0d659a 100644 --- a/packages/@n8n/engine/src/database/__tests__/workflow-step-execution.integration.test.ts +++ b/packages/@n8n/engine/src/database/__tests__/workflow-step-execution.integration.test.ts @@ -53,6 +53,7 @@ describe('workflow_step_execution table (integration)', () => { workflow: {}, triggerOutputs: null, callerContext: { hostMode: 'trigger' }, + responseExpectation: { kind: 'none' }, finishedAt: null, }); await repo.save(execution); diff --git a/packages/@n8n/engine/src/database/entities/workflow-execution.entity.ts b/packages/@n8n/engine/src/database/entities/workflow-execution.entity.ts index 7badd677e1e..09b4cf0734f 100644 --- a/packages/@n8n/engine/src/database/entities/workflow-execution.entity.ts +++ b/packages/@n8n/engine/src/database/entities/workflow-execution.entity.ts @@ -15,6 +15,7 @@ import type { WorkflowDocument, } from '../../execution/execution.types'; import type { WorkflowGraph } from '../../graph'; +import type { ResponseExpectation } from '../../response-channel'; @Entity('workflow_execution') @Index('idx_workflow_execution_workflow_id', ['workflowId']) @@ -46,6 +47,10 @@ export class WorkflowExecution { @Column('jsonb', { name: 'caller_context' }) callerContext!: CallerContext; + /** What kind of a response (if any) the caller expects */ + @Column('jsonb', { name: 'response_expectation' }) + responseExpectation!: ResponseExpectation; + @CreateDateColumn({ name: 'created_at', type: 'timestamptz', precision: 3 }) createdAt!: Date; diff --git a/packages/@n8n/engine/src/database/migrations/1778529600000-CreateWorkflowExecution.ts b/packages/@n8n/engine/src/database/migrations/1778529600000-CreateWorkflowExecution.ts index 571c01f77b8..1a115b58aa2 100644 --- a/packages/@n8n/engine/src/database/migrations/1778529600000-CreateWorkflowExecution.ts +++ b/packages/@n8n/engine/src/database/migrations/1778529600000-CreateWorkflowExecution.ts @@ -22,6 +22,11 @@ export class CreateWorkflowExecution1778529600000 implements MigrationInterface comment: 'Supplied by the caller at start (user, project, host mode). The engine stores and forwards it to step executors without reading it. Not the engine-internal per-request context.', }, + { + name: 'response_expectation', + type: 'jsonb', + comment: 'What kind of a response the caller expects (none, runEnd or stepResponse)', + }, { name: 'created_at', type: 'timestamptz', diff --git a/packages/@n8n/engine/src/database/typeorm-execution-store.ts b/packages/@n8n/engine/src/database/typeorm-execution-store.ts index db182dad7ee..4474304f97d 100644 --- a/packages/@n8n/engine/src/database/typeorm-execution-store.ts +++ b/packages/@n8n/engine/src/database/typeorm-execution-store.ts @@ -37,6 +37,7 @@ export class TypeOrmExecutionStore implements ExecutionStore { .addSelect('execution.graph', 'graph') .addSelect('execution.trigger_outputs', 'triggerOutputs') .addSelect('execution.caller_context', 'callerContext') + .addSelect('execution.response_expectation', 'responseExpectation') .where('execution.id = :id', { id }) .getRawOne(); if (!row) throw new ExecutionNotFoundError(id); diff --git a/packages/@n8n/engine/src/execution/__tests__/execution-start-handler.test.ts b/packages/@n8n/engine/src/execution/__tests__/execution-start-handler.test.ts index efd41924d83..81d5998d01f 100644 --- a/packages/@n8n/engine/src/execution/__tests__/execution-start-handler.test.ts +++ b/packages/@n8n/engine/src/execution/__tests__/execution-start-handler.test.ts @@ -69,6 +69,7 @@ function record(graph: WorkflowGraph, overrides: Partial = {}): workflow: {}, triggerOutputs: null, callerContext: { hostMode: 'trigger' }, + responseExpectation: { kind: 'none' }, ...overrides, }; } diff --git a/packages/@n8n/engine/src/execution/__tests__/execution-start.integration.test.ts b/packages/@n8n/engine/src/execution/__tests__/execution-start.integration.test.ts index 5dc5cf52f2c..8b6fd77bde6 100644 --- a/packages/@n8n/engine/src/execution/__tests__/execution-start.integration.test.ts +++ b/packages/@n8n/engine/src/execution/__tests__/execution-start.integration.test.ts @@ -150,6 +150,7 @@ describe('execution start (integration)', () => { workflow: {}, triggerOutputs: null, callerContext: { hostMode: 'trigger' }, + responseExpectation: { kind: 'none' }, }); // Delivered twice, both awaited — the CAS is what makes the second a no-op. diff --git a/packages/@n8n/engine/src/execution/__tests__/start-execution.service.test.ts b/packages/@n8n/engine/src/execution/__tests__/start-execution.service.test.ts index 8d82a6dd91a..67582b7ae5c 100644 --- a/packages/@n8n/engine/src/execution/__tests__/start-execution.service.test.ts +++ b/packages/@n8n/engine/src/execution/__tests__/start-execution.service.test.ts @@ -63,6 +63,7 @@ describe('StartExecutionService', () => { workflow: sampleWorkflow, triggerOutputs: [[{ json: { hello: 'world' } }]], callerContext: { hostMode: 'trigger' }, + responseExpectation: { kind: 'none' }, }); expect(queue.publish).toHaveBeenCalledWith({ type: 'execution:enqueued', @@ -89,7 +90,28 @@ describe('StartExecutionService', () => { expect(store.createExecution).toHaveBeenCalledWith(expect.objectContaining({ callerContext })); }); - it('defaults mode to production and triggerOutputs to null', async () => { + it('stores the response expectation as given', async () => { + const admittance: AdmittanceService = { + evaluate: vi.fn().mockResolvedValue({ accept: true }), + }; + const store = makeStore(); + const service = new StartExecutionService(admittance, store, makeQueue()); + + await service.start({ + workflowId: 'wf-1', + graph: sampleGraph, + workflow: sampleWorkflow, + executionId: 'exec-id-1', + callerContext: { hostMode: 'webhook' }, + responseExpectation: { kind: 'runEnd' }, + }); + + expect(store.createExecution).toHaveBeenCalledWith( + expect.objectContaining({ responseExpectation: { kind: 'runEnd' } }), + ); + }); + + it('defaults mode to production, triggerOutputs to null and the expectation to none', async () => { const admittance: AdmittanceService = { evaluate: vi.fn().mockResolvedValue({ accept: true }), }; @@ -106,7 +128,11 @@ describe('StartExecutionService', () => { }); expect(store.createExecution).toHaveBeenCalledWith( - expect.objectContaining({ mode: 'production', triggerOutputs: null }), + expect.objectContaining({ + mode: 'production', + triggerOutputs: null, + responseExpectation: { kind: 'none' }, + }), ); }); diff --git a/packages/@n8n/engine/src/execution/__tests__/step-execution.integration.test.ts b/packages/@n8n/engine/src/execution/__tests__/step-execution.integration.test.ts index 3da90ad22b3..4fe94897f84 100644 --- a/packages/@n8n/engine/src/execution/__tests__/step-execution.integration.test.ts +++ b/packages/@n8n/engine/src/execution/__tests__/step-execution.integration.test.ts @@ -559,6 +559,7 @@ describe('step execution (integration)', () => { workflow: {}, triggerOutputs: null, callerContext: { hostMode: 'trigger' }, + responseExpectation: { kind: 'none' }, }); const created = await stepStore.createSteps(executionId, [ // completed steps always carry outputs, as the start handler writes them diff --git a/packages/@n8n/engine/src/execution/__tests__/step-ready-handler.test.ts b/packages/@n8n/engine/src/execution/__tests__/step-ready-handler.test.ts index 1a532f4fc97..d074f9e8693 100644 --- a/packages/@n8n/engine/src/execution/__tests__/step-ready-handler.test.ts +++ b/packages/@n8n/engine/src/execution/__tests__/step-ready-handler.test.ts @@ -81,6 +81,7 @@ function makeExecutionStore(overrides: Partial = {}): Execution workflow: {}, triggerOutputs: null, callerContext: { hostMode: 'trigger' }, + responseExpectation: { kind: 'none' }, ...overrides, }; return { @@ -203,6 +204,44 @@ describe('StepReadyHandler', () => { ); }); + it.each([ + ['stepResponse', true], + ['runEnd', false], + ['none', false], + ] as const)( + 'gives the step an emitter that obeys the stored expectation %s', + async (kind, sends) => { + const responseSender: ExecutionResponseSender = { send: vi.fn(), stop: vi.fn() }; + const executor: IStepExecutor = { + execute: vi.fn(async (request) => { + await Promise.resolve(); + request.respond.send(() => ({ ok: true })); + return { outputs: [[{ json: { ok: true } }]] }; + }), + }; + const handler = makeHandler( + makeExecutionStore({ responseExpectation: { kind } }), + makeStepStore(), + makeQueue(), + { v1StepExecutor: executor }, + makeLifecycleEventPublisher(), + responseSender, + ); + + await handler.handle(event); + + if (sends) { + expect(responseSender.send).toHaveBeenCalledExactlyOnceWith({ + type: 'response', + executionId: 'exec-1', + payload: { ok: true }, + }); + } else { + expect(responseSender.send).not.toHaveBeenCalled(); + } + }, + ); + it('reads inputs from the predecessor step outputs when the predecessor is not the trigger', async () => { const executor = makeExecutor(); // step 'b', whose only predecessor is 'a' diff --git a/packages/@n8n/engine/src/execution/__tests__/step-settled-handler.test.ts b/packages/@n8n/engine/src/execution/__tests__/step-settled-handler.test.ts index 2f7af6d0c57..7078343f87b 100644 --- a/packages/@n8n/engine/src/execution/__tests__/step-settled-handler.test.ts +++ b/packages/@n8n/engine/src/execution/__tests__/step-settled-handler.test.ts @@ -58,6 +58,7 @@ function makeExecutionStore( workflow: {}, triggerOutputs: null, callerContext: { hostMode: 'trigger' }, + responseExpectation: { kind: 'none' }, ...overrides, }; return { @@ -519,7 +520,9 @@ describe('StepSettledHandler lifecycle events', () => { { id: 'step-m', nodeId: 'm' }, { countSettledSteps: vi.fn().mockResolvedValue(5) }, ); - const { handler, lifecycleEventPublisher, responseSender } = makeHandler(stepStore); + const { handler, lifecycleEventPublisher, responseSender } = makeHandler(stepStore, { + executionStore: makeExecutionStore({ responseExpectation: { kind: 'runEnd' } }), + }); await handler.handle({ ...event, stepId: 'step-m' }); @@ -547,7 +550,9 @@ describe('StepSettledHandler lifecycle events', () => { status: 'failed', error: { name: 'NodeOperationError', message: 'it broke', stack: 'at trace' }, }); - const { handler, lifecycleEventPublisher, responseSender } = makeHandler(stepStore); + const { handler, lifecycleEventPublisher, responseSender } = makeHandler(stepStore, { + executionStore: makeExecutionStore({ responseExpectation: { kind: 'runEnd' } }), + }); await handler.handle(event); @@ -571,6 +576,61 @@ describe('StepSettledHandler lifecycle events', () => { }); }); + describe('the ended message for each response expectation', () => { + const outputs = [[{ json: { a: 1 } }]]; + const finishingStore = () => + makeStepStore( + { id: 'step-m', nodeId: 'm', outputs }, + { countSettledSteps: vi.fn().mockResolvedValue(5) }, + ); + + it('sends no ended message when the caller expects none', async () => { + const { handler, lifecycleEventPublisher, responseSender } = makeHandler(finishingStore(), { + executionStore: makeExecutionStore({ responseExpectation: { kind: 'none' } }), + }); + + await handler.handle({ ...event, stepId: 'step-m' }); + + // The run still ends: only the response is left out. + expect(lifecycleEventPublisher.publish).toHaveBeenCalledExactlyOnceWith({ + type: 'execution:completed', + ...finished, + }); + expect(responseSender.send).not.toHaveBeenCalled(); + }); + + it('sends the outputs of the last step when the caller expects the run end', async () => { + const { handler, responseSender } = makeHandler(finishingStore(), { + executionStore: makeExecutionStore({ responseExpectation: { kind: 'runEnd' } }), + }); + + await handler.handle({ ...event, stepId: 'step-m' }); + + expect(responseSender.send).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + type: 'ended', + lastStep: expect.objectContaining({ nodeId: 'm', outputs }) as unknown, + }), + ); + }); + + it('sends no outputs when the caller expects a step response', async () => { + const { handler, responseSender } = makeHandler(finishingStore(), { + executionStore: makeExecutionStore({ responseExpectation: { kind: 'stepResponse' } }), + }); + + await handler.handle({ ...event, stepId: 'step-m' }); + + expect(responseSender.send).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + type: 'ended', + status: 'completed', + lastStep: expect.objectContaining({ nodeId: 'm', outputs: null }) as unknown, + }), + ); + }); + }); + it('announces nothing when finishExecution loses the compare-and-set', async () => { // Another worker already wrote the outcome, so it announces it. const executionStore = makeExecutionStore( diff --git a/packages/@n8n/engine/src/execution/execution-store.ts b/packages/@n8n/engine/src/execution/execution-store.ts index ece60a62be8..b3e1a44e986 100644 --- a/packages/@n8n/engine/src/execution/execution-store.ts +++ b/packages/@n8n/engine/src/execution/execution-store.ts @@ -1,4 +1,5 @@ import type { WorkflowGraph } from '../graph'; +import type { ResponseExpectation } from '../response-channel'; import type { CallerContext, ExecutionMode, @@ -19,6 +20,8 @@ interface BaseExecutionRecord { workflow: WorkflowDocument; triggerOutputs: TriggerOutputs | null; callerContext: CallerContext; + /** What kind of a response the caller expects. */ + responseExpectation: ResponseExpectation; } /** A new execution to persist. Timestamps are assigned by the store. */ diff --git a/packages/@n8n/engine/src/execution/start-execution.service.ts b/packages/@n8n/engine/src/execution/start-execution.service.ts index b640ad0d4f9..57b3f19a3f0 100644 --- a/packages/@n8n/engine/src/execution/start-execution.service.ts +++ b/packages/@n8n/engine/src/execution/start-execution.service.ts @@ -1,6 +1,7 @@ import { AdmittanceRejectedError, type AdmittanceService } from '../admittance'; import { validateExecutableGraph, type WorkflowGraph } from '../graph'; import type { OrchestrationMessage, WorkQueue } from '../queue'; +import type { ResponseExpectation } from '../response-channel'; import type { ExecutionStore } from './execution-store'; import type { CallerContext, @@ -22,6 +23,8 @@ export interface StartExecutionRequest { mode?: ExecutionMode; /** Stored with the execution and handed to every step executor. */ callerContext: CallerContext; + /** What kind of a response the caller expects. Defaults to 'none' */ + responseExpectation?: ResponseExpectation; /** * Caller-minted, so the caller can record state against the run before it * starts. The engine never mints one. @@ -65,6 +68,7 @@ export class StartExecutionService { workflow: request.workflow, triggerOutputs: request.triggerOutputs ?? null, callerContext: request.callerContext, + responseExpectation: request.responseExpectation ?? { kind: 'none' }, }); // TODO(CAT-2938): the persist above and this publish aren't atomic — a diff --git a/packages/@n8n/engine/src/execution/step-ready-handler.ts b/packages/@n8n/engine/src/execution/step-ready-handler.ts index 156baf37ce3..2fa7d7bf143 100644 --- a/packages/@n8n/engine/src/execution/step-ready-handler.ts +++ b/packages/@n8n/engine/src/execution/step-ready-handler.ts @@ -8,7 +8,7 @@ import { type WorkflowLoop, } from '../graph'; import type { LifecycleEventPublisher } from '../lifecycle-events'; -import type { ExecutionResponseSender } from '../response-channel'; +import { createResponseEmitter, type ExecutionResponseSender } from '../response-channel'; import { runBatchStep } from './batch-step'; import type { OrchestrationMessage, StepReadyEvent, WorkQueue } from '../queue'; import type { ExecutionRecord, ExecutionStore } from './execution-store'; @@ -186,7 +186,7 @@ export class StepReadyHandler { iteration: step.iteration, callerContext: execution.callerContext, }, - respond: this.responseSender.emitterFor(execution.id), + respond: createResponseEmitter(this.responseSender, execution), }); if (result.wait) validateWaitDeclaration(step.id, result.wait); diff --git a/packages/@n8n/engine/src/execution/step-settled-handler.ts b/packages/@n8n/engine/src/execution/step-settled-handler.ts index 303fee67605..56758b10e8c 100644 --- a/packages/@n8n/engine/src/execution/step-settled-handler.ts +++ b/packages/@n8n/engine/src/execution/step-settled-handler.ts @@ -237,6 +237,9 @@ export class StepSettledHandler { ); } + const { kind } = execution.responseExpectation; + if (kind === 'none') return; + this.responseSender.send({ type: 'ended', executionId: execution.id, @@ -246,7 +249,7 @@ export class StepSettledHandler { nodeId: step.nodeId, nodeName: node.name, status: step.status, - outputs: step.outputs, + outputs: kind === 'runEnd' ? step.outputs : null, // Name and message only: the caller reports them, and the rest of the // error stays on the step row. error: step.error ? { name: step.error.name, message: step.error.message } : undefined, diff --git a/packages/@n8n/engine/src/index.ts b/packages/@n8n/engine/src/index.ts index 2ac3fb2074b..768908039e0 100644 --- a/packages/@n8n/engine/src/index.ts +++ b/packages/@n8n/engine/src/index.ts @@ -35,15 +35,20 @@ export type { } from './lifecycle-events'; export { + createResponseEmitter, executionResponseSchema, noopExecutionResponseSender, noopResponseEmitter, + RESPONSE_EXPECTATION_KINDS, + responseExpectationSchema, } from './response-channel'; export type { EndedMessage, ExecutionResponse, ExecutionResponseSender, ResponseEmitter, + ResponseExpectation, + ResponseExpectationKind, ResponseMessage, UndeliverableMessage, } from './response-channel'; diff --git a/packages/@n8n/engine/src/response-channel/__tests__/create-response-emitter.test.ts b/packages/@n8n/engine/src/response-channel/__tests__/create-response-emitter.test.ts new file mode 100644 index 00000000000..3bfb017e958 --- /dev/null +++ b/packages/@n8n/engine/src/response-channel/__tests__/create-response-emitter.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createResponseEmitter } from '../create-response-emitter'; +import type { ExecutionResponseSender } from '../execution-response-sender'; + +const newSender = (): ExecutionResponseSender => ({ + send: vi.fn(() => ({ ok: true as const, result: undefined })), + stop: vi.fn(), +}); + +describe('createResponseEmitter', () => { + it('builds and sends the payload when the caller expects a step response', () => { + const sender = newSender(); + const emitter = createResponseEmitter(sender, { + id: 'exec-1', + responseExpectation: { kind: 'stepResponse' }, + }); + + const result = emitter.send(() => ({ ok: true })); + + expect(result.ok).toBe(true); + expect(sender.send).toHaveBeenCalledExactlyOnceWith({ + type: 'response', + executionId: 'exec-1', + payload: { ok: true }, + }); + }); + + it('returns the error of the sender', () => { + const error = new Error('Too large'); + const sender = newSender(); + vi.mocked(sender.send).mockReturnValue({ ok: false, error }); + const emitter = createResponseEmitter(sender, { + id: 'exec-1', + responseExpectation: { kind: 'stepResponse' }, + }); + + expect(emitter.send(() => ({ ok: true }))).toEqual({ ok: false, error }); + }); + + it.each(['none', 'runEnd'] as const)( + 'drops the response without building it when the caller expects %s', + (kind) => { + const sender = newSender(); + const build = vi.fn(() => ({ ok: true })); + const emitter = createResponseEmitter(sender, { + id: 'exec-1', + responseExpectation: { kind }, + }); + + const result = emitter.send(build); + + expect(result.ok).toBe(true); + expect(build).not.toHaveBeenCalled(); + expect(sender.send).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/@n8n/engine/src/response-channel/__tests__/execution-response.schema.test.ts b/packages/@n8n/engine/src/response-channel/__tests__/execution-response.schema.test.ts index 80841f79700..0565271a825 100644 --- a/packages/@n8n/engine/src/response-channel/__tests__/execution-response.schema.test.ts +++ b/packages/@n8n/engine/src/response-channel/__tests__/execution-response.schema.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { executionResponseSchema } from '../execution-response.schema'; +import { executionResponseSchema, responseExpectationSchema } from '../execution-response.schema'; const ended = (overrides: Record = {}) => ({ type: 'ended', @@ -100,3 +100,16 @@ describe('executionResponseSchema', () => { expect(parsed).not.toHaveProperty('extra'); }); }); + +describe('responseExpectationSchema', () => { + it.each(['none', 'runEnd', 'stepResponse'])('accepts the kind %s', (kind) => { + expect(responseExpectationSchema.parse({ kind })).toEqual({ kind }); + }); + + it.each([{ kind: 'stream' }, { kind: 'none', extra: true }, {}, 'none'])( + 'rejects %j', + (value) => { + expect(responseExpectationSchema.safeParse(value).success).toBe(false); + }, + ); +}); diff --git a/packages/@n8n/engine/src/response-channel/create-response-emitter.ts b/packages/@n8n/engine/src/response-channel/create-response-emitter.ts new file mode 100644 index 00000000000..29452151fe8 --- /dev/null +++ b/packages/@n8n/engine/src/response-channel/create-response-emitter.ts @@ -0,0 +1,25 @@ +import type { ExecutionResponseSender } from './execution-response-sender'; +import { + noopResponseEmitter, + type ResponseEmitter, + type ResponseExpectation, +} from './execution-response.types'; +import type { JsonValue } from '../common'; + +/** + * Gives one step a response emitter that obeys the caller's expectation. + * + * Only a caller that expects a step response gets one. For any other + * expectation the emitter drops the response before the step builds it. + */ +export function createResponseEmitter( + sender: ExecutionResponseSender, + execution: { id: string; responseExpectation: ResponseExpectation }, +): ResponseEmitter { + if (execution.responseExpectation.kind !== 'stepResponse') return noopResponseEmitter; + + return { + send: (build: () => JsonValue) => + sender.send({ type: 'response', executionId: execution.id, payload: build() }), + }; +} diff --git a/packages/@n8n/engine/src/response-channel/execution-response-sender.ts b/packages/@n8n/engine/src/response-channel/execution-response-sender.ts index eea44d953ea..82da17041ad 100644 --- a/packages/@n8n/engine/src/response-channel/execution-response-sender.ts +++ b/packages/@n8n/engine/src/response-channel/execution-response-sender.ts @@ -1,21 +1,15 @@ import { createResultOk, type Result } from '@n8n/utils/result'; -import { - noopResponseEmitter, - type ExecutionResponse, - type ResponseEmitter, -} from './execution-response.types'; +import type { ExecutionResponse } from './execution-response.types'; /** Sends responses from an execution back to its caller. */ export interface ExecutionResponseSender { send(response: ExecutionResponse): Result; - emitterFor(executionId: string): ResponseEmitter; stop(): Promise; } /** Response sender for a host that discards execution responses. */ export const noopExecutionResponseSender: ExecutionResponseSender = Object.freeze({ send: () => createResultOk(undefined), - emitterFor: (_executionId: string) => noopResponseEmitter, stop: async () => {}, }); diff --git a/packages/@n8n/engine/src/response-channel/execution-response.schema.ts b/packages/@n8n/engine/src/response-channel/execution-response.schema.ts index e6540888d10..9ec9d0e8902 100644 --- a/packages/@n8n/engine/src/response-channel/execution-response.schema.ts +++ b/packages/@n8n/engine/src/response-channel/execution-response.schema.ts @@ -5,6 +5,19 @@ import { jsonValueSchema } from '../common'; // which import this module back. import { SETTLED_STEP_STATUSES } from '../execution/execution.types'; +/** The response kinds a caller can expect. */ +export const RESPONSE_EXPECTATION_KINDS = ['none', 'runEnd', 'stepResponse'] as const; + +/** + * What the caller of an execution waits for. The caller sets it at start, and + * the engine stores it with the execution. `strict`, so a misspelled key fails. + */ +export const responseExpectationSchema = z + .object({ + kind: z.enum(RESPONSE_EXPECTATION_KINDS), + }) + .strict(); + /** * The one definition of a response's shape. A frame can cross a process * boundary, so it is parsed against this before a handler sees it. diff --git a/packages/@n8n/engine/src/response-channel/execution-response.types.ts b/packages/@n8n/engine/src/response-channel/execution-response.types.ts index 3f072131ae1..905e22c158b 100644 --- a/packages/@n8n/engine/src/response-channel/execution-response.types.ts +++ b/packages/@n8n/engine/src/response-channel/execution-response.types.ts @@ -1,7 +1,12 @@ import { createResultOk, type Result } from '@n8n/utils/result'; import type { z } from 'zod'; -import type { executionResponseSchema } from './execution-response.schema'; +import type { JsonValue } from '../common'; + +import type { + executionResponseSchema, + responseExpectationSchema, +} from './execution-response.schema'; /** * A response that an execution sends to its caller. @@ -24,21 +29,38 @@ export type UndeliverableMessage = Extract; /** A message that a step produced a response */ export type ResponseMessage = Extract; +/** + * What kind of a response the caller expects: + * + * - `none`: nothing. The run sends no response. + * - `runEnd`: the `ended` message, with the outputs of the last step. + * - `stepResponse`: a response from a step. The `ended` message follows without + * outputs, so the caller can tell that no step responded. + */ +export type ResponseExpectation = z.infer; + +export type ResponseExpectationKind = ResponseExpectation['kind']; + /** An emitter that allows the step execution to produce a response */ export interface ResponseEmitter { - send(payload: unknown): Result; + /** + * Sends the payload that `build` returns. The emitter calls `build` only + * when the caller expects a step response, so a rejected response costs no + * serialization. An error that `build` throws is not caught. + */ + send(build: () => JsonValue): Result; } -/** For a step whose responses nobody wants. */ +/** For a step whose responses nobody wants. It never calls `build`. */ export const noopResponseEmitter: ResponseEmitter = Object.freeze({ send: () => createResultOk(undefined), }); diff --git a/packages/@n8n/engine/src/response-channel/index.ts b/packages/@n8n/engine/src/response-channel/index.ts index d08c814cbc4..b4d43414be3 100644 --- a/packages/@n8n/engine/src/response-channel/index.ts +++ b/packages/@n8n/engine/src/response-channel/index.ts @@ -1,11 +1,18 @@ +export { createResponseEmitter } from './create-response-emitter'; export { noopExecutionResponseSender } from './execution-response-sender'; export type { ExecutionResponseSender } from './execution-response-sender'; -export { executionResponseSchema } from './execution-response.schema'; +export { + executionResponseSchema, + RESPONSE_EXPECTATION_KINDS, + responseExpectationSchema, +} from './execution-response.schema'; export { noopResponseEmitter } from './execution-response.types'; export type { EndedMessage, ExecutionResponse, ResponseEmitter, + ResponseExpectation, + ResponseExpectationKind, ResponseMessage, UndeliverableMessage, } from './execution-response.types'; diff --git a/packages/@n8n/engine/src/server/__tests__/workflow-executions.integration.test.ts b/packages/@n8n/engine/src/server/__tests__/workflow-executions.integration.test.ts index d7a5a8f7a6f..053779447c6 100644 --- a/packages/@n8n/engine/src/server/__tests__/workflow-executions.integration.test.ts +++ b/packages/@n8n/engine/src/server/__tests__/workflow-executions.integration.test.ts @@ -303,6 +303,49 @@ describe('POST /api/workflow-executions (integration)', () => { expect(row.callerContext).toEqual(callerContext); }); + it('stores the response expectation with the row', async () => { + const body = startBody({ responseExpectation: { kind: 'stepResponse' } }); + + const response = await request(url) + .post('/api/workflow-executions') + .set(authHeader()) + .send(body); + + expect(response.status).toBe(201); + const row = await dataSource + .getRepository(WorkflowExecution) + .findOneOrFail({ where: { id: body.executionId } }); + expect(row.responseExpectation).toEqual({ kind: 'stepResponse' }); + }); + + it('stores the expectation none when the body has no response expectation', async () => { + const body = startBody(); + + const response = await request(url) + .post('/api/workflow-executions') + .set(authHeader()) + .send(body); + + expect(response.status).toBe(201); + const row = await dataSource + .getRepository(WorkflowExecution) + .findOneOrFail({ where: { id: body.executionId } }); + expect(row.responseExpectation).toEqual({ kind: 'none' }); + }); + + it.each([{ kind: 'stream' }, { kind: 'none', extra: true }, 'none'])( + 'rejects the response expectation %j with 400', + async (responseExpectation) => { + const response = await request(url) + .post('/api/workflow-executions') + .set(authHeader()) + .send(startBody({ responseExpectation })); + + expect(response.status).toBe(400); + expect((response.body as { error: string }).error).toBe('invalid_request'); + }, + ); + it('rejects a body without a caller context with 400', async () => { const response = await request(url) .post('/api/workflow-executions') diff --git a/packages/@n8n/engine/src/server/routes/workflow-executions.ts b/packages/@n8n/engine/src/server/routes/workflow-executions.ts index dd6e04d59aa..ee9a3bca8a3 100644 --- a/packages/@n8n/engine/src/server/routes/workflow-executions.ts +++ b/packages/@n8n/engine/src/server/routes/workflow-executions.ts @@ -9,6 +9,7 @@ import { import { AdmittanceRejectedError } from '../../admittance'; import { jsonObjectSchema, jsonValueSchema, UnimplementedError } from '../../common'; import { GraphValidationError, MAX_SLOT_INDEX } from '../../graph'; +import { responseExpectationSchema } from '../../response-channel'; import type { EngineServerDeps } from '../create-engine-server'; import { fail } from '../error-response'; @@ -59,6 +60,8 @@ const StartExecutionBody = z.object({ hostMode: z.string().min(1), }) .strict(), + /** What kind of a response the caller expects. */ + responseExpectation: responseExpectationSchema.optional(), /** The caller mints the id. v7 only, so ids stay time-ordered. */ executionId: z.string().regex(UUID_V7_PATTERN), }); diff --git a/packages/workflow/src/errors/base/base.error.ts b/packages/@n8n/errors/src/base.error.ts similarity index 89% rename from packages/workflow/src/errors/base/base.error.ts rename to packages/@n8n/errors/src/base.error.ts index f460688bf46..6fbe3593880 100644 --- a/packages/workflow/src/errors/base/base.error.ts +++ b/packages/@n8n/errors/src/base.error.ts @@ -1,7 +1,7 @@ import type { Event } from '@sentry/node'; import callsites from 'callsites'; -import type { ErrorTags, ErrorLevel, ReportingOptions } from '@n8n/errors'; +import type { ErrorLevel, ErrorTags, ReportingOptions } from './types'; export type BaseErrorOptions = { description?: string | undefined | null } & ErrorOptions & ReportingOptions; @@ -50,7 +50,7 @@ export abstract class BaseError extends Error { try { const filePath = callsites()[2].getFileName() ?? ''; - const match = /packages\/([^\/]+)\//.exec(filePath)?.[1]; + const match = /packages\/([^/]+)\//.exec(filePath)?.[1]; if (match) this.tags.packageName = match; } catch {} diff --git a/packages/@n8n/errors/src/index.ts b/packages/@n8n/errors/src/index.ts index 2e38b3d0bdc..bda29ba2e2e 100644 --- a/packages/@n8n/errors/src/index.ts +++ b/packages/@n8n/errors/src/index.ts @@ -1,3 +1,24 @@ export { ApplicationError } from './application.error'; +export { BaseError, type BaseErrorOptions } from './base.error'; export { IsolateError } from './isolate.error'; +export { OperationalError, type OperationalErrorOptions } from './operational.error'; +export { AuthError } from './response-errors/auth.error'; +export { BadRequestError } from './response-errors/bad-request.error'; +export { ConflictError } from './response-errors/conflict.error'; +export { ContentTooLargeError } from './response-errors/content-too-large.error'; +export { ForbiddenError } from './response-errors/forbidden.error'; +export { InternalServerError } from './response-errors/internal-server.error'; +export { LockedError } from './response-errors/locked.error'; +export { NotFoundError } from './response-errors/not-found.error'; +export { NotImplementedError } from './response-errors/not-implemented.error'; +export { PaymentRequiredError } from './response-errors/payment-required.error'; +export { ResponseError } from './response-errors/response.error'; +export { ScopeForbiddenError } from './response-errors/scope-forbidden.error'; +export { ServiceUnavailableError } from './response-errors/service-unavailable.error'; +export { TooManyRequestsError } from './response-errors/too-many-requests.error'; +export { UnauthenticatedError } from './response-errors/unauthenticated.error'; +export { UnprocessableRequestError } from './response-errors/unprocessable.error'; +export { UnsupportedMediaTypeError } from './response-errors/unsupported-media-type.error'; +export { UnexpectedError, type UnexpectedErrorOptions } from './unexpected.error'; +export { UserError, type UserErrorOptions } from './user.error'; export type * from './types'; diff --git a/packages/workflow/src/errors/base/operational.error.ts b/packages/@n8n/errors/src/operational.error.ts similarity index 85% rename from packages/workflow/src/errors/base/operational.error.ts rename to packages/@n8n/errors/src/operational.error.ts index c67848dca82..307155f43fb 100644 --- a/packages/workflow/src/errors/base/operational.error.ts +++ b/packages/@n8n/errors/src/operational.error.ts @@ -1,5 +1,4 @@ -import type { BaseErrorOptions } from './base.error'; -import { BaseError } from './base.error'; +import { BaseError, type BaseErrorOptions } from './base.error'; export type OperationalErrorOptions = Omit & { level?: 'info' | 'warning' | 'error'; diff --git a/packages/cli/src/errors/response-errors/auth.error.ts b/packages/@n8n/errors/src/response-errors/auth.error.ts similarity index 70% rename from packages/cli/src/errors/response-errors/auth.error.ts rename to packages/@n8n/errors/src/response-errors/auth.error.ts index cb3441211ba..4f3af6042f9 100644 --- a/packages/cli/src/errors/response-errors/auth.error.ts +++ b/packages/@n8n/errors/src/response-errors/auth.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from './response.error'; export class AuthError extends ResponseError { constructor(message: string, errorCode?: number) { diff --git a/packages/cli/src/errors/response-errors/bad-request.error.ts b/packages/@n8n/errors/src/response-errors/bad-request.error.ts similarity index 84% rename from packages/cli/src/errors/response-errors/bad-request.error.ts rename to packages/@n8n/errors/src/response-errors/bad-request.error.ts index cdb7973cca4..40b444a7193 100644 --- a/packages/cli/src/errors/response-errors/bad-request.error.ts +++ b/packages/@n8n/errors/src/response-errors/bad-request.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from './response.error'; export class BadRequestError extends ResponseError { constructor(message: string, errorCode?: number, hint?: string, cause?: unknown) { diff --git a/packages/cli/src/errors/response-errors/conflict.error.ts b/packages/@n8n/errors/src/response-errors/conflict.error.ts similarity index 78% rename from packages/cli/src/errors/response-errors/conflict.error.ts rename to packages/@n8n/errors/src/response-errors/conflict.error.ts index 3dca76a65c5..821010e19e1 100644 --- a/packages/cli/src/errors/response-errors/conflict.error.ts +++ b/packages/@n8n/errors/src/response-errors/conflict.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from './response.error'; export class ConflictError extends ResponseError { constructor( diff --git a/packages/cli/src/errors/response-errors/content-too-large.error.ts b/packages/@n8n/errors/src/response-errors/content-too-large.error.ts similarity index 74% rename from packages/cli/src/errors/response-errors/content-too-large.error.ts rename to packages/@n8n/errors/src/response-errors/content-too-large.error.ts index c56aed1129e..32a6b9a3818 100644 --- a/packages/cli/src/errors/response-errors/content-too-large.error.ts +++ b/packages/@n8n/errors/src/response-errors/content-too-large.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from './response.error'; export class ContentTooLargeError extends ResponseError { constructor(message: string, hint: string | undefined = undefined) { diff --git a/packages/cli/src/errors/response-errors/forbidden.error.ts b/packages/@n8n/errors/src/response-errors/forbidden.error.ts similarity index 71% rename from packages/cli/src/errors/response-errors/forbidden.error.ts rename to packages/@n8n/errors/src/response-errors/forbidden.error.ts index 4856f7cd472..8484b62dbbc 100644 --- a/packages/cli/src/errors/response-errors/forbidden.error.ts +++ b/packages/@n8n/errors/src/response-errors/forbidden.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from './response.error'; export class ForbiddenError extends ResponseError { constructor(message = 'Forbidden', hint?: string) { diff --git a/packages/cli/src/errors/response-errors/internal-server.error.ts b/packages/@n8n/errors/src/response-errors/internal-server.error.ts similarity index 76% rename from packages/cli/src/errors/response-errors/internal-server.error.ts rename to packages/@n8n/errors/src/response-errors/internal-server.error.ts index 2ebe5ca67c1..b702535610b 100644 --- a/packages/cli/src/errors/response-errors/internal-server.error.ts +++ b/packages/@n8n/errors/src/response-errors/internal-server.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from './response.error'; export class InternalServerError extends ResponseError { constructor(message?: string, cause?: unknown) { diff --git a/packages/cli/src/errors/response-errors/locked.error.ts b/packages/@n8n/errors/src/response-errors/locked.error.ts similarity index 72% rename from packages/cli/src/errors/response-errors/locked.error.ts rename to packages/@n8n/errors/src/response-errors/locked.error.ts index d8e43a7f6fa..3476f436f28 100644 --- a/packages/cli/src/errors/response-errors/locked.error.ts +++ b/packages/@n8n/errors/src/response-errors/locked.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from './response.error'; export class LockedError extends ResponseError { constructor(message: string, hint: string | undefined = undefined) { diff --git a/packages/cli/src/errors/response-errors/not-found.error.ts b/packages/@n8n/errors/src/response-errors/not-found.error.ts similarity index 86% rename from packages/cli/src/errors/response-errors/not-found.error.ts rename to packages/@n8n/errors/src/response-errors/not-found.error.ts index 6bbcea9581d..36bac6214c9 100644 --- a/packages/cli/src/errors/response-errors/not-found.error.ts +++ b/packages/@n8n/errors/src/response-errors/not-found.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from './response.error'; export class NotFoundError extends ResponseError { static isDefinedAndNotNull( diff --git a/packages/cli/src/errors/response-errors/not-implemented.error.ts b/packages/@n8n/errors/src/response-errors/not-implemented.error.ts similarity index 73% rename from packages/cli/src/errors/response-errors/not-implemented.error.ts rename to packages/@n8n/errors/src/response-errors/not-implemented.error.ts index f6c66391ffa..eacc88b5f6e 100644 --- a/packages/cli/src/errors/response-errors/not-implemented.error.ts +++ b/packages/@n8n/errors/src/response-errors/not-implemented.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from './response.error'; export class NotImplementedError extends ResponseError { constructor(message: string, hint: string | undefined = undefined) { diff --git a/packages/cli/src/errors/response-errors/payment-required.error.ts b/packages/@n8n/errors/src/response-errors/payment-required.error.ts similarity index 72% rename from packages/cli/src/errors/response-errors/payment-required.error.ts rename to packages/@n8n/errors/src/response-errors/payment-required.error.ts index ed9723520b2..e57d894bf2d 100644 --- a/packages/cli/src/errors/response-errors/payment-required.error.ts +++ b/packages/@n8n/errors/src/response-errors/payment-required.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from './response.error'; export class PaymentRequiredError extends ResponseError { constructor(message = 'Payment required', hint?: string) { diff --git a/packages/cli/src/errors/response-errors/abstract/response.error.ts b/packages/@n8n/errors/src/response-errors/response.error.ts similarity index 96% rename from packages/cli/src/errors/response-errors/abstract/response.error.ts rename to packages/@n8n/errors/src/response-errors/response.error.ts index 4a8f604ce6f..68fdb406c5b 100644 --- a/packages/cli/src/errors/response-errors/abstract/response.error.ts +++ b/packages/@n8n/errors/src/response-errors/response.error.ts @@ -1,4 +1,4 @@ -import { BaseError } from 'n8n-workflow'; +import { BaseError } from '../base.error'; /** * Special Error which allows to return also an error code and http status code diff --git a/packages/cli/src/errors/response-errors/scope-forbidden.error.ts b/packages/@n8n/errors/src/response-errors/scope-forbidden.error.ts similarity index 81% rename from packages/cli/src/errors/response-errors/scope-forbidden.error.ts rename to packages/@n8n/errors/src/response-errors/scope-forbidden.error.ts index fa12dc6019d..ca9d9dbb226 100644 --- a/packages/cli/src/errors/response-errors/scope-forbidden.error.ts +++ b/packages/@n8n/errors/src/response-errors/scope-forbidden.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from './response.error'; export class ScopeForbiddenError extends ResponseError { constructor( diff --git a/packages/cli/src/errors/response-errors/service-unavailable.error.ts b/packages/@n8n/errors/src/response-errors/service-unavailable.error.ts similarity index 71% rename from packages/cli/src/errors/response-errors/service-unavailable.error.ts rename to packages/@n8n/errors/src/response-errors/service-unavailable.error.ts index 7736e85f5f2..e5ff19d7f20 100644 --- a/packages/cli/src/errors/response-errors/service-unavailable.error.ts +++ b/packages/@n8n/errors/src/response-errors/service-unavailable.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from './response.error'; export class ServiceUnavailableError extends ResponseError { constructor(message: string, errorCode = 503) { diff --git a/packages/cli/src/errors/response-errors/too-many-requests.error.ts b/packages/@n8n/errors/src/response-errors/too-many-requests.error.ts similarity index 74% rename from packages/cli/src/errors/response-errors/too-many-requests.error.ts rename to packages/@n8n/errors/src/response-errors/too-many-requests.error.ts index d280dd399cb..6d66b0e5927 100644 --- a/packages/cli/src/errors/response-errors/too-many-requests.error.ts +++ b/packages/@n8n/errors/src/response-errors/too-many-requests.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from './response.error'; export class TooManyRequestsError extends ResponseError { constructor(message: string, hint: string | undefined = undefined) { diff --git a/packages/cli/src/errors/response-errors/unauthenticated.error.ts b/packages/@n8n/errors/src/response-errors/unauthenticated.error.ts similarity index 72% rename from packages/cli/src/errors/response-errors/unauthenticated.error.ts rename to packages/@n8n/errors/src/response-errors/unauthenticated.error.ts index 7f1409da7fa..fa6b921e37c 100644 --- a/packages/cli/src/errors/response-errors/unauthenticated.error.ts +++ b/packages/@n8n/errors/src/response-errors/unauthenticated.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from './response.error'; export class UnauthenticatedError extends ResponseError { constructor(message = 'Unauthenticated', hint?: string) { diff --git a/packages/cli/src/errors/response-errors/unprocessable.error.ts b/packages/@n8n/errors/src/response-errors/unprocessable.error.ts similarity index 79% rename from packages/cli/src/errors/response-errors/unprocessable.error.ts rename to packages/@n8n/errors/src/response-errors/unprocessable.error.ts index c5e96d8893a..f8c07c0422d 100644 --- a/packages/cli/src/errors/response-errors/unprocessable.error.ts +++ b/packages/@n8n/errors/src/response-errors/unprocessable.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from './response.error'; export class UnprocessableRequestError extends ResponseError { constructor( diff --git a/packages/cli/src/errors/response-errors/unsupported-media-type.error.ts b/packages/@n8n/errors/src/response-errors/unsupported-media-type.error.ts similarity index 74% rename from packages/cli/src/errors/response-errors/unsupported-media-type.error.ts rename to packages/@n8n/errors/src/response-errors/unsupported-media-type.error.ts index cd20a507997..7c283f229b4 100644 --- a/packages/cli/src/errors/response-errors/unsupported-media-type.error.ts +++ b/packages/@n8n/errors/src/response-errors/unsupported-media-type.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from './response.error'; export class UnsupportedMediaTypeError extends ResponseError { constructor(message: string, hint: string | undefined = undefined) { diff --git a/packages/workflow/src/errors/base/unexpected.error.ts b/packages/@n8n/errors/src/unexpected.error.ts similarity index 84% rename from packages/workflow/src/errors/base/unexpected.error.ts rename to packages/@n8n/errors/src/unexpected.error.ts index a2045690524..8f88ba14896 100644 --- a/packages/workflow/src/errors/base/unexpected.error.ts +++ b/packages/@n8n/errors/src/unexpected.error.ts @@ -1,5 +1,4 @@ -import type { BaseErrorOptions } from './base.error'; -import { BaseError } from './base.error'; +import { BaseError, type BaseErrorOptions } from './base.error'; export type UnexpectedErrorOptions = Omit & { level?: 'error' | 'fatal'; diff --git a/packages/workflow/src/errors/base/user.error.ts b/packages/@n8n/errors/src/user.error.ts similarity index 86% rename from packages/workflow/src/errors/base/user.error.ts rename to packages/@n8n/errors/src/user.error.ts index 725f41694c5..cd2ea48aa0d 100644 --- a/packages/workflow/src/errors/base/user.error.ts +++ b/packages/@n8n/errors/src/user.error.ts @@ -1,5 +1,4 @@ -import type { BaseErrorOptions } from './base.error'; -import { BaseError } from './base.error'; +import { BaseError, type BaseErrorOptions } from './base.error'; export type UserErrorOptions = Omit & { level?: 'info' | 'warning'; diff --git a/packages/@n8n/eslint-plugin-community-nodes/package.json b/packages/@n8n/eslint-plugin-community-nodes/package.json index 18d05a93f7f..caf705c0d28 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/package.json +++ b/packages/@n8n/eslint-plugin-community-nodes/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/eslint-plugin-community-nodes", "type": "module", - "version": "0.34.0", + "version": "0.35.0", "main": "./dist/plugin.js", "types": "./dist/plugin.d.ts", "exports": { diff --git a/packages/@n8n/expression-runtime/package.json b/packages/@n8n/expression-runtime/package.json index fd7b6894330..bb643aa2cd2 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.32.0", + "version": "0.33.0", "description": "Secure, isolated expression evaluation runtime for n8n", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/packages/@n8n/imap/package.json b/packages/@n8n/imap/package.json index 25085573044..84b327804bd 100644 --- a/packages/@n8n/imap/package.json +++ b/packages/@n8n/imap/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/imap", - "version": "0.27.0", + "version": "0.28.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/inbound-auth/eslint.config.mjs b/packages/@n8n/inbound-auth/eslint.config.mjs new file mode 100644 index 00000000000..beff10615e5 --- /dev/null +++ b/packages/@n8n/inbound-auth/eslint.config.mjs @@ -0,0 +1,4 @@ +import { defineConfig } from 'eslint/config'; +import { baseConfig } from '@n8n/eslint-config/base'; + +export default defineConfig(baseConfig); diff --git a/packages/@n8n/inbound-auth/package.json b/packages/@n8n/inbound-auth/package.json new file mode 100644 index 00000000000..e0a6df71033 --- /dev/null +++ b/packages/@n8n/inbound-auth/package.json @@ -0,0 +1,44 @@ +{ + "name": "@n8n/inbound-auth", + "version": "0.1.0", + "scripts": { + "clean": "rimraf dist .turbo", + "dev": "pnpm watch", + "typecheck": "tsc --noEmit", + "build": "tsc -p tsconfig.build.json", + "build:unchecked": "tsc -p tsconfig.build.json --noCheck", + "format": "biome format --write .", + "format:check": "biome ci .", + "lint": "eslint . --quiet", + "lint:fix": "eslint . --fix", + "watch": "tsc -p tsconfig.build.json --watch", + "test": "vitest run", + "test:unit": "vitest run", + "test:dev": "vitest --silent=false" + }, + "main": "dist/index.js", + "module": "src/index.ts", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*" + ], + "dependencies": { + "@n8n/permissions": "workspace:*", + "n8n-workflow": "workspace:*" + }, + "peerDependencies": { + "zod": "catalog:" + }, + "devDependencies": { + "@n8n/eslint-config": "workspace:*", + "@n8n/typescript-config": "workspace:*", + "@n8n/vitest-config": "workspace:*", + "@vitest/coverage-v8": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:typescript", + "vite": "catalog:", + "vitest": "catalog:", + "zod": "catalog:" + }, + "license": "LicenseRef-n8n-sustainable-use" +} diff --git a/packages/@n8n/inbound-auth/src/__tests__/fixtures/trusted-source-config.v1.ts b/packages/@n8n/inbound-auth/src/__tests__/fixtures/trusted-source-config.v1.ts new file mode 100644 index 00000000000..803a271eea8 --- /dev/null +++ b/packages/@n8n/inbound-auth/src/__tests__/fixtures/trusted-source-config.v1.ts @@ -0,0 +1,30 @@ +import type { TrustedSourceConfigV1 } from '../../trusted-source-config'; + +/** A fully-specified v1 document, used by the migration-chain test harness. */ +export const trustedSourceConfigV1Fixture = { + version: 1, + authentication: { + type: 'oauth2', + discovery: { mode: 'auto' }, + keys: { kind: 'jwks-uri' }, + verification: { mode: 'jwt' }, + algorithms: ['RS256', 'ES256'], + maxTokenLifetimeSeconds: 3600, + clockSkewSeconds: 30, + }, + surfaces: { 'public-api': { audiences: ['n8n'] } }, + identity: { + subject: 'binding', + claimMapping: { + subject: 'sub', + email: 'email', + emailVerified: 'email_verified', + displayName: 'name', + clientId: 'client_id', + scopes: 'scope', + }, + linkByEmail: 'verified-only', + provision: { human: 'off' }, + roleMapping: { mode: 'off', instanceRoleRules: [], projectRoleRules: [] }, + }, +} satisfies TrustedSourceConfigV1; diff --git a/packages/@n8n/inbound-auth/src/__tests__/trusted-source-config.test.ts b/packages/@n8n/inbound-auth/src/__tests__/trusted-source-config.test.ts new file mode 100644 index 00000000000..007cdda0a57 --- /dev/null +++ b/packages/@n8n/inbound-auth/src/__tests__/trusted-source-config.test.ts @@ -0,0 +1,376 @@ +import { UnexpectedError } from 'n8n-workflow'; + +import { + configMigrations, + migrateToLatest, + TrustedSourceConfigSchema, + trustedSourceConfigSchemaFor, + TrustedSourceSecretsSchema, + type TrustedSourceConfig, +} from '../trusted-source-config'; +import { trustedSourceConfigV1Fixture } from './fixtures/trusted-source-config.v1'; + +const minimalDocument = { + version: 1, + authentication: { type: 'oauth2' }, + surfaces: {}, +}; + +const fullyDefaultedDocument = { + version: 1, + authentication: { + type: 'oauth2', + discovery: { mode: 'auto' }, + keys: { kind: 'jwks-uri' }, + verification: { mode: 'jwt' }, + algorithms: [ + 'RS256', + 'RS384', + 'RS512', + 'ES256', + 'ES384', + 'ES512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA', + ], + maxTokenLifetimeSeconds: 86400, + clockSkewSeconds: 60, + }, + surfaces: {}, + identity: { + subject: 'binding', + claimMapping: { + subject: 'sub', + email: 'email', + emailVerified: 'email_verified', + displayName: 'name', + clientId: 'client_id', + scopes: 'scope', + }, + linkByEmail: 'verified-only', + provision: { human: 'off' }, + roleMapping: { mode: 'off', instanceRoleRules: [], projectRoleRules: [] }, + }, +}; + +const rule = { id: 'r1', expression: '={{ true }}', role: 'global:member', enabled: true }; + +const withIdentity = (identity: Record) => ({ + ...fullyDefaultedDocument, + identity: { ...fullyDefaultedDocument.identity, ...identity }, +}); +const withAuthentication = (authentication: Record) => ({ + ...fullyDefaultedDocument, + authentication: { ...fullyDefaultedDocument.authentication, ...authentication }, +}); +const withRoleMapping = (roleMapping: Record) => + withIdentity({ roleMapping: { ...fullyDefaultedDocument.identity.roleMapping, ...roleMapping } }); + +const issuesOf = (result: ReturnType) => + result.success ? [] : result.error.issues; + +describe('TrustedSourceConfigSchema defaults', () => { + test('a minimal document with only version, authentication type and surfaces gets every default applied', () => { + expect(TrustedSourceConfigSchema.parse(minimalDocument)).toEqual(fullyDefaultedDocument); + }); +}); + +describe('TrustedSourceConfigSchema shape', () => { + test.each([ + { name: 'an unknown version', document: { ...minimalDocument, version: 2 }, path: ['version'] }, + { + name: 'a missing authentication block', + document: { version: 1, surfaces: {} }, + path: ['authentication'], + }, + { + name: 'an unknown authentication type', + document: { ...minimalDocument, authentication: { type: 'saml' } }, + path: ['authentication', 'type'], + }, + { + name: 'a surface key that is not a registered surface id', + document: { ...fullyDefaultedDocument, surfaces: { 'not-a-real-surface': {} } }, + path: ['surfaces', 'not-a-real-surface'], + }, + { + name: 'an empty audiences list', + document: { ...fullyDefaultedDocument, surfaces: { 'public-api': { audiences: [] } } }, + path: ['surfaces', 'public-api', 'audiences'], + }, + { + name: 'a registered client without clientId', + document: withAuthentication({ client: { kind: 'registered', clientId: '' } }), + path: ['authentication', 'client', 'clientId'], + }, + { + name: 'an empty algorithms list', + document: withAuthentication({ algorithms: [] }), + path: ['authentication', 'algorithms'], + }, + { + name: 'a token lifetime above one day', + document: withAuthentication({ maxTokenLifetimeSeconds: 86401 }), + path: ['authentication', 'maxTokenLifetimeSeconds'], + }, + { + name: 'a clock skew above five minutes', + document: withAuthentication({ clockSkewSeconds: 301 }), + path: ['authentication', 'clockSkewSeconds'], + }, + ])('rejects $name', ({ document, path }) => { + const result = TrustedSourceConfigSchema.safeParse(document); + + expect(result.success).toBe(false); + expect(issuesOf(result)).toContainEqual(expect.objectContaining({ path })); + }); + + test('a registered client carries no secret in the document', () => { + const document = withAuthentication({ + client: { kind: 'registered', clientId: 'n8n', clientSecret: 'leaked' }, + }); + + const parsed = TrustedSourceConfigSchema.parse(document); + + expect(parsed.authentication.client).toEqual({ kind: 'registered', clientId: 'n8n' }); + }); +}); + +describe('TrustedSourceConfigSchema refinement rules', () => { + test.each([ + { + rule: 'jit provisioning requires a role-mapping mode', + document: withIdentity({ provision: { human: 'jit' } }), + path: ['identity', 'roleMapping', 'mode'], + }, + { + rule: 'a role-mapping mode requires a fallback role', + document: withRoleMapping({ mode: 'on-provision' }), + path: ['identity', 'roleMapping', 'fallbackInstanceRole'], + }, + { + rule: 'manual discovery with jwks-uri keys requires jwksUri or metadataUrl', + document: withAuthentication({ discovery: { mode: 'manual' } }), + path: ['authentication', 'discovery'], + }, + { + rule: 'provisioning never assigns the instance owner role', + document: withRoleMapping({ mode: 'on-provision', fallbackInstanceRole: 'global:owner' }), + path: ['identity', 'roleMapping', 'fallbackInstanceRole'], + }, + { + rule: 'provisioning never assigns the instance owner role', + document: withRoleMapping({ instanceRoleRules: [{ ...rule, role: 'global:owner' }] }), + path: ['identity', 'roleMapping', 'instanceRoleRules', 0, 'role'], + }, + { + rule: 'an instance role rule must not set projectId', + document: withRoleMapping({ instanceRoleRules: [{ ...rule, projectId: 'p1' }] }), + path: ['identity', 'roleMapping', 'instanceRoleRules', 0, 'projectId'], + }, + { + rule: 'a project role rule requires projectId', + document: withRoleMapping({ projectRoleRules: [{ ...rule, role: 'project:editor' }] }), + path: ['identity', 'roleMapping', 'projectRoleRules', 0, 'projectId'], + }, + ])('fails validation and names the rule: $rule', ({ rule: message, document, path }) => { + const result = TrustedSourceConfigSchema.safeParse(document); + + expect(result.success).toBe(false); + expect(issuesOf(result)).toContainEqual(expect.objectContaining({ path, message })); + }); + + test.each([ + { + name: 'a role-mapping mode with a fallback role', + document: withRoleMapping({ mode: 'on-provision', fallbackInstanceRole: 'global:member' }), + }, + { + name: 'jit provisioning with a role-mapping mode and a fallback role', + document: withIdentity({ + provision: { human: 'jit' }, + roleMapping: { + mode: 'continuous', + fallbackInstanceRole: 'global:member', + instanceRoleRules: [rule], + projectRoleRules: [{ ...rule, id: 'r2', role: 'project:editor', projectId: 'p1' }], + }, + }), + }, + { + name: 'manual discovery with a metadata URL', + document: withAuthentication({ + discovery: { mode: 'manual', metadataUrl: 'https://idp.example.com/.well-known' }, + }), + }, + { + name: 'manual discovery without endpoints when keys come from the local keystore', + document: withAuthentication({ + discovery: { mode: 'manual' }, + keys: { kind: 'local-keystore' }, + }), + }, + ])('accepts $name', ({ document }) => { + expect(TrustedSourceConfigSchema.safeParse(document).success).toBe(true); + }); +}); + +describe('managedBy-gated fields', () => { + const systemOnlyDocuments = [ + { + field: 'identity.subject: n8n-user-id', + document: withIdentity({ subject: 'n8n-user-id' }), + path: ['identity', 'subject'], + }, + { + field: 'authentication.keys.kind: local-keystore', + document: withAuthentication({ keys: { kind: 'local-keystore' } }), + path: ['authentication', 'keys', 'kind'], + }, + { + field: 'authentication.client.kind: virtual', + document: withAuthentication({ client: { kind: 'virtual' } }), + path: ['authentication', 'client', 'kind'], + }, + ]; + + test.each(systemOnlyDocuments)('$field is accepted for a system row', ({ document }) => { + expect(trustedSourceConfigSchemaFor('system').safeParse(document).success).toBe(true); + }); + + test.each(systemOnlyDocuments)('$field is refused for an admin row', ({ document, path }) => { + const result = trustedSourceConfigSchemaFor('admin').safeParse(document); + + expect(result.success).toBe(false); + expect(issuesOf(result)).toContainEqual( + expect.objectContaining({ + path, + message: expect.stringContaining('only allowed on system-managed sources'), + }), + ); + }); + + test('an admin row accepts a document without system-only fields', () => { + const document = withAuthentication({ client: { kind: 'registered', clientId: 'n8n' } }); + + expect(trustedSourceConfigSchemaFor('admin').safeParse(document).success).toBe(true); + }); +}); + +describe('claim mapping sources', () => { + test('accepts an expression for a derived attribute', () => { + const document = withIdentity({ + claimMapping: { + ...fullyDefaultedDocument.identity.claimMapping, + displayName: '={{ $claims.given_name }} {{ $claims.family_name }}', + }, + }); + + expect(TrustedSourceConfigSchema.safeParse(document).success).toBe(true); + }); + + test('refuses an expression for the subject', () => { + const document = withIdentity({ + claimMapping: { + ...fullyDefaultedDocument.identity.claimMapping, + subject: '={{ $claims.oid }}', + }, + }); + + const result = TrustedSourceConfigSchema.safeParse(document); + + expect(result.success).toBe(false); + expect(issuesOf(result)).toContainEqual( + expect.objectContaining({ path: ['identity', 'claimMapping', 'subject'] }), + ); + }); +}); + +describe('algorithm allow-list inside authentication.algorithms', () => { + test.each([ + { name: 'none', algorithms: ['none'] }, + { name: 'a single HMAC algorithm', algorithms: ['HS256'] }, + { name: 'one valid and one HMAC algorithm', algorithms: ['RS256', 'HS512'] }, + ])('rejects $name', ({ algorithms }) => { + expect(TrustedSourceConfigSchema.safeParse(withAuthentication({ algorithms })).success).toBe( + false, + ); + }); +}); + +describe('manual discovery endpoints must be https', () => { + test.each(['metadataUrl', 'jwksUri', 'authorizationEndpoint', 'tokenEndpoint'])( + 'rejects a plain http %s', + (field) => { + const document = withAuthentication({ + discovery: { + mode: 'manual', + jwksUri: 'https://idp.example.com/jwks.json', + [field]: 'http://idp.example.com/x', + }, + }); + + const result = TrustedSourceConfigSchema.safeParse(document); + + expect(result.success).toBe(false); + expect(issuesOf(result)).toContainEqual( + expect.objectContaining({ path: ['authentication', 'discovery', field] }), + ); + }, + ); +}); + +describe('TrustedSourceSecretsSchema', () => { + test('accepts a client secret and refuses unknown keys', () => { + expect( + TrustedSourceSecretsSchema.safeParse({ version: 1, clientSecret: 's3cret' }).success, + ).toBe(true); + expect(TrustedSourceSecretsSchema.safeParse({ version: 1, clientId: 'n8n' }).success).toBe( + false, + ); + }); +}); + +describe('migrateToLatest', () => { + const asConfig = (document: Record) => + document as unknown as TrustedSourceConfig; + + test('returns a v1 document unchanged', () => { + const parsed = TrustedSourceConfigSchema.parse(trustedSourceConfigV1Fixture); + + expect(migrateToLatest(parsed)).toEqual(trustedSourceConfigV1Fixture); + }); + + test('throws on a version without a migration', () => { + const v0 = asConfig({ ...trustedSourceConfigV1Fixture, version: 0 }); + + expect(() => migrateToLatest(v0, {})).toThrow(UnexpectedError); + expect(() => migrateToLatest(v0, {})).toThrow( + 'No migration from trusted source config version 0', + ); + }); + + test('throws when a migration step does not advance the version', () => { + const v0 = asConfig({ ...trustedSourceConfigV1Fixture, version: 0 }); + const stuck = { 0: (config: TrustedSourceConfig) => config }; + + expect(() => migrateToLatest(v0, stuck)).toThrow(UnexpectedError); + expect(() => migrateToLatest(v0, stuck)).toThrow('did not advance the version'); + }); + + test('the shipped migration map is empty while v1 is the only version', () => { + expect(configMigrations).toEqual({}); + }); + + test.each([{ name: 'v1', fixture: trustedSourceConfigV1Fixture }])( + 'migrates the $name fixture to a document the latest schema accepts', + ({ fixture }) => { + const parsed = TrustedSourceConfigSchema.parse(fixture); + const migrated = migrateToLatest(parsed); + + expect(TrustedSourceConfigSchema.safeParse(migrated).success).toBe(true); + }, + ); +}); diff --git a/packages/@n8n/inbound-auth/src/index.ts b/packages/@n8n/inbound-auth/src/index.ts new file mode 100644 index 00000000000..072fc3d9cfc --- /dev/null +++ b/packages/@n8n/inbound-auth/src/index.ts @@ -0,0 +1 @@ +export * from './trusted-source-config'; diff --git a/packages/@n8n/inbound-auth/src/trusted-source-config.ts b/packages/@n8n/inbound-auth/src/trusted-source-config.ts new file mode 100644 index 00000000000..f16262789d6 --- /dev/null +++ b/packages/@n8n/inbound-auth/src/trusted-source-config.ts @@ -0,0 +1,320 @@ +import { GLOBAL_OWNER_ROLE_SLUG } from '@n8n/permissions'; +import { UnexpectedError } from 'n8n-workflow'; +import { z } from 'zod'; + +/** + * Asymmetric-only JWT algorithms accepted for trusted sources. + * Symmetric (HMAC) and 'none' are excluded by design. + */ +export const JwtAlgorithmSchema = z.enum([ + 'RS256', + 'RS384', + 'RS512', + 'ES256', + 'ES384', + 'ES512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA', +]); +export type JwtAlgorithm = z.infer; + +/** A single rule mapping claims to an instance or project role. Bounds match the public API DTO. */ +export const RoleMappingRuleSchema = z.object({ + id: z.string().min(1), + expression: z.string().min(1), + role: z.string().min(1).max(128), + /** undefined = instance role rule; set = project role rule */ + projectId: z.string().min(1).optional(), + enabled: z.boolean(), + description: z.string().optional(), +}); +export type RoleMappingRule = z.infer; + +export const SurfaceIdSchema = z.enum(['public-api', 'instance-mcp', 'trigger']); +export type SurfaceId = z.infer; + +export const ManagedBySchema = z.enum(['system', 'admin']); +export type ManagedBy = z.infer; + +// 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' }); + +export const Oauth2AuthenticationSchema = z.object({ + type: z.literal('oauth2'), + discovery: z + .discriminatedUnion('mode', [ + z.object({ mode: z.literal('auto') }), + z.object({ + mode: z.literal('manual'), + metadataUrl: HttpsUrl.optional(), + jwksUri: HttpsUrl.optional(), + authorizationEndpoint: HttpsUrl.optional(), + tokenEndpoint: HttpsUrl.optional(), + }), + ]) + .default({ mode: 'auto' }), + keys: z + .discriminatedUnion('kind', [ + z.object({ kind: z.literal('jwks-uri') }), + z.object({ kind: z.literal('local-keystore') }), + ]) + .default({ kind: 'jwks-uri' }), + // A union with one member, so introspection can be added later without a version bump. + verification: z + .discriminatedUnion('mode', [z.object({ mode: z.literal('jwt') })]) + .default({ mode: 'jwt' }), + algorithms: z + .array(JwtAlgorithmSchema) + .min(1) + .default([...JwtAlgorithmSchema.options]), + // Upper bounds keep both windows from disabling `exp`/`nbf` checks. Relaxing later is additive. + maxTokenLifetimeSeconds: z.number().int().positive().max(86400).default(86400), + clockSkewSeconds: z.number().int().nonnegative().max(300).default(60), + // The client secret is not part of the document: see `TrustedSourceSecretsSchema`. + client: z + .discriminatedUnion('kind', [ + z.object({ + kind: z.literal('registered'), + clientId: z.string().min(1), + scopes: z.array(z.string()).optional(), + }), + z.object({ kind: z.literal('virtual') }), + ]) + .optional(), +}); +export type Oauth2Authentication = z.infer; + +/** Discriminated on `type`. A new authentication method is a new member here, not a new config version. */ +export const AuthenticationSchema = z.discriminatedUnion('type', [Oauth2AuthenticationSchema]); +export type TrustedSourceAuthentication = z.infer; + +/** + * `audiences` absent means the surface's canonical audience is required. It never means the `aud` + * check is skipped, and an empty list is refused so it cannot be read that way either. + */ +export const SurfaceSettingsSchema = z.object({ + audiences: z.array(z.string().min(1)).min(1).optional(), +}); +/** A key present means the source is accepted on that surface. Unregistered keys are rejected by zod. */ +export const SurfacesSchema = z.record(SurfaceIdSchema, SurfaceSettingsSchema); +export type TrustedSourceSurfaces = z.infer; + +/** + * A claim name, or an n8n expression over `$claims` when prefixed with `=`, the same convention + * node parameters use. `={{ $claims.email ?? $claims.upn }}` covers IdPs whose claims differ. + */ +const claimSource = z.string().min(1); +/** The binding key stays a plain claim name, so re-keying a source is a one-token, auditable change. */ +const claimName = claimSource.refine((value) => !value.startsWith('='), { + message: 'subject must be a claim name, not an expression', +}); + +export const ClaimMappingSchema = z.object({ + subject: claimName.default('sub'), + email: claimSource.default('email'), + emailVerified: claimSource.default('email_verified'), + displayName: claimSource.default('name'), + clientId: claimSource.default('client_id'), + scopes: claimSource.default('scope'), +}); +export type ClaimMapping = z.infer; + +export const RoleMappingSchema = z.object({ + mode: z.enum(['off', 'on-provision', 'continuous']).default('off'), + instanceRoleRules: z.array(RoleMappingRuleSchema).default([]), + projectRoleRules: z.array(RoleMappingRuleSchema).default([]), + fallbackInstanceRole: z.string().min(1).optional(), +}); +export type RoleMapping = z.infer; + +export const IdentitySchema = z.object({ + subject: z.enum(['n8n-user-id', 'binding']).default('binding'), + claimMapping: ClaimMappingSchema.default({}), + linkByEmail: z.enum(['off', 'verified-only', 'any']).default('verified-only'), + provision: z.object({ human: z.enum(['off', 'jit']).default('off') }).default({}), + roleMapping: RoleMappingSchema.default({}), +}); +export type TrustedSourceIdentity = z.infer; + +export const TrustedSourceConfigV1Schema = z.object({ + version: z.literal(1), + // No default: the authentication method is an explicit choice. + authentication: AuthenticationSchema, + surfaces: SurfacesSchema, + identity: IdentitySchema.default({}), +}); +export type TrustedSourceConfigV1 = z.infer; +export type TrustedSourceConfigV1Input = z.input; + +const ownerRoleIssue = (path: Array): z.IssueData => ({ + code: z.ZodIssueCode.custom, + message: 'provisioning never assigns the instance owner role', + path, +}); + +/** + * The config document of one trusted source. Unknown keys are stripped, not rejected: an added + * optional field keeps the version, so an older process in a rolling deploy must tolerate it. The + * write path (admin DTO) must therefore reject unknown keys itself, and the store must persist the + * parsed output, not the raw input, so a later default change does not alter stored rows. + * + * Cross-field rules live here on the union, not on the block schemas: a discriminated-union member + * has to stay a plain object, and the rules hold for every version anyway. + */ +export const TrustedSourceConfigSchema = z + .discriminatedUnion('version', [TrustedSourceConfigV1Schema]) + .superRefine((config, ctx) => { + const { authentication, identity } = config; + if ( + authentication.discovery.mode === 'manual' && + authentication.keys.kind === 'jwks-uri' && + !authentication.discovery.jwksUri && + !authentication.discovery.metadataUrl + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'manual discovery with jwks-uri keys requires jwksUri or metadataUrl', + path: ['authentication', 'discovery'], + }); + } + if (identity.provision.human === 'jit' && identity.roleMapping.mode === 'off') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'jit provisioning requires a role-mapping mode', + path: ['identity', 'roleMapping', 'mode'], + }); + } + if (identity.roleMapping.mode !== 'off' && !identity.roleMapping.fallbackInstanceRole) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'a role-mapping mode requires a fallback role', + path: ['identity', 'roleMapping', 'fallbackInstanceRole'], + }); + } + if (identity.roleMapping.fallbackInstanceRole === GLOBAL_OWNER_ROLE_SLUG) { + ctx.addIssue(ownerRoleIssue(['identity', 'roleMapping', 'fallbackInstanceRole'])); + } + identity.roleMapping.instanceRoleRules.forEach((rule, index) => { + const path = ['identity', 'roleMapping', 'instanceRoleRules', index]; + if (rule.role === GLOBAL_OWNER_ROLE_SLUG) ctx.addIssue(ownerRoleIssue([...path, 'role'])); + if (rule.projectId !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'an instance role rule must not set projectId', + path: [...path, 'projectId'], + }); + } + }); + identity.roleMapping.projectRoleRules.forEach((rule, index) => { + if (rule.projectId === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'a project role rule requires projectId', + path: ['identity', 'roleMapping', 'projectRoleRules', index, 'projectId'], + }); + } + }); + }); +export type TrustedSourceConfig = z.infer; + +/** + * The config schema for one row. `managedBy` is a column, not part of the document, so the rules + * that depend on it are added per row on top of the shared schema. + */ +export const trustedSourceConfigSchemaFor = (managedBy: ManagedBy) => + TrustedSourceConfigSchema.superRefine((config, ctx) => { + if (managedBy === 'system') return; + if (config.identity.subject === 'n8n-user-id') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "identity.subject 'n8n-user-id' is only allowed on system-managed sources", + path: ['identity', 'subject'], + }); + } + // Narrow on the method first: these two rules are oauth2-specific. + if (config.authentication.type === 'oauth2') { + if (config.authentication.keys.kind === 'local-keystore') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "authentication.keys.kind 'local-keystore' is only allowed on system-managed sources", + path: ['authentication', 'keys', 'kind'], + }); + } + if (config.authentication.client?.kind === 'virtual') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "authentication.client.kind 'virtual' is only allowed on system-managed sources", + path: ['authentication', 'client', 'kind'], + }); + } + } + }); + +/** + * Secret material of a trusted source. Stored apart from the config document in its own encrypted + * column, so the document stays safe to return to an admin UI unchanged. Strict: a secret payload + * with an unknown key is a bug, not a forward-compatible read. + */ +export const TrustedSourceSecretsSchema = z + .object({ + version: z.literal(1), + clientSecret: z.string().min(1).optional(), + }) + .strict(); +export type TrustedSourceSecrets = z.infer; + +type ConfigMigration = (config: TrustedSourceConfig) => TrustedSourceConfig; + +/** One step per version, keyed by the version it migrates from. Empty until a v2 exists. */ +export const configMigrations: Record = {}; + +const LATEST_VERSION = 1; + +export type TrustedSourceConfigLatest = Extract< + TrustedSourceConfig, + { version: typeof LATEST_VERSION } +>; + +const isLatest = (config: TrustedSourceConfig): config is TrustedSourceConfigLatest => + config.version === LATEST_VERSION; + +/** + * Every throw here is a programming error, not bad input: the union parse already rejected + * unknown versions, so a missing or non-advancing step means the chain itself is wrong. + * `migrations` is injectable so tests seed a local map instead of mutating the module one. + */ +export function migrateToLatest( + config: TrustedSourceConfig, + migrations: Readonly> = configMigrations, +): TrustedSourceConfigLatest { + let current: TrustedSourceConfig = config; + // A plain number, not `current.version`: comparing the discriminant would narrow `current` to + // `never` while the union has one member. + let version: number = current.version; + while (version !== LATEST_VERSION) { + const migrate: ConfigMigration | undefined = migrations[version]; + if (!migrate) { + throw new UnexpectedError(`No migration from trusted source config version ${version}`); + } + current = migrate(current); + // A step must advance the version, or the chain would never end. + if (current.version <= version) { + throw new UnexpectedError( + `Migration from trusted source config version ${version} did not advance the version`, + ); + } + version = current.version; + } + if (!isLatest(current)) { + throw new UnexpectedError( + 'Migration chain did not reach the latest trusted source config version', + ); + } + return current; +} diff --git a/packages/@n8n/inbound-auth/tsconfig.build.json b/packages/@n8n/inbound-auth/tsconfig.build.json new file mode 100644 index 00000000000..1e88f16fb0e --- /dev/null +++ b/packages/@n8n/inbound-auth/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.go.json"], + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/__tests__/**"] +} diff --git a/packages/@n8n/inbound-auth/tsconfig.json b/packages/@n8n/inbound-auth/tsconfig.json new file mode 100644 index 00000000000..33ccd974365 --- /dev/null +++ b/packages/@n8n/inbound-auth/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@n8n/typescript-config/tsconfig.common.go.json", + "compilerOptions": { + "types": ["node", "vitest/globals", "vite/client"], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../permissions/tsconfig.build.json" }, + { "path": "../../workflow/tsconfig.build.esm.json" } + ] +} diff --git a/packages/@n8n/inbound-auth/vite.config.ts b/packages/@n8n/inbound-auth/vite.config.ts new file mode 100644 index 00000000000..bcfcc92b9f6 --- /dev/null +++ b/packages/@n8n/inbound-auth/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig, mergeConfig } from 'vite'; +import { vitestConfig } from '@n8n/vitest-config/node'; +import path from 'node:path'; + +export default mergeConfig( + defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + }), + vitestConfig, +); diff --git a/packages/@n8n/instance-ai/docs/streaming-protocol.md b/packages/@n8n/instance-ai/docs/streaming-protocol.md index f063e8ec543..64d7ecf25b2 100644 --- a/packages/@n8n/instance-ai/docs/streaming-protocol.md +++ b/packages/@n8n/instance-ai/docs/streaming-protocol.md @@ -344,8 +344,26 @@ persisted snapshots at run start and drops a snapshot whose content did not change, so a recomputed, unchanged list publishes nothing. Snapshot reads wait for pending events to drain. The final workflow setup handoff confirms the stored snapshot before it saves the setup routing marker. A failed handoff returns an -error instead of `announced: true`. Credential replacement requests and existing -setup cards keep their selection and resume flows. +error instead of `announced: true`. Requests to replace a bound account and +existing setup cards keep their selection and resume flows. A new-account request +for an unconnected service stays in the panel. Choices saved during the current +build satisfy repeated new-account flags at the final handoff. + +Before generating source, the agent can call `credentials(action="setup")` +with `filePath`, `workflowName`, and known service credential types. This creates +a temporary workflow in the bound project and persists its source-file binding. +The tool confirms the stored checklist and returns `preBuild: true` without +suspending. Later calls replace the planned requirements until the first build. +New-account preferences keep those rows unselected. A saved user choice satisfies +the preference for the rest of that run, including later build repairs. +The build saves into the same workflow and replaces the checklist with its +actual requirements. + +The panel saves pending credential references through the existing thread +metadata endpoint. Each choice has its own completion marker. The builder +validates choices against project-scoped credentials before automatic selection. +The panel applies choices made later when the build ends. Refresh and artifact +switches retain pending choices. Removing a requirement never deletes a credential. The agent observes saved workflows at the start of a user turn. This read updates its private open-item memo. It does not publish a snapshot or select a workflow. diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/case-pipeline.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/case-pipeline.test.ts index 1ffd405340e..dd98269d148 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/case-pipeline.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/case-pipeline.test.ts @@ -278,6 +278,64 @@ describe('createCasePipeline', () => { }); }); + it('grades the saved workflow of a timed-out conversation but keeps the row out of the pass rate', async () => { + const lane = makeLane(); + vi.mocked(lane.tracedExecute).mockResolvedValue({ + success: true, + score: 1, + reasoning: 'the saved workflow handles the scenario', + } as never); + const timeout = { kind: 'turn' as const, turn: 3, elapsedMs: 900_400 }; + const orchestrator = makeOrchestrator({ + build: okBuild({ timeout }), + lane, + buildDurationMs: 1_500_000, + }); + const pipeline = createCasePipeline(makeDeps(orchestrator)); + + const output = await pipeline.runRow(rowInputs('happy-path')); + + expect(vi.mocked(lane.tracedExecute)).toHaveBeenCalledTimes(1); + expect(output).toMatchObject({ + buildSuccess: true, + passed: true, + reasoning: 'the saved workflow handles the scenario', + incomplete: true, + attribution: 'timeout', + failureCategory: 'build_timeout', + buildTimeout: timeout, + }); + }); + + it('stamps a timed-out conversation that saved nothing as build_timeout, not build_failure', async () => { + const lane = makeLane(); + const timeout = { kind: 'inactivity' as const, turn: 1, elapsedMs: 240_100 }; + const orchestrator = makeOrchestrator({ + build: okBuild({ + success: false, + workflowId: undefined, + error: 'Run timed out after 240100ms (inactivity budget, user turn 1)', + timeout, + }), + lane, + buildDurationMs: 5, + }); + const pipeline = createCasePipeline(makeDeps(orchestrator)); + + const output = await pipeline.runRow(rowInputs('happy-path')); + + expect(vi.mocked(lane.tracedExecute)).not.toHaveBeenCalled(); + expect(output).toMatchObject({ + buildSuccess: false, + passed: false, + incomplete: true, + attribution: 'timeout', + failureCategory: 'build_timeout', + buildTimeout: timeout, + execErrors: ['Run timed out after 240100ms (inactivity budget, user turn 1)'], + }); + }); + it('classifies transport-failed builds as framework_issue, not build_failure', async () => { const lane = makeLane(); const orchestrator = makeOrchestrator({ diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/chat-loop-timeouts.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/chat-loop-timeouts.test.ts new file mode 100644 index 00000000000..a9fbebc1433 --- /dev/null +++ b/packages/@n8n/instance-ai/evaluations/__tests__/chat-loop-timeouts.test.ts @@ -0,0 +1,244 @@ +import { vi } from 'vitest'; + +import type { N8nClient } from '../clients/n8n-client'; +import { + runMultiTurnConversation, + timeoutBreach, + waitForAllActivity, + type WaitConfig, +} from '../harness/chat-loop'; +import type { EvalLogger } from '../harness/logger'; +import { MIN_TURN_BUDGET_MS, RunTimeoutError } from '../harness/timeouts'; +import { USER_TURN_EVENT, type CapturedEvent } from '../types'; + +const logger = { verbose: () => {}, info: () => {}, warn: () => {} } as unknown as EvalLogger; + +function event(type: string, timestamp: number): CapturedEvent { + return { timestamp, type, data: { type } }; +} + +function userTurn(timestamp: number): CapturedEvent { + return { + timestamp, + type: USER_TURN_EVENT, + data: { type: USER_TURN_EVENT, payload: { text: 'go' } }, + }; +} + +function makeClient(): N8nClient & { cancelRun: ReturnType } { + return { + cancelRun: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue(undefined), + getThreadStatus: vi.fn().mockResolvedValue({ backgroundTasks: [], memoryTasks: [] }), + } as unknown as N8nClient & { cancelRun: ReturnType }; +} + +function config(overrides: Partial): WaitConfig { + const now = Date.now(); + return { + client: makeClient(), + threadId: 'thread-1', + events: [userTurn(now), event('run-start', now)], + approvedRequests: new Set(), + startTime: now, + timeoutMs: 1_800_000, + turnTimeoutMs: 900_000, + turnStartedAt: now, + inactivityTimeoutMs: 240_000, + logger, + ...overrides, + }; +} + +describe('timeoutBreach', () => { + it('is quiet while every budget has room', () => { + const now = Date.now(); + expect(timeoutBreach(config({}), now + 60_000)).toBeUndefined(); + }); + + it('names the turn budget when one user turn overruns it while the conversation still has room', () => { + const now = Date.now(); + const cfg = config({ startTime: now - 600_000, turnStartedAt: now - 900_001 }); + cfg.events.push(event('reasoning-delta', now)); + expect(timeoutBreach(cfg, now)).toEqual({ kind: 'turn', turn: 1, elapsedMs: 900_001 }); + }); + + it('names the conversation budget first, even when the turn budget is also over', () => { + const now = Date.now(); + const cfg = config({ startTime: now - 1_800_001, turnStartedAt: now - 900_001 }); + cfg.events.push(event('reasoning-delta', now)); + expect(timeoutBreach(cfg, now)?.kind).toBe('conversation'); + }); + + it('names inactivity when the run in flight has emitted nothing for the bound', () => { + const now = Date.now(); + const cfg = config({ startTime: now - 300_000, turnStartedAt: now - 300_000 }); + cfg.events.push(event('tool-input-start', now - 240_001)); + expect(timeoutBreach(cfg, now)).toEqual({ kind: 'inactivity', turn: 1, elapsedMs: 240_001 }); + }); + + it('does not read a streaming run as inactive', () => { + const now = Date.now(); + const cfg = config({ startTime: now - 300_000, turnStartedAt: now - 300_000 }); + cfg.events.push(event('reasoning-delta', now - 10_000)); + expect(timeoutBreach(cfg, now)).toBeUndefined(); + }); + + it('counts the user turn the breach happened in', () => { + const now = Date.now(); + const cfg = config({ startTime: now - 1_000_000, turnStartedAt: now - 900_001 }); + cfg.events.push( + event('run-finish', now - 950_000), + userTurn(now - 900_001), + event('run-start', now - 900_000), + ); + expect(timeoutBreach(cfg, now)?.turn).toBe(2); + }); + + it('applies only the conversation budget when no turn budget is configured', () => { + const now = Date.now(); + const cfg = config({ + startTime: now - 1_000_000, + turnTimeoutMs: undefined, + turnStartedAt: undefined, + inactivityTimeoutMs: undefined, + }); + cfg.events.push(event('tool-input-start', now - 999_000)); + expect(timeoutBreach(cfg, now)).toBeUndefined(); + }); +}); + +describe('waitForAllActivity under a budget', () => { + it('cancels the run and throws a typed error when the turn budget fires', async () => { + const now = Date.now(); + const cfg = config({ startTime: now - 600_000, turnStartedAt: now - 900_001 }); + cfg.events.push(event('reasoning-delta', now)); + + await expect(waitForAllActivity(cfg)).rejects.toBeInstanceOf(RunTimeoutError); + await expect(waitForAllActivity(cfg)).rejects.toMatchObject({ + timeout: { kind: 'turn', turn: 1 }, + message: expect.stringContaining('Run timed out after'), + }); + expect(cfg.client.cancelRun).toHaveBeenCalledWith('thread-1'); + }); + + it('cancels a silent run when the inactivity bound fires', async () => { + const now = Date.now(); + const cfg = config({ startTime: now - 300_000, turnStartedAt: now - 300_000 }); + cfg.events.push(event('tool-input-start', now - 240_001)); + + await expect(waitForAllActivity(cfg)).rejects.toMatchObject({ + timeout: { kind: 'inactivity' }, + }); + expect(cfg.client.cancelRun).toHaveBeenCalled(); + }); + + it('ends the turn as a timeout when its budget runs out during the background wait', async () => { + // The background wait is bounded by the budget left, so it returns rather + // than throws; without a check after it the turn would read as finished. + const now = Date.now(); + const cfg = config({ startTime: now - 60_000, turnStartedAt: now - 900_000 + 300 }); + cfg.events.push(event('run-finish', now), event('agent-spawned', now)); + vi.mocked(cfg.client.getThreadStatus).mockResolvedValue({ + backgroundTasks: [{ status: 'running' }], + memoryTasks: [], + } as never); + + await expect(waitForAllActivity(cfg)).rejects.toMatchObject({ timeout: { kind: 'turn', turn: 1 } }); + expect(cfg.client.cancelRun).not.toHaveBeenCalled(); + }); + + it('returns normally when the run finishes inside every budget', async () => { + const now = Date.now(); + const cfg = config({ startTime: now - 60_000, turnStartedAt: now - 60_000 }); + cfg.events.push(event('run-finish', now)); + + await expect(waitForAllActivity(cfg)).resolves.toBeUndefined(); + expect(cfg.client.cancelRun).not.toHaveBeenCalled(); + }); +}); + +describe('runMultiTurnConversation follow-up gate', () => { + function loop( + overrides: Partial, + decisions: Array<{ kind: 'followUp'; message: string } | { kind: 'done' }>, + ) { + const now = Date.now(); + const remaining = [...decisions]; + const cfg = config({ + events: [userTurn(now - 1000), event('run-start', now - 900), event('run-finish', now)], + ...overrides, + }); + return { + cfg, + run: async () => + await runMultiTurnConversation({ + ...cfg, + nextMessageDecider: vi + .fn() + .mockImplementation( + async () => await Promise.resolve(remaining.shift() ?? { kind: 'done' }), + ), + }), + }; + } + + it('ends the conversation as a timeout instead of starting a turn that cannot finish', async () => { + const now = Date.now(); + const { cfg, run } = loop({ startTime: now - (1_800_000 - MIN_TURN_BUDGET_MS + 1_000) }, [ + { kind: 'followUp', message: 'one more change' }, + ]); + + await expect(run()).resolves.toMatchObject({ kind: 'conversation', turn: 2 }); + expect(cfg.client.sendMessage).not.toHaveBeenCalled(); + }); + + it('sends the follow-up while a minimal turn budget is left and marks the turn start', async () => { + const now = Date.now(); + const { cfg, run } = loop({ startTime: now - 600_000 }, [ + { kind: 'followUp', message: 'one more change' }, + { kind: 'done' }, + ]); + // The follow-up's run finishes at once, so the loop asks the proxy again. + vi.mocked(cfg.client.sendMessage).mockImplementation(async () => { + cfg.events.push(event('run-start', Date.now()), event('run-finish', Date.now())); + return await Promise.resolve({ runId: 'run-2' }); + }); + + await expect(run()).resolves.toBeUndefined(); + expect(cfg.client.sendMessage).toHaveBeenCalledTimes(1); + // The loop works on a copy of `cfg`, so the turn start is observed through + // the shared events array: the follow-up's user-turn marker. + const userTurns = cfg.events.filter((e) => e.type === USER_TURN_EVENT); + expect(userTurns).toHaveLength(2); + expect(userTurns[1].timestamp).toBeGreaterThanOrEqual(now); + }); + + it('returns undefined when the proxy is done, whatever the budget says', async () => { + const now = Date.now(); + const { cfg, run } = loop({ startTime: now - (1_800_000 - 10_000) }, [{ kind: 'done' }]); + + await expect(run()).resolves.toBeUndefined(); + expect(cfg.client.sendMessage).not.toHaveBeenCalled(); + }); + + it('keeps the old rule with no turn budget: a follow-up goes out while any time is left', async () => { + const now = Date.now(); + const { cfg, run } = loop( + { + startTime: now - 25_000, + timeoutMs: 30_000, + turnTimeoutMs: undefined, + turnStartedAt: undefined, + }, + [{ kind: 'followUp', message: 'one more change' }, { kind: 'done' }], + ); + vi.mocked(cfg.client.sendMessage).mockImplementation(async () => { + cfg.events.push(event('run-start', Date.now()), event('run-finish', Date.now())); + return await Promise.resolve({ runId: 'run-2' }); + }); + + await expect(run()).resolves.toBeUndefined(); + expect(cfg.client.sendMessage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/comparison-compare.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/comparison-compare.test.ts index 38a05d39eac..b70025b6461 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/comparison-compare.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/comparison-compare.test.ts @@ -211,10 +211,12 @@ describe('compareBuckets', () => { // those). The unknown `-` category is dropped here with a warning. expect(cats.map((c) => c.category).sort()).toEqual([ 'build_failure', + 'build_timeout', 'builder_issue', 'expectations_failed', 'framework_issue', 'mock_issue', + 'timeout', 'verification_failure', 'verification_gap', ]); diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/fixtures/eval-results.partial-abort.json b/packages/@n8n/instance-ai/evaluations/__tests__/fixtures/eval-results.partial-abort.json index d06f57fb8c2..b220a45f13d 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/fixtures/eval-results.partial-abort.json +++ b/packages/@n8n/instance-ai/evaluations/__tests__/fixtures/eval-results.partial-abort.json @@ -40,7 +40,8 @@ } ], "transcriptPerRun": [null], - "buildErrorPerRun": [null] + "buildErrorPerRun": [null], + "buildTimeoutPerRun": [null] }, { "name": "build me something", @@ -74,7 +75,8 @@ } ], "transcriptPerRun": [null], - "buildErrorPerRun": ["TimeoutError: The operation was aborted due to timeout"] + "buildErrorPerRun": ["TimeoutError: The operation was aborted due to timeout"], + "buildTimeoutPerRun": [null] } ] } diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/scenario-seed-tables.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/scenario-seed-tables.test.ts index 9cfcb168f10..c2903201816 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/scenario-seed-tables.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/scenario-seed-tables.test.ts @@ -1,9 +1,11 @@ +import type { InstanceAiEvalExecutionResult } from '@n8n/api-types'; import { vi } from 'vitest'; import type { Mock } from 'vitest'; import type { N8nClient } from '../clients/n8n-client'; import { SEED_NAME_RE } from '../harness/conversation-seed'; import type { EvalLogger } from '../harness/logger'; +import { executeScenario } from '../harness/scenario-execution'; import { buildSeededTablesNote, dedupeScenarioSeedTables, @@ -367,3 +369,36 @@ describe('evictLeftoverSeedTables', () => { ).resolves.toBeUndefined(); }); }); + +describe('executeScenario with seeded tables', () => { + it('asks the server to read the reseeded tables live', async () => { + // A budget stop ends the scenario before verification, so only the execute call runs. + const executeWithLlmMock = vi.fn().mockResolvedValue({ + executionId: 'exec-1', + success: false, + nodeResults: {}, + errors: ['Execution exceeded its 895s eval budget and was stopped'], + } as unknown as InstanceAiEvalExecutionResult); + const client = { + seedDataTableRows: vi.fn().mockResolvedValue(undefined), + executeWithLlmMock, + } as unknown as N8nClient; + + await expect( + executeScenario( + client, + 'wf-1', + scenario({ seedDataTables: [jobApplications] }), + [], + silentLogger, + undefined, + undefined, + undefined, + undefined, + { threadId: 'thread-1', tableIdsByName: { 'Job Applications': 'dt-real-1' } }, + ), + ).rejects.toThrow(); + + expect(executeWithLlmMock.mock.calls[0][4]).toEqual(['dt-real-1']); + }); +}); diff --git a/packages/@n8n/instance-ai/evaluations/cli/args.ts b/packages/@n8n/instance-ai/evaluations/cli/args.ts index 7741f33a022..bb9fa69f138 100644 --- a/packages/@n8n/instance-ai/evaluations/cli/args.ts +++ b/packages/@n8n/instance-ai/evaluations/cli/args.ts @@ -34,7 +34,9 @@ function resolveBuildModel(env: NodeJS.ProcessEnv = process.env): string { // --------------------------------------------------------------------------- export interface CliArgs { - /** TimeoutMs is defined per iteration, not as the total timeout for all iterations */ + /** Budget of one unit of work: one user turn of the build conversation, or one + * scenario execution attempt. A conversation may spend `CONVERSATION_BUDGET_TURNS` + * of these in total (harness/timeouts.ts). Not a total for all iterations. */ timeoutMs: number; /** One or more n8n base URLs. Multi-lane runs use a work-stealing allocator * that dispatches each build to a lane that isn't already running its diff --git a/packages/@n8n/instance-ai/evaluations/clients/n8n-client.ts b/packages/@n8n/instance-ai/evaluations/clients/n8n-client.ts index 023e9d47b2d..176b49733c1 100644 --- a/packages/@n8n/instance-ai/evaluations/clients/n8n-client.ts +++ b/packages/@n8n/instance-ai/evaluations/clients/n8n-client.ts @@ -1301,17 +1301,27 @@ export class N8nClient { * sub-nodes actually run instead of being short-circuited by pin data; * pass `pinNodes` to keep specific roots on the pinned baseline (e.g. for * A/B comparison). Gated server-side behind the - * `085_eval_vendor_sdk_interception` PostHog flag. + * `085_eval_vendor_sdk_interception` PostHog flag. Data Table reads of + * `seededDataTableIds` run live instead of pinned. */ async executeWithLlmMock( workflowId: string, scenarioHints?: string, timeoutMs: number = 120_000, pinNodes?: string[], + seededDataTableIds?: string[], ): Promise { - const body: { scenarioHints?: string; pinNodes?: string[]; timeoutMs?: number } = {}; + const body: { + scenarioHints?: string; + pinNodes?: string[]; + timeoutMs?: number; + seededDataTableIds?: string[]; + } = {}; if (scenarioHints) body.scenarioHints = scenarioHints; if (pinNodes && pinNodes.length > 0) body.pinNodes = pinNodes; + if (seededDataTableIds && seededDataTableIds.length > 0) { + body.seededDataTableIds = seededDataTableIds; + } // Forwarded so the server stops the run rather than leaving it burning CPU. const serverBudgetMs = serverBudgetFor(timeoutMs); body.timeoutMs = serverBudgetMs; diff --git a/packages/@n8n/instance-ai/evaluations/comparison/compare.ts b/packages/@n8n/instance-ai/evaluations/comparison/compare.ts index dbbfd52b2ce..e03858620a6 100644 --- a/packages/@n8n/instance-ai/evaluations/comparison/compare.ts +++ b/packages/@n8n/instance-ai/evaluations/comparison/compare.ts @@ -291,6 +291,10 @@ const KNOWN_FAILURE_CATEGORIES = new Set([ 'verification_gap', 'verification_failure', 'build_failure', + // A budget ended the conversation (harness/timeouts.ts): the category and the + // attribution both travel on such rows. + 'build_timeout', + 'timeout', // Build-only sentinel rows — reachable through a LangSmith baseline fetch, // which reads run outputs rather than execution-scenario results. 'expectations_failed', diff --git a/packages/@n8n/instance-ai/evaluations/harness/attribution.ts b/packages/@n8n/instance-ai/evaluations/harness/attribution.ts index eb9e3329069..ce7b2934d6f 100644 --- a/packages/@n8n/instance-ai/evaluations/harness/attribution.ts +++ b/packages/@n8n/instance-ai/evaluations/harness/attribution.ts @@ -16,13 +16,15 @@ // --------------------------------------------------------------------------- /** - * Who owns a failed graded unit. Deliberately the SAME four names the verifier - * prompt already defines, so the enum the LLM picks from and the enum we store - * are one vocabulary rather than two that need translating — which is how the - * old contract drifted. The harness-code categories (`build_failure`, - * `verification_failure`, `expectations_failed`) fold into these. + * The categories the LLM verifier picks from, as spelled out in + * `system-prompts/mock-execution-verify.ts`. Deliberately the SAME four names + * we store, so the enum the LLM picks from and the enum we store are one + * vocabulary rather than two that need translating — which is how the old + * contract drifted. The harness-code categories (`build_failure`, + * `verification_failure`, `expectations_failed`) fold into these. A test fails + * when the prompt's enum and this list drift. */ -export const EVAL_ATTRIBUTIONS = [ +export const VERIFIER_CATEGORIES = [ /** The agent built it wrong — including "runs as built, misses the criteria". */ 'builder_issue', /** The eval mock/fixture layer served data the scenario did not describe. */ @@ -35,20 +37,23 @@ export const EVAL_ATTRIBUTIONS = [ 'verification_gap', ] as const; +/** Who owns a graded unit: the verifier's four buckets plus the one only the + * harness asserts. */ +export const EVAL_ATTRIBUTIONS = [ + ...VERIFIER_CATEGORIES, + /** A budget ended the build conversation (harness/timeouts.ts). Every graded + * unit of that iteration carries it and is marked `incomplete`: what was + * saved is still graded and recorded, but the iteration counts neither as a + * pass nor as a failure. */ + 'timeout', +] as const; + export type EvalAttribution = (typeof EVAL_ATTRIBUTIONS)[number]; export function isEvalAttribution(value: unknown): value is EvalAttribution { return EVAL_ATTRIBUTIONS.includes(value as EvalAttribution); } -/** - * The categories the LLM verifier picks from, as spelled out in - * `system-prompts/mock-execution-verify.ts`. Identical to `EVAL_ATTRIBUTIONS` - * on purpose — kept as its own name so the prompt's enum has a code-side - * counterpart that fails a test when the two drift. - */ -export const VERIFIER_CATEGORIES = EVAL_ATTRIBUTIONS; - /** * Attribution for a category the LLM verifier chose — an identity map, since * the two enums are now the same list. diff --git a/packages/@n8n/instance-ai/evaluations/harness/build-workflow.ts b/packages/@n8n/instance-ai/evaluations/harness/build-workflow.ts index 0035f8f30fe..363125b5b78 100644 --- a/packages/@n8n/instance-ai/evaluations/harness/build-workflow.ts +++ b/packages/@n8n/instance-ai/evaluations/harness/build-workflow.ts @@ -62,6 +62,7 @@ import { reseedScenarioTables, uniquifySeedTableNames, } from './seed-tables'; +import { CONVERSATION_BUDGET_TURNS, INACTIVITY_TIMEOUT_MS, RunTimeoutError } from './timeouts'; import type { CheckOutcome } from '../binaryChecks/types'; import { N8nApiError, type N8nClient, type WorkflowResponse } from '../clients/n8n-client'; import { createDeclaredCredentials } from '../credentials/seeder'; @@ -74,6 +75,7 @@ import { buildTranscriptFromEvents } from '../outcome/transcript-from-events'; import { buildAgentOutcome, extractWorkflowIdsFromMessages } from '../outcome/workflow-discovery'; import type { ArtifactRef, + BuildTimeout, BuildTrace, CapturedEvent, ConversationMetrics, @@ -97,12 +99,16 @@ import { UserProxyLlm, type ProxyDecisionStats } from '../utils/user-proxy'; // Constants // --------------------------------------------------------------------------- -// 15 min. Lanes with heavy multi-agent scenarios (large mocked payloads) -// legitimately need more — the MCP CI workflow passes --timeout-ms 1500000 -// explicitly (observed: trading-bot at 863s with a 15-row dataset, hard -// timeouts at 32 rows). Do NOT raise this default: a timed-out attempt is -// retried once, so under high-concurrency contention (the Instance AI -// experiments suite runs ~4x the MCP lane's concurrency) a generous default +// 15 min, the budget of ONE USER TURN of the build conversation (the whole +// conversation gets CONVERSATION_BUDGET_TURNS of these, see harness/timeouts.ts) +// and of one scenario execution attempt. No productive completed turn in the +// 2026-09 model-comparison traces exceeded 744 s (medium) or 1304 s (complex, +// which gets 1.5x). Lanes with heavy multi-agent scenarios (large mocked +// payloads) legitimately need more for scenarios — the MCP CI workflow passes +// --timeout-ms 1500000 explicitly (observed: trading-bot at 863s with a 15-row +// dataset, hard timeouts at 32 rows). Do NOT raise this default: a timed-out +// attempt is retried once, so under high-concurrency contention (the Instance +// AI experiments suite runs ~4x the MCP lane's concurrency) a generous default // lets starved scenarios hold lane slots for 2x the budget and amplify the // very contention that starved them (observed: run 28779266673). const DEFAULT_TIMEOUT_MS = 900_000; @@ -129,7 +135,10 @@ interface MultiTurnDriverConfig { events: CapturedEvent[]; approvedRequests: Set; startTime: number; + /** Conversation budget, from `startTime`. */ timeoutMs: number; + /** Budget of each user turn. */ + turnTimeoutMs: number; logger: EvalLogger; proxyResponses?: Map; followUpMessagesOut?: string[]; @@ -170,7 +179,7 @@ function isMultiTurnConversation(conversation: ConversationTurn[]): boolean { async function driveMultiTurnConversation( config: MultiTurnDriverConfig, -): Promise { +): Promise<{ proxyDecisionStats: ProxyDecisionStats; timeout?: BuildTimeout }> { const openingMessage = config.conversation[0]?.text ?? ''; const recordedOpeningMessage = config.recordedOpeningMessage ?? openingMessage; // The proxy renders both its script and its running transcript from `text` alone, @@ -221,25 +230,36 @@ async function driveMultiTurnConversation( config.observerThresholdTokens, ); - await runMultiTurnConversation({ - client: config.client, - threadId: config.threadId, - events: config.events, - approvedRequests: config.approvedRequests, - startTime: config.startTime, - timeoutMs: config.timeoutMs, - logger: config.logger, - confirmationStrategy, - nextMessageDecider, - proxyResponses: config.proxyResponses, - buildMode: config.buildMode, - promptVersion: config.promptVersion, - observerThresholdTokens: config.observerThresholdTokens, - allowUserExecution: config.allowUserExecution, - beforeUserExecution: config.beforeUserExecution, - }); + let timeout: BuildTimeout | undefined; + try { + timeout = await runMultiTurnConversation({ + client: config.client, + threadId: config.threadId, + events: config.events, + approvedRequests: config.approvedRequests, + startTime: config.startTime, + timeoutMs: config.timeoutMs, + turnTimeoutMs: config.turnTimeoutMs, + turnStartedAt: config.startTime, + inactivityTimeoutMs: INACTIVITY_TIMEOUT_MS, + logger: config.logger, + confirmationStrategy, + nextMessageDecider, + proxyResponses: config.proxyResponses, + buildMode: config.buildMode, + promptVersion: config.promptVersion, + observerThresholdTokens: config.observerThresholdTokens, + allowUserExecution: config.allowUserExecution, + beforeUserExecution: config.beforeUserExecution, + }); + } catch (error: unknown) { + // A budget that fires inside a run ends the conversation, not the build: + // what the agent saved before it is graded, stamped with the timeout. + if (!(error instanceof RunTimeoutError)) throw error; + timeout = error.timeout; + } - return { ...proxy.getDecisionStats() }; + return { proxyDecisionStats: { ...proxy.getDecisionStats() }, timeout }; } // --------------------------------------------------------------------------- @@ -251,6 +271,9 @@ export interface BuildResult { workflowId?: string; workflowJsons: WorkflowResponse[]; error?: string; + /** Set when a budget ended the conversation (harness/timeouts.ts). `success` + * and the workflow fields describe what the agent had saved by then. */ + timeout?: BuildTimeout; buildTrace?: BuildTrace; /** IDs to pass to cleanupBuild() */ createdWorkflowIds: string[]; @@ -542,8 +565,12 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise {}); + if (timeout) { + logger.info( + ` Conversation ended by the ${timeout.kind} budget after ${String(Math.round(timeout.elapsedMs / 1000))}s at user turn ${String(timeout.turn)}; grading what was saved${config.laneTag ?? ''} [thread ${threadId}]`, + ); + } + const conversationMetrics = mergeSeededConversationMetrics( seededTranscript, buildConversationMetrics(events), @@ -1251,6 +1296,7 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise; + /** When the conversation started (the opening message was sent). */ startTime: number; + /** Conversation budget, measured from `startTime`. */ timeoutMs: number; + /** Budget of one user turn, measured from `turnStartedAt`. Absent: no per-turn cap. */ + turnTimeoutMs?: number; + /** When the user message of the turn in flight was sent. */ + turnStartedAt?: number; + /** Cancel a run in flight after this long without an event. Absent: no bound. */ + inactivityTimeoutMs?: number; logger: EvalLogger; confirmationStrategy?: ConfirmationStrategy; /** Per-conversation retry count by requestId. Auto-allocated when omitted. */ @@ -125,8 +134,7 @@ export async function waitForAllActivity(config: WaitConfig): Promise { ); // Wait for background agent tasks to complete - const remainingMs = Math.max(0, config.timeoutMs - (Date.now() - config.startTime)); - await waitForBackgroundTasks(config, remainingMs); + await waitForBackgroundTasks(config, remainingBudgetMs(config)); // Wait for observational-memory jobs (observer/reflector) before the next user turn await waitForMemoryTasks(config); @@ -136,6 +144,11 @@ export async function waitForAllActivity(config: WaitConfig): Promise { const newRunStarts = countEvents(config.events, 'run-start'); const currentRunFinishes = countEvents(config.events, 'run-finish'); if (newRunStarts <= currentRunFinishes) { + // The waits above return when the budget they are bounded by is spent, + // they do not throw. Checked here, or a turn that overran during them + // goes on to a follow-up, or is graded as a turn that finished. + const breach = elapsedBudgetBreach(config); + if (breach) throw new RunTimeoutError(breach); break; } @@ -143,21 +156,77 @@ export async function waitForAllActivity(config: WaitConfig): Promise { `[${config.threadId}] Main agent resumed (run-start #${String(newRunStarts)}) -- waiting for completion`, ); - if (Date.now() - config.startTime > config.timeoutMs) { - await config.client.cancelRun(config.threadId).catch(() => {}); - throw new Error(`Run timed out after ${String(config.timeoutMs)}ms`); - } + await cancelOnTimeout(config); } } +/** + * Conversation budget a follow-up needs before it is sent: the fixed floor, or a + * quarter of a turn budget when that is smaller (short local budgets). Without + * a turn budget the loop keeps the old rule: send while any time is left. + */ +function minTurnBudgetMs(config: WaitConfig): number { + if (config.turnTimeoutMs === undefined) return 0; + return Math.min(MIN_TURN_BUDGET_MS, config.turnTimeoutMs / 4); +} + +/** Time left in the tightest of the conversation and turn budgets. */ +function remainingBudgetMs(config: WaitConfig, now = Date.now()): number { + const conversation = config.timeoutMs - (now - config.startTime); + const turn = + config.turnTimeoutMs !== undefined && config.turnStartedAt !== undefined + ? config.turnTimeoutMs - (now - config.turnStartedAt) + : Infinity; + return Math.max(0, Math.min(conversation, turn)); +} + +/** + * The budget the run in flight has overrun, if any. The conversation budget is + * the ceiling the other two live inside, so it is checked first. Inactivity + * reads the last captured event: harness markers count, so a turn whose run has + * not started yet is measured from its own user message. + */ +export function timeoutBreach(config: WaitConfig, now = Date.now()): BuildTimeout | undefined { + const elapsed = elapsedBudgetBreach(config, now); + if (elapsed) return elapsed; + const turn = Math.max(1, countEvents(config.events, USER_TURN_EVENT)); + if (config.inactivityTimeoutMs !== undefined) { + const lastEventAt = config.events.at(-1)?.timestamp ?? config.turnStartedAt ?? config.startTime; + const idleMs = now - lastEventAt; + if (idleMs > config.inactivityTimeoutMs) return { kind: 'inactivity', turn, elapsedMs: idleMs }; + } + return undefined; +} + +/** + * The conversation or turn budget the elapsed time has overrun, if any. The + * inactivity bound is not in here: it reads the last event, and the post-run + * waits emit none, so it belongs to a run in flight only. + */ +export function elapsedBudgetBreach(config: WaitConfig, now = Date.now()): BuildTimeout | undefined { + const turn = Math.max(1, countEvents(config.events, USER_TURN_EVENT)); + const conversationElapsed = now - config.startTime; + if (conversationElapsed > config.timeoutMs) { + return { kind: 'conversation', turn, elapsedMs: conversationElapsed }; + } + if (config.turnTimeoutMs !== undefined && config.turnStartedAt !== undefined) { + const turnElapsed = now - config.turnStartedAt; + if (turnElapsed > config.turnTimeoutMs) return { kind: 'turn', turn, elapsedMs: turnElapsed }; + } + return undefined; +} + +/** Cancel the run in flight and throw when a budget has fired. */ +async function cancelOnTimeout(config: WaitConfig): Promise { + const breach = timeoutBreach(config); + if (!breach) return; + await config.client.cancelRun(config.threadId).catch(() => {}); + throw new RunTimeoutError(breach); +} + async function waitForRunFinish(config: WaitConfig, expectedFinishCount: number): Promise { while (countEvents(config.events, 'run-finish') <= expectedFinishCount) { - const elapsed = Date.now() - config.startTime; - if (elapsed > config.timeoutMs) { - await config.client.cancelRun(config.threadId).catch(() => {}); - throw new Error(`Run timed out after ${String(config.timeoutMs)}ms`); - } - + await cancelOnTimeout(config); await processConfirmationRequests(config); await delay(POLL_INTERVAL_MS); } @@ -305,23 +374,39 @@ export interface MultiTurnConfig extends WaitConfig { observerThresholdTokens?: number; } -export async function runMultiTurnConversation(config: MultiTurnConfig): Promise { +/** + * Drives the conversation until the proxy is done or a budget ends it. Returns + * the budget that ended it, or undefined when the proxy said done. A budget that + * fires inside a run throws `RunTimeoutError` from `waitForAllActivity`; one that + * runs out between turns returns here, with the state saved so far intact. + */ +export async function runMultiTurnConversation( + config: MultiTurnConfig, +): Promise { while (true) { await waitForAllActivity(config); - if (Date.now() - config.startTime > config.timeoutMs) { - config.logger.verbose( - `[multi-turn] Timeout reached after ${String(Date.now() - config.startTime)}ms — exiting loop`, - ); - return; - } - const decision = await config.nextMessageDecider(); if (decision.kind === 'done') { config.logger.verbose('[multi-turn] Proxy returned done — exiting loop'); - return; + return undefined; } + // The proxy still has something to say: only a turn that can get a minimal + // budget is worth starting. Otherwise the conversation ends here and the + // saved state is graded. + const nextTurn = countEvents(config.events, USER_TURN_EVENT) + 1; + const conversationBudgetExhausted = (): BuildTimeout | undefined => { + const elapsedMs = Date.now() - config.startTime; + if (config.timeoutMs - elapsedMs >= minTurnBudgetMs(config)) return undefined; + config.logger.verbose( + `[multi-turn] ${String(Math.max(0, Math.round((config.timeoutMs - elapsedMs) / 1000)))}s of conversation budget left — not sending user turn ${String(nextTurn)}`, + ); + return { kind: 'conversation', turn: nextTurn, elapsedMs }; + }; + const exhaustedBeforeEdits = conversationBudgetExhausted(); + if (exhaustedBeforeEdits) return exhaustedBeforeEdits; + // After the decision, so an edit never lands on the boundary that ends the // conversation: there the agent would get no turn to react, and the renamed // workflow would still be what the judge and workflow checks read. @@ -336,11 +421,13 @@ export async function runMultiTurnConversation(config: MultiTurnConfig): Promise await applyUserExecution(config, decision.runWorkflowId); } - if (Date.now() - config.startTime >= config.timeoutMs) return; + const exhaustedAfterEdits = conversationBudgetExhausted(); + if (exhaustedAfterEdits) return exhaustedAfterEdits; config.logger.verbose( `[multi-turn] Sending follow-up: ${decision.message.slice(0, 80)}${decision.message.length > 80 ? '...' : ''}`, ); + config.turnStartedAt = Date.now(); recordUserTurn(config.events, decision.message); try { await config.client.sendMessage( @@ -356,7 +443,7 @@ export async function runMultiTurnConversation(config: MultiTurnConfig): Promise } catch (error: unknown) { const msg = error instanceof Error ? error.message : String(error); config.logger.verbose(`[multi-turn] sendMessage failed: ${msg} — exiting loop`); - return; + return undefined; } } } diff --git a/packages/@n8n/instance-ai/evaluations/harness/scenario-execution.ts b/packages/@n8n/instance-ai/evaluations/harness/scenario-execution.ts index 1ef1d318ec1..e5ec22cf5cd 100644 --- a/packages/@n8n/instance-ai/evaluations/harness/scenario-execution.ts +++ b/packages/@n8n/instance-ai/evaluations/harness/scenario-execution.ts @@ -281,6 +281,10 @@ async function runScenario( logger, ); } + // The server reads these tables live, so a read sees the rows and earlier writes. + const seededDataTableIds = seedContext + ? (scenario.seedDataTables ?? []).map((table) => seedContext.tableIdsByName[table.name]) + : undefined; const execStart = Date.now(); let evalResult = await client.executeWithLlmMock( @@ -288,6 +292,7 @@ async function runScenario( scenario.dataSetup, timeoutMs, pinNodes, + seededDataTableIds, ); // DB write races abort the execution before any node runs and are reported // in-band (success:false), bypassing the throw-based transient retry — @@ -308,6 +313,7 @@ async function runScenario( scenario.dataSetup, timeoutMs, pinNodes, + seededDataTableIds, ); } // Killed for time, not by the builder — throw so the timeout path classifies it. diff --git a/packages/@n8n/instance-ai/evaluations/harness/timeouts.ts b/packages/@n8n/instance-ai/evaluations/harness/timeouts.ts new file mode 100644 index 00000000000..75801a8532f --- /dev/null +++ b/packages/@n8n/instance-ai/evaluations/harness/timeouts.ts @@ -0,0 +1,41 @@ +// --------------------------------------------------------------------------- +// Build conversation budgets +// +// Three clocks bound a build conversation. The values come from the traces of +// the model-comparison-v2 sweeps of 2026-09-23/24 (174 completed turns). +// --------------------------------------------------------------------------- + +import type { BuildTimeout } from '../types'; + +/** + * Conversation budget, in turn budgets. A conversation may spend this many + * `--timeout-ms` budgets in total, measured from the opening message. Every + * productive conversation that hit the old single budget would have finished + * within 1.6 of them; one runaway turn plus normal turns fits in 2. + */ +export const CONVERSATION_BUDGET_TURNS = 2; + +/** + * A run in flight that emits no event for this long is cancelled as stalled. + * The longest silence inside a run that later resumed was 190 s; stalled runs + * stayed silent for 730 to 867 s. + */ +export const INACTIVITY_TIMEOUT_MS = 240_000; + +/** + * A follow-up is sent only when at least this much conversation budget is left + * (or a quarter of a turn budget, when that is smaller). A turn that starts with + * less cannot finish, and grading the state saved so far beats cancelling a turn + * seconds after it started. + */ +export const MIN_TURN_BUDGET_MS = 120_000; + +/** Thrown by the chat loop when a budget fires; carries which one and when. */ +export class RunTimeoutError extends Error { + constructor(readonly timeout: BuildTimeout) { + super( + `Run timed out after ${String(timeout.elapsedMs)}ms (${timeout.kind} budget, user turn ${String(timeout.turn)})`, + ); + this.name = 'RunTimeoutError'; + } +} diff --git a/packages/@n8n/instance-ai/evaluations/run/build-orchestrator.ts b/packages/@n8n/instance-ai/evaluations/run/build-orchestrator.ts index c087ee7e760..4a279d429e7 100644 --- a/packages/@n8n/instance-ai/evaluations/run/build-orchestrator.ts +++ b/packages/@n8n/instance-ai/evaluations/run/build-orchestrator.ts @@ -515,12 +515,19 @@ export function createBuildOrchestrator(deps: BuildOrchestratorDeps): BuildOrche // and reshape's side band) then carry the same verdict (TRUST-375). const infraFailed = buildFailedOnInfra(build); const attribute = (verdicts: BuildExpectationResult[]): BuildExpectationResult[] => - verdicts.map((v) => ({ - // An attribution already set is a decision the caller made with more - // context than this closure has; don't overwrite it. - ...v, - attribution: v.attribution ?? attributionForExpectation(v, infraFailed), - })); + verdicts.map((v) => + // A budget ended the conversation: the judge's verdict on the partial + // transcript is kept for the record but counts neither way, same as + // the iteration's scenario rows (see `attachExpectations`). + build.timeout + ? { ...v, incomplete: true, attribution: 'timeout' as const } + : { + // An attribution already set is a decision the caller made with more + // context than this closure has; don't overwrite it. + ...v, + attribution: v.attribution ?? attributionForExpectation(v, infraFailed), + }, + ); // The lane's deterministic verdicts ride along on EVERY path, including the // unjudged one: they describe what the run actually did to the provider and // to n8n, which stays true whether or not the author expectations got judged. @@ -710,7 +717,7 @@ export function createBuildOrchestrator(deps: BuildOrchestratorDeps): BuildOrche const timeoutMs = effectiveTimeoutMs(entry.complexity, args.timeoutMs); if (timeoutMs !== args.timeoutMs) { logger.info( - ` Complex case: per-iteration budget ${String(Math.round(timeoutMs / 1000))}s [${fileSlug}]`, + ` Complex case: per-turn budget ${String(Math.round(timeoutMs / 1000))}s [${fileSlug}]`, ); } // Transport failures are not agent verdicts — retry on a different lane diff --git a/packages/@n8n/instance-ai/evaluations/run/case-pipeline.ts b/packages/@n8n/instance-ai/evaluations/run/case-pipeline.ts index 6fe4b84067c..b946925faf8 100644 --- a/packages/@n8n/instance-ai/evaluations/run/case-pipeline.ts +++ b/packages/@n8n/instance-ai/evaluations/run/case-pipeline.ts @@ -203,7 +203,22 @@ export function createCasePipeline(deps: CasePipelineDeps): CasePipeline { // still knows why the build ended) — attach them as-is. const attachExpectations = async (output: TargetOutput): Promise => { const verdicts = await verdictsPromise; - return verdicts && verdicts.length > 0 ? { ...output, expectationResults: verdicts } : output; + // A conversation a budget ended is graded on what was saved by then, but + // the iteration is neither a pass nor a failure: every row keeps its + // verdict for the record and is stamped `timeout` + `incomplete`, which + // keeps it out of the pass rate here and in lang-tracer. + const stamped: TargetOutput = build.timeout + ? { + ...output, + buildTimeout: build.timeout, + incomplete: true, + attribution: 'timeout', + failureCategory: 'build_timeout', + } + : output; + return verdicts && verdicts.length > 0 + ? { ...stamped, expectationResults: verdicts } + : stamped; }; // A staged prior run that never produced an execution record leaves the case @@ -270,6 +285,15 @@ export function createCasePipeline(deps: CasePipelineDeps): CasePipeline { workflowJson: build.workflowJsons[0], buildTrace: build.buildTrace, ...(verdicts && verdicts.length > 0 ? { expectationResults: verdicts } : {}), + // Same neutral stamp the scenario rows get (see `attachExpectations`). + ...(build.timeout + ? { + buildTimeout: build.timeout, + incomplete: true, + attribution: 'timeout' as const, + failureCategory: 'build_timeout', + } + : {}), }; } @@ -282,6 +306,7 @@ export function createCasePipeline(deps: CasePipelineDeps): CasePipeline { // Seeding, transport and provider failures are harness/infra problems, // not agent build failures — keep them out of the build_failure bucket, // which lang-tracer's legacy map reads straight as `builder_issue`. + // (A timed-out build is re-stamped in `attachExpectations`.) failureCategory: build.seedingFailed || build.transportFailure ? 'framework_issue' : 'build_failure', // The verdict lang-tracer actually stores. A provider outage is infra diff --git a/packages/@n8n/instance-ai/evaluations/run/persist.ts b/packages/@n8n/instance-ai/evaluations/run/persist.ts index ab9c111344e..a85fb3c56f5 100644 --- a/packages/@n8n/instance-ai/evaluations/run/persist.ts +++ b/packages/@n8n/instance-ai/evaluations/run/persist.ts @@ -292,6 +292,54 @@ export async function runEvalAndPersist( ): Promise { const partialResults: WorkflowTestCaseResult[][] = []; let persisted = false; + const writePartial = (why: string): void => { + try { + // Prefer sink recovery — row-granular and fed by BOTH drivers; fall + // back to the direct driver's per-iteration channel. + const sinkRows = config.rowSink?.readRows() ?? []; + const recovered = + sinkRows.length > 0 && config.testCasesWithFiles + ? recoverCompleteIterations(sinkRows, config.testCasesWithFiles) + : []; + const runResults = recovered.length > 0 ? recovered : partialResults; + const evaluation: MultiRunEvaluation = + runResults.length > 0 + ? aggregateResults(runResults, runResults.length) + : { totalRuns: config.iterations, testCases: [] }; + const recoverySlugByTestCase = config.testCasesWithFiles + ? new Map(config.testCasesWithFiles.map(({ testCase, fileSlug }) => [testCase, fileSlug])) + : undefined; + const { jsonPath } = writeEvalResults( + evaluation, + Date.now() - config.startTime, + config.outputDir, + undefined, + undefined, + config.commitSha, + recoverySlugByTestCase, + config.rerun, + undefined, + config.mcpBuildSpend, + undefined, + config.tier === 'agents' ? 'agent' : 'workflow', + ); + config.logger.error( + `${why} — wrote partial results (${String(runResults.length)} iteration(s)) to ${jsonPath}`, + ); + } catch (writeError: unknown) { + config.logger.error( + `Failed to write partial eval results: ${extractErrorMessage(writeError)}`, + ); + } + }; + // A dispatcher that gives up on the run sends SIGTERM. Node would exit before + // the `finally` below runs, and the rows the run had already graded would be + // lost with it; the write is synchronous, so it fits in the handler. + const onSigterm = (): void => { + if (!persisted) writePartial('Received SIGTERM'); + process.exit(143); + }; + process.once('SIGTERM', onSigterm); try { const out = await runEval(partialResults); // Gated tiers report an absolute green verdict in place of the baseline comparison. @@ -315,46 +363,8 @@ export async function runEvalAndPersist( persisted = true; return { ...out, gate, jsonPath, prCommentPath }; } finally { - if (!persisted) { - try { - // Prefer sink recovery — row-granular and fed by BOTH drivers; fall - // back to the direct driver's per-iteration channel. - const sinkRows = config.rowSink?.readRows() ?? []; - const recovered = - sinkRows.length > 0 && config.testCasesWithFiles - ? recoverCompleteIterations(sinkRows, config.testCasesWithFiles) - : []; - const runResults = recovered.length > 0 ? recovered : partialResults; - const evaluation: MultiRunEvaluation = - runResults.length > 0 - ? aggregateResults(runResults, runResults.length) - : { totalRuns: config.iterations, testCases: [] }; - const recoverySlugByTestCase = config.testCasesWithFiles - ? new Map(config.testCasesWithFiles.map(({ testCase, fileSlug }) => [testCase, fileSlug])) - : undefined; - const { jsonPath } = writeEvalResults( - evaluation, - Date.now() - config.startTime, - config.outputDir, - undefined, - undefined, - config.commitSha, - recoverySlugByTestCase, - config.rerun, - undefined, - config.mcpBuildSpend, - undefined, - config.tier === 'agents' ? 'agent' : 'workflow', - ); - config.logger.error( - `Eval run did not finish cleanly — wrote partial results (${String(runResults.length)} iteration(s)) to ${jsonPath}`, - ); - } catch (writeError: unknown) { - config.logger.error( - `Failed to write partial eval results: ${extractErrorMessage(writeError)}`, - ); - } - } + process.off('SIGTERM', onSigterm); + if (!persisted) writePartial('Eval run did not finish cleanly'); } } @@ -501,6 +511,10 @@ export function writeEvalResults( // Transcript content is redacted at capture (transcript-from-events). transcriptPerRun: tc.runs.map((run) => run.transcript ?? null), buildErrorPerRun: tc.runs.map((run) => run.buildError ?? null), + // The budget that ended each iteration's conversation, when one did + // (harness/timeouts.ts). Its rows arrive `incomplete` with attribution + // `timeout`; this says which clock fired and on which user turn. + buildTimeoutPerRun: tc.runs.map((run) => run.buildTimeout ?? null), // `claude` build spend per iteration (--build-via-mcp only) — the // dedupe-safe source for per-case cost (LangSmith feedback repeats the // value on every row of a case's build). diff --git a/packages/@n8n/instance-ai/evaluations/run/reshape.ts b/packages/@n8n/instance-ai/evaluations/run/reshape.ts index 98ba37180ed..fe11444ec96 100644 --- a/packages/@n8n/instance-ai/evaluations/run/reshape.ts +++ b/packages/@n8n/instance-ai/evaluations/run/reshape.ts @@ -24,6 +24,7 @@ import { EVAL_ATTRIBUTIONS, type EvalAttribution } from '../harness/attribution' import { BUILD_ONLY_SCENARIO_NAME } from '../langsmith/dataset-sync'; import type { AgentArtifact, + BuildTimeout, BuildTrace, BuildExpectationResult, ExecutionScenarioResult, @@ -54,11 +55,20 @@ export const expectationResultsSchema = z.array( }), ); +const buildTimeoutSchema = z.object({ + kind: z.enum(['turn', 'conversation', 'inactivity']), + turn: z.number(), + elapsedMs: z.number(), +}); + const targetOutputSchema = z.object({ buildSuccess: z.boolean().default(false), passed: z.boolean().default(false), score: z.number().default(0), reasoning: z.string().default(''), + /** The budget that ended the conversation, when one did (harness/timeouts.ts). + * Repeats on every row of the case's build. */ + buildTimeout: buildTimeoutSchema.optional(), workflowId: z.string().optional(), scenarioWorkflowId: z.string().optional(), /** Set when the scenario ran against a built first-class Agent instead of a workflow. */ @@ -280,6 +290,7 @@ export function reshapeLangSmithRuns( let buildTrace: BuildTrace | undefined; let buildCostUsd: number | undefined; let buildTurns: number | undefined; + let buildTimeout: BuildTimeout | undefined; for (const scenario of testCase.executionScenarios ?? []) { const run = byKey.get(`${String(iter)}/${fileSlug}/${scenario.name}`); @@ -315,6 +326,7 @@ export function reshapeLangSmithRuns( // Every row of the case repeats the build's spend — first defined wins. buildCostUsd ??= output.buildCostUsd; buildTurns ??= output.buildTurns; + buildTimeout ??= output.buildTimeout; executionScenarioResults.push({ scenario, success: output.passed, @@ -348,6 +360,7 @@ export function reshapeLangSmithRuns( buildTrace = output.buildTrace; buildCostUsd = output.buildCostUsd; buildTurns = output.buildTurns; + buildTimeout = output.buildTimeout; } } @@ -363,6 +376,7 @@ export function reshapeLangSmithRuns( agentArtifact, executionScenarioResults, buildError, + buildTimeout, threadId, transcript, buildExpectationResults, diff --git a/packages/@n8n/instance-ai/evaluations/types.ts b/packages/@n8n/instance-ai/evaluations/types.ts index 03c7b848444..73e7aea6e45 100644 --- a/packages/@n8n/instance-ai/evaluations/types.ts +++ b/packages/@n8n/instance-ai/evaluations/types.ts @@ -41,6 +41,24 @@ export interface ChecklistResult { rootCause?: string; } +// --------------------------------------------------------------------------- +// Build conversation timeouts +// --------------------------------------------------------------------------- + +/** Which budget ended a build conversation. `turn`: one user turn overran its + * budget. `conversation`: the whole conversation overran its budget. + * `inactivity`: a run in flight emitted no event for the inactivity bound (a + * stalled model stream). */ +export type RunTimeoutKind = 'turn' | 'conversation' | 'inactivity'; + +export interface BuildTimeout { + kind: RunTimeoutKind; + /** User turn (1-based) in flight, or about to start, when the budget fired. */ + turn: number; + /** Time measured by the budget that fired. */ + elapsedMs: number; +} + // --------------------------------------------------------------------------- // SSE event capture // --------------------------------------------------------------------------- @@ -343,6 +361,9 @@ export interface WorkflowTestCaseResult { agentArtifact?: AgentArtifact; workflowBuildSuccess: boolean; buildError?: string; + /** Set when a budget ended the conversation. The build fields describe what + * was saved before it fired; the scenarios ran against that. */ + buildTimeout?: BuildTimeout; executionScenarioResults: ExecutionScenarioResult[]; /** The built workflow JSON — saved for debugging and cross-run comparison */ workflowJson?: WorkflowResponse; diff --git a/packages/@n8n/instance-ai/package.json b/packages/@n8n/instance-ai/package.json index 4335785cdbe..add91a691bc 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.26.0", + "version": "1.27.0", "scripts": { "clean": "rimraf dist .turbo", "typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json", diff --git a/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md b/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md index dc28308b595..57103f30dd4 100644 --- a/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md +++ b/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md @@ -59,6 +59,20 @@ first action turn (each extra sequential turn resends the whole context). When unsure which nodes to use, load this skill first and follow its research process below. +## Early service connections + +When the `credentials(action="setup")` schema offers `filePath`, announce +known service credentials immediately after the first node definitions or +credential type search. Do this before detailed planning, SDK research, or +source generation. Do not wait for every service to be resolved. + +Pick the source `filePath` and call credential setup with the complete list +of types known so far. Make it the only tool call in that response. Wait for +its successful `preBuild: true` result before continuing. Add later discoveries +with another complete list. Do not batch setup with source writing. + +When early setup is unavailable, keep the post-build setup flow below. + ## Repair Strategy When the edit is to fix a node the user reports as erroring or showing a red @@ -97,10 +111,12 @@ resources, credentials, channel IDs, or timezone; use placeholders or unresolved for a capability the user did not name, discover coverage first and use a Gateway credits–covered node instead of asking when the user has no credential for a comparable tool (see Gateway credits -Preference). Setup details — recipients, accounts, -resources, channels, credentials, timezone — belong in placeholders or -unresolved `newCredential()` calls until post-build setup. After the first -build, use `ask-user` when stuck or genuinely ambiguous; do not retry the same +Preference). Setup details such as recipients, accounts, resources, +channels, credentials, and timezone belong in placeholders or +unresolved `newCredential()` calls. Announce known credentials through early +setup when available, then continue building without waiting for the user. +After the first build, resolve remaining setup and use `ask-user` when stuck +or when choices are ambiguous; do not retry the same failing approach more than twice. Never re-ask an answered, deferred, or skipped question. A skip grants no additional permission. Choose defaults only for unspecified details within the requested task. If a skipped question seeks diff --git a/packages/@n8n/instance-ai/src/agent/system-prompt.ts b/packages/@n8n/instance-ai/src/agent/system-prompt.ts index e9d6970ee37..cec1a883c3f 100644 --- a/packages/@n8n/instance-ai/src/agent/system-prompt.ts +++ b/packages/@n8n/instance-ai/src/agent/system-prompt.ts @@ -203,7 +203,7 @@ If the user asks for a blocked operation, explain that the instance is in read-o */ function getCredentialSetupBullet(setupPanelEnabled?: boolean): string { if (setupPanelEnabled) { - return '**Credential setup** uses `workflows(action="setup")` when a workflowId is available. Requirements can appear in the setup panel while the workflow is being built. The user can complete them immediately. Do not describe setup as happening only after the build. When the result has `announced: true`, the setup panel lists the remaining credentials and parameters. Summarize that result, report any validation warnings, and end your turn. Other results need their returned guidance: correct validation errors, respect denials and skipped items, and wait for requested destination approvals. Explicit credential replacement and an already-open setup card keep their card flow, including apply and test-trigger results. Do not treat a resumed card as a panel announcement. Each new user turn carries a `` block with current configuration; trust it over older tool results. Configuration alone does not prove successful testing. Use `credentials(action="setup")` when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor, and never ask the user to paste secrets into chat.'; + return '**Early credential setup**: announce each explicitly requested service as soon as its exact credential type is known from node definitions or credential type search. Do this before detailed workflow planning, SDK research, or reasoning about implementation. Do not wait to identify every service. Call `credentials(action="setup", filePath, workflowName, credentials)` as the only tool call in that response. Wait for its successful `preBuild: true` result before planning the implementation or generating source. Never batch this call with workspace writes or build-workflow. Pick the source filePath now and reuse it throughout the build. Include workflowId for an existing workflow and folderPath when creating in a known folder. This creates the workflow context and makes setup actionable immediately. Use the announced credential types when configuring matching nodes. Announce only supported service-specific types; leave ambiguous services and generic authentication to build-time discovery. Pass the complete known requirement list again if the plan changes, including an empty list if all requirements are dropped. Do not wait for the user to connect. The early result has `preBuild: true`: continue generating source, omit folderPath from build-workflow, and do not end your turn. This early step overrides runtime skill guidance that defers all setup until after building. After the build, **credential setup** uses `workflows(action="setup")` when a workflowId is available. Requirements can appear in the setup panel while the workflow is being built. The user can complete them immediately. Do not describe setup as happening only after the build. When the result has `announced: true` without `preBuild: true`, the setup panel lists the remaining credentials and parameters. Summarize that result, report any validation warnings, and end your turn. Other results need their returned guidance: correct validation errors, respect denials and skipped items, and wait for requested destination approvals. Explicit credential replacement and an already-open setup card keep their card flow, including apply and test-trigger results. Do not treat a resumed card as a panel announcement. Each new user turn carries a `` block with current configuration; trust it over older tool results. Configuration alone does not prove successful testing. Use `credentials(action="setup")` without filePath when the user explicitly asks to create a credential outside of any workflow context. Early credential announcements and final workflow setup are separate stages; do not repeat either without changed requirements. Never describe workflow setup as something the user starts from the canvas or editor, and never ask the user to paste secrets into chat.'; } return '**Credential setup** uses `workflows(action="setup")` when a workflowId is available — it opens the inline setup card in the n8n Assistant panel and handles credentials, parameters, and triggers in one step. Use `credentials(action="setup")` only when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor. Setup cards are only open while the setup call is pending — once it returns a result, the card is resolved: describe the outcome (e.g. credentials selected and ready), never that a card is open or that the user still needs to authorize. When a node in `nodesStillNeedingSetup` carries `parameterIssues`, the connected credential can\'t reach the value that was configured (e.g. a model outside what the credential allows) — fix the value, then tell the user plainly which value didn\'t work and what you set instead. Never silently swap a model or other parameter without saying so. Nodes listed under `skippedByUser` are different: the user chose to skip them, so never re-open the setup card for those — say what stays unconfigured and offer to set it up later.'; } diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts index 27f2b1bf0df..1edf888a63e 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts @@ -6,6 +6,7 @@ import type { Mock } from 'vitest'; import { executeTool } from '../../__tests__/tool-test-utils'; import type { InstanceAiContext, CredentialSummary, CredentialDetail } from '../../types'; import { createCredentialsTool, type CredentialAction } from '../credentials.tool'; +import { prepareWorkflowSetup } from '../workflows/prepare-workflow-setup'; import type { SetupRequest } from '../workflows/setup-workflow.schema'; import { analyzeWorkflow } from '../workflows/setup-workflow.service'; @@ -14,6 +15,8 @@ vi.mock('../workflows/setup-workflow.service', async (importOriginal) => ({ analyzeWorkflow: vi.fn(async () => await Promise.resolve([])), })); +vi.mock('../workflows/prepare-workflow-setup', () => ({ prepareWorkflowSetup: vi.fn() })); + // ── Helpers ────────────────────────────────────────────────────────────────── function createMockContext( @@ -954,6 +957,7 @@ describe('credentials tool', () => { describe('setup action — setup panel announcement', () => { beforeEach(() => { vi.mocked(analyzeWorkflow).mockReset().mockResolvedValue([]); + vi.mocked(prepareWorkflowSetup).mockReset(); }); function panelContext(overrides: Parameters[0] = {}) { @@ -983,6 +987,105 @@ describe('credentials tool', () => { ).toBe(true); }); + it('exposes early setup fields only when the panel is enabled', () => { + const control = inputJsonSchema(createCredentialsTool(createMockContext())); + const { context } = panelContext(); + const panel = inputJsonSchema(createCredentialsTool(context)); + const withFolders = inputJsonSchema( + createCredentialsTool({ + ...context, + folderExplorationEnabled: true, + }), + ); + + expect(control.properties).not.toHaveProperty('filePath'); + expect(control.properties).not.toHaveProperty('workflowName'); + expect(control.properties).not.toHaveProperty('folderPath'); + expect(panel.properties).toHaveProperty('filePath'); + expect(panel.properties).toHaveProperty('workflowName'); + expect(panel.properties).not.toHaveProperty('folderPath'); + expect(withFolders.properties).toHaveProperty('folderPath'); + }); + + it.each([ + { credentials: [{ credentialType: 'slackApi', preferNew: true }] }, + { credentials: [] }, + ])( + 'prepares source-bound setup without suspending for requirements %j', + async ({ credentials }) => { + const { context, emitter } = panelContext({ folderExplorationEnabled: true }); + const suspend = vi.fn(); + const prepared = { + success: true, + announced: true, + preBuild: true, + workflowId: 'wf-early', + filePath: 'src/workflows/main.ts', + message: 'Continue the build', + }; + vi.mocked(prepareWorkflowSetup).mockResolvedValue(prepared); + const input = { + action: 'setup', + filePath: prepared.filePath, + workflowName: 'Daily update', + folderPath: 'Notifications', + credentials, + }; + const tool = createCredentialsTool(context); + + expect(getInputSchema(tool).safeParse(input).success).toBe(true); + const result = await executeTool(tool, input, suspendCtx(suspend)); + + expect(result).toEqual(prepared); + expect(prepareWorkflowSetup).toHaveBeenCalledWith(context, input); + expect(suspend).not.toHaveBeenCalled(); + expect(emitter.merge).not.toHaveBeenCalled(); + }, + ); + + it('does not report early setup as announced when persistence fails', async () => { + const { context } = panelContext(); + const suspend = vi.fn(); + vi.mocked(prepareWorkflowSetup).mockRejectedValue(new Error('Could not persist setup')); + + const result = await executeTool( + createCredentialsTool(context), + { + action: 'setup', + filePath: 'src/workflows/main.ts', + workflowName: 'Daily update', + credentials: [{ credentialType: 'slackApi' }], + }, + suspendCtx(suspend), + ); + + expect(result).toEqual({ + success: false, + announced: false, + message: 'Could not persist setup', + }); + expect(suspend).not.toHaveBeenCalled(); + }); + + it('validates registered credential types before creating early workflow context', async () => { + const { context } = panelContext(); + context.credentialService.credentialTypeExists = vi.fn().mockResolvedValue(false); + + const result = await executeTool( + createCredentialsTool(context), + { + action: 'setup', + filePath: 'src/workflows/main.ts', + workflowName: 'Daily update', + credentials: [{ credentialType: 'slackApiTypo' }], + }, + suspendCtx(), + ); + + expect(result).toMatchObject({ error: 'unknown_credential_type' }); + expect(prepareWorkflowSetup).not.toHaveBeenCalled(); + }); + it('should announce the credentials to the setup panel and return without suspending', async () => { const { context, emitter } = panelContext(); (context.credentialService.list as Mock).mockResolvedValue([ @@ -1167,8 +1270,28 @@ describe('credentials tool', () => { expect(emitter.merge).not.toHaveBeenCalled(); }); - it('should keep the card when an entry asks for a new credential', async () => { + it.each(['bound', 'unreadable'])('keeps the card for %s workflows', async (state) => { const { context, emitter } = panelContext(); + vi.mocked(analyzeWorkflow).mockResolvedValue([ + { + node: { + name: 'Slack', + type: 'n8n-nodes-base.slack', + typeVersion: 2, + id: 'slack-node', + position: [0, 0], + parameters: {}, + credentials: { slackApi: { id: 'old-account', name: 'Old account' } }, + }, + credentialType: 'slackApi', + needsAction: false, + credentialNeedsAction: false, + isTrigger: false, + }, + ]); + if (state === 'unreadable') { + vi.mocked(analyzeWorkflow).mockRejectedValue(new Error('workflow unavailable')); + } const suspendFn = vi.fn(); await executeTool( @@ -1192,6 +1315,25 @@ describe('credentials tool', () => { ); }); + it('announces a first account requested through the credential tool without a card', async () => { + const { context, emitter } = panelContext(); + const suspend = vi.fn(); + const result = await executeTool( + createCredentialsTool(context), + { + action: 'setup', + workflowId: 'wf-1', + credentials: [{ credentialType: 'slackApi', preferNew: true }], + }, + suspendCtx(suspend), + ); + expect(result).toMatchObject({ success: true, announced: true }); + expect(suspend).not.toHaveBeenCalled(); + expect(emitter.merge).toHaveBeenCalledWith('wf-1', [ + expect.objectContaining({ credentialType: 'slackApi', preferNew: true }), + ]); + }); + it('should still announce when the emitter fails', async () => { const { context, emitter } = panelContext(); emitter.merge.mockImplementation(() => { diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts index 9202eb42c4f..104784b8842 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts @@ -3904,7 +3904,14 @@ describe('workflows(action="setup") — setup panel', () => { }); it('keeps explicit credential replacement in the selection card', async () => { - const replacement = { ...openSlack, preferNewCredential: true }; + const replacement = { + ...openSlack, + node: { + ...openSlack.node, + credentials: { slackApi: { id: 'old-account', name: 'Old account' } }, + }, + preferNewCredential: true, + }; (analyzeWorkflow as Mock).mockResolvedValue([replacement, boundGmail]); const { context, emitter, markWorkflowSetupHandled } = panelContext(); const suspend = vi.fn(); @@ -3920,6 +3927,73 @@ describe('workflows(action="setup") — setup panel', () => { expect(markWorkflowSetupHandled).not.toHaveBeenCalled(); }); + it('announces unbound new-account requirements without opening a setup card', async () => { + (analyzeWorkflow as Mock).mockResolvedValue([ + { ...openSlack, preferNewCredential: true }, + boundGmail, + sheetParams, + ]); + const { context, emitter, markWorkflowSetupHandled } = panelContext(); + const suspend = vi.fn(); + const result = await executeTool( + createWorkflowsTool(context, 'full'), + { action: 'setup', workflowId: 'wf1', preferNewCredentials: ['slackApi'] }, + { suspend, resumeData: undefined } as never, + ); + expect(result).toMatchObject({ success: true, announced: true }); + expect(suspend).not.toHaveBeenCalled(); + expect(emitter.announce).toHaveBeenCalledWith( + 'wf1', + expect.arrayContaining([ + expect.objectContaining({ credentialType: 'slackApi', preferNew: true }), + expect.objectContaining({ credentialType: 'gmailOAuth2' }), + expect.objectContaining({ parameterNames: ['documentId'] }), + ]), + ); + expect(markWorkflowSetupHandled).toHaveBeenCalledWith('wf1'); + }); + + it('does not reopen an account selected during the current build', async () => { + (analyzeWorkflow as Mock).mockResolvedValue([boundGmail]); + const { context } = panelContext({ + runId: 'run-early', + threadId: 'thread-early', + threadMemory: { + getThread: vi.fn().mockResolvedValue({ + id: 'thread-early', + resourceId: 'user-1', + createdAt: new Date(), + updatedAt: new Date(), + metadata: { + instanceAiWorkflowSourceFiles: { + 'src/workflows/main.ts': { + filePath: 'src/workflows/main.ts', + workflowId: 'wf1', + setupPreferences: { + runId: 'run-early', + satisfiedCredentialTypes: ['gmailOAuth2'], + preferNewCredentialTypes: [], + }, + }, + }, + }, + }), + saveThread: vi.fn().mockResolvedValue(undefined), + }, + }); + const suspend = vi.fn(); + const result = await executeTool( + createWorkflowsTool(context, 'full'), + { action: 'setup', workflowId: 'wf1', preferNewCredentials: ['gmailOAuth2'] }, + { suspend, resumeData: undefined } as never, + ); + expect(result).toMatchObject({ success: true, announced: true, open: [] }); + expect(suspend).not.toHaveBeenCalled(); + expect(analyzeWorkflow).toHaveBeenCalledWith(context, 'wf1', undefined, { + includeSettled: true, + }); + }); + it('keeps failed connection checks in the announcement', async () => { (analyzeWorkflow as Mock).mockResolvedValue([ { ...boundGmail, credentialTestResult: { success: false, message: 'Connection failed' } }, diff --git a/packages/@n8n/instance-ai/src/tools/credentials.tool.ts b/packages/@n8n/instance-ai/src/tools/credentials.tool.ts index cac68a5198e..c43ec0eddc2 100644 --- a/packages/@n8n/instance-ai/src/tools/credentials.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/credentials.tool.ts @@ -24,10 +24,13 @@ import { GENERIC_AUTH_CREDENTIAL_TYPES, N8N_CONNECT_DISPLAY_NAME, } from './workflows/credential-utils'; +import { prepareWorkflowSetup } from './workflows/prepare-workflow-setup'; +import { filterSatisfiedSetupCredentialTypes } from './workflows/setup-credential-selections'; import { buildSetupItemsFromCredentialRequests, buildSetupItemsFromAnnouncement, isSetupPanelEnabled, + requestsCredentialReplacement, } from './workflows/setup-items'; import { analyzeWorkflow } from './workflows/setup-workflow.service'; @@ -435,6 +438,33 @@ const setupAction = z.object({ ), }); +const earlySetupAction = setupAction.extend({ + action: z + .literal('setup') + .describe( + 'Announce workflow credential requirements without waiting by passing filePath before generating source. Omit filePath for existing workflow or standalone setup. Follow the returned guidance: announced means continue working, while a resumed card reports the completed interaction.', + ), + filePath: z + .string() + .optional() + .describe( + 'Before writing workflow source, announce known service credentials with its planned workspace filePath. This creates or reuses the workflow context and returns without waiting for setup. Pass the complete current requirement list, including an empty list when a plan drops all services. Omit for standalone credential setup.', + ), + workflowName: z + .string() + .optional() + .describe('Name of the new workflow, required on its first early setup call.'), +}); + +const earlySetupActionWithFolder = earlySetupAction.extend({ + folderPath: z + .string() + .optional() + .describe( + 'Folder for the new workflow. Pass the intended folder here before building; omit it on the later build-workflow call.', + ), +}); + const testAction = z.object({ action: z .literal('test') @@ -485,9 +515,16 @@ function getCredentialActions(options: CredentialsToolOptions): CredentialAction return CREDENTIAL_ACTION_ORDER.filter((action) => allowedActions.has(action)); } -function createCredentialInputSchema(actions: readonly CredentialAction[]) { - const actionSchemas: CredentialActionSchema[] = actions.map( - (action) => CREDENTIAL_ACTION_SCHEMAS[action], +function createCredentialInputSchema( + actions: readonly CredentialAction[], + context: InstanceAiContext, +) { + const actionSchemas: CredentialActionSchema[] = actions.map((action) => + action === 'setup' && isSetupPanelEnabled(context) + ? context.folderExplorationEnabled + ? earlySetupActionWithFolder + : earlySetupAction + : CREDENTIAL_ACTION_SCHEMAS[action], ); if (actionSchemas.length === 0) { @@ -515,11 +552,11 @@ type Input = | z.infer | z.infer | z.infer - | z.infer + | z.infer | z.infer; -function buildInputSchema(options: CredentialsToolOptions) { - return createCredentialInputSchema(getCredentialActions(options)); +function buildInputSchema(options: CredentialsToolOptions, context: InstanceAiContext) { + return createCredentialInputSchema(getCredentialActions(options), context); } function formatActionList(actions: readonly CredentialAction[]): string { @@ -764,6 +801,7 @@ async function announceSetupItems( context: InstanceAiContext & { setupItemsEmitter: SetupItemsEmitter }, input: Extract, workflowId: string, + analyzedRequests?: Awaited>, ) { const credentials = input.credentials ?? []; // Best-effort, like the build's emission: the panel is a side channel. @@ -771,9 +809,11 @@ async function announceSetupItems( // Announce against the saved workflow's analysis so generic auth types // land on their per-node rows and the snapshot stays whole. A merge, not // a replace: earlier announcements may list types no saved node uses yet. - const analyzed = await analyzeWorkflow(context, workflowId, undefined, { - includeSettled: true, - }); + const analyzed = + analyzedRequests ?? + (await analyzeWorkflow(context, workflowId, undefined, { + includeSettled: true, + })); context.setupItemsEmitter.merge( workflowId, buildSetupItemsFromAnnouncement(workflowId, credentials, analyzed), @@ -834,7 +874,7 @@ async function handleSetup( const resumeData = ctx.resumeData; const isFinalize = input.credentialFlow?.stage === 'finalize'; - if (!input.credentials || input.credentials.length === 0) { + if (!input.credentials || (input.credentials.length === 0 && !input.filePath)) { return { error: 'missing_credentials', message: @@ -882,21 +922,63 @@ async function handleSetup( problems: hintProblems, }; } + if (isSetupPanelEnabled(context) && input.filePath) { + if (isFinalize || input.requireUserSelection) { + return { + success: false, + message: + 'Use early setup for known service requirements. Explicit credential replacement keeps the existing setup flow.', + }; + } + try { + return await prepareWorkflowSetup(context, { ...input, filePath: input.filePath }); + } catch (error) { + return { + success: false, + announced: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } - // Setup panel v2: a workflow's credential needs are announced to the - // persistent panel and the turn continues — no card, no suspension. The - // finalize stage, standalone setup (no workflow), and an explicit user - // choice — the picker (`requireUserSelection`) or a fresh credential - // (`preferNew`) — keep the card: the panel has no way to express "replace - // the bound one", so the next save would silently re-attach it. - const keepsCard = - isFinalize || - input.requireUserSelection === true || - input.credentials.some((req: { preferNew?: boolean }) => req.preferNew === true); + // Finalization and explicit selection keep their card. Workflow requirements + // use the panel unless the request replaces an account already bound there. + const keepsCard = isFinalize || input.requireUserSelection === true; if (isSetupPanelEnabled(context) && !keepsCard) { const panelWorkflowId = resolveSetupPanelWorkflowId(context, input); if (panelWorkflowId !== undefined) { - return await announceSetupItems(context, input, panelWorkflowId); + try { + const preferNewTypes = await filterSatisfiedSetupCredentialTypes( + context, + panelWorkflowId, + input.credentials + .filter((request) => request.preferNew) + .map((request) => request.credentialType), + ); + const analyzed = preferNewTypes?.length + ? await analyzeWorkflow(context, panelWorkflowId, undefined, { includeSettled: true }) + : undefined; + if (!analyzed || !requestsCredentialReplacement(analyzed, preferNewTypes)) { + return await announceSetupItems( + context, + { + ...input, + credentials: input.credentials.map((request) => + request.preferNew + ? { ...request, preferNew: preferNewTypes?.includes(request.credentialType) } + : request, + ), + }, + panelWorkflowId, + analyzed, + ); + } + } catch (error) { + context.logger.warn('Failed to check credential replacement; using setup card', { + workflowId: panelWorkflowId, + error: error instanceof Error ? error.message : String(error), + }); + } } } @@ -1124,7 +1206,7 @@ export function createCredentialsTool( context: InstanceAiContext, options: CredentialsToolOptions = {}, ) { - const inputSchema = buildInputSchema(options); + const inputSchema = buildInputSchema(options, context); return new Tool(CREDENTIALS_TOOL_ID) .description(getToolDescription(options, context.credentialDescriptionsEnabled === true)) diff --git a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts index 52c1f76ba40..bc44aa13ea1 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts @@ -32,7 +32,8 @@ import { describeSavedPublishState, type SavedWorkflowState, } from './workflows/saved-workflow-state'; -import { isSetupPanelEnabled } from './workflows/setup-items'; +import { filterSatisfiedSetupCredentialTypes } from './workflows/setup-credential-selections'; +import { isSetupPanelEnabled, requestsCredentialReplacement } from './workflows/setup-items'; import { describeSetupItem, rememberWorkflowSetupState, @@ -1586,8 +1587,13 @@ async function handleSetup( // The setup panel lists bound slots too (rendered as done), so its snapshot // needs the settled requests the card logic below must not see. const setupPanelEnabled = isSetupPanelEnabled(context); + const preferNewCredentialTypes = await filterSatisfiedSetupCredentialTypes( + context, + input.workflowId, + input.preferNewCredentials, + ); const analyzedRequests = await analyzeWorkflow(context, input.workflowId, undefined, { - ...preferNewCredentialOptions(input), + ...(preferNewCredentialTypes?.length ? { preferNewCredentialTypes } : {}), ...(setupPanelEnabled ? { includeSettled: true } : {}), }); const allSetupRequests = setupPanelEnabled @@ -1735,8 +1741,11 @@ async function handleSetup( // Setup panel v2: announce the final checklist and return. The user // completes it in the panel; the turn ends with the agent's summary. - // Replacement needs an explicit selection. A saved binding already appears done in the panel. - if (isSetupPanelEnabled(context) && !input.preferNewCredentials?.length) { + // Only a bound account needs the explicit replacement card. + if ( + isSetupPanelEnabled(context) && + !requestsCredentialReplacement(analyzedRequests, preferNewCredentialTypes) + ) { return await announceWorkflowSetup(context, input.workflowId, analyzedRequests); } diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts index 3cfcf70b1d2..9dc723e4897 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts @@ -1,4 +1,9 @@ import { isZodSchema } from '@n8n/agents'; +import { + instanceAiSetupCredentialAppliedKey, + instanceAiSetupCredentialSelectionKey, + type InstanceAiSetupCredentialSelection, +} from '@n8n/api-types'; import { executeTool } from '../../../__tests__/tool-test-utils'; import { FolderResolutionError } from '../../../errors/folder-resolution.error'; @@ -8,6 +13,7 @@ import { loadInstanceAiRuntimeSkillSource, loadInstanceAiRuntimeSkillSourceForBuildMode, } from '../../../skills/runtime-skills'; +import type { ThreadRecord } from '../../../storage/thread-patch'; import { emitTraceOnlyChildRun } from '../../../tracing/langsmith-tracing'; import type { InstanceAiContext } from '../../../types'; import type { WorkflowBuildOutcome } from '../../../workflow-loop/workflow-loop-state'; @@ -16,9 +22,10 @@ import { buildWorkflowInputSchema, createBuildWorkflowTool, } from '../build-workflow.tool'; +import { prepareWorkflowSetup } from '../prepare-workflow-setup'; import { buildCredentialMap, resolveCredentials } from '../resolve-credentials'; import type { SetupRequest } from '../setup-workflow.schema'; -import { analyzeWorkflow } from '../setup-workflow.service'; +import { analyzeWorkflow, getValidCredentialTypes } from '../setup-workflow.service'; import { getWorkflowSourceFileBinding, hashWorkflowSource } from '../workflow-file-bindings'; import { ensureWebhookIds } from '../workflow-json-utils'; import { compileWorkflowSource } from '../workflow-source-compiler'; @@ -89,6 +96,7 @@ vi.mock('../resolve-credentials', () => ({ vi.mock('../setup-workflow.service', () => ({ analyzeWorkflow: vi.fn(async () => await Promise.resolve([])), + getValidCredentialTypes: vi.fn(async () => new Set()), stripStaleCredentialsFromWorkflow: vi.fn(async () => await Promise.resolve()), })); @@ -1502,6 +1510,152 @@ describe('createBuildWorkflowTool', () => { ); }); + it('continues a stopped early setup draft with the same file and selected account', async () => { + const filePath = 'src/workflows/main.workflow.ts'; + const setupItemId = 'wf-early:credential:slackApi'; + const selection: InstanceAiSetupCredentialSelection = { + selectionId: 'selected-1', + credentialType: 'slackApi', + credentialId: 'selected-account', + }; + const appliedKey = instanceAiSetupCredentialAppliedKey(setupItemId, selection.selectionId); + let thread: ThreadRecord = { + id: 'thread-1', + resourceId: 'user-1', + createdAt: new Date(), + updatedAt: new Date(), + metadata: { + [instanceAiSetupCredentialSelectionKey(setupItemId)]: selection, + }, + }; + const { context } = makeContext({ + filePath, + overrides: { + threadId: thread.id, + runId: 'early-run', + threadMemory: { + getThread: vi.fn(async () => thread), + saveThread: vi.fn(async (updated: ThreadRecord) => (thread = updated)), + }, + setupItemsEmitter: { + emit: vi.fn(() => true), + announce: vi.fn().mockResolvedValue(undefined), + merge: vi.fn(() => true), + lastWorkflowId: vi.fn(), + workflowIds: vi.fn(() => []), + }, + }, + }); + let isTemporary = true; + let isArchived = false; + vi.mocked(context.workflowService.clearAiTemporary).mockImplementation(async () => { + isTemporary = false; + }); + context.workflowService.archiveIfAiTemporary = vi.fn(async () => { + isArchived = isTemporary; + return isArchived; + }); + vi.mocked(context.workflowService.createFromWorkflowJSON).mockResolvedValue( + await context.workflowService.get('wf-early'), + ); + const requirements = [{ credentialType: 'slackApi', preferNew: true }]; + await prepareWorkflowSetup(context, { + filePath, + workflowName: 'Slack updates', + credentials: requirements, + }); + await context.workflowService.archiveIfAiTemporary('wf-early'); + const resumedContext = { + ...context, + runId: 'continued-run', + aiCreatedWorkflowIds: new Set(), + }; + await prepareWorkflowSetup(resumedContext, { filePath, credentials: requirements }); + + vi.mocked(compileWorkflowSource).mockResolvedValueOnce({ + success: true, + compiler: 'sandbox-tsx', + warnings: [], + workflow: { + name: 'Slack updates', + connections: {}, + nodes: [ + { + id: 'slack-1', + name: 'Slack', + type: 'n8n-nodes-base.slack', + typeVersion: 2, + position: [0, 0], + credentials: { slackApi: { id: 'source-account', name: 'Source account' } }, + }, + ], + }, + }); + vi.mocked(buildCredentialMap).mockResolvedValueOnce( + new Map([ + [ + 'slackApi', + [ + { id: 'source-account', name: 'Source account', type: 'slackApi' }, + { id: 'selected-account', name: 'Selected account', type: 'slackApi' }, + ], + ], + ]), + ); + vi.mocked(getValidCredentialTypes).mockResolvedValue(new Set(['slackApi'])); + const actualResolver = + await vi.importActual('../resolve-credentials'); + vi.mocked(resolveCredentials).mockImplementationOnce(actualResolver.resolveCredentials); + vi.mocked(context.workflowService.updateFromWorkflowJSON).mockImplementationOnce( + async (workflowId) => { + if (isArchived) throw new Error('Cannot update an archived workflow.'); + expect(thread.metadata?.[appliedKey]).toBeUndefined(); + return { + id: workflowId, + name: 'Slack updates', + versionId: 'v-saved', + activeVersionId: null, + isArchived: false, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + nodes: [], + connections: {}, + checksum: 'checksum-saved', + }; + }, + ); + + const result = await executeTool(createBuildWorkflowTool(resumedContext), { + filePath, + preferNewCredentials: ['slackApi'], + }); + + expect(result).toMatchObject({ success: true, workflowId: 'wf-early' }); + expect(context.workflowService.createFromWorkflowJSON).toHaveBeenCalledTimes(1); + expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledWith( + 'wf-early', + expect.objectContaining({ + nodes: [ + expect.objectContaining({ + credentials: { slackApi: { id: 'selected-account', name: 'Selected account' } }, + }), + ], + }), + { expectedChecksum: 'checksum-current' }, + ); + expect(thread.metadata?.[appliedKey]).toBe(true); + await expect(getWorkflowSourceFileBinding(resumedContext, filePath)).resolves.toMatchObject({ + workflowId: 'wf-early', + workflowChecksum: 'checksum-saved', + setupPending: undefined, + setupPreferences: { + runId: 'continued-run', + satisfiedCredentialTypes: ['slackApi'], + preferNewCredentialTypes: [], + }, + }); + }); + it('updates a workflow created earlier in the run without requesting approval', async () => { const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined); const { context, filePath } = makeContext({ diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/prepare-workflow-setup.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/prepare-workflow-setup.test.ts new file mode 100644 index 00000000000..8340161813a --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/prepare-workflow-setup.test.ts @@ -0,0 +1,270 @@ +import type { InstanceAiSetupItem } from '@n8n/api-types'; +import { mock } from 'vitest-mock-extended'; + +import type { InstanceAiEventBus } from '../../../event-bus/event-bus.interface'; +import type { ThreadRecord } from '../../../storage/thread-patch'; +import type { InstanceAiContext } from '../../../types'; +import { prepareWorkflowSetup } from '../prepare-workflow-setup'; +import { createSetupItemsEmitter } from '../setup-items'; +import { analyzeWorkflow } from '../setup-workflow.service'; +import { getWorkflowSourceFileBinding } from '../workflow-file-bindings'; + +vi.mock('../setup-workflow.service', () => ({ analyzeWorkflow: vi.fn(async () => []) })); + +const filePath = 'src/workflows/slack.workflow.ts'; +const requirements = [{ credentialType: 'slackApi', reason: 'Send daily updates' }]; + +function createContext() { + let thread: ThreadRecord = { + id: 'thread-1', + resourceId: 'user-1', + metadata: { unrelated: 'retained' }, + createdAt: new Date(), + updatedAt: new Date(), + }; + const snapshots = new Map(); + const publish = vi.fn((_threadId, event) => { + if (event.type === 'setup-items') snapshots.set(event.payload.workflowId, event.payload.items); + }); + const threadMemory = { + patchThread: undefined, + getThread: vi.fn(async () => thread), + saveThread: vi.fn(async (updated: ThreadRecord) => (thread = updated)), + }; + const workflowService = mock({ + createFromWorkflowJSON: vi.fn().mockResolvedValue({ + id: 'wf-1', + versionId: 'version-1', + checksum: 'checksum-1', + }), + get: vi.fn().mockResolvedValue({ + id: 'wf-1', + versionId: 'version-2', + checksum: 'checksum-2', + }), + }); + const context = mock({ + threadId: thread.id, + runId: undefined, + workspaceRoot: undefined, + aiCreatedWorkflowIds: undefined, + permissions: mock(), + logger: mock(), + threadMemory, + workflowService, + setupItemsEmitter: createSetupItemsEmitter({ + eventBus: { publish }, + threadId: thread.id, + runId: 'run-1', + agentId: 'orchestrator', + readPersistedSnapshot: async (workflowId) => snapshots.get(workflowId), + }), + }); + return { context, threadMemory, workflowService, snapshots, publish }; +} + +describe('prepareWorkflowSetup', () => { + beforeEach(() => vi.clearAllMocks()); + + it('persists and promotes the draft before announcing its requirements', async () => { + const { context, workflowService, threadMemory, snapshots, publish } = createContext(); + + const result = await prepareWorkflowSetup(context, { + filePath: `./${filePath}`, + workflowName: 'Daily update', + folderPath: 'Notifications', + credentials: requirements, + }); + + expect(result).toMatchObject({ success: true, announced: true, workflowId: 'wf-1', filePath }); + expect(workflowService.createFromWorkflowJSON).toHaveBeenCalledWith( + { name: 'Daily update', nodes: [], connections: {} }, + { markAsAiTemporary: true, folderPath: 'Notifications' }, + ); + expect(threadMemory.saveThread.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(workflowService.clearAiTemporary).mock.invocationCallOrder[0], + ); + expect(vi.mocked(workflowService.clearAiTemporary).mock.invocationCallOrder[0]).toBeLessThan( + publish.mock.invocationCallOrder[0], + ); + expect(await getWorkflowSourceFileBinding({ ...context }, filePath)).toMatchObject({ + workflowId: 'wf-1', + workflowVersionId: 'version-1', + setupPending: true, + }); + expect((await threadMemory.getThread()).metadata).toHaveProperty('unrelated', 'retained'); + expect(context.aiCreatedWorkflowIds).toContain('wf-1'); + expect(snapshots.get('wf-1')).toEqual([ + { id: 'wf-1:credential:slackApi', kind: 'credential', ...requirements[0] }, + ]); + expect(analyzeWorkflow).not.toHaveBeenCalled(); + }); + + it('reuses the persisted workflow after refresh and removes dropped requirements', async () => { + const { context, workflowService, snapshots } = createContext(); + await prepareWorkflowSetup(context, { + filePath, + workflowName: 'Daily update', + credentials: requirements, + }); + + const refreshedContext = { ...context }; + await prepareWorkflowSetup(refreshedContext, { + filePath, + credentials: [{ credentialType: 'gmailOAuth2' }], + }); + expect(snapshots.get('wf-1')).toEqual([ + { id: 'wf-1:credential:gmailOAuth2', kind: 'credential', credentialType: 'gmailOAuth2' }, + ]); + + await prepareWorkflowSetup(refreshedContext, { filePath, credentials: [] }); + expect(snapshots.get('wf-1')).toEqual([]); + expect(workflowService.createFromWorkflowJSON).toHaveBeenCalledTimes(1); + expect(workflowService.get).toHaveBeenCalledWith('wf-1'); + expect(await getWorkflowSourceFileBinding({ ...context }, filePath)).toMatchObject({ + workflowId: 'wf-1', + workflowVersionId: 'version-2', + setupPending: true, + }); + }); + + it('announces new-account requirements and retains them for the first build', async () => { + const { context, snapshots } = createContext(); + context.runId = 'run-early'; + await prepareWorkflowSetup(context, { + filePath, + workflowName: 'Daily update', + credentials: [ + { credentialType: 'slackApi', preferNew: true }, + { credentialType: 'gmailOAuth2' }, + ], + }); + expect(snapshots.get('wf-1')).toContainEqual({ + id: 'wf-1:credential:slackApi', + kind: 'credential', + credentialType: 'slackApi', + preferNew: true, + }); + await expect(getWorkflowSourceFileBinding(context, filePath)).resolves.toMatchObject({ + setupPreferences: { + runId: 'run-early', + satisfiedCredentialTypes: [], + preferNewCredentialTypes: ['slackApi'], + }, + }); + }); + + it('reuses one workflow for concurrent setup calls for the same source', async () => { + const { context, workflowService } = createContext(); + const input = { filePath, workflowName: 'Daily update', credentials: requirements }; + + const results = await Promise.all([ + prepareWorkflowSetup(context, input), + prepareWorkflowSetup(context, input), + ]); + + expect(results.map(({ workflowId }) => workflowId)).toEqual(['wf-1', 'wf-1']); + expect(workflowService.createFromWorkflowJSON).toHaveBeenCalledTimes(1); + }); + + it.each([ + { filePath: '../outside.ts', workflowName: 'Daily update', credentials: requirements }, + { filePath, workflowName: 'Daily update', credentials: [{ credentialType: 'httpHeaderAuth' }] }, + { filePath, workflowName: ' ', credentials: requirements }, + { filePath, workflowName: 'Daily update', credentials: [] }, + ])('rejects invalid early setup before creating a workflow: %j', async (input) => { + const { context, workflowService, publish } = createContext(); + + await expect(prepareWorkflowSetup(context, input)).rejects.toThrow(); + expect(workflowService.createFromWorkflowJSON).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + }); + + it('checks existing workflow access before announcing requirements', async () => { + const { context, workflowService, publish } = createContext(); + vi.mocked(workflowService.get).mockRejectedValue(new Error('Workflow is unavailable')); + + await expect( + prepareWorkflowSetup(context, { + filePath, + workflowId: 'wf-foreign', + credentials: requirements, + }), + ).rejects.toThrow('Workflow is unavailable'); + expect(workflowService.get).toHaveBeenCalledWith('wf-foreign'); + expect(publish).not.toHaveBeenCalled(); + }); + + it('announces a first account when adding a service to an existing workflow', async () => { + const { context, snapshots, workflowService } = createContext(); + await expect( + prepareWorkflowSetup(context, { + filePath, + workflowId: 'wf-1', + credentials: [{ credentialType: 'slackApi', preferNew: true }], + }), + ).resolves.toMatchObject({ success: true, announced: true, preBuild: true }); + expect(workflowService.createFromWorkflowJSON).not.toHaveBeenCalled(); + expect(snapshots.get('wf-1')).toContainEqual( + expect.objectContaining({ credentialType: 'slackApi', preferNew: true }), + ); + }); + + it('reuses the temporary workflow after its first binding write fails', async () => { + const { context, threadMemory, workflowService, publish } = createContext(); + threadMemory.saveThread.mockRejectedValueOnce(new Error('Storage unavailable')); + const input = { filePath, workflowName: 'Daily update', credentials: requirements }; + + await expect(prepareWorkflowSetup(context, input)).rejects.toThrow('Could not persist'); + expect(publish).not.toHaveBeenCalled(); + expect(workflowService.clearAiTemporary).not.toHaveBeenCalled(); + expect(context.aiCreatedWorkflowIds).toContain('wf-1'); + + await expect(prepareWorkflowSetup(context, input)).resolves.toMatchObject({ + success: true, + announced: true, + workflowId: 'wf-1', + }); + expect(workflowService.createFromWorkflowJSON).toHaveBeenCalledTimes(1); + expect(publish).toHaveBeenCalledTimes(1); + expect(threadMemory.saveThread.mock.invocationCallOrder[1]).toBeLessThan( + publish.mock.invocationCallOrder[0], + ); + await expect(getWorkflowSourceFileBinding({ ...context }, filePath)).resolves.toMatchObject({ + workflowId: 'wf-1', + setupPending: true, + }); + }); + + it('does not create a workflow when persisted source bindings cannot be read', async () => { + const { context, threadMemory, workflowService, publish } = createContext(); + threadMemory.getThread.mockRejectedValue(new Error('Storage unavailable')); + + await expect( + prepareWorkflowSetup(context, { + filePath, + workflowName: 'Daily update', + credentials: requirements, + }), + ).rejects.toThrow(); + expect(workflowService.createFromWorkflowJSON).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + }); + + it.each(['createWorkflow', 'updateWorkflow'] as const)( + 'respects a blocked %s permission', + async (permission) => { + const { context, workflowService } = createContext(); + context.permissions = mock({ [permission]: 'blocked' }); + + await expect( + prepareWorkflowSetup(context, { + filePath, + workflowName: 'Daily update', + credentials: requirements, + }), + ).rejects.toThrow('blocked by admin policy'); + expect(workflowService.createFromWorkflowJSON).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-credential-selections.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-credential-selections.test.ts new file mode 100644 index 00000000000..f5e1d6476db --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-credential-selections.test.ts @@ -0,0 +1,356 @@ +import { + AI_GATEWAY_MANAGED_TAG, + TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE, + instanceAiSetupCredentialAppliedKey, + instanceAiSetupCredentialSelectionKey, + readPendingInstanceAiSetupCredentialSelections, + type InstanceAiSetupCredentialSelection, +} from '@n8n/api-types'; +import type { NodeJSON, WorkflowJSON } from '@n8n/workflow-sdk'; +import { mock } from 'vitest-mock-extended'; + +import type { ThreadPatch, ThreadRecord } from '../../../storage/thread-patch'; +import type { InstanceAiContext, NodeDescription, SetupItemsEmitter } from '../../../types'; +import { resolveCredentials, type CredentialMap } from '../resolve-credentials'; +import type { ResolvedCredential } from '../resolved-credential.schema'; +import { + applyPendingSetupCredentialSelections, + markSetupCredentialSelectionsApplied, +} from '../setup-credential-selections'; + +const itemId = 'workflow-1:credential:slackApi'; +const selection: InstanceAiSetupCredentialSelection = { + selectionId: 'choice-1', + credentialType: 'slackApi', + credentialId: 'selected-credential', +}; +const credentialMap: CredentialMap = new Map([ + ['slackApi', [{ id: 'selected-credential', name: 'Selected account', type: 'slackApi' }]], + [ + 'httpBasicAuth', + [{ id: 'selected-credential', name: 'Selected account', type: 'httpBasicAuth' }], + ], +]); + +function node(name: string, type = 'slackApi', overrides: Partial = {}): NodeJSON { + return { id: name, name, type, typeVersion: 1, position: [0, 0], ...overrides }; +} + +function workflow(nodes: NodeJSON[]): WorkflowJSON { + return { name: 'Test', nodes, connections: {} }; +} + +function createContext(metadata: Record) { + const thread: ThreadRecord = { + id: 'thread-1', + resourceId: 'user-1', + metadata, + createdAt: new Date(), + updatedAt: new Date(), + }; + const threadMemory = { + getThread: vi.fn(async () => thread), + patchThread: vi.fn( + async ({ update }: { update: (current: ThreadRecord) => ThreadPatch | null | undefined }) => { + Object.assign(thread, update(thread)); + return thread; + }, + ), + }; + const context = mock({ + threadId: thread.id, + threadMemory, + setupItemsEmitter: mock(), + logger: mock(), + credentialService: mock(), + workflowService: mock({ + getAsWorkflowJSON: vi.fn().mockResolvedValue(workflow([])), + }), + nodeService: mock({ + getNodeCredentialTypes: undefined, + getDescription: vi.fn( + async (type: string): Promise => ({ + name: type, + displayName: type, + description: '', + version: 1, + group: [], + properties: [], + inputs: [], + outputs: [], + credentials: [{ name: type, displayOptions: { hide: { authentication: ['none'] } } }], + }), + ), + }), + }); + return { + context, + thread, + threadMemory, + apply: async (json: WorkflowJSON, candidates = credentialMap) => + await applyPendingSetupCredentialSelections(json, 'workflow-1', context, candidates), + resolve: async ( + json: WorkflowJSON, + candidates: CredentialMap, + preferNewCredentialTypes?: readonly string[], + setupSelections?: Record, + ) => + await resolveCredentials( + json, + 'workflow-1', + context, + candidates, + preferNewCredentialTypes, + setupSelections, + ), + }; +} + +describe('setup credential selections', () => { + it.each([false, true])( + 'keeps node-specific choices when inserted first: %s', + async (specificFirst) => { + const specific = { + ...selection, + selectionId: 'specific', + credentialId: 'specific-account', + nodeNames: ['First'], + }; + const entries = [ + [instanceAiSetupCredentialSelectionKey(itemId), selection], + [instanceAiSetupCredentialSelectionKey(`${itemId}:First`), specific], + ]; + const { apply } = createContext( + Object.fromEntries(specificFirst ? entries.reverse() : entries), + ); + const json = workflow([node('First'), node('Second')]); + const candidates: CredentialMap = new Map([ + [ + 'slackApi', + [ + { id: selection.credentialId, name: 'Default account', type: 'slackApi' }, + { id: specific.credentialId, name: 'Specific account', type: 'slackApi' }, + ], + ], + ]); + const result = await apply(json, candidates); + expect(json.nodes.map((current) => current.credentials?.slackApi?.id)).toEqual([ + 'specific-account', + 'selected-credential', + ]); + expect(result.resolvedCredentialsByNode.First).toEqual([ + { type: 'slackApi', id: 'specific-account', name: 'Specific account' }, + ]); + }, + ); + + it('applies an early choice to compatible nodes with the stored credential name', async () => { + const { apply } = createContext({ + [instanceAiSetupCredentialSelectionKey(itemId)]: selection, + }); + const json = workflow([ + node('First', 'slackApi', { credentials: { slackApi: { id: 'old', name: 'Old' } } }), + node('Second'), + node('Other service', 'gmailOAuth2'), + node('Inactive', 'slackApi', { parameters: { authentication: 'none' } }), + node('Disabled', 'slackApi', { disabled: true }), + ]); + const result = await apply(json); + expect(json.nodes.map(({ credentials }) => credentials)).toEqual([ + { slackApi: { id: 'selected-credential', name: 'Selected account' } }, + { slackApi: { id: 'selected-credential', name: 'Selected account' } }, + undefined, + undefined, + undefined, + ]); + expect(Object.keys(result.resolvedCredentialsByNode)).toEqual(['First', 'Second']); + expect(result.consumedSelections).toEqual([{ itemId, selection }]); + }); + + it('keeps an unavailable choice open and removes its previous binding', async () => { + const { context, apply, resolve } = createContext({ + [instanceAiSetupCredentialSelectionKey(itemId)]: selection, + }); + const json = workflow([ + node('Slack', 'slackApi', { + credentials: { slackApi: { id: 'other-project', name: 'Other' } }, + }), + ]); + vi.mocked(context.workflowService.getAsWorkflowJSON).mockResolvedValue(structuredClone(json)); + const result = await apply(json, new Map()); + expect(json.nodes[0].credentials).toEqual({}); + expect(result.unavailableCredentialTypes).toEqual(['slackApi']); + expect(result.consumedSelections).toEqual([]); + await resolve(json, new Map(), result.unavailableCredentialTypes); + expect(json.nodes[0].credentials).toEqual({}); + }); + + it('keeps an explicit custom-auth choice through automatic resolution', async () => { + const customType = TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE; + const customSelection = { ...selection, credentialType: customType, nodeNames: ['API'] }; + const { apply, resolve } = createContext({ + [instanceAiSetupCredentialSelectionKey(`workflow-1:credential:${customType}:API`)]: + customSelection, + }); + const json = workflow([node('API', customType)]); + const customMap: CredentialMap = new Map([ + [customType, [{ id: selection.credentialId, name: 'Selected account', type: customType }]], + ]); + const selected = await apply(json, customMap); + const resolved = await resolve(json, customMap, undefined, selected.resolvedCredentialsByNode); + expect(json.nodes[0].credentials?.[customType]).toEqual({ + id: selection.credentialId, + name: 'Selected account', + }); + expect(resolved.mockedNodeNames).toEqual([]); + }); + + it('applies generic credentials only to the selected nodes', async () => { + const genericSelection = { + ...selection, + credentialType: 'httpBasicAuth', + nodeNames: ['First'], + }; + const genericId = 'workflow-1:credential:httpBasicAuth:First'; + const { apply } = createContext({ + [instanceAiSetupCredentialSelectionKey(genericId)]: genericSelection, + }); + const json = workflow([node('First', 'httpBasicAuth'), node('Second', 'httpBasicAuth')]); + await apply(json); + expect(json.nodes[0].credentials).toEqual({ + httpBasicAuth: { id: 'selected-credential', name: 'Selected account' }, + }); + expect(json.nodes[1].credentials).toBeUndefined(); + }); + + it('does not fan a generic credential out without node names', async () => { + const { apply } = createContext({ + [instanceAiSetupCredentialSelectionKey('workflow-1:credential:httpBasicAuth')]: { + ...selection, + credentialType: 'httpBasicAuth', + }, + }); + const json = workflow([node('First', 'httpBasicAuth')]); + await apply(json); + expect(json.nodes[0].credentials).toBeUndefined(); + }); + + it('validates Gateway support before applying its marker', async () => { + const { context, apply } = createContext({ + [instanceAiSetupCredentialSelectionKey(itemId)]: { + ...selection, + credentialId: AI_GATEWAY_MANAGED_TAG, + }, + }); + const json = workflow([node('Slack')]); + vi.mocked(context.credentialService.isAiGatewayCredentialType!).mockResolvedValue(false); + expect((await apply(json)).unavailableCredentialTypes).toEqual(['slackApi']); + vi.mocked(context.credentialService.isAiGatewayCredentialType!).mockResolvedValue(true); + await apply(json); + expect(json.nodes[0].credentials).toEqual({ + slackApi: { id: null, name: 'Gateway credits', __aiGatewayManaged: true }, + }); + }); + + it('keeps selected Gateway credits when a stored alternative exists', async () => { + const { context, apply, resolve } = createContext({ + [instanceAiSetupCredentialSelectionKey(itemId)]: { + ...selection, + credentialId: AI_GATEWAY_MANAGED_TAG, + }, + }); + vi.mocked(context.credentialService.isAiGatewayCredentialType!).mockResolvedValue(true); + const json = workflow([ + node('Slack', 'slackApi', { + credentials: { slackApi: { id: 'selected-credential', name: 'Stored account' } }, + }), + ]); + const selected = await apply(json); + const resolved = await resolve( + json, + credentialMap, + undefined, + selected.resolvedCredentialsByNode, + ); + + expect(json.nodes[0].credentials).toEqual({ + slackApi: { id: null, name: 'Gateway credits', __aiGatewayManaged: true }, + }); + expect(resolved.resolvedCredentialsByNode.Slack).toEqual([ + { type: 'slackApi', id: null, name: 'Gateway credits', __aiGatewayManaged: true }, + ]); + expect(resolved.mockedCredentialTypes).toEqual([]); + }); + + it('keeps a choice until a later build adds the service', async () => { + const { context, thread, apply, resolve } = createContext({ + [instanceAiSetupCredentialSelectionKey(itemId)]: selection, + }); + const first = await apply(workflow([node('Trigger', 'manualTrigger')])); + await markSetupCredentialSelectionsApplied(context, first.consumedSelections); + expect(readPendingInstanceAiSetupCredentialSelections(thread.metadata, 'workflow-1')).toEqual([ + { itemId, selection }, + ]); + + const json = workflow([ + node('Slack', 'slackApi', { + credentials: { slackApi: { id: 'other', name: 'Other account' } }, + }), + ]); + const candidates: CredentialMap = new Map([ + [ + 'slackApi', + [ + { id: selection.credentialId, name: 'Selected account', type: 'slackApi' }, + { id: 'other', name: 'Other account', type: 'slackApi' }, + ], + ], + ]); + const result = await apply(json, candidates); + await resolve(json, candidates, undefined, result.resolvedCredentialsByNode); + expect(json.nodes[0].credentials?.slackApi?.id).toBe(selection.credentialId); + await markSetupCredentialSelectionsApplied(context, result.consumedSelections); + expect(readPendingInstanceAiSetupCredentialSelections(thread.metadata, 'workflow-1')).toEqual( + [], + ); + }); + + it('preserves a newer selection when the earlier workflow save finishes', async () => { + const { context, thread, apply } = createContext({ + [instanceAiSetupCredentialSelectionKey(itemId)]: selection, + }); + const result = await apply(workflow([node('Slack')])); + const newerSelection = { ...selection, selectionId: 'choice-2' }; + thread.metadata = { [instanceAiSetupCredentialSelectionKey(itemId)]: newerSelection }; + await markSetupCredentialSelectionsApplied(context, result.consumedSelections); + expect( + thread.metadata[instanceAiSetupCredentialAppliedKey(itemId, newerSelection.selectionId)], + ).toBeUndefined(); + expect(readPendingInstanceAiSetupCredentialSelections(thread.metadata, 'workflow-1')).toEqual([ + { itemId, selection: newerSelection }, + ]); + }); + + it('keeps the choice pending if marking a successful save fails', async () => { + const { context, thread, threadMemory } = createContext({ + [instanceAiSetupCredentialSelectionKey(itemId)]: selection, + }); + threadMemory.patchThread.mockRejectedValue(new Error('Storage unavailable')); + await expect( + markSetupCredentialSelectionsApplied(context, [{ itemId, selection }]), + ).resolves.toBeUndefined(); + expect(readPendingInstanceAiSetupCredentialSelections(thread.metadata, 'workflow-1')).toEqual([ + { itemId, selection }, + ]); + expect(context.logger.warn).toHaveBeenCalled(); + }); + + it('does not read selections when the setup panel is disabled', async () => { + const { context, threadMemory, apply } = createContext({ + [instanceAiSetupCredentialSelectionKey(itemId)]: selection, + }); + context.setupItemsEmitter = undefined; + await apply(workflow([node('Slack')])); + expect(threadMemory.getThread).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-items.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-items.test.ts index 834c18fc145..c0ce2ab8c3c 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-items.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-items.test.ts @@ -9,6 +9,7 @@ import { buildSetupItemsFromCredentialRequests, buildSetupItemsFromSetupRequests, createSetupItemsEmitter, + requestsCredentialReplacement, } from '../setup-items'; import type { SetupRequest } from '../setup-workflow.schema'; @@ -66,6 +67,23 @@ function setupRequest(overrides: { }; } +describe('requestsCredentialReplacement', () => { + it.each([ + { boundCredential: undefined, replacement: false }, + { boundCredential: { id: '', name: 'Unconnected' }, replacement: false }, + { boundCredential: { id: 'existing', name: 'Existing' }, replacement: true }, + { boundCredential: { id: null, name: 'Gateway credits' }, replacement: true }, + ])('requires an existing account for replacement: %j', ({ boundCredential, replacement }) => { + const request = setupRequest({ + nodeName: 'Slack', + credentialType: 'slackApi', + boundCredential, + }); + expect(requestsCredentialReplacement([request], ['slackApi'])).toBe(replacement); + expect(requestsCredentialReplacement([request], ['gmailOAuth2'])).toBe(false); + }); +}); + describe('createSetupItemsEmitter', () => { it('waits for persisted state before it completes the final announcement', async () => { const items: InstanceAiSetupItem[] = [ @@ -355,6 +373,28 @@ const templatedAuthHint = { }; describe('buildSetupItemsFromCredentialRequests', () => { + it.each([ + [undefined, true], + [true, undefined], + ])('preserves preferNew across duplicate requests (%s, %s)', (first, second) => { + const requests = [first, second].map((preferNew) => ({ + credentialType: 'slackApi', + preferNew, + })); + const analyzed = [ + setupRequest({ nodeName: 'Slack', credentialType: 'slackApi', credentialNeedsAction: true }), + ]; + + for (const items of [ + buildSetupItemsFromCredentialRequests('wf-1', requests), + buildSetupItemsFromAnnouncement('wf-1', requests, analyzed), + ]) { + expect(items).toEqual([ + expect.objectContaining({ credentialType: 'slackApi', preferNew: true }), + ]); + } + }); + it('builds one service-keyed credential item per type, carrying reason and setupHint', () => { const items = buildSetupItemsFromCredentialRequests('wf-1', [ { credentialType: 'slackApi', reason: 'to post alerts' }, diff --git a/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts index df1c0427887..0dcda9220e5 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts @@ -26,6 +26,10 @@ import { import { resolvedCredentialSchema } from './resolved-credential.schema'; import { describeSavedPublishState, savedWorkflowStateSchema } from './saved-workflow-state'; import { isSetupPanelEnabled } from './setup-items'; +import { + applyPendingSetupCredentialSelections, + markSetupCredentialSelectionsApplied, +} from './setup-credential-selections'; import { recordWorkflowSetupState } from './setup-panel-state'; import { getSkippedSetupSubjects, partitionSkippedSetupRequests } from './setup-skip-state'; import { analyzeWorkflow, stripStaleCredentialsFromWorkflow } from './setup-workflow.service'; @@ -1174,13 +1178,49 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { } const credentialMap = await buildCredentialMap(context.credentialService); + const setupSelections = await applyPendingSetupCredentialSelections( + json, + targetWorkflowId, + context, + credentialMap, + ); + const setupPreferences = + isSetupPanelEnabled(context) && binding.setupPreferences?.runId === context.runId + ? binding.setupPreferences + : undefined; + const selectedTypes = new Set( + Object.values(setupSelections.resolvedCredentialsByNode) + .flat() + .map((credential) => credential.type), + ); + // Keep a saved choice when the model repeats setup flags during a repair. + const preferNewCredentialTypes = [ + ...new Set([ + ...(setupPreferences?.preferNewCredentialTypes ?? []), + ...(input.preferNewCredentials ?? []), + ]), + ].filter( + (type) => + !selectedTypes.has(type) && !setupPreferences?.satisfiedCredentialTypes.includes(type), + ); const mockResult = await resolveCredentials( json, targetWorkflowId, context, credentialMap, - input.preferNewCredentials, + [...preferNewCredentialTypes, ...setupSelections.unavailableCredentialTypes], + setupSelections.resolvedCredentialsByNode, ); + for (const [nodeName, selections] of Object.entries( + setupSelections.resolvedCredentialsByNode, + )) { + mockResult.resolvedCredentialsByNode[nodeName] = [ + ...(mockResult.resolvedCredentialsByNode[nodeName] ?? []).filter( + (credential) => !selections.some((selection) => selection.type === credential.type), + ), + ...selections, + ]; + } // Deterministic backstop for a builder that never checked credentials: // a chat-model node for a provider the user has no credential for gets @@ -1418,15 +1458,14 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { }, operation: 'create' | 'update', ) => { + await markSetupCredentialSelectionsApplied(context, setupSelections.consumedSelections); // The setup panel lists bound slots too (rendered as done), so its // snapshot needs the settled requests the routing below must not see. const setupItemsEmitter = isSetupPanelEnabled(context) ? context.setupItemsEmitter : undefined; const analyzedRequests = await analyzeWorkflow(context, saved.id, undefined, { - ...(input.preferNewCredentials - ? { preferNewCredentialTypes: input.preferNewCredentials } - : {}), + ...(preferNewCredentialTypes.length > 0 ? { preferNewCredentialTypes } : {}), ...(setupItemsEmitter ? { includeSettled: true } : {}), }); const setupRequests = analyzedRequests.filter((request) => !!request.needsAction); @@ -1476,6 +1515,18 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { workflowVersionId: saved.versionId, ...(saved.checksum ? { workflowChecksum: saved.checksum } : {}), sourceHash, + setupPending: undefined, + ...(setupPreferences + ? { + setupPreferences: { + ...setupPreferences, + preferNewCredentialTypes, + satisfiedCredentialTypes: [ + ...new Set([...setupPreferences.satisfiedCredentialTypes, ...selectedTypes]), + ], + }, + } + : {}), }); // Trace-only compiled-JSON event for eval seed reconstruction — never part // of the tool result, so it never enters the agent's context. @@ -1635,7 +1686,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { json, updateOptions, ); - return await createSuccessResponse(updated, 'update'); + return await createSuccessResponse(updated, binding.setupPending ? 'create' : 'update'); } const created = await context.workflowService.createFromWorkflowJSON(json, { diff --git a/packages/@n8n/instance-ai/src/tools/workflows/prepare-workflow-setup.ts b/packages/@n8n/instance-ai/src/tools/workflows/prepare-workflow-setup.ts new file mode 100644 index 00000000000..9469b2651d7 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/workflows/prepare-workflow-setup.ts @@ -0,0 +1,172 @@ +import { GENERIC_AUTH_CREDENTIAL_TYPES } from '@n8n/api-types'; +import { OperationalError, UserError } from 'n8n-workflow'; + +import { + buildSetupItemsFromAnnouncement, + buildSetupItemsFromCredentialRequests, + isSetupPanelEnabled, + requestsCredentialReplacement, + type AnnouncedCredentialRequest, +} from './setup-items'; +import { analyzeWorkflow } from './setup-workflow.service'; +import { recordSessionOwnedWorkflow } from './workflow-build-context'; +import { promoteMainWorkflow } from './workflow-build-reporting'; +import { + getWorkflowSourceFileBinding, + normalizeWorkflowSourceFilePath, + saveWorkflowSourceFileBinding, + type WorkflowSourceFileBinding, +} from './workflow-file-bindings'; +import type { InstanceAiContext } from '../../types'; + +const pendingPreparations = new WeakMap>(); + +/** Parallel tool calls must not create two workflows for the same source file. */ +export async function prepareWorkflowSetup( + context: InstanceAiContext, + input: Parameters[1], +) { + const previous = pendingPreparations.get(context) ?? Promise.resolve(); + const pending = previous.catch(() => undefined).then(async () => await prepare(context, input)); + pendingPreparations.set(context, pending); + try { + return await pending; + } finally { + if (pendingPreparations.get(context) === pending) pendingPreparations.delete(context); + } +} + +/** Establish the saved workflow identity before the model writes its source. */ +async function prepare( + context: InstanceAiContext, + input: { + filePath: string; + workflowName?: string; + workflowId?: string; + folderPath?: string; + credentials: readonly AnnouncedCredentialRequest[]; + }, +) { + if (!isSetupPanelEnabled(context) || !context.threadMemory || !context.threadId) { + throw new OperationalError('Early workflow setup is unavailable'); + } + if ( + input.credentials.some(({ credentialType }) => + GENERIC_AUTH_CREDENTIAL_TYPES.has(credentialType), + ) + ) { + throw new UserError( + 'Announce service-specific credential types before building. Leave generic authentication for workflow setup after the build.', + ); + } + const filePath = normalizeWorkflowSourceFilePath(input.filePath, { + workspaceRoot: context.workspaceRoot, + }); + let binding = await getWorkflowSourceFileBinding(context, filePath, { requirePersistence: true }); + if (input.workflowId && binding?.workflowId && input.workflowId !== binding.workflowId) { + throw new UserError( + 'The source file belongs to another workflow. Use its bound workflow or a different filePath.', + ); + } + const workflowId = binding?.workflowId ?? input.workflowId; + const satisfiedCredentialTypes = + binding?.setupPreferences?.runId === context.runId + ? (binding?.setupPreferences?.satisfiedCredentialTypes ?? []) + : []; + const setupPreferences: NonNullable = { + runId: context.runId, + satisfiedCredentialTypes, + preferNewCredentialTypes: input.credentials + .filter( + (request) => + request.preferNew && !satisfiedCredentialTypes.includes(request.credentialType), + ) + .map((request) => request.credentialType), + }; + let analyzedRequests: Awaited> | undefined; + if (workflowId) { + // Validate access before publishing a workflow-scoped announcement. + const saved = await context.workflowService.get(workflowId); + if (!binding?.setupPending) { + analyzedRequests = await analyzeWorkflow(context, workflowId, undefined, { + includeSettled: true, + }); + } + if ( + analyzedRequests && + requestsCredentialReplacement(analyzedRequests, setupPreferences.preferNewCredentialTypes) + ) { + throw new UserError( + 'Use credential setup without filePath to replace an existing workflow account.', + ); + } + binding = await saveWorkflowSourceFileBinding( + context, + { + ...binding, + filePath, + workflowId, + workflowVersionId: saved.versionId, + workflowChecksum: saved.checksum, + setupPreferences, + }, + { requirePersistence: true }, + ); + if (context.aiCreatedWorkflowIds?.has(workflowId)) { + await recordSessionOwnedWorkflow(context, workflowId); + } + } else { + if ( + context.permissions?.createWorkflow === 'blocked' || + context.permissions?.updateWorkflow === 'blocked' + ) { + throw new UserError('Early workflow setup is blocked by admin policy'); + } + if (!input.workflowName?.trim() || input.credentials.length === 0) { + throw new UserError( + 'Provide a workflowName and the known credential requirements for a new workflow.', + ); + } + const saved = await context.workflowService.createFromWorkflowJSON( + { name: input.workflowName, nodes: [], connections: {} }, + { markAsAiTemporary: true, ...(input.folderPath ? { folderPath: input.folderPath } : {}) }, + ); + try { + binding = await saveWorkflowSourceFileBinding( + context, + { + filePath, + workflowId: saved.id, + workflowVersionId: saved.versionId, + workflowChecksum: saved.checksum, + setupPending: true, + setupPreferences, + }, + { requirePersistence: true }, + ); + } finally { + await recordSessionOwnedWorkflow(context, saved.id); + } + } + if (!binding.workflowId) + throw new OperationalError('The workflow setup context has no workflow ID'); + if (binding.setupPending) await promoteMainWorkflow(context, binding.workflowId); + const items = binding.setupPending + ? buildSetupItemsFromCredentialRequests(binding.workflowId, input.credentials) + : buildSetupItemsFromAnnouncement( + binding.workflowId, + input.credentials, + analyzedRequests ?? + (await analyzeWorkflow(context, binding.workflowId, undefined, { includeSettled: true })), + ); + await context.setupItemsEmitter.announce(binding.workflowId, items); + return { + success: true, + announced: true, + preBuild: true, + workflowId: binding.workflowId, + filePath, + message: + 'The setup panel is ready. Continue writing the workflow source and call build-workflow with this filePath. Do not wait for connections or repeat folderPath on the build. The user can connect while you work. Their selections will be applied to the generated nodes. If the plan changes before building, call setup again with the complete remaining requirements.', + }; +} diff --git a/packages/@n8n/instance-ai/src/tools/workflows/resolve-credentials.ts b/packages/@n8n/instance-ai/src/tools/workflows/resolve-credentials.ts index 067c698bc21..2f6e27b55e3 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/resolve-credentials.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/resolve-credentials.ts @@ -183,6 +183,7 @@ export async function resolveCredentials( ctx: InstanceAiContext, availableCredentials?: CredentialMap, preferNewCredentialTypes?: readonly string[], + setupSelections?: Record, ): Promise { const preferNewTypes = new Set(preferNewCredentialTypes ?? []); const heldForNewCredentialTypes = new Set(); @@ -435,6 +436,14 @@ export async function resolveCredentials( mockCredential(); }; + const setupSelection = node.name + ? setupSelections?.[node.name]?.find((selection) => selection.type === key) + : undefined; + if (setupSelection?.id === null && (await isGatewayCredentialType(key))) { + await attachGatewayCredential(); + continue; + } + if (value !== undefined && value !== null) { // Templated Custom Auth ids are service-agnostic at the type level, so a // model-attached reference can silently wire another service's credential @@ -444,7 +453,8 @@ export async function resolveCredentials( if (key === TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE) { const suppliedId = getCredentialId(value); const priorId = getCredentialId(existingCreds?.[key]); - if (suppliedId !== undefined && suppliedId === priorId) { + const selectedByUser = setupSelection?.id === suppliedId; + if (suppliedId !== undefined && (suppliedId === priorId || selectedByUser)) { cleanupMockPinData(json, node.name); continue; } diff --git a/packages/@n8n/instance-ai/src/tools/workflows/setup-credential-selections.ts b/packages/@n8n/instance-ai/src/tools/workflows/setup-credential-selections.ts new file mode 100644 index 00000000000..78745097a93 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/workflows/setup-credential-selections.ts @@ -0,0 +1,145 @@ +import { + AI_GATEWAY_MANAGED_TAG, + GENERIC_AUTH_CREDENTIAL_TYPES, + instanceAiSetupCredentialAppliedKey, + readPendingInstanceAiSetupCredentialSelections, +} from '@n8n/api-types'; +import type { WorkflowJSON } from '@n8n/workflow-sdk'; + +import { + AI_GATEWAY_CREDENTIAL, + N8N_CONNECT_DISPLAY_NAME, + assignCredentialToNode, +} from './credential-utils'; +import type { CredentialMap } from './resolve-credentials'; +import type { ResolvedCredential } from './resolved-credential.schema'; +import { isSetupPanelEnabled } from './setup-items'; +import { getValidCredentialTypes } from './setup-workflow.service'; +import { findWorkflowSourceFileBindingsForWorkflow } from './workflow-file-bindings'; +import { getThread, patchThread } from '../../storage/thread-patch'; +import type { InstanceAiContext } from '../../types'; + +type PendingSelection = ReturnType[number]; + +/** A repeated setup call must retain choices already saved during this run. */ +export async function filterSatisfiedSetupCredentialTypes( + context: InstanceAiContext, + workflowId: string, + credentialTypes: readonly string[] | undefined, +): Promise { + if (!isSetupPanelEnabled(context) || !context.runId || !credentialTypes?.length) + return credentialTypes; + const bindings = await findWorkflowSourceFileBindingsForWorkflow(context, workflowId); + const satisfied = new Set( + bindings.flatMap(({ setupPreferences }) => + setupPreferences && setupPreferences.runId === context.runId + ? setupPreferences.satisfiedCredentialTypes + : [], + ), + ); + return credentialTypes.filter((type) => !satisfied.has(type)); +} + +export interface SetupCredentialSelectionResult { + consumedSelections: PendingSelection[]; + unavailableCredentialTypes: string[]; + resolvedCredentialsByNode: Record; +} + +/** Apply the user's latest choices before automatic credential resolution. */ +export async function applyPendingSetupCredentialSelections( + json: WorkflowJSON, + workflowId: string | undefined, + context: InstanceAiContext, + credentialMap: CredentialMap, +): Promise { + const result: SetupCredentialSelectionResult = { + consumedSelections: [], + unavailableCredentialTypes: [], + resolvedCredentialsByNode: {}, + }; + if (!isSetupPanelEnabled(context) || !workflowId || !context.threadId || !context.threadMemory) { + return result; + } + const thread = await getThread(context.threadMemory, context.threadId); + const selections = readPendingInstanceAiSetupCredentialSelections(thread?.metadata, workflowId); + if (selections.length === 0) return result; + + const activeNodes = await Promise.all( + json.nodes + .filter((node) => !node.disabled && node.name) + .map(async (node) => ({ + node, + credentialTypes: await getValidCredentialTypes(context, node), + })), + ); + + for (const pending of selections) { + const { credentialType, credentialId, nodeNames } = pending.selection; + const generic = GENERIC_AUTH_CREDENTIAL_TYPES.has(credentialType); + const targets = activeNodes.filter( + ({ node, credentialTypes }) => + credentialTypes.has(credentialType) && + (nodeNames ? nodeNames.includes(node.name ?? '') : !generic), + ); + if (targets.length === 0) continue; + + const stored = credentialMap.get(credentialType)?.find(({ id }) => id === credentialId); + const gateway = + credentialId === AI_GATEWAY_MANAGED_TAG && + (await context.credentialService.isAiGatewayCredentialType?.(credentialType)); + if (!stored && !gateway) { + // Leave these slots open instead of substituting another account. + for (const { node } of targets) { + delete node.credentials?.[credentialType]; + if (node.name && result.resolvedCredentialsByNode[node.name]) { + result.resolvedCredentialsByNode[node.name] = result.resolvedCredentialsByNode[ + node.name + ].filter((credential) => credential.type !== credentialType); + } + } + result.unavailableCredentialTypes.push(credentialType); + continue; + } + + const credential = stored + ? { id: stored.id, name: stored.name } + : { ...AI_GATEWAY_CREDENTIAL, name: N8N_CONNECT_DISPLAY_NAME }; + for (const { node } of targets) { + if (!node.name) continue; + assignCredentialToNode(node, credentialType, credential); + result.resolvedCredentialsByNode[node.name] = [ + ...(result.resolvedCredentialsByNode[node.name] ?? []).filter( + (current) => current.type !== credentialType, + ), + { type: credentialType, ...credential }, + ]; + } + result.consumedSelections.push(pending); + } + return result; +} + +/** Call only after the workflow save succeeds. */ +export async function markSetupCredentialSelectionsApplied( + context: InstanceAiContext, + selections: readonly PendingSelection[], +): Promise { + if (!context.threadMemory || !context.threadId || selections.length === 0) return; + try { + await patchThread(context.threadMemory, { + threadId: context.threadId, + update: ({ metadata = {} }) => { + const updated = { ...metadata }; + for (const { itemId, selection } of selections) { + updated[instanceAiSetupCredentialAppliedKey(itemId, selection.selectionId)] = true; + } + return { metadata: updated }; + }, + }); + } catch (error) { + context.logger?.warn('Failed to mark setup credential selections as applied', { + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/packages/@n8n/instance-ai/src/tools/workflows/setup-items.ts b/packages/@n8n/instance-ai/src/tools/workflows/setup-items.ts index cc4fead7310..abe59095006 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/setup-items.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/setup-items.ts @@ -12,6 +12,7 @@ import { } from '@n8n/api-types'; import { OperationalError } from 'n8n-workflow'; +import { isAiGatewayManagedCredential } from './credential-utils'; import type { SetupRequest } from './setup-workflow.schema'; import type { InstanceAiEventBus } from '../../event-bus/event-bus.interface'; import type { InstanceAiContext, SetupItemsEmitter } from '../../types'; @@ -149,9 +150,21 @@ export function parametersSetupItemId(workflowId: string, nodeName: string): str export interface AnnouncedCredentialRequest { credentialType: string; reason?: string; + preferNew?: boolean; setupHint?: InstanceAiCredentialSetupHint; } +function mergeCredentialRequests(requests: readonly AnnouncedCredentialRequest[]) { + const byType = new Map(); + for (const request of requests) { + const existing = byType.get(request.credentialType); + if (!existing || request.preferNew) { + byType.set(request.credentialType, existing ? { ...existing, preferNew: true } : request); + } + } + return byType; +} + /** * Items for a `credentials(action="setup")` announcement without node context: * one service-keyed row per type, no bindings; the next build snapshot fans them @@ -162,20 +175,20 @@ export function buildSetupItemsFromCredentialRequests( workflowId: string, requests: readonly AnnouncedCredentialRequest[], ): InstanceAiSetupItem[] { - const byType = new Map(); - for (const request of requests) { + const items: InstanceAiSetupItem[] = []; + for (const request of mergeCredentialRequests(requests).values()) { if (GENERIC_AUTH_CREDENTIAL_TYPES.has(request.credentialType)) continue; const id = credentialSetupItemId(workflowId, request.credentialType); - if (byType.has(id)) continue; - byType.set(id, { + items.push({ id, kind: 'credential', credentialType: request.credentialType, + ...(request.preferNew !== undefined ? { preferNew: request.preferNew } : {}), ...(request.reason ? { reason: request.reason } : {}), ...(request.setupHint ? { setupHint: request.setupHint } : {}), }); } - return [...byType.values()]; + return items; } /** @@ -190,12 +203,7 @@ export function buildSetupItemsFromAnnouncement( requests: readonly AnnouncedCredentialRequest[], analyzedRequests: readonly SetupRequest[], ): InstanceAiSetupItem[] { - const requestByType = new Map(); - for (const request of requests) { - if (!requestByType.has(request.credentialType)) { - requestByType.set(request.credentialType, request); - } - } + const requestByType = mergeCredentialRequests(requests); const coveredTypes = new Set(); const items = buildSetupItemsFromSetupRequests(workflowId, analyzedRequests).map((item) => { if (item.kind !== 'credential') return item; @@ -204,6 +212,7 @@ export function buildSetupItemsFromAnnouncement( coveredTypes.add(item.credentialType); return { ...item, + ...(request.preferNew !== undefined ? { preferNew: request.preferNew } : {}), ...(request.reason ? { reason: request.reason } : {}), ...(request.setupHint ? { setupHint: request.setupHint } : {}), }; @@ -223,6 +232,20 @@ function isBoundToStoredCredential(request: SetupRequest): boolean { return typeof bound?.id === 'string' && bound.id.length > 0; } +/** A request for a first account does not replace an existing binding. */ +export function requestsCredentialReplacement( + requests: readonly SetupRequest[], + credentialTypes: readonly string[] = [], +): boolean { + return requests.some( + (request) => + request.credentialType !== undefined && + credentialTypes.includes(request.credentialType) && + (isBoundToStoredCredential(request) || + isAiGatewayManagedCredential(request.node.credentials?.[request.credentialType])), + ); +} + /** * Items for a saved workflow from its setup analysis (`analyzeWorkflow` with * `includeSettled`): every credential slot that is open or already bound to a @@ -250,11 +273,13 @@ export function buildSetupItemsFromSetupRequests( existing.nodeBindings = [...(existing.nodeBindings ?? []), { nodeName }]; } if (!existing.setupHint && request.setupHint) existing.setupHint = request.setupHint; + if (request.preferNewCredential) existing.preferNew = true; } else { credentialItems.set(id, { id, kind: 'credential', credentialType, + ...(request.preferNewCredential ? { preferNew: true } : {}), nodeBindings: [{ nodeName }], ...(request.setupHint ? { setupHint: request.setupHint } : {}), }); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/workflow-file-bindings.ts b/packages/@n8n/instance-ai/src/tools/workflows/workflow-file-bindings.ts index 61e297e1971..6dfae435336 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/workflow-file-bindings.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/workflow-file-bindings.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { z } from 'zod'; +import { OperationalError } from 'n8n-workflow'; import { getThread, patchThread } from '../../storage/thread-patch'; import type { InstanceAiContext } from '../../types'; @@ -14,6 +15,14 @@ const workflowSourceFileBindingSchema = z.object({ workflowVersionId: z.string().optional(), workflowChecksum: z.string().optional(), sourceHash: z.string().optional(), + setupPending: z.boolean().optional(), + setupPreferences: z + .object({ + runId: z.string().optional(), + satisfiedCredentialTypes: z.array(z.string()), + preferNewCredentialTypes: z.array(z.string()), + }) + .optional(), }); const workflowSourceFileBindingsSchema = z.record(z.string(), workflowSourceFileBindingSchema); @@ -53,13 +62,22 @@ function getFallbackBindings(context: InstanceAiContext): Map | undefined> { - if (!context.threadMemory || !context.threadId) return undefined; + if (!context.threadMemory || !context.threadId) { + if (requirePersistence) throw new OperationalError('Workflow setup persistence is unavailable'); + return undefined; + } try { const thread = await getThread(context.threadMemory, context.threadId); + if (requirePersistence) { + if (!thread) throw new OperationalError('The workflow setup thread is unavailable'); + return workflowSourceFileBindingsSchema.parse(thread.metadata?.[METADATA_KEY] ?? {}); + } return parseBindings(thread?.metadata?.[METADATA_KEY]); } catch (error) { + if (requirePersistence) throw error; context.logger?.debug('Failed to read workflow source file bindings from thread metadata', { error: error instanceof Error ? error.message : String(error), }); @@ -70,9 +88,10 @@ async function readThreadBindings( export async function getWorkflowSourceFileBinding( context: InstanceAiContext, filePath: string, + options: { requirePersistence?: boolean } = {}, ): Promise { const normalizedFilePath = normalizeWorkflowSourceFilePath(filePath); - const threadBindings = await readThreadBindings(context); + const threadBindings = await readThreadBindings(context, options.requirePersistence); if (threadBindings) { return ( threadBindings[normalizedFilePath] ?? getFallbackBindings(context).get(normalizedFilePath) @@ -109,14 +128,14 @@ export async function findWorkflowSourceFileBindingsForWorkflow( export async function saveWorkflowSourceFileBinding( context: InstanceAiContext, binding: WorkflowSourceFileBinding, + options: { requirePersistence?: boolean } = {}, ): Promise { const normalizedBinding = { ...binding, filePath: normalizeWorkflowSourceFilePath(binding.filePath), }; - // Always keep the run-local copy: a later thread-metadata read can fail, and the - // binding must still be found so an existing file is never treated as unbound. + // Retain the identity for retries even when its durable write fails. getFallbackBindings(context).set(normalizedBinding.filePath, normalizedBinding); if (context.threadMemory && context.threadId) { @@ -137,6 +156,9 @@ export async function saveWorkflowSourceFileBinding( }); } } + if (options.requirePersistence) { + throw new OperationalError('Could not persist the workflow setup context'); + } return normalizedBinding; } diff --git a/packages/@n8n/json-schema-to-zod/package.json b/packages/@n8n/json-schema-to-zod/package.json index 4be80726460..2ef25e97ea8 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.16.0", + "version": "1.17.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 8d545366838..bfe2c00a983 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.18.0", + "version": "0.19.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/oxlint.config.mts b/packages/@n8n/mcp-browser/oxlint.config.mts new file mode 100644 index 00000000000..b50c92512db --- /dev/null +++ b/packages/@n8n/mcp-browser/oxlint.config.mts @@ -0,0 +1,7 @@ +import { backendConfig } from '@n8n/oxlint-config/backend'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [backendConfig], + options: { typeAware: true }, +}); diff --git a/packages/@n8n/mcp-browser/package.json b/packages/@n8n/mcp-browser/package.json index cec9b6ea227..54e3c1aa016 100644 --- a/packages/@n8n/mcp-browser/package.json +++ b/packages/@n8n/mcp-browser/package.json @@ -16,8 +16,8 @@ "build:unchecked": "tsc -p tsconfig.build.json --noCheck", "format": "biome format --write src", "format:check": "biome ci src", - "lint": "eslint . --quiet", - "lint:fix": "eslint . --fix", + "lint": "oxlint --quiet", + "lint:fix": "oxlint --fix", "watch": "tsc -p tsconfig.build.json --watch", "test": "vitest run", "test:unit": "vitest run", @@ -38,6 +38,7 @@ "zod": "catalog:" }, "devDependencies": { + "@n8n/oxlint-config": "workspace:*", "@n8n/typescript-config": "workspace:*", "@n8n/vitest-config": "workspace:*", "@types/jsdom": "catalog:", @@ -45,6 +46,8 @@ "@types/ws": "catalog:", "@types/yargs-parser": "21.0.0", "@vitest/coverage-v8": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", "typescript": "catalog:typescript", "tsx": "catalog:", "vitest": "catalog:" diff --git a/packages/@n8n/node-cli/eslint.config.mjs b/packages/@n8n/node-cli/eslint.config.mjs index 365457f7ed6..91529f7deef 100644 --- a/packages/@n8n/node-cli/eslint.config.mjs +++ b/packages/@n8n/node-cli/eslint.config.mjs @@ -14,11 +14,4 @@ export default defineConfig( files: ['src/commands/**/*.ts', 'src/modules.d.ts', 'src/configs/eslint.ts'], rules: { 'import-x/no-default-export': 'off', '@typescript-eslint/naming-convention': 'off' }, }, - { - // Debt: the base layer enforces kebab-case filenames and this package has - // 22 files that predate it. Rename them, then delete this block. - rules: { - 'unicorn/filename-case': 'off', - }, - }, ); diff --git a/packages/@n8n/node-cli/oxlint.config.mts b/packages/@n8n/node-cli/oxlint.config.mts new file mode 100644 index 00000000000..0a225a02a67 --- /dev/null +++ b/packages/@n8n/node-cli/oxlint.config.mts @@ -0,0 +1,17 @@ +import { backendConfig } from '@n8n/oxlint-config/backend'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [backendConfig], + options: { typeAware: true }, + ignorePatterns: ['src/template/templates/**/template', 'src/template/templates/shared'], + overrides: [ + { + files: ['**/*.test.ts', 'src/test-utils/**/*'], + jsPlugins: ['@n8n/oxlint-config/import-x-alias'], + rules: { + 'import-x-alias/no-extraneous-dependencies': ['error', { devDependencies: true }], + }, + }, + ], +}); diff --git a/packages/@n8n/node-cli/package.json b/packages/@n8n/node-cli/package.json index 636052e97ee..b7cb1572923 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.50.0", + "version": "0.51.0", "description": "Official CLI for developing community nodes for n8n", "bin": { "n8n-node": "bin/n8n-node.mjs" @@ -22,8 +22,8 @@ "dev": "tsc-watch -p tsconfig.build.json -w --onCompilationComplete \"pnpm copy-templates\"", "format": "biome format --write src", "format:check": "biome ci src", - "lint": "eslint src --quiet", - "lintfix": "eslint src --fix", + "lint": "oxlint src --quiet", + "lintfix": "oxlint src --fix", "build": "tsc -p tsconfig.build.json && pnpm copy-templates", "build:unchecked": "tsc -p tsconfig.build.json --noCheck && pnpm copy-templates", "publish:dry": "pnpm run build && pnpm pub --dry-run", @@ -65,9 +65,12 @@ "typescript-eslint": "catalog:" }, "devDependencies": { + "@n8n/oxlint-config": "workspace:*", "@n8n/typescript-config": "workspace:*", "@n8n/vitest-config": "workspace:*", "@typescript/native": "catalog:typescript-tooling", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", "typescript": "catalog:typescript-tooling", "vite": "catalog:", "vitest": "catalog:", diff --git a/packages/@n8n/node-engine-compatibility/oxlint.config.mts b/packages/@n8n/node-engine-compatibility/oxlint.config.mts new file mode 100644 index 00000000000..0b93a1b734c --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/oxlint.config.mts @@ -0,0 +1,8 @@ +import { backendConfig } from '@n8n/oxlint-config/backend'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [backendConfig], + options: { typeAware: true }, + ignorePatterns: ['dist/**', 'vitest.integration.config.ts'], +}); diff --git a/packages/@n8n/node-engine-compatibility/package.json b/packages/@n8n/node-engine-compatibility/package.json index 641704e945d..49f3da9596d 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.9.0", + "version": "0.10.0", "description": "Adapts v1 nodes and workflows to the Engine v2 execution model", "scripts": { "clean": "rimraf dist .turbo", @@ -9,8 +9,8 @@ "build:unchecked": "tsc -p tsconfig.build.json --noCheck", "format": "biome format --write src", "format:check": "biome ci src", - "lint": "eslint . --quiet", - "lint:fix": "eslint . --fix", + "lint": "oxlint --quiet", + "lint:fix": "oxlint --fix", "test": "vitest run --passWithNoTests", "test:dev": "vitest --watch", "test:integration": "vitest run --config vitest.integration.config.ts", @@ -27,11 +27,15 @@ "n8n-workflow": "workspace:*" }, "devDependencies": { + "@n8n/eslint-config": "workspace:*", + "@n8n/oxlint-config": "workspace:*", "@n8n/typescript-config": "workspace:*", "@n8n/vitest-config": "workspace:*", "@testcontainers/postgresql": "catalog:", "@types/supertest": "^6.0.3", "n8n-nodes-base": "workspace:*", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", "supertest": "^7.1.1", "typescript": "catalog:typescript", "uuid": "catalog:", diff --git a/packages/@n8n/node-engine-compatibility/src/__tests__/v1-response-hooks.test.ts b/packages/@n8n/node-engine-compatibility/src/__tests__/v1-response-hooks.test.ts index 9fb37c6470f..abafe45d15b 100644 --- a/packages/@n8n/node-engine-compatibility/src/__tests__/v1-response-hooks.test.ts +++ b/packages/@n8n/node-engine-compatibility/src/__tests__/v1-response-hooks.test.ts @@ -1,4 +1,9 @@ -import type { ResponseEmitter, StepExecutionRequest } from '@n8n/engine'; +import { + noopResponseEmitter, + type JsonValue, + type ResponseEmitter, + type StepExecutionRequest, +} from '@n8n/engine'; import { ENCODED_BUFFER_KEY, ExecutionLifecycleHooks } from 'n8n-core'; import type { IWorkflowBase, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; import { describe, expect, it, vi } from 'vitest'; @@ -6,8 +11,14 @@ import { describe, expect, it, vi } from 'vitest'; import { attachResponseHooks } from '../v1-response-hooks'; const newRequest = () => { + // Builds the payload like an emitter for a caller that expects a step + // response, so a test can read what the node sent. + const sent: JsonValue[] = []; const respond: ResponseEmitter = { - send: vi.fn(() => ({ ok: true as const, result: undefined })), + send: vi.fn((build: () => JsonValue) => { + sent.push(build()); + return { ok: true as const, result: undefined }; + }), }; const request = { context: { @@ -21,14 +32,14 @@ const newRequest = () => { respond, } as unknown as StepExecutionRequest; - return { request, respond }; + return { request, respond, sent }; }; const newAdditionalData = () => ({}) as IWorkflowExecuteAdditionalData; describe('attachResponseHooks', () => { it('preserves hooks supplied by the host', async () => { - const { request, respond } = newRequest(); + const { request, sent } = newRequest(); const existingHandler = vi.fn(); const hooks = new ExecutionLifecycleHooks('webhook', 'exec-1', {} as IWorkflowBase); hooks.addHandler('sendChunk', existingHandler); @@ -40,17 +51,17 @@ describe('attachResponseHooks', () => { expect(additionalData.hooks).toBe(hooks); expect(additionalData.hooks.handlers.sendChunk).toEqual([existingHandler]); - expect(respond.send).toHaveBeenCalledWith(response); + expect(sent).toContainEqual(response); }); it('sends what the Respond node produced to the channel', async () => { - const { request, respond } = newRequest(); + const { request, sent } = newRequest(); const additionalData = newAdditionalData(); attachResponseHooks(additionalData, request); await additionalData.hooks?.runHook('sendResponse', [{ body: { ok: true }, statusCode: 200 }]); - expect(respond.send).toHaveBeenCalledWith({ body: { ok: true }, statusCode: 200 }); + expect(sent).toContainEqual({ body: { ok: true }, statusCode: 200 }); }); it('surfaces errors from the response channel', async () => { @@ -66,11 +77,24 @@ describe('attachResponseHooks', () => { expect(respond.send).toHaveBeenCalledOnce(); }); + it('does not build a payload the emitter drops', async () => { + // Like the engine's emitter for a caller that expects no step response. + const respond: ResponseEmitter = { send: noopResponseEmitter.send }; + const { request } = newRequest(); + const additionalData = newAdditionalData(); + attachResponseHooks(additionalData, { ...request, respond }); + + // A bare Buffer has no JSON form, so building it would throw. + await expect( + additionalData.hooks?.runHook('sendResponse', [Buffer.from('hi')]), + ).resolves.toBeUndefined(); + }); + describe('a Buffer body', () => { const headers = { 'content-type': 'application/octet-stream', 'content-length': 5 }; it('is sent as a base64 envelope, with its headers and status code', async () => { - const { request, respond } = newRequest(); + const { request, sent } = newRequest(); const additionalData = newAdditionalData(); attachResponseHooks(additionalData, request); @@ -78,7 +102,7 @@ describe('attachResponseHooks', () => { { body: Buffer.from('hello'), headers, statusCode: 201 }, ]); - expect(respond.send).toHaveBeenCalledWith({ + expect(sent).toContainEqual({ body: { [ENCODED_BUFFER_KEY]: 'aGVsbG8=' }, headers, statusCode: 201, @@ -100,7 +124,7 @@ describe('attachResponseHooks', () => { describe('a stored binary reference body', () => { it('is sent as is, with its headers and status code', async () => { - const { request, respond } = newRequest(); + const { request, sent } = newRequest(); const additionalData = newAdditionalData(); const response = { body: { binaryData: { id: 'filesystem-v2:file-1', mimeType: 'image/png', data: '' } }, @@ -111,7 +135,7 @@ describe('attachResponseHooks', () => { attachResponseHooks(additionalData, request); await additionalData.hooks?.runHook('sendResponse', [response]); - expect(respond.send).toHaveBeenCalledWith(response); + expect(sent).toContainEqual(response); }); }); diff --git a/packages/@n8n/node-engine-compatibility/src/v1-response-hooks.ts b/packages/@n8n/node-engine-compatibility/src/v1-response-hooks.ts index 185740ef01e..14e26c0bcd5 100644 --- a/packages/@n8n/node-engine-compatibility/src/v1-response-hooks.ts +++ b/packages/@n8n/node-engine-compatibility/src/v1-response-hooks.ts @@ -41,7 +41,8 @@ export function attachResponseHooks( ); additionalData.hooks.addHandler('sendResponse', (response) => { - const result = respond.send(toJsonPayload(response)); + // The payload is built only if the caller expects it. + const result = respond.send(() => toJsonPayload(response)); if (!result.ok) { // Fail the node so the caller can see the response error. throw result.error; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V1/AgentV1.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V1/AgentV1.node.ts index e8d39293bd6..fc58b936577 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V1/AgentV1.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V1/AgentV1.node.ts @@ -324,18 +324,6 @@ export class AgentV1 implements INodeType { }, ], properties: [ - { - displayName: - 'Tip: Get a feel for agents with our quick tutorial or see an example of how this node works', - name: 'aiAgentStarterCallout', - type: 'callout', - default: '', - displayOptions: { - show: { - agent: ['conversationalAgent', 'toolsAgent'], - }, - }, - }, { displayName: "This node is using Agent that has been deprecated. Please switch to using 'Tools Agent' instead.", diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V2/AgentV2.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V2/AgentV2.node.ts index 1762d87def2..930dfe63242 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V2/AgentV2.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V2/AgentV2.node.ts @@ -49,13 +49,6 @@ export class AgentV2 implements INodeType { }, }, properties: [ - { - displayName: - 'Tip: Get a feel for agents with our quick tutorial or see an example of how this node works', - name: 'aiAgentStarterCallout', - type: 'callout', - default: '', - }, promptTypeOptionsDeprecated, { ...textFromGuardrailsNode, diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentV3.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentV3.node.ts index 33e853158b5..757d2f98f00 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentV3.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentV3.node.ts @@ -53,13 +53,6 @@ export class AgentV3 implements INodeType { }, }, properties: [ - { - displayName: - 'Tip: Get a feel for agents with our quick tutorial or see an example of how this node works', - name: 'aiAgentStarterCallout', - type: 'callout', - default: '', - }, { ...promptTypeOptionsDeprecated, displayOptions: { show: { '@version': [{ _cnd: { lt: 3.1 } }] } }, diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 7255d7c24d7..dd4076a60b5 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.41.0", + "version": "2.42.0", "description": "", "main": "index.js", "exports": { diff --git a/packages/@n8n/permissions/package.json b/packages/@n8n/permissions/package.json index 25da67464d5..8403cef05d5 100644 --- a/packages/@n8n/permissions/package.json +++ b/packages/@n8n/permissions/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/permissions", - "version": "0.78.0", + "version": "0.79.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/scan-community-package/package.json b/packages/@n8n/scan-community-package/package.json index cb62c6f028d..4a8438c8a44 100644 --- a/packages/@n8n/scan-community-package/package.json +++ b/packages/@n8n/scan-community-package/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/scan-community-package", - "version": "0.37.0", + "version": "0.38.0", "description": "Static code analyser for n8n community packages", "license": "LicenseRef-n8n-sustainable-use", "bin": "scanner/cli.mjs", diff --git a/packages/@n8n/scheduler/package.json b/packages/@n8n/scheduler/package.json index 9398e866bab..22a7eebacef 100644 --- a/packages/@n8n/scheduler/package.json +++ b/packages/@n8n/scheduler/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/scheduler", - "version": "1.10.0", + "version": "1.11.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/syslog-client/package.json b/packages/@n8n/syslog-client/package.json index 6d6a215a658..9d8b363e7a8 100644 --- a/packages/@n8n/syslog-client/package.json +++ b/packages/@n8n/syslog-client/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/syslog-client", - "version": "1.11.0", + "version": "1.12.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/task-runner/eslint.config.mjs b/packages/@n8n/task-runner/eslint.config.mjs index 3934ca643e6..0cd3b58987a 100644 --- a/packages/@n8n/task-runner/eslint.config.mjs +++ b/packages/@n8n/task-runner/eslint.config.mjs @@ -6,10 +6,12 @@ export default defineConfig( { rules: { complexity: 'error', - - '@typescript-eslint/no-require-imports': 'warn', }, }, + { + files: ['src/js-task-runner/js-task-runner.ts'], + rules: { '@typescript-eslint/no-require-imports': 'warn' }, + }, { files: ['**/*.test.ts'], rules: { diff --git a/packages/@n8n/task-runner/oxlint.config.mts b/packages/@n8n/task-runner/oxlint.config.mts new file mode 100644 index 00000000000..e0c689591d6 --- /dev/null +++ b/packages/@n8n/task-runner/oxlint.config.mts @@ -0,0 +1,24 @@ +import { backendConfig } from '@n8n/oxlint-config/backend'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [backendConfig], + options: { typeAware: true }, + rules: { complexity: 'error' }, + overrides: [ + { + files: ['src/js-task-runner/js-task-runner.ts'], + rules: { 'typescript/no-require-imports': 'warn' }, + }, + { + files: ['**/*.test.ts'], + rules: { + 'import/no-duplicates': 'warn', + 'typescript/unbound-method': 'warn', + 'typescript/no-unsafe-argument': 'warn', + 'typescript/no-unsafe-member-access': 'warn', + 'typescript/no-unsafe-assignment': 'warn', + }, + }, + ], +}); diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index 49f73835e7a..af8b9dd3127 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.41.0", + "version": "2.42.0", "scripts": { "clean": "rimraf dist .turbo", "start": "node dist/start.js", @@ -14,8 +14,8 @@ "test": "vitest run", "test:unit": "vitest run", "test:dev": "vitest --silent=false", - "lint": "eslint . --quiet", - "lint:fix": "eslint . --fix", + "lint": "oxlint --quiet", + "lint:fix": "oxlint --fix", "watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\"" }, "main": "dist/start.js", @@ -52,12 +52,15 @@ }, "devDependencies": { "@n8n/eslint-config": "workspace:*", + "@n8n/oxlint-config": "workspace:*", "@n8n/typescript-config": "workspace:*", "@n8n/vitest-config": "workspace:*", "@types/lodash": "catalog:", "@types/luxon": "catalog:", "@types/ws": "catalog:", "@vitest/coverage-v8": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", "typescript": "catalog:typescript", "vite": "catalog:", "vitest": "catalog:", diff --git a/packages/@n8n/telemetry/package.json b/packages/@n8n/telemetry/package.json index dfa44e8a516..7fb206c5432 100644 --- a/packages/@n8n/telemetry/package.json +++ b/packages/@n8n/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/telemetry", - "version": "1.9.0", + "version": "1.10.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/telemetry/src/events/instance-ai.ts b/packages/@n8n/telemetry/src/events/instance-ai.ts index 4d8ce5dc0bd..0078c048962 100644 --- a/packages/@n8n/telemetry/src/events/instance-ai.ts +++ b/packages/@n8n/telemetry/src/events/instance-ai.ts @@ -250,6 +250,33 @@ export const INSTANCE_AI_TELEMETRY = defineTelemetryEvents({ description: 'The user clicked the plus button in the n8n Assistant input.', properties: z.object({}), }, + // Experiment cleanup (119_surface_assistant_on_workflow_error) + USER_VIEWED_FIX_WITH_ASSISTANT_NUDGE: { + name: 'User viewed fix with Assistant nudge', + description: + 'The Fix with Assistant button was shown inside a workflow error toast. Fires once for each toast that receives the button.', + properties: z.object({ + variant: z.enum(['control', 'variant']), + // eslint-disable-next-line @typescript-eslint/naming-convention -- PostHog feature property + '$feature/119_surface_assistant_on_workflow_error': z.enum(['control', 'variant']), + }), + }, + USER_CLICKED_ERROR_TOAST_FIX_WITH_ASSISTANT: { + name: 'User clicked fix with Assistant CTA on error toast', + description: + 'The user clicked the Fix with Assistant button on a workflow error toast. assistant_enabled records whether n8n Assistant was enabled at click time. Enabled opens the Assistant. Disabled opens Assistant settings.', + properties: z.object({ + assistant_enabled: z + .boolean() + .describe( + 'Whether n8n Assistant was enabled. True opens the Assistant. False opens Assistant settings.', + ), + variant: z.enum(['control', 'variant']), + // eslint-disable-next-line @typescript-eslint/naming-convention -- PostHog feature property + '$feature/119_surface_assistant_on_workflow_error': z.enum(['control', 'variant']), + }), + }, + // EOF Experiment cleanup TOOLS_LIST_OPENED: { name: 'Instance AI tools list opened', description: 'The user opened the n8n Assistant tools connection modal.', diff --git a/packages/@n8n/utils/package.json b/packages/@n8n/utils/package.json index b0c20c7e0c5..a73fe3cb1ad 100644 --- a/packages/@n8n/utils/package.json +++ b/packages/@n8n/utils/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/utils", "type": "module", - "version": "1.48.0", + "version": "1.49.0", "files": [ "dist" ], diff --git a/packages/@n8n/utils/src/sort/partition-last.test.ts b/packages/@n8n/utils/src/sort/partition-last.test.ts new file mode 100644 index 00000000000..8f6bb0bcbfb --- /dev/null +++ b/packages/@n8n/utils/src/sort/partition-last.test.ts @@ -0,0 +1,36 @@ +import { partitionLast } from './partition-last'; + +describe('partitionLast', () => { + it('moves matching items to the end and keeps the order inside both groups', () => { + const items = ['a', 'B', 'c', 'D', 'e']; + + expect(partitionLast(items, (item) => item === item.toUpperCase())).toEqual([ + 'a', + 'c', + 'e', + 'B', + 'D', + ]); + }); + + it('returns the same array when nothing matches', () => { + const items = [1, 2, 3]; + + expect(partitionLast(items, () => false)).toBe(items); + }); + + it('returns a new array when at least one item matches', () => { + const items = [1, 2, 3]; + const result = partitionLast(items, (item) => item === 1); + + expect(result).not.toBe(items); + expect(result).toEqual([2, 3, 1]); + expect(items).toEqual([1, 2, 3]); + }); + + it('handles an empty array', () => { + const items: number[] = []; + + expect(partitionLast(items, () => true)).toBe(items); + }); +}); diff --git a/packages/@n8n/utils/src/sort/partition-last.ts b/packages/@n8n/utils/src/sort/partition-last.ts new file mode 100644 index 00000000000..d5f74acd023 --- /dev/null +++ b/packages/@n8n/utils/src/sort/partition-last.ts @@ -0,0 +1,11 @@ +/** Stable: moves matches to the end. Returns `items` itself when nothing matches. */ +export function partitionLast(items: T[], predicate: (item: T) => boolean): T[] { + const rest: T[] = []; + const last: T[] = []; + for (const item of items) { + (predicate(item) ? last : rest).push(item); + } + if (last.length === 0) return items; + + return [...rest, ...last]; +} diff --git a/packages/@n8n/vitest-config/frontend.ts b/packages/@n8n/vitest-config/frontend.ts index acb65de4fcb..bbb15c76dfe 100644 --- a/packages/@n8n/vitest-config/frontend.ts +++ b/packages/@n8n/vitest-config/frontend.ts @@ -4,6 +4,7 @@ import type { InlineConfig } from 'vitest/node'; import { changedFileCoverage } from './changed-file-coverage.js'; import { coverageExcludes } from './coverage-excludes.js'; +import { profilingConfig, profilingReporters } from './profiling.js'; // Resolves to the empty component that stands in for `.svg` imports (see below). const svgStub = fileURLToPath(new URL('./svg-stub.js', import.meta.url)); @@ -11,6 +12,7 @@ const svgStub = fileURLToPath(new URL('./svg-stub.js', import.meta.url)); export const createVitestConfig = (options: InlineConfig = {}) => { const vitestConfig = defineConfig({ test: { + ...profilingConfig(), // Redirect the node-icon `.svg` imports (the ~80 files under // `N8nIcon/nodes/`) to an inert component. `node-icons.ts` is imported // lazily (on the first `node:*` icon render), so its bulk async @@ -34,7 +36,7 @@ export const createVitestConfig = (options: InlineConfig = {}) => { // spies set up once don't leak across tests. Packages may override via `options`. restoreMocks: true, environment: 'jsdom', - // CI shards the frontend suite 2-way (`--shard=N/2`). A package with fewer + // CI shards the frontend suite across runners. A package with fewer // test files than shards leaves a shard with nothing to run, and vitest // treats that as an error — so a sparse package (a freshly scaffolded // module, a config-only package) would fail outright. Default it on here so @@ -45,7 +47,6 @@ export const createVitestConfig = (options: InlineConfig = {}) => { // Externalized, pnpm can link them to a second vitest copy. Vitest 5 bundles // `expect` into `vitest`, so a second copy breaks snapshots and `.rejects`. server: { deps: { inline: ['vitest-mock-extended', '@testing-library/jest-dom'] } }, - reporters: process.env.CI === 'true' ? ['default', 'junit'] : ['default'], outputFile: { junit: './junit.xml' }, coverage: { enabled: false, @@ -60,6 +61,9 @@ export const createVitestConfig = (options: InlineConfig = {}) => { }, }, ...options, + reporters: profilingReporters( + options.reporters ?? (process.env.CI === 'true' ? ['default', 'junit'] : ['default']), + ), }, }); diff --git a/packages/@n8n/vitest-config/node.ts b/packages/@n8n/vitest-config/node.ts index 55733a6178d..b2663c4d077 100644 --- a/packages/@n8n/vitest-config/node.ts +++ b/packages/@n8n/vitest-config/node.ts @@ -6,6 +6,9 @@ import type { InlineConfig } from 'vitest/node'; import { changedFileCoverage } from './changed-file-coverage.js'; import { coverageExcludes } from './coverage-excludes.js'; +import { profilingConfig, profilingReporters } from './profiling.js'; + +export { profilingReporters } from './profiling.js'; /** * Pin dual-build (ESM+CJS) deps to their CJS entry so a single class identity is shared @@ -52,6 +55,7 @@ export const forkPoolOptions = (): InlineConfig => { * Use this when you need to spread the config into workspace projects. */ export const createBaseInlineConfig = (options: InlineConfig = {}): InlineConfig => ({ + ...profilingConfig(), silent: true, globals: true, // Restore `vi.spyOn` spies to their original implementation before each test, so @@ -62,7 +66,6 @@ export const createBaseInlineConfig = (options: InlineConfig = {}): InlineConfig // Externalized, pnpm can link it to a second vitest copy, which breaks snapshot state. server: { deps: { inline: ['vitest-mock-extended'] } }, ...forkPoolOptions(), - reporters: process.env.CI === 'true' ? ['default', 'junit'] : ['default'], outputFile: { junit: './junit.xml' }, ...(process.env.COVERAGE_ENABLED === 'true' ? { @@ -78,6 +81,9 @@ export const createBaseInlineConfig = (options: InlineConfig = {}): InlineConfig } : {}), ...options, + reporters: profilingReporters( + options.reporters ?? (process.env.CI === 'true' ? ['default', 'junit'] : ['default']), + ), }); export const createVitestConfig = ( diff --git a/packages/@n8n/vitest-config/package.json b/packages/@n8n/vitest-config/package.json index d3a3aeae045..060065035aa 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.22.0", + "version": "1.23.0", "type": "module", "peerDependencies": { "@testing-library/jest-dom": "catalog:frontend", diff --git a/packages/@n8n/vitest-config/phase-reporter.ts b/packages/@n8n/vitest-config/phase-reporter.ts new file mode 100644 index 00000000000..222d1cc7fd6 --- /dev/null +++ b/packages/@n8n/vitest-config/phase-reporter.ts @@ -0,0 +1,21 @@ +import type { Reporter } from 'vitest/node'; + +export class PhaseReporter implements Reporter { + private firstModuleStart?: string; + private lastModuleEnd?: string; + + onTestModuleStart() { + this.firstModuleStart ??= new Date().toISOString(); + } + + onTestModuleEnd() { + this.lastModuleEnd = new Date().toISOString(); + } + + onTestRunEnd() { + if (!this.firstModuleStart || !this.lastModuleEnd) return; + process.stdout.write( + `N8N_VITEST_PHASE ${JSON.stringify({ version: 1, firstModuleStart: this.firstModuleStart, lastModuleEnd: this.lastModuleEnd })}\n`, + ); + } +} diff --git a/packages/@n8n/vitest-config/profiling.ts b/packages/@n8n/vitest-config/profiling.ts new file mode 100644 index 00000000000..47049c194d7 --- /dev/null +++ b/packages/@n8n/vitest-config/profiling.ts @@ -0,0 +1,24 @@ +import type { InlineConfig } from 'vitest/node'; + +import { PhaseReporter } from './phase-reporter.js'; + +export const profilingReporters = ( + reporters: NonNullable, +): NonNullable => + process.env.VITEST_PHASE_PROFILE === 'true' + ? [...(Array.isArray(reporters) ? reporters : [reporters]), new PhaseReporter()] + : reporters; + +export const profilingConfig = (): InlineConfig => { + const profileDir = process.env.VITEST_RUNNER_PROFILE_DIR; + const profileKinds = new Set(process.env.VITEST_RUNNER_PROFILE_KINDS?.split(',') ?? []); + if (!profileDir) return {}; + + const execArgv = [ + ...(profileKinds.has('cpu') ? ['--cpu-prof', `--cpu-prof-dir=${profileDir}`] : []), + ...(profileKinds.has('heap') ? ['--heap-prof', `--heap-prof-dir=${profileDir}`] : []), + ]; + + // Keep diagnostic profiles readable. These settings are only active through the local profiler. + return execArgv.length > 0 ? { execArgv, fileParallelism: false } : {}; +}; diff --git a/packages/@n8n/workflow-sdk/package.json b/packages/@n8n/workflow-sdk/package.json index 0eeba97793b..9ffdd64749f 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.34.0", + "version": "0.35.0", "description": "TypeScript SDK for programmatically creating n8n workflows", "exports": { ".": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 81321cb5a57..e703e2d11a8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "2.41.0", + "version": "2.42.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", @@ -111,7 +111,6 @@ "concurrently": "^8.2.0", "esbuild": "catalog:", "form-data": "catalog:", - "ioredis-mock": "^8.8.1", "minifaker": "1.34.1", "mjml": "^4.15.3", "n8n-containers": "workspace:*", @@ -172,6 +171,7 @@ "@n8n/engine": "workspace:*", "@n8n/errors": "workspace:*", "@n8n/expression-runtime": "workspace:*", + "@n8n/inbound-auth": "workspace:*", "@n8n/instance-ai": "workspace:*", "@n8n/mcp-apps": "workspace:*", "@n8n/mcp-browser": "workspace:*", @@ -204,7 +204,6 @@ "ai": "catalog:", "bcryptjs": "2.4.3", "bull": "4.16.4", - "cache-manager": "catalog:", "change-case": "catalog:", "chat": "catalog:", "class-transformer": "0.5.1", diff --git a/packages/cli/scripts/build.mjs b/packages/cli/scripts/build.mjs index f92ee2dfa07..3a532751098 100644 --- a/packages/cli/scripts/build.mjs +++ b/packages/cli/scripts/build.mjs @@ -39,7 +39,7 @@ function generateUserManagementEmailTemplates() { // One mjml process for all templates: each `pnpm mjml` start costs about 0.3s. // With several inputs, mjml writes `.html` into the output directory. const sources = templates.map((template) => `"${path.resolve(sourceDir, template)}"`).join(' '); - const result = shell.exec(`pnpm mjml ${sources} --output "${destinationDir}${path.sep}"`, { + const result = shell.exec(`pnpm mjml ${sources} --output "${destinationDir}"`, { silent: false, }); if (result.code !== 0) { diff --git a/packages/cli/src/__tests__/active-executions.test.ts b/packages/cli/src/__tests__/active-executions.test.ts index ff43e51f69a..d8ee28f3160 100644 --- a/packages/cli/src/__tests__/active-executions.test.ts +++ b/packages/cli/src/__tests__/active-executions.test.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { mockInstance, mockLogger } from '@n8n/backend-test-utils'; import { ExecutionsConfig } from '@n8n/config'; import type { GlobalConfig } from '@n8n/config'; @@ -26,7 +27,6 @@ import { captor, mock } from 'vitest-mock-extended'; import { ActiveExecutions } from '@/active-executions'; import { EXECUTION_ENDED_WITHOUT_RESPONSE } from '@/webhooks/constants'; import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; -import type { EventService } from '@/events/event.service'; import type { ExecutionPersistence } from '@/executions/execution-persistence'; import type { License } from '@/license'; import type { Telemetry } from '@/telemetry'; diff --git a/packages/cli/src/__tests__/active-workflow-manager.test.ts b/packages/cli/src/__tests__/active-workflow-manager.test.ts index 599ac39a503..4a8b91b140b 100644 --- a/packages/cli/src/__tests__/active-workflow-manager.test.ts +++ b/packages/cli/src/__tests__/active-workflow-manager.test.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/unbound-method */ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { mockLogger } from '@n8n/backend-test-utils'; import type { GlobalConfig, WorkflowsConfig } from '@n8n/config'; import type { Project, WorkflowEntity, WorkflowHistory, WorkflowRepository } from '@n8n/db'; @@ -33,7 +34,6 @@ import { mock, type MockProxy } from 'vitest-mock-extended'; import type { ActivationErrorsService } from '@/activation-errors.service'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { DuplicateExecutionError } from '@/errors/duplicate-execution.error'; -import type { EventService } from '@/events/event.service'; import type { ExecutionService } from '@/executions/execution.service'; import type { NodeTypes } from '@/node-types'; import type { Push } from '@/push'; @@ -262,10 +262,13 @@ describe('ActiveWorkflowManager', () => { // Registration fails here (no real node types); the check runs before it. await activeWorkflowManager.add('wf-1', 'activate').catch(() => {}); - expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledExactlyOnceWith({ - workflow: { id: 'wf-1', name: 'My workflow', nodes: VERSION_NODES }, - projectId: 'project-1', - }); + expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledExactlyOnceWith( + { + workflow: { id: 'wf-1', name: 'My workflow', nodes: VERSION_NODES }, + projectId: 'project-1', + }, + { kind: 'system', reason: 'activation' }, + ); }); test('registers nothing and records an activation error when policy blocks', async () => { diff --git a/packages/cli/src/__tests__/controller.registry.test.ts b/packages/cli/src/__tests__/controller.registry.test.ts index 544df733ceb..d4cc9d23660 100644 --- a/packages/cli/src/__tests__/controller.registry.test.ts +++ b/packages/cli/src/__tests__/controller.registry.test.ts @@ -28,7 +28,7 @@ import { z } from 'zod'; import type { AuthService } from '@/auth/auth.service'; import { ControllerRegistry } from '@/controller.registry'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import type { License } from '@/license'; import type { LastActiveAtService } from '@/services/last-active-at.service'; import { RateLimitService } from '@/services/rate-limit.service'; diff --git a/packages/cli/src/__tests__/credentials-helper.test.ts b/packages/cli/src/__tests__/credentials-helper.test.ts index 46da2ed569c..5b173415856 100644 --- a/packages/cli/src/__tests__/credentials-helper.test.ts +++ b/packages/cli/src/__tests__/credentials-helper.test.ts @@ -3049,7 +3049,11 @@ describe('CredentialsHelper', () => { source: null, } as IExecuteData; - const additionalData = mock({ projectId: 'proj-1' }); + const additionalData = mock({ + projectId: 'proj-1', + executionId: 'exec-1', + userId: 'user-1', + }); await helper.getDecrypted( additionalData, @@ -3060,16 +3064,69 @@ describe('CredentialsHelper', () => { true, ); - expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith({ - credentialType: 'testApi', - credentialId: 'cred-policy', - consumer: { nodeType: 'n8n-nodes-base.slack' }, - projectId: 'proj-1', + expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith( + { + credentialType: 'testApi', + credentialId: 'cred-policy', + consumer: { nodeType: 'n8n-nodes-base.slack' }, + projectId: 'proj-1', + }, + { kind: 'system', reason: 'execution', executionId: 'exec-1' }, + ); + }); + + test('names the user when the decrypt is outside a run, e.g. an OAuth flow', async () => { + const additionalData = mock({ + projectId: undefined, + executionId: undefined, + userId: 'user-1', }); + + await helper.getDecrypted( + additionalData, + nodeCredentials, + 'testApi', + 'internal', + undefined, + true, + ); + + expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith( + expect.anything(), + { kind: 'user', user: { id: 'user-1' } }, + ); + }); + + test('uses the actor the caller names over the derived one', async () => { + const additionalData = mock({ + projectId: undefined, + executionId: undefined, + userId: undefined, + }); + + await helper.getDecrypted( + additionalData, + nodeCredentials, + 'testApi', + 'internal', + undefined, + true, + undefined, + { actor: { kind: 'system', reason: 'log-streaming' } }, + ); + + expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith( + expect.anything(), + { kind: 'system', reason: 'log-streaming' }, + ); }); test('passes a null consumer when no node is asking, e.g. a credential test', async () => { - const additionalData = mock({ projectId: undefined }); + const additionalData = mock({ + projectId: undefined, + executionId: undefined, + userId: undefined, + }); await helper.getDecrypted( additionalData, @@ -3082,6 +3139,7 @@ describe('CredentialsHelper', () => { expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ consumer: null, projectId: null }), + { kind: 'system', reason: 'execution' }, ); }); diff --git a/packages/cli/src/__tests__/response-helper.test.ts b/packages/cli/src/__tests__/response-helper.test.ts index dac4f9d66b2..4954207f0bd 100644 --- a/packages/cli/src/__tests__/response-helper.test.ts +++ b/packages/cli/src/__tests__/response-helper.test.ts @@ -4,11 +4,8 @@ import { ErrorReporter } from 'n8n-core'; import { UserError } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import { ConflictError, ForbiddenError, InternalServerError, NotFoundError } from '@n8n/errors'; import { LicenseEulaRequiredError } from '@/errors/response-errors/license-eula-required.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { PolicyViolationError } from '@/policy/policy-violation.error'; import { reportError, sendErrorResponse } from '@/response-helper'; diff --git a/packages/cli/src/__tests__/wait-tracker.test.ts b/packages/cli/src/__tests__/wait-tracker.test.ts index b41cdbc93b2..9c2533f9dd0 100644 --- a/packages/cli/src/__tests__/wait-tracker.test.ts +++ b/packages/cli/src/__tests__/wait-tracker.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/unbound-method */ import type { Logger } from '@n8n/backend-common'; -import { mockLogger } from '@n8n/backend-test-utils'; +import { mockLogger, mockInstance } from '@n8n/backend-test-utils'; import type { Project, IExecutionResponse, ExecutionRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; @@ -25,6 +25,7 @@ import { mock, captor } from 'vitest-mock-extended'; import type { ActiveExecutions } from '@/active-executions'; import { ExecutionAlreadyResumingError } from '@/errors/execution-already-resuming.error'; import { ExecutionPersistence } from '@/executions/execution-persistence'; +import { NodeTypes } from '@/node-types'; import type { MultiMainSetup } from '@/scaling/multi-main-setup.ee'; import type { OwnershipService } from '@/services/ownership.service'; import type { WorkflowPublisherService } from '@/workflows/workflow-publisher.service'; @@ -324,6 +325,21 @@ describe('WaitTracker', () => { return { parentExecution, subworkflowResults, postExecutePromise }; }; + it('passes the saved child definition when a timer resumes the child', async () => { + setupParentExecutionTest(true); + const resume = vi + .spyOn(waitTracker, 'resumeParentExecution') + .mockResolvedValueOnce(undefined); + await waitTracker.startExecution(execution.id); + expect(resume).toHaveBeenCalledWith( + execution.data.parentExecution, + expect.any(Promise), + { executionId: execution.id, workflowId: execution.workflowData.id }, + execution.workflowData, + ); + resume.mockRestore(); + }); + it('should resume parent execution once sub-workflow finishes by default', async () => { // ARRANGE const { parentExecution, postExecutePromise, subworkflowResults } = @@ -936,30 +952,68 @@ describe('WaitTracker', () => { expect(executionRepository.findParkedOnSubExecution).toHaveBeenCalledTimes(2); }); - it('patches and resumes a parent whose tagged child has finished', async () => { - const parent = parkedParent([childId]); - executionPersistence.findSingleExecution.calledWith(parentId).mockResolvedValue(parent); - executionPersistence.findSingleExecution - .calledWith(childId) - .mockResolvedValue(finishedChild()); - executionRepository.findStatusesByIds.mockResolvedValue([{ id: childId, status: 'success' }]); + it.each([false, true])( + 'patches a finished child with single-output policy: %s', + async (singleOutput) => { + const parent = parkedParent([childId]); + const child = finishedChild(); + if (singleOutput) { + child.data.subWorkflowOutput = { lastRunOnly: false }; + const task = child.data.resultData.runData['Final Node'][0]; + task.data = { main: [[], task.data!.main![0]] }; + child.workflowData = { + ...child.workflowData, + connections: {}, + settings: {}, + staticData: undefined, + nodes: [ + { + id: 'final', + name: 'Final Node', + type: 'test', + typeVersion: 1, + parameters: {}, + position: [0, 0], + }, + ], + }; + mockInstance(NodeTypes).getByNameAndVersion.mockReturnValue({ + description: { + name: 'test', + displayName: 'Test', + group: ['transform'], + version: 1, + description: '', + defaults: {}, + inputs: ['main'], + outputs: ['main', 'main'], + properties: [], + }, + }); + } + executionPersistence.findSingleExecution.calledWith(parentId).mockResolvedValue(parent); + executionPersistence.findSingleExecution.calledWith(childId).mockResolvedValue(child); + executionRepository.findStatusesByIds.mockResolvedValue([ + { id: childId, status: 'success' }, + ]); - await waitTracker.resumeParentsOfFinishedSubExecutions(); + await waitTracker.resumeParentsOfFinishedSubExecutions(); - const dataCaptor = captor<{ data: IRunExecutionData }>(); - expect(executionPersistence.updateExistingExecution).toHaveBeenCalledWith( - parentId, - dataCaptor, - ); - expect(dataCaptor.value.data.executionData?.nodeExecutionStack[0].data.main).toEqual([ - [{ json: { data: 'child output' }, pairedItem: { item: 0 } }], - ]); - expect(workflowRunner.run).toHaveBeenCalledWith(expect.any(Object), false, false, { - executionId: parentId, - expectedStatus: 'waiting', - }); - expect(logger.error).not.toHaveBeenCalled(); - }); + const dataCaptor = captor<{ data: IRunExecutionData }>(); + expect(executionPersistence.updateExistingExecution).toHaveBeenCalledWith( + parentId, + dataCaptor, + ); + expect(dataCaptor.value.data.executionData?.nodeExecutionStack[0].data.main).toEqual([ + [{ json: { data: 'child output' }, pairedItem: { item: 0 } }], + ]); + expect(workflowRunner.run).toHaveBeenCalledWith(expect.any(Object), false, false, { + executionId: parentId, + expectedStatus: 'waiting', + }); + expect(logger.error).not.toHaveBeenCalled(); + }, + ); it('leaves the parent parked while its child is still waiting', async () => { executionPersistence.findSingleExecution.mockResolvedValue(parkedParent([childId])); diff --git a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts index e582ac79fc3..a6a8c2284da 100644 --- a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts +++ b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts @@ -1,3 +1,4 @@ +import { EventService, UrlService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { ExecutionsConfig, GlobalConfig, WorkflowsConfig } from '@n8n/config'; import type { WorkflowEntity, Project, WorkflowHistory } from '@n8n/db'; @@ -21,6 +22,7 @@ import type { WorkflowExecuteMode, ExecuteAgentWorkflowContext, IRunExecutionData, + IWorkflowExecutionDataProcess, } from 'n8n-workflow'; import { createRunExecutionData } from 'n8n-workflow'; import type PCancelable from 'p-cancelable'; @@ -30,7 +32,6 @@ import { mock } from 'vitest-mock-extended'; import { ActiveExecutions } from '@/active-executions'; import { CredentialsHelper } from '@/credentials-helper'; import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; -import { EventService } from '@/events/event.service'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import { CredentialsPermissionChecker, @@ -42,7 +43,6 @@ import { hashAgentSandboxPrincipal } from '@/modules/agents/agent-sandbox-princi import { AgentWorkflowExecutionService } from '@/modules/agents/agent-workflow-execution.service'; import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.service'; import { OwnershipService } from '@/services/ownership.service'; -import { UrlService } from '@n8n/backend-services'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { Telemetry } from '@/telemetry'; import { @@ -65,6 +65,7 @@ const LAST_NODE_EXECUTED = 'Last node executed'; const getMockRun = ({ lastNodeOutput }: { lastNodeOutput: Array }) => mock({ data: { + subWorkflowOutput: undefined, resultData: { runData: { [LAST_NODE_EXECUTED]: [ @@ -209,6 +210,55 @@ describe('WorkflowExecuteAdditionalData', () => { expect(activeExecutions.add).not.toHaveBeenCalled(); }); + it.each([false, true])( + 'persists caller lastRunOnly=%s before the child starts', + async (returnLastRunOnly) => { + const workflowData = mock({ + nodes: [ + { + id: 'trigger', + name: 'Start', + type: 'n8n-nodes-base.executeWorkflowTrigger', + typeVersion: 1.3, + position: [0, 0], + parameters: {}, + }, + ], + connections: {}, + }); + activeExecutions.add.mockImplementationOnce(async (runData) => { + expect(runData.executionData?.subWorkflowOutput).toEqual({ + lastRunOnly: returnLastRunOnly, + }); + throw new Error('Stop after persistence check'); + }); + await expect( + executeWorkflow(mock(), mock(), { + ...mock(), + loadedWorkflowData: workflowData, + loadedRunData: undefined, + returnLastRunOnly, + }), + ).rejects.toThrow('Stop after persistence check'); + }, + ); + + it('keeps the saved output policy when supplied run data is reused', async () => { + const executionData = createRunExecutionData({ subWorkflowOutput: { lastRunOnly: true } }); + activeExecutions.add.mockImplementationOnce(async (runData) => { + expect(runData.executionData?.subWorkflowOutput).toEqual({ lastRunOnly: true }); + throw new Error('Stop after persistence check'); + }); + await expect( + executeWorkflow(mock(), mock(), { + ...mock(), + loadedWorkflowData: undefined, + loadedRunData: { ...mock(), executionData }, + returnLastRunOnly: false, + }), + ).rejects.toThrow('Stop after persistence check'); + }); + it('should execute workflow, return data and execution id', async () => { const response = await executeWorkflow( mock(), @@ -477,6 +527,7 @@ describe('WorkflowExecuteAdditionalData', () => { const getMockRunWithCredentialFlags = (task: Partial, executedByUserId?: string) => mock({ data: { + subWorkflowOutput: undefined, resultData: { runData: { [LAST_NODE_EXECUTED]: [ @@ -583,6 +634,7 @@ describe('WorkflowExecuteAdditionalData', () => { ) => mock({ data: { + subWorkflowOutput: undefined, resultData: { runData: { [LAST_NODE_EXECUTED]: [{ startTime: 100, ...task }], @@ -2033,6 +2085,7 @@ describe('WorkflowExecuteAdditionalData', () => { return { mode: overrides.mode ?? 'manual', data: { + subWorkflowOutput: undefined, resultData: { runData: overrides.runData ?? twoRunsOnTerminalNode, pinData: overrides.pinData, diff --git a/packages/cli/src/__tests__/workflow-helpers.test.ts b/packages/cli/src/__tests__/workflow-helpers.test.ts index 18bf1d5222a..a298889f6b5 100644 --- a/packages/cli/src/__tests__/workflow-helpers.test.ts +++ b/packages/cli/src/__tests__/workflow-helpers.test.ts @@ -16,6 +16,7 @@ import type { import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; import { ExecutionPersistence } from '@/executions/execution-persistence'; +import { NodeTypes } from '@/node-types'; import { OwnershipService } from '@/services/ownership.service'; import { getLastExecutedNodeData, @@ -32,7 +33,7 @@ import { sanitizeNodeGroupDescriptions, WorkflowStructureBadRequestError, } from '@/workflow-helpers'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { mock } from 'vitest-mock-extended'; describe('workflow-helpers', () => { @@ -1019,11 +1020,15 @@ describe('updateParentExecutionWithChildResults', () => { }; // Runs the workflow helper against a waiting parent and returns the updated stack entry. - async function resumeWith(child: IRun, childExecution?: RelatedExecution) { + async function resumeWith( + child: IRun, + childExecution?: RelatedExecution, + workflowData?: IWorkflowBase, + ) { const executionPersistence = mockInstance(ExecutionPersistence); executionPersistence.findSingleExecution.mockResolvedValue(waitingParent()); - await updateParentExecutionWithChildResults(PARENT_ID, child, childExecution); + await updateParentExecutionWithChildResults(PARENT_ID, child, childExecution, workflowData); expect(executionPersistence.updateExistingExecution).toHaveBeenCalledTimes(1); const [, payload] = executionPersistence.updateExistingExecution.mock.calls[0]; @@ -1031,6 +1036,93 @@ describe('updateParentExecutionWithChildResults', () => { .nodeExecutionStack[0] as unknown as StackEntry; } + function savedWorkflow(outputCount: number): IWorkflowBase { + mockInstance(NodeTypes).getByNameAndVersion.mockReturnValue({ + description: { + name: 'test', + displayName: 'Test', + group: ['transform'], + version: 1, + description: '', + defaults: {}, + inputs: ['main'], + outputs: Array.from({ length: outputCount }, () => 'main'), + properties: [], + }, + }); + return { + ...mock(), + nodes: [ + { + id: 'last', + name: 'Last', + type: 'test', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + connections: {}, + settings: {}, + staticData: undefined, + }; + } + + it.each([false, true])('uses the saved lastRunOnly=%s policy on resume', async (lastRunOnly) => { + const child = childRun('success', 'Last', { + executionIndex: 1, + data: { main: [[], [{ json: { id: 57 } }]] }, + }); + child.data.resultData.runData.Last.unshift({ + startTime: 0, + executionTime: 0, + source: [], + executionIndex: 0, + data: { main: [[], [{ json: { id: 55 } }]] }, + }); + if (!lastRunOnly) child.data.resultData.runData.Last.reverse(); + child.data.subWorkflowOutput = { lastRunOnly }; + const entry = await resumeWith(child, undefined, savedWorkflow(2)); + expect(entry.data).toEqual({ + main: [lastRunOnly ? [{ json: { id: 57 } }] : [{ json: { id: 55 } }, { json: { id: 57 } }]], + }); + }); + + it.each([true, false])( + 'excludes discarded items on resume when Filter keeps items: %s', + async (keepsItems) => { + const kept = keepsItems ? [{ json: { id: 55 } }] : []; + const child = childRun('success', 'Last', { data: { main: [kept, [{ json: { id: 56 } }]] } }); + child.data.subWorkflowOutput = { lastRunOnly: false }; + const entry = await resumeWith(child, undefined, savedWorkflow(1)); + expect(entry.data).toEqual({ main: [kept] }); + }, + ); + + it('keeps the final run and branch shape for an execution without a saved policy', async () => { + const child = childRun('success', 'Last', { data: { main: [[], [{ json: { id: 57 } }]] } }); + child.data.resultData.runData.Last.unshift({ + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + data: { main: [[{ json: { id: 55 } }]] }, + }); + const entry = await resumeWith(child, undefined, savedWorkflow(2)); + expect(entry.data).toEqual({ main: [[], [{ json: { id: 57 } }]] }); + }); + + it('requires the execution snapshot for a saved output policy', async () => { + const child = childRun('success', 'Last', { data: { main: [[{ json: {} }]] } }); + child.data.subWorkflowOutput = { lastRunOnly: false }; + const persistence = mockInstance(ExecutionPersistence); + persistence.findSingleExecution.mockResolvedValue(waitingParent()); + await expect(updateParentExecutionWithChildResults(PARENT_ID, child)).rejects.toThrow( + 'saved child workflow', + ); + expect(persistence.updateExistingExecution).not.toHaveBeenCalled(); + }); + it('carries the child error and execution reference onto the parent node so resume can fail it', async () => { const error = { name: 'NodeOperationError', message: 'ERROR' } as unknown as ExecutionError; const entry = await resumeWith(childRun('error', 'Stop and Error', { error }, error), { diff --git a/packages/cli/src/abstract-server.ts b/packages/cli/src/abstract-server.ts index c9cebd322b5..39c0fa02669 100644 --- a/packages/cli/src/abstract-server.ts +++ b/packages/cli/src/abstract-server.ts @@ -12,7 +12,7 @@ import { SLACK_HITL_WEBHOOK_SUFFIX, TELEGRAM_HITL_WEBHOOK_SUFFIX } from 'n8n-cor import config from '@/config'; import { N8N_VERSION, TEMPLATES_DIR } from '@/constants'; -import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; +import { ServiceUnavailableError } from '@n8n/errors'; import { ExternalHooks } from '@/external-hooks'; import { bodyParser, corsMiddleware, rawBodyReader } from '@/middlewares'; import { sendErrorResponse } from '@/response-helper'; diff --git a/packages/cli/src/activation-errors.service.ts b/packages/cli/src/activation-errors.service.ts index 1d1bb349aa6..a15a10e2dea 100644 --- a/packages/cli/src/activation-errors.service.ts +++ b/packages/cli/src/activation-errors.service.ts @@ -1,6 +1,6 @@ import { Service } from '@n8n/di'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; @Service() export class ActivationErrorsService { diff --git a/packages/cli/src/active-executions.ts b/packages/cli/src/active-executions.ts index 6219481f50d..a80a36214d9 100644 --- a/packages/cli/src/active-executions.ts +++ b/packages/cli/src/active-executions.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { ExecutionsConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import type { CreateExecutionPayload, IExecutionDb } from '@n8n/db'; @@ -36,7 +37,6 @@ import { EXECUTION_ENDED_WITHOUT_RESPONSE } from '@/webhooks/constants'; import { ConcurrencyCapacityReservation } from './concurrency/concurrency-capacity-reservation'; import { ConcurrencyControlService } from './concurrency/concurrency-control.service'; -import { EventService } from './events/event.service'; const DEFAULT_CANCEL_WRITE_TIMEOUT_MS = 3 * Time.seconds.toMilliseconds; diff --git a/packages/cli/src/active-workflow-manager.ts b/packages/cli/src/active-workflow-manager.ts index 36713b0f107..95cd31b0dc1 100644 --- a/packages/cli/src/active-workflow-manager.ts +++ b/packages/cli/src/active-workflow-manager.ts @@ -45,6 +45,7 @@ import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus' import { ExternalHooks } from '@/external-hooks'; import { NodeTypes } from '@/node-types'; import { enforceWorkflowPublishPolicy } from '@/policy/enforce-workflow-publish'; +import type { PolicyActor } from '@/policy/policy-enforcement-backend'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { isPolicyRefusal } from '@/policy/policy-violation.error'; import { Publisher } from '@/scaling/pubsub/publisher.service'; @@ -540,7 +541,10 @@ export class ActiveWorkflowManager { workflowId: WorkflowId, activationMode: WorkflowActivateMode, existingWorkflow?: WorkflowEntity, - { shouldPublish } = { shouldPublish: true }, + { + shouldPublish = true, + actor = { kind: 'system', reason: 'activation' }, + }: { shouldPublish?: boolean; actor?: PolicyActor } = {}, ) { const added = { webhooks: false, triggersAndPollers: false }; @@ -601,11 +605,12 @@ export class ActiveWorkflowManager { // Trigger and poller nodes run code at registration, so this gates startup // and leadership change too, not just the activate button. - await enforceWorkflowPublishPolicy(this.policyEnforcementService, this.ownershipService, { - id: dbWorkflow.id, - name: dbWorkflow.name, - nodes, - }); + await enforceWorkflowPublishPolicy( + this.policyEnforcementService, + this.ownershipService, + { id: dbWorkflow.id, name: dbWorkflow.name, nodes }, + actor, + ); workflow = new Workflow({ id: dbWorkflow.id, diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index 97131070be6..c33dae7f30d 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -12,8 +12,7 @@ import escapeRegExp from 'lodash/escapeRegExp'; import type { StringValue as TimeUnitValue } from 'ms'; import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from '@/constants'; -import { AuthError } from '@/errors/response-errors/auth.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { AuthError, ForbiddenError } from '@n8n/errors'; import { License } from '@/license'; import { MfaService } from '@/mfa/mfa.service'; import { JwtService } from '@/services/jwt.service'; diff --git a/packages/cli/src/auth/handlers/__tests__/email.auth-handler.test.ts b/packages/cli/src/auth/handlers/__tests__/email.auth-handler.test.ts index 3f19f3d02e0..1b2f2112ccb 100644 --- a/packages/cli/src/auth/handlers/__tests__/email.auth-handler.test.ts +++ b/packages/cli/src/auth/handlers/__tests__/email.auth-handler.test.ts @@ -1,9 +1,9 @@ +import type { EventService } from '@n8n/backend-services'; import type { GlobalConfig } from '@n8n/config'; import type { AuthIdentity, User, UserRepository } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import { AuthError } from '@/errors/response-errors/auth.error'; -import type { EventService } from '@/events/event.service'; +import { AuthError } from '@n8n/errors'; import type { PasswordUtility } from '@/services/password.utility'; import { EmailAuthHandler } from '../email.auth-handler'; diff --git a/packages/cli/src/auth/handlers/email.auth-handler.ts b/packages/cli/src/auth/handlers/email.auth-handler.ts index bd597d44848..3839684b429 100644 --- a/packages/cli/src/auth/handlers/email.auth-handler.ts +++ b/packages/cli/src/auth/handlers/email.auth-handler.ts @@ -1,11 +1,11 @@ +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import { User, UserRepository } from '@n8n/db'; import type { IPasswordAuthHandler } from '@n8n/decorators'; import { AuthHandler } from '@n8n/decorators'; import { Constructable } from '@n8n/di'; -import { AuthError } from '@/errors/response-errors/auth.error'; -import { EventService } from '@/events/event.service'; +import { AuthError } from '@n8n/errors'; import { PasswordUtility } from '@/services/password.utility'; @AuthHandler() diff --git a/packages/cli/src/chat/chat-execution-manager.ts b/packages/cli/src/chat/chat-execution-manager.ts index 7bbf1ffd256..d62551c5338 100644 --- a/packages/cli/src/chat/chat-execution-manager.ts +++ b/packages/cli/src/chat/chat-execution-manager.ts @@ -17,7 +17,7 @@ import { isHitlToolType, } from 'n8n-workflow'; -import { NotFoundError } from '../errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { ExecutionPersistence } from '../executions/execution-persistence'; import * as WorkflowExecuteAdditionalData from '../workflow-execute-additional-data'; import { preserveInputOverride } from '../workflow-helpers'; diff --git a/packages/cli/src/collaboration/__tests__/collaboration.state.test.ts b/packages/cli/src/collaboration/__tests__/collaboration.state.test.ts index 58f3a53caa6..815adc21fcf 100644 --- a/packages/cli/src/collaboration/__tests__/collaboration.state.test.ts +++ b/packages/cli/src/collaboration/__tests__/collaboration.state.test.ts @@ -1,7 +1,7 @@ import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { CollaborationState } from '../collaboration.state'; diff --git a/packages/cli/src/collaboration/collaboration.service.ts b/packages/cli/src/collaboration/collaboration.service.ts index c906d0e5d0e..13781dedf46 100644 --- a/packages/cli/src/collaboration/collaboration.service.ts +++ b/packages/cli/src/collaboration/collaboration.service.ts @@ -22,9 +22,7 @@ import type { } from './collaboration.message'; import { CollaborationState, type WriteLock } from '@/collaboration/collaboration.state'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { LockedError } from '@/errors/response-errors/locked.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ConflictError, LockedError, NotFoundError } from '@n8n/errors'; import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import { Push } from '@/push'; import type { OnPushMessage } from '@/push/types'; diff --git a/packages/cli/src/collaboration/collaboration.state.ts b/packages/cli/src/collaboration/collaboration.state.ts index ebb800e0009..ab210963304 100644 --- a/packages/cli/src/collaboration/collaboration.state.ts +++ b/packages/cli/src/collaboration/collaboration.state.ts @@ -5,7 +5,7 @@ import { Service } from '@n8n/di'; import type { Workflow } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; // clientId -> "userId|lastSeen" type WorkflowCacheHash = Record; diff --git a/packages/cli/src/commands/__tests__/engine.test.ts b/packages/cli/src/commands/__tests__/engine.test.ts index 2d55a9c382f..c462a06ca0e 100644 --- a/packages/cli/src/commands/__tests__/engine.test.ts +++ b/packages/cli/src/commands/__tests__/engine.test.ts @@ -17,7 +17,7 @@ import type { RedisResponsePublisher } from '@/modules/engine-v2/response-channe import { NodeTypes } from '@/node-types'; import { OtelService } from '@/modules/otel/otel.service'; import { PostHogClient } from '@/posthog'; -import { RedisClientService } from '@/services/redis-client.service'; +import { RedisClientService } from '@n8n/backend-services'; import { ShutdownService } from '@/shutdown/shutdown.service'; import { TaskRunnerModule } from '@/task-runners/task-runner-module'; diff --git a/packages/cli/src/commands/__tests__/execute-batch.test.ts b/packages/cli/src/commands/__tests__/execute-batch.test.ts index 4575f490dc8..22e11bfe4c5 100644 --- a/packages/cli/src/commands/__tests__/execute-batch.test.ts +++ b/packages/cli/src/commands/__tests__/execute-batch.test.ts @@ -1,4 +1,4 @@ -import { LicenseState } from '@n8n/backend-common'; +import { LicenseState, ModuleRegistry } from '@n8n/backend-common'; import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import type { User, WorkflowEntity } from '@n8n/db'; @@ -62,7 +62,7 @@ const deploymentKeyRepository = mockInstance(DeploymentKeyRepository); deploymentKeyRepository.findActiveIdentifier.mockResolvedValue(null); deploymentKeyRepository.seedActiveIdentifier.mockResolvedValue(undefined); -test('should start a task runner', async () => { +test('should start a task runner and the policy modules', async () => { // arrange const workflow = mock({ @@ -97,6 +97,8 @@ test('should start a task runner', async () => { }), ); + const initModules = vi.spyOn(Container.get(ModuleRegistry), 'initModules'); + const cmd = new ExecuteBatch(); // @ts-expect-error Protected property cmd.flags = {}; @@ -111,6 +113,10 @@ test('should start a task runner', async () => { // assert expect(taskRunnerModule.start).toHaveBeenCalledTimes(1); + expect(initModules).toHaveBeenCalledWith(expect.anything(), [ + 'policy-infrastructure', + 'type-availability-policies', + ]); }); test('execute:batch needs the expression engine', () => { diff --git a/packages/cli/src/commands/__tests__/execute.test.ts b/packages/cli/src/commands/__tests__/execute.test.ts index 9118c5bc639..43c723717c0 100644 --- a/packages/cli/src/commands/__tests__/execute.test.ts +++ b/packages/cli/src/commands/__tests__/execute.test.ts @@ -1,4 +1,4 @@ -import { LicenseState, Logger } from '@n8n/backend-common'; +import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common'; import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import type { User, WorkflowEntity, Project } from '@n8n/db'; @@ -91,7 +91,7 @@ beforeEach(() => { ); }); -test('should start a task runner', async () => { +test('should start a task runner and the policy modules', async () => { // arrange const workflow = mock({ @@ -116,6 +116,8 @@ test('should start a task runner', async () => { workflowRunner.run.mockResolvedValue('123'); activeExecutions.getPostExecutePromise.mockResolvedValue(run); + const initModules = vi.spyOn(Container.get(ModuleRegistry), 'initModules'); + const cmd = new Execute(); // @ts-expect-error Protected property cmd.flags = { id: '123' }; @@ -128,6 +130,10 @@ test('should start a task runner', async () => { // assert expect(taskRunnerModule.start).toHaveBeenCalledTimes(1); + expect(initModules).toHaveBeenCalledWith(expect.anything(), [ + 'policy-infrastructure', + 'type-availability-policies', + ]); }); test('should not seed the instance identity and should tolerate deployment key read errors', async () => { diff --git a/packages/cli/src/commands/__tests__/start-system-task-metrics.test.ts b/packages/cli/src/commands/__tests__/start-system-task-metrics.test.ts index 93084a1c9c6..554814985c8 100644 --- a/packages/cli/src/commands/__tests__/start-system-task-metrics.test.ts +++ b/packages/cli/src/commands/__tests__/start-system-task-metrics.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import '@/zod-alias-support'; import { Logger } from '@n8n/backend-common'; @@ -14,7 +15,6 @@ import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { AuthService } from '@/auth/auth.service'; import { ControllerRegistry } from '@/controller.registry'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; -import { EventService } from '@/events/event.service'; import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay'; import { EnqueuedExecutionRecoveryService } from '@/executions/enqueued-execution-recovery.service'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; diff --git a/packages/cli/src/commands/__tests__/webhook.test.ts b/packages/cli/src/commands/__tests__/webhook.test.ts index 2e192eca669..f3b2d60455e 100644 --- a/packages/cli/src/commands/__tests__/webhook.test.ts +++ b/packages/cli/src/commands/__tests__/webhook.test.ts @@ -12,7 +12,7 @@ import { PubSubRegistry } from '@/scaling/pubsub/pubsub.registry'; import { Subscriber } from '@/scaling/pubsub/subscriber.service'; import { SystemTaskRunner } from '@/scheduling/system-tasks/system-task-runner'; import { JwtService } from '@/services/jwt.service'; -import { RedisClientService } from '@/services/redis-client.service'; +import { RedisClientService } from '@n8n/backend-services'; import { WebhookServer } from '@/webhooks/webhook-server'; import { BaseCommand } from '../base-command'; diff --git a/packages/cli/src/commands/__tests__/worker.test.ts b/packages/cli/src/commands/__tests__/worker.test.ts index 851aa6bbe87..f96216d2fb8 100644 --- a/packages/cli/src/commands/__tests__/worker.test.ts +++ b/packages/cli/src/commands/__tests__/worker.test.ts @@ -1,5 +1,6 @@ import type { Logger } from '@n8n/backend-common'; import { uninstallGlobalProxyAgent } from '@n8n/backend-network/testing'; +import type { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import type { ExecutionsConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config'; @@ -17,7 +18,6 @@ import type { ConcurrencyControlService } from '@/concurrency/concurrency-contro import { CredentialsOverwrites } from '@/credentials-overwrites'; import { DeprecationService } from '@/deprecation/deprecation.service'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; -import type { EventService } from '@/events/event.service'; import { ActivityEventRelay } from '@/events/relays/activity.event-relay'; import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay'; import { WorkflowFailureNotificationEventRelay } from '@/events/relays/workflow-failure-notification.event-relay'; @@ -33,7 +33,7 @@ import { WorkerServer } from '@/scaling/worker-server'; import { WorkerStatusService } from '@/scaling/worker-status.service.ee'; import { SystemTaskRunner } from '@/scheduling/system-tasks/system-task-runner'; import { JwtService } from '@/services/jwt.service'; -import { RedisClientService } from '@/services/redis-client.service'; +import { RedisClientService } from '@n8n/backend-services'; import { ShutdownService } from '@/shutdown/shutdown.service'; import { TaskRunnerModule } from '@/task-runners/task-runner-module'; diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index a3bd0a908eb..34a6db84861 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -47,6 +47,7 @@ import { License } from '@/license'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config'; import { NodeTypes } from '@/node-types'; +import { POLICY_MODULES } from '@/policy/policy-modules'; import { PostHogClient } from '@/posthog'; import { instanceSystemTasks } from '@/scheduling/system-tasks/instance-system-tasks'; import { ShutdownService } from '@/shutdown/shutdown.service'; @@ -552,6 +553,14 @@ export abstract class BaseCommand { } } + /** + * Registers the policy checks, so a one-off command that writes or runs content is refused + * what the server refuses. Call after `initLicense()`: the feature module is license-gated. + */ + async initPolicyEnforcement() { + await this.moduleRegistry.initModules(this.instanceSettings.instanceType, POLICY_MODULES); + } + initWorkflowHistory() { Container.get(WorkflowHistoryManager).init(); } diff --git a/packages/cli/src/commands/execute-batch.ts b/packages/cli/src/commands/execute-batch.ts index 7678d91d18d..ad195a7d8d6 100644 --- a/packages/cli/src/commands/execute-batch.ts +++ b/packages/cli/src/commands/execute-batch.ts @@ -191,6 +191,7 @@ export class ExecuteBatch extends BaseCommand> { async init() { await super.init(); await this.initLicense(); + await this.initPolicyEnforcement(); await this.initCommunityPackages(); await this.initBinaryDataService(); await this.initDataDeduplicationService(); diff --git a/packages/cli/src/commands/execute.ts b/packages/cli/src/commands/execute.ts index 51b0992fbbf..dd52e1e51f1 100644 --- a/packages/cli/src/commands/execute.ts +++ b/packages/cli/src/commands/execute.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { WorkflowRepository } from '@n8n/db'; import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; @@ -6,7 +7,6 @@ import { ExecutionBaseError, UnexpectedError, UserError } from 'n8n-workflow'; import { z } from 'zod'; import { ActiveExecutions } from '@/active-executions'; -import { EventService } from '@/events/event.service'; import { OwnershipService } from '@/services/ownership.service'; import { findCliWorkflowStart, isWorkflowIdValid } from '@/utils'; import { WorkflowRunner } from '@/workflow-runner'; @@ -37,6 +37,7 @@ export class Execute extends BaseCommand> { async init() { await super.init(); await this.initLicense(); + await this.initPolicyEnforcement(); await this.initCommunityPackages(); await this.initBinaryDataService(); await this.initDataDeduplicationService(); diff --git a/packages/cli/src/commands/export/workflow.ts b/packages/cli/src/commands/export/workflow.ts index 516a788993d..cf6b9b2992c 100644 --- a/packages/cli/src/commands/export/workflow.ts +++ b/packages/cli/src/commands/export/workflow.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import type { WorkflowHistory } from '@n8n/db'; import { WorkflowRepository, WorkflowHistoryRepository } from '@n8n/db'; import { Command } from '@n8n/decorators'; @@ -12,7 +13,6 @@ import type { IWorkflowWithVersionMetadata } from '@/interfaces'; import { BaseCommand } from '../base-command'; import '../../zod-alias-support'; -import { EventService } from '@/events/event.service'; const flagsSchema = z.object({ all: z.boolean().describe('Export all workflows').optional(), diff --git a/packages/cli/src/commands/import/credentials.ts b/packages/cli/src/commands/import/credentials.ts index 14dee4b31d1..3b3706addbf 100644 --- a/packages/cli/src/commands/import/credentials.ts +++ b/packages/cli/src/commands/import/credentials.ts @@ -102,6 +102,12 @@ type SkippedCredential = { id?: string; name?: string; violations: string[] }; flagsSchema, }) export class ImportCredentialsCommand extends BaseCommand> { + async init() { + await super.init(); + await this.initLicense(); + await this.initPolicyEnforcement(); + } + async run(): Promise { const { flags } = this; @@ -158,7 +164,7 @@ export class ImportCredentialsCommand extends BaseCommand { const result = await this.checkRelations( transactionManager, - credentials, + admitted.map(({ credential }) => credential), project.id, flags, ); @@ -267,11 +273,14 @@ export class ImportCredentialsCommand extends BaseCommand; try { - cleared = await Container.get(PolicyEnforcementService).enforceContentImport({ - credential: { id: credential.id ?? null, type }, - projectId: credential.usageScope === 'instance' ? null : landingProjectId, - transport: 'cli', - }); + cleared = await Container.get(PolicyEnforcementService).enforceContentImport( + { + credential: { id: credential.id ?? null, type }, + projectId: credential.usageScope === 'instance' ? null : landingProjectId, + transport: 'cli', + }, + { kind: 'system', reason: 'cli-import' }, + ); } catch (error) { if (!(error instanceof PolicyViolationError)) throw error; diff --git a/packages/cli/src/commands/import/workflow.ts b/packages/cli/src/commands/import/workflow.ts index 5df85a4170b..e3664fc1092 100644 --- a/packages/cli/src/commands/import/workflow.ts +++ b/packages/cli/src/commands/import/workflow.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { generateNanoId, ProjectRepository, @@ -16,7 +17,6 @@ import { jsonParse, UserError } from 'n8n-workflow'; import { z } from 'zod'; import { UM_FIX_INSTRUCTION } from '@/constants'; -import { EventService } from '@/events/event.service'; import type { IWorkflowToImport, IWorkflowWithVersionMetadata } from '@/interfaces'; import { ImportService, type WorkflowImportViolations } from '@/services/import.service'; @@ -104,6 +104,12 @@ export class ImportWorkflowsCommand extends BaseCommand { const { flags } = this; diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index f329905433e..3ef81108531 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { EventService, UrlService } from '@n8n/backend-services'; import { HTML_NONCE_PLACEHOLDER, LICENSE_FEATURES } from '@n8n/constants'; import { AuthRolesService, @@ -29,7 +30,6 @@ import { CredentialsOverwrites } from '@/credentials-overwrites'; import { DeprecationService } from '@/deprecation/deprecation.service'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; -import { EventService } from '@/events/event.service'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { MultiMainSetup } from '@/scaling/multi-main-setup.ee'; import { Publisher } from '@/scaling/pubsub/publisher.service'; @@ -43,7 +43,6 @@ import { JwtService } from '@/services/jwt.service'; import { ExecutionsPruningService } from '@/services/pruning/executions-pruning.service'; import { WorkflowHistoryCompactionService } from '@/services/pruning/workflow-history-compaction.service'; import { RoleCacheService } from '@/services/role-cache.service'; -import { UrlService } from '@n8n/backend-services'; import { WorkflowStatisticsRollupService } from '@/services/workflow-statistics-rollup.service'; import { WaitTracker } from '@/wait-tracker'; diff --git a/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts b/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts index b188d58746b..69fcd22aac8 100644 --- a/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts +++ b/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts @@ -1,3 +1,4 @@ +import type { EventService } from '@n8n/backend-services'; import { mockInstance, mockLogger } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import type { ExecutionRepository } from '@n8n/db'; @@ -11,7 +12,6 @@ import { ConcurrencyControlService, } from '@/concurrency/concurrency-control.service'; import { InvalidConcurrencyLimitError } from '@/errors/invalid-concurrency-limit.error'; -import type { EventService } from '@/events/event.service'; import type { License } from '@/license'; import type { Telemetry } from '@/telemetry'; diff --git a/packages/cli/src/concurrency/concurrency-control.service.ts b/packages/cli/src/concurrency/concurrency-control.service.ts index 7becb09b686..3c3068e6efe 100644 --- a/packages/cli/src/concurrency/concurrency-control.service.ts +++ b/packages/cli/src/concurrency/concurrency-control.service.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import { ExecutionRepository } from '@n8n/db'; import { Service } from '@n8n/di'; @@ -7,7 +8,6 @@ import type { WorkflowExecuteMode } from 'n8n-workflow'; import { InvalidConcurrencyLimitError } from '@/errors/invalid-concurrency-limit.error'; import { UnknownExecutionModeError } from '@/errors/unknown-execution-mode.error'; import { resolveEvaluationConcurrencyLimit } from '@/evaluation.ee/evaluation-concurrency.helper'; -import { EventService } from '@/events/event.service'; import { License } from '@/license'; import { Telemetry } from '@/telemetry'; diff --git a/packages/cli/src/controller.registry.ts b/packages/cli/src/controller.registry.ts index 1b60b7a96cd..bb332e0f462 100644 --- a/packages/cli/src/controller.registry.ts +++ b/packages/cli/src/controller.registry.ts @@ -12,6 +12,7 @@ import type { KeyedRateLimiterConfig, } from '@n8n/decorators'; import { Container, Service } from '@n8n/di'; +import { NotFoundError, UnauthenticatedError } from '@n8n/errors'; import { ensureError } from '@n8n/utils/errors/ensure-error'; import { Router } from 'express'; import type { Application, Request, Response, RequestHandler } from 'express'; @@ -20,13 +21,11 @@ import assert from 'node:assert'; import { AuthService } from '@/auth/auth.service'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; -import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; import { License } from '@/license'; import { userHasScopes } from '@/permissions.ee/check-access'; import { reportError, send, sendErrorResponse } from '@/response-helper'; import { AbstractServer } from './abstract-server'; -import { NotFoundError } from './errors/response-errors/not-found.error'; import { CorsService } from './services/cors-service'; import { LastActiveAtService } from './services/last-active-at.service'; import { RateLimitService } from './services/rate-limit.service'; diff --git a/packages/cli/src/controllers/__tests__/ai.controller.test.ts b/packages/cli/src/controllers/__tests__/ai.controller.test.ts index f43762a5a3a..bf4ef325b6c 100644 --- a/packages/cli/src/controllers/__tests__/ai.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/ai.controller.test.ts @@ -9,10 +9,12 @@ import type { AuthenticatedRequest } from '@n8n/db'; import { APIResponseError, NetworkError, type AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; +import { + BadRequestError, + InternalServerError, + NotFoundError, + ServiceUnavailableError, +} from '@n8n/errors'; import type { AiGatewayService } from '@/services/ai-gateway.service'; import type { AiUsageService } from '@/services/ai-usage.service'; import type { WorkflowBuilderService } from '@/services/ai-workflow-builder.service'; diff --git a/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts b/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts index 79682042af3..cf36109a42e 100644 --- a/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts @@ -1,9 +1,9 @@ +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import type { AuthenticatedRequest, User, ApiKey } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'vitest-mock-extended'; -import { EventService } from '@/events/event.service'; import { PublicApiKeyService } from '@/services/public-api-key.service'; import { ApiKeysController } from '../api-keys.controller'; diff --git a/packages/cli/src/controllers/__tests__/auth.controller.test.ts b/packages/cli/src/controllers/__tests__/auth.controller.test.ts index 9548d0ba967..810de8b607b 100644 --- a/packages/cli/src/controllers/__tests__/auth.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/auth.controller.test.ts @@ -1,6 +1,7 @@ import type { LoginRequestDto } from '@n8n/api-types'; import { ResolveSignupTokenQueryDto, SSO_LOGIN_REQUIRED_ERROR_CODE } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import type { AuthenticatedRequest, User } from '@n8n/db'; import { UserRepository } from '@n8n/db'; @@ -14,10 +15,7 @@ import { AuthService } from '@/auth/auth.service'; import type { EmailAuthHandler } from '@/auth/handlers/email.auth-handler'; import config from '@/config'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; -import { AuthError } from '@/errors/response-errors/auth.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { AuthError, BadRequestError, ForbiddenError } from '@n8n/errors'; import { License } from '@/license'; import { MfaService } from '@/mfa/mfa.service'; import { LdapService } from '@/modules/ldap.ee/ldap.service.ee'; diff --git a/packages/cli/src/controllers/__tests__/change-email.controller.test.ts b/packages/cli/src/controllers/__tests__/change-email.controller.test.ts index 069068cae3c..618d3afe5f7 100644 --- a/packages/cli/src/controllers/__tests__/change-email.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/change-email.controller.test.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; import { UserRepository } from '@n8n/db'; @@ -8,9 +9,7 @@ import { mock } from 'vitest-mock-extended'; import { AuthService } from '@/auth/auth.service'; import { ChangeEmailController } from '@/controllers/change-email.controller'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { ExternalHooks } from '@/external-hooks'; import type { AuthlessRequest } from '@/requests'; import { EmailChangeService } from '@/services/email-change.service'; diff --git a/packages/cli/src/controllers/__tests__/invitation.controller.test.ts b/packages/cli/src/controllers/__tests__/invitation.controller.test.ts index 9b6aeb7847a..c2493b71467 100644 --- a/packages/cli/src/controllers/__tests__/invitation.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/invitation.controller.test.ts @@ -1,6 +1,7 @@ import type { AcceptInvitationRequestDto } from '@n8n/api-types'; import { InviteUsersRequestDto } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { UserRepository, GLOBAL_OWNER_ROLE, GLOBAL_MEMBER_ROLE } from '@n8n/db'; import type { User, PublicUser, AuthenticatedRequest } from '@n8n/db'; @@ -9,8 +10,7 @@ import { v4 as uuidv4 } from 'uuid'; import { mock } from 'vitest-mock-extended'; import { AuthService } from '@/auth/auth.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError } from '@n8n/errors'; import { ExternalHooks } from '@/external-hooks'; import { PostHogClient } from '@/posthog'; import type { AuthlessRequest } from '@/requests'; diff --git a/packages/cli/src/controllers/__tests__/me.controller.test.ts b/packages/cli/src/controllers/__tests__/me.controller.test.ts index fb4e7f38a32..d0ee52cf4bc 100644 --- a/packages/cli/src/controllers/__tests__/me.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/me.controller.test.ts @@ -1,4 +1,5 @@ import { UserUpdateRequestDto } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import type { AuthenticatedRequest, User, PublicUser, AuthIdentity } from '@n8n/db'; @@ -11,10 +12,8 @@ import { mock, anyObject } from 'vitest-mock-extended'; import { AUTH_COOKIE_NAME } from '@/constants'; import { MeController } from '@/controllers/me.controller'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error'; -import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { License } from '@/license'; import { MfaService } from '@/mfa/mfa.service'; diff --git a/packages/cli/src/controllers/__tests__/owner.controller.test.ts b/packages/cli/src/controllers/__tests__/owner.controller.test.ts index e1f3a6f49ea..309499b4e60 100644 --- a/packages/cli/src/controllers/__tests__/owner.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/owner.controller.test.ts @@ -3,7 +3,7 @@ import { mock } from 'vitest-mock-extended'; import type { AuthService } from '@/auth/auth.service'; import { OwnerController } from '@/controllers/owner.controller'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { PostHogClient } from '@/posthog'; import type { BannerService } from '@/services/banner.service'; import type { OwnershipService } from '@/services/ownership.service'; diff --git a/packages/cli/src/controllers/__tests__/role.controller.test.ts b/packages/cli/src/controllers/__tests__/role.controller.test.ts index 86276712a01..9850397d231 100644 --- a/packages/cli/src/controllers/__tests__/role.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/role.controller.test.ts @@ -1,9 +1,9 @@ +import type { EventService } from '@n8n/backend-services'; import type { AuthenticatedRequest, Project } from '@n8n/db'; import type { Role } from '@n8n/permissions'; import { mock } from 'vitest-mock-extended'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { EventService } from '@/events/event.service'; +import { NotFoundError } from '@n8n/errors'; import type { ProjectService } from '@/services/project.service.ee'; import type { RoleService } from '@/services/role.service'; diff --git a/packages/cli/src/controllers/__tests__/security-settings.controller.test.ts b/packages/cli/src/controllers/__tests__/security-settings.controller.test.ts index fdafa150bb3..e63f30d8fb1 100644 --- a/packages/cli/src/controllers/__tests__/security-settings.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/security-settings.controller.test.ts @@ -4,7 +4,7 @@ import type { InstanceSettingsLoaderConfig } from '@n8n/config'; import type { AuthenticatedRequest } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import type { SecuritySettingsService } from '@/services/security-settings.service'; import type { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service'; diff --git a/packages/cli/src/controllers/__tests__/translation.controller.test.ts b/packages/cli/src/controllers/__tests__/translation.controller.test.ts index a5ba1b2165b..2c1725b040b 100644 --- a/packages/cli/src/controllers/__tests__/translation.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/translation.controller.test.ts @@ -4,7 +4,7 @@ import { mock } from 'vitest-mock-extended'; import type { TranslationRequest } from '@/controllers/translation.controller'; import { TranslationController } from '@/controllers/translation.controller'; import type { CredentialTypes } from '@/credential-types'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; // The controller loads the translation file via `require()`. // Vitest cannot mock a path that doesn't resolve to a real module, so write a diff --git a/packages/cli/src/controllers/__tests__/users.controller.test.ts b/packages/cli/src/controllers/__tests__/users.controller.test.ts index e772f32bac0..4c53da2a431 100644 --- a/packages/cli/src/controllers/__tests__/users.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/users.controller.test.ts @@ -2,7 +2,7 @@ import type { AuthenticatedRequest, User, UserRepository } from '@n8n/db'; import type { Response } from 'express'; import { mock } from 'vitest-mock-extended'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { UserRequest } from '@/requests'; import type { JwtService } from '@/services/jwt.service'; import type { UrlService } from '@n8n/backend-services'; diff --git a/packages/cli/src/controllers/ai.controller.ts b/packages/cli/src/controllers/ai.controller.ts index 5d7158a83ab..16e48b26008 100644 --- a/packages/cli/src/controllers/ai.controller.ts +++ b/packages/cli/src/controllers/ai.controller.ts @@ -23,12 +23,14 @@ import { strict as assert } from 'node:assert'; import { WritableStream } from 'node:stream/web'; import { STREAM_SEPARATOR } from '@/constants'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ContentTooLargeError } from '@/errors/response-errors/content-too-large.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; -import { TooManyRequestsError } from '@/errors/response-errors/too-many-requests.error'; +import { + BadRequestError, + ContentTooLargeError, + InternalServerError, + NotFoundError, + ServiceUnavailableError, + TooManyRequestsError, +} from '@n8n/errors'; import { AiGatewayService } from '@/services/ai-gateway.service'; import { AiUsageService } from '@/services/ai-usage.service'; import { WorkflowBuilderService } from '@/services/ai-workflow-builder.service'; diff --git a/packages/cli/src/controllers/api-keys.controller.ts b/packages/cli/src/controllers/api-keys.controller.ts index f788cf96fd2..8defd4c916d 100644 --- a/packages/cli/src/controllers/api-keys.controller.ts +++ b/packages/cli/src/controllers/api-keys.controller.ts @@ -3,6 +3,7 @@ import { ListApiKeysQueryDto, UpdateApiKeyRequestDto, } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import { AuthenticatedRequest } from '@n8n/db'; import { Body, @@ -18,8 +19,7 @@ import { import { getApiKeyScopesForRole } from '@n8n/permissions'; import type { RequestHandler } from 'express'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError } from '@n8n/errors'; import { isApiKeyAuthEnabled } from '@/public-api'; import { PublicApiKeyService } from '@/services/public-api-key.service'; diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 0d7208b0372..4854158f71e 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -4,6 +4,7 @@ import { SSO_LOGIN_REQUIRED_ERROR_CODE, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { Time } from '@n8n/constants'; import type { User, PublicUser, AuthProviderType } from '@n8n/db'; import { UserRepository, AuthenticatedRequest, GLOBAL_OWNER_ROLE } from '@n8n/db'; @@ -21,11 +22,7 @@ import { Response } from 'express'; import { AuthHandlerRegistry } from '@/auth/auth-handler.registry'; import { AuthService } from '@/auth/auth.service'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; -import { AuthError } from '@/errors/response-errors/auth.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { EventService } from '@/events/event.service'; +import { AuthError, BadRequestError, ForbiddenError, InternalServerError } from '@n8n/errors'; import { License } from '@/license'; import { MfaService } from '@/mfa/mfa.service'; import { PostHogClient } from '@/posthog'; diff --git a/packages/cli/src/controllers/binary-data.controller.ts b/packages/cli/src/controllers/binary-data.controller.ts index d21f3b96277..e4390d9f648 100644 --- a/packages/cli/src/controllers/binary-data.controller.ts +++ b/packages/cli/src/controllers/binary-data.controller.ts @@ -11,7 +11,7 @@ import { } from 'n8n-core'; import { BinaryDataAccessService } from '@/binary-data/binary-data-access.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; @RestController('/binary-data') export class BinaryDataController { diff --git a/packages/cli/src/controllers/change-email.controller.ts b/packages/cli/src/controllers/change-email.controller.ts index 277ffe0e0d2..3e235f32ab0 100644 --- a/packages/cli/src/controllers/change-email.controller.ts +++ b/packages/cli/src/controllers/change-email.controller.ts @@ -5,6 +5,7 @@ import { type ChangeEmailResponse, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { AuthenticatedRequest, UserRepository } from '@n8n/db'; import { Body, @@ -17,10 +18,7 @@ import { import { Response } from 'express'; import { AuthService } from '@/auth/auth.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, InternalServerError, NotFoundError } from '@n8n/errors'; import { ExternalHooks } from '@/external-hooks'; import { AuthlessRequest } from '@/requests'; import { EmailChangeService } from '@/services/email-change.service'; diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index cc448cc1bd2..faa3c40c31b 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -32,7 +32,7 @@ import { MfaService } from '@/mfa/mfa.service'; import { LogStreamingDestinationService } from '@/modules/log-streaming.ee/log-streaming-destination.service'; import { Push } from '@/push'; import { WorkflowScheduledJobOwner } from '@/scheduling/workflow-scheduled-job-owner'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { FrontendService } from '@/services/frontend.service'; import { PasswordUtility } from '@/services/password.utility'; import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; diff --git a/packages/cli/src/controllers/folder.controller.ts b/packages/cli/src/controllers/folder.controller.ts index 18ae5e75365..86b87e3e6a0 100644 --- a/packages/cli/src/controllers/folder.controller.ts +++ b/packages/cli/src/controllers/folder.controller.ts @@ -24,9 +24,7 @@ import { NextFunction, Response } from 'express'; import { UserError } from 'n8n-workflow'; import { FolderNotFoundError } from '@/errors/folder-not-found.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, InternalServerError, NotFoundError } from '@n8n/errors'; import { FolderService } from '@/services/folder.service'; import { ProjectService } from '@/services/project.service.ee'; import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts index 88c27fa269a..33ee6ab4bfe 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -1,5 +1,6 @@ import { AcceptInvitationRequestDto, InviteUsersRequestDto } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { Time } from '@n8n/constants'; import type { User } from '@n8n/db'; import { UserRepository, AuthenticatedRequest } from '@n8n/db'; @@ -7,8 +8,7 @@ import { Post, GlobalScope, RestController, Body } from '@n8n/decorators'; import { Response } from 'express'; import { AuthService } from '@/auth/auth.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError } from '@n8n/errors'; import { ExternalHooks } from '@/external-hooks'; import { PostHogClient } from '@/posthog'; import { AuthlessRequest } from '@/requests'; diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index 46cd1bc5c83..6ddeccdfadb 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -5,6 +5,7 @@ import { UserUpdateRequestDto, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import type { User, PublicUser, AuthIdentity } from '@n8n/db'; import { UserRepository, AuthenticatedRequest } from '@n8n/db'; @@ -13,10 +14,8 @@ import { plainToInstance } from 'class-transformer'; import { Response } from 'express'; import { AuthService } from '@/auth/auth.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error'; -import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { validateEntity } from '@/generic-helpers'; import { MfaService } from '@/mfa/mfa.service'; diff --git a/packages/cli/src/controllers/mfa.controller.ts b/packages/cli/src/controllers/mfa.controller.ts index b58e57d7fe3..86ba39ccc4c 100644 --- a/packages/cli/src/controllers/mfa.controller.ts +++ b/packages/cli/src/controllers/mfa.controller.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { InstanceSettingsLoaderConfig } from '@n8n/config'; import { AuthenticatedRequest, UserRepository } from '@n8n/db'; import { @@ -10,9 +11,7 @@ import { import { Response } from 'express'; import { AuthService } from '@/auth/auth.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { ExternalHooks } from '@/external-hooks'; import { MfaService } from '@/mfa/mfa.service'; import { MFA } from '@/requests'; diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts index af9533e1ae5..8c21ad15f18 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { type CredentialsEntity, type User } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -7,7 +8,6 @@ import { mock } from 'vitest-mock-extended'; import { OAuth1CredentialController } from '@/controllers/oauth/oauth1-credential.controller'; import { CredentialsOverwrites } from '@/credentials-overwrites'; -import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { OauthService } from '@/oauth/oauth.service'; import type { OAuthRequest } from '@/requests'; diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts index 67b781372ab..24a6ea872e0 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts @@ -1,5 +1,6 @@ import { Logger } from '@n8n/backend-common'; import type { SsrfBridge } from '@n8n/backend-network'; +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import type { ClientOAuth2 } from '@n8n/client-oauth2'; import { type CredentialsEntity, type User } from '@n8n/db'; @@ -10,7 +11,6 @@ import { mock } from 'vitest-mock-extended'; import { OAuth2CredentialController } from '@/controllers/oauth/oauth2-credential.controller'; import { CredentialsOverwrites } from '@/credentials-overwrites'; -import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { OAuthJweServiceProxy } from '@/oauth/oauth-jwe-service.proxy'; import { OauthService } from '@/oauth/oauth.service'; diff --git a/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts b/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts index ce837effe7d..e7aeec57e26 100644 --- a/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts @@ -1,10 +1,10 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { Get, RestController } from '@n8n/decorators'; import { Response } from 'express'; import { ensureError } from '@n8n/utils/errors/ensure-error'; import { CredentialsOverwrites } from '@/credentials-overwrites'; -import { EventService } from '@/events/event.service'; import { OauthService, type OAuth1CredentialData } from '@/oauth/oauth.service'; import { OAuthRequest } from '@/requests'; diff --git a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts index c51eb166905..a623b759f3b 100644 --- a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import type { ClientOAuth2Options, OAuth2CredentialData } from '@n8n/client-oauth2'; import { ClientOAuth2, resolveClientAuthOptions } from '@n8n/client-oauth2'; import { Get, RestController } from '@n8n/decorators'; @@ -11,7 +12,6 @@ import { ensureError } from '@n8n/utils/errors/ensure-error'; import { jsonParse } from 'n8n-workflow'; import { CredentialsOverwrites } from '@/credentials-overwrites'; -import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { OAuthJweServiceProxy } from '@/oauth/oauth-jwe-service.proxy'; import { OauthService, OauthVersion } from '@/oauth/oauth.service'; diff --git a/packages/cli/src/controllers/password-reset.controller.ts b/packages/cli/src/controllers/password-reset.controller.ts index 67cf5d3576e..7abae5dbe06 100644 --- a/packages/cli/src/controllers/password-reset.controller.ts +++ b/packages/cli/src/controllers/password-reset.controller.ts @@ -4,6 +4,7 @@ import { ResolvePasswordTokenQueryDto, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { Time } from '@n8n/constants'; import { GLOBAL_OWNER_ROLE, UserRepository } from '@n8n/db'; import { @@ -20,12 +21,13 @@ import { ErrorReporter } from 'n8n-core'; import { AuthService } from '@/auth/auth.service'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; -import { EventService } from '@/events/event.service'; +import { + BadRequestError, + ForbiddenError, + InternalServerError, + NotFoundError, + UnprocessableRequestError, +} from '@n8n/errors'; import { ExternalHooks } from '@/external-hooks'; import { License } from '@/license'; import { MfaService } from '@/mfa/mfa.service'; diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index 46ee797a391..6baaf93a3bc 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -23,9 +23,7 @@ import { import { combineScopes, getAuthPrincipalScopes } from '@n8n/permissions'; import { Response } from 'express'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; import type { ProjectRequest } from '@/requests'; import { diff --git a/packages/cli/src/controllers/role.controller.ts b/packages/cli/src/controllers/role.controller.ts index 79b5f373baa..d9a767559c0 100644 --- a/packages/cli/src/controllers/role.controller.ts +++ b/packages/cli/src/controllers/role.controller.ts @@ -13,6 +13,7 @@ import type { RoleMembersResponse, RoleProjectMembersResponse, } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import { LICENSE_FEATURES } from '@n8n/constants'; import { AuthenticatedRequest } from '@n8n/db'; import { @@ -29,8 +30,7 @@ import { } from '@n8n/decorators'; import { Role as RoleDTO } from '@n8n/permissions'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { NotFoundError } from '@n8n/errors'; import { ProjectService } from '@/services/project.service.ee'; import { assertCanManageRoleType, canReassignUsers } from '@/services/role-authorization'; import { RoleService } from '@/services/role.service'; diff --git a/packages/cli/src/controllers/security-settings.controller.ts b/packages/cli/src/controllers/security-settings.controller.ts index ef8ad841d08..2aab7a1089d 100644 --- a/packages/cli/src/controllers/security-settings.controller.ts +++ b/packages/cli/src/controllers/security-settings.controller.ts @@ -5,7 +5,7 @@ import { type AuthenticatedRequest } from '@n8n/db'; import { Body, Get, GlobalScope, Licensed, Post, RestController } from '@n8n/decorators'; import type { Response } from 'express'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { SecuritySettingsService } from '@/services/security-settings.service'; import { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service'; diff --git a/packages/cli/src/controllers/translation.controller.ts b/packages/cli/src/controllers/translation.controller.ts index 38d7d3688f2..614bc78f637 100644 --- a/packages/cli/src/controllers/translation.controller.ts +++ b/packages/cli/src/controllers/translation.controller.ts @@ -6,8 +6,7 @@ import { access } from 'fs/promises'; import { NODES_BASE_DIR } from '@/constants'; import { CredentialTypes } from '@/credential-types'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import { BadRequestError, InternalServerError } from '@n8n/errors'; export const CREDENTIAL_TRANSLATIONS_DIR = 'n8n-nodes-base/dist/credentials/translations'; export const NODE_HEADERS_PATH = safeJoinPath(NODES_BASE_DIR, 'dist/nodes/headers'); diff --git a/packages/cli/src/controllers/user-settings.controller.ts b/packages/cli/src/controllers/user-settings.controller.ts index 7b1505fe0b2..0172da43084 100644 --- a/packages/cli/src/controllers/user-settings.controller.ts +++ b/packages/cli/src/controllers/user-settings.controller.ts @@ -1,7 +1,7 @@ import { Patch, RestController } from '@n8n/decorators'; import type { NpsSurveyState } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { NpsSurveyRequest } from '@/requests'; import { UserService } from '@/services/user.service'; diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 7e458ac4510..017a3737a9e 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -30,8 +30,7 @@ import { hasGlobalScope } from '@n8n/permissions'; import { Response } from 'express'; import { AuthService } from '@/auth/auth.service'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import { UserRequest } from '@/requests'; import { JwtService } from '@/services/jwt.service'; import { UrlService } from '@n8n/backend-services'; diff --git a/packages/cli/src/controllers/workflow-statistics.controller.ts b/packages/cli/src/controllers/workflow-statistics.controller.ts index fb1341de7e1..bc01359c5ef 100644 --- a/packages/cli/src/controllers/workflow-statistics.controller.ts +++ b/packages/cli/src/controllers/workflow-statistics.controller.ts @@ -4,7 +4,7 @@ import { StatisticsNames, WorkflowStatisticsRepository } from '@n8n/db'; import { Get, Middleware, RestController } from '@n8n/decorators'; import { Response, NextFunction } from 'express'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { IWorkflowStatisticsDataLoaded } from '@/interfaces'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; diff --git a/packages/cli/src/credentials-helper.ts b/packages/cli/src/credentials-helper.ts index b673a0753e5..e73a7547a91 100644 --- a/packages/cli/src/credentials-helper.ts +++ b/packages/cli/src/credentials-helper.ts @@ -43,6 +43,7 @@ import { CredentialTypes } from '@/credential-types'; import { CredentialsOverwrites } from '@/credentials-overwrites'; import { DCR_MANAGED_CREDENTIAL_FIELDS, OAUTH_PINNED_FIELDS } from '@/oauth/dcr-managed-fields'; import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config'; +import type { PolicyActor } from '@/policy/policy-enforcement-backend'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { AiGatewayService } from '@/services/ai-gateway.service'; @@ -100,6 +101,13 @@ const { nodeTypes: mockNodeTypes } = createMockNodeTypes(); const INVALID_JSON_VALUE = Symbol('invalidJsonValue'); +/** A run names no user: the starter is not known reliably on every path, so a run never guesses. */ +function decryptActor({ executionId, userId }: IWorkflowExecuteAdditionalData): PolicyActor { + if (executionId) return { kind: 'system', reason: 'execution', executionId }; + if (userId) return { kind: 'user', user: { id: userId } }; + return { kind: 'system', reason: 'execution' }; +} + @Service() export class CredentialsHelper extends ICredentialsHelper { constructor( @@ -556,7 +564,7 @@ export class CredentialsHelper extends ICredentialsHelper { executeData?: IExecuteData, raw?: boolean, expressionResolveValues?: ICredentialsExpressionResolveValues, - options?: IGetDecryptedCredentialsOptions, + options?: IGetDecryptedCredentialsOptions & { actor?: PolicyActor }, ): Promise { // Sub-nodes, such as a chat model connected to a chain or agent, inherit executeData.node // from their parent. Prefer expressionResolveValues.node when present: it is always @@ -578,12 +586,15 @@ export class CredentialsHelper extends ICredentialsHelper { const credentialsEntity = await this.getCredentialsEntity(nodeCredentials, type); // Validate against the executing project's policy before any decryption happens. - await this.policyEnforcementService.enforceCredentialDecrypt({ - credentialType: type, - credentialId: credentialsEntity.id, - consumer: consumerNode ? { nodeType: consumerNode.type } : null, - projectId: additionalData.projectId ?? null, - }); + await this.policyEnforcementService.enforceCredentialDecrypt( + { + credentialType: type, + credentialId: credentialsEntity.id, + consumer: consumerNode ? { nodeType: consumerNode.type } : null, + projectId: additionalData.projectId ?? null, + }, + options?.actor ?? decryptActor(additionalData), + ); const credentials = new Credentials( { id: credentialsEntity.id, name: credentialsEntity.name }, diff --git a/packages/cli/src/credentials/__tests__/credentials.controller.test.ts b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts index ea59699dea3..55df6566cd4 100644 --- a/packages/cli/src/credentials/__tests__/credentials.controller.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts @@ -20,8 +20,7 @@ import { GLOBAL_OWNER_ROLE, GLOBAL_MEMBER_ROLE } from '@n8n/db'; import type { Scope } from '@n8n/permissions'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import * as checkAccess from '@/permissions.ee/check-access'; import type { CredentialRequest } from '@/requests'; @@ -388,6 +387,7 @@ describe('CredentialsController', () => { expect.objectContaining({ isGlobal: true, }), + { kind: 'user', user: ownerReq.user }, expect.any(Object), expect.any(Object), ); @@ -571,6 +571,7 @@ describe('CredentialsController', () => { expect(updateSpy).toHaveBeenCalledWith( credentialId, expect.objectContaining({ description: prepared }), + { kind: 'user', user: req.user }, expect.anything(), expect.any(Object), ); @@ -687,6 +688,7 @@ describe('CredentialsController', () => { expect.objectContaining({ isGlobal: false, }), + { kind: 'user', user: ownerReq.user }, expect.any(Object), expect.any(Object), ); @@ -774,6 +776,7 @@ describe('CredentialsController', () => { expect(updateSpy).toHaveBeenCalledWith( credentialId, expect.any(Object), + { kind: 'user', user: ownerReq.user }, expect.any(Object), expect.any(Object), ); @@ -821,6 +824,7 @@ describe('CredentialsController', () => { expect.objectContaining({ isResolvable: true, }), + { kind: 'user', user: ownerReq.user }, expect.any(Object), expect.any(Object), ); @@ -898,6 +902,7 @@ describe('CredentialsController', () => { expect.objectContaining({ isResolvable: true, // Should keep the existing value }), + { kind: 'user', user: ownerReq.user }, expect.any(Object), expect.any(Object), ); @@ -1041,10 +1046,16 @@ describe('CredentialsController', () => { await credentialsController.updateCredentials(ownerReq); expect(getChangedSharedFieldsSpy).toHaveBeenCalled(); - expect(updateSpy).toHaveBeenCalledWith(credentialId, expect.any(Object), expect.any(Object), { - deleteUserEntries: true, - user: ownerReq.user, - }); + expect(updateSpy).toHaveBeenCalledWith( + credentialId, + expect.any(Object), + { kind: 'user', user: ownerReq.user }, + expect.any(Object), + { + deleteUserEntries: true, + user: ownerReq.user, + }, + ); expect(emitSpy).toHaveBeenCalledWith('private-credential-connections-cleared', { user: ownerReq.user, credentialType: privateCredential.type, @@ -1071,10 +1082,16 @@ describe('CredentialsController', () => { await credentialsController.updateCredentials(ownerReq); - expect(updateSpy).toHaveBeenCalledWith(credentialId, expect.any(Object), expect.any(Object), { - deleteUserEntries: false, - user: ownerReq.user, - }); + expect(updateSpy).toHaveBeenCalledWith( + credentialId, + expect.any(Object), + { kind: 'user', user: ownerReq.user }, + expect.any(Object), + { + deleteUserEntries: false, + user: ownerReq.user, + }, + ); const emittedEventNames = emitSpy.mock.calls.map((call) => call[0]); expect(emittedEventNames).not.toContain('private-credential-connections-cleared'); }); @@ -1099,10 +1116,16 @@ describe('CredentialsController', () => { await credentialsController.updateCredentials(ownerReq); expect(getChangedSharedFieldsSpy).not.toHaveBeenCalled(); - expect(updateSpy).toHaveBeenCalledWith(credentialId, expect.any(Object), expect.any(Object), { - deleteUserEntries: true, - user: ownerReq.user, - }); + expect(updateSpy).toHaveBeenCalledWith( + credentialId, + expect.any(Object), + { kind: 'user', user: ownerReq.user }, + expect.any(Object), + { + deleteUserEntries: true, + user: ownerReq.user, + }, + ); }); it('should not emit toggle events when resolvable state is unchanged', async () => { @@ -1176,7 +1199,7 @@ describe('CredentialsController', () => { req.user, ['credential:update'], ); - expect(clearSpy).toHaveBeenCalledWith(credential); + expect(clearSpy).toHaveBeenCalledWith(credential, { kind: 'user', user: req.user }); expect(result).toEqual({ success: true }); }); diff --git a/packages/cli/src/credentials/__tests__/credentials.service.test.ts b/packages/cli/src/credentials/__tests__/credentials.service.test.ts index 5dac73ba391..dc43d7bc674 100644 --- a/packages/cli/src/credentials/__tests__/credentials.service.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.service.test.ts @@ -2,6 +2,7 @@ import { CredentialDescriptionsService } from '@/credentials/credential-descript import type { PostHogClient } from '@/posthog'; import { CREDENTIAL_DESCRIPTION_MAX_LENGTH } from '@n8n/api-types'; import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { CredentialsRepository, ICredentialsDb, @@ -48,12 +49,11 @@ import { CredentialsService } from '@/credentials/credentials.service'; import type { InstanceCredentialUseRegistry } from '@/credentials/instance-credential-use.registry'; import * as validation from '@/credentials/validation'; import type { CredentialsHelper } from '@/credentials-helper'; +import type { PolicyActor } from '@/policy/policy-enforcement-backend'; import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { PolicyViolationError } from '@/policy/policy-violation.error'; import { CredentialNotFoundError } from '@/errors/credential-not-found.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import type { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import type { ExternalHooks } from '@/external-hooks'; import type { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config'; import type { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee'; @@ -70,6 +70,7 @@ import type { RoleService } from '@/services/role.service'; import { mockExistingCredential } from './credentials.test-data'; const ownerUser = mock({ id: 'owner-id', role: GLOBAL_OWNER_ROLE }); +const ownerActor: PolicyActor = { kind: 'user', user: ownerUser }; const memberUser = mock({ id: 'member-id', role: GLOBAL_MEMBER_ROLE }); /** A custom instance role that can see every credential but not use one. */ const viewOnlyUser = mock({ @@ -240,13 +241,18 @@ describe('CredentialsService', () => { .mockResolvedValue(mock()); const updateSpy = vi.spyOn(service, 'update').mockResolvedValue(mock()); - await service.clearOauthTokenData(credential); + await service.clearOauthTokenData(credential, ownerActor); const passedData = createEncryptedDataSpy.mock.calls[0][0].data; expect(passedData).not.toHaveProperty('oauthTokenData'); expect(passedData).not.toHaveProperty('accountIdentifier'); expect(passedData).toHaveProperty('clientId', 'abc'); - expect(updateSpy).toHaveBeenCalledWith(credential.id, expect.anything(), passedData); + expect(updateSpy).toHaveBeenCalledWith( + credential.id, + expect.anything(), + ownerActor, + passedData, + ); }); it('aborts without persisting when the credential cannot be decrypted', async () => { @@ -257,7 +263,9 @@ describe('CredentialsService', () => { const updateSpy = vi.spyOn(service, 'update'); // Must propagate the failure rather than overwrite the credential with empty data. - await expect(service.clearOauthTokenData(credential)).rejects.toThrow(decryptionError); + await expect(service.clearOauthTokenData(credential, ownerActor)).rejects.toThrow( + decryptionError, + ); expect(updateSpy).not.toHaveBeenCalled(); }); }); @@ -1541,7 +1549,7 @@ describe('CredentialsService', () => { return []; }); - const update = service.update(credential.id, encrypted, undefined, { + const update = service.update(credential.id, encrypted, ownerActor, undefined, { instanceCredential: credential, }); await vi.waitFor(() => expect(dbLockService.withLock).toHaveBeenCalledOnce()); @@ -1570,7 +1578,7 @@ describe('CredentialsService', () => { data: 'encrypted', }); - await expect(service.update(credential.id, encrypted)).resolves.toBe(credential); + await expect(service.update(credential.id, encrypted, ownerActor)).resolves.toBe(credential); expect(repositoryTransaction).toHaveBeenCalledExactlyOnceWith( { policyCleared: cleared }, @@ -1595,20 +1603,23 @@ describe('CredentialsService', () => { setRepositoryTransaction(transactionManager); const encrypted = mock({ id: credential.id, type: 'slackApi' }); - await service.update(credential.id, encrypted); + await service.update(credential.id, encrypted, ownerActor); - expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith({ - credential: { id: credential.id, type: 'slackApi' }, - storedCredential: { id: credential.id, type: 'githubApi' }, - projectId: 'project-1', - }); + expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith( + { + credential: { id: credential.id, type: 'slackApi' }, + storedCredential: { id: credential.id, type: 'githubApi' }, + projectId: 'project-1', + }, + ownerActor, + ); }); it('returns null without checking policy when the credential does not exist', async () => { credentialsRepository.findOneBy.mockResolvedValue(null); await expect( - service.update('missing', mock({ id: 'missing' })), + service.update('missing', mock({ id: 'missing' }), ownerActor), ).resolves.toBeNull(); expect(policyEnforcementService.enforceCredentialSave).not.toHaveBeenCalled(); @@ -1624,7 +1635,11 @@ describe('CredentialsService', () => { ); await expect( - service.update('project-credential', mock({ id: 'project-credential' })), + service.update( + 'project-credential', + mock({ id: 'project-credential' }), + ownerActor, + ), ).rejects.toThrow(PolicyViolationError); expect(credentialsRepository.runInTransaction).not.toHaveBeenCalled(); @@ -1645,7 +1660,7 @@ describe('CredentialsService', () => { new Map([[credential.id, { accountIdentifier: 'me@gmail.com' }]]), ); - const result = await service.update(credential.id, encrypted, undefined, { + const result = await service.update(credential.id, encrypted, ownerActor, undefined, { user: ownerUser, }); @@ -1669,7 +1684,7 @@ describe('CredentialsService', () => { setRepositoryTransaction(transactionManager); const encrypted = mock({ id: credential.id }); - await service.update(credential.id, encrypted); + await service.update(credential.id, encrypted, ownerActor); expect(connectionStatusProxy.findMyConnections).not.toHaveBeenCalled(); }); @@ -1726,11 +1741,14 @@ describe('CredentialsService', () => { await service.updateInstanceCredential(ownerUser, 'instance-credential', payload, ctx); - expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith({ - credential: { id: 'instance-credential', type: 'openAiApi' }, - storedCredential: { id: 'instance-credential', type: 'openAiApi' }, - projectId: null, - }); + expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith( + { + credential: { id: 'instance-credential', type: 'openAiApi' }, + storedCredential: { id: 'instance-credential', type: 'openAiApi' }, + projectId: null, + }, + { kind: 'user', user: ownerUser }, + ); }); it('writes nothing when policy refuses the update', async () => { @@ -1874,11 +1892,14 @@ describe('CredentialsService', () => { it('gates the create on a policy clearance for its type, with no project', async () => { await service.createInstanceCredential(dto, ownerUser, {}); - expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith({ - credential: { id: null, type: 'apiKey' }, - storedCredential: null, - projectId: null, - }); + expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith( + { + credential: { id: null, type: 'apiKey' }, + storedCredential: null, + projectId: null, + }, + { kind: 'user', user: ownerUser }, + ); expect(credentialsRepository.saveInstanceCredential).toHaveBeenCalledWith( expect.objectContaining({ type: 'apiKey', usageScope: 'instance' }), { policyCleared: cleared }, @@ -3480,11 +3501,14 @@ describe('CredentialsService', () => { ownerUser, ); - expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith({ - credential: { id: null, type: credentialData.type }, - storedCredential: null, - projectId: 'project-1', - }); + expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith( + { + credential: { id: null, type: credentialData.type }, + storedCredential: null, + projectId: 'project-1', + }, + { kind: 'user', user: ownerUser }, + ); expect(credentialsRepository.runInTransaction).toHaveBeenCalledWith( { policyCleared: cleared }, expect.any(Function), @@ -3774,11 +3798,14 @@ describe('CredentialsService', () => { [], { policyCleared: cleared }, ); - expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith({ - credential: { id: null, type: 'githubApi' }, - storedCredential: null, - projectId: 'project-1', - }); + expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith( + { + credential: { id: null, type: 'githubApi' }, + storedCredential: null, + projectId: 'project-1', + }, + { kind: 'user', user: ownerUser }, + ); expect(projectService.getProjectWithScope).toHaveBeenCalledWith(ownerUser, 'project-1', [ 'credential:create', ]); diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 191d3eb9db6..6d2a47ada44 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -6,6 +6,7 @@ import { TestCredentialRequestDto, } from '@n8n/api-types'; import { LicenseState, Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import { SharedCredentials, @@ -42,10 +43,7 @@ import { getExternalSecretExpressionPaths } from './external-secrets.utils'; import { CredentialsOverwrites } from '@/credentials-overwrites'; import { CredentialNotFoundError } from '@/errors/credential-not-found.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; import { listQueryMiddleware } from '@/middlewares'; import { userHasScopes } from '@/permissions.ee/check-access'; import { CredentialRequest } from '@/requests'; @@ -380,6 +378,7 @@ export class CredentialsController { const responseData = await this.credentialsService.update( credentialId, newCredentialData, + { kind: 'user', user: req.user }, body.data ? (preparedCredentialData.data as unknown as ICredentialDataDecryptedObject) : undefined, @@ -470,7 +469,7 @@ export class CredentialsController { throw new BadRequestError('Only OAuth credentials can be disconnected'); } - await this.credentialsService.clearOauthTokenData(credential); + await this.credentialsService.clearOauthTokenData(credential, { kind: 'user', user: req.user }); this.logger.debug('Credential OAuth token cleared', { credentialId }); diff --git a/packages/cli/src/credentials/credentials.service.ee.ts b/packages/cli/src/credentials/credentials.service.ee.ts index eda4083e7c9..31442cd0525 100644 --- a/packages/cli/src/credentials/credentials.service.ee.ts +++ b/packages/cli/src/credentials/credentials.service.ee.ts @@ -12,7 +12,7 @@ import { hasGlobalScope } from '@n8n/permissions'; import { In, type EntityManager } from '@n8n/typeorm'; import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { TransferCredentialError } from '@/errors/response-errors/transfer-credential.error'; import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config'; import { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee'; diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 28298f9cf91..728b123f380 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -1,5 +1,6 @@ import type { CreateCredentialDto, CredentialConnectionStatus } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { Time } from '@n8n/constants'; import { Project, @@ -60,11 +61,7 @@ import { isCredSharingEnabled } from '@/constants/credential-sharing'; import { CredentialTypes } from '@/credential-types'; import { createCredentialsFromCredentialsEntity, CredentialsHelper } from '@/credentials-helper'; import { CredentialNotFoundError } from '@/errors/credential-not-found.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '@n8n/errors'; import { ExternalHooks } from '@/external-hooks'; import { validateEntity } from '@/generic-helpers'; import { getChangedSharedFields } from '@/modules/dynamic-credentials.ee/services/shared-fields'; @@ -73,6 +70,7 @@ import { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee import { DCR_MANAGED_CREDENTIAL_FIELDS } from '@/oauth/dcr-managed-fields'; import { validateOAuthUrl } from '@/oauth/validate-oauth-url'; import { userHasScopes } from '@/permissions.ee/check-access'; +import type { PolicyActor } from '@/policy/policy-enforcement-backend'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import type { CredentialRequest, ListQuery } from '@/requests'; import { CredentialsTester } from '@/services/credentials-tester.service'; @@ -952,7 +950,7 @@ export class CredentialsService { * deliberately carries `oauthTokenData` forward, so a "Disconnect" action on a * fixed OAuth credential needs this dedicated path. */ - async clearOauthTokenData(credential: CredentialsEntity): Promise { + async clearOauthTokenData(credential: CredentialsEntity, actor: PolicyActor): Promise { // Decrypt via the core credential so a decryption failure aborts the // disconnect. `this.decrypt` swallows CredentialDataError and returns `{}`, // which would otherwise overwrite the whole credential with empty data. @@ -967,7 +965,7 @@ export class CredentialsService { data: decryptedData, }); - await this.update(credential.id, newCredentialData, decryptedData); + await this.update(credential.id, newCredentialData, actor, decryptedData); } /** @@ -999,6 +997,7 @@ export class CredentialsService { async update( credentialId: string, newCredentialData: ICredentialsDb, + actor: PolicyActor, decryptedCredentialData?: ICredentialDataDecryptedObject, options?: UpdateOptions, ) { @@ -1006,7 +1005,7 @@ export class CredentialsService { await this.externalHooks.run('credentials.update', [newCredentialData]); await this.credentialDescriptions.stripIfDisabled(newCredentialData); - const cleared = await this.enforceCredentialUpdate(credentialId, newCredentialData.type); + const cleared = await this.enforceCredentialUpdate(credentialId, newCredentialData.type, actor); if (cleared === null) return null; const persist = async (transactionManager: EntityManager, ctx: OperationContext) => { @@ -1137,11 +1136,14 @@ export class CredentialsService { ); await this.validateInstanceCredentialUpdate(credential, hookedData, undefined, ctx); // The type cannot change here, so the check sees the same stored and new type. - const cleared = await this.policyEnforcementService.enforceCredentialSave({ - credential: { id: credential.id, type: credential.type }, - storedCredential: { id: credential.id, type: credential.type }, - projectId: null, - }); + const cleared = await this.policyEnforcementService.enforceCredentialSave( + { + credential: { id: credential.id, type: credential.type }, + storedCredential: { id: credential.id, type: credential.type }, + projectId: null, + }, + { kind: 'user', user }, + ); const updated = await this.credentialsRepository.updateInstanceCredential( credential.id, encrypted, @@ -1201,7 +1203,7 @@ export class CredentialsService { // Gate the save on policy before persisting, so the author learns about a blocked type // while setting the credential up rather than at run time. No stored credential: this one is new. - const cleared = await this.enforceCredentialCreate(newCredential.type, project.id); + const cleared = await this.enforceCredentialCreate(newCredential.type, project.id, user); const result = await this.credentialsRepository.runInTransaction( { policyCleared: cleared }, @@ -1248,26 +1250,29 @@ export class CredentialsService { private async enforceCredentialUpdate( credentialId: string, newType: string | undefined, + actor: PolicyActor, ): Promise | null> { const stored = await this.credentialsRepository.findOneBy({ id: credentialId }); if (!stored) return null; const owningProject = await this.sharedCredentialsRepository.findCredentialOwningProject(credentialId); - return await this.policyEnforcementService.enforceCredentialSave({ - credential: { id: credentialId, type: newType ?? stored.type }, - storedCredential: { id: credentialId, type: stored.type }, - projectId: owningProject?.id ?? null, - }); + return await this.policyEnforcementService.enforceCredentialSave( + { + credential: { id: credentialId, type: newType ?? stored.type }, + storedCredential: { id: credentialId, type: stored.type }, + projectId: owningProject?.id ?? null, + }, + actor, + ); } /** A create binds to its type: the row has no committed id yet, whatever the payload claims. */ - private async enforceCredentialCreate(type: string, projectId: string | null) { - return await this.policyEnforcementService.enforceCredentialSave({ - credential: { id: null, type }, - storedCredential: null, - projectId, - }); + private async enforceCredentialCreate(type: string, projectId: string | null, user: User) { + return await this.policyEnforcementService.enforceCredentialSave( + { credential: { id: null, type }, storedCredential: null, projectId }, + { kind: 'user', user }, + ); } private async insertProjectCredential( @@ -1296,7 +1301,7 @@ export class CredentialsService { "You don't have the permissions to save the credential in this project.", ); } - const cleared = await this.enforceCredentialCreate(newCredential.type, project.id); + const cleared = await this.enforceCredentialCreate(newCredential.type, project.id, user); const saved = await this.transactionRunner.run({ policyCleared: cleared }, async (ctx) => { return await this.credentialsRepository.insertProjectCredentialWithOwner( newCredential, @@ -2242,7 +2247,7 @@ export class CredentialsService { }); // After the hooks ran: the type checked has to be the type written. - const cleared = await this.enforceCredentialCreate(credentialEntity.type, null); + const cleared = await this.enforceCredentialCreate(credentialEntity.type, null, user); const savedCredential = await this.credentialsRepository.saveInstanceCredential( credentialEntity, { ...ctx, policyCleared: cleared }, diff --git a/packages/cli/src/credentials/instance-credential-broker.ts b/packages/cli/src/credentials/instance-credential-broker.ts index cb4c03a6105..197c33661bc 100644 --- a/packages/cli/src/credentials/instance-credential-broker.ts +++ b/packages/cli/src/credentials/instance-credential-broker.ts @@ -7,7 +7,7 @@ import { import { Service } from '@n8n/di'; import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { UnprocessableRequestError } from '@n8n/errors'; import { CredentialsService } from './credentials.service'; import { diff --git a/packages/cli/src/credentials/validation.ts b/packages/cli/src/credentials/validation.ts index 2c862801f29..847743489e9 100644 --- a/packages/cli/src/credentials/validation.ts +++ b/packages/cli/src/credentials/validation.ts @@ -3,7 +3,7 @@ import type { User } from '@n8n/db'; import get from 'lodash/get'; import { type ICredentialDataDecryptedObject } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee'; import { userHasScopes } from '@/permissions.ee/check-access'; diff --git a/packages/cli/src/encryption/__tests__/key-manager.service.test.ts b/packages/cli/src/encryption/__tests__/key-manager.service.test.ts index 046815a5949..f2218ae24cc 100644 --- a/packages/cli/src/encryption/__tests__/key-manager.service.test.ts +++ b/packages/cli/src/encryption/__tests__/key-manager.service.test.ts @@ -7,7 +7,7 @@ import { Cipher, type EncryptionKeyProxy, InstanceSettings } from 'n8n-core'; import { randomBytes } from 'node:crypto'; import { mock } from 'vitest-mock-extended'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { KeyManagerService } from '@/encryption/key-manager.service'; const makeKey = (overrides: Partial = {}): DeploymentKey => diff --git a/packages/cli/src/encryption/key-manager.service.ts b/packages/cli/src/encryption/key-manager.service.ts index f46f51ff3b2..5b740534ca8 100644 --- a/packages/cli/src/encryption/key-manager.service.ts +++ b/packages/cli/src/encryption/key-manager.service.ts @@ -16,7 +16,7 @@ import { } from 'n8n-core'; import { randomBytes } from 'node:crypto'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { isKeyRotationEnabled } from './key-rotation-flag'; diff --git a/packages/cli/src/environments.ee/variables/__tests__/variables.service.integration.test.ts b/packages/cli/src/environments.ee/variables/__tests__/variables.service.integration.test.ts index 77a8589838b..0f16b2ad724 100644 --- a/packages/cli/src/environments.ee/variables/__tests__/variables.service.integration.test.ts +++ b/packages/cli/src/environments.ee/variables/__tests__/variables.service.integration.test.ts @@ -6,8 +6,7 @@ import type { AssignableProjectRole } from '@n8n/permissions'; import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService, type EventService } from '@n8n/backend-services'; import { ProjectService } from '@/services/project.service.ee'; import { createAdmin, createMember } from '@test-integration/db/users'; import { createProjectVariable, createVariable } from '@test-integration/db/variables'; diff --git a/packages/cli/src/environments.ee/variables/variables.controller.ee.ts b/packages/cli/src/environments.ee/variables/variables.controller.ee.ts index 5d0701bfe52..3e90478f9ff 100644 --- a/packages/cli/src/environments.ee/variables/variables.controller.ee.ts +++ b/packages/cli/src/environments.ee/variables/variables.controller.ee.ts @@ -7,8 +7,7 @@ import { AuthenticatedRequest } from '@n8n/db'; import { Body, Delete, Get, Licensed, Patch, Post, Query, RestController } from '@n8n/decorators'; import type { Response } from 'express'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { VariableCountLimitReachedError } from '@/errors/variable-count-limit-reached.error'; import { VariableValidationError } from '@/errors/variable-validation.error'; diff --git a/packages/cli/src/environments.ee/variables/variables.service.ee.ts b/packages/cli/src/environments.ee/variables/variables.service.ee.ts index ca6b7493470..73e44fab5ff 100644 --- a/packages/cli/src/environments.ee/variables/variables.service.ee.ts +++ b/packages/cli/src/environments.ee/variables/variables.service.ee.ts @@ -11,12 +11,10 @@ import { Service } from '@n8n/di'; import { hasGlobalScope, Scope } from '@n8n/permissions'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import { VariableCountLimitReachedError } from '@/errors/variable-count-limit-reached.error'; import { VariableValidationError } from '@/errors/variable-validation.error'; -import { EventService } from '@/events/event.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService, EventService } from '@n8n/backend-services'; import { ProjectService } from '@/services/project.service.ee'; const projectVariableScopes: Partial> = { diff --git a/packages/cli/src/errors/__tests__/http-error-classifier.test.ts b/packages/cli/src/errors/__tests__/http-error-classifier.test.ts index 69c8bb0c91c..7899c1be2ff 100644 --- a/packages/cli/src/errors/__tests__/http-error-classifier.test.ts +++ b/packages/cli/src/errors/__tests__/http-error-classifier.test.ts @@ -1,9 +1,13 @@ import { BadRequest, Unauthorized } from 'express-openapi-validator/dist/framework/types'; -import { OperationalError, UnexpectedError, UserError } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { + BadRequestError, + NotFoundError, + OperationalError, + UnexpectedError, + UserError, +} from '@n8n/errors'; import { LicenseEulaRequiredError } from '@/errors/response-errors/license-eula-required.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { classifyHttpError, HttpErrorKind, isResponseError } from '../http-error-classifier'; diff --git a/packages/cli/src/errors/__tests__/http-error-serializers.test.ts b/packages/cli/src/errors/__tests__/http-error-serializers.test.ts index d925d5f9646..bdafd2ef7e7 100644 --- a/packages/cli/src/errors/__tests__/http-error-serializers.test.ts +++ b/packages/cli/src/errors/__tests__/http-error-serializers.test.ts @@ -1,14 +1,12 @@ import type { SourceControlledFile } from '@n8n/api-types'; -import { UnexpectedError, UserError } from 'n8n-workflow'; import { classifyHttpError } from '@/errors/http-error-classifier'; import { serializeInternalRestError, serializePublicApiError, } from '@/errors/http-error-serializers'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ConflictError, NotFoundError, UnexpectedError, UserError } from '@n8n/errors'; import { LicenseEulaRequiredError } from '@/errors/response-errors/license-eula-required.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { WorkflowPublishBlockedError } from '@/errors/response-errors/workflow-publish-blocked.error'; import { toImportBlockedError } from '@/modules/n8n-packages/engine/import-blocked.error'; import { PolicyViolationError } from '@/policy/policy-violation.error'; diff --git a/packages/cli/src/errors/aborted-execution-retry.error.ts b/packages/cli/src/errors/aborted-execution-retry.error.ts index 88ba834ee06..d05804fc3c2 100644 --- a/packages/cli/src/errors/aborted-execution-retry.error.ts +++ b/packages/cli/src/errors/aborted-execution-retry.error.ts @@ -1,4 +1,4 @@ -import { UnexpectedError } from 'n8n-workflow'; +import { UnexpectedError } from '@n8n/errors'; export class AbortedExecutionRetryError extends UnexpectedError { constructor() { diff --git a/packages/cli/src/errors/credential-missing-id.error.ts b/packages/cli/src/errors/credential-missing-id.error.ts index bb1024656e8..0401a5a55cc 100644 --- a/packages/cli/src/errors/credential-missing-id.error.ts +++ b/packages/cli/src/errors/credential-missing-id.error.ts @@ -1,4 +1,4 @@ -import { UnexpectedError } from 'n8n-workflow'; +import { UnexpectedError } from '@n8n/errors'; export class CredentialMissingIdError extends UnexpectedError { constructor(credentialName: string, credentialType: string) { diff --git a/packages/cli/src/errors/credential-not-found.error.ts b/packages/cli/src/errors/credential-not-found.error.ts index e13d730e6a7..64d288a4470 100644 --- a/packages/cli/src/errors/credential-not-found.error.ts +++ b/packages/cli/src/errors/credential-not-found.error.ts @@ -1,4 +1,4 @@ -import { UserError } from 'n8n-workflow'; +import { UserError } from '@n8n/errors'; export class CredentialNotFoundError extends UserError { constructor(credentialId: string, credentialType?: string) { diff --git a/packages/cli/src/errors/credentials-overwrites-already-set.error.ts b/packages/cli/src/errors/credentials-overwrites-already-set.error.ts index 84480d641a2..c02d7f9890e 100644 --- a/packages/cli/src/errors/credentials-overwrites-already-set.error.ts +++ b/packages/cli/src/errors/credentials-overwrites-already-set.error.ts @@ -1,4 +1,4 @@ -import { UserError } from 'n8n-workflow'; +import { UserError } from '@n8n/errors'; export class CredentialsOverwritesAlreadySetError extends UserError { constructor() { diff --git a/packages/cli/src/errors/deduplication.error.ts b/packages/cli/src/errors/deduplication.error.ts index 996a41fc362..ae36ab43b1c 100644 --- a/packages/cli/src/errors/deduplication.error.ts +++ b/packages/cli/src/errors/deduplication.error.ts @@ -1,4 +1,4 @@ -import { UnexpectedError } from 'n8n-workflow'; +import { UnexpectedError } from '@n8n/errors'; export class DeduplicationError extends UnexpectedError { constructor(message: string) { diff --git a/packages/cli/src/errors/duplicate-execution.error.ts b/packages/cli/src/errors/duplicate-execution.error.ts index 01af6f960d3..fe4ad011517 100644 --- a/packages/cli/src/errors/duplicate-execution.error.ts +++ b/packages/cli/src/errors/duplicate-execution.error.ts @@ -1,4 +1,4 @@ -import { OperationalError } from 'n8n-workflow'; +import { OperationalError } from '@n8n/errors'; export class DuplicateExecutionError extends OperationalError { constructor( diff --git a/packages/cli/src/errors/execution-already-resuming.error.ts b/packages/cli/src/errors/execution-already-resuming.error.ts index 26ed588cab2..8343aa5958f 100644 --- a/packages/cli/src/errors/execution-already-resuming.error.ts +++ b/packages/cli/src/errors/execution-already-resuming.error.ts @@ -1,4 +1,4 @@ -import { OperationalError } from 'n8n-workflow'; +import { OperationalError } from '@n8n/errors'; export class ExecutionAlreadyResumingError extends OperationalError { constructor(executionId: string) { diff --git a/packages/cli/src/errors/execution-not-found-error.ts b/packages/cli/src/errors/execution-not-found-error.ts index f6e6f3a7c4a..ef9b6e5743f 100644 --- a/packages/cli/src/errors/execution-not-found-error.ts +++ b/packages/cli/src/errors/execution-not-found-error.ts @@ -1,4 +1,4 @@ -import { UnexpectedError } from 'n8n-workflow'; +import { UnexpectedError } from '@n8n/errors'; export class ExecutionNotFoundError extends UnexpectedError { constructor(executionId: string) { diff --git a/packages/cli/src/errors/feature-not-licensed.error.ts b/packages/cli/src/errors/feature-not-licensed.error.ts index 41f211fbdbf..0ac84dd120c 100644 --- a/packages/cli/src/errors/feature-not-licensed.error.ts +++ b/packages/cli/src/errors/feature-not-licensed.error.ts @@ -1,5 +1,5 @@ import type { LICENSE_FEATURES } from '@n8n/constants'; -import { UserError } from 'n8n-workflow'; +import { UserError } from '@n8n/errors'; export class FeatureNotLicensedError extends UserError { constructor( diff --git a/packages/cli/src/errors/folder-not-found.error.ts b/packages/cli/src/errors/folder-not-found.error.ts index 1066410b544..025d6292963 100644 --- a/packages/cli/src/errors/folder-not-found.error.ts +++ b/packages/cli/src/errors/folder-not-found.error.ts @@ -1,4 +1,4 @@ -import { OperationalError } from 'n8n-workflow'; +import { OperationalError } from '@n8n/errors'; export class FolderNotFoundError extends OperationalError { constructor(folderId: string) { diff --git a/packages/cli/src/errors/http-error-classifier.ts b/packages/cli/src/errors/http-error-classifier.ts index d80dff32da2..b5f9a94b6f9 100644 --- a/packages/cli/src/errors/http-error-classifier.ts +++ b/packages/cli/src/errors/http-error-classifier.ts @@ -1,7 +1,6 @@ import { HttpError, Unauthorized } from 'express-openapi-validator/dist/framework/types'; -import { UnexpectedError, UserError } from 'n8n-workflow'; -import { ResponseError } from '@/errors/response-errors/abstract/response.error'; +import { ResponseError, UnexpectedError, UserError } from '@n8n/errors'; export const enum HttpErrorKind { responseError = 'responseError', diff --git a/packages/cli/src/errors/invalid-concurrency-limit.error.ts b/packages/cli/src/errors/invalid-concurrency-limit.error.ts index 48c584b8b0f..731c636931a 100644 --- a/packages/cli/src/errors/invalid-concurrency-limit.error.ts +++ b/packages/cli/src/errors/invalid-concurrency-limit.error.ts @@ -1,4 +1,4 @@ -import { UserError } from 'n8n-workflow'; +import { UserError } from '@n8n/errors'; export class InvalidConcurrencyLimitError extends UserError { constructor(value: number) { diff --git a/packages/cli/src/errors/invalid-role.error.ts b/packages/cli/src/errors/invalid-role.error.ts index 7e14bdc2e12..34aa606b7d3 100644 --- a/packages/cli/src/errors/invalid-role.error.ts +++ b/packages/cli/src/errors/invalid-role.error.ts @@ -1,3 +1,3 @@ -import { UnexpectedError } from 'n8n-workflow'; +import { UnexpectedError } from '@n8n/errors'; export class InvalidRoleError extends UnexpectedError {} diff --git a/packages/cli/src/errors/max-stalled-count.error.ts b/packages/cli/src/errors/max-stalled-count.error.ts index 4e92fad4054..29c81446ac7 100644 --- a/packages/cli/src/errors/max-stalled-count.error.ts +++ b/packages/cli/src/errors/max-stalled-count.error.ts @@ -1,4 +1,4 @@ -import { OperationalError } from 'n8n-workflow'; +import { OperationalError } from '@n8n/errors'; /** * @docs https://docs.bullmq.io/guide/workers/stalled-jobs diff --git a/packages/cli/src/errors/missing-execution-stop.error.ts b/packages/cli/src/errors/missing-execution-stop.error.ts index be8dea83022..3de3259b965 100644 --- a/packages/cli/src/errors/missing-execution-stop.error.ts +++ b/packages/cli/src/errors/missing-execution-stop.error.ts @@ -1,4 +1,4 @@ -import { UserError } from 'n8n-workflow'; +import { UserError } from '@n8n/errors'; export class MissingExecutionStopError extends UserError { constructor(executionId: string) { diff --git a/packages/cli/src/errors/non-json-body.error.ts b/packages/cli/src/errors/non-json-body.error.ts index b6da36aead9..0d6449d45fd 100644 --- a/packages/cli/src/errors/non-json-body.error.ts +++ b/packages/cli/src/errors/non-json-body.error.ts @@ -1,4 +1,4 @@ -import { UserError } from 'n8n-workflow'; +import { UserError } from '@n8n/errors'; export class NonJsonBodyError extends UserError { constructor() { diff --git a/packages/cli/src/errors/postgres-live-rows-retrieval.error.ts b/packages/cli/src/errors/postgres-live-rows-retrieval.error.ts index 0bd59000cbd..ac4e8768ddd 100644 --- a/packages/cli/src/errors/postgres-live-rows-retrieval.error.ts +++ b/packages/cli/src/errors/postgres-live-rows-retrieval.error.ts @@ -1,4 +1,4 @@ -import { UnexpectedError } from 'n8n-workflow'; +import { UnexpectedError } from '@n8n/errors'; export class PostgresLiveRowsRetrievalError extends UnexpectedError { constructor(rows: unknown) { diff --git a/packages/cli/src/errors/pre-execute-blocked.error.ts b/packages/cli/src/errors/pre-execute-blocked.error.ts index a6fa845f67a..b065f98f3a9 100644 --- a/packages/cli/src/errors/pre-execute-blocked.error.ts +++ b/packages/cli/src/errors/pre-execute-blocked.error.ts @@ -1,4 +1,4 @@ -import { UserError } from 'n8n-workflow'; +import { UserError } from '@n8n/errors'; /** * Thrown when `workflow.preExecute` refuses the run. The original hook error diff --git a/packages/cli/src/errors/queued-execution-retry.error.ts b/packages/cli/src/errors/queued-execution-retry.error.ts index 9ccd17732cb..92647f3f1f2 100644 --- a/packages/cli/src/errors/queued-execution-retry.error.ts +++ b/packages/cli/src/errors/queued-execution-retry.error.ts @@ -1,4 +1,4 @@ -import { UnexpectedError } from 'n8n-workflow'; +import { UnexpectedError } from '@n8n/errors'; export class QueuedExecutionRetryError extends UnexpectedError { constructor() { diff --git a/packages/cli/src/errors/redactable.error.ts b/packages/cli/src/errors/redactable.error.ts index ecc492d22b8..34a793b828b 100644 --- a/packages/cli/src/errors/redactable.error.ts +++ b/packages/cli/src/errors/redactable.error.ts @@ -1,4 +1,4 @@ -import { UnexpectedError } from 'n8n-workflow'; +import { UnexpectedError } from '@n8n/errors'; export class RedactableError extends UnexpectedError { constructor(fieldName: string, args: string) { diff --git a/packages/cli/src/errors/response-errors/ai-preference-scope-full.error.ts b/packages/cli/src/errors/response-errors/ai-preference-scope-full.error.ts index f834b411718..513e92fd231 100644 --- a/packages/cli/src/errors/response-errors/ai-preference-scope-full.error.ts +++ b/packages/cli/src/errors/response-errors/ai-preference-scope-full.error.ts @@ -1,6 +1,6 @@ import type { AiPreferenceScope } from '@n8n/api-types'; -import { BadRequestError } from './bad-request.error'; +import { BadRequestError } from '@n8n/errors'; /** The per-scope cap refused a preference write. `meta` stays out of the REST body. */ export class AiPreferenceScopeFullError extends BadRequestError { diff --git a/packages/cli/src/errors/response-errors/incompatible-nodes-api-version.error.ts b/packages/cli/src/errors/response-errors/incompatible-nodes-api-version.error.ts index 0322ffd772d..d49fe274df1 100644 --- a/packages/cli/src/errors/response-errors/incompatible-nodes-api-version.error.ts +++ b/packages/cli/src/errors/response-errors/incompatible-nodes-api-version.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from '@n8n/errors'; /** * A community package requires a node-authoring API version this runtime does diff --git a/packages/cli/src/errors/response-errors/invalid-mfa-code.error.ts b/packages/cli/src/errors/response-errors/invalid-mfa-code.error.ts index cc00976a84e..7038124c76a 100644 --- a/packages/cli/src/errors/response-errors/invalid-mfa-code.error.ts +++ b/packages/cli/src/errors/response-errors/invalid-mfa-code.error.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from './forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; export class InvalidMfaCodeError extends ForbiddenError { constructor(hint?: string) { diff --git a/packages/cli/src/errors/response-errors/invalid-mfa-recovery-code-error.ts b/packages/cli/src/errors/response-errors/invalid-mfa-recovery-code-error.ts index baa0ead8701..09761bcf210 100644 --- a/packages/cli/src/errors/response-errors/invalid-mfa-recovery-code-error.ts +++ b/packages/cli/src/errors/response-errors/invalid-mfa-recovery-code-error.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from './forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; export class InvalidMfaRecoveryCodeError extends ForbiddenError { constructor(hint?: string) { diff --git a/packages/cli/src/errors/response-errors/license-eula-required.error.ts b/packages/cli/src/errors/response-errors/license-eula-required.error.ts index 64222349ee0..7acee7e2d3d 100644 --- a/packages/cli/src/errors/response-errors/license-eula-required.error.ts +++ b/packages/cli/src/errors/response-errors/license-eula-required.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from '@n8n/errors'; /** * Error thrown when license activation requires EULA acceptance. diff --git a/packages/cli/src/errors/response-errors/transfer-credential.error.ts b/packages/cli/src/errors/response-errors/transfer-credential.error.ts index f10de3104ae..c9257822f63 100644 --- a/packages/cli/src/errors/response-errors/transfer-credential.error.ts +++ b/packages/cli/src/errors/response-errors/transfer-credential.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from '@n8n/errors'; export class TransferCredentialError extends ResponseError { constructor(message: string) { diff --git a/packages/cli/src/errors/response-errors/transfer-workflow.error.ts b/packages/cli/src/errors/response-errors/transfer-workflow.error.ts index c22c0730890..a2b7d5a0932 100644 --- a/packages/cli/src/errors/response-errors/transfer-workflow.error.ts +++ b/packages/cli/src/errors/response-errors/transfer-workflow.error.ts @@ -1,4 +1,4 @@ -import { ResponseError } from './abstract/response.error'; +import { ResponseError } from '@n8n/errors'; export class TransferWorkflowError extends ResponseError { constructor(message: string) { diff --git a/packages/cli/src/errors/response-errors/webhook-not-found.error.ts b/packages/cli/src/errors/response-errors/webhook-not-found.error.ts index 94f48f288c7..69004fdc63e 100644 --- a/packages/cli/src/errors/response-errors/webhook-not-found.error.ts +++ b/packages/cli/src/errors/response-errors/webhook-not-found.error.ts @@ -1,4 +1,4 @@ -import { NotFoundError } from './not-found.error'; +import { NotFoundError } from '@n8n/errors'; export const webhookNotFoundErrorMessage = ({ path, diff --git a/packages/cli/src/errors/response-errors/workflow-activation-bad-request.error.ts b/packages/cli/src/errors/response-errors/workflow-activation-bad-request.error.ts index efb72525179..ee0ff6df835 100644 --- a/packages/cli/src/errors/response-errors/workflow-activation-bad-request.error.ts +++ b/packages/cli/src/errors/response-errors/workflow-activation-bad-request.error.ts @@ -1,4 +1,4 @@ -import { BadRequestError } from './bad-request.error'; +import { BadRequestError } from '@n8n/errors'; /** * Error thrown when a workflow fails to activate due to a node-level error. diff --git a/packages/cli/src/errors/response-errors/workflow-deactivation-bad-request.error.ts b/packages/cli/src/errors/response-errors/workflow-deactivation-bad-request.error.ts index d707da2ede4..838088bc845 100644 --- a/packages/cli/src/errors/response-errors/workflow-deactivation-bad-request.error.ts +++ b/packages/cli/src/errors/response-errors/workflow-deactivation-bad-request.error.ts @@ -1,4 +1,4 @@ -import { BadRequestError } from './bad-request.error'; +import { BadRequestError } from '@n8n/errors'; /** * Error thrown when a workflow fails to deactivate, e.g. because a diff --git a/packages/cli/src/errors/response-errors/workflow-publish-blocked.error.ts b/packages/cli/src/errors/response-errors/workflow-publish-blocked.error.ts index 88ddbbf4749..0f136f42b4b 100644 --- a/packages/cli/src/errors/response-errors/workflow-publish-blocked.error.ts +++ b/packages/cli/src/errors/response-errors/workflow-publish-blocked.error.ts @@ -1,6 +1,6 @@ import type { WorkflowReviewBlockedDetails, WorkflowReviewBlockedReason } from '@n8n/api-types'; -import { ConflictError } from './conflict.error'; +import { ConflictError } from '@n8n/errors'; const messages: Record = { review_pending: diff --git a/packages/cli/src/errors/response-errors/workflow-publish-forbidden.error.ts b/packages/cli/src/errors/response-errors/workflow-publish-forbidden.error.ts index 73b0e11b8b4..0874628452b 100644 --- a/packages/cli/src/errors/response-errors/workflow-publish-forbidden.error.ts +++ b/packages/cli/src/errors/response-errors/workflow-publish-forbidden.error.ts @@ -3,7 +3,7 @@ import type { WorkflowPublishForbiddenReason, } from '@n8n/api-types'; -import { ForbiddenError } from './forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; const messages: Record = { insufficient_api_key_scope: diff --git a/packages/cli/src/errors/shared-workflow-not-found.error.ts b/packages/cli/src/errors/shared-workflow-not-found.error.ts index f9b9d6564e9..c0fcf5e52d0 100644 --- a/packages/cli/src/errors/shared-workflow-not-found.error.ts +++ b/packages/cli/src/errors/shared-workflow-not-found.error.ts @@ -1,3 +1,3 @@ -import { UserError } from 'n8n-workflow'; +import { UserError } from '@n8n/errors'; export class SharedWorkflowNotFoundError extends UserError {} diff --git a/packages/cli/src/errors/single-webhook-trigger.error.ts b/packages/cli/src/errors/single-webhook-trigger.error.ts index 2627960863b..c558e884107 100644 --- a/packages/cli/src/errors/single-webhook-trigger.error.ts +++ b/packages/cli/src/errors/single-webhook-trigger.error.ts @@ -1,4 +1,4 @@ -import { UserError } from 'n8n-workflow'; +import { UserError } from '@n8n/errors'; export class SingleWebhookTriggerError extends UserError { constructor(triggerName: string) { diff --git a/packages/cli/src/errors/unknown-execution-mode.error.ts b/packages/cli/src/errors/unknown-execution-mode.error.ts index 8b30b4fec84..4e0b66ce7cb 100644 --- a/packages/cli/src/errors/unknown-execution-mode.error.ts +++ b/packages/cli/src/errors/unknown-execution-mode.error.ts @@ -1,4 +1,4 @@ -import { UnexpectedError } from 'n8n-workflow'; +import { UnexpectedError } from '@n8n/errors'; export class UnknownExecutionModeError extends UnexpectedError { constructor(mode: string) { diff --git a/packages/cli/src/errors/unsupported-end-user-credential-trigger.error.ts b/packages/cli/src/errors/unsupported-end-user-credential-trigger.error.ts index 5852a63046a..95124016811 100644 --- a/packages/cli/src/errors/unsupported-end-user-credential-trigger.error.ts +++ b/packages/cli/src/errors/unsupported-end-user-credential-trigger.error.ts @@ -1,4 +1,4 @@ -import { UserError } from 'n8n-workflow'; +import { UserError } from '@n8n/errors'; const SYSTEM_RESOLVER_MESSAGE = "End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed."; diff --git a/packages/cli/src/errors/variable-count-limit-reached.error.ts b/packages/cli/src/errors/variable-count-limit-reached.error.ts index ac1bdc0059f..8db5157da06 100644 --- a/packages/cli/src/errors/variable-count-limit-reached.error.ts +++ b/packages/cli/src/errors/variable-count-limit-reached.error.ts @@ -1,3 +1,3 @@ -import { UserError } from 'n8n-workflow'; +import { UserError } from '@n8n/errors'; export class VariableCountLimitReachedError extends UserError {} diff --git a/packages/cli/src/errors/variable-validation.error.ts b/packages/cli/src/errors/variable-validation.error.ts index 166659bafab..d552554bbac 100644 --- a/packages/cli/src/errors/variable-validation.error.ts +++ b/packages/cli/src/errors/variable-validation.error.ts @@ -1,3 +1,3 @@ -import { UserError } from 'n8n-workflow'; +import { UserError } from '@n8n/errors'; export class VariableValidationError extends UserError {} diff --git a/packages/cli/src/errors/webhook-response-too-large.error.ts b/packages/cli/src/errors/webhook-response-too-large.error.ts index 7a3f7c2f2fb..cae95e4792a 100644 --- a/packages/cli/src/errors/webhook-response-too-large.error.ts +++ b/packages/cli/src/errors/webhook-response-too-large.error.ts @@ -1,4 +1,4 @@ -import { UserError } from 'n8n-workflow'; +import { UserError } from '@n8n/errors'; /** * A response a worker cannot deliver back to the main instance because of its size. diff --git a/packages/cli/src/errors/workflow-history-version-not-found.error.ts b/packages/cli/src/errors/workflow-history-version-not-found.error.ts index 177ee22d9aa..a026c1c7662 100644 --- a/packages/cli/src/errors/workflow-history-version-not-found.error.ts +++ b/packages/cli/src/errors/workflow-history-version-not-found.error.ts @@ -1,3 +1,3 @@ -import { UnexpectedError } from 'n8n-workflow'; +import { UnexpectedError } from '@n8n/errors'; export class WorkflowHistoryVersionNotFoundError extends UnexpectedError {} diff --git a/packages/cli/src/errors/workflow-missing-id.error.ts b/packages/cli/src/errors/workflow-missing-id.error.ts index c709b753565..684558b1dfa 100644 --- a/packages/cli/src/errors/workflow-missing-id.error.ts +++ b/packages/cli/src/errors/workflow-missing-id.error.ts @@ -1,5 +1,5 @@ import type { Workflow, IWorkflowBase } from 'n8n-workflow'; -import { UnexpectedError } from 'n8n-workflow'; +import { UnexpectedError } from '@n8n/errors'; export class WorkflowMissingIdError extends UnexpectedError { constructor(workflow: Workflow | IWorkflowBase) { diff --git a/packages/cli/src/evaluation.ee/__tests__/evaluation-collection.service.test.ts b/packages/cli/src/evaluation.ee/__tests__/evaluation-collection.service.test.ts index f0cf6dd9d2a..18e877565c9 100644 --- a/packages/cli/src/evaluation.ee/__tests__/evaluation-collection.service.test.ts +++ b/packages/cli/src/evaluation.ee/__tests__/evaluation-collection.service.test.ts @@ -20,8 +20,7 @@ import { OperationalError } from 'n8n-workflow'; import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import type { Telemetry } from '@/telemetry'; import type { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; diff --git a/packages/cli/src/evaluation.ee/__tests__/evaluation-collections.controller.test.ts b/packages/cli/src/evaluation.ee/__tests__/evaluation-collections.controller.test.ts index ffff29f1f95..ba0bb5923a9 100644 --- a/packages/cli/src/evaluation.ee/__tests__/evaluation-collections.controller.test.ts +++ b/packages/cli/src/evaluation.ee/__tests__/evaluation-collections.controller.test.ts @@ -6,8 +6,7 @@ import { Container } from '@n8n/di'; import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import type { PostHogClient } from '@/posthog'; import type { EvaluationCollectionService } from '../evaluation-collection.service'; diff --git a/packages/cli/src/evaluation.ee/__tests__/evaluation-config.controller.test.ts b/packages/cli/src/evaluation.ee/__tests__/evaluation-config.controller.test.ts index 8c4b39cbae0..eedcfb8ead9 100644 --- a/packages/cli/src/evaluation.ee/__tests__/evaluation-config.controller.test.ts +++ b/packages/cli/src/evaluation.ee/__tests__/evaluation-config.controller.test.ts @@ -2,8 +2,7 @@ import type { Mocked } from 'vitest'; import type { AuthenticatedRequest, EvaluationConfig, User, WorkflowEntity } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { EvaluationApiError } from '../evaluation-api-error'; diff --git a/packages/cli/src/evaluation.ee/__tests__/evaluation-dataset.service.test.ts b/packages/cli/src/evaluation.ee/__tests__/evaluation-dataset.service.test.ts index 90132ff8c8d..622aed88181 100644 --- a/packages/cli/src/evaluation.ee/__tests__/evaluation-dataset.service.test.ts +++ b/packages/cli/src/evaluation.ee/__tests__/evaluation-dataset.service.test.ts @@ -5,9 +5,7 @@ import type { EvaluationConfigRepository } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; import type { IConnections, IRunData } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; import type { ExecutionPersistence } from '@/executions/execution-persistence'; import type { DataTableColumn } from '@/modules/data-table/data-table-column.entity'; import type { DataTableService } from '@/modules/data-table/data-table.service'; diff --git a/packages/cli/src/evaluation.ee/__tests__/evaluation-test-run.service.test.ts b/packages/cli/src/evaluation.ee/__tests__/evaluation-test-run.service.test.ts index 38c046ee99d..29d7ab0ada1 100644 --- a/packages/cli/src/evaluation.ee/__tests__/evaluation-test-run.service.test.ts +++ b/packages/cli/src/evaluation.ee/__tests__/evaluation-test-run.service.test.ts @@ -10,7 +10,7 @@ import type { import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { PaymentRequiredError } from '@/errors/response-errors/payment-required.error'; +import { PaymentRequiredError } from '@n8n/errors'; import { EvaluationTestRunService } from '../evaluation-test-run.service'; diff --git a/packages/cli/src/evaluation.ee/__tests__/test-runs.controller.ee.test.ts b/packages/cli/src/evaluation.ee/__tests__/test-runs.controller.ee.test.ts index 6706ce0e1ab..0e4f883d30c 100644 --- a/packages/cli/src/evaluation.ee/__tests__/test-runs.controller.ee.test.ts +++ b/packages/cli/src/evaluation.ee/__tests__/test-runs.controller.ee.test.ts @@ -8,8 +8,7 @@ import type { } from '@n8n/db'; import type express from 'express'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ConflictError, NotFoundError } from '@n8n/errors'; import type { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee'; import { TestRunsController } from '@/evaluation.ee/test-runs.controller.ee'; import type { TestRunsRequest } from '@/evaluation.ee/test-runs.types.ee'; diff --git a/packages/cli/src/evaluation.ee/evaluation-collection.service.ts b/packages/cli/src/evaluation.ee/evaluation-collection.service.ts index 32a0503a6be..882fe5afeb8 100644 --- a/packages/cli/src/evaluation.ee/evaluation-collection.service.ts +++ b/packages/cli/src/evaluation.ee/evaluation-collection.service.ts @@ -25,8 +25,7 @@ import { In } from '@n8n/typeorm'; import { OperationalError, type IDataObject } from 'n8n-workflow'; import { nanoid } from 'nanoid'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee'; import { Telemetry } from '@/telemetry'; import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; diff --git a/packages/cli/src/evaluation.ee/evaluation-collections.controller.ee.ts b/packages/cli/src/evaluation.ee/evaluation-collections.controller.ee.ts index 18c2590f40b..d99a491f519 100644 --- a/packages/cli/src/evaluation.ee/evaluation-collections.controller.ee.ts +++ b/packages/cli/src/evaluation.ee/evaluation-collections.controller.ee.ts @@ -8,8 +8,7 @@ import { Logger } from '@n8n/backend-common'; import type { AuthenticatedRequest, User } from '@n8n/db'; import { Body, Delete, Get, Patch, Post, ProjectScope, RestController } from '@n8n/decorators'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { PostHogClient } from '@/posthog'; import { EvaluationCollectionService } from './evaluation-collection.service'; diff --git a/packages/cli/src/evaluation.ee/evaluation-config.controller.ts b/packages/cli/src/evaluation.ee/evaluation-config.controller.ts index e10cf1dcac8..437c733b04d 100644 --- a/packages/cli/src/evaluation.ee/evaluation-config.controller.ts +++ b/packages/cli/src/evaluation.ee/evaluation-config.controller.ts @@ -2,8 +2,7 @@ import { addDatasetRowSchema, upsertEvaluationConfigSchema } from '@n8n/api-type import type { AuthenticatedRequest, User } from '@n8n/db'; import { Delete, Get, Post, Put, RestController } from '@n8n/decorators'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { EvaluationConfigService } from './evaluation-config.service'; diff --git a/packages/cli/src/evaluation.ee/evaluation-dataset.service.ts b/packages/cli/src/evaluation.ee/evaluation-dataset.service.ts index 8c10a627514..e329e625ae2 100644 --- a/packages/cli/src/evaluation.ee/evaluation-dataset.service.ts +++ b/packages/cli/src/evaluation.ee/evaluation-dataset.service.ts @@ -23,9 +23,7 @@ import type { JsonValue, } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import type { DataTableColumn } from '@/modules/data-table/data-table-column.entity'; import { DataTableService } from '@/modules/data-table/data-table.service'; diff --git a/packages/cli/src/evaluation.ee/evaluation-test-run.service.ts b/packages/cli/src/evaluation.ee/evaluation-test-run.service.ts index 50893c713ca..d1ccf992bc2 100644 --- a/packages/cli/src/evaluation.ee/evaluation-test-run.service.ts +++ b/packages/cli/src/evaluation.ee/evaluation-test-run.service.ts @@ -4,7 +4,7 @@ import type { TestRun } from '@n8n/db'; import { TestCaseExecutionRepository, TestRunRepository, WorkflowRepository } from '@n8n/db'; import { Service } from '@n8n/di'; -import { PaymentRequiredError } from '@/errors/response-errors/payment-required.error'; +import { PaymentRequiredError } from '@n8n/errors'; type Pagination = { offset: number; limit: number }; diff --git a/packages/cli/src/evaluation.ee/insights/__tests__/eval-insights.controller.test.ts b/packages/cli/src/evaluation.ee/insights/__tests__/eval-insights.controller.test.ts index 1966b124d8f..eb5def89db9 100644 --- a/packages/cli/src/evaluation.ee/insights/__tests__/eval-insights.controller.test.ts +++ b/packages/cli/src/evaluation.ee/insights/__tests__/eval-insights.controller.test.ts @@ -7,7 +7,7 @@ import { Container } from '@n8n/di'; import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { PostHogClient } from '@/posthog'; import { EvalInsightsController } from '../eval-insights.controller.ee'; diff --git a/packages/cli/src/evaluation.ee/insights/__tests__/eval-insights.service.test.ts b/packages/cli/src/evaluation.ee/insights/__tests__/eval-insights.service.test.ts index 95228ef9288..8f4db864178 100644 --- a/packages/cli/src/evaluation.ee/insights/__tests__/eval-insights.service.test.ts +++ b/packages/cli/src/evaluation.ee/insights/__tests__/eval-insights.service.test.ts @@ -10,9 +10,7 @@ import type { import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; import type { Telemetry } from '@/telemetry'; import { DETERMINISTIC_MODEL_TAG, EvalInsightsService } from '../eval-insights.service'; diff --git a/packages/cli/src/evaluation.ee/insights/eval-insights.controller.ee.ts b/packages/cli/src/evaluation.ee/insights/eval-insights.controller.ee.ts index 9aab37f64b2..4112564abbc 100644 --- a/packages/cli/src/evaluation.ee/insights/eval-insights.controller.ee.ts +++ b/packages/cli/src/evaluation.ee/insights/eval-insights.controller.ee.ts @@ -3,7 +3,7 @@ import { Logger } from '@n8n/backend-common'; import type { AuthenticatedRequest, User } from '@n8n/db'; import { Body, Post, ProjectScope, RestController } from '@n8n/decorators'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { PostHogClient } from '@/posthog'; import { EvalInsightsService } from './eval-insights.service'; diff --git a/packages/cli/src/evaluation.ee/insights/eval-insights.service.ts b/packages/cli/src/evaluation.ee/insights/eval-insights.service.ts index 6c7f6b7d3b4..35fecc820be 100644 --- a/packages/cli/src/evaluation.ee/insights/eval-insights.service.ts +++ b/packages/cli/src/evaluation.ee/insights/eval-insights.service.ts @@ -12,9 +12,7 @@ import type { EvaluationConfig, TestRun, User } from '@n8n/db'; import { EvaluationCollectionRepository, EvaluationConfigRepository } from '@n8n/db'; import { Service } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; import { Telemetry } from '@/telemetry'; import { runMetricScales } from '../metric-scales'; diff --git a/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts index 3fe4e2f63cc..281582f819d 100644 --- a/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts +++ b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { ExecutionsConfig } from '@n8n/config'; import type { EvaluationConfig, User } from '@n8n/db'; import { @@ -49,7 +50,6 @@ import { checkNodeParameterNotEmpty, extractTokenUsage, } from '@/evaluation.ee/test-runner/utils.ee'; -import { EventService } from '@/events/event.service'; import { License } from '@/license'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { OwnershipService } from '@/services/ownership.service'; diff --git a/packages/cli/src/evaluation.ee/test-runs.controller.ee.ts b/packages/cli/src/evaluation.ee/test-runs.controller.ee.ts index e6b36e10209..8755fce4ab8 100644 --- a/packages/cli/src/evaluation.ee/test-runs.controller.ee.ts +++ b/packages/cli/src/evaluation.ee/test-runs.controller.ee.ts @@ -12,8 +12,7 @@ import { UnexpectedError } from 'n8n-workflow'; import { resolveConfigMetricScales, runMetricScales } from './metric-scales'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ConflictError, NotFoundError } from '@n8n/errors'; import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee'; import { TestRunsRequest } from '@/evaluation.ee/test-runs.types.ee'; import { listQueryMiddleware } from '@/middlewares'; diff --git a/packages/cli/src/eventbus/event-message-classes/event-message-audit.ts b/packages/cli/src/eventbus/event-message-classes/event-message-audit.ts index 120dfc30f1a..58d9b80c453 100644 --- a/packages/cli/src/eventbus/event-message-classes/event-message-audit.ts +++ b/packages/cli/src/eventbus/event-message-classes/event-message-audit.ts @@ -11,14 +11,16 @@ import type { AbstractEventPayload } from './abstract-event-payload'; // -------------------------------------- export interface EventPayloadAudit extends AbstractEventPayload { msg?: JsonValue; - userId?: string; + /** `null` when the event names no user. */ + userId?: string | null; userEmail?: string; firstName?: string; lastName?: string; credentialName?: string; credentialType?: string; - credentialId?: string; - workflowId?: string; + credentialId?: string | null; + /** `null` when the event has no workflow row, such as a create that was blocked. */ + workflowId?: string | null; workflowName?: string; projectId?: string | null; projectName?: string; diff --git a/packages/cli/src/eventbus/event-message-classes/index.ts b/packages/cli/src/eventbus/event-message-classes/index.ts index 7e84edb3ef3..b4f410da1c6 100644 --- a/packages/cli/src/eventbus/event-message-classes/index.ts +++ b/packages/cli/src/eventbus/event-message-classes/index.ts @@ -170,6 +170,7 @@ export const eventNamesAudit = [ 'n8n.audit.node-type-policy.document.updated', 'n8n.audit.node-type-policy.document.deleted', 'n8n.audit.node-type-policy.attachments.updated', + 'n8n.audit.policy.decision.blocked', ] as const; // Instance MCP server events. Kept as their own list and message class because the payload diff --git a/packages/cli/src/events/__tests__/activity-event-relay.test.ts b/packages/cli/src/events/__tests__/activity-event-relay.test.ts index 5c35b2b958e..ee52fbd5070 100644 --- a/packages/cli/src/events/__tests__/activity-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/activity-event-relay.test.ts @@ -1,4 +1,5 @@ import type { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import type { GlobalConfig } from '@n8n/config'; import type { ActivityEventRepository, @@ -11,7 +12,6 @@ import type { import type { INode } from 'n8n-workflow'; import { mock, type MockProxy } from 'vitest-mock-extended'; -import { EventService } from '@/events/event.service'; import type { PostHogClient } from '@/posthog'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { ActivityEventRelay } from '@/events/relays/activity.event-relay'; diff --git a/packages/cli/src/events/__tests__/event-map.test.ts b/packages/cli/src/events/__tests__/event-map.test.ts new file mode 100644 index 00000000000..dd504487846 --- /dev/null +++ b/packages/cli/src/events/__tests__/event-map.test.ts @@ -0,0 +1,48 @@ +import { EventService, type EventMap } from '@n8n/backend-services'; +import { expectTypeOf } from 'vitest'; + +import type { AiEventMap } from '../maps/ai.event-map'; +import type { ExecutionDataEventMap } from '../maps/execution-data.event-map'; +import type { InstanceAiEventMap } from '../maps/instance-ai.event-map'; +import type { McpPostSaveMetricsEventMap } from '../maps/mcp-post-save-metrics.event-map'; +import type { PollTriggerMetricsEventMap } from '../maps/poll-trigger-metrics.event-map'; +import type { QueueMetricsEventMap } from '../maps/queue-metrics.event-map'; +import type { RelayEventMap } from '../maps/relay.event-map'; +import type { SystemTaskMetricsEventMap } from '../maps/system-task-metrics.event-map'; +import type { WorkflowPublicationMetricsEventMap } from '../maps/workflow-publication-metrics.event-map'; + +describe('CLI event map', () => { + it('registers all existing event payloads on the shared service', () => { + type CliEventMap = RelayEventMap & + QueueMetricsEventMap & + AiEventMap & + ExecutionDataEventMap & + InstanceAiEventMap & + McpPostSaveMetricsEventMap & + WorkflowPublicationMetricsEventMap & + PollTriggerMetricsEventMap & + SystemTaskMetricsEventMap; + + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it('delivers CLI events through the package export', () => { + const events = new EventService(); + const listener = vi.fn(); + events.once('session-started', (payload) => { + expectTypeOf(payload).toEqualTypeOf<{ pushRef?: string }>(); + listener(payload); + }); + + events.emit('session-started', { pushRef: 'test-push-ref' }); + events.emit('session-started', {}); + + expect(listener).toHaveBeenCalledExactlyOnceWith({ pushRef: 'test-push-ref' }); + // @ts-expect-error CLI payload types must remain strict. + events.emit('session-started', { pushRef: 123 }); + // @ts-expect-error Unknown event names must remain invalid. + events.emit('unregistered-event', {}); + }); +}); diff --git a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts index 19a033e42ed..8db6db60e5f 100644 --- a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts @@ -1,10 +1,10 @@ -import { GLOBAL_OWNER_ROLE, type IWorkflowDb } from '@n8n/db'; +import { EventService } from '@n8n/backend-services'; +import { GLOBAL_OWNER_ROLE, type IWorkflowDb, type User, type UserRepository } from '@n8n/db'; import type { InstanceSettings } from 'n8n-core'; import type { INode, IRun, IWorkflowBase, IWorkflowExecutionDataProcess } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; import type { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay'; @@ -13,7 +13,8 @@ describe('LogStreamingEventRelay', () => { const eventService = new EventService(); const hostId = 'host-xyz'; const instanceSettings = mock({ hostId }); - new LogStreamingEventRelay(eventService, eventBus, instanceSettings).init(); + const userRepository = mock(); + new LogStreamingEventRelay(eventService, eventBus, instanceSettings, userRepository).init(); afterEach(() => { vi.clearAllMocks(); @@ -3027,6 +3028,164 @@ describe('LogStreamingEventRelay', () => { }); }); + describe('policy enforcement events', () => { + const blocked: RelayEventMap['policy-decision-blocked'] = { + point: 'workflowSave', + outcome: 'violation', + durationMs: 12, + checkIds: ['node-types'], + violations: [ + { + checkId: 'node-types', + kind: 'node-type-unavailable', + subject: 'n8n-nodes-base.slack', + subjectType: null, + scope: null, + matchedRuleId: null, + }, + ], + policyVersions: [{ scope: 'instance', version: 4 }], + workflowId: 'wf-1', + workflowName: 'My workflow', + projectId: 'proj-1', + actorType: 'user', + user: { + id: 'user-1', + email: 'alice@example.com', + firstName: 'Alice', + lastName: 'Admin', + role: { slug: 'global:admin' }, + }, + }; + + it('should log on `policy-decision-blocked` event, with the user redactable', () => { + eventService.emit('policy-decision-blocked', { ...blocked }); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.policy.decision.blocked', + payload: { + userId: 'user-1', + _email: 'alice@example.com', + _firstName: 'Alice', + _lastName: 'Admin', + globalRole: 'global:admin', + point: 'workflowSave', + outcome: 'violation', + durationMs: 12, + checkIds: ['node-types'], + violations: [ + { + checkId: 'node-types', + kind: 'node-type-unavailable', + subject: 'n8n-nodes-base.slack', + subjectType: null, + scope: null, + matchedRuleId: null, + }, + ], + policyVersions: [{ scope: 'instance', version: 4 }], + workflowId: 'wf-1', + workflowName: 'My workflow', + projectId: 'proj-1', + actorType: 'user', + }, + }); + }); + + it('should send a null user id and the reason when no user asked for the action', () => { + eventService.emit('policy-decision-blocked', { + ...blocked, + point: 'workflowStart', + outcome: 'checkFailure', + correlationIds: ['corr-1'], + policyVersions: undefined, + actorType: 'system', + user: null, + systemReason: 'execution', + executionId: 'exec-1', + }); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.policy.decision.blocked', + payload: expect.objectContaining({ + userId: null, + actorType: 'system', + systemReason: 'execution', + executionId: 'exec-1', + point: 'workflowStart', + outcome: 'checkFailure', + correlationIds: ['corr-1'], + policyVersions: [], + }), + }); + }); + + it('should not send an event when a destination credential was blocked', () => { + eventService.emit('policy-decision-blocked', { + ...blocked, + point: 'credentialDecrypt', + actorType: 'system', + user: null, + systemReason: 'log-streaming', + }); + + expect(eventBus.sendAuditEvent).not.toHaveBeenCalled(); + }); + + it('should read the user fields when the host passed only the user id', async () => { + userRepository.findByIdWithRole.mockResolvedValue( + mock({ + id: 'user-1', + email: 'alice@example.com', + firstName: 'Alice', + lastName: 'Admin', + role: { slug: 'global:admin' }, + }), + ); + + eventService.emit('policy-decision-blocked', { ...blocked, user: { id: 'user-1' } }); + + await vi.waitFor(() => + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.policy.decision.blocked', + payload: expect.objectContaining({ + userId: 'user-1', + _email: 'alice@example.com', + _firstName: 'Alice', + _lastName: 'Admin', + globalRole: 'global:admin', + }), + }), + ); + expect(userRepository.findByIdWithRole).toHaveBeenCalledWith('user-1'); + }); + + it.each([ + ['no longer exists', () => userRepository.findByIdWithRole.mockResolvedValue(null)], + [ + 'cannot be read', + () => userRepository.findByIdWithRole.mockRejectedValue(new Error('db down')), + ], + ])('should still send the event with the user id when the user %s', async (_, arrange) => { + arrange(); + + eventService.emit('policy-decision-blocked', { ...blocked, user: { id: 'user-1' } }); + + await vi.waitFor(() => + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.policy.decision.blocked', + payload: expect.objectContaining({ userId: 'user-1', actorType: 'user' }), + }), + ); + }); + + it('should not read the user when the host passed a full user', () => { + eventService.emit('policy-decision-blocked', { ...blocked }); + + expect(userRepository.findByIdWithRole).not.toHaveBeenCalled(); + }); + }); + describe('workflow review events', () => { const reviewer = { id: 'user123', diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 94b71d2dd8c..7841982e55a 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -1,4 +1,5 @@ import type { LicenseState } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { EMPTY_CANVAS_GROUPS_FLAG } from '@n8n/api-types'; import type { GlobalConfig } from '@n8n/config'; @@ -32,7 +33,6 @@ import { mock } from 'vitest-mock-extended'; import { N8N_VERSION } from '@/constants'; import type { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { TelemetryEventRelay, getSemanticVersioning } from '@/events/relays/telemetry.event-relay'; import type { License } from '@/license'; diff --git a/packages/cli/src/events/__tests__/workflow-failure-notification-event-relay.test.ts b/packages/cli/src/events/__tests__/workflow-failure-notification-event-relay.test.ts index 71b8c3707d2..020139fec7e 100644 --- a/packages/cli/src/events/__tests__/workflow-failure-notification-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/workflow-failure-notification-event-relay.test.ts @@ -1,8 +1,8 @@ import type { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import type { UserRepository } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { WorkflowFailureNotificationEventRelay } from '@/events/relays/workflow-failure-notification.event-relay'; import type { UserManagementMailer } from '@/user-management/email'; diff --git a/packages/cli/src/events/event.service.ts b/packages/cli/src/events/event-map.ts similarity index 61% rename from packages/cli/src/events/event.service.ts rename to packages/cli/src/events/event-map.ts index ce42589e256..ba8f2491f16 100644 --- a/packages/cli/src/events/event.service.ts +++ b/packages/cli/src/events/event-map.ts @@ -1,5 +1,4 @@ -import { TypedEmitter } from '@n8n/backend-common'; -import { Service } from '@n8n/di'; +import type {} from '@n8n/backend-services'; import type { AiEventMap } from './maps/ai.event-map'; import type { ExecutionDataEventMap } from './maps/execution-data.event-map'; @@ -11,15 +10,16 @@ import type { RelayEventMap } from './maps/relay.event-map'; import type { SystemTaskMetricsEventMap } from './maps/system-task-metrics.event-map'; import type { WorkflowPublicationMetricsEventMap } from './maps/workflow-publication-metrics.event-map'; -export type EventMap = RelayEventMap & - QueueMetricsEventMap & - AiEventMap & - ExecutionDataEventMap & - InstanceAiEventMap & - McpPostSaveMetricsEventMap & - WorkflowPublicationMetricsEventMap & - PollTriggerMetricsEventMap & - SystemTaskMetricsEventMap; - -@Service() -export class EventService extends TypedEmitter {} +// Keep CLI payload types here so the shared service does not depend on CLI modules. +declare module '@n8n/backend-services' { + interface EventMap + extends RelayEventMap, + QueueMetricsEventMap, + AiEventMap, + ExecutionDataEventMap, + InstanceAiEventMap, + McpPostSaveMetricsEventMap, + WorkflowPublicationMetricsEventMap, + PollTriggerMetricsEventMap, + SystemTaskMetricsEventMap {} +} diff --git a/packages/cli/src/events/events.controller.ts b/packages/cli/src/events/events.controller.ts index 40d5b43ba2e..98fd49ea923 100644 --- a/packages/cli/src/events/events.controller.ts +++ b/packages/cli/src/events/events.controller.ts @@ -1,8 +1,7 @@ +import { EventService } from '@n8n/backend-services'; import { AuthenticatedRequest } from '@n8n/db'; import { Get, RestController } from '@n8n/decorators'; -import { EventService } from './event.service'; - /** This controller holds endpoints that the frontend uses to trigger telemetry events */ @RestController('/events') export class EventsController { diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 2e37e36efe5..3770b87887b 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -25,12 +25,18 @@ import type { } from '@/modules/n8n-packages/n8n-packages.types'; import type { TokenExchangeFailureReason } from '@/modules/token-exchange/token-exchange.types'; import type { AdminCredentialSelection as InstanceAiCredentialSelection } from '@/modules/instance-ai/instance-ai-settings.service'; +import type { + AuditedActor, + PolicyDecisionAudit, +} from '@/modules/policy-infrastructure/policy-decision-audit'; import type { PolicyAction, PolicyAttachment, PolicyRule, } from '@/modules/type-availability-policies/policy-rule.types'; +import type { TracingContext } from '@/modules/otel/tracing-context'; import type { McpCallerAuth } from '@/services/oauth-token-verifier-proxy.service'; +import type { UserLike } from '@/types/user-like.types'; import type { AiEventMap } from './ai.event-map'; @@ -42,15 +48,7 @@ export type WorkflowActionSource = | 'import' | 'review-approval'; -export type UserLike = { - id: string; - email?: string; - firstName?: string; - lastName?: string; - role?: { - slug: string; - }; -}; +export type { UserLike }; /** * Which write path produced a policy document event. A composed save emits a document event @@ -658,7 +656,7 @@ export type RelayEventMap = { stoppedAt: Date; detector: CrashDetector; hostId: string; - tracingContext?: { traceparent: string; tracestate?: string }; + tracingContext?: TracingContext; workflowVersionId?: string; retryOf?: string; workflowCustomTelemetryTags?: IWorkflowSettings['customTelemetryTags']; @@ -1401,4 +1399,10 @@ export type RelayEventMap = { }; // #endregion + + // #region Policy enforcement + + 'policy-decision-blocked': PolicyDecisionAudit & AuditedActor; + + // #endregion } & AiEventMap; diff --git a/packages/cli/src/events/relays/activity.event-relay.ts b/packages/cli/src/events/relays/activity.event-relay.ts index 70ae151001b..1f2dc53e7df 100644 --- a/packages/cli/src/events/relays/activity.event-relay.ts +++ b/packages/cli/src/events/relays/activity.event-relay.ts @@ -1,5 +1,6 @@ import { Logger } from '@n8n/backend-common'; import { INSTANCE_ACTIVITY_CONTEXT_FLAG } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import { activityDataMaxLength, @@ -11,7 +12,6 @@ import type { ActivityEventInput } from '@n8n/db'; import { Service } from '@n8n/di'; import type { IDataObject, INode, IWorkflowBase } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap, WorkflowActionSource } from '@/events/maps/relay.event-map'; import { EventRelay } from '@/events/relays/event-relay'; import { PostHogClient } from '@/posthog'; diff --git a/packages/cli/src/events/relays/event-relay.ts b/packages/cli/src/events/relays/event-relay.ts index 742bfcba27c..2e28b853a37 100644 --- a/packages/cli/src/events/relays/event-relay.ts +++ b/packages/cli/src/events/relays/event-relay.ts @@ -1,6 +1,6 @@ +import { EventService } from '@n8n/backend-services'; import { Service } from '@n8n/di'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; @Service() diff --git a/packages/cli/src/events/relays/log-streaming.event-relay.ts b/packages/cli/src/events/relays/log-streaming.event-relay.ts index af5f89a3cc3..0f0015f5c5e 100644 --- a/packages/cli/src/events/relays/log-streaming.event-relay.ts +++ b/packages/cli/src/events/relays/log-streaming.event-relay.ts @@ -1,3 +1,5 @@ +import { EventService } from '@n8n/backend-services'; +import { UserRepository } from '@n8n/db'; import { Redactable } from '@n8n/decorators'; import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; @@ -5,7 +7,6 @@ import type { IWorkflowBase, JsonValue } from 'n8n-workflow'; import { EventMessageGeneric } from '@/eventbus/event-message-classes/event-message-generic'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap, UserLike } from '@/events/maps/relay.event-map'; import { EventRelay } from '@/events/relays/event-relay'; import type { @@ -68,6 +69,7 @@ export class LogStreamingEventRelay extends EventRelay { readonly eventService: EventService, private readonly eventBus: MessageEventBus, private readonly instanceSettings: InstanceSettings, + private readonly userRepository: UserRepository, ) { super(eventService); } @@ -124,6 +126,7 @@ export class LogStreamingEventRelay extends EventRelay { 'node-type-policy-document-deleted': (event) => this.nodeTypePolicyDocumentDeleted(event), 'node-type-policy-attachments-updated': (event) => this.nodeTypePolicyAttachmentsUpdated(event), + 'policy-decision-blocked': (event) => this.policyDecisionBlocked(event), 'external-secrets-provider-settings-saved': (event) => this.externalSecretsProviderSettingsSaved(event), 'external-secrets-provider-reloaded': (event) => this.externalSecretsProviderReloaded(event), @@ -909,6 +912,50 @@ export class LogStreamingEventRelay extends EventRelay { // #endregion + // #region Policy enforcement + + /** Hosts that know only the user id pass `{ id }`, so read the rest to keep the fields uniform. */ + private policyDecisionBlocked(event: RelayEventMap['policy-decision-blocked']) { + // The event would go to the destination whose credential was blocked, and block again. + if (event.actorType === 'system' && event.systemReason === 'log-streaming') return; + + if (event.actorType === 'system' || event.user.email !== undefined) { + this.sendPolicyDecisionBlocked(event); + return; + } + + void this.findAuditedUser(event.user).then((user) => + this.sendPolicyDecisionBlocked({ ...event, user }), + ); + } + + /** Falls back to the id alone, so a failed lookup never drops the event. */ + private async findAuditedUser(user: UserLike): Promise { + try { + return (await this.userRepository.findByIdWithRole(user.id)) ?? user; + } catch { + return user; + } + } + + @Redactable() + private sendPolicyDecisionBlocked({ + user, + policyVersions, + ...rest + }: RelayEventMap['policy-decision-blocked']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.policy.decision.blocked', + payload: { + ...(user ?? { userId: null }), + ...rest, + policyVersions: policyVersions?.map((version) => ({ ...version })) ?? [], + }, + }); + } + + // #endregion + // #region External Secrets Provider private externalSecretsProviderSettingsSaved( diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index b326ea3abb2..9e3cc69dcca 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -1,5 +1,6 @@ -import { LicenseState } from '@n8n/backend-common'; import { EMPTY_CANVAS_GROUPS_FLAG } from '@n8n/api-types'; +import { LicenseState } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import { CredentialsRepository, @@ -37,7 +38,6 @@ import semver from 'semver'; import config from '@/config'; import { N8N_VERSION } from '@/constants'; import { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { determineFinalExecutionStatus } from '@/execution-lifecycle/shared/shared-hook-functions'; import type { IExecutionTrackProperties } from '@/interfaces'; diff --git a/packages/cli/src/events/relays/workflow-failure-notification.event-relay.ts b/packages/cli/src/events/relays/workflow-failure-notification.event-relay.ts index 9fb8241c024..e0d7e22d1b8 100644 --- a/packages/cli/src/events/relays/workflow-failure-notification.event-relay.ts +++ b/packages/cli/src/events/relays/workflow-failure-notification.event-relay.ts @@ -1,8 +1,8 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { UserRepository } from '@n8n/db'; import { Service } from '@n8n/di'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { EventRelay } from '@/events/relays/event-relay'; import { UserManagementMailer } from '@/user-management/email'; diff --git a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts index 4e0ddf77939..4c69981bdf5 100644 --- a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts @@ -1,5 +1,6 @@ import type { PushMessage } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { ExecutionsConfig } from '@n8n/config'; import type { Project, User } from '@n8n/db'; @@ -23,6 +24,7 @@ import type { IRun, INode, IWorkflowBase, + IWorkflowExecutionDataProcess, WorkflowExecuteMode, ITaskStartedData, } from 'n8n-workflow'; @@ -35,7 +37,6 @@ import { getLifecycleHooksForScalingMain, } from '../execution-lifecycle-hooks'; -import { EventService } from '@/events/event.service'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import { ExecutionRedactionServiceProxy } from '@/executions/execution-redaction-proxy.service'; import { ExternalHooks } from '@/external-hooks'; @@ -1987,6 +1988,78 @@ describe('Execution Lifecycle Hooks', () => { ); }); + it.each( + [false, true, undefined].flatMap((lastRunOnly) => + ['subexecution', 'regular', 'worker'].map((hookType) => ({ lastRunOnly, hookType })), + ), + )( + 'copies returned binaries for $hookType hooks when lastRunOnly is $lastRunOnly', + async ({ lastRunOnly, hookType }) => { + const childBinaryId = (index: number) => + `filesystem:workflows/${workflowId}/executions/${executionId}/binary_data/${index}`; + const parentBinaryId = (index: number) => + `filesystem:workflows/${parentWorkflowId}/executions/${parentExecutionId}/binary_data/${index}`; + const runs = [0, 1].map((executionIndex) => ({ + startTime: 0, + executionTime: 0, + executionIndex, + source: [], + data: { + main: [ + [], + [ + { + json: { index: executionIndex }, + binary: { + file: { id: childBinaryId(executionIndex), data: '', mimeType: 'text/plain' }, + }, + }, + ], + ], + }, + })); + successfulRun.data.resultData.runData = { [nodeName]: runs }; + successfulRun.data.resultData.lastNodeExecuted = nodeName; + successfulRun.data.parentExecution = parentExecution; + if (lastRunOnly !== undefined) successfulRun.data.subWorkflowOutput = { lastRunOnly }; + if (hookType !== 'subexecution') { + workflowData.settings = { saveDataSuccessExecution: 'all' }; + const createHooks = + hookType === 'worker' + ? getLifecycleHooksForScalingWorker + : getLifecycleHooksForRegularMain; + lifecycleHooks = createHooks( + mock({ executionMode: 'integrated', workflowData }), + executionId, + ); + } + binaryDataService.duplicateBinaryData.mockImplementation(async (_location, outputs) => + outputs.map( + (branch) => + branch?.map((item) => ({ + ...item, + binary: { + file: { ...item.binary!.file, id: parentBinaryId(Number(item.json.index)) }, + }, + })) ?? [], + ), + ); + + await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]); + + expect(runs.map((run) => run.data.main[1][0].binary.file.id)).toEqual([ + lastRunOnly === false ? parentBinaryId(0) : childBinaryId(0), + parentBinaryId(1), + ]); + expect(binaryDataService.duplicateBinaryData).toHaveBeenCalledTimes( + lastRunOnly === false ? 2 : 1, + ); + expect( + binaryDataService.duplicateBinaryData.mock.invocationCallOrder.at(-1), + ).toBeLessThan(executionPersistence.updateExistingExecution.mock.invocationCallOrder[0]); + }, + ); + it('should not duplicate binary data when there is no output data', async () => { successfulRun.data.resultData.runData = {}; successfulRun.data.resultData.lastNodeExecuted = undefined; diff --git a/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts index cdf0b32fcac..3408c5c152b 100644 --- a/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts +++ b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { ExecutionsConfig } from '@n8n/config'; import type { User } from '@n8n/db'; import { ExecutionRepository, UserRepository } from '@n8n/db'; @@ -35,7 +36,6 @@ import { } from './shared/shared-hook-functions'; import { type ExecutionSaveSettings, toSaveSettings } from './to-save-settings'; -import { EventService } from '@/events/event.service'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import type { RedactableExecution } from '@/executions/execution-redaction'; import { ExecutionRedactionServiceProxy } from '@/executions/execution-redaction-proxy.service'; @@ -44,7 +44,7 @@ import { Push } from '@/push'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { isWorkflowIdValid } from '@/utils'; import { getItemCountByConnectionType } from '@/utils/get-item-count-by-connection-type'; -import { getLastExecutedNodeData } from '@/workflow-helpers'; +import { getLastExecutedNodeData, getLastExecutedNodeRuns } from '@/workflow-helpers'; import { WorkflowHookContextService } from '@/workflow-hook-context.service'; import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; @@ -549,8 +549,12 @@ async function duplicateBinaryDataToParent( parentExecution: RelatedExecution, binaryDataService: BinaryDataService, ) { - const outputData = getLastExecutedNodeData(fullRunData); - if (outputData?.data?.main) { + const outputRuns = + fullRunData.data.subWorkflowOutput?.lastRunOnly === false + ? getLastExecutedNodeRuns(fullRunData) + : [getLastExecutedNodeData(fullRunData)]; + for (const outputData of outputRuns) { + if (!outputData?.data?.main) continue; const duplicatedData = await binaryDataService.duplicateBinaryData( FileLocation.ofExecution(parentExecution.workflowId, parentExecution.executionId), outputData.data.main, @@ -592,8 +596,9 @@ function hookFunctionsSave( // If this is a subworkflow execution, duplicate binary data to the parent's // execution. This must happen before any potential deletion of this execution's // data, and updates the binary data IDs in fullRunData to point to the parent location. - if (parentExecution) { - await duplicateBinaryDataToParent(fullRunData, parentExecution, binaryDataService); + const parent = parentExecution ?? fullRunData.data.parentExecution; + if (parent) { + await duplicateBinaryDataToParent(fullRunData, parent, binaryDataService); } const isManualMode = this.mode === 'manual'; @@ -716,6 +721,7 @@ function hookFunctionsSaveWorker( ) { const logger = Container.get(Logger); const errorReporter = Container.get(ErrorReporter); + const binaryDataService = Container.get(BinaryDataService); const workflowStaticDataService = Container.get(WorkflowStaticDataService); const workflowStatisticsService = Container.get(WorkflowStatisticsService); hooks.addHandler('workflowExecuteAfter', async function (fullRunData, newStaticData) { @@ -724,6 +730,11 @@ function hookFunctionsSaveWorker( workflowId: this.workflowData.id, }); + const { parentExecution } = fullRunData.data; + if (parentExecution) { + await duplicateBinaryDataToParent(fullRunData, parentExecution, binaryDataService); + } + const isManualMode = this.mode === 'manual'; let wasUpdated = true; diff --git a/packages/cli/src/executions/__tests__/enqueued-execution-recovery.service.test.ts b/packages/cli/src/executions/__tests__/enqueued-execution-recovery.service.test.ts index 0b73712aba5..4ded6659b41 100644 --- a/packages/cli/src/executions/__tests__/enqueued-execution-recovery.service.test.ts +++ b/packages/cli/src/executions/__tests__/enqueued-execution-recovery.service.test.ts @@ -1,3 +1,4 @@ +import type { EventService } from '@n8n/backend-services'; import { mockLogger } from '@n8n/backend-test-utils'; import type { ExecutionsConfig } from '@n8n/config'; import type { IExecutionResponse, Project } from '@n8n/db'; @@ -5,7 +6,6 @@ import type { ErrorReporter } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; import { ExecutionAlreadyResumingError } from '@/errors/execution-already-resuming.error'; -import type { EventService } from '@/events/event.service'; import { EnqueuedExecutionRecoveryService } from '@/executions/enqueued-execution-recovery.service'; import type { ExecutionCrashService } from '@/executions/execution-crash.service'; import type { ExecutionService } from '@/executions/execution.service'; diff --git a/packages/cli/src/executions/__tests__/execution-crash.service.test.ts b/packages/cli/src/executions/__tests__/execution-crash.service.test.ts index 09d657741f7..8e4d86743d8 100644 --- a/packages/cli/src/executions/__tests__/execution-crash.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution-crash.service.test.ts @@ -1,8 +1,8 @@ +import type { EventService } from '@n8n/backend-services'; import type { CrashedExecution, ExecutionRepository } from '@n8n/db'; import type { InstanceSettings } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; import { ExecutionCrashService } from '@/executions/execution-crash.service'; import type { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; diff --git a/packages/cli/src/executions/__tests__/execution-persistence.test.ts b/packages/cli/src/executions/__tests__/execution-persistence.test.ts index a0628d9d781..8de5f11944a 100644 --- a/packages/cli/src/executions/__tests__/execution-persistence.test.ts +++ b/packages/cli/src/executions/__tests__/execution-persistence.test.ts @@ -1,6 +1,7 @@ /* eslint-disable id-denylist */ /* eslint-disable @typescript-eslint/unbound-method */ +import type { EventService } from '@n8n/backend-services'; import type { DatabaseConfig, ExecutionsConfig } from '@n8n/config'; import { ExecutionEntity, @@ -17,7 +18,6 @@ import { createEmptyRunExecutionData, UnexpectedError } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; import { DuplicateExecutionError } from '@/errors/duplicate-execution.error'; -import type { EventService } from '@/events/event.service'; import { CorruptedExecutionDataError } from '@/executions/execution-data/corrupted-execution-data.error'; import type { DbStore } from '@/executions/execution-data/db-store'; import type { ExecutionDataJsonStore } from '@/executions/execution-data/execution-data-json-store'; diff --git a/packages/cli/src/executions/__tests__/execution.service.test.ts b/packages/cli/src/executions/__tests__/execution.service.test.ts index ef2d84a5533..01ff1affe97 100644 --- a/packages/cli/src/executions/__tests__/execution.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution.service.test.ts @@ -1,4 +1,5 @@ import { DeleteExecutionsDto } from '@n8n/api-types'; +import type { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import type { @@ -22,9 +23,7 @@ import type { ActiveExecutions } from '@/active-executions'; import type { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error'; import { MissingExecutionStopError } from '@/errors/missing-execution-stop.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { EventService } from '@/events/event.service'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import type { EngineV2ExecutionReader } from '@/executions/engine-v2-execution-reader.service'; import { MissingExecutionDataError } from '@/executions/execution-data/missing-execution-data.error'; import type { ExecutionPersistence } from '@/executions/execution-persistence'; diff --git a/packages/cli/src/executions/__tests__/executions.controller.test.ts b/packages/cli/src/executions/__tests__/executions.controller.test.ts index 5bc56747cc8..cd50287fb7a 100644 --- a/packages/cli/src/executions/__tests__/executions.controller.test.ts +++ b/packages/cli/src/executions/__tests__/executions.controller.test.ts @@ -2,9 +2,7 @@ import { DeleteExecutionsDto } from '@n8n/api-types'; import type { AuthenticatedRequest, ExecutionSummaries, User } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { NotImplementedError } from '@/errors/response-errors/not-implemented.error'; +import { BadRequestError, NotFoundError, NotImplementedError } from '@n8n/errors'; import type { ExecutionCursor } from '@/executions/execution-cursor'; import type { ExecutionListService } from '@/executions/execution-list.service'; import type { ExecutionService } from '@/executions/execution.service'; diff --git a/packages/cli/src/executions/enqueued-execution-recovery.service.ts b/packages/cli/src/executions/enqueued-execution-recovery.service.ts index d3a3af21726..67c9cac4229 100644 --- a/packages/cli/src/executions/enqueued-execution-recovery.service.ts +++ b/packages/cli/src/executions/enqueued-execution-recovery.service.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { ExecutionsConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import { ErrorReporter } from 'n8n-core'; @@ -6,7 +7,6 @@ import type { IWorkflowExecutionDataProcess } from 'n8n-workflow'; import { strict as assert } from 'node:assert'; import { ExecutionAlreadyResumingError } from '@/errors/execution-already-resuming.error'; -import { EventService } from '@/events/event.service'; import { ExecutionCrashService } from '@/executions/execution-crash.service'; import { ExecutionService } from '@/executions/execution.service'; import { OwnershipService } from '@/services/ownership.service'; diff --git a/packages/cli/src/executions/execution-crash.service.ts b/packages/cli/src/executions/execution-crash.service.ts index e1260d628ff..1826c1a8f2c 100644 --- a/packages/cli/src/executions/execution-crash.service.ts +++ b/packages/cli/src/executions/execution-crash.service.ts @@ -1,9 +1,9 @@ +import { EventService } from '@n8n/backend-services'; import type { CrashedExecution } from '@n8n/db'; import { ExecutionRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; -import { EventService } from '@/events/event.service'; import type { CrashDetector } from '@/events/maps/relay.event-map'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; diff --git a/packages/cli/src/executions/execution-cursor.ts b/packages/cli/src/executions/execution-cursor.ts index 3ad64d0785f..5d3baddd656 100644 --- a/packages/cli/src/executions/execution-cursor.ts +++ b/packages/cli/src/executions/execution-cursor.ts @@ -1,7 +1,7 @@ import type { SerializedCursor } from '@n8n/api-types'; import { z } from 'zod'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; const timestamp = z.string().datetime({ offset: true }); diff --git a/packages/cli/src/executions/execution-persistence.ts b/packages/cli/src/executions/execution-persistence.ts index 3ce11ce374e..1e61d1a9b4d 100644 --- a/packages/cli/src/executions/execution-persistence.ts +++ b/packages/cli/src/executions/execution-persistence.ts @@ -1,4 +1,5 @@ import { parseFlatted } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { DatabaseConfig, ExecutionsConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import type { @@ -40,7 +41,6 @@ import { import { UnreadableRunDataError } from './execution-data/unreadable-run-data.error'; import { sumBinaryDataBytes } from './sum-binary-data-bytes'; import { DuplicateExecutionError } from '../errors/duplicate-execution.error'; -import { EventService } from '../events/event.service'; type DeletionTarget = ExecutionRef & { storedAt: ExecutionDataStorageLocation }; diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index ecb8e1aaccb..8ffdf72dd35 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -1,6 +1,7 @@ import type { DeleteExecutionsDto } from '@n8n/api-types'; import { ExecutionRedactionQueryDtoSchema } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import type { CreateExecutionPayload, @@ -45,11 +46,7 @@ import { ConcurrencyControlService } from '@/concurrency/concurrency-control.ser import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error'; import { MissingExecutionStopError } from '@/errors/missing-execution-stop.error'; import { QueuedExecutionRetryError } from '@/errors/queued-execution-retry.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from '@n8n/errors'; import type { IExecutionFlattedResponse } from '@/interfaces'; import { License } from '@/license'; import { NodeTypes } from '@/node-types'; diff --git a/packages/cli/src/executions/executions.controller.ts b/packages/cli/src/executions/executions.controller.ts index 0c3ae4e2ab8..cc3c1a681e5 100644 --- a/packages/cli/src/executions/executions.controller.ts +++ b/packages/cli/src/executions/executions.controller.ts @@ -4,9 +4,7 @@ import { Body, Get, Patch, Post, RestController } from '@n8n/decorators'; import type { Scope } from '@n8n/permissions'; import type { Response } from 'express'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { NotImplementedError } from '@/errors/response-errors/not-implemented.error'; +import { BadRequestError, NotFoundError, NotImplementedError } from '@n8n/errors'; import { License } from '@/license'; import { isPositiveInteger } from '@/utils'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; diff --git a/packages/cli/src/executions/parse-range-query.middleware.ts b/packages/cli/src/executions/parse-range-query.middleware.ts index 86353f42c00..ee1e595949b 100644 --- a/packages/cli/src/executions/parse-range-query.middleware.ts +++ b/packages/cli/src/executions/parse-range-query.middleware.ts @@ -4,7 +4,7 @@ import { validate } from 'jsonschema'; import type { JsonObject } from 'n8n-workflow'; import { jsonParse, UnexpectedError } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import * as ResponseHelper from '@/response-helper'; import { parseExecutionCursor } from './execution-cursor'; diff --git a/packages/cli/src/executions/validation.ts b/packages/cli/src/executions/validation.ts index 75071114a8c..1caafd9d141 100644 --- a/packages/cli/src/executions/validation.ts +++ b/packages/cli/src/executions/validation.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { ExecutionRequest } from '@/executions/execution.types'; const executionUpdateSchema = z.object({ diff --git a/packages/cli/src/generic-helpers.ts b/packages/cli/src/generic-helpers.ts index 050902623c4..4c02410f63e 100644 --- a/packages/cli/src/generic-helpers.ts +++ b/packages/cli/src/generic-helpers.ts @@ -8,7 +8,7 @@ import type { import { validate } from 'class-validator'; import type { PersonalizationSurveyAnswersV4 } from './controllers/survey-answers.dto'; -import { BadRequestError } from './errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; export async function validateEntity( entity: diff --git a/packages/cli/src/license/__tests__/license.service.test.ts b/packages/cli/src/license/__tests__/license.service.test.ts index 3b5f11cb2ca..02b44fbffae 100644 --- a/packages/cli/src/license/__tests__/license.service.test.ts +++ b/packages/cli/src/license/__tests__/license.service.test.ts @@ -1,13 +1,13 @@ import type { LicenseState } from '@n8n/backend-common'; import { markHttpRequestError } from '@n8n/backend-network'; import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network'; +import type { EventService } from '@n8n/backend-services'; import type { WorkflowRepository } from '@n8n/db'; import type { TEntitlement } from '@n8n_io/license-sdk'; import { AxiosError } from 'axios'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import type { EventService } from '@/events/event.service'; +import { BadRequestError } from '@n8n/errors'; import type { License } from '@/license'; import { LicenseErrors, LicenseService } from '@/license/license.service'; diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index c05b0fd620d..c80305c1aaa 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -4,7 +4,7 @@ import { Get, Post, RestController, GlobalScope, Body } from '@n8n/decorators'; import type { AxiosError } from 'axios'; import { InstanceSettings } from 'n8n-core'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { LicenseRequest } from '@/requests'; import { UrlService } from '@n8n/backend-services'; diff --git a/packages/cli/src/license/license.service.ts b/packages/cli/src/license/license.service.ts index 557e14d73fe..498d0880b81 100644 --- a/packages/cli/src/license/license.service.ts +++ b/packages/cli/src/license/license.service.ts @@ -1,16 +1,15 @@ import { LicenseState, Logger } from '@n8n/backend-common'; import { OutboundHttp, type HttpRequestClient, isHttpRequestError } from '@n8n/backend-network'; +import { EventService, UrlService } from '@n8n/backend-services'; import { Time } from '@n8n/constants'; import type { User } from '@n8n/db'; import { WorkflowRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import { ensureError } from '@n8n/utils/errors/ensure-error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { LicenseEulaRequiredError } from '@/errors/response-errors/license-eula-required.error'; -import { EventService } from '@/events/event.service'; import { License } from '@/license'; -import { UrlService } from '@n8n/backend-services'; const REQUEST_TIMEOUT_MS = 30 * Time.seconds.toMilliseconds; diff --git a/packages/cli/src/metrics/prometheus/__tests__/active-workflow-metrics.service.test.ts b/packages/cli/src/metrics/prometheus/__tests__/active-workflow-metrics.service.test.ts index cbdc2a582db..ac04a5c2cfe 100644 --- a/packages/cli/src/metrics/prometheus/__tests__/active-workflow-metrics.service.test.ts +++ b/packages/cli/src/metrics/prometheus/__tests__/active-workflow-metrics.service.test.ts @@ -4,7 +4,7 @@ import type { WorkflowRepository } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; import promClient from 'prom-client'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { PrometheusActiveWorkflowMetricsService } from '../active-workflow-metrics.service'; diff --git a/packages/cli/src/metrics/prometheus/__tests__/cache-metrics.service.test.ts b/packages/cli/src/metrics/prometheus/__tests__/cache-metrics.service.test.ts index fc7c237cff6..5303b0995d3 100644 --- a/packages/cli/src/metrics/prometheus/__tests__/cache-metrics.service.test.ts +++ b/packages/cli/src/metrics/prometheus/__tests__/cache-metrics.service.test.ts @@ -6,7 +6,7 @@ import promClient from 'prom-client'; import { PrometheusCacheMetricsService } from '../cache-metrics.service'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; vi.mock('prom-client'); diff --git a/packages/cli/src/metrics/prometheus/__tests__/cached-metric-query.test.ts b/packages/cli/src/metrics/prometheus/__tests__/cached-metric-query.test.ts index 5338198c7c7..47e33e568ef 100644 --- a/packages/cli/src/metrics/prometheus/__tests__/cached-metric-query.test.ts +++ b/packages/cli/src/metrics/prometheus/__tests__/cached-metric-query.test.ts @@ -1,6 +1,6 @@ import { mock } from 'vitest-mock-extended'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { CachedMetricQuery } from '../cached-metric-query'; diff --git a/packages/cli/src/metrics/prometheus/__tests__/execution-data-metrics.service.test.ts b/packages/cli/src/metrics/prometheus/__tests__/execution-data-metrics.service.test.ts index 1a585c42e23..296200307f1 100644 --- a/packages/cli/src/metrics/prometheus/__tests__/execution-data-metrics.service.test.ts +++ b/packages/cli/src/metrics/prometheus/__tests__/execution-data-metrics.service.test.ts @@ -1,3 +1,4 @@ +import type { EventService } from '@n8n/backend-services'; import type { Mock } from 'vitest'; import { mockInstance } from '@n8n/backend-test-utils'; import { PrometheusMetricsConfig } from '@n8n/config'; @@ -8,8 +9,6 @@ import promClient from 'prom-client'; import { DURATION_BUCKETS_SECONDS, SIZE_BUCKETS_BYTES } from '../constant'; import { PrometheusExecutionDataMetricsService } from '../execution-data-metrics.service'; -import type { EventService } from '@/events/event.service'; - vi.mock('prom-client'); describe('PrometheusExecutionDataMetricsService', () => { diff --git a/packages/cli/src/metrics/prometheus/__tests__/instance-ai-metrics.service.test.ts b/packages/cli/src/metrics/prometheus/__tests__/instance-ai-metrics.service.test.ts index a6d35889429..883301a4325 100644 --- a/packages/cli/src/metrics/prometheus/__tests__/instance-ai-metrics.service.test.ts +++ b/packages/cli/src/metrics/prometheus/__tests__/instance-ai-metrics.service.test.ts @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/unbound-method -- vi mocks */ +import type { EventService } from '@n8n/backend-services'; import type { Mock } from 'vitest'; import { mockInstance } from '@n8n/backend-test-utils'; import { PrometheusMetricsConfig } from '@n8n/config'; import { mock } from 'vitest-mock-extended'; import promClient from 'prom-client'; -import type { EventService } from '@/events/event.service'; import type { InstanceAiRunProbe } from '@/modules/instance-ai/instance-ai-run-probe'; import { DURATION_BUCKETS_SECONDS } from '../constant'; diff --git a/packages/cli/src/metrics/prometheus/__tests__/mcp-post-save-metrics.service.test.ts b/packages/cli/src/metrics/prometheus/__tests__/mcp-post-save-metrics.service.test.ts index 80bd46bd272..299fd73b9b1 100644 --- a/packages/cli/src/metrics/prometheus/__tests__/mcp-post-save-metrics.service.test.ts +++ b/packages/cli/src/metrics/prometheus/__tests__/mcp-post-save-metrics.service.test.ts @@ -1,11 +1,10 @@ +import type { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { PrometheusMetricsConfig } from '@n8n/config'; import promClient from 'prom-client'; import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; - import { PrometheusMcpPostSaveMetricsService } from '../mcp-post-save-metrics.service'; vi.mock('prom-client'); diff --git a/packages/cli/src/metrics/prometheus/__tests__/poll-trigger-metrics.service.test.ts b/packages/cli/src/metrics/prometheus/__tests__/poll-trigger-metrics.service.test.ts index 66ef86581bf..aba4f96c9f4 100644 --- a/packages/cli/src/metrics/prometheus/__tests__/poll-trigger-metrics.service.test.ts +++ b/packages/cli/src/metrics/prometheus/__tests__/poll-trigger-metrics.service.test.ts @@ -1,3 +1,4 @@ +import type { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { PrometheusMetricsConfig } from '@n8n/config'; import type { InstanceSettings, TriggersAndPollers } from 'n8n-core'; @@ -5,8 +6,6 @@ import promClient from 'prom-client'; import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; - import { PrometheusPollTriggerMetricsService } from '../poll-trigger-metrics.service'; vi.mock('prom-client'); diff --git a/packages/cli/src/metrics/prometheus/__tests__/queue-metrics.service.test.ts b/packages/cli/src/metrics/prometheus/__tests__/queue-metrics.service.test.ts index e14f04d0243..f10623969e6 100644 --- a/packages/cli/src/metrics/prometheus/__tests__/queue-metrics.service.test.ts +++ b/packages/cli/src/metrics/prometheus/__tests__/queue-metrics.service.test.ts @@ -1,3 +1,4 @@ +import type { EventService } from '@n8n/backend-services'; import type { Mock } from 'vitest'; import { mockInstance } from '@n8n/backend-test-utils'; import { ExecutionsConfig, PrometheusMetricsConfig } from '@n8n/config'; @@ -7,8 +8,6 @@ import promClient from 'prom-client'; import { PrometheusQueueMetricsService } from '../queue-metrics.service'; -import type { EventService } from '@/events/event.service'; - vi.mock('prom-client'); describe('PrometheusQueueMetricsService', () => { 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 16cb026a822..9c66444813b 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 @@ -9,7 +9,7 @@ import { mock } from 'vitest-mock-extended'; import { PrometheusSchedulerMetricsService } from '../scheduler-metrics.service'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; vi.mock('prom-client'); diff --git a/packages/cli/src/metrics/prometheus/__tests__/system-task-metrics.service.test.ts b/packages/cli/src/metrics/prometheus/__tests__/system-task-metrics.service.test.ts index 16a03166540..a1346a7d6db 100644 --- a/packages/cli/src/metrics/prometheus/__tests__/system-task-metrics.service.test.ts +++ b/packages/cli/src/metrics/prometheus/__tests__/system-task-metrics.service.test.ts @@ -1,11 +1,10 @@ +import type { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { PrometheusMetricsConfig } from '@n8n/config'; import promClient from 'prom-client'; import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; - import { LAG_BUCKETS_SECONDS } from '../constant'; import { PrometheusSystemTaskMetricsService } from '../system-task-metrics.service'; diff --git a/packages/cli/src/metrics/prometheus/__tests__/token-exchange-metrics.service.test.ts b/packages/cli/src/metrics/prometheus/__tests__/token-exchange-metrics.service.test.ts index 23fadd9c899..f0f5353abc1 100644 --- a/packages/cli/src/metrics/prometheus/__tests__/token-exchange-metrics.service.test.ts +++ b/packages/cli/src/metrics/prometheus/__tests__/token-exchange-metrics.service.test.ts @@ -1,3 +1,4 @@ +import type { EventService } from '@n8n/backend-services'; import type { Mock } from 'vitest'; import { mockInstance } from '@n8n/backend-test-utils'; import { PrometheusMetricsConfig } from '@n8n/config'; @@ -6,8 +7,6 @@ import promClient from 'prom-client'; import { PrometheusTokenExchangeMetricsService } from '../token-exchange-metrics.service'; -import type { EventService } from '@/events/event.service'; - vi.mock('prom-client'); describe('PrometheusTokenExchangeMetricsService', () => { diff --git a/packages/cli/src/metrics/prometheus/__tests__/workflow-execution-duration-metrics.service.test.ts b/packages/cli/src/metrics/prometheus/__tests__/workflow-execution-duration-metrics.service.test.ts index 4b69a57c4ae..1e827db8915 100644 --- a/packages/cli/src/metrics/prometheus/__tests__/workflow-execution-duration-metrics.service.test.ts +++ b/packages/cli/src/metrics/prometheus/__tests__/workflow-execution-duration-metrics.service.test.ts @@ -1,11 +1,10 @@ +import type { EventService } from '@n8n/backend-services'; import type { Mock } from 'vitest'; import { mockInstance } from '@n8n/backend-test-utils'; import { PrometheusMetricsConfig } from '@n8n/config'; import { mock } from 'vitest-mock-extended'; import promClient from 'prom-client'; -import type { EventService } from '@/events/event.service'; - import { DURATION_BUCKETS_SECONDS } from '../constant'; import { PrometheusWorkflowExecutionDurationMetricsService } from '../workflow-execution-duration-metrics.service'; diff --git a/packages/cli/src/metrics/prometheus/__tests__/workflow-info-metrics.service.test.ts b/packages/cli/src/metrics/prometheus/__tests__/workflow-info-metrics.service.test.ts index 321e743048a..d27c013fde8 100644 --- a/packages/cli/src/metrics/prometheus/__tests__/workflow-info-metrics.service.test.ts +++ b/packages/cli/src/metrics/prometheus/__tests__/workflow-info-metrics.service.test.ts @@ -5,7 +5,7 @@ import type { InstanceSettings } from 'n8n-core'; import promClient from 'prom-client'; import { mock } from 'vitest-mock-extended'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { PrometheusWorkflowInfoMetricsService } from '../workflow-info-metrics.service'; diff --git a/packages/cli/src/metrics/prometheus/__tests__/workflow-publication-metrics.service.test.ts b/packages/cli/src/metrics/prometheus/__tests__/workflow-publication-metrics.service.test.ts index df07b2f6407..147f5ead723 100644 --- a/packages/cli/src/metrics/prometheus/__tests__/workflow-publication-metrics.service.test.ts +++ b/packages/cli/src/metrics/prometheus/__tests__/workflow-publication-metrics.service.test.ts @@ -9,8 +9,7 @@ import promClient from 'prom-client'; import { PrometheusWorkflowPublicationMetricsService } from '../workflow-publication-metrics.service'; -import type { EventService } from '@/events/event.service'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService, EventService } from '@n8n/backend-services'; vi.mock('prom-client'); diff --git a/packages/cli/src/metrics/prometheus/__tests__/workflow-statistics-metrics.service.test.ts b/packages/cli/src/metrics/prometheus/__tests__/workflow-statistics-metrics.service.test.ts index 0937115516a..886a2ae9a49 100644 --- a/packages/cli/src/metrics/prometheus/__tests__/workflow-statistics-metrics.service.test.ts +++ b/packages/cli/src/metrics/prometheus/__tests__/workflow-statistics-metrics.service.test.ts @@ -4,7 +4,7 @@ import type { LicenseMetricsRepository } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; import promClient from 'prom-client'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { PrometheusWorkflowStatisticsMetricsService } from '../workflow-statistics-metrics.service'; diff --git a/packages/cli/src/metrics/prometheus/active-workflow-metrics.service.ts b/packages/cli/src/metrics/prometheus/active-workflow-metrics.service.ts index 19c9fa83af2..39a516df496 100644 --- a/packages/cli/src/metrics/prometheus/active-workflow-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/active-workflow-metrics.service.ts @@ -4,7 +4,7 @@ import { WorkflowRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import promClient from 'prom-client'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import type { PrometheusMetricsCollector } from './base'; import { CachedMetricQuery } from './cached-metric-query'; diff --git a/packages/cli/src/metrics/prometheus/cache-metrics.service.ts b/packages/cli/src/metrics/prometheus/cache-metrics.service.ts index c7191a5034f..09abcdd52a5 100644 --- a/packages/cli/src/metrics/prometheus/cache-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/cache-metrics.service.ts @@ -2,7 +2,7 @@ import { PrometheusMetricsConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import promClient from 'prom-client'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import type { PrometheusMetricsCollector } from './base'; diff --git a/packages/cli/src/metrics/prometheus/cached-metric-query.ts b/packages/cli/src/metrics/prometheus/cached-metric-query.ts index 78192018df1..4734054aa86 100644 --- a/packages/cli/src/metrics/prometheus/cached-metric-query.ts +++ b/packages/cli/src/metrics/prometheus/cached-metric-query.ts @@ -1,6 +1,6 @@ import type { JsonValue } from 'n8n-workflow'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; type CachedMetricQueryOpts = { cacheService: CacheService; diff --git a/packages/cli/src/metrics/prometheus/execution-data-metrics.service.ts b/packages/cli/src/metrics/prometheus/execution-data-metrics.service.ts index c574628e0d7..d7fd8402efd 100644 --- a/packages/cli/src/metrics/prometheus/execution-data-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/execution-data-metrics.service.ts @@ -1,10 +1,9 @@ +import { EventService } from '@n8n/backend-services'; import { PrometheusMetricsConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import { StorageConfig } from 'n8n-core'; import promClient from 'prom-client'; -import { EventService } from '@/events/event.service'; - import type { PrometheusMetricsCollector } from './base'; import { DURATION_BUCKETS_SECONDS, SIZE_BUCKETS_BYTES } from './constant'; diff --git a/packages/cli/src/metrics/prometheus/instance-ai-metrics.service.ts b/packages/cli/src/metrics/prometheus/instance-ai-metrics.service.ts index 170eda0c018..927ba311bca 100644 --- a/packages/cli/src/metrics/prometheus/instance-ai-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/instance-ai-metrics.service.ts @@ -1,8 +1,8 @@ +import { EventService } from '@n8n/backend-services'; import { PrometheusMetricsConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import promClient from 'prom-client'; -import { EventService } from '@/events/event.service'; import { InstanceAiRunProbe } from '@/modules/instance-ai/instance-ai-run-probe'; import type { PrometheusMetricsCollector } from './base'; diff --git a/packages/cli/src/metrics/prometheus/mcp-post-save-metrics.service.ts b/packages/cli/src/metrics/prometheus/mcp-post-save-metrics.service.ts index 62cfddd35ba..1244a6cf716 100644 --- a/packages/cli/src/metrics/prometheus/mcp-post-save-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/mcp-post-save-metrics.service.ts @@ -1,9 +1,8 @@ +import { EventService } from '@n8n/backend-services'; import { PrometheusMetricsConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import promClient from 'prom-client'; -import { EventService } from '@/events/event.service'; - import type { PrometheusMetricsCollector } from './base'; @Service() diff --git a/packages/cli/src/metrics/prometheus/poll-trigger-metrics.service.ts b/packages/cli/src/metrics/prometheus/poll-trigger-metrics.service.ts index 9dc2ab2a217..f6b305ddf20 100644 --- a/packages/cli/src/metrics/prometheus/poll-trigger-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/poll-trigger-metrics.service.ts @@ -1,10 +1,9 @@ +import { EventService } from '@n8n/backend-services'; import { PrometheusMetricsConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import { InstanceSettings, TriggersAndPollers } from 'n8n-core'; import promClient from 'prom-client'; -import { EventService } from '@/events/event.service'; - import type { PrometheusMetricsCollector } from './base'; import { DURATION_BUCKETS_SECONDS } from './constant'; diff --git a/packages/cli/src/metrics/prometheus/queue-metrics.service.ts b/packages/cli/src/metrics/prometheus/queue-metrics.service.ts index a00e6e3ce3c..fea74202d5b 100644 --- a/packages/cli/src/metrics/prometheus/queue-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/queue-metrics.service.ts @@ -1,10 +1,9 @@ +import { EventService } from '@n8n/backend-services'; import { ExecutionsConfig, PrometheusMetricsConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; import promClient from 'prom-client'; -import { EventService } from '@/events/event.service'; - import type { PrometheusMetricsCollector } from './base'; /** diff --git a/packages/cli/src/metrics/prometheus/scheduler-metrics.service.ts b/packages/cli/src/metrics/prometheus/scheduler-metrics.service.ts index 35274cd3846..0de192d578d 100644 --- a/packages/cli/src/metrics/prometheus/scheduler-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/scheduler-metrics.service.ts @@ -6,7 +6,7 @@ import type { MisfireCount, SchedulerMetrics } from '@n8n/scheduler'; import { InstanceSettings } from 'n8n-core'; import promClient from 'prom-client'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import type { PrometheusMetricsCollector } from './base'; import { CachedMetricQuery } from './cached-metric-query'; 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 5f8445ec167..165e1b51293 100644 --- a/packages/cli/src/metrics/prometheus/system-task-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/system-task-metrics.service.ts @@ -1,9 +1,9 @@ +import { EventService } from '@n8n/backend-services'; import { PrometheusMetricsConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { Service } from '@n8n/di'; import promClient from 'prom-client'; -import { EventService } from '@/events/event.service'; import type { SystemTaskMode } from '@/events/maps/system-task-metrics.event-map'; import type { PrometheusMetricsCollector } from './base'; diff --git a/packages/cli/src/metrics/prometheus/token-exchange-metrics.service.ts b/packages/cli/src/metrics/prometheus/token-exchange-metrics.service.ts index 8f10eaeadc0..3dc0d07d78f 100644 --- a/packages/cli/src/metrics/prometheus/token-exchange-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/token-exchange-metrics.service.ts @@ -1,9 +1,8 @@ +import { EventService } from '@n8n/backend-services'; import { PrometheusMetricsConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import promClient from 'prom-client'; -import { EventService } from '@/events/event.service'; - import type { PrometheusMetricsCollector } from './base'; /** diff --git a/packages/cli/src/metrics/prometheus/workflow-execution-duration-metrics.service.ts b/packages/cli/src/metrics/prometheus/workflow-execution-duration-metrics.service.ts index 323713e8d8c..d4c4a1e1d7f 100644 --- a/packages/cli/src/metrics/prometheus/workflow-execution-duration-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/workflow-execution-duration-metrics.service.ts @@ -1,9 +1,8 @@ +import { EventService } from '@n8n/backend-services'; import { PrometheusMetricsConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import promClient from 'prom-client'; -import { EventService } from '@/events/event.service'; - import type { PrometheusMetricsCollector } from './base'; import { DURATION_BUCKETS_SECONDS } from './constant'; diff --git a/packages/cli/src/metrics/prometheus/workflow-info-metrics.service.ts b/packages/cli/src/metrics/prometheus/workflow-info-metrics.service.ts index 5f07788507f..b236505443c 100644 --- a/packages/cli/src/metrics/prometheus/workflow-info-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/workflow-info-metrics.service.ts @@ -5,7 +5,7 @@ import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; import promClient from 'prom-client'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import type { PrometheusMetricsCollector } from './base'; import { CachedMetricQuery } from './cached-metric-query'; diff --git a/packages/cli/src/metrics/prometheus/workflow-publication-metrics.service.ts b/packages/cli/src/metrics/prometheus/workflow-publication-metrics.service.ts index 9adf9eec5a7..c640f5908b2 100644 --- a/packages/cli/src/metrics/prometheus/workflow-publication-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/workflow-publication-metrics.service.ts @@ -5,8 +5,7 @@ import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; import promClient from 'prom-client'; -import { EventService } from '@/events/event.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService, EventService } from '@n8n/backend-services'; import type { PrometheusMetricsCollector } from './base'; import { CachedMetricQuery } from './cached-metric-query'; diff --git a/packages/cli/src/metrics/prometheus/workflow-statistics-metrics.service.ts b/packages/cli/src/metrics/prometheus/workflow-statistics-metrics.service.ts index a1f72f7468a..6a41f0ad43d 100644 --- a/packages/cli/src/metrics/prometheus/workflow-statistics-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/workflow-statistics-metrics.service.ts @@ -4,7 +4,7 @@ import { LicenseMetricsRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import promClient from 'prom-client'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import type { PrometheusMetricsCollector } from './base'; import { CachedMetricQuery } from './cached-metric-query'; diff --git a/packages/cli/src/mfa/__tests__/mfa.service.test.ts b/packages/cli/src/mfa/__tests__/mfa.service.test.ts index f7039685c67..12ff73620af 100644 --- a/packages/cli/src/mfa/__tests__/mfa.service.test.ts +++ b/packages/cli/src/mfa/__tests__/mfa.service.test.ts @@ -5,7 +5,7 @@ import type { Cipher } from 'n8n-core'; import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { MFA_ENFORCE_SETTING } from '../constants'; import { MFA_CACHE_KEY, MfaService } from '../mfa.service'; diff --git a/packages/cli/src/mfa/mfa.service.ts b/packages/cli/src/mfa/mfa.service.ts index e1d085d6aee..d00e9a17804 100644 --- a/packages/cli/src/mfa/mfa.service.ts +++ b/packages/cli/src/mfa/mfa.service.ts @@ -6,7 +6,7 @@ import { v4 as uuid } from 'uuid'; import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error'; import { InvalidMfaRecoveryCodeError } from '@/errors/response-errors/invalid-mfa-recovery-code-error'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { MFA_ENFORCE_SETTING } from './constants'; import { TOTPService } from './totp.service'; diff --git a/packages/cli/src/middlewares/__tests__/body-parser.test.ts b/packages/cli/src/middlewares/__tests__/body-parser.test.ts index 510f47e79b5..321508eadae 100644 --- a/packages/cli/src/middlewares/__tests__/body-parser.test.ts +++ b/packages/cli/src/middlewares/__tests__/body-parser.test.ts @@ -2,7 +2,7 @@ import type { Request, Response } from 'express'; import { mock } from 'vitest-mock-extended'; import { Readable } from 'stream'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { rawBodyReader } from '../body-parser'; diff --git a/packages/cli/src/middlewares/body-parser.ts b/packages/cli/src/middlewares/body-parser.ts index f5b9844faf4..609c54b18a2 100644 --- a/packages/cli/src/middlewares/body-parser.ts +++ b/packages/cli/src/middlewares/body-parser.ts @@ -9,8 +9,7 @@ import { type Readable } from 'stream'; import { Parser as XmlParser } from 'xml2js'; import { createGunzip, createInflate } from 'zlib'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { BadRequestError, UnprocessableRequestError } from '@n8n/errors'; const xmlParser = new XmlParser({ async: true, diff --git a/packages/cli/src/modules/agent-evals/__tests__/agent-eval-case-generation.service.test.ts b/packages/cli/src/modules/agent-evals/__tests__/agent-eval-case-generation.service.test.ts index 7f10ff1ab0f..952e65c1159 100644 --- a/packages/cli/src/modules/agent-evals/__tests__/agent-eval-case-generation.service.test.ts +++ b/packages/cli/src/modules/agent-evals/__tests__/agent-eval-case-generation.service.test.ts @@ -5,8 +5,7 @@ import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; import type { CredentialsService } from '@/credentials/credentials.service'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import type { InstanceWriteAccessService } from '@/services/instance-write-access.service'; import type { AgentConfigService } from '../../agents/agent-config.service'; diff --git a/packages/cli/src/modules/agent-evals/__tests__/agent-eval-rating.service.test.ts b/packages/cli/src/modules/agent-evals/__tests__/agent-eval-rating.service.test.ts index 2452dca3d4a..ef7ab2bfb09 100644 --- a/packages/cli/src/modules/agent-evals/__tests__/agent-eval-rating.service.test.ts +++ b/packages/cli/src/modules/agent-evals/__tests__/agent-eval-rating.service.test.ts @@ -12,8 +12,7 @@ import type { import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import type { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import { AgentEvalRatingService } from '../agent-eval-rating.service'; diff --git a/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.test.ts b/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.test.ts index 7ae1f3c3375..ea6eb634ed8 100644 --- a/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.test.ts +++ b/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.test.ts @@ -12,7 +12,7 @@ import type { InstanceSettings } from 'n8n-core'; import { mock, type MockProxy } from 'vitest-mock-extended'; import type { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { resolveEvaluationConcurrencyLimit } from '@/evaluation.ee/evaluation-concurrency.helper'; import type { License } from '@/license'; import type { Agent } from '@/modules/agents/entities/agent.entity'; diff --git a/packages/cli/src/modules/agent-evals/__tests__/agent-eval.service.test.ts b/packages/cli/src/modules/agent-evals/__tests__/agent-eval.service.test.ts index 36f2dc2b854..1e785a2bbeb 100644 --- a/packages/cli/src/modules/agent-evals/__tests__/agent-eval.service.test.ts +++ b/packages/cli/src/modules/agent-evals/__tests__/agent-eval.service.test.ts @@ -10,8 +10,7 @@ import type { } from '@n8n/db'; import { mock, type MockProxy } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import type { Agent } from '@/modules/agents/entities/agent.entity'; import type { AgentRepository } from '@/modules/agents/repositories/agent.repository'; diff --git a/packages/cli/src/modules/agent-evals/__tests__/agent-evals.controller.test.ts b/packages/cli/src/modules/agent-evals/__tests__/agent-evals.controller.test.ts index a3623f47225..333dc950584 100644 --- a/packages/cli/src/modules/agent-evals/__tests__/agent-evals.controller.test.ts +++ b/packages/cli/src/modules/agent-evals/__tests__/agent-evals.controller.test.ts @@ -9,8 +9,7 @@ import { ControllerRegistryMetadata } from '@n8n/decorators'; import { Container } from '@n8n/di'; import { mock, type MockProxy } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import type { AgentEvalRatingService } from '../agent-eval-rating.service'; import type { AgentEvalService } from '../agent-eval.service'; diff --git a/packages/cli/src/modules/agent-evals/agent-eval-case-generation.service.ts b/packages/cli/src/modules/agent-evals/agent-eval-case-generation.service.ts index a89bb49ad97..04be40d2097 100644 --- a/packages/cli/src/modules/agent-evals/agent-eval-case-generation.service.ts +++ b/packages/cli/src/modules/agent-evals/agent-eval-case-generation.service.ts @@ -14,7 +14,7 @@ import { Service } from '@n8n/di'; import { OperationalError, UserError } from 'n8n-workflow'; import { CredentialsService } from '@/credentials/credentials.service'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { InstanceWriteAccessService } from '@/services/instance-write-access.service'; import { AgentEvalsFlagGate } from './agent-evals-flag-gate'; diff --git a/packages/cli/src/modules/agent-evals/agent-eval-rating.service.ts b/packages/cli/src/modules/agent-evals/agent-eval-rating.service.ts index d1f6a859cd0..685f390f393 100644 --- a/packages/cli/src/modules/agent-evals/agent-eval-rating.service.ts +++ b/packages/cli/src/modules/agent-evals/agent-eval-rating.service.ts @@ -9,8 +9,7 @@ import { } from '@n8n/db'; import { Service } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import { toRatingRecord } from './agent-eval-record-mappers'; diff --git a/packages/cli/src/modules/agent-evals/agent-eval-runner.service.ts b/packages/cli/src/modules/agent-evals/agent-eval-runner.service.ts index e2a862f5b0c..69ce19a4488 100644 --- a/packages/cli/src/modules/agent-evals/agent-eval-runner.service.ts +++ b/packages/cli/src/modules/agent-evals/agent-eval-runner.service.ts @@ -20,9 +20,7 @@ import { jsonParse, jsonStringify } from 'n8n-workflow'; import pLimit from 'p-limit'; import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; import { resolveEvaluationConcurrencyLimit } from '@/evaluation.ee/evaluation-concurrency.helper'; import { License } from '@/license'; import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; diff --git a/packages/cli/src/modules/agent-evals/agent-eval.service.ts b/packages/cli/src/modules/agent-evals/agent-eval.service.ts index 42a9b93134d..6806c89016d 100644 --- a/packages/cli/src/modules/agent-evals/agent-eval.service.ts +++ b/packages/cli/src/modules/agent-evals/agent-eval.service.ts @@ -19,8 +19,7 @@ import { } from '@n8n/db'; import { Service } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import { AgentEvalCaseGenerationService } from './agent-eval-case-generation.service'; diff --git a/packages/cli/src/modules/agent-evals/agent-evals-flag-gate.ts b/packages/cli/src/modules/agent-evals/agent-evals-flag-gate.ts index 9fb8377b3bb..d702acd388c 100644 --- a/packages/cli/src/modules/agent-evals/agent-evals-flag-gate.ts +++ b/packages/cli/src/modules/agent-evals/agent-evals-flag-gate.ts @@ -2,7 +2,7 @@ import { AGENT_EVALS_FLAG } from '@n8n/api-types'; import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { PostHogClient } from '@/posthog'; /** diff --git a/packages/cli/src/modules/agent-evals/agent-evals-required-modules.ts b/packages/cli/src/modules/agent-evals/agent-evals-required-modules.ts index c1541eec77d..134b0f38730 100644 --- a/packages/cli/src/modules/agent-evals/agent-evals-required-modules.ts +++ b/packages/cli/src/modules/agent-evals/agent-evals-required-modules.ts @@ -1,6 +1,6 @@ import type { ModuleRegistry } from '@n8n/backend-common'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; const REQUIRED_MODULES = ['agents', 'data-table'] as const; diff --git a/packages/cli/src/modules/agent-evals/agent-evals.controller.ts b/packages/cli/src/modules/agent-evals/agent-evals.controller.ts index dab8aed25d0..5ebe5e972fc 100644 --- a/packages/cli/src/modules/agent-evals/agent-evals.controller.ts +++ b/packages/cli/src/modules/agent-evals/agent-evals.controller.ts @@ -26,7 +26,7 @@ import { RestController, } from '@n8n/decorators'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { AgentEvalRatingService } from './agent-eval-rating.service'; import { AgentEvalService } from './agent-eval.service'; diff --git a/packages/cli/src/modules/agents/PREVIEW_CHAT_RECOVERY.md b/packages/cli/src/modules/agents/PREVIEW_CHAT_RECOVERY.md index d165a3fbc70..bfcf99a23d7 100644 --- a/packages/cli/src/modules/agents/PREVIEW_CHAT_RECOVERY.md +++ b/packages/cli/src/modules/agents/PREVIEW_CHAT_RECOVERY.md @@ -17,3 +17,11 @@ Recovery guarantees the latest retained timeline, followed by newer snapshots. U Accepted input survives backend process failure through the durable queue. Interrupted executions are not replayed. Valid approval checkpoints retain their place. The existing Agents enablement controls the feature. This contract does not include token replay, sender deduplication, or queue reordering. Users can edit the text of a pending message. Edits retain its attachments and queue position. The update uses the session lock and fails if the consumer has claimed the item. Queue notifications refresh the edited text across mains. The execution-start event carries the accepted text so waiting streams do not show the original draft. If a message starts during editing, the editor keeps the unsaved text available to copy until the user dismisses it. + +Users can send a queued Preview message to the current execution. `POST /projects/:projectId/agents/v2/:agentId/chat/:threadId/queue/:queueId/steer` takes `{ executionId }`. It requires `agent:execute` and the Preview session owner. The session lock protects reservation, claim, edit, removal, Stop, and completion. A request for an older execution cannot target a newer execution. The queue response includes `steerableExecutionId` and each item's `steeringExecutionId`. + +A reserved message stays in the queue panel until the current model request and tool batch finish. The runtime then waits for the stream consumer to acknowledge an internal boundary. The consumer acknowledges it after preceding output reaches the recorder. This control event does not reach SSE. One transaction links the existing canonical messages to the execution, saves runtime context and message-ID timeline markers, and deletes the queue rows. Original content and attachment references stay unchanged. After the transaction commits, the runtime applies the additional messages before its next model call. Existing execution limits still apply. + +After consumption, `message-steered` carries the queue ID, execution ID, and persisted user message. The active execution stream relays this event across mains. The client removes the row, detaches its waiting stream, and inserts the user message between assistant segments. It keeps the execution and Stop target. Repeated events and late acceptance events cannot restore the row. Reload resolves the input markers through the execution's message links. The existing limit of two waiting streams remains in effect. + +Stop closes steering admission before it signals cancellation. Failure, cancellation, suspension, and interrupted-execution recovery return unconsumed messages to their original FIFO positions. Approval checkpoints still block ordinary processing. A committed input remains in its execution after a crash and is never requeued. Preview continuations use the same boundary. Integrations and sub-agents do not accept steering. diff --git a/packages/cli/src/modules/agents/__tests__/agent-chat-execution.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-chat-execution.service.test.ts index e68ae2013d4..d5953b7d689 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-chat-execution.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-chat-execution.service.test.ts @@ -1,3 +1,4 @@ +import type { AgentMessageSteeringService } from '../agent-message-steering.service'; import type { SerializableAgentState } from '@n8n/agents'; import { LockService } from '@n8n/backend-common'; import { Container } from '@n8n/di'; @@ -5,7 +6,7 @@ import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; import type { InstanceSettings } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { Publisher } from '@/scaling/pubsub/publisher.service'; import { AgentChatExecutionService } from '../agent-chat-execution.service'; @@ -61,6 +62,7 @@ function makeService(isMultiMain = true) { const checkpointStorage = mock(); const publisher = mock(); const updates = mock(); + const steering = mock(); const service = new AgentChatExecutionService( Container.get(LockService), repository, @@ -69,6 +71,7 @@ function makeService(isMultiMain = true) { publisher, mock({ isMultiMain }), updates, + steering, ); executionService.findThreadById.mockResolvedValue(thread); repository.findOneBy.mockImplementation(async (where) => @@ -80,11 +83,31 @@ function makeService(isMultiMain = true) { checkpointStorage.findSuspendedForThread.mockResolvedValue(checkpoint); checkpointStorage.getStatus.mockResolvedValue({ status: 'active', checkpoint }); checkpointStorage.cancelSuspended.mockResolvedValue(true); - return { service, repository, executionService, checkpointStorage, publisher, updates }; + return { service, repository, executionService, checkpointStorage, publisher, updates, steering }; } beforeEach(() => Container.reset()); +it('closes steering admission before signaling Stop', async () => { + const { service, steering } = makeService(); + const controller = new AbortController(); + service.register(context, controller); + const committed = createDeferredPromise(); + steering.close.mockReturnValue(committed.promise); + const stopping = service.requestCancel(context); + await vi.waitFor(() => + expect(steering.close).toHaveBeenCalledWith(context.threadId, context.executionId), + ); + try { + expect(controller.signal.aborted).toBe(false); + } finally { + committed.resolve(); + } + await stopping; + expect(controller.signal.aborted).toBe(true); + await service.settle(context.executionId, async () => {}); +}); + it.each([ { userId: 'other-user' }, { projectId: 'other-project' }, 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 7b870de41ae..8f554293638 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 @@ -7,7 +7,7 @@ import { mock } from 'vitest-mock-extended'; import { FileNotFoundError } from 'n8n-core'; import type { CredentialsService } from '@/credentials/credentials.service'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { AgentChatAttachmentService } from '../agent-chat-attachment.service'; import { AgentChatController } from '../agent-chat.controller'; @@ -76,7 +76,7 @@ function makeController() { const messageQueue = mock(); messageQueue.enqueue.mockImplementation(async (_input, onInserted) => { onInserted?.('queue-1'); - return mock({ id: 'queue-1' }); + return { status: 'accepted', item: mock({ id: 'queue-1' }) }; }); const previewStreams = new AgentQueuedPreviewStreamService( mock(), @@ -174,6 +174,7 @@ describe('AgentChatController route access scopes', () => { ['getQueuedMessages', 'agent:read'], ['removeQueuedMessage', 'agent:execute'], ['updateQueuedMessage', 'agent:execute'], + ['steerQueuedMessage', 'agent:execute'], ['getBackgroundJobs', 'agent:read'], ['getTestChatMessages', 'agent:read'], ['clearTestChatMessages', 'agent:update'], @@ -182,29 +183,35 @@ describe('AgentChatController route access scopes', () => { }); }); -describe('AgentChatController queue editing', () => { - it('reads the message after the request and response arguments supplied by the registry', async () => { - const { controller, agentsService, messageQueue } = makeController(); - agentsService.findById.mockResolvedValue({ id: 'agent-1' } as never); - const params = { - projectId: 'project-1', - agentId: 'agent-1', - threadId: 'thread-1', - queueId: '1', - }; +describe('AgentChatController queue mutations', () => { + it.each([ + ['updateQueuedMessage', 'updatePending', { message: 'Edited message' }], + ['steerQueuedMessage', 'steer', { executionId: 'execution-1' }], + ] as const)( + '%s reads the body after the request and response arguments', + async (handler, operation, payload) => { + const { controller, agentsService, messageQueue } = makeController(); + agentsService.findById.mockResolvedValue({ id: 'agent-1' } as never); + const params = { + projectId: 'project-1', + agentId: 'agent-1', + threadId: 'thread-1', + queueId: '1', + }; - await Reflect.apply(controller.updateQueuedMessage, controller, [ - { params, user: { id: 'user-1' } }, - makeSseResponse([]), - { message: 'Edited message' }, - ]); + await Reflect.apply(controller[handler], controller, [ + { params, user: { id: 'user-1' } }, + makeSseResponse([]), + payload, + ]); - expect(messageQueue.updatePending).toHaveBeenCalledWith({ - ...params, - userId: 'user-1', - message: 'Edited message', - }); - }); + expect(messageQueue[operation]).toHaveBeenCalledWith({ + ...params, + userId: 'user-1', + ...payload, + }); + }, + ); }); describe('AgentChatController background tasks', () => { @@ -779,20 +786,28 @@ describe('AgentChatController attachment cleanup on failed turns', () => { expect(agentChatAttachmentService.deleteByIds).not.toHaveBeenCalled(); }); - it('acknowledges committed acceptance and relays events from the claiming main', async () => { - const { controller, messageQueue, previewStreams } = makeController(); + it('completes a duplicate request without interrupting the accepted stream or its attachments', async () => { + const { controller, messageQueue, previewStreams, agentChatAttachmentService } = + makeController(); const commit = createDeferredPromise(); messageQueue.enqueue.mockImplementationOnce(async (_input, onInserted) => { onInserted?.('queue-1'); await commit.promise; - return mock({ id: 'queue-1' }); + return { status: 'accepted', item: mock({ id: 'queue-1' }) }; }); const { res, events } = makeCleanupSseResponse(); + const payload = { + message: 'hi', + sessionId: 'thread-1', + messageId: 'c4b02d7b-2088-41ce-9c6b-faf8c7b83d8a', + newSession: true as const, + attachments: [textAttachment('notes.txt')], + }; const request = controller.chat( { params: { projectId: 'project-1' }, user: { id: 'user-1' } } as never, res, 'agent-1', - { message: 'hi', sessionId: 'thread-1', newSession: true } as never, + payload, ); await vi.waitFor(() => expect(messageQueue.enqueue).toHaveBeenCalled()); expect(events()).toEqual([]); @@ -804,6 +819,32 @@ describe('AgentChatController attachment cleanup on failed turns', () => { sessionId: 'thread-1', }), ); + messageQueue.enqueue.mockResolvedValueOnce({ status: 'duplicate' }); + agentChatAttachmentService.storeInbound.mockResolvedValueOnce( + mock({ + id: 'duplicate-file', + fileName: 'notes.txt', + mimeType: 'text/plain', + fileSizeBytes: 5, + }), + ); + const duplicate = makeCleanupSseResponse(); + await controller.chat( + { params: { projectId: 'project-1' }, user: { id: 'user-1' } } as never, + duplicate.res, + 'agent-1', + payload, + ); + expect(messageQueue.enqueue.mock.lastCall?.[0].payload).toMatchObject({ + messageId: payload.messageId, + userId: 'user-1', + }); + expect(duplicate.events()).toEqual([{ type: 'done' }]); + expect(duplicate.res.end).toHaveBeenCalledOnce(); + expect(res.end).not.toHaveBeenCalled(); + expect(agentChatAttachmentService.deleteByIds).toHaveBeenCalledExactlyOnceWith([ + 'duplicate-file', + ]); const started = { type: 'execution-started' as const, executionId: 'exec-1', @@ -885,10 +926,12 @@ describe('AgentChatController attachment cleanup on failed turns', () => { async (newSession) => { const { controller, + messageQueue, agentExecutionService, agentChatAttachmentService, agentExecutionOrchestratorService, } = makeController(); + messageQueue.enqueue.mockResolvedValue({ status: 'duplicate' }); agentExecutionService.canUseDraftThread.mockImplementation( async (_threadId, _projectId, _agentId, _userId, options) => !options?.previewChat, ); @@ -899,6 +942,7 @@ describe('AgentChatController attachment cleanup on failed turns', () => { 'agent-1', { sessionId: 'thread-1', + messageId: 'c4b02d7b-2088-41ce-9c6b-faf8c7b83d8a', newSession, message: 'hi', attachments: [textAttachment('notes.txt')], @@ -906,6 +950,7 @@ describe('AgentChatController attachment cleanup on failed turns', () => { ); expect(events()).toContainEqual(expect.objectContaining({ type: 'error' })); expect(agentChatAttachmentService.storeInbound).not.toHaveBeenCalled(); + expect(messageQueue.enqueue).not.toHaveBeenCalled(); expect(agentExecutionOrchestratorService.executeForChat).not.toHaveBeenCalled(); }, ); 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 5d373836940..9fedcf42c2f 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 @@ -1,3 +1,4 @@ +import type { EventService } from '@n8n/backend-services'; import type { Mocked } from 'vitest'; import { DEFAULT_AGENT_PERSONALISATION, type AgentJsonConfig } from '@n8n/api-types'; import { mockLogger } from '@n8n/backend-test-utils'; @@ -6,7 +7,6 @@ import { TELEMETRY_EVENT } from '@n8n/telemetry'; import { mock } from 'vitest-mock-extended'; import type { CredentialsService } from '@/credentials/credentials.service'; -import type { EventService } from '@/events/event.service'; import type { Telemetry } from '@/telemetry'; 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 9d24f7aaa6b..f80a673bd38 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 @@ -3,7 +3,7 @@ import { mockLogger } from '@n8n/backend-test-utils'; import { mock } from 'vitest-mock-extended'; import { UserError } from 'n8n-workflow'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { AgentModificationTelemetryService } from '../agent-modification-telemetry.service'; import type { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; diff --git a/packages/cli/src/modules/agents/__tests__/agent-dependency-index.listener.test.ts b/packages/cli/src/modules/agents/__tests__/agent-dependency-index.listener.test.ts index 7b1b954979d..6312eb9b277 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-dependency-index.listener.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-dependency-index.listener.test.ts @@ -1,10 +1,9 @@ /* eslint-disable @typescript-eslint/unbound-method */ import type { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import type { WorkflowsConfig } from '@n8n/config'; import { mock } from 'vitest-mock-extended'; -import { EventService } from '@/events/event.service'; - import { AgentDependencyIndexListener } from '../agent-dependency-index.listener'; import { AgentDependencyIndexService } from '../agent-dependency-index.service'; import type { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts index 36875f44080..c239e088c63 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts @@ -1,3 +1,4 @@ +import type { AgentMessageSteeringService } from '../agent-message-steering.service'; import type { Agent as RuntimeAgent, CredentialProvider, @@ -41,6 +42,7 @@ import type { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; import { AgentExecutionRecordingError } from '../agent-execution-recording.error'; import { AgentTestRunService } from '../agent-test-run.service'; import { AgentTurnExecutionService } from '../agent-turn-execution.service'; +import type { AgentExecutionStreamChunk } from '../types/agent-steering'; import type { AgentValidationService } from '../agent-validation.service'; import type { Agent } from '../entities/agent.entity'; import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity'; @@ -187,6 +189,7 @@ function makeService(sandboxEnabled = false) { mock(), mock(), mock(), + mock(), ); const telemetry = mock(); const runtimeCacheService = mock(); @@ -244,6 +247,7 @@ function makeService(sandboxEnabled = false) { executionService, chatExecutionService, mock(), + mock(), ), telemetry, runtimeCacheService, @@ -275,8 +279,8 @@ function makeService(sandboxEnabled = false) { }; } -async function collect(generator: AsyncGenerator) { - const chunks: StreamChunk[] = []; +async function collect(generator: AsyncGenerator) { + const chunks: AgentExecutionStreamChunk[] = []; for await (const chunk of generator) chunks.push(chunk); return chunks; } @@ -1490,7 +1494,8 @@ describe('AgentExecutionOrchestratorService', () => { } = makeService(); agentRepository.isN8nChatPublished.mockResolvedValue(true); executionService.canUseProductionChatThread.mockResolvedValue(true); - runtimeCacheService.getRuntime.mockResolvedValue(makeRuntime()); + const runtime = makeRuntime(); + runtimeCacheService.getRuntime.mockResolvedValue(runtime); await collect( service.executeForN8nChatPublished({ @@ -1510,6 +1515,7 @@ describe('AgentExecutionOrchestratorService', () => { attributionUserId: user.id, }), ); + expect(runtime.agent.stream.mock.calls[0][1]?.onInputBoundary).toBeUndefined(); expect(integrationMessageContextService.setLatest).toHaveBeenCalledWith( 'thread-1', 'n8n-chat-production:user-1', diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution.repository.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution.repository.test.ts index 3409a43c27b..5e6c4bc2a95 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution.repository.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution.repository.test.ts @@ -39,4 +39,126 @@ describe('AgentExecutionRepository', () => { expect(result).toBeNull(); }); }); + + describe('incrementCost', () => { + it('is a no-op for zero or negative cost and never issues an UPDATE', async () => { + await repository.incrementCost('execution-1', 0); + await repository.incrementCost('execution-1', -1); + + // incrementCost builds the query from the context manager + // (`managerFor(ctx).createQueryBuilder()`), so the no-op guard + // must keep it from reaching the entity manager's query builder. + expect(entityManager.createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('issues an atomic cost increment UPDATE for a positive cost', async () => { + const execute = vi.fn().mockResolvedValue(undefined); + const qb = { + update: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + setParameters: vi.fn().mockReturnThis(), + execute, + }; + entityManager.createQueryBuilder.mockReturnValue(qb as never); + + await repository.incrementCost('execution-1', 0.00125); + + expect(qb.update).toHaveBeenCalledWith(AgentExecution); + expect(qb.set).toHaveBeenCalledTimes(1); + // The cost value is a SQL-fragment function; invoke it to verify the + // accumulating expression. `COALESCE` keeps a nullable `cost` from + // collapsing to `NULL + :cost = NULL` when no main-turn usage is priced. + const setArg = qb.set.mock.calls[0][0] as { cost: () => string }; + expect(typeof setArg.cost).toBe('function'); + expect(setArg.cost()).toBe('COALESCE(cost, 0) + :cost'); + expect(qb.where).toHaveBeenCalledWith('id = :executionId', { executionId: 'execution-1' }); + expect(qb.setParameters).toHaveBeenCalledWith({ cost: 0.00125 }); + expect(execute).toHaveBeenCalledTimes(1); + }); + }); + + describe('updateIfRunning', () => { + it('folds a positive costIncrement into the terminal UPDATE as an additive fragment', async () => { + const execute = vi.fn().mockResolvedValue({ affected: 1 }); + const qb = { + update: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + andWhere: vi.fn().mockReturnThis(), + setParameters: vi.fn().mockReturnThis(), + execute, + }; + entityManager.createQueryBuilder.mockReturnValue(qb as never); + + const result = await repository.updateIfRunning( + 'execution-1', + { + status: 'success', + stoppedAt: new Date('2026-01-01'), + duration: 1000, + timeline: null, + storedAt: 'db', + error: null, + failureSummary: null, + }, + undefined, + {}, + 0.05, + ); + + expect(result).toBe(true); + expect(qb.update).toHaveBeenCalledWith(AgentExecution); + expect(qb.set).toHaveBeenCalledTimes(1); + // `cost` is a SQL-fragment function so the increment is additive and + // cannot overwrite a side-call `incrementCost` that landed earlier. + const setArg = qb.set.mock.calls[0][0] as { cost: () => string; status: string }; + expect(typeof setArg.cost).toBe('function'); + expect(setArg.cost()).toBe('COALESCE(cost, 0) + :costIncrement'); + expect(qb.where).toHaveBeenCalledWith('id = :executionId', { executionId: 'execution-1' }); + expect(qb.andWhere).toHaveBeenCalledWith('status = :status', { status: 'running' }); + expect(qb.setParameters).toHaveBeenCalledWith({ + executionId: 'execution-1', + status: 'running', + costIncrement: 0.05, + }); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it('omits the cost fragment when no costIncrement is given', async () => { + const execute = vi.fn().mockResolvedValue({ affected: 1 }); + const qb = { + update: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + andWhere: vi.fn().mockReturnThis(), + setParameters: vi.fn().mockReturnThis(), + execute, + }; + entityManager.createQueryBuilder.mockReturnValue(qb as never); + + await repository.updateIfRunning( + 'execution-1', + { + status: 'success', + stoppedAt: new Date('2026-01-01'), + duration: 1000, + timeline: null, + storedAt: 'db', + error: null, + failureSummary: null, + }, + undefined, + {}, + undefined, + ); + + const setArg = qb.set.mock.calls[0][0] as { cost?: () => string }; + expect(setArg.cost).toBeUndefined(); + expect(qb.setParameters).toHaveBeenCalledWith({ + executionId: 'execution-1', + status: 'running', + }); + }); + }); }); 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 9709457fc26..b9233676081 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 @@ -24,6 +24,8 @@ import type { AgentExecutionThreadRepository } from '../repositories/agent-execu import type { AgentExecutionRepository } from '../repositories/agent-execution.repository'; import type { AgentMessageRepository } from '../repositories/agent-message.repository'; import type { AgentMessageQueueRepository } from '../repositories/agent-message-queue.repository'; +import { MARK_SESSION_FAILED_TOOL_NAME } from '../tools/mark-session-failed.tool'; +import { MAX_ITERATIONS_STOPPED_MESSAGE } from '../utils/fatal-session-outcome'; const previewAccess = { accessScope: 'user' as const, ownerId: 'user-1' }; @@ -86,7 +88,7 @@ describe('AgentExecutionService', () => { beforeEach(() => { vi.clearAllMocks(); - messageRepository.createExecutionInput.mockResolvedValue('message-1'); + messageRepository.createInput.mockResolvedValue(mock({ id: 'message-1' })); messageRepository.findExecutionInputs.mockResolvedValue(new Map()); messageRepository.copyExecutionInputs.mockResolvedValue([]); @@ -141,6 +143,180 @@ describe('AgentExecutionService', () => { return await service.finalizeExecution(executionId, params); } + describe('recordSideCallUsage', () => { + it('increments the execution cost and thread totalCost by the report cost in one transaction', async () => { + await service.recordSideCallUsage('execution-1', 'thread-1', { + task: 'title', + model: 'openai/gpt-4o', + cost: 0.00125, + reportId: 'report-1', + }); + + expect(txRunner.run).toHaveBeenCalledTimes(1); + // Both increments run inside the same transaction context. + const txCtx = txRunner.run.mock.calls[0][0]; + expect(agentExecutionRepository.incrementCost).toHaveBeenCalledWith( + 'execution-1', + 0.00125, + txCtx, + ); + expect(agentExecutionThreadRepository.incrementUsage).toHaveBeenCalledWith( + 'thread-1', + 0, + 0, + 0.00125, + 0, + txCtx, + ); + }); + + it('claims the reportId only after the transaction commits', async () => { + // A failed transaction must not claim the reportId, so a later + // replay can reapply both totals atomically instead of being + // suppressed while one total already diverged. + agentExecutionRepository.incrementCost.mockRejectedValueOnce(new Error('db down')); + const report = { task: 'title', model: 'openai/gpt-4o', cost: 0.001, reportId: 'retry-1' }; + + await expect( + service.recordSideCallUsage('execution-1', 'thread-1', report), + ).resolves.toBeUndefined(); + + // Not claimed: the failed transaction is replayable. The replay runs + // both increments again — `incrementCost` is retried (2 calls); the + // first attempt threw before reaching `incrementUsage`, so it runs + // only on the successful replay (1 call). + await service.recordSideCallUsage('execution-1', 'thread-1', report); + expect(agentExecutionRepository.incrementCost).toHaveBeenCalledTimes(2); + expect(agentExecutionThreadRepository.incrementUsage).toHaveBeenCalledTimes(1); + }); + + it('re-applies a sequentially replayed reportId (no process-local dedup)', async () => { + // The SDK calls `onSideCallUsage` once per model call with a fresh + // `reportId`, so a sequential replay is not expected. The in-flight + // map only coalesces concurrent deliveries; once it clears, a later + // delivery with the same id re-applies. + const report = { task: 'observer', model: 'openai/gpt-4o', cost: 0.0007, reportId: 'dup-1' }; + + await service.recordSideCallUsage('execution-1', 'thread-1', report); + await service.recordSideCallUsage('execution-1', 'thread-1', report); + + expect(agentExecutionRepository.incrementCost).toHaveBeenCalledTimes(2); + expect(agentExecutionThreadRepository.incrementUsage).toHaveBeenCalledTimes(2); + }); + + it('coalesces concurrent deliveries of the same reportId onto one transaction', async () => { + // Two deliveries of the same reportId racing past the duplicate + // check must not double-count: the second delivery awaits the + // in-flight attempt instead of starting its own transaction. + let resolveIncrement!: () => void; + agentExecutionRepository.incrementCost.mockImplementationOnce(async () => { + await new Promise((resolve) => { + resolveIncrement = resolve; + }); + }); + const report = { + task: 'observer', + model: 'openai/gpt-4o', + cost: 0.0007, + reportId: 'race-1', + }; + + const a = service.recordSideCallUsage('execution-1', 'thread-1', report); + const b = service.recordSideCallUsage('execution-1', 'thread-1', report); + // The first transaction is in flight; release it so both settle. + resolveIncrement(); + await Promise.all([a, b]); + + expect(txRunner.run).toHaveBeenCalledTimes(1); + expect(agentExecutionRepository.incrementCost).toHaveBeenCalledTimes(1); + expect(agentExecutionThreadRepository.incrementUsage).toHaveBeenCalledTimes(1); + }); + + it('applies two different reportIds separately', async () => { + await service.recordSideCallUsage('execution-1', 'thread-1', { + task: 'title', + model: 'openai/gpt-4o', + cost: 0.001, + reportId: 'report-a', + }); + await service.recordSideCallUsage('execution-1', 'thread-1', { + task: 'observer', + model: 'openai/gpt-4o', + cost: 0.002, + reportId: 'report-b', + }); + + expect(agentExecutionRepository.incrementCost).toHaveBeenCalledTimes(2); + expect(agentExecutionThreadRepository.incrementUsage).toHaveBeenCalledTimes(2); + }); + + it('swallows repository errors so a side-call cost failure never breaks the run', async () => { + agentExecutionRepository.incrementCost.mockRejectedValueOnce(new Error('db down')); + + await expect( + service.recordSideCallUsage('execution-1', 'thread-1', { + task: 'title', + model: 'openai/gpt-4o', + cost: 0.001, + reportId: 'report-err', + }), + ).resolves.toBeUndefined(); + }); + + it('drains pending side-call usage recordings before the terminal write so a crash after finalization does not lose cost', async () => { + // `onSideCallUsage` dispatches `recordSideCallUsage` fire-and-forget + // (`void` in `agent-turn-execution.service.ts`). Without a drain at + // finalization, a side-call `incrementCost` still in flight when the + // process exits after the terminal UPDATE would be lost. The terminal + // write drains this execution's pending recordings first, so the + // side-call cost settles before the row is finalized. + const gate = createDeferredPromise(); + agentExecutionRepository.incrementCost.mockImplementationOnce(async () => { + await gate.promise; + }); + + // Fire-and-forget a side call (do not await), simulating the SDK path. + void service.recordSideCallUsage('execution-1', 'thread-1', { + task: 'title', + model: 'openai/gpt-4o', + cost: 0.001, + reportId: 'drain-1', + }); + + const record = makeMessageRecord({ totalCost: 0.05 }); + // Start finalization (do not await): it blocks on the drain, so the + // terminal write must not have run yet. + const finalize = service + .finalizeExecution('execution-1', { + threadId: 'thread-1', + agentId: 'agent-1', + agentName: 'Agent', + projectId: 'project-1', + userMessage: 'Run', + record, + }) + .catch((error: unknown) => error); + + // `recordSideCallUsage` invokes `incrementCost` synchronously (before + // its first await), so it has already been called. Finalization blocks + // on the drain, so the terminal write has not run yet. + await Promise.resolve(); + expect(agentExecutionRepository.incrementCost).toHaveBeenCalledTimes(1); + expect(agentExecutionRepository.updateIfRunning).not.toHaveBeenCalled(); + + gate.resolve(); + const result = await finalize; + expect(result).toBe('execution-1'); + + // The side-call cost settled before the terminal write committed. + expect(agentExecutionRepository.incrementCost).toHaveBeenCalledTimes(1); + expect(agentExecutionRepository.updateIfRunning).toHaveBeenCalledTimes(1); + expect(agentExecutionRepository.incrementCost.mock.invocationCallOrder[0]).toBeLessThan( + agentExecutionRepository.updateIfRunning.mock.invocationCallOrder[0], + ); + }); + }); + describe('startExecutionRecording', () => { it('stores the signal before publishing the execution update', async () => { agentExecutionThreadRepository.findOrCreate.mockResolvedValue({ @@ -188,6 +364,9 @@ describe('AgentExecutionService', () => { expect(agentExecutionRepository.updateIfRunning).toHaveBeenCalledWith( id, expect.objectContaining({ timeline: initialTimeline }), + undefined, + expect.any(Object), + undefined, ); }); @@ -320,48 +499,69 @@ describe('AgentExecutionService', () => { } }); - it('serializes timeline snapshot updates', async () => { - const params = await startSnapshotExecution(); - let releaseFirstWrite!: () => void; - agentExecutionRepository.updateTimelineIfRunning - .mockImplementationOnce( - async () => - await new Promise((resolve) => { - releaseFirstWrite = () => resolve(true); - }), - ) - .mockResolvedValue(true); - const first: TimelineEvent[] = [{ type: 'text', content: 'First', timestamp: 1 }]; - const second: TimelineEvent[] = [{ type: 'text', content: 'Second', timestamp: 1 }]; + it.each([false, true])( + 'drains paused snapshots after an input check (consumed: %s)', + async (consumed) => { + const params = await startSnapshotExecution(); + let releaseFirstWrite!: () => void; + agentExecutionRepository.updateTimelineIfRunning + .mockImplementationOnce( + async () => + await new Promise((resolve) => { + releaseFirstWrite = () => resolve(true); + }), + ) + .mockResolvedValue(true); + const first: TimelineEvent[] = [{ type: 'text', content: 'First', timestamp: 1 }]; + const second: TimelineEvent[] = [{ type: 'text', content: 'Second', timestamp: 1 }]; + const withInput: TimelineEvent[] = [ + ...second, + { + type: 'input', + timestamp: 2, + messageId: 'input-1', + }, + ]; - service.recordTimelineSnapshot({ - executionId: 'execution-1', - projectId: 'project-1', - agentId: 'agent-1', - threadId: 'thread-1', - timeline: first, - }); - service.recordTimelineSnapshot({ - executionId: 'execution-1', - projectId: 'project-1', - agentId: 'agent-1', - threadId: 'thread-1', - timeline: second, - }); - await vi.waitFor(() => - expect(agentExecutionRepository.updateTimelineIfRunning).toHaveBeenCalledTimes(1), - ); - releaseFirstWrite(); - await vi.waitFor(() => - expect(agentExecutionRepository.updateTimelineIfRunning).toHaveBeenCalledTimes(2), - ); + service.recordTimelineSnapshot({ + executionId: 'execution-1', + projectId: 'project-1', + agentId: 'agent-1', + threadId: 'thread-1', + timeline: first, + }); + service.recordTimelineSnapshot({ + executionId: 'execution-1', + projectId: 'project-1', + agentId: 'agent-1', + threadId: 'thread-1', + timeline: second, + }); + await vi.waitFor(() => + expect(agentExecutionRepository.updateTimelineIfRunning).toHaveBeenCalledTimes(1), + ); + const check = service.withTimelineWritesPaused('execution-1', async () => { + if (consumed) { + service.recordTimelineSnapshot({ + ...params, + executionId: 'execution-1', + timeline: withInput, + }); + } + }); + releaseFirstWrite(); + await check; + await vi.waitFor(() => + expect(agentExecutionRepository.updateTimelineIfRunning).toHaveBeenCalledTimes(2), + ); - expect(agentExecutionRepository.updateTimelineIfRunning).toHaveBeenLastCalledWith( - 'execution-1', - second, - ); - await service.finalizeExecution('execution-1', { ...params, record: makeMessageRecord() }); - }); + expect(agentExecutionRepository.updateTimelineIfRunning).toHaveBeenLastCalledWith( + 'execution-1', + consumed ? withInput : second, + ); + await service.finalizeExecution('execution-1', { ...params, record: makeMessageRecord() }); + }, + ); it('notifies only after a timeline snapshot retry persists', async () => { vi.useFakeTimers(); @@ -462,7 +662,10 @@ describe('AgentExecutionService', () => { .catch((error: unknown) => error); expect(agentExecutionRepository.updateIfRunning).not.toHaveBeenCalled(); if (snapshotState === 'in flight') snapshot.reject(new Error('snapshot unavailable')); - await vi.advanceTimersByTimeAsync(1_000); + // The snapshot retry timer fires at 1s; `writeTerminalExecution` + // then runs its bounded retry (100ms + 200ms backoff) before + // rethrowing `cause`, so advance past all of it. + await vi.advanceTimersByTimeAsync(2_000); expect(await finished).toBe(cause); service.recordTimelineSnapshot(update); await vi.advanceTimersByTimeAsync(180_000); @@ -506,6 +709,9 @@ describe('AgentExecutionService', () => { expect(agentExecutionRepository.updateIfRunning).toHaveBeenCalledWith( 'execution-1', expect.objectContaining({ status: 'success', totalTokens: 5, model: 'mock' }), + undefined, + expect.any(Object), + undefined, ); expect(executionUpdateBroadcaster.notify).toHaveBeenCalledOnce(); }); @@ -566,6 +772,7 @@ describe('AgentExecutionService', () => { expect(agentExecutionRepository.updateIfRunning).toHaveBeenCalledWith( 'execution-1', expect.objectContaining({ + status: 'success', timeline: record.timeline, storedAt: 'db', failureSummary: { @@ -578,6 +785,9 @@ describe('AgentExecutionService', () => { }, }, }), + undefined, + expect.any(Object), + undefined, ); expect(agentExecutionRepository.moveTimelineToBlob).toHaveBeenCalledWith('execution-1', 'fs'); expect(agentExecutionRepository.updateIfRunning.mock.invocationCallOrder[0]).toBeLessThan( @@ -671,6 +881,9 @@ describe('AgentExecutionService', () => { storedAt: 'db', failureSummary: null, }), + undefined, + expect.any(Object), + undefined, ); expect(errorReporter.error).toHaveBeenCalledWith(error); if (shouldDeleteBlob) { @@ -984,13 +1197,7 @@ describe('AgentExecutionService', () => { ); }); - it.each([ - { name: 'suspended turn', record: makeMessageRecord(), hitlStatus: 'suspended' as const }, - { - name: 'max-iterations turn', - record: makeMessageRecord({ finishReason: 'max-iterations' }), - }, - ])('tracks $name without an error as succeeded', async ({ record, hitlStatus }) => { + it('tracks a suspended turn without an error as succeeded', async () => { agentExecutionThreadRepository.findOrCreate.mockResolvedValue({ thread: makeThread(), created: false, @@ -1006,8 +1213,8 @@ describe('AgentExecutionService', () => { agentName: 'Agent', projectId: 'project-1', userMessage: 'Run', - record, - ...(hitlStatus ? { hitlStatus } : {}), + record: makeMessageRecord(), + hitlStatus: 'suspended', telemetry: { runType: 'test', configuration: { @@ -1027,6 +1234,53 @@ describe('AgentExecutionService', () => { }), ); }); + + it('tracks a max-iterations turn as failed', async () => { + agentExecutionThreadRepository.findOrCreate.mockResolvedValue({ + thread: makeThread(), + created: false, + }); + agentExecutionRepository.create.mockImplementation((data) => data as AgentExecution); + agentExecutionRepository.saveInContext.mockResolvedValue({ + id: 'execution-1', + } as AgentExecution); + + await recordExecution({ + threadId: 'thread-1', + agentId: 'agent-1', + agentName: 'Agent', + projectId: 'project-1', + userMessage: 'Run', + record: makeMessageRecord({ finishReason: 'max-iterations' }), + telemetry: { + runType: 'test', + configuration: { + model: null, + channels: [], + tool_types: [], + tool_count: 0, + num_skills: 0, + memory_type: 'none', + }, + }, + }); + + expect(agentExecutionRepository.updateIfRunning).toHaveBeenCalledWith( + 'execution-1', + expect.objectContaining({ + status: 'error', + error: MAX_ITERATIONS_STOPPED_MESSAGE, + }), + undefined, + expect.any(Object), + undefined, + ); + expect(telemetry.trackAgentTurnFinished).toHaveBeenCalledWith( + expect.objectContaining({ + turn_status: 'failed', + }), + ); + }); }); describe('finalizeExecution', () => { @@ -1065,12 +1319,113 @@ describe('AgentExecutionService', () => { storedAt: 'db', failureSummary: null, }), + undefined, + expect.any(Object), + undefined, ); expect(telemetry.trackAgentTurnFinished).toHaveBeenCalledWith( expect.objectContaining({ turn_status: 'failed' }), ); }); + it('applies the terminal main-loop cost additively so in-flight side-call increments survive', async () => { + // A side-call `incrementCost` that lands before the terminal write must + // not be overwritten by `cost = record.totalCost`. The terminal write + // therefore folds the main-loop cost into `updateIfRunning` as an + // additive `costIncrement` (`COALESCE(cost, 0) + :costIncrement`) on the + // same column the side calls increment, so the two never race and a + // failure leaves nothing half-applied. + const record = makeMessageRecord({ totalCost: 0.05 }); + agentExecutionRepository.updateIfRunning.mockResolvedValue(true); + + await service.finalizeExecution('execution-1', { + threadId: 'thread-1', + agentId: 'agent-1', + agentName: 'Agent', + projectId: 'project-1', + userMessage: 'Run', + record, + }); + + const lastCall = agentExecutionRepository.updateIfRunning.mock.calls.at(-1)!; + const [, terminalPayload, , , costIncrement] = lastCall; + expect(terminalPayload).not.toHaveProperty('cost'); + expect(costIncrement).toBe(0.05); + // The cost is folded into the terminal UPDATE, so no separate + // `incrementCost` call is issued for the main loop. + expect(agentExecutionRepository.incrementCost).not.toHaveBeenCalled(); + }); + + it('does not issue a cost increment when the terminal run has no priced usage', async () => { + const record = makeMessageRecord({ totalCost: null }); + agentExecutionRepository.updateIfRunning.mockResolvedValue(true); + + await service.finalizeExecution('execution-1', { + threadId: 'thread-1', + agentId: 'agent-1', + agentName: 'Agent', + projectId: 'project-1', + userMessage: 'Run', + record, + }); + + const lastCall = agentExecutionRepository.updateIfRunning.mock.calls.at(-1)!; + const [, , , , costIncrement] = lastCall; + expect(costIncrement).toBeUndefined(); + expect(agentExecutionRepository.incrementCost).not.toHaveBeenCalled(); + }); + + it('retries the terminal write when it fails transiently so the row is finalized with cost', async () => { + // The terminal status write and the main-loop cost increment run as one + // atomic UPDATE. A transient failure (DB blip, deadlock) leaves the row + // `running` because nothing committed. The heartbeat is already stopped, + // so without a retry the row would stay `running` until the sweeper + // marks it `interrupted` and the main-loop cost would be lost. + // `writeTerminalExecution` retries a bounded number of times with linear + // backoff; the row is only terminal once a retry succeeds. + vi.useFakeTimers(); + try { + const rows = new Map([ + ['execution-1', { status: 'running', cost: 0 }], + ]); + agentExecutionRepository.updateIfRunning.mockImplementation( + async (id, values, _staleBefore, _ctx, costIncrement) => { + const row = rows.get(id); + if (row?.status !== 'running') return false; + row.status = values.status; + if (typeof costIncrement === 'number' && costIncrement > 0) { + row.cost += costIncrement; + } + return true; + }, + ); + // First attempt fails before mutating any state; the retry succeeds. + agentExecutionRepository.updateIfRunning.mockRejectedValueOnce(new Error('db down')); + + const record = makeMessageRecord({ totalCost: 0.05 }); + const finalize = service.finalizeExecution('execution-1', { + threadId: 'thread-1', + agentId: 'agent-1', + agentName: 'Agent', + projectId: 'project-1', + userMessage: 'Run', + record, + }); + await vi.advanceTimersByTimeAsync(1000); + await finalize; + + // After the failed attempt the row is still `running` with no cost + // (the throw happened before any state mutation). After the retry + // the row is `success` with the main-loop cost applied. + expect(rows.get('execution-1')).toEqual({ status: 'success', cost: 0.05 }); + expect(agentExecutionRepository.updateIfRunning).toHaveBeenCalledTimes(2); + expect(agentExecutionRepository.incrementCost).not.toHaveBeenCalled(); + expect(executionUpdateBroadcaster.notify).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + it('preserves an interrupted execution inline without overwriting blob storage', async () => { storageConfig = mock({ modeTag: 'fs' }); service = new AgentExecutionService( @@ -1134,14 +1489,62 @@ describe('AgentExecutionService', () => { ); expect(agentExecutionLogStore.write).not.toHaveBeenCalled(); }); + + it('persists a successful mark_session_failed call as an execution error', async () => { + const record = makeMessageRecord({ + timeline: [ + { + type: 'tool-call', + kind: 'tool', + name: MARK_SESSION_FAILED_TOOL_NAME, + toolCallId: 'tc-fail', + input: { reason: 'Could not recover' }, + output: { marked: true }, + startTime: 0, + endTime: 1, + success: true, + }, + ], + }); + + await service.finalizeExecution('execution-1', { + threadId: 'thread-1', + agentId: 'agent-1', + agentName: 'Agent', + projectId: 'project-1', + userMessage: 'Run', + record, + }); + + expect(agentExecutionRepository.updateIfRunning).toHaveBeenCalledWith( + 'execution-1', + expect.objectContaining({ + status: 'error', + error: 'Could not recover', + failureSummary: { + count: 1, + latest: { + kind: 'execution', + name: null, + message: 'Could not recover', + occurredAt: 1, + }, + }, + }), + undefined, + expect.any(Object), + undefined, + ); + }); }); describe('getThreads', () => { it('returns composite statuses and aggregated failure summaries', async () => { - const failedThread = makeThread({ id: 'thread-failed' }); + const recoveredThread = makeThread({ id: 'thread-recovered' }); const cleanThread = makeThread({ id: 'thread-clean', accessScope: 'project', ownerId: null }); const runningThread = makeThread({ id: 'thread-running', parentThreadId: 'parent' }); const emptyThread = makeThread({ id: 'thread-empty' }); + const errorThread = makeThread({ id: 'thread-error' }); const failureSummary = { count: 2, latest: { @@ -1153,19 +1556,20 @@ describe('AgentExecutionService', () => { }, }; agentExecutionThreadRepository.findByProjectIdPaginated.mockResolvedValue({ - threads: [failedThread, cleanThread, runningThread, emptyThread], + threads: [recoveredThread, cleanThread, runningThread, emptyThread, errorThread], nextCursor: null, }); agentExecutionRepository.findFirstUserMessageByThreadIds.mockResolvedValue(new Map()); agentExecutionRepository.findFirstSourceByThreadIds.mockResolvedValue(new Map()); agentExecutionRepository.findFailureSummariesByThreadIds.mockResolvedValue( - new Map([[failedThread.id, failureSummary]]), + new Map([[recoveredThread.id, failureSummary]]), ); agentExecutionRepository.findLatestStatusesByThreadIds.mockResolvedValue( new Map([ - [failedThread.id, 'success'], + [recoveredThread.id, 'success'], [cleanThread.id, 'success'], [runningThread.id, 'running'], + [errorThread.id, 'error'], ]), ); @@ -1173,9 +1577,9 @@ describe('AgentExecutionService', () => { expect(result.threads).toEqual([ expect.objectContaining({ - id: failedThread.id, + id: recoveredThread.id, failureSummary, - status: 'error', + status: 'succeeded', canContinueInPreview: true, }), expect.objectContaining({ @@ -1191,6 +1595,12 @@ describe('AgentExecutionService', () => { canContinueInPreview: false, }), expect.objectContaining({ id: emptyThread.id, failureSummary: null, status: null }), + expect.objectContaining({ + id: errorThread.id, + failureSummary: null, + status: 'error', + canContinueInPreview: true, + }), ]); }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts index 8a38b097835..17bf3fffe98 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts @@ -5,8 +5,7 @@ import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; import { mock } from 'vitest-mock-extended'; import type { CredentialsService } from '@/credentials/credentials.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { AgentIntegrationManagementService } from '../agent-integration-management.service'; import type { AgentIntegrationPersistenceService } from '../agent-integration-persistence.service'; 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 39a67366737..0180a03e114 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 @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */ import type { AgentIntegrationConfig, AgentJsonConfig } from '@n8n/api-types'; +import type { EventService } from '@n8n/backend-services'; import type { User } from '@n8n/db'; import { TELEMETRY_EVENT } from '@n8n/telemetry'; import { OperationalError, UserError } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; import type { CredentialsService } from '@/credentials/credentials.service'; -import type { EventService } from '@/events/event.service'; import type { Telemetry } from '@/telemetry'; import { AgentIntegrationPersistenceService } from '../agent-integration-persistence.service'; diff --git a/packages/cli/src/modules/agents/__tests__/agent-integrations.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-integrations.controller.test.ts index e0af76b678d..48c86787a15 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-integrations.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-integrations.controller.test.ts @@ -13,7 +13,7 @@ import type { ChatIntegrationService } from '../integrations/chat-integration.se import type { AgentChannelStatusRepository } from '../repositories/agent-channel-status.repository'; import type { AgentRepository } from '../repositories/agent.repository'; import type { CollaborationService } from '@/collaboration/collaboration.service'; -import { LockedError } from '@/errors/response-errors/locked.error'; +import { LockedError } from '@n8n/errors'; import { expectProjectScopedAgentRoutes, getRoutesByHandlerName, diff --git a/packages/cli/src/modules/agents/__tests__/agent-knowledge.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-knowledge.controller.test.ts index c13b2a54fd3..0aba983d437 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-knowledge.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-knowledge.controller.test.ts @@ -2,8 +2,7 @@ import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; import multer from 'multer'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { AgentKnowledgeController } from '../agent-knowledge.controller'; import type { AgentKnowledgeService } from '../agent-knowledge.service'; diff --git a/packages/cli/src/modules/agents/__tests__/agent-mcp-access.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-mcp-access.service.test.ts index 94e1d7e052d..242523ca22a 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-mcp-access.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-mcp-access.service.test.ts @@ -2,7 +2,7 @@ import { mockInstance } from '@n8n/backend-test-utils'; import { User } from '@n8n/db'; import { CollaborationService } from '@/collaboration/collaboration.service'; -import { LockedError } from '@/errors/response-errors/locked.error'; +import { LockedError } from '@n8n/errors'; import { ProjectScopeService } from '@/permissions.ee/project-scope.service'; import { AgentMcpAccessService } from '../agent-mcp-access.service'; diff --git a/packages/cli/src/modules/agents/__tests__/agent-message-queue-consumer.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-message-queue-consumer.service.test.ts index b9d1289f5cb..dd5302382b7 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-message-queue-consumer.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-message-queue-consumer.service.test.ts @@ -15,10 +15,11 @@ import type { AgentQueuedPreviewStreamService } from '../agent-queued-preview-st import type { AgentTestRunService } from '../agent-test-run.service'; import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity'; import type { AgentMessageQueue } from '../entities/agent-message-queue.entity'; +import type { AgentMessageEntity } from '../entities/agent-message.entity'; import type { AgentChatBridge } from '../integrations/agent-chat-bridge'; import type { ChatIntegrationService } from '../integrations/chat-integration.service'; import type { AgentMessageQueueRepository } from '../repositories/agent-message-queue.repository'; -import type { QueuedIntegrationMessage } from '../types/agent-queued-message'; +import type { AgentQueueDispatch, QueuedIntegrationMessage } from '../types/agent-queued-message'; vi.mock('@/permissions.ee/check-access', () => ({ userHasScopes: vi.fn() })); @@ -35,21 +36,36 @@ describe('AgentMessageQueueConsumer', () => { let consumer: AgentMessageQueueConsumer; function claim(threadId: string, integration = false): ClaimedAgentMessage { + const payload: ClaimedAgentMessage['payload'] = integration + ? mock({ + kind: 'integration', + message: 'input', + credentialId: 'credential', + platformThreadId: 'slack:channel:thread', + messageContext: { + platform: 'slack', + integrationConnectionId: 'slack:credential', + target: { type: 'thread', threadId: 'slack:channel:thread' }, + updatedAt: '2026-06-01T12:00:00.000Z', + }, + }) + : { kind: 'preview', message: 'input', resourceId: 'draft-chat:user' }; return { + payload, item: mock({ id: threadId, threadId, - source: integration ? 'slack' : 'chat', + message: mock({ origin: { source: integration ? 'slack' : 'chat' } }), payload: integration - ? mock({ - kind: 'integration', - message: 'input', - credentialId: 'credential', - platformThreadId: 'slack:channel:thread', - }) - : { kind: 'preview', message: 'input', userId: 'user', resourceId: 'draft-chat:user' }, + ? mock({ kind: 'integration', credentialId: 'credential' }) + : { kind: 'preview' }, + }), + thread: mock({ + id: threadId, + agentId: 'agent', + projectId: 'project', + ownerId: integration ? null : 'user', }), - thread: mock({ id: threadId, agentId: 'agent', projectId: 'project' }), admission: { executionId: `execution-${threadId}`, startedAt: new Date(), @@ -62,6 +78,7 @@ describe('AgentMessageQueueConsumer', () => { agentName: 'Agent', projectId: 'project', userMessage: 'input', + source: integration ? 'slack' : 'chat', access: { accessScope: 'user', ownerId: 'user' }, }, }; @@ -103,8 +120,8 @@ describe('AgentMessageQueueConsumer', () => { it('runs independent sessions without waiting and reuses each claimed execution', async () => { const first = claim('first'); - first.item.payload.message = 'edited first message'; - first.recording.userMessage = first.item.payload.message; + first.payload.message = 'edited first message'; + first.recording.userMessage = first.payload.message; const second = claim('second'); const waiting = createDeferredPromise(); repository.findThreadIds.mockResolvedValue(['first', 'second']); @@ -126,6 +143,11 @@ describe('AgentMessageQueueConsumer', () => { expect(queue.settle).toHaveBeenCalledWith('second', second.admission.executionId), ); expect(queue.settle).not.toHaveBeenCalledWith('first', first.admission.executionId); + expect(users.findByIdWithRole).toHaveBeenCalledWith(first.thread.ownerId); + expect(chatExecutions.register).toHaveBeenCalledWith( + expect.objectContaining({ userId: first.thread.ownerId, threadId: 'first' }), + expect.any(AbortController), + ); expect(sender.send).toHaveBeenCalledWith({ type: 'execution-started', inputMessageIds: ['message-1'], @@ -143,6 +165,7 @@ describe('AgentMessageQueueConsumer', () => { expect.objectContaining({ sessionId: 'first', sessionMode: 'existing', + source: 'chat', admittedExecution: first.admission, }), ); @@ -154,31 +177,58 @@ describe('AgentMessageQueueConsumer', () => { ); }); - it.each(['revoked access', 'invalid draft'] as const)( - 'records %s on the claimed execution and releases the item', - async (failure) => { - const item = claim('session'); - if (failure === 'revoked access') vi.mocked(userHasScopes).mockResolvedValue(false); - else + it.each([ + 'missing owner', + 'deleted owner', + 'disabled owner', + 'revoked access', + 'invalid draft', + ] as const)('records %s on the claimed execution and releases the item', async (failure) => { + const item = claim('session'); + switch (failure) { + case 'missing owner': + item.thread.ownerId = null; + break; + case 'deleted owner': + users.findByIdWithRole.mockResolvedValue(null); + break; + case 'disabled owner': + users.findByIdWithRole.mockResolvedValue(mock({ id: 'user', disabled: true })); + break; + case 'revoked access': + vi.mocked(userHasScopes).mockResolvedValue(false); + break; + case 'invalid draft': testRuns.prepareDraftRun.mockResolvedValue({ status: 'agent_misconfigured', missing: ['model'], }); - repository.findThreadIds.mockResolvedValue(['session']); - queue.claimNext.mockResolvedValueOnce(item).mockResolvedValue(null); - consumer.start(); - await vi.waitFor(() => - expect(queue.settle).toHaveBeenCalledWith('session', item.admission.executionId), - ); - expect(queue.recordFailure).toHaveBeenCalledWith( - item, - expect.any(Error), - expect.any(AbortSignal), - ); - expect(testRuns.executePreparedDraftRun).not.toHaveBeenCalled(); - expect(sender.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })); - }, - ); + break; + } + repository.findThreadIds.mockResolvedValue(['session']); + queue.claimNext.mockResolvedValueOnce(item).mockResolvedValue(null); + consumer.start(); + await vi.waitFor(() => + expect(queue.settle).toHaveBeenCalledWith('session', item.admission.executionId), + ); + expect(queue.recordFailure).toHaveBeenCalledWith( + item, + expect.objectContaining({ + message: + failure === 'invalid draft' + ? 'This agent is not ready to run yet.' + : 'You can no longer execute this agent', + }), + expect.any(AbortSignal), + ); + expect(testRuns.executePreparedDraftRun).not.toHaveBeenCalled(); + if (failure !== 'invalid draft') { + expect(chatExecutions.register).not.toHaveBeenCalled(); + expect(testRuns.prepareDraftRun).not.toHaveBeenCalled(); + } + if (failure === 'missing owner') expect(users.findByIdWithRole).not.toHaveBeenCalled(); + expect(sender.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })); + }); it('leaves integration work pending until its bridge is available and stops admission during shutdown', async () => { const item = claim('session', true); @@ -187,9 +237,16 @@ describe('AgentMessageQueueConsumer', () => { const release = vi.fn(); repository.findThreadIds.mockResolvedValue(['session']); queue.claimNext.mockImplementationOnce(async (_threadId, canConsume) => { + item.item.message.origin = null; + await expect(canConsume(item.item, item.thread, {})).rejects.toThrow( + 'Queued integration input has no source', + ); + expect(repository.findPublishedConnection).not.toHaveBeenCalled(); + item.item.message.origin = { source: 'slack' }; expect(await canConsume(item.item, item.thread, {})).toBe(false); integrations.acquireQueueBridge.mockReturnValue({ bridge: mock(), release }); expect(await canConsume(item.item, item.thread, {})).toBe(true); + expect(integrations.acquireQueueBridge).toHaveBeenCalledWith('agent', 'slack', 'credential'); checked.resolve(); await resume.promise; expect(await canConsume(item.item, item.thread, {})).toBe(false); @@ -234,7 +291,7 @@ describe('AgentMessageQueueConsumer', () => { if (outcome === 'accepted') { await vi.waitFor(() => expect(bridge.consumeQueuedMessage).toHaveBeenCalledWith( - item.item.payload, + item.payload, item.thread.id, item.admission, expect.any(AbortSignal), @@ -242,6 +299,12 @@ describe('AgentMessageQueueConsumer', () => { ), ); expect(release).not.toHaveBeenCalled(); + expect(repository.findPublishedConnection).toHaveBeenCalledWith( + 'agent', + 'project', + 'slack', + 'credential', + ); } } finally { running.resolve(); diff --git a/packages/cli/src/modules/agents/__tests__/agent-publish.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-publish.controller.test.ts index f03b314bd0a..98b9581b745 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-publish.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-publish.controller.test.ts @@ -2,7 +2,7 @@ import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; import type { CredentialsService } from '@/credentials/credentials.service'; -import { LockedError } from '@/errors/response-errors/locked.error'; +import { LockedError } from '@n8n/errors'; import type { AgentPublishService } from '../agent-publish.service'; import { AgentPublishController } from '../agent-publish.controller'; 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 a2e58130365..9aea8f9ae63 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,4 +1,5 @@ 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 { Container } from '@n8n/di'; @@ -7,8 +8,7 @@ import { QueryFailedError } from '@n8n/typeorm'; import { mock } from 'vitest-mock-extended'; import type { CredentialsService } from '@/credentials/credentials.service'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import type { EventService } from '@/events/event.service'; +import { ConflictError } from '@n8n/errors'; import type { Telemetry } from '@/telemetry'; import type { AgentCustomToolsService } from '../agent-custom-tools.service'; 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 a8b93a3189e..fa70bb226f2 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 @@ -1,3 +1,5 @@ +import type { EventService } from '@n8n/backend-services'; +import type { AgentMessageSteeringService } from '../agent-message-steering.service'; import type { Mocked } from 'vitest'; import { type AgentJsonConfig } from '@n8n/api-types'; import type { Logger } from '@n8n/backend-common'; @@ -17,7 +19,6 @@ import type { ActiveExecutions } from '@/active-executions'; import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import type { ExternalHooks } from '@/external-hooks'; import { CredentialsService } from '@/credentials/credentials.service'; -import type { EventService } from '@/events/event.service'; import type { EphemeralNodeExecutor } from '@/node-execution'; import type { NodeTypes } from '@/node-types'; import type { OauthService } from '@/oauth/oauth.service'; @@ -284,6 +285,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => { agentExecutionService, mock(), mock(), + mock(), ), telemetry, runtimeCacheService, diff --git a/packages/cli/src/modules/agents/__tests__/agent-session-langsmith-export.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-session-langsmith-export.service.test.ts index 370a879ee8b..891844b2b4e 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-session-langsmith-export.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-session-langsmith-export.service.test.ts @@ -4,7 +4,7 @@ import type { User } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; import { N8N_VERSION } from '@/constants'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { AiService } from '@/services/ai.service'; import { AgentSessionLangSmithExportService } from '../agent-session-langsmith-export.service'; @@ -233,6 +233,27 @@ describe('AgentSessionLangSmithExportService', () => { ); }); + it('exports additional input from its canonical message reference', async () => { + const message = { + id: 'steered', + role: 'user' as const, + content: [{ type: 'text' as const, text: 'Use the newer file' }], + }; + const { service } = setupSession( + makeExecution({ + inputMessages: [message], + timeline: [{ type: 'input', messageId: message.id, timestamp: 100 }], + }), + ); + await service.exportSession(input); + expect(submittedRuns()).toContainEqual( + expect.objectContaining({ + name: 'Additional user input', + inputs: { message }, + }), + ); + }); + it('checks the caller for each child before sending a trace', async () => { const { service, agentExecutionService, threadRepository } = setup(); agentExecutionService.getThreadDetail diff --git a/packages/cli/src/modules/agents/__tests__/agent-sse-stream.test.ts b/packages/cli/src/modules/agents/__tests__/agent-sse-stream.test.ts index aba414dc84c..989ed9ec480 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-sse-stream.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-sse-stream.test.ts @@ -4,6 +4,7 @@ import { LoggerProxy } from 'n8n-workflow'; import { EventEmitter } from 'node:events'; import { emitChunkEvents, initSseStream, type FlushableResponse } from '../agent-sse-stream'; +import type { SteeredMessageEvent } from '../types/agent-steering'; // --------------------------------------------------------------------------- // Helpers @@ -118,6 +119,33 @@ describe('agent-sse-stream — connection setup', () => { expect(res.write).not.toHaveBeenCalled(); }); + it('sends a steered message and keeps delivery open for the next chunk', () => { + const { res } = createResponse(); + const { onChunk, abortSignal, close } = initSseStream(res); + const event: SteeredMessageEvent = { + type: 'message-steered', + queueId: '2', + executionId: 'execution-1', + message: { + id: 'message-2', + role: 'user', + content: [{ type: 'text', text: 'Use the second option.' }], + createdAt: '2026-09-29T11:00:00.000Z', + }, + }; + + onChunk(event); + onChunk({ type: 'text-delta', id: 't-1', delta: 'hello' }); + + expect(abortSignal.aborted).toBe(false); + expect(res.end).not.toHaveBeenCalled(); + expect(res.write).toHaveBeenCalledWith(`data: ${JSON.stringify(event)}\n\n`); + expect(res.write).toHaveBeenCalledWith( + 'data: {"type":"text-delta","id":"t-1","delta":"hello"}\n\n', + ); + close(); + }); + it('closes delivery when an event cannot be serialized', () => { const { res } = createResponse(); const { onChunk, abortSignal } = initSseStream(res); 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 3ef35fa430f..7040d9cdf60 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 @@ -5,8 +5,7 @@ import type { User } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; import type { InstanceSettings, ScheduledTaskManager } from 'n8n-core'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import type { Publisher } from '@/scaling/pubsub/publisher.service'; import { AgentChangePublisher } from '../agent-change-publisher.service'; diff --git a/packages/cli/src/modules/agents/__tests__/agent-tasks.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-tasks.controller.test.ts index 0e37a02af48..be317387610 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-tasks.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-tasks.controller.test.ts @@ -1,7 +1,7 @@ import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { AgentTaskService } from '../agent-task.service'; import { AgentTasksController } from '../agent-tasks.controller'; diff --git a/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts index 9e00edc16cf..415f48d4884 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts @@ -1,3 +1,4 @@ +import type { AgentMessageSteeringService } from '../agent-message-steering.service'; import type { Agent as RuntimeAgent, StreamChunk } from '@n8n/agents'; import type { AgentJsonConfig } from '@n8n/api-types'; import { mockLogger } from '@n8n/backend-test-utils'; @@ -153,6 +154,7 @@ function makeService() { executionService, mock(), mock(), + mock(), ), telemetry, credentialsService, diff --git a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts index 798e989377b..3f5f6d33829 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts @@ -21,13 +21,12 @@ import { NodeConnectionTypes } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; import type { CredentialTypes } from '@/credential-types'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ConflictError, LockedError } from '@n8n/errors'; import type { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service'; import type { NodeTypes } from '@/node-types'; import type { AiGatewayService } from '@/services/ai-gateway.service'; import type { AiService } from '@/services/ai.service'; import type { CollaborationService } from '@/collaboration/collaboration.service'; -import { LockedError } from '@/errors/response-errors/locked.error'; import type { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service'; import type { FreeAiCreditsService } from '@/services/free-ai-credits.service'; import type { Telemetry } from '@/telemetry'; diff --git a/packages/cli/src/modules/agents/__tests__/agents-catalog.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agents-catalog.controller.test.ts index 6db7179ecfe..987a238b1d8 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-catalog.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-catalog.controller.test.ts @@ -3,7 +3,7 @@ import type { AuthenticatedRequest } from '@n8n/db'; import type { Response } from 'express'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { AgentIntegrationPersistenceService } from '../agent-integration-persistence.service'; import type { AgentModelCatalogService } from '../agent-model-catalog.service'; 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 f04bc032268..b998ff5181c 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -1,14 +1,14 @@ /* eslint-disable @typescript-eslint/require-await, @typescript-eslint/unbound-method -- async mock stubs, unbound-method references and short `cb` names are acceptable test idioms */ import { DEFAULT_AGENT_PERSONALISATION } from '@n8n/api-types'; +import type { EventService } from '@n8n/backend-services'; import { mockLogger } from '@n8n/backend-test-utils'; import type { ProjectRelationRepository, User } from '@n8n/db'; import { Container } from '@n8n/di'; import { QueryFailedError } from '@n8n/typeorm'; import { mock } from 'vitest-mock-extended'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ConflictError, NotFoundError } from '@n8n/errors'; import type { AgentChatAttachmentService } from '../agent-chat-attachment.service'; import type { AgentKnowledgeService } from '../agent-knowledge.service'; @@ -23,7 +23,6 @@ import { ChatIntegrationService } from '../integrations/chat-integration.service import type { AgentTaskRepository } from '../repositories/agent-task.repository'; import type { AgentRepository } from '../repositories/agent.repository'; import type { SubAgentCleanupService } from '../sub-agents/sub-agent-cleanup.service'; -import type { EventService } from '@/events/event.service'; import type { CredentialsService } from '@/credentials/credentials.service'; const agentId = 'agent-1'; diff --git a/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts b/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts index 23c87e288c5..cc22244277a 100644 --- a/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts +++ b/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts @@ -291,6 +291,39 @@ describe('ExecutionRecorder', () => { ]); }); + it('keeps committed inputs in order when restored markers repeat', () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const recorder = new ExecutionRecorder(); + const inputC = { + type: 'input', + messageId: 'c', + timestamp: 2_000, + } satisfies TimelineEvent; + const inputD = { + type: 'input', + messageId: 'd', + timestamp: 3_000, + } satisfies TimelineEvent; + + recorder.record({ type: 'text-delta', id: 't1', delta: 'Before C' }); + vi.setSystemTime(2_000); + recorder.recordInputs([inputC]); + recorder.record({ type: 'text-delta', id: 't2', delta: 'After C' }); + vi.setSystemTime(3_000); + recorder.recordInputs([structuredClone(inputC), inputD]); + recorder.record({ type: 'text-delta', id: 't3', delta: 'After D' }); + recorder.record({ type: 'finish', finishReason: 'stop' }); + + expect(recorder.getMessageRecord().timeline).toEqual([ + { type: 'text', content: 'Before C', timestamp: 1_000, endTime: 2_000 }, + inputC, + { type: 'text', content: 'After C', timestamp: 2_000, endTime: 3_000 }, + inputD, + { type: 'text', content: 'After D', timestamp: 3_000, endTime: 3_000 }, + ]); + }); + it('does not create empty text events for whitespace-only segments', () => { const recorder = new ExecutionRecorder(); diff --git a/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts b/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts index 2709124b76a..e713f5d1a1f 100644 --- a/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts +++ b/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts @@ -1,8 +1,10 @@ import type { AgentExecution } from '../entities/agent-execution.entity'; +import { MARK_SESSION_FAILED_TOOL_NAME } from '../tools/mark-session-failed.tool'; import { executionToMessagesDto, executionsToMessagesDto, } from '../utils/execution-to-message-mapper'; +import { MAX_ITERATIONS_STOPPED_MESSAGE } from '../utils/fatal-session-outcome'; const FIXED_CREATED_AT = new Date('2024-01-15T10:00:00.000Z'); @@ -17,6 +19,64 @@ function execution(overrides: Partial = {}): AgentExecution { } describe('execution-to-message-mapper', () => { + it('splits assistant output around stable additional user messages', () => { + const inputD = { + id: 'steer-d', + role: 'user' as const, + content: [{ type: 'text' as const, text: 'D' }], + createdAt: new Date(150).toISOString(), + }; + const inputB = { + id: 'steer-b', + role: 'user' as const, + content: [{ type: 'text' as const, text: 'B' }], + createdAt: new Date(250).toISOString(), + }; + const result = executionToMessagesDto( + execution({ + status: 'error', + error: 'Failed after input', + inputMessages: [ + { id: 'initial-message', role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + inputD, + inputB, + ], + timeline: [ + { type: 'text', content: 'before', timestamp: 100, endTime: 120 }, + { type: 'input', messageId: inputD.id, timestamp: 150 }, + { type: 'text', content: 'between', timestamp: 200, endTime: 220 }, + { type: 'input', messageId: inputB.id, timestamp: 250 }, + { type: 'text', content: 'after', timestamp: 300, endTime: 320 }, + ], + }), + ); + expect(result.map(({ role, content }) => ({ role, content }))).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'before' }] }, + { role: 'user', content: inputD.content }, + { role: 'assistant', content: [{ type: 'text', text: 'between' }] }, + { role: 'user', content: inputB.content }, + { role: 'assistant', content: [{ type: 'text', text: 'after' }] }, + ]); + expect(result[2]).toMatchObject({ ...inputD, executionId: 'execution-1' }); + expect(result[4]).toMatchObject({ ...inputB, executionId: 'execution-1' }); + expect(result.map(({ id }) => id)).toEqual([ + 'initial-message', + 'execution-1:assistant', + 'steer-d', + 'execution-1:assistant:steer-d', + 'steer-b', + 'execution-1:assistant:steer-b', + ]); + expect(result[1].executionStatus).toBe('success'); + expect(result[3].executionStatus).toBe('success'); + expect(result[1].executionError).toBeUndefined(); + expect(result[3].executionError).toBeUndefined(); + expect(result[5]).toMatchObject({ + executionStatus: 'error', + executionError: 'Failed after input', + }); + }); it.each(['running', 'success', 'error', 'cancelled', 'interrupted'] as const)( 'keeps a signal-only turn with status %s', (status) => { @@ -86,6 +146,74 @@ describe('execution-to-message-mapper', () => { expect(assistant?.content).toEqual([]); }); + it('does not attach the max-iterations stop as a run error', () => { + const result = executionToMessagesDto( + execution({ + status: 'error', + error: MAX_ITERATIONS_STOPPED_MESSAGE, + timeline: [ + { + type: 'text', + content: MAX_ITERATIONS_STOPPED_MESSAGE, + timestamp: 100, + endTime: 110, + }, + ], + }), + ); + + expect(result[1]).toMatchObject({ role: 'assistant', executionStatus: 'error' }); + expect(result[1]?.executionError).toBeUndefined(); + }); + + it('does not attach a mark_session_failed reason as a run error', () => { + const result = executionToMessagesDto( + execution({ + status: 'error', + error: 'Could not recover', + timeline: [ + { + type: 'tool-call', + kind: 'tool', + name: MARK_SESSION_FAILED_TOOL_NAME, + toolCallId: 'tc-fail', + input: { reason: 'Could not recover' }, + output: { marked: true }, + startTime: 100, + endTime: 110, + success: true, + }, + ], + }), + ); + + expect(result[1]?.executionError).toBeUndefined(); + }); + + it('keeps a real run error when the turn also marked the session failed', () => { + const result = executionToMessagesDto( + execution({ + status: 'error', + error: 'fetch failed', + timeline: [ + { + type: 'tool-call', + kind: 'tool', + name: MARK_SESSION_FAILED_TOOL_NAME, + toolCallId: 'tc-fail', + input: { reason: 'Could not recover' }, + output: { marked: true }, + startTime: 100, + endTime: 110, + success: true, + }, + ], + }), + ); + + expect(result[1]?.executionError).toBe('fetch failed'); + }); + it('does not attach the recorded error to successful turns', () => { const result = executionToMessagesDto( execution({ diff --git a/packages/cli/src/modules/agents/__tests__/instance-ai-agent-context.adapter.test.ts b/packages/cli/src/modules/agents/__tests__/instance-ai-agent-context.adapter.test.ts index 987e943ff85..1d6d6e60653 100644 --- a/packages/cli/src/modules/agents/__tests__/instance-ai-agent-context.adapter.test.ts +++ b/packages/cli/src/modules/agents/__tests__/instance-ai-agent-context.adapter.test.ts @@ -6,8 +6,7 @@ import { UserError } from 'n8n-workflow'; import type { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service'; import { userHasScopes } from '@/permissions.ee/check-access'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import type { AgentExecutionService, ThreadListItem } from '../agent-execution.service'; import type { AgentIntegrationPersistenceService } from '../agent-integration-persistence.service'; diff --git a/packages/cli/src/modules/agents/__tests__/instance-ai-builder-delegate.adapter.test.ts b/packages/cli/src/modules/agents/__tests__/instance-ai-builder-delegate.adapter.test.ts index 8084e3eef9d..8b1df44f93d 100644 --- a/packages/cli/src/modules/agents/__tests__/instance-ai-builder-delegate.adapter.test.ts +++ b/packages/cli/src/modules/agents/__tests__/instance-ai-builder-delegate.adapter.test.ts @@ -12,8 +12,7 @@ import { Like } from '@n8n/typeorm'; import { UserError } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import * as checkAccess from '@/permissions.ee/check-access'; import type { AgentsService } from '../agents.service'; diff --git a/packages/cli/src/modules/agents/__tests__/queued-message-id.test.ts b/packages/cli/src/modules/agents/__tests__/queued-message-id.test.ts new file mode 100644 index 00000000000..e017fd184d5 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/queued-message-id.test.ts @@ -0,0 +1,113 @@ +import { mock } from 'vitest-mock-extended'; + +import type { QueuedIntegrationMessage, QueuedPreviewMessage } from '../types/agent-queued-message'; +import { queuedMessageId } from '../utils/queued-message-id'; + +describe('queuedMessageId', () => { + const preview: QueuedPreviewMessage = { + kind: 'preview', + userId: 'user-1', + messageId: 'C4B02D7B-2088-41CE-9C6B-FAF8C7B83D8A', + message: 'Hello', + resourceId: 'draft-chat:user-1', + }; + const integration = mock({ + kind: 'integration', + platformThreadId: 'slack:channel:thread', + messageContext: { + platform: 'slack', + integrationConnectionId: 'slack:credential', + messageId: '1726748100.000042', + }, + }); + + it('keeps the acceptance ID contract stable across releases', () => { + expect(queuedMessageId('agent-1', 'session-1', preview)).toBe( + '14926758-36d7-5db0-a865-962afe3ad861', + ); + expect(queuedMessageId('agent-1', 'session-1', integration)).toBe( + '2e2ada63-7a78-5ddf-ab15-154a4b635e3e', + ); + expect( + queuedMessageId('agent-1', 'session-1', { + ...preview, + messageId: preview.messageId!.toLowerCase(), + }), + ).toBe('14926758-36d7-5db0-a865-962afe3ad861'); + }); + + it('separates Preview agents, users, sessions, and submissions', () => { + const ids = [ + queuedMessageId('agent-1', 'session-1', preview), + queuedMessageId('agent-2', 'session-1', preview), + queuedMessageId('agent-1', 'session-2', preview), + queuedMessageId('agent-1', 'session-1', { ...preview, userId: 'user-2' }), + queuedMessageId('agent-1', 'session-1', { + ...preview, + messageId: 'b6c50392-01d7-4d9b-b331-42de11c261f8', + }), + ]; + expect(new Set(ids).size).toBe(ids.length); + expect(queuedMessageId('agent:one', 'session-1', { ...preview, userId: 'two' })).not.toBe( + queuedMessageId('agent', 'session-1', { ...preview, userId: 'one:two' }), + ); + }); + + it('separates native integration scopes and preserves native identifier case', () => { + const ids = [ + queuedMessageId('agent-1', 'session-1', integration), + queuedMessageId('agent-2', 'session-1', integration), + queuedMessageId('agent-1', 'session-1', { + ...integration, + platformThreadId: 'Slack:channel:thread', + }), + ...['platform', 'integrationConnectionId', 'messageId'].map((field) => + queuedMessageId('agent-1', 'session-1', { + ...integration, + messageContext: { ...integration.messageContext, [field]: 'different' }, + }), + ), + ]; + expect(new Set(ids).size).toBe(ids.length); + expect( + queuedMessageId('agent-1', 'session-1', { + ...integration, + messageContext: { ...integration.messageContext, messageId: 'Native-ID' }, + }), + ).not.toBe( + queuedMessageId('agent-1', 'session-1', { + ...integration, + messageContext: { ...integration.messageContext, messageId: 'native-id' }, + }), + ); + }); + + it('ignores delivery content and the resolved integration session and task binding', () => { + expect( + queuedMessageId('agent-1', 'session-1', { ...preview, message: 'changed', attachments: [] }), + ).toBe(queuedMessageId('agent-1', 'session-1', preview)); + expect( + queuedMessageId('agent-1', 'rotated-session#2', { + ...integration, + resourceId: 'different-resource', + message: 'changed', + attachments: [], + contextConversation: { threadId: 'task-thread', resourceId: 'task-resource' }, + }), + ).toBe(queuedMessageId('agent-1', 'session-1', integration)); + }); + + it('leaves messages without a native or client ID on the random-ID path', () => { + expect( + queuedMessageId('agent-1', 'session-1', { ...preview, messageId: undefined }), + ).toBeUndefined(); + for (const messageId of [undefined, '']) { + expect( + queuedMessageId('agent-1', 'session-1', { + ...integration, + messageContext: { ...integration.messageContext, messageId }, + }), + ).toBeUndefined(); + } + }); +}); diff --git a/packages/cli/src/modules/agents/agent-chat-execution.service.ts b/packages/cli/src/modules/agents/agent-chat-execution.service.ts index f8575462c71..46cc9945c70 100644 --- a/packages/cli/src/modules/agents/agent-chat-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-chat-execution.service.ts @@ -3,11 +3,12 @@ import { OnPubSubEvent } from '@n8n/decorators'; import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { AgentExecutionService } from './agent-execution.service'; +import { AgentMessageSteeringService } from './agent-message-steering.service'; import { AgentExecutionUpdateBroadcaster } from './agent-execution-update-broadcaster'; import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage'; import { AgentExecutionRepository } from './repositories/agent-execution.repository'; @@ -55,6 +56,7 @@ export class AgentChatExecutionService { private readonly publisher: Publisher, private readonly instanceSettings: InstanceSettings, private readonly executionUpdates: AgentExecutionUpdateBroadcaster, + private readonly steering: AgentMessageSteeringService, ) {} register(context: ExecutionContext, controller: AbortController): void { @@ -111,6 +113,7 @@ export class AgentChatExecutionService { async () => { const execution = await this.getOwnedExecution(context); if (!execution) throw new NotFoundError('Execution not found'); + await this.steering.close(context.threadId, context.executionId); if (this.cancelLocal(context)) return true; if (execution.status !== 'running') return await this.cancelRecordedSuspension(context); this.cancelOrRemember(context); diff --git a/packages/cli/src/modules/agents/agent-chat.controller.ts b/packages/cli/src/modules/agents/agent-chat.controller.ts index fe1cfb613c8..052f39be165 100644 --- a/packages/cli/src/modules/agents/agent-chat.controller.ts +++ b/packages/cli/src/modules/agents/agent-chat.controller.ts @@ -3,6 +3,7 @@ import { type AgentChatAttachmentPayload, AgentChatMessageDto, AgentChatQueueUpdateDto, + AgentChatQueueSteerDto, type AgentChatMessagesResponse, type AgentChatQueueResponse, AgentChatResumeDto, @@ -29,8 +30,7 @@ import { pipeline } from 'node:stream/promises'; import { randomUUID } from 'node:crypto'; import { CredentialsService } from '@/credentials/credentials.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { AgentsCredentialProvider } from './adapters/agents-credential-provider'; import { AgentChatAttachmentService } from './agent-chat-attachment.service'; @@ -338,7 +338,7 @@ export class AgentChatController { ) { const { projectId } = req.params; // The text-or-attachment invariant is enforced by the DTO schema. - const { message, sessionId, newSession, attachments } = payload; + const { message, sessionId, messageId, newSession, attachments } = payload; const credentialProvider = new AgentsCredentialProvider( this.credentialsService, @@ -387,7 +387,7 @@ export class AgentChatController { }); abortSignal.throwIfAborted(); - const item = await this.messageQueue.enqueue( + const result = await this.messageQueue.enqueue( { agentId, projectId, @@ -397,6 +397,7 @@ export class AgentChatController { payload: { kind: 'preview', message, + messageId, attachments: storedAttachments, userId: req.user.id, resourceId: draftChatMemoryResourceId(req.user.id), @@ -406,17 +407,15 @@ export class AgentChatController { subscription = this.queuedPreviewStreams.subscribe(queueId, delivery); }, ); + if (result.status === 'duplicate') { + send({ type: 'done' }); + return; + } accepted = true; subscription?.accepted(); - send({ type: 'message-queued', queueId: item.id, sessionId: threadId }); + send({ type: 'message-queued', queueId: result.item.id, sessionId: threadId }); await subscription?.done; } catch (error) { - // Committed messages own their attachments, including after a disconnect. - if (!accepted && storedAttachments?.length) { - await this.agentChatAttachmentService - .deleteByIds(storedAttachments.map((ref) => ref.id)) - .catch(() => {}); - } const errorMessage = error instanceof Error ? error.message : 'Chat failed'; send({ type: 'error', @@ -426,6 +425,12 @@ export class AgentChatController { : {}), }); } finally { + // Committed messages own their attachments, including after a disconnect. + if (!accepted && storedAttachments?.length) { + await this.agentChatAttachmentService + .deleteByIds(storedAttachments.map((ref) => ref.id)) + .catch(() => {}); + } subscription?.close(); delivery.close(); } @@ -662,6 +667,28 @@ export class AgentChatController { return { removed: true }; } + @Post('/:agentId/chat/:threadId/queue/:queueId/steer') + @ProjectScope('agent:execute') + async steerQueuedMessage( + req: AuthenticatedRequest<{ + projectId: string; + agentId: string; + threadId: string; + queueId: string; + }>, + _res: Response, + @Body payload: AgentChatQueueSteerDto, + ): Promise { + if (!/^[1-9]\d*$/.test(req.params.queueId)) throw new BadRequestError('Invalid queue ID'); + const agent = await this.agentsService.findById(req.params.agentId, req.params.projectId); + if (!agent) throw new NotFoundError('Agent not found'); + await this.messageQueue.steer({ + ...req.params, + userId: req.user.id, + executionId: payload.executionId, + }); + } + @Get('/:agentId/chat/:threadId/background-tasks') @ProjectScope('agent:read') async getBackgroundJobs( diff --git a/packages/cli/src/modules/agents/agent-config.service.ts b/packages/cli/src/modules/agents/agent-config.service.ts index 0f9dcc818a5..f1f000507ae 100644 --- a/packages/cli/src/modules/agents/agent-config.service.ts +++ b/packages/cli/src/modules/agents/agent-config.service.ts @@ -9,14 +9,14 @@ import { type 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 { 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 '@/errors/response-errors/conflict.error'; -import { EventService } from '@/events/event.service'; +import { ConflictError } from '@n8n/errors'; import { type AgentConfigPart, diff --git a/packages/cli/src/modules/agents/agent-dependency-index.listener.ts b/packages/cli/src/modules/agents/agent-dependency-index.listener.ts index af39157631b..d043ca7882b 100644 --- a/packages/cli/src/modules/agents/agent-dependency-index.listener.ts +++ b/packages/cli/src/modules/agents/agent-dependency-index.listener.ts @@ -1,8 +1,7 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { Service } from '@n8n/di'; -import { EventService } from '@/events/event.service'; - import { AgentDependencyIndexService } from './agent-dependency-index.service'; @Service() diff --git a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts index 9adad42ad40..58bf44255d3 100644 --- a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts +++ b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts @@ -1,8 +1,4 @@ -import { - type Agent as RuntimeAgent, - type SerializableAgentState, - type StreamChunk, -} from '@n8n/agents'; +import { type Agent as RuntimeAgent, type SerializableAgentState } from '@n8n/agents'; import type { AgentBackgroundJobSignal, AgentMessageAuthor, @@ -66,6 +62,7 @@ import { AgentRepository } from './repositories/agent.repository'; import type { ToolRegistry } from './tool-registry'; import type { StoredAttachmentRef } from './types/agent-chat-attachment'; import type { AgentExecutionAdmission } from './types/agent-queued-message'; +import type { AgentExecutionStreamChunk } from './types/agent-steering'; import { createAgentExecutionCounter } from './utils/agent-execution-counter'; import { getPublishedAgentSnapshot } from './utils/agent-published-snapshot'; import { buildInboundUserMessage } from './utils/inbound-attachments'; @@ -427,7 +424,7 @@ export class AgentExecutionOrchestratorService { * Used by chat integration handlers to continue an agent run after * a human-in-the-loop action (button click, modal submission). */ - async *resumeForChat(config: ResumeForChatConfig): AsyncGenerator { + async *resumeForChat(config: ResumeForChatConfig): AsyncGenerator { const resume = { ...config, usePublishedVersion: config.usePublishedVersion ?? true }; if ( resume.source === N8N_CHAT_PRODUCTION_SOURCE && @@ -445,7 +442,7 @@ export class AgentExecutionOrchestratorService { /** * Execute an agent for the in-app test chat and yield stream chunks. */ - async *executeForChat(config: ExecuteForChatConfig): AsyncGenerator { + async *executeForChat(config: ExecuteForChatConfig): AsyncGenerator { const draft = { ...config, sessionMode: config.sessionMode ?? 'new' }; const sandboxPrincipalHash = hashAgentSandboxPrincipal({ type: 'n8n-user', @@ -467,7 +464,7 @@ export class AgentExecutionOrchestratorService { */ async *executeForChatPublished( config: ExecuteForChatPublishedConfig, - ): AsyncGenerator { + ): AsyncGenerator { const { agentId, projectId, @@ -558,7 +555,7 @@ export class AgentExecutionOrchestratorService { async *executeForN8nChatPublished( config: ExecuteForN8nChatPublishedConfig, - ): AsyncGenerator { + ): AsyncGenerator { const { agentId, projectId, @@ -649,7 +646,7 @@ export class AgentExecutionOrchestratorService { */ async *executeForTaskPublished( config: ExecuteForTaskPublishedConfig, - ): AsyncGenerator { + ): AsyncGenerator { const { agentId, projectId, message, memory, taskId, taskVersionId } = config; await this.externalHooks.run('agent.preExecute', [agentId]); @@ -702,7 +699,9 @@ export class AgentExecutionOrchestratorService { * Execute a task on demand against the current (draft) config as the * requesting user. */ - async *executeForTaskNow(config: ExecuteForTaskNowConfig): AsyncGenerator { + async *executeForTaskNow( + config: ExecuteForTaskNowConfig, + ): AsyncGenerator { const { agentId, projectId, user, message, memory, taskId } = config; // `user` is always set (see ExecuteForTaskNowConfig) — manual "Run now" @@ -814,12 +813,12 @@ export class AgentExecutionOrchestratorService { } private async *streamWakeResponse( - stream: AsyncGenerator, + stream: AsyncGenerator, abortSignal: AbortSignal, delivery?: { bridge: AgentChatBridge; threadId: string }, cardRecipientId?: string, - ): AsyncGenerator { - const chunks: StreamChunk[] = []; + ): AsyncGenerator { + const chunks: AgentExecutionStreamChunk[] = []; let runError: unknown; for await (const chunk of stream) { if (delivery) chunks.push(chunk); @@ -868,7 +867,9 @@ export class AgentExecutionOrchestratorService { /** * Stream an agent response, record it, and yield each chunk. */ - async *streamChatResponse(config: StreamChatResponseConfig): AsyncGenerator { + async *streamChatResponse( + config: StreamChatResponseConfig, + ): AsyncGenerator { yield* this.turnExecutionService.execute({ admittedExecution: config.admittedExecution, onAdmitted: config.onAdmitted, @@ -897,8 +898,10 @@ export class AgentExecutionOrchestratorService { acquire: () => Promise, use: ( runtime: AgentRuntime, - ) => AsyncGenerator | Promise>, - ): AsyncGenerator { + ) => + | AsyncGenerator + | Promise>, + ): AsyncGenerator { const runtime = await acquire(); try { yield* await use(runtime); diff --git a/packages/cli/src/modules/agents/agent-execution.service.ts b/packages/cli/src/modules/agents/agent-execution.service.ts index aab37be898f..69200a2d0f9 100644 --- a/packages/cli/src/modules/agents/agent-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-execution.service.ts @@ -13,7 +13,7 @@ import chunk from 'lodash/chunk'; import { ErrorReporter, StorageConfig } from 'n8n-core'; import { OperationalError, UnexpectedError } from 'n8n-workflow'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ConflictError } from '@n8n/errors'; import type { AgentRunTelemetryType, IAgentConfigurationTelemetryProperties } from '@/interfaces'; import { Telemetry } from '@/telemetry'; @@ -62,6 +62,7 @@ import { computeExecutionFailureSummary, type ThreadFailureSummary, } from './utils/execution-failure-summary'; +import { applyFatalSessionOutcome } from './utils/fatal-session-outcome'; export interface RecordMessageParams { threadId: string; @@ -97,6 +98,7 @@ export interface StartExecutionParams extends Omit; hideUserMessageFromTranscript?: boolean; access: AgentThreadAccess; + previewChat?: boolean; sessionMode?: AgentSessionMode; initialTimeline?: TimelineEvent[]; /** Internal admission data. These fields are not stored on the execution. */ @@ -152,9 +154,25 @@ export class AgentExecutionService { >(); private readonly timelineSnapshotWrites = new Map>(); + private readonly pausedTimelineSnapshots = new Set(); private readonly executionsNeedingTitleSync = new Set(); + /** + * Side-call cost report ids with an in-flight transaction. Concurrent + * deliveries of the same `reportId` coalesce onto the in-flight promise + * instead of each running its own transaction, which would double-count. + * The entry is removed when the attempt settles; a failed attempt is not + * claimed, so a later replay can retry. + */ + private readonly sideCallReportInFlight = new Map>(); + + /** + * Per-execution in-flight side-call cost recordings, drained by + * `writeTerminalExecution` before the terminal UPDATE. + */ + private readonly sideCallUsageInFlightByExecution = new Map>>(); + constructor( private readonly logger: Logger, private readonly agentExecutionRepository: AgentExecutionRepository, @@ -192,12 +210,17 @@ export class AgentExecutionService { params: StartExecutionParams, startedAt: Date, ctx: OperationContext, + lockedQueueThread?: AgentExecutionThread, ): Promise { - const prepared = await this.prepareThread(params, ctx); + // The queue already locked the session. Its consumer validates the owner before execution. + const prepared = lockedQueueThread + ? { thread: lockedQueueThread, created: false } + : await this.prepareThread(params, ctx); const { queueItem, predecessorId } = await this.checkAdmission(params, ctx); const execution = this.agentExecutionRepository.create({ threadId: params.threadId, status: 'running', + acceptsSteering: params.previewChat === true, startedAt, stoppedAt: null, duration: 0, @@ -218,7 +241,13 @@ export class AgentExecutionService { attachments: null, }); const inserted = await this.agentExecutionRepository.saveInContext(execution, ctx); - const inputMessageIds = await this.reserveInput(params, inserted.id, predecessorId, ctx); + const inputMessageIds = await this.reserveInput( + params, + inserted.id, + predecessorId, + queueItem?.messageId, + ctx, + ); if ( queueItem && !(await this.queueRepository.linkExecution( @@ -241,6 +270,7 @@ export class AgentExecutionService { params: StartExecutionParams, executionId: string, predecessorId: string | undefined, + queuedMessageId: string | undefined, ctx: OperationContext, ): Promise { if (predecessorId) { @@ -252,10 +282,13 @@ export class AgentExecutionService { ); } if (params.resumeRunId || params.userMessage === null) return []; + if (queuedMessageId) { + await this.messageRepository.linkExecutionInput(executionId, queuedMessageId, params, ctx); + return [queuedMessageId]; + } const [content] = buildInboundUserMessage(params.userMessage, params.attachments ?? []); - const id = await this.messageRepository.createExecutionInput( + const message = await this.messageRepository.createInput( { - executionId, threadId: params.threadId, resourceId: params.resourceId, content, @@ -268,7 +301,8 @@ export class AgentExecutionService { }, ctx, ); - return [id]; + await this.messageRepository.linkExecutionInput(executionId, message.id, params, ctx); + return [message.id]; } private async checkAdmission(params: StartExecutionParams, ctx: OperationContext) { @@ -337,10 +371,26 @@ export class AgentExecutionService { this.ensureTimelineSnapshotWrite(executionId); } + /** Keep older snapshots behind the transaction that commits additional input. */ + async withTimelineWritesPaused(executionId: string, commit: () => Promise): Promise { + this.pausedTimelineSnapshots.add(executionId); + try { + await this.timelineSnapshotWrites.get(executionId); + return await commit(); + } catch (error) { + this.pendingTimelineSnapshots.delete(executionId); + throw error; + } finally { + this.pausedTimelineSnapshots.delete(executionId); + this.ensureTimelineSnapshotWrite(executionId); + } + } + async finalizeExecution(executionId: string, params: RecordMessageParams): Promise { this.stopHeartbeat(executionId); this.pendingTimelineSnapshots.delete(executionId); - const { record } = params; + const record = applyFatalSessionOutcome(params.record); + const settled = record === params.record ? params : { ...params, record }; const status = executionStatus(record); const stoppedAt = new Date(record.startTime + record.duration); const failureSummary = computeExecutionFailureSummary({ @@ -353,10 +403,10 @@ export class AgentExecutionService { record.timeline.length > 0 ? this.storageConfig.modeTag : 'db'; try { - await this.writeTerminalExecution(executionId, params, status, stoppedAt, failureSummary); + await this.writeTerminalExecution(executionId, settled, status, stoppedAt, failureSummary); // Save the terminal row first. A rejected finalization must not replace a stored blob. - await this.moveFinalTimelineToBlob(executionId, params, storedAt); + await this.moveFinalTimelineToBlob(executionId, settled, storedAt); this.executionUpdateBroadcaster.notify({ projectId: params.projectId, @@ -364,7 +414,7 @@ export class AgentExecutionService { threadId: params.threadId, executionId, }); - await this.completeRecordedExecution(params, executionId, status); + await this.completeRecordedExecution(settled, executionId, status); return executionId; } catch (error) { this.errorReporter.error(error); @@ -482,6 +532,7 @@ export class AgentExecutionService { private ensureTimelineSnapshotWrite(executionId: string): void { if ( + this.pausedTimelineSnapshots.has(executionId) || this.timelineSnapshotWrites.has(executionId) || !this.pendingTimelineSnapshots.has(executionId) ) { @@ -495,7 +546,7 @@ export class AgentExecutionService { } private async drainTimelineSnapshots(executionId: string): Promise { - while (true) { + while (!this.pausedTimelineSnapshots.has(executionId)) { const snapshot = this.pendingTimelineSnapshots.get(executionId); if (!snapshot) return; this.pendingTimelineSnapshots.delete(executionId); @@ -702,7 +753,7 @@ export class AgentExecutionService { firstMessage: cleanUserMessage(messageMap.get(t.id) ?? null, t.agentName), source, failureSummary: failureSummaryMap.get(t.id) ?? null, - status: toSessionStatus(latestStatusMap.get(t.id), failureSummaryMap.has(t.id)), + status: toSessionStatus(latestStatusMap.get(t.id)), }; }); } @@ -906,7 +957,29 @@ export class AgentExecutionService { ): Promise { const { record, hitlStatus } = params; await this.timelineSnapshotWrites.get(executionId); - const finalized = await this.agentExecutionRepository.updateIfRunning(executionId, { + // Drain in-flight side-call cost recordings before the terminal UPDATE + // so a process exit right after finalization cannot lose side-call cost. + const inFlight = this.sideCallUsageInFlightByExecution.get(executionId); + if (inFlight) { + this.sideCallUsageInFlightByExecution.delete(executionId); + await Promise.allSettled(inFlight); + } + // The terminal status write and the main-loop cost increment run as one + // atomic UPDATE (`status = ... , cost = COALESCE(cost, 0) + :costIncrement`), + // so they commit or roll back together. Cost is applied additively rather + // than assigned, so a side-call `incrementCost` that lands before this + // terminal write is not overwritten. `record.totalCost` is the main-loop + // cost only; side calls price themselves onto the same column. + // + // A transient failure (DB blip, deadlock) leaves the row `running`. The + // heartbeat is already stopped at this point, so without a retry the row + // would stay `running` until the sweeper marks it `interrupted` and the + // main-loop cost would be lost. Retry a bounded number of times with linear + // backoff; the total worst-case wait stays under the sweeper's 2-min grace + // so a finalized row is never prematurely reaped. The one error that is + // definitive — `OperationalError('Agent execution is no longer running')` + // — means another path already finalized the row, so it is not retried. + const terminalValues = { status, stoppedAt, duration: record.duration, @@ -914,18 +987,48 @@ export class AgentExecutionService { promptTokens: record.usage?.promptTokens ?? null, completionTokens: record.usage?.completionTokens ?? null, totalTokens: record.usage?.totalTokens ?? null, - cost: record.totalCost, timeline: record.timeline.length > 0 ? record.timeline : null, - storedAt: 'db', + storedAt: 'db' as const, error: record.error, failureSummary, hitlStatus: hitlStatus ?? null, - }); - if (!finalized) { - throw new OperationalError('Agent execution is no longer running', { - extra: { executionId }, - }); + }; + const maxAttempts = 3; + let lastError: unknown; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const finalized = await this.txRunner.run({}, async (ctx) => { + if (!(await this.agentExecutionThreadRepository.lockById(params.threadId, ctx))) + return false; + return await this.agentExecutionRepository.updateIfRunning( + executionId, + terminalValues, + undefined, + ctx, + record.totalCost ?? undefined, + ); + }); + if (!finalized) { + throw new OperationalError('Agent execution is no longer running', { + extra: { executionId }, + }); + } + return; + } catch (error) { + if ( + error instanceof OperationalError && + error.message === 'Agent execution is no longer running' + ) { + throw error; + } + lastError = error; + if (attempt < maxAttempts) { + const delay = 100 * attempt; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } } + throw lastError; } private async moveFinalTimelineToBlob( @@ -1015,6 +1118,82 @@ export class AgentExecutionService { } } + /** + * Apply a side-call model cost (title generation, observation-log + * observer/reflector, episodic-memory model calls) onto its execution row + * and thread totals. The SDK prices the call and sends `{ task, model, + * usage, cost, reportId }`; the host only adds `cost`. Best-effort: a + * failure logs a warning and never breaks the run. + * + * Both totals are updated in one transaction. Concurrent deliveries of the + * same `reportId` coalesce onto a single in-flight transaction so they + * cannot each run their own and double-count. The in-flight entry is + * removed when the attempt settles, so the map does not grow unbounded. + * The SDK calls `onSideCallUsage` once per model call with a fresh + * `reportId`, so a sequential replay is not expected; cross-process + * idempotency would need a DB-backed unique constraint, not a process-local + * set. + */ + async recordSideCallUsage( + executionId: string, + threadId: string, + report: { task: string; model?: string; cost: number; reportId: string }, + ): Promise { + // Register the in-flight promise synchronously (before any await) so a + // concurrent delivery observes it and waits on the same attempt rather + // than starting a second transaction that would double-count. + let attempt = this.sideCallReportInFlight.get(report.reportId); + if (attempt === undefined) { + const created = this.applySideCallUsage(executionId, threadId, report); + attempt = created; + this.sideCallReportInFlight.set(report.reportId, created); + let bucket = this.sideCallUsageInFlightByExecution.get(executionId); + if (!bucket) { + bucket = new Set(); + this.sideCallUsageInFlightByExecution.set(executionId, bucket); + } + bucket.add(created); + void created.finally(() => bucket?.delete(created)); + } + try { + await attempt; + } finally { + // Only the caller that created the attempt clears the slot, so a + // coalesced delivery does not delete a later attempt's entry. + if (this.sideCallReportInFlight.get(report.reportId) === attempt) { + this.sideCallReportInFlight.delete(report.reportId); + } + } + } + + private async applySideCallUsage( + executionId: string, + threadId: string, + report: { task: string; model?: string; cost: number; reportId: string }, + ): Promise { + try { + await this.txRunner.run({}, async (ctx) => { + await this.agentExecutionRepository.incrementCost(executionId, report.cost, ctx); + await this.agentExecutionThreadRepository.incrementUsage( + threadId, + 0, + 0, + report.cost, + 0, + ctx, + ); + }); + } catch (error) { + this.logger.warn('Failed to record agent side-call usage', { + executionId, + threadId, + task: report.task, + reportId: report.reportId, + error, + }); + } + } + private trackFinishedTurn(params: RecordMessageParams, status: AgentExecutionStatus): void { if (!params.telemetry) return; const { agentId, threadId, record } = params; @@ -1040,10 +1219,9 @@ export class AgentExecutionService { export function toSessionStatus( latestStatus: AgentExecutionStatus | undefined, - hasFailureSummary: boolean, ): AgentSessionStatus | null { if (!latestStatus) return null; - if (latestStatus === 'success') return hasFailureSummary ? 'error' : 'succeeded'; + if (latestStatus === 'success') return 'succeeded'; return latestStatus; } diff --git a/packages/cli/src/modules/agents/agent-integration-management.service.ts b/packages/cli/src/modules/agents/agent-integration-management.service.ts index cab9f97cb73..c5db24f2ba3 100644 --- a/packages/cli/src/modules/agents/agent-integration-management.service.ts +++ b/packages/cli/src/modules/agents/agent-integration-management.service.ts @@ -9,8 +9,7 @@ import { Service } from '@n8n/di'; import { runSerially } from '@n8n/utils/run-serially'; import { CredentialsService } from '@/credentials/credentials.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { AgentIntegrationPersistenceService, 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 ba4be718416..f0175d45785 100644 --- a/packages/cli/src/modules/agents/agent-integration-persistence.service.ts +++ b/packages/cli/src/modules/agents/agent-integration-persistence.service.ts @@ -4,13 +4,13 @@ import { type AgentIntegrationConfig, type ChatIntegrationDescriptor, } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; import { OperationalError, UserError } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { CredentialsService } from '@/credentials/credentials.service'; -import { EventService } from '@/events/event.service'; import { AgentModificationTelemetryService, diff --git a/packages/cli/src/modules/agents/agent-integrations.controller.ts b/packages/cli/src/modules/agents/agent-integrations.controller.ts index 35b8ee58dbe..a819f3ead6f 100644 --- a/packages/cli/src/modules/agents/agent-integrations.controller.ts +++ b/packages/cli/src/modules/agents/agent-integrations.controller.ts @@ -19,7 +19,7 @@ import { AgentChannelStatusRepository } from './repositories/agent-channel-statu import { AgentRepository } from './repositories/agent.repository'; import { CollaborationService } from '@/collaboration/collaboration.service'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; @RestController('/projects/:projectId/agents/v2') export class AgentIntegrationsController { diff --git a/packages/cli/src/modules/agents/agent-knowledge-mirror.service.ts b/packages/cli/src/modules/agents/agent-knowledge-mirror.service.ts index cb22df59dd5..64948fd1864 100644 --- a/packages/cli/src/modules/agents/agent-knowledge-mirror.service.ts +++ b/packages/cli/src/modules/agents/agent-knowledge-mirror.service.ts @@ -7,8 +7,7 @@ import escapeRegExp from 'lodash/escapeRegExp'; import { OperationalError, safeRegex } from 'n8n-workflow'; import { nanoid } from 'nanoid'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { buildMirrorFinalizeCommand, diff --git a/packages/cli/src/modules/agents/agent-knowledge-retrieval.ts b/packages/cli/src/modules/agents/agent-knowledge-retrieval.ts index 29e691e11ab..377d20af379 100644 --- a/packages/cli/src/modules/agents/agent-knowledge-retrieval.ts +++ b/packages/cli/src/modules/agents/agent-knowledge-retrieval.ts @@ -1,7 +1,7 @@ import type { ExecutionDataStorageLocation } from '@n8n/db'; import { z } from 'zod'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { hasControlCharacter } from './agent-knowledge-storage'; diff --git a/packages/cli/src/modules/agents/agent-knowledge-storage.ts b/packages/cli/src/modules/agents/agent-knowledge-storage.ts index a24b85fe731..4dd8628da6f 100644 --- a/packages/cli/src/modules/agents/agent-knowledge-storage.ts +++ b/packages/cli/src/modules/agents/agent-knowledge-storage.ts @@ -2,7 +2,7 @@ import type { AgentFileDto } from '@n8n/api-types'; import { getPromptWorkspaceRoot, type SandboxProvider } from '@n8n/agents/sandbox'; import path from 'node:path'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { AgentFile } from './entities/agent-file.entity'; diff --git a/packages/cli/src/modules/agents/agent-knowledge.controller.ts b/packages/cli/src/modules/agents/agent-knowledge.controller.ts index 65bacc0210c..c45744aaafc 100644 --- a/packages/cli/src/modules/agents/agent-knowledge.controller.ts +++ b/packages/cli/src/modules/agents/agent-knowledge.controller.ts @@ -4,8 +4,7 @@ import { Delete, Get, Param, Post, ProjectScope, RestController } from '@n8n/dec import type { Response } from 'express'; import multer from 'multer'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { AgentKnowledgeService } from './agent-knowledge.service'; import { AgentRuntimeCacheService } from './agent-runtime-cache.service'; diff --git a/packages/cli/src/modules/agents/agent-knowledge.service.ts b/packages/cli/src/modules/agents/agent-knowledge.service.ts index 519be983492..b489006147b 100644 --- a/packages/cli/src/modules/agents/agent-knowledge.service.ts +++ b/packages/cli/src/modules/agents/agent-knowledge.service.ts @@ -12,7 +12,7 @@ import { createReadStream } from 'node:fs'; import { unlink } from 'node:fs/promises'; import path from 'node:path'; import type { Readable } from 'node:stream'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { getAgentOrThrow } from './utils/get-agent-or-throw'; import { diff --git a/packages/cli/src/modules/agents/agent-mcp-access.service.ts b/packages/cli/src/modules/agents/agent-mcp-access.service.ts index f9a150ec502..1b3823d3a44 100644 --- a/packages/cli/src/modules/agents/agent-mcp-access.service.ts +++ b/packages/cli/src/modules/agents/agent-mcp-access.service.ts @@ -3,7 +3,7 @@ import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; import { CollaborationService } from '@/collaboration/collaboration.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { ProjectScopeService } from '@/permissions.ee/project-scope.service'; import type { Agent } from './entities/agent.entity'; diff --git a/packages/cli/src/modules/agents/agent-message-queue-consumer.service.ts b/packages/cli/src/modules/agents/agent-message-queue-consumer.service.ts index d090d515597..ccba5b2c232 100644 --- a/packages/cli/src/modules/agents/agent-message-queue-consumer.service.ts +++ b/packages/cli/src/modules/agents/agent-message-queue-consumer.service.ts @@ -1,9 +1,9 @@ import type { AgentSseEvent } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; -import { UserRepository } from '@n8n/db'; +import { UserRepository, type User } from '@n8n/db'; import { OnShutdown } from '@n8n/decorators'; import { Service } from '@n8n/di'; -import { OperationalError, UserError } from 'n8n-workflow'; +import { OperationalError, UnexpectedError, UserError } from 'n8n-workflow'; import { CredentialsService } from '@/credentials/credentials.service'; import { userHasScopes } from '@/permissions.ee/check-access'; @@ -15,6 +15,7 @@ import { AgentMessageQueueService, type ClaimedAgentMessage } from './agent-mess import { AgentQueuedPreviewStreamService } from './agent-queued-preview-stream.service'; import { emitChunkEvents } from './agent-sse-stream'; import { AgentTestRunService } from './agent-test-run.service'; +import type { AgentExecutionThread } from './entities/agent-execution-thread.entity'; import type { AgentChatBridge } from './integrations/agent-chat-bridge'; import { ChatIntegrationService } from './integrations/chat-integration.service'; import { AgentMessageQueueRepository } from './repositories/agent-message-queue.repository'; @@ -95,10 +96,12 @@ export class AgentMessageQueueConsumer { const claim = await this.queue.claimNext(threadId, async (item, thread, ctx) => { if (this.stopped) return false; if (item.payload.kind === 'preview') return true; + const source = item.message.origin?.source; + if (!source) throw new UnexpectedError('Queued integration input has no source'); const connection = await this.repository.findPublishedConnection( thread.agentId, thread.projectId, - item.source, + source, item.payload.credentialId, ctx, ); @@ -107,7 +110,7 @@ export class AgentMessageQueueConsumer { if (!connection) return true; lease = this.integrations.acquireQueueBridge( thread.agentId, - item.source, + source, item.payload.credentialId, ); return lease !== undefined; @@ -122,22 +125,23 @@ export class AgentMessageQueueConsumer { /** Execute a claimed message, deliver its output, and settle its queue item. */ private async consume(claim: ClaimedAgentMessage, bridge?: AgentChatBridge): Promise { - const { item, thread, admission } = claim; + const { item, thread, admission, payload } = claim; const controller = new AbortController(); const signal = AbortSignal.any([ controller.signal, this.executionService.getAbortSignal(admission.executionId), ]); const sender = - item.payload.kind === 'preview' ? this.previewStreams.createSender(item.id) : undefined; + payload.kind === 'preview' ? this.previewStreams.createSender(item.id) : undefined; try { - if (item.payload.kind === 'preview' && sender) { + if (payload.kind === 'preview' && sender) { + const user = await this.getPreviewOwner(thread); this.chatExecutionService.register( { projectId: thread.projectId, agentId: thread.agentId, threadId: thread.id, - userId: item.payload.userId, + userId: user.id, executionId: admission.executionId, }, controller, @@ -147,11 +151,11 @@ export class AgentMessageQueueConsumer { executionId: admission.executionId, sessionId: thread.id, inputMessageIds: admission.inputMessageIds, - message: item.payload.message, + message: payload.message, }); await this.chatExecutionService.settle( admission.executionId, - async () => await this.consumePreview(claim, signal, sender.send), + async () => await this.consumePreview(claim, user, signal, sender.send), ); } else { await this.consumeIntegration(claim, signal, bridge); @@ -173,14 +177,9 @@ export class AgentMessageQueueConsumer { } } - private async consumePreview( - claim: ClaimedAgentMessage, - signal: AbortSignal, - send: (event: AgentSseEvent) => void, - ): Promise { - const { item, thread, admission } = claim; - if (item.payload.kind !== 'preview') return; - const user = await this.userRepository.findByIdWithRole(item.payload.userId); + private async getPreviewOwner(thread: AgentExecutionThread): Promise { + if (!thread.ownerId) throw new UserError('You can no longer execute this agent'); + const user = await this.userRepository.findByIdWithRole(thread.ownerId); if ( !user || user.disabled || @@ -188,6 +187,17 @@ export class AgentMessageQueueConsumer { ) { throw new UserError('You can no longer execute this agent'); } + return user; + } + + private async consumePreview( + claim: ClaimedAgentMessage, + user: User, + signal: AbortSignal, + send: (event: AgentSseEvent) => void, + ): Promise { + const { thread, admission, payload } = claim; + if (payload.kind !== 'preview') return; signal.throwIfAborted(); const prepared = await this.testRunService.prepareDraftRun({ agentId: thread.agentId, @@ -222,12 +232,12 @@ export class AgentMessageQueueConsumer { agentId: thread.agentId, projectId: thread.projectId, user, - message: item.payload.message, - attachments: item.payload.attachments, + message: payload.message, + attachments: payload.attachments, sessionId: thread.id, sessionMode: 'existing', previewChat: true, - source: item.source, + source: claim.recording.source, admittedExecution: admission, abortSignal: signal, errorMode: 'forward', @@ -242,16 +252,16 @@ export class AgentMessageQueueConsumer { signal: AbortSignal, bridge?: AgentChatBridge, ): Promise { - const { item, thread } = claim; - if (item.payload.kind !== 'integration') return; + const { thread, payload } = claim; + if (payload.kind !== 'integration') return; const connection = await this.repository.findPublishedConnection( thread.agentId, thread.projectId, - item.source, - item.payload.credentialId, + payload.messageContext.platform, + payload.credentialId, ); if (!connection) throw new UserError('The message integration is no longer configured'); if (!bridge) throw new OperationalError('The message integration is unavailable'); - await bridge.consumeQueuedMessage(item.payload, thread.id, claim.admission, signal, connection); + await bridge.consumeQueuedMessage(payload, thread.id, claim.admission, signal, connection); } } diff --git a/packages/cli/src/modules/agents/agent-message-queue.service.ts b/packages/cli/src/modules/agents/agent-message-queue.service.ts index 8bd3060d30d..731518e4f1c 100644 --- a/packages/cli/src/modules/agents/agent-message-queue.service.ts +++ b/packages/cli/src/modules/agents/agent-message-queue.service.ts @@ -1,28 +1,42 @@ -import type { AgentChatQueueResponse } from '@n8n/api-types'; +import type { Message } from '@n8n/agents'; +import type { AgentChatQueueResponse, AgentMessageAuthor } from '@n8n/api-types'; import { TransactionRunner, type OperationContext } from '@n8n/db'; import { Service } from '@n8n/di'; -import { OperationalError, UserError } from 'n8n-workflow'; +import { OperationalError, UnexpectedError, UserError } from 'n8n-workflow'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ConflictError, BadRequestError, NotFoundError } from '@n8n/errors'; import { AgentChatAttachmentService } from './agent-chat-attachment.service'; +import { AgentMessageSteeringService } from './agent-message-steering.service'; import { AgentExecutionUpdateBroadcaster } from './agent-execution-update-broadcaster'; import { AgentExecutionService, type StartExecutionParams } from './agent-execution.service'; import type { AgentExecutionThread } from './entities/agent-execution-thread.entity'; import type { AgentMessageQueue } from './entities/agent-message-queue.entity'; +import type { AgentMessageOrigin } from './entities/agent-message.entity'; import { ExecutionRecorder } from './execution-recorder'; import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage'; import { AgentExecutionRepository } from './repositories/agent-execution.repository'; import { AgentExecutionThreadRepository } from './repositories/agent-execution-thread.repository'; import { AgentMessageQueueRepository } from './repositories/agent-message-queue.repository'; +import { + AgentMessageIdConflictError, + AgentMessageRepository, +} from './repositories/agent-message.repository'; import { AgentRepository } from './repositories/agent.repository'; -import type { AgentExecutionAdmission, AgentQueuedMessage } from './types/agent-queued-message'; +import type { + AgentExecutionAdmission, + AgentQueuedMessage, + AgentQueueDispatch, + QueuedIntegrationMessage, + QueuedPreviewMessage, +} from './types/agent-queued-message'; import { canContinueThreadInPreview, type AgentSessionMode } from './utils/agent-thread-access'; +import { buildInboundUserMessage, readInboundUserMessage } from './utils/inbound-attachments'; +import { queuedMessageId } from './utils/queued-message-id'; export interface ClaimedAgentMessage { item: AgentMessageQueue; + payload: Omit | QueuedIntegrationMessage; thread: AgentExecutionThread; /** Admission identifies the committed execution that the turn pipeline must reuse. */ admission: AgentExecutionAdmission; @@ -44,6 +58,8 @@ export class AgentMessageQueueService { private readonly agentRepository: AgentRepository, private readonly attachments: AgentChatAttachmentService, private readonly updates: AgentExecutionUpdateBroadcaster, + private readonly messages: AgentMessageRepository, + private readonly steering: AgentMessageSteeringService, ) {} /** Save a pending message. It is durably accepted when the transaction commits. */ @@ -57,32 +73,92 @@ export class AgentMessageQueueService { payload: AgentQueuedMessage; }, onInserted?: (queueId: string) => void, - ): Promise { + ): Promise<{ status: 'accepted'; item: AgentMessageQueue } | { status: 'duplicate' }> { const agent = await this.agentRepository.findByIdAndProjectId(input.agentId, input.projectId); if (!agent) throw new UserError('Agent not found'); const { payload } = input; - const item = await this.txRunner.run({}, async (ctx) => { - await this.executionService.prepareThread( - { - ...input, - agentName: agent.name, - userMessage: payload.message, - resourceId: payload.resourceId, - access: - payload.kind === 'preview' - ? { accessScope: 'user', ownerId: payload.userId } - : { accessScope: 'project', ownerId: null }, - }, - ctx, - ); - const inserted = await this.repository.enqueue(input.threadId, input.source, payload, ctx); - // Register delivery before another main can see the committed item. - onInserted?.(inserted.id); - return inserted; - }); + const messageId = queuedMessageId(input.agentId, input.threadId, payload); + let item: AgentMessageQueue; + try { + item = await this.txRunner.run({}, async (ctx) => { + await this.executionService.prepareThread( + { + ...input, + agentName: agent.name, + userMessage: payload.message, + resourceId: payload.resourceId, + access: + payload.kind === 'preview' + ? { accessScope: 'user', ownerId: payload.userId } + : { accessScope: 'project', ownerId: null }, + }, + ctx, + ); + const inserted = await this.enqueueMessage( + input.threadId, + input.source, + payload, + ctx, + messageId, + ); + // Register delivery before another main can see the committed item. + onInserted?.(inserted.id); + return inserted; + }); + } catch (error) { + // A duplicate must roll back session and thread creation before it returns. + if (error instanceof AgentMessageIdConflictError && error.messageId === messageId) { + return { status: 'duplicate' }; + } + throw error; + } if (payload.kind === 'preview') this.updates.notifyQueueUpdated(item.threadId); this.onAvailable?.(item.threadId); - return item; + return { status: 'accepted', item }; + } + + private async enqueueMessage( + threadId: string, + source: string, + payload: AgentQueuedMessage, + ctx: OperationContext, + messageId?: string, + ): Promise { + const { message, resourceId, attachments = [], ...dispatch } = payload; + const [content] = buildInboundUserMessage(message, attachments); + let queueDispatch: AgentQueueDispatch = { kind: 'preview' }; + let modelContent: Message | undefined; + let author: AgentMessageAuthor | undefined; + let origin: AgentMessageOrigin = { source }; + if (dispatch.kind === 'integration') { + const { + modelMessage, + author: inputAuthor, + platformThreadId, + messageContext: fullContext, + ...integrationDispatch + } = dispatch; + const { + platform: _platform, + integrationConnectionId, + messageId: platformMessageId, + ...messageContext + } = fullContext; + queueDispatch = { ...integrationDispatch, messageContext }; + [modelContent] = buildInboundUserMessage(modelMessage, attachments); + author = inputAuthor; + origin = { + source, + integrationConnectionId, + platformMessageId, + platformThreadId, + }; + } + const input = await this.messages.createInput( + { id: messageId, threadId, resourceId, content, modelContent, author, origin }, + ctx, + ); + return await this.repository.enqueue(threadId, input.id, queueDispatch, ctx); } async listPending(input: { @@ -93,16 +169,24 @@ export class AgentMessageQueueService { }): Promise { const thread = await this.threadRepository.findOneBy({ id: input.threadId }); // A client-created Preview session can have no accepted messages yet. - if (!thread) return { items: [] }; + if (!thread) return { items: [], steerableExecutionId: null }; await this.assertPreviewAccess(thread, input); const items = await this.repository.listPending(thread.id); + const steerable = await this.steering.findEligible(thread); return { + steerableExecutionId: steerable?.id ?? null, items: items .filter((item) => item.payload.kind === 'preview') + // Show the next inputs first. Keep unreserved messages in their original FIFO order. + .sort( + (a, b) => + (a.steeringOrder ?? Number.MAX_SAFE_INTEGER) - + (b.steeringOrder ?? Number.MAX_SAFE_INTEGER), + ) .map((item) => ({ id: item.id, - message: item.payload.message, - attachments: item.payload.attachments, + ...readInboundUserMessage(item.message.content), + steeringExecutionId: item.steeringExecutionId, createdAt: item.createdAt.toISOString(), })), }; @@ -115,7 +199,7 @@ export class AgentMessageQueueService { userId: string; queueId: string; }): Promise { - const item = await this.txRunner.run({}, async (ctx) => { + const removed = await this.txRunner.run({}, async (ctx) => { const thread = await this.threadRepository.lockById(input.threadId, ctx); if (!thread) throw new NotFoundError('Session not found'); await this.assertPreviewAccess(thread, input, ctx); @@ -124,14 +208,19 @@ export class AgentMessageQueueService { throw new NotFoundError('Queued message not found'); if ( item.executionId !== null || + item.steeringExecutionId !== null || !(await this.repository.removePending(thread.id, item.id, ctx)) ) { throw new ConflictError('This message has already started'); } - return item; + const attachmentIds = readInboundUserMessage(item.message.content).attachments.map( + ({ id }) => id, + ); + await this.messages.clearPendingInput(item.messageId, ctx); + return { threadId: item.threadId, attachmentIds }; }); - this.updates.notifyQueueUpdated(item.threadId); - await this.attachments.deleteByIds(item.payload.attachments?.map(({ id }) => id) ?? []); + this.updates.notifyQueueUpdated(removed.threadId); + await this.attachments.deleteByIds(removed.attachmentIds); } async updatePending(input: { @@ -150,16 +239,44 @@ export class AgentMessageQueueService { if (!item || item.payload.kind !== 'preview') throw new NotFoundError('Queued message not found'); if (item.executionId !== null) throw new ConflictError('This message has already started'); + if (item.steeringExecutionId !== null) + throw new ConflictError('This message is no longer available'); const message = input.message.trim(); - if (!message && !item.payload.attachments?.length) + const { attachments } = readInboundUserMessage(item.message.content); + if (!message && !attachments.length) throw new BadRequestError('A message or attachment is required'); - const updated = await this.repository.updatePendingPayload( - thread.id, - item.id, - { ...item.payload, message }, - ctx, - ); - if (!updated) throw new ConflictError('This message has already started'); + const [content] = buildInboundUserMessage(message, attachments); + await this.messages.updatePendingInput(item.messageId, content, ctx); + }); + this.updates.notifyQueueUpdated(input.threadId); + } + + async steer(input: { + projectId: string; + agentId: string; + threadId: string; + userId: string; + queueId: string; + executionId: string; + }): Promise { + await this.txRunner.run({}, async (ctx) => { + const thread = await this.threadRepository.lockById(input.threadId, ctx); + if (!thread) throw new NotFoundError('Session not found'); + await this.assertPreviewAccess(thread, input, ctx); + const item = await this.repository.findItem(thread.id, input.queueId, ctx); + if (!item || item.payload.kind !== 'preview') + throw new ConflictError('This message is no longer available'); + const execution = await this.steering.findEligible(thread, ctx); + if ( + execution?.id !== input.executionId || + item.executionId !== null || + item.steeringExecutionId !== null + ) { + throw new ConflictError('This message cannot be added to that execution'); + } + if (!(await this.repository.reserveSteering(thread.id, item.id, execution.id, ctx))) { + throw new ConflictError('This message is no longer available'); + } }); this.updates.notifyQueueUpdated(input.threadId); } @@ -191,26 +308,39 @@ export class AgentMessageQueueService { ctx: OperationContext, ) => Promise, ): Promise { + let steeringChanged = false; const claimed = await this.txRunner.run({}, async (ctx) => { const thread = await this.threadRepository.lockById(threadId, ctx); - if (!thread || (await this.isBlocked(thread, ctx))) return null; + if (!thread) return null; + steeringChanged = await this.steering.releaseInactive(thread, ctx); + if (await this.isBlocked(thread, ctx)) return null; const active = await this.repository.findActive(threadId, ctx); if (active?.executionId) await this.repository.removeActive(threadId, active.executionId, ctx); const item = await this.repository.findHead(threadId, ctx); - if (!item || !(await canConsume(item, thread, ctx))) return null; - const recording = this.recordingFor(item, thread); + if (!item || item.steeringExecutionId !== null || !(await canConsume(item, thread, ctx))) + return null; + const payload = this.restoreInput(item); + const recording = this.recordingFor(item, thread, payload); + await this.threadRepository.bumpUpdatedAt(threadId, ctx); // Reservation creates the running execution and links it to this item in one transaction. // Runtime work starts only after the transaction commits. - const reservation = await this.executionService.reserveExecution(recording, new Date(), ctx); - return { item, thread, recording, reservation }; + const reservation = await this.executionService.reserveExecution( + recording, + new Date(), + ctx, + thread, + ); + return { item, thread, payload, recording, reservation }; }); + if (steeringChanged) this.updates.notifyQueueUpdated(threadId); if (!claimed) return null; this.executionService.activateExecution(claimed.reservation, claimed.recording); const { execution } = claimed.reservation; if (!execution.startedAt) throw new OperationalError('Queued execution has no start time'); return { item: claimed.item, + payload: claimed.payload, thread: claimed.thread, recording: claimed.recording, admission: { @@ -229,10 +359,12 @@ export class AgentMessageQueueService { await this.txRunner.run({}, async (ctx) => { const thread = await this.threadRepository.lockById(threadId, ctx); if (!thread) return; + await this.steering.release(threadId, executionId, ctx); const active = await this.repository.findActive(threadId, ctx); if (active?.executionId !== executionId || (await this.isBlocked(thread, ctx))) return; await this.repository.removeActive(threadId, executionId, ctx); }); + this.updates.notifyQueueUpdated(threadId); this.onAvailable?.(threadId); } @@ -248,6 +380,8 @@ export class AgentMessageQueueService { if (!signal?.aborted) recorder.record({ type: 'error', error }); recorder.record({ type: 'finish', finishReason: 'error' }); const record = recorder.getMessageRecord(); + // Preserve committed input when a later preparation or recording step fails. + if (execution.timeline) record.timeline = execution.timeline; await this.executionService.finalizeExecution(executionId, { ...claim.recording, record: signal?.aborted ? { ...record, finishReason: 'cancelled', error: null } : record, @@ -264,6 +398,7 @@ export class AgentMessageQueueService { private recordingFor( item: AgentMessageQueue, thread: AgentExecutionThread, + payload: ClaimedAgentMessage['payload'], ): StartExecutionParams { return { threadId: thread.id, @@ -273,18 +408,38 @@ export class AgentMessageQueueService { access: { accessScope: thread.accessScope, ownerId: thread.ownerId }, sessionMode: 'existing', queueItemId: item.id, - userMessage: item.payload.message, - resourceId: item.payload.resourceId, - ...(item.payload.kind === 'integration' && { - messageOrigin: { - integrationConnectionId: item.payload.messageContext.integrationConnectionId, - platformMessageId: item.payload.messageContext.messageId, - platformThreadId: item.payload.platformThreadId, - }, - }), - source: item.source, - attachments: item.payload.attachments, - author: item.payload.kind === 'integration' ? item.payload.author : undefined, + previewChat: item.payload.kind === 'preview', + userMessage: payload.message, + resourceId: payload.resourceId, + source: item.message.origin?.source ?? undefined, + attachments: payload.attachments, + author: payload.kind === 'integration' ? payload.author : undefined, + }; + } + + private restoreInput(item: AgentMessageQueue): ClaimedAgentMessage['payload'] { + const input = { + ...readInboundUserMessage(item.message.content), + resourceId: item.message.resourceId, + }; + if (item.payload.kind === 'preview') return { ...item.payload, ...input }; + if (!item.message.author) throw new UnexpectedError('Queued integration input has no author'); + const { origin } = item.message; + if (!origin?.source || !origin.integrationConnectionId || !origin.platformThreadId) + throw new UnexpectedError('Queued integration input has incomplete origin'); + return { + ...item.payload, + ...input, + platformThreadId: origin.platformThreadId, + messageContext: { + ...item.payload.messageContext, + platform: origin.source, + integrationConnectionId: origin.integrationConnectionId, + ...(origin.platformMessageId !== undefined ? { messageId: origin.platformMessageId } : {}), + }, + author: item.message.author, + modelMessage: readInboundUserMessage(item.message.modelContent ?? item.message.content) + .message, }; } } diff --git a/packages/cli/src/modules/agents/agent-message-steering.service.ts b/packages/cli/src/modules/agents/agent-message-steering.service.ts new file mode 100644 index 00000000000..8aac0f4973d --- /dev/null +++ b/packages/cli/src/modules/agents/agent-message-steering.service.ts @@ -0,0 +1,228 @@ +import type { AgentDbMessage, AgentInputBoundary } from '@n8n/agents'; +import { TransactionRunner, type OperationContext } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { OperationalError, UnexpectedError } from 'n8n-workflow'; + +import { AgentExecutionService } from './agent-execution.service'; +import { AgentExecutionUpdateBroadcaster } from './agent-execution-update-broadcaster'; +import { messageToDto } from './agent-message-mapper'; +import type { AgentExecutionThread } from './entities/agent-execution-thread.entity'; +import type { AgentMessageQueue } from './entities/agent-message-queue.entity'; +import { type ExecutionRecorder, type TimelineEvent } from './execution-recorder'; +import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage'; +import { AgentExecutionRepository } from './repositories/agent-execution.repository'; +import { AgentExecutionThreadRepository } from './repositories/agent-execution-thread.repository'; +import { AgentMessageQueueRepository } from './repositories/agent-message-queue.repository'; +import { AgentMessageRepository } from './repositories/agent-message.repository'; +import { checkpointExecutionId } from './types/agent-queued-message'; +import type { SteeringConsumption } from './types/agent-steering'; +import { draftChatMemoryResourceId } from './utils/agent-memory-scope'; + +interface SteeringContext { + agentId: string; + projectId: string; + threadId: string; + executionId: string; + userId: string; +} + +@Service() +export class AgentMessageSteeringService { + constructor( + private readonly txRunner: TransactionRunner, + private readonly queue: AgentMessageQueueRepository, + private readonly threads: AgentExecutionThreadRepository, + private readonly executions: AgentExecutionRepository, + private readonly messages: AgentMessageRepository, + private readonly checkpoints: N8NCheckpointStorage, + private readonly executionService: AgentExecutionService, + private readonly updates: AgentExecutionUpdateBroadcaster, + ) {} + + async findEligible(thread: AgentExecutionThread, ctx: OperationContext = {}) { + const execution = await this.executions.findSteerable(thread.id, ctx); + if (!execution) return null; + if (await this.checkpoints.findSuspendedForThread(thread.agentId, thread.id, ctx)) return null; + return execution; + } + + async close(threadId: string, executionId: string): Promise { + await this.txRunner.run({}, async (ctx) => { + if (!(await this.threads.lockById(threadId, ctx))) return; + await this.release(threadId, executionId, ctx); + }); + this.updates.notifyQueueUpdated(threadId); + } + + /** Return unconsumed input to its original FIFO position. The caller holds the session lock. */ + async release(threadId: string, executionId: string, ctx: OperationContext) { + await this.executions.closeSteering(threadId, executionId, ctx); + return await this.queue.releaseSteering(threadId, executionId, ctx); + } + + async releaseInactive(thread: AgentExecutionThread, ctx: OperationContext): Promise { + const ids = await this.queue.findSteeringExecutionIds(thread.id, ctx); + if (ids.length === 0) return false; + const checkpoint = await this.checkpoints.findSuspendedForThread( + thread.agentId, + thread.id, + ctx, + ); + const suspendedExecutionId = checkpoint && checkpointExecutionId(checkpoint); + let changed = false; + for (const id of ids) { + const execution = await this.executions.findExecution(id, ctx); + if ( + execution?.status === 'running' && + execution.acceptsSteering && + id !== suspendedExecutionId + ) + continue; + changed = (await this.release(thread.id, id, ctx)) || changed; + } + return changed; + } + + async consume( + context: SteeringContext, + boundary: AgentInputBoundary, + recorder: ExecutionRecorder, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + const result = await this.executionService.withTimelineWritesPaused( + context.executionId, + async () => { + const timeline = structuredClone(recorder.getMessageRecord().timeline); + try { + const consumed = await this.txRunner.run( + {}, + async (ctx) => await this.consumeLocked(context, boundary, timeline, signal, ctx), + ); + recorder.recordInputs(inputMarkers(consumed)); + return consumed; + } catch (error) { + // A commit can succeed before its acknowledgement fails. Keep its input in later snapshots. + await this.restoreCommittedInputs(context.executionId, recorder); + throw error; + } + }, + ); + if (result.events.length || boundary.completing || !boundary.canContinue || result.stopped) { + this.updates.notifyQueueUpdated(context.threadId); + this.updates.notify(context); + } + return result; + } + + private async consumeLocked( + context: SteeringContext, + boundary: AgentInputBoundary, + timeline: TimelineEvent[], + signal: AbortSignal, + ctx: OperationContext, + ): Promise { + const { threadId, executionId, userId } = context; + const thread = await this.threads.lockById(threadId, ctx); + const execution = await this.executions.findExecution(executionId, ctx); + signal.throwIfAborted(); + if (!thread || execution?.threadId !== threadId || execution.status !== 'running') { + throw new OperationalError('Agent execution ownership was lost'); + } + if (!execution.acceptsSteering) return { messages: [], events: [], stopped: true }; + if (!boundary.canContinue) { + await this.release(threadId, executionId, ctx); + return { messages: [], events: [], stopped: false }; + } + const items = await this.queue.findSteering(threadId, executionId, ctx); + if (items.length === 0) { + if (boundary.completing) await this.executions.closeSteering(threadId, executionId, ctx); + return { messages: [], events: [], stopped: false }; + } + const resourceId = draftChatMemoryResourceId(userId); + const consumed = this.prepareInput(items, executionId, boundary.lastCreatedAt); + for (const { messageId } of items) { + await this.messages.linkExecutionInput(executionId, messageId, { threadId, resourceId }, ctx); + } + await this.messages.saveRuntimeMessages( + { threadId, resourceId, executionId, messages: [...boundary.messages, ...consumed.messages] }, + ctx, + ); + signal.throwIfAborted(); + if ( + !(await this.executions.updateTimelineIfRunning( + executionId, + [...timeline, ...inputMarkers(consumed)], + ctx, + )) + ) { + throw new OperationalError('Agent execution ownership was lost'); + } + await this.queue.consumeSteering( + threadId, + executionId, + items.map(({ id }) => id), + ctx, + ); + return consumed; + } + + private prepareInput( + items: AgentMessageQueue[], + executionId: string, + lastCreatedAt: number, + ): SteeringConsumption { + let timestamp = Math.max(Date.now(), lastCreatedAt + 1); + const messages: AgentDbMessage[] = []; + const events: SteeringConsumption['events'] = []; + for (const item of items) { + if (item.payload.kind !== 'preview') + throw new UnexpectedError('Only Preview input can be steered'); + const message: AgentDbMessage = { + ...(item.message.modelContent ?? item.message.content), + id: item.messageId, + createdAt: new Date(timestamp++), + }; + const dto = messageToDto({ + ...item.message.content, + id: item.messageId, + createdAt: item.message.createdAt, + }); + if (dto?.role !== 'user') throw new UnexpectedError('Steering input must be a user message'); + messages.push(message); + events.push({ + type: 'message-steered', + queueId: item.id, + executionId, + message: { + ...dto, + executionId, + ...(item.message.author ? { author: item.message.author } : {}), + }, + }); + } + return { messages, events, stopped: false }; + } + + private async restoreCommittedInputs( + executionId: string, + recorder: ExecutionRecorder, + ): Promise { + try { + const execution = await this.executions.findExecution(executionId); + recorder.recordInputs((execution?.timeline ?? []).filter((event) => event.type === 'input')); + } catch { + // Recovery retains the committed database timeline if recording cannot finish. + } + } +} + +function inputMarkers( + consumed: SteeringConsumption, +): Array> { + return consumed.messages.map((message) => ({ + type: 'input', + messageId: message.id, + timestamp: message.createdAt.getTime(), + })); +} diff --git a/packages/cli/src/modules/agents/agent-publish.service.ts b/packages/cli/src/modules/agents/agent-publish.service.ts index 257edabfe4a..5c0f3aedd7c 100644 --- a/packages/cli/src/modules/agents/agent-publish.service.ts +++ b/packages/cli/src/modules/agents/agent-publish.service.ts @@ -8,6 +8,7 @@ import { type AgentVersionListItemDto, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { isUniqueConstraintError, type User } from '@n8n/db'; import { Container, Service } from '@n8n/di'; import { TELEMETRY_EVENT } from '@n8n/telemetry'; @@ -17,9 +18,7 @@ import { deepCopy, UserError } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { CredentialsService } from '@/credentials/credentials.service'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { ConflictError, NotFoundError } from '@n8n/errors'; import { getMissingSkillIds } from '@/modules/agents/utils/agent-missing-skill-ids'; import { Telemetry } from '@/telemetry'; diff --git a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts index 1b266bee379..1db4c990827 100644 --- a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts +++ b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts @@ -84,6 +84,7 @@ import { createN8nDelegateSubAgentTool } from './sub-agents/delegate-sub-agent-t import { SubAgentRunner } from './sub-agents/sub-agent-runner'; import { buildToolRegistry, type ReferencedToolKind, type ToolRegistry } from './tool-registry'; import { createGetEnvironmentTool } from './tools/environment-tool'; +import { createMarkSessionFailedTool } from './tools/mark-session-failed.tool'; import type { WorkflowToolExecutionMode } from './tools/workflow-tool-factory'; import { WorkflowToolUnavailableError } from './tools/workflow-tool-unavailable-error'; import { findWorkflowToolWorkflow } from './tools/workflow-tool-workflow-resolver'; @@ -1027,6 +1028,7 @@ export class AgentRuntimeReconstructionService { }; await this.attachSubAgentDelegationTool({ ...delegationParams, config, parentWorkspaceHandle }); this.attachWriteTodosTool(agent, agentId); + agent.tool(createMarkSessionFailedTool()); if (!backgroundTasksEnabled) return; await this.attachBackgroundJobTools({ ...delegationParams, diff --git a/packages/cli/src/modules/agents/agent-sandbox.controller.ts b/packages/cli/src/modules/agents/agent-sandbox.controller.ts index 6f78ecef066..e5e68744931 100644 --- a/packages/cli/src/modules/agents/agent-sandbox.controller.ts +++ b/packages/cli/src/modules/agents/agent-sandbox.controller.ts @@ -3,7 +3,7 @@ import type { AuthenticatedRequest } from '@n8n/db'; import { Param, Post, ProjectScope, RestController } from '@n8n/decorators'; import type { Response } from 'express'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { AgentKnowledgeService } from './agent-knowledge.service'; import { AgentSandboxRuntimeService } from './agent-sandbox-runtime.service'; diff --git a/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts b/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts index 0d9b974965a..a7636003153 100644 --- a/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts +++ b/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts @@ -13,9 +13,7 @@ import { nanoid } from 'nanoid'; import { v5 as uuidv5 } from 'uuid'; import { N8N_VERSION } from '@/constants'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; +import { ConflictError, NotFoundError, ServiceUnavailableError } from '@n8n/errors'; import { AiService } from '@/services/ai.service'; import { ProxyTokenManager } from '@/services/proxy-token-manager'; import { createAiProxyFetch } from '@/utils/ai-proxy-fetch'; @@ -317,6 +315,20 @@ function buildExecutionRun( function buildEventRun(event: TimelineEvent, execution: AgentExecution, path: string): DraftRun { switch (event.type) { + case 'input': + return { + path, + name: 'Additional user input', + runType: 'chain', + startTime: event.timestamp, + endTime: event.timestamp, + inputs: { + message: execution.inputMessages?.find(({ id }) => id === event.messageId), + }, + outputs: {}, + metadata: {}, + children: [], + }; case 'background-task-signal': return buildBackgroundTaskSignalRun(event, path); case 'text': diff --git a/packages/cli/src/modules/agents/agent-skills.service.ts b/packages/cli/src/modules/agents/agent-skills.service.ts index fce6bb50c39..2bf1be3691e 100644 --- a/packages/cli/src/modules/agents/agent-skills.service.ts +++ b/packages/cli/src/modules/agents/agent-skills.service.ts @@ -9,8 +9,7 @@ import { Container, Service } from '@n8n/di'; import isEqual from 'lodash/isEqual'; import { UserError } from 'n8n-workflow'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ConflictError, NotFoundError } from '@n8n/errors'; import { AgentModificationTelemetryService, diff --git a/packages/cli/src/modules/agents/agent-sse-stream.ts b/packages/cli/src/modules/agents/agent-sse-stream.ts index f08ad6e5684..555753cedc7 100644 --- a/packages/cli/src/modules/agents/agent-sse-stream.ts +++ b/packages/cli/src/modules/agents/agent-sse-stream.ts @@ -9,6 +9,8 @@ import { scrubSecretsInText } from '@n8n/utils/scrub-secrets'; import type { Response } from 'express'; import { LoggerProxy } from 'n8n-workflow'; +import type { AgentExecutionStreamChunk } from './types/agent-steering'; + export type FlushableResponse = Response & { flush?: () => void }; const SSE_HEARTBEAT_INTERVAL_MS = 30_000; @@ -56,7 +58,7 @@ export function initSseStream(res: FlushableResponse) { write(`data: ${JSON.stringify(event)}\n\n`); }; - const onChunk = (chunk: StreamChunk) => { + const onChunk = (chunk: AgentExecutionStreamChunk) => { if (abortController.signal.aborted) return; try { emitChunkEvents(chunk, send); @@ -212,8 +214,14 @@ function emitToolChunk( /** * Translate a single chunk into one or more SSE events. */ -export function emitChunkEvents(chunk: StreamChunk, send: (event: AgentSseEvent) => void): void { +export function emitChunkEvents( + chunk: AgentExecutionStreamChunk, + send: (event: AgentSseEvent) => void, +): void { switch (chunk.type) { + case 'message-steered': + send(chunk); + return; case 'start-step': send({ type: 'start-step' }); return; diff --git a/packages/cli/src/modules/agents/agent-task.service.ts b/packages/cli/src/modules/agents/agent-task.service.ts index 45b3ab56bf4..21005fddceb 100644 --- a/packages/cli/src/modules/agents/agent-task.service.ts +++ b/packages/cli/src/modules/agents/agent-task.service.ts @@ -10,8 +10,7 @@ import { randomUUID } from 'crypto'; import { DateTime } from 'luxon'; import { InstanceSettings, ScheduledTaskManager, type ScheduledTaskGroup } from 'n8n-core'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map'; import { diff --git a/packages/cli/src/modules/agents/agent-test-run.service.ts b/packages/cli/src/modules/agents/agent-test-run.service.ts index cf513759571..3e8b281a426 100644 --- a/packages/cli/src/modules/agents/agent-test-run.service.ts +++ b/packages/cli/src/modules/agents/agent-test-run.service.ts @@ -25,6 +25,7 @@ import { import { AgentExecutionService } from './agent-execution.service'; import { AgentValidationService } from './agent-validation.service'; import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage'; +import type { AgentExecutionStreamChunk } from './types/agent-steering'; import { draftChatMemoryResourceId } from './utils/agent-memory-scope'; import type { AgentSessionMode } from './utils/agent-thread-access'; @@ -53,7 +54,7 @@ interface DraftRunState { interface DraftRunConsumptionOptions { errorMode?: 'throw' | 'forward'; - onChunk?: (chunk: StreamChunk) => void; + onChunk?: (chunk: AgentExecutionStreamChunk) => void; } export interface ExecutePreparedDraftRunInput @@ -351,7 +352,7 @@ export class AgentTestRunService { } private async collectDraftRun( - stream: AsyncIterable, + stream: AsyncIterable, initialResponse: string, getExecutionId: () => string | undefined, { errorMode = 'throw', onChunk }: DraftRunConsumptionOptions, @@ -417,7 +418,7 @@ export class AgentTestRunService { } private observeDraftChunk( - chunk: StreamChunk, + chunk: AgentExecutionStreamChunk, onChunk: DraftRunConsumptionOptions['onChunk'], state: DraftRunState, ): void { @@ -431,7 +432,7 @@ export class AgentTestRunService { } private collectDraftChunk( - chunk: StreamChunk, + chunk: AgentExecutionStreamChunk, errorMode: DraftRunConsumptionOptions['errorMode'], state: DraftRunState, ): void { diff --git a/packages/cli/src/modules/agents/agent-threads.controller.ts b/packages/cli/src/modules/agents/agent-threads.controller.ts index f00d2698603..094881978d3 100644 --- a/packages/cli/src/modules/agents/agent-threads.controller.ts +++ b/packages/cli/src/modules/agents/agent-threads.controller.ts @@ -3,7 +3,7 @@ import type { AuthenticatedRequest } from '@n8n/db'; import { Delete, Get, Post, ProjectScope, Query, RestController } from '@n8n/decorators'; import type { Response } from 'express'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { AgentExecutionService } from './agent-execution.service'; import { AgentSessionLangSmithExportService } from './agent-session-langsmith-export.service'; diff --git a/packages/cli/src/modules/agents/agent-turn-execution.service.ts b/packages/cli/src/modules/agents/agent-turn-execution.service.ts index 52303e9a1eb..1900c42d39a 100644 --- a/packages/cli/src/modules/agents/agent-turn-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-turn-execution.service.ts @@ -3,6 +3,7 @@ import type { ExecutionOptions, ResumeOptions, RunOptions, + SideCallUsageReport, StreamChunk, } from '@n8n/agents'; import type { AgentBackgroundJobSignal } from '@n8n/api-types'; @@ -16,6 +17,8 @@ import { AgentExecutionRecordingError } from './agent-execution-recording.error' import { AgentTurnAlreadyRunningError } from './agent-turn-already-running.error'; import { AgentChatExecutionService } from './agent-chat-execution.service'; import { AgentMessageQueueService } from './agent-message-queue.service'; +import { AgentMessageSteeringService } from './agent-message-steering.service'; +import type { AgentExecutionStreamChunk, SteeredMessageEvent } from './types/agent-steering'; import { EXECUTION_METADATA_KEY, type AgentExecutionAdmission } from './types/agent-queued-message'; import { AgentExecutionService, @@ -25,6 +28,7 @@ import { import { buildToolCallDetails, ExecutionRecorder } from './execution-recorder'; import type { ToolRegistry } from './tool-registry'; import { streamAgentChunks } from './utils/agent-stream'; +import { MAX_ITERATIONS_STOPPED_MESSAGE } from './utils/fatal-session-outcome'; import { createAttributionTracker } from './utils/mcp-attribution'; import { bindExecutionInput } from './utils/execution-input'; @@ -62,6 +66,7 @@ interface ExecuteTurnConfig { } interface TurnExecutionState { + steeredMessages: Map; executionId?: string; executionStarted: boolean; executionError?: unknown; @@ -98,7 +103,7 @@ function getMaxIterationsChunks(): StreamChunk[] { { type: 'text-delta', id, - delta: 'The agent has reached the maximum number of iterations and has stopped.', + delta: MAX_ITERATIONS_STOPPED_MESSAGE, }, { type: 'text-end', id }, ]; @@ -111,16 +116,18 @@ export class AgentTurnExecutionService { private readonly agentExecutionService: AgentExecutionService, private readonly chatExecutionService: AgentChatExecutionService, private readonly messageQueue: AgentMessageQueueService, + private readonly steering: AgentMessageSteeringService, ) {} async getSessionMode(threadId: string): Promise { return await this.agentExecutionService.getSessionMode(threadId); } - async *execute(config: ExecuteTurnConfig): AsyncGenerator { + async *execute(config: ExecuteTurnConfig): AsyncGenerator { let turn: AgentTurnRequest | undefined; let previewControl: PreviewExecutionControl | undefined; const state: TurnExecutionState = { + steeredMessages: new Map(), executionStarted: false, receivedFinish: false, executionId: config.admittedExecution?.executionId, @@ -207,10 +214,22 @@ export class AgentTurnExecutionService { config: ExecuteTurnConfig, recorder: ExecutionRecorder, state: TurnExecutionState, - ): AsyncGenerator { + ): AsyncGenerator { const attributionTracker = createAttributionTracker(config.mcpServerAttributions); for await (const value of streamAgentChunks(stream)) { + if (value.type === 'input-boundary') { + value.acknowledge(); + continue; + } + if (value.type === 'input') { + const event = state.steeredMessages.get(value.message.id); + if (event) { + state.steeredMessages.delete(value.message.id); + yield event; + } + continue; + } const chunk = config.includeHitlToolDetails ? withApprovalToolDetails(value, config.toolRegistry) : value; @@ -396,6 +415,7 @@ export class AgentTurnExecutionService { const admission = await this.startExecution( { ...turn.recording, + previewChat: config.previewChat, resumeRunId: turn.type === 'resume' ? turn.options.runId : undefined, allowSuspendedPredecessor: config.automaticPreviewContinuation, ...(config.backgroundJobSignal @@ -434,6 +454,19 @@ export class AgentTurnExecutionService { [EXECUTION_METADATA_KEY]: executionId, }; } + if (config.previewChat && previewControl) { + turn.options.onInputBoundary = async (boundary) => { + const result = await this.steering.consume( + { ...config.context, executionId, userId: previewControl.userId }, + boundary, + recorder, + turn.options.abortSignal!, + ); + if (result.stopped) previewControl.controller.abort(); + for (const event of result.events) state.steeredMessages.set(event.message.id, event); + return result.messages; + }; + } if (previewControl) { this.chatExecutionService.register( { @@ -450,6 +483,14 @@ export class AgentTurnExecutionService { turn.options.abortSignal?.throwIfAborted(); await config.onAdmitted?.(); turn.options.abortSignal?.throwIfAborted(); + // Forward side-call model costs (title generation, observation-log + // observer/reflector, episodic-memory model calls) onto this execution + // row and its thread. The SDK prices each call; the host only adds the + // cost. Best-effort and idempotent per reportId. + const threadId = config.context.threadId; + turn.options.onSideCallUsage = (report: SideCallUsageReport) => { + void this.agentExecutionService.recordSideCallUsage(executionId, threadId, report); + }; return await this.startTurn(turn, config, recorder, state); } diff --git a/packages/cli/src/modules/agents/agent-upload.middleware.ts b/packages/cli/src/modules/agents/agent-upload.middleware.ts index f2181e6e385..a624ce9a41b 100644 --- a/packages/cli/src/modules/agents/agent-upload.middleware.ts +++ b/packages/cli/src/modules/agents/agent-upload.middleware.ts @@ -9,7 +9,7 @@ import multer from 'multer'; import { unlink } from 'node:fs/promises'; import path from 'node:path'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; const allowedAgentFileExtensions = new Set(ALLOWED_AGENT_FILE_EXTENSIONS); diff --git a/packages/cli/src/modules/agents/agents-catalog.controller.ts b/packages/cli/src/modules/agents/agents-catalog.controller.ts index 09180041682..b5f97ed95e7 100644 --- a/packages/cli/src/modules/agents/agents-catalog.controller.ts +++ b/packages/cli/src/modules/agents/agents-catalog.controller.ts @@ -8,7 +8,7 @@ import type { AuthenticatedRequest } from '@n8n/db'; import { Get, Param, ProjectScope, Query, RestController } from '@n8n/decorators'; import type { Response } from 'express'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { AgentIntegrationPersistenceService } from './agent-integration-persistence.service'; import { AgentModelCatalogService } from './agent-model-catalog.service'; diff --git a/packages/cli/src/modules/agents/agents-config.controller.ts b/packages/cli/src/modules/agents/agents-config.controller.ts index b6e133ef461..38bc3da3b08 100644 --- a/packages/cli/src/modules/agents/agents-config.controller.ts +++ b/packages/cli/src/modules/agents/agents-config.controller.ts @@ -11,7 +11,7 @@ import { AgentRepository } from './repositories/agent.repository'; import { getAgentConfigHash } from './utils/agent-config-hash'; import { CollaborationService } from '@/collaboration/collaboration.service'; import { CredentialsService } from '@/credentials/credentials.service'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; @RestController('/projects/:projectId/agents/v2') export class AgentsConfigController { diff --git a/packages/cli/src/modules/agents/agents.controller.ts b/packages/cli/src/modules/agents/agents.controller.ts index b00ff42848d..97174d953d1 100644 --- a/packages/cli/src/modules/agents/agents.controller.ts +++ b/packages/cli/src/modules/agents/agents.controller.ts @@ -12,7 +12,7 @@ import { } from '@n8n/decorators'; import type { Response } from 'express'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { CollaborationService } from '@/collaboration/collaboration.service'; import { AgentRunnableStateService } from './agent-runnable-state.service'; diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index 273850ce73d..e6ea67ae618 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -12,6 +12,7 @@ import { type ListAgentsQueryDto, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { In, isUniqueConstraintError, ProjectRelationRepository, type User } from '@n8n/db'; import { Container, Service } from '@n8n/di'; import { hasGlobalScope } from '@n8n/permissions'; @@ -22,8 +23,7 @@ import { v4 as uuid } from 'uuid'; // in this area (see `agents-credential-provider.ts`). Resolved lazily by DI. // eslint-disable-next-line import-x/no-cycle import { CredentialsService } from '@/credentials/credentials.service'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { EventService } from '@/events/event.service'; +import { ConflictError } from '@n8n/errors'; import { getAgentOrThrow } from './utils/get-agent-or-throw'; import { AgentChatAttachmentService } from './agent-chat-attachment.service'; diff --git a/packages/cli/src/modules/agents/builder/__tests__/format-preview-context.test.ts b/packages/cli/src/modules/agents/builder/__tests__/format-preview-context.test.ts index d2f6bda3b43..0e9b8bfaa13 100644 --- a/packages/cli/src/modules/agents/builder/__tests__/format-preview-context.test.ts +++ b/packages/cli/src/modules/agents/builder/__tests__/format-preview-context.test.ts @@ -72,10 +72,29 @@ describe('formatPreviewSessionContext', () => { makeExecution({ id: 'exec-1', userMessage: 'First question', + inputMessages: [ + { + id: 'initial-input', + role: 'user', + content: [{ type: 'text', text: 'First question' }], + }, + { + id: 'steered-input', + role: 'user', + content: [ + { type: 'file', fileId: 'file-1', fileName: 'orders.csv', mimeType: 'text/csv' }, + ], + }, + ], timeline: [ { type: 'reasoning', content: 'I should search the orders.', timestamp: 0, endTime: 1 }, toolCallEvent(), { type: 'text', content: 'Here are your orders.', timestamp: 1, endTime: 2 }, + { + type: 'input', + timestamp: 3, + messageId: 'steered-input', + }, ], }), makeExecution({ id: 'exec-2', userMessage: 'Second question' }), @@ -95,6 +114,11 @@ describe('formatPreviewSessionContext', () => { expect(block).toContain('Input: {"query":"open orders"}'); expect(block).toContain('Output: {"count":2}'); expect(block).toContain('Assistant: Here are your orders.'); + expect(block!.match(/User: First question/g)).toHaveLength(1); + expect(block!.match(/User: \[Attachment: orders\.csv\]/g)).toHaveLength(1); + expect(block!.indexOf('User: [Attachment: orders.csv]')).toBeGreaterThan( + block!.indexOf('Assistant: Here are your orders.'), + ); }); it('scopes a single turn to the anchor plus trailing resume continuations', () => { diff --git a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts index c31d9d84a2c..b5d381da65d 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts @@ -44,8 +44,7 @@ import { z } from 'zod'; import { CredentialTypes } from '@/credential-types'; import { CollaborationService } from '@/collaboration/collaboration.service'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { LockedError } from '@/errors/response-errors/locked.error'; +import { ConflictError, LockedError } from '@n8n/errors'; import { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service'; import { NodeTypes } from '@/node-types'; import { OauthService } from '@/oauth/oauth.service'; diff --git a/packages/cli/src/modules/agents/builder/agents-builder.service.ts b/packages/cli/src/modules/agents/builder/agents-builder.service.ts index 9cfb9cbac04..178c186a6d6 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder.service.ts @@ -27,7 +27,7 @@ import { type ReportRequiredArtifactInput, } from '@n8n/instance-ai'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { NodeCatalogService } from '@/node-catalog'; import { InstanceAiCreditService } from '../../instance-ai/instance-ai-credit.service'; diff --git a/packages/cli/src/modules/agents/builder/format-preview-context.ts b/packages/cli/src/modules/agents/builder/format-preview-context.ts index 905c3495f2e..c80cda2d532 100644 --- a/packages/cli/src/modules/agents/builder/format-preview-context.ts +++ b/packages/cli/src/modules/agents/builder/format-preview-context.ts @@ -1,3 +1,5 @@ +import type { AgentPersistedMessageDto } from '@n8n/api-types'; + import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity'; import type { AgentExecution } from '../entities/agent-execution.entity'; import type { TimelineEvent } from '../execution-recorder'; @@ -33,7 +35,22 @@ function stringifyToolValue(value: unknown): string { return truncate(serialized, MAX_TOOL_VALUE_CHARS); } -function formatTimelineEvent(event: TimelineEvent): string { +function formatInput(message: AgentPersistedMessageDto): string { + const text = message.content + .flatMap((part) => { + if (part.type === 'text') return [part.text]; + if (part.type === 'file') return [`[Attachment: ${part.fileName ?? part.fileId ?? 'file'}]`]; + return []; + }) + .join('\n'); + return `User: ${truncate(text, MAX_TEXT_CHARS)}`; +} + +function formatTimelineEvent(event: TimelineEvent, execution: AgentExecution): string { + if (event.type === 'input') { + const message = execution.inputMessages?.find(({ id }) => id === event.messageId); + return message ? formatInput(message) : ''; + } if (event.type === 'background-task-signal') { return `Background task results received: ${stringifyToolValue(event.signal.tasks)}`; } @@ -73,11 +90,19 @@ function formatExecution(execution: AgentExecution): string { if (execution.error) headerParts.push(`error=${execution.error}`); const lines = [`## Turn (${headerParts.join(', ')})`]; - if (execution.userMessage !== null) { + if (execution.inputMessages !== undefined) { + const steeredIds = new Set( + execution.timeline?.filter((event) => event.type === 'input').map((event) => event.messageId), + ); + for (const message of execution.inputMessages) { + if (!steeredIds.has(message.id)) lines.push(formatInput(message)); + } + } else if (execution.userMessage !== null) { lines.push(`User: ${truncate(execution.userMessage, MAX_TEXT_CHARS)}`); } for (const event of execution.timeline ?? []) { - lines.push(formatTimelineEvent(event)); + const line = formatTimelineEvent(event, execution); + if (line) lines.push(line); } return lines.join('\n'); } @@ -106,9 +131,16 @@ function selectTurnExecutions( function continuesExecution(previous: AgentExecution, current: AgentExecution): boolean { if (current.inputMessageIds !== undefined) { + const firstInputId = current.inputMessageIds[0]; + if (!firstInputId) return false; + if (previous.inputMessageIds !== undefined) { + return firstInputId === previous.inputMessageIds[0]; + } + // A legacy continuation gets its first input link when it consumes steering. return ( - current.inputMessageIds.length > 0 && - current.inputMessageIds[0] === previous.inputMessageIds?.[0] + current.timeline?.some( + (event) => event.type === 'input' && event.messageId === firstInputId, + ) ?? false ); } return current.userMessage === null; diff --git a/packages/cli/src/modules/agents/entities/agent-execution.entity.ts b/packages/cli/src/modules/agents/entities/agent-execution.entity.ts index fcaf3cc4c17..60adc628356 100644 --- a/packages/cli/src/modules/agents/entities/agent-execution.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent-execution.entity.ts @@ -43,6 +43,13 @@ export class AgentExecution extends WithTimestampsAndStringId { @Column({ type: 'varchar', length: 16 }) status: AgentExecutionStatus; + @Column({ + type: 'boolean', + default: false, + comment: 'Accept input until the runtime closes admission', + }) + acceptsSteering: boolean; + @DateTimeColumn({ precision: 3, nullable: true }) startedAt: Date | null; diff --git a/packages/cli/src/modules/agents/entities/agent-message-queue.entity.ts b/packages/cli/src/modules/agents/entities/agent-message-queue.entity.ts index cb5cc2d44a3..fce57672f88 100644 --- a/packages/cli/src/modules/agents/entities/agent-message-queue.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent-message-queue.entity.ts @@ -12,11 +12,16 @@ import { import { AgentExecutionThread } from './agent-execution-thread.entity'; import { AgentExecution } from './agent-execution.entity'; -import type { AgentQueuedMessage } from '../types/agent-queued-message'; +import { AgentMessageEntity } from './agent-message.entity'; +import type { AgentQueueDispatch } from '../types/agent-queued-message'; @Entity({ name: 'agent_message_queue' }) @Index(['threadId', 'id']) @Index(['threadId'], { unique: true, where: '"executionId" IS NOT NULL' }) +@Index(['steeringExecutionId', 'steeringOrder'], { + unique: true, + where: '"steeringExecutionId" IS NOT NULL', +}) export class AgentMessageQueue extends WithTimestamps { @Generated() @PrimaryColumn({ @@ -33,11 +38,22 @@ export class AgentMessageQueue extends WithTimestamps { @Column({ type: 'varchar', length: 128 }) threadId: string; - @Column({ type: 'varchar', length: 32, comment: 'Preview or integration source' }) - source: string; + @ManyToOne(() => AgentMessageEntity, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'messageId', foreignKeyConstraintName: 'FK_agent_message_queue_messageId' }) + message: Relation; - @JsonColumn({ comment: 'Input, attachment references, identity, and reply context' }) - payload: AgentQueuedMessage; + @Index({ unique: true }) + @Column({ + type: 'varchar', + length: 36, + comment: 'Canonical input created when the queue accepts it', + }) + messageId: string; + + @JsonColumn({ + comment: 'Dispatch, authorization, and reply context. Input is stored on the message', + }) + payload: AgentQueueDispatch; @ManyToOne(() => AgentExecution, { nullable: true, onDelete: 'NO ACTION' }) @JoinColumn({ name: 'executionId' }) @@ -51,4 +67,19 @@ export class AgentMessageQueue extends WithTimestamps { comment: 'Current execution; NULL means pending', }) executionId: string | null; + + @ManyToOne(() => AgentExecution, { nullable: true, onDelete: 'NO ACTION' }) + @JoinColumn({ name: 'steeringExecutionId' }) + steeringExecution: Relation | null; + + @Column({ + type: 'varchar', + length: 36, + nullable: true, + comment: 'Execution reserved to consume this input', + }) + steeringExecutionId: string | null; + + @Column({ type: 'int', nullable: true, comment: 'Acceptance order among outstanding steers' }) + steeringOrder: number | null; } diff --git a/packages/cli/src/modules/agents/execution-recorder.ts b/packages/cli/src/modules/agents/execution-recorder.ts index 59bd5e7b09e..975660d598c 100644 --- a/packages/cli/src/modules/agents/execution-recorder.ts +++ b/packages/cli/src/modules/agents/execution-recorder.ts @@ -262,6 +262,7 @@ export interface RecordedUsage { } export type TimelineEvent = + | { type: 'input'; messageId: string; timestamp: number } | { type: 'background-task-signal'; signal: AgentBackgroundJobSignal; timestamp: number } | { type: 'text'; content: string; timestamp: number; endTime?: number } | { type: 'reasoning'; content: string; timestamp: number; endTime?: number } @@ -383,6 +384,17 @@ export class ExecutionRecorder { private childTraceChars = new Map(); + /** Record additional input only after its transaction commits. */ + recordInputs(events: Array>): void { + this.flushReasoningBuffer(); + this.flushTextBuffer(); + for (const event of events) { + if (this.timeline.some((item) => item.type === 'input' && item.messageId === event.messageId)) + continue; + this.appendCompletedEvent(event); + } + } + /** Record the human response that caused a suspended tool call to resume. */ recordHitlResponse(toolCallId: string, response: unknown): void { this.flushReasoningBuffer(); diff --git a/packages/cli/src/modules/agents/instance-ai-agent-context.adapter.ts b/packages/cli/src/modules/agents/instance-ai-agent-context.adapter.ts index 7aa22be14ed..b09999ea502 100644 --- a/packages/cli/src/modules/agents/instance-ai-agent-context.adapter.ts +++ b/packages/cli/src/modules/agents/instance-ai-agent-context.adapter.ts @@ -11,8 +11,7 @@ import { UserError } from 'n8n-workflow'; import { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service'; import { userHasScopes } from '@/permissions.ee/check-access'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import { AgentExecutionService, @@ -55,7 +54,7 @@ const toSessionDetailSummary = (detail: ThreadDetail): AgentSessionSummary => { 0, ); const latestStatus = detail.executions.at(-1)?.status; - const status = toSessionStatus(latestStatus, failureCount > 0); + const status = toSessionStatus(latestStatus); return { threadId: detail.thread.id, diff --git a/packages/cli/src/modules/agents/instance-ai-builder-delegate.adapter.ts b/packages/cli/src/modules/agents/instance-ai-builder-delegate.adapter.ts index 63e128c8a7e..f75ee1fe1f1 100644 --- a/packages/cli/src/modules/agents/instance-ai-builder-delegate.adapter.ts +++ b/packages/cli/src/modules/agents/instance-ai-builder-delegate.adapter.ts @@ -13,7 +13,7 @@ import { type Scope } from '@n8n/permissions'; import { Like } from '@n8n/typeorm'; import { UserError } from 'n8n-workflow'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { userHasScopes } from '@/permissions.ee/check-access'; import { AgentConfigService } from './agent-config.service'; diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts index c8618fdd70b..dd14600d94a 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts @@ -56,7 +56,7 @@ function makeQueuedBridge(...args: ConstructorParameters payload: deepCopy(payload), threadId, }); - return mock(); + return { status: 'accepted', item: mock() }; }); const bridge = new AgentChatBridge( bot, @@ -1269,6 +1269,7 @@ describe('AgentChatBridge — consumeStream', () => { findOpenSuspension: vi.fn().mockResolvedValue({ suspendPayload: {} }), }; const queue = mock(); + queue.enqueue.mockResolvedValue({ status: 'accepted', item: mock() }); new AgentChatBridge( bot as unknown as ChatBotLike, 'agent-1', @@ -1755,6 +1756,57 @@ describe('AgentChatBridge — consumeStream', () => { return handlers; } + it('cleans only retry attachments and gives no reply for a duplicate delivery', async () => { + const { bot, handlers } = makeBot(); + const agentExecutor = makeAgentExecutor([finishChunk]); + const attachmentService = makeAttachmentService(); + const queue = mock(); + queue.enqueue + .mockResolvedValueOnce({ status: 'accepted', item: mock() }) + .mockResolvedValue({ status: 'duplicate' }); + new AgentChatBridge( + bot as unknown as ChatBotLike, + 'agent-1', + agentExecutor as never, + componentMapper, + logger, + 'project-1', + { type: 'test-restricted', credentialId: 'cred-1' } as unknown as AgentIntegrationConfig, + undefined, + attachmentService as never, + undefined, + queue, + ); + const thread = makeThread(); + const message = { + id: 'native-message', + text: 'look at this', + author: { userId: 'allowed-user', userName: 'Ada' }, + attachments: [ + { + type: 'image', + name: 'photo.png', + mimeType: 'image/png', + fetchData: async () => pngBytes, + }, + ], + }; + await handlers.mention!(thread, message); + expect(attachmentService.deleteByIds).not.toHaveBeenCalled(); + await handlers.mention!(thread, message); + expect(attachmentService.deleteByIds).toHaveBeenCalledExactlyOnceWith(['att-2']); + expect(thread.post).not.toHaveBeenCalled(); + expect(agentExecutor.executeForChatPublished).not.toHaveBeenCalled(); + + await handlers.mention!(thread, { + ...message, + author: { userId: 'other-user', userName: 'Other' }, + }); + expect(queue.enqueue).toHaveBeenCalledTimes(2); + expect(attachmentService.storeInbound).toHaveBeenCalledTimes(2); + expect(thread.post).not.toHaveBeenCalled(); + }); + // PNG magic bytes so the mime sniff confirms the declared type. const pngBytes = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, @@ -3190,6 +3242,7 @@ describe('AgentChatBridge — consumeStream', () => { const { bot, handlers } = makeBot(); const executor = makeAgentExecutor([finishChunk]); const queue = mock(); + queue.enqueue.mockResolvedValue({ status: 'accepted', item: mock() }); new AgentChatBridge( bot as unknown as ChatBotLike, 'agent-1', diff --git a/packages/cli/src/modules/agents/integrations/__tests__/callback-store.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/callback-store.test.ts index 7f0074dda3e..3a750f305b1 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/callback-store.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/callback-store.test.ts @@ -3,7 +3,7 @@ import type { GlobalConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import { mock } from 'vitest-mock-extended'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { CallbackStore } from '../callback-store'; diff --git a/packages/cli/src/modules/agents/integrations/__tests__/discord-gateway-lifecycle.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/discord-gateway-lifecycle.test.ts index a1ae14bdbb5..205a793e48f 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/discord-gateway-lifecycle.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/discord-gateway-lifecycle.test.ts @@ -3,7 +3,7 @@ import type { OutboundHttp } from '@n8n/backend-network'; import type { InstanceSettings } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ConflictError } from '@n8n/errors'; import type { AgentRepository } from '../../repositories/agent.repository'; import type { AgentChatIntegrationContext } from '../agent-chat-integration'; diff --git a/packages/cli/src/modules/agents/integrations/__tests__/discord-integration.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/discord-integration.test.ts index 97c3d2d8847..c47c27451e0 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/discord-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/discord-integration.test.ts @@ -3,8 +3,7 @@ import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network'; import type { InstanceSettings } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { BadRequestError, ConflictError } from '@n8n/errors'; import type { Agent } from '../../entities/agent.entity'; import type { AgentRepository } from '../../repositories/agent.repository'; diff --git a/packages/cli/src/modules/agents/integrations/__tests__/helpers/replay-test-helpers.ts b/packages/cli/src/modules/agents/integrations/__tests__/helpers/replay-test-helpers.ts index a823f994fd8..72cd7065f97 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/helpers/replay-test-helpers.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/helpers/replay-test-helpers.ts @@ -248,7 +248,7 @@ export function createReplayContextSetup(params: { queue.enqueue.mockImplementation(async ({ payload, threadId }) => { if (payload.kind !== 'integration') throw new Error('Expected integration input'); pending.push({ payload: deepCopy(payload), threadId }); - return mock(); + return { status: 'accepted', item: mock() }; }); const bridge = new AgentChatBridge( diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts index 25c51b935e7..9f63d737cb9 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts @@ -8,8 +8,8 @@ import type { Cipher } from 'n8n-core'; import type { CredentialsService } from '@/credentials/credentials.service'; import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import type { CredentialsOverwrites } from '@/credentials-overwrites'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import type { CacheService } from '@/services/cache/cache.service'; +import { BadRequestError } from '@n8n/errors'; +import type { CacheService } from '@n8n/backend-services'; import type { ProjectService } from '@/services/project.service.ee'; import type { UrlService } from '@n8n/backend-services'; @@ -127,6 +127,7 @@ describe('Slack setup services', () => { deleteManagedAppForCredential: ( options: AgentIntegrationRemovalContext, ) => ReturnType; + isAppConfiguredForAgent: (credentialId: string) => Promise; }; beforeEach(() => { @@ -216,6 +217,8 @@ describe('Slack setup services', () => { updateManagedAppSettings: async (options) => await managedService.updateAppSettings(options), deleteManagedAppForCredential: async (options) => await managedService.deleteAppForCredential(options), + isAppConfiguredForAgent: async (credentialId) => + await managedService.isAppConfiguredForAgent(credentialId, agent as never, user), }; }); @@ -422,6 +425,7 @@ describe('Slack setup services', () => { data: { accessToken: 'xoxb-installed-token', signatureSecret: 'signing-secret', + agentId: 'agent-1', }, projectId: 'project-1', }, @@ -673,6 +677,7 @@ describe('Slack setup services', () => { type: 'slackManagerOAuth2Api', data: 're-encrypted', }, + { kind: 'user', user }, rawData, ); } else { @@ -809,12 +814,16 @@ describe('Slack setup services', () => { managerCredentialId: 'manager', workspaceId: 'T123', user, + modifiedBy: 'mcp', }), ).resolves.toEqual({ status: 'connected', appId: 'A123', credentialId: 'bot-credential', }); + expect(integrationManagementService.connect).toHaveBeenCalledWith( + expect.objectContaining({ modifiedBy: 'mcp' }), + ); const manifestParams = fetchParams(requestMock, 0); const manifest = JSON.parse(manifestParams.get('manifest') ?? '') as { @@ -846,6 +855,7 @@ describe('Slack setup services', () => { expect(credentialsService.update).toHaveBeenCalledWith( 'manager', { data: 'encrypted' }, + { kind: 'system', reason: 'integration' }, expect.objectContaining({ oauthTokenData: expect.objectContaining({ authed_user: expect.objectContaining({ @@ -866,6 +876,7 @@ describe('Slack setup services', () => { data: expect.objectContaining({ accessToken: 'xoxb-managed', signatureSecret: 'signing-secret', + agentId: 'agent-1', managedAppId: 'A123', teamId: 'T123', managerCredentialId: 'manager', @@ -1126,6 +1137,7 @@ describe('Slack setup services', () => { data: { accessToken: 'xoxb-installed-token', signatureSecret: 'signing-secret', + agentId: 'agent-1', managedAppId: 'A123', teamId: 'T456', managerCredentialId: 'manager', @@ -1548,4 +1560,122 @@ describe('Slack setup services', () => { expect(requestMock).not.toHaveBeenCalled(); expect(credentialsService.delete).toHaveBeenCalledWith(user, 'bot-credential'); }); + + describe('isAppConfiguredForAgent', () => { + const botCredential = { + id: 'bot-credential', + name: 'Slack bot', + type: 'slackApi', + } as CredentialsEntity; + const managerCredential = { + id: 'manager', + name: 'Slack manager', + type: 'slackManagerOAuth2Api', + } as CredentialsEntity; + const legacyBotData = { + accessToken: 'xoxb-token', + managedAppId: 'A123', + managerCredentialId: 'manager', + }; + const agentRequestUrl = + 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack'; + + function mockManagerCredential() { + credentialsOverwrites.usesManagedAuth.mockReturnValue(true); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + { id: 'manager', type: 'slackManagerOAuth2Api' }, + ] as never); + credentialsFinderService.findCredentialForUser + .mockResolvedValueOnce(botCredential) + .mockResolvedValueOnce(managerCredential); + credentialsService.decrypt.mockResolvedValueOnce(legacyBotData).mockResolvedValueOnce({ + oauthTokenData: { + authed_user: { + access_token: 'xoxp-manager', + scope: 'app_configurations:read app_configurations:write managed_apps:install', + }, + }, + }); + } + + function exportedManifest(requestUrl: string) { + return slackResponse({ + ok: true, + manifest: { settings: { event_subscriptions: { request_url: requestUrl } } }, + }); + } + + it('returns true when n8n built the app for this Agent', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(botCredential); + credentialsService.decrypt.mockResolvedValue({ + accessToken: 'xoxb-token', + agentId: 'agent-1', + }); + + await expect(service.isAppConfiguredForAgent('bot-credential')).resolves.toBe(true); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('returns false when n8n built the app for another Agent', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(botCredential); + credentialsService.decrypt.mockResolvedValue({ + ...legacyBotData, + agentId: 'agent-deleted', + }); + + await expect(service.isAppConfiguredForAgent('bot-credential')).resolves.toBe(false); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('returns false for a credential that n8n did not build', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(botCredential); + credentialsService.decrypt.mockResolvedValue({ accessToken: 'xoxb-token' }); + + await expect(service.isAppConfiguredForAgent('bot-credential')).resolves.toBe(false); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('returns false for a missing credential or one of another type', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValueOnce(null); + await expect(service.isAppConfiguredForAgent('missing')).resolves.toBe(false); + + credentialsFinderService.findCredentialForUser.mockResolvedValueOnce({ + ...botCredential, + type: 'telegramApi', + } as CredentialsEntity); + await expect(service.isAppConfiguredForAgent('other')).resolves.toBe(false); + expect(credentialsService.decrypt).not.toHaveBeenCalled(); + }); + + it('reads the request URL from Slack for a managed app without an Agent ID', async () => { + mockManagerCredential(); + requestMock.mockResolvedValueOnce(exportedManifest(agentRequestUrl)); + + await expect(service.isAppConfiguredForAgent('bot-credential')).resolves.toBe(true); + expect(requestMock.mock.calls[0]?.[0]).toMatchObject({ + url: 'https://slack.com/api/apps.manifest.export', + }); + expect(fetchParams(requestMock, 0).get('app_id')).toBe('A123'); + }); + + it('returns false when the managed app sends events to another Agent', async () => { + mockManagerCredential(); + requestMock.mockResolvedValueOnce( + exportedManifest( + 'https://hooks.example/rest/projects/project-1/agents/v2/agent-deleted/webhooks/slack', + ), + ); + + await expect(service.isAppConfiguredForAgent('bot-credential')).resolves.toBe(false); + }); + + it('returns false when the manager credential cannot be used in this project', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(botCredential); + credentialsService.decrypt.mockResolvedValue(legacyBotData); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([]); + + await expect(service.isAppConfiguredForAgent('bot-credential')).resolves.toBe(false); + expect(requestMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-integration.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-integration.test.ts index c28460baefb..e0b15f96b57 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/slack-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-integration.test.ts @@ -3,7 +3,7 @@ import type { AgentIntegrationConfig } from '@n8n/api-types'; import type { Thread } from 'chat'; import { mock } from 'vitest-mock-extended'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ConflictError } from '@n8n/errors'; import type { AgentRepository } from '../../repositories/agent.repository'; import type { AgentChatIntegrationContext } from '../agent-chat-integration'; diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts index 1b5e9a1f5d0..819db80609b 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts @@ -2,8 +2,8 @@ import type { UserRepository } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import type { CacheService } from '@/services/cache/cache.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import type { CacheService } from '@n8n/backend-services'; +import { BadRequestError } from '@n8n/errors'; import type { ProjectService } from '@/services/project.service.ee'; import { SlackManualSetupService } from '../platforms/slack/slack-manual-setup.service'; diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts index 8c59516c307..7c8cde3ed5c 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts @@ -5,7 +5,7 @@ import type { Cipher } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; import type { CredentialsService } from '@/credentials/credentials.service'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import type { UrlService } from '@n8n/backend-services'; import type { AgentIntegrationManagementService } from '../../agent-integration-management.service'; @@ -78,6 +78,7 @@ describe('SlackMethodsService', () => { data: { accessToken: 'xoxb-token', signatureSecret: 'signing-secret', + agentId: 'agent-1', }, projectId: 'project-1', }, diff --git a/packages/cli/src/modules/agents/integrations/__tests__/teams-integration.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/teams-integration.test.ts index bd2552770b1..63164db1e94 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/teams-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/teams-integration.test.ts @@ -2,7 +2,7 @@ import type { Logger } from '@n8n/backend-common'; import { UserError } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ConflictError } from '@n8n/errors'; import type { Agent } from '../../entities/agent.entity'; import type { AgentRepository } from '../../repositories/agent.repository'; diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts index 61b0f7caeae..787b02b0be4 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts @@ -14,7 +14,7 @@ import { isRecord } from '@n8n/utils/is-record'; import type { Attachment, Author, CardElement, Chat, Message, Thread } from 'chat'; import { UserError, type Logger } from 'n8n-workflow'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { AgentChatAttachmentService } from '../agent-chat-attachment.service'; import { AgentMessageQueueService } from '../agent-message-queue.service'; @@ -23,6 +23,7 @@ import type { QueuedIntegrationMessage, } from '../types/agent-queued-message'; import type { StoredAttachmentRef } from '../types/agent-chat-attachment'; +import type { AgentExecutionStreamChunk } from '../types/agent-steering'; import { AgentConversationStateService } from '../agent-conversation-state.service'; import type { AgentExecutionOrchestratorService, @@ -139,7 +140,7 @@ interface AgentExecutor extends Pick & { memory: { threadId: InternalThread; resourceId: string }; }, - ): AsyncGenerator; + ): AsyncGenerator; /** An open approval prevents automatic session rotation. */ findOpenSuspension?(config: { @@ -505,7 +506,7 @@ export class AgentChatBridge { async deliverWakeResponse( threadId: string, - chunks: StreamChunk[], + chunks: AgentExecutionStreamChunk[], cardRecipientId?: string, ): Promise { await this.streamConsumer.consume( @@ -775,6 +776,7 @@ export class AgentChatBridge { isNewMention, platformAgentContext, }) ?? 'required'; + let accepted = false; try { const [context, subject] = await Promise.all([ this.resolveBridgeExecutionContext( @@ -797,7 +799,7 @@ export class AgentChatBridge { const author = toMessageAuthor(message.author); const textWithNotes = [text, ...attachmentNotes].filter(Boolean).join('\n'); const { userId, userName, fullName, isBot, isMe } = message.author; - await this.messageQueue.enqueue({ + const result = await this.messageQueue.enqueue({ agentId: this.agentId, projectId: this.n8nProjectId, threadId: session.memory.threadId.id, @@ -822,9 +824,11 @@ export class AgentChatBridge { slackThreadContext: context.slackThreadContext, }, }); - } catch (error) { - await this.attachmentService?.deleteByIds(attachments.map((ref) => ref.id)).catch(() => {}); - throw error; + accepted = result.status === 'accepted'; + } finally { + if (!accepted) { + await this.attachmentService?.deleteByIds(attachments.map((ref) => ref.id)).catch(() => {}); + } } } diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-stream-consumer.ts b/packages/cli/src/modules/agents/integrations/agent-chat-stream-consumer.ts index fae0351eb40..0686ba52c80 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-stream-consumer.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-stream-consumer.ts @@ -3,6 +3,7 @@ import { isRecord } from '@n8n/utils/is-record'; import type { Thread } from 'chat'; import { OperationalError, type Logger } from 'n8n-workflow'; +import type { AgentExecutionStreamChunk } from '../types/agent-steering'; import type { BridgeStatusHandle } from './agent-chat-integration'; import { isIntegrationActionSuspendPayload } from './agent-chat-suspension-cards'; import { type TextEndFn, type TextYieldFn } from './types'; @@ -92,7 +93,7 @@ export class AgentChatStreamConsumer { constructor(private readonly options: AgentChatStreamConsumerOptions) {} async consume( - stream: AsyncGenerator, + stream: AsyncGenerator, thread: Thread, options: ConsumeStreamOptions = {}, ): Promise { @@ -378,7 +379,7 @@ export class AgentChatStreamConsumer { } private async consumeBuffered( - stream: AsyncGenerator, + stream: AsyncGenerator, thread: Thread, options: ConsumeStreamOptions = {}, ): Promise { diff --git a/packages/cli/src/modules/agents/integrations/callback-store.ts b/packages/cli/src/modules/agents/integrations/callback-store.ts index 04d277e6e52..be64333b60a 100644 --- a/packages/cli/src/modules/agents/integrations/callback-store.ts +++ b/packages/cli/src/modules/agents/integrations/callback-store.ts @@ -2,7 +2,7 @@ import { createHash, randomBytes } from 'node:crypto'; import { LockNamespace, type LockService } from '@n8n/backend-common'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; export interface CallbackPayload { actionId: string; diff --git a/packages/cli/src/modules/agents/integrations/credential-claim.ts b/packages/cli/src/modules/agents/integrations/credential-claim.ts index 11975d228b2..7fbf738afd9 100644 --- a/packages/cli/src/modules/agents/integrations/credential-claim.ts +++ b/packages/cli/src/modules/agents/integrations/credential-claim.ts @@ -1,4 +1,4 @@ -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ConflictError } from '@n8n/errors'; import type { AgentChannelPreconditionContext } from './agent-chat-integration'; import type { AgentRepository } from '../repositories/agent.repository'; diff --git a/packages/cli/src/modules/agents/integrations/leader-channel-relay.service.ts b/packages/cli/src/modules/agents/integrations/leader-channel-relay.service.ts index eff67c479cb..fe56aac8a73 100644 --- a/packages/cli/src/modules/agents/integrations/leader-channel-relay.service.ts +++ b/packages/cli/src/modules/agents/integrations/leader-channel-relay.service.ts @@ -5,7 +5,7 @@ import type { DistributiveOmit } from '@n8n/utils/types'; import { InstanceSettings } from 'n8n-core'; import { nanoid } from 'nanoid'; -import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; +import { ServiceUnavailableError } from '@n8n/errors'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map'; diff --git a/packages/cli/src/modules/agents/integrations/n8n-checkpoint-storage.ts b/packages/cli/src/modules/agents/integrations/n8n-checkpoint-storage.ts index f9d4aae784b..21bd9234264 100644 --- a/packages/cli/src/modules/agents/integrations/n8n-checkpoint-storage.ts +++ b/packages/cli/src/modules/agents/integrations/n8n-checkpoint-storage.ts @@ -118,6 +118,13 @@ export class N8NCheckpointStorage { expired: false, }); await this.agentCheckpointRepository.saveCheckpoint(checkpoint, ctx); + const executionId = checkpointExecutionId(state); + const threadId = state.persistence?.threadId; + if (state.status === 'suspended' && executionId && threadId) { + // A continuation can start before the suspended predecessor finishes recording. + await this.executionRepository.closeSteering(threadId, executionId, ctx); + await this.queueRepository.releaseSteering(threadId, executionId, ctx); + } }); } diff --git a/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts b/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts index dd738d8ca38..68a268656dd 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts @@ -7,8 +7,7 @@ import { createHmac } from 'crypto'; import { mock } from 'vitest-mock-extended'; import type { InstanceSettings } from 'n8n-core'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { BadRequestError, ConflictError } from '@n8n/errors'; import type { UrlService } from '@n8n/backend-services'; import type { Agent } from '../../../../entities/agent.entity'; diff --git a/packages/cli/src/modules/agents/integrations/platforms/discord-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/discord-integration.ts index 2ef436fd8c3..9d77d5de7b2 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/discord-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/discord-integration.ts @@ -8,8 +8,7 @@ import type { Message, Thread } from 'chat'; import escapeRegExp from 'lodash/escapeRegExp'; import { InstanceSettings } from 'n8n-core'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { BadRequestError, ConflictError } from '@n8n/errors'; import { AgentRepository } from '../../repositories/agent.repository'; import { diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts index 15d68f4339a..ab18b15013d 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts @@ -1,4 +1,5 @@ import type { + AgentActor, AgentIntegrationConfig, CreateSlackManagerCredentialResponse, InstallSlackManagedAppResponse, @@ -19,9 +20,8 @@ import { join } from 'node:path'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { CredentialsService } from '@/credentials/credentials.service'; import { CredentialsOverwrites } from '@/credentials-overwrites'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { CacheService } from '@/services/cache/cache.service'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; +import { CacheService } from '@n8n/backend-services'; import { SlackMethodsService } from './slack-methods.service'; import { @@ -64,6 +64,7 @@ export interface GetManagedSetupStateOptions { export interface InstallManagedSlackAppOptions extends GetManagedSetupStateOptions { managerCredentialId: string; workspaceId: string; + modifiedBy?: AgentActor; } export interface FinalizeSlackManagerCredentialOptions extends GetManagedSetupStateOptions { @@ -250,7 +251,12 @@ export class SlackManagedSetupService { type: manager.credential.type, data: manager.rawData, }); - await this.credentialsService.update(options.credentialId, encrypted, manager.rawData); + await this.credentialsService.update( + options.credentialId, + encrypted, + { kind: 'user', user: options.user }, + manager.rawData, + ); } async installApp( @@ -365,6 +371,58 @@ export class SlackManagedSetupService { ); } + /** + * True when n8n built the Slack app behind this bot credential for this + * Agent, so the app sends its events to the Agent's request URL. A credential + * that n8n built for another Agent, or a credential from any other Slack app, + * returns false. + */ + async isAppConfiguredForAgent(credentialId: string, agent: Agent, user: User): Promise { + const credential = await this.credentialsFinderService.findCredentialForUser( + credentialId, + user, + ['credential:read'], + ); + if (!credential || credential.type !== SLACK_CREDENTIAL_TYPE) return false; + const data = await this.credentialsService.decrypt(credential, true); + const agentId = stringProperty(data, 'agentId'); + if (agentId) return agentId === agent.id; + return await this.managedAppSendsEventsTo(data, agent, user); + } + + /** + * Managed credentials made before n8n recorded the Agent ID only know their + * app, so read the app's request URL from Slack instead. + */ + private async managedAppSendsEventsTo( + data: ICredentialDataDecryptedObject, + agent: Agent, + user: User, + ): Promise { + const managedAppId = stringProperty(data, 'managedAppId'); + const managerCredentialId = stringProperty(data, 'managerCredentialId'); + if (!managedAppId || !managerCredentialId) return false; + try { + const manager = await this.getManagerCredentialContext( + managerCredentialId, + agent.projectId, + user, + ); + const manifest = await this.exportManagedAppManifest(manager, managedAppId); + const settings = childRecord(manifest, 'settings'); + const eventSubscriptions = settings + ? childRecord(settings, 'event_subscriptions') + : undefined; + return ( + stringProperty(eventSubscriptions, 'request_url') === + this.methods.webhookUrl(agent.projectId, agent.id) + ); + } catch { + // The manager credential is gone or not usable here, so n8n cannot confirm the app. + return false; + } + } + async deleteAppForCredential( options: AgentIntegrationRemovalContext, ): Promise { @@ -751,6 +809,7 @@ export class SlackManagedSetupService { options.user, botAccessToken, session, + options.modifiedBy, ); return { status: 'connected', appId: session.appId, credentialId }; } @@ -926,7 +985,12 @@ export class SlackManagedSetupService { type: manager.credential.type, data: updatedData, }); - await this.credentialsService.update(manager.credential.id, encrypted, updatedData); + await this.credentialsService.update( + manager.credential.id, + encrypted, + { kind: 'system', reason: 'integration' }, + updatedData, + ); manager.oauthTokenData = oauthTokenData; manager.accessToken = refreshedAccessToken; } diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts index 4952e30fec2..366e0f47a6a 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts @@ -4,10 +4,8 @@ import { UserRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import { randomBytes } from 'node:crypto'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { CacheService } from '@/services/cache/cache.service'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; +import { CacheService } from '@n8n/backend-services'; import { ProjectService } from '@/services/project.service.ee'; import { SlackMethodsService } from './slack-methods.service'; diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts index ab1f854dc8f..242aa123c58 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts @@ -1,4 +1,5 @@ import type { + AgentActor, AgentIntegrationConfig, SlackAgentAppManifest, SlackApiErrorMeta, @@ -12,9 +13,8 @@ import { Cipher } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; import { CredentialsService } from '@/credentials/credentials.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { CacheService } from '@/services/cache/cache.service'; -import { UrlService } from '@n8n/backend-services'; +import { BadRequestError } from '@n8n/errors'; +import { CacheService, UrlService } from '@n8n/backend-services'; import { getAgentOrThrow } from '../../../utils/get-agent-or-throw'; import { @@ -204,6 +204,7 @@ export class SlackMethodsService { user: User, accessToken: string, session: SlackAppSetupSession, + modifiedBy?: AgentActor, ): Promise { const credentialData = { name: this.credentialName(session.teamName, agent.name), @@ -211,6 +212,7 @@ export class SlackMethodsService { data: { accessToken, signatureSecret: session.signingSecret, + agentId: session.agentId, ...(session.managerCredentialId ? { managedAppId: session.appId, @@ -234,6 +236,7 @@ export class SlackMethodsService { agent, user, integration, + ...(modifiedBy ? { modifiedBy } : {}), }); } catch (error) { await this.deleteUnreferencedCredential(agent.id, credential.id, user); @@ -295,7 +298,7 @@ export class SlackMethodsService { } } - private webhookUrl(projectId: string, agentId: string): string { + webhookUrl(projectId: string, agentId: string): string { return `${this.urlService.getWebhookBaseUrl()}rest/projects/${projectId}/agents/v2/${agentId}/webhooks/slack`; } diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-setup.types.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-setup.types.ts index 6a1a7ffc82b..94c1371794d 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-setup.types.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-setup.types.ts @@ -1,6 +1,6 @@ import { isRecord } from '@n8n/utils/is-record'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { stringProperty } from '../../integration-helpers'; diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup.service.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup.service.ts index a615ede7523..c5d42bf9c22 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup.service.ts @@ -2,8 +2,7 @@ import type { AgentTeamsIntegrationSettings, TeamsAgentSetupState } from '@n8n/a import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { UrlService } from '@n8n/backend-services'; import { TeamsArmTemplateService } from './teams-arm-template.service'; diff --git a/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts index ddfd9c10689..b41ffd6d009 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts @@ -9,7 +9,7 @@ import { createHmac } from 'crypto'; import { InstanceSettings } from 'n8n-core'; import { UnexpectedError } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { UrlService } from '@n8n/backend-services'; import { AgentRepository } from '../../repositories/agent.repository'; 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 41c94106746..13c26aea60d 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 @@ -146,6 +146,11 @@ 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 }) @@ -157,9 +162,9 @@ export class AgentExecutionThreadRepository extends BaseRepository limit; if (hasMore) threads.pop(); @@ -173,14 +178,19 @@ export class AgentExecutionThreadRepository extends BaseRepository, filters: AgentSessionQueryFilters, + userId: string, + updatedAt: string, ) { + if (filters.scope === 'mine') { + query.andWhere('thread.ownerId = :userId', { userId }); + } if (filters.updatedAfter) { - query.andWhere('thread.updatedAt >= :updatedAfter', { + query.andWhere(`${updatedAt} >= :updatedAfter`, { updatedAfter: filters.updatedAfter, }); } if (filters.updatedBefore) { - query.andWhere('thread.updatedAt <= :updatedBefore', { + query.andWhere(`${updatedAt} <= :updatedBefore`, { updatedBefore: filters.updatedBefore, }); } @@ -198,14 +208,8 @@ export class AgentExecutionThreadRepository extends BaseRepository, ) { const latestStatus = this.latestExecutionStatusSubquery(query); - const failureExists = this.failureExistsSubquery(query); if (status === 'succeeded') { - query.andWhere(`(${latestStatus}) = 'success' AND NOT EXISTS ${failureExists}`); - } else if (status === 'error') { - query.andWhere( - `((${latestStatus}) = 'error' OR ` + - `((${latestStatus}) = 'success' AND EXISTS ${failureExists}))`, - ); + query.andWhere(`(${latestStatus}) = 'success'`); } else { query.andWhere(`(${latestStatus}) = :sessionStatus`, { sessionStatus: status }); } @@ -233,16 +237,6 @@ export class AgentExecutionThreadRepository extends BaseRepository): string { - return query - .subQuery() - .select('1') - .from(AgentExecution, 'failedExecution') - .where('failedExecution.threadId = thread.id') - .andWhere('failedExecution.failureSummary IS NOT NULL') - .getQuery(); - } - private normalizedFirstSource(query: SelectQueryBuilder): string { const firstSource = query .subQuery() @@ -299,13 +293,16 @@ export class AgentExecutionThreadRepository extends BaseRepository { const set: Record string> = { totalPromptTokens: () => '"totalPromptTokens" + :promptTokens', @@ -318,7 +315,8 @@ export class AgentExecutionThreadRepository extends BaseRepository '"totalDuration" + :duration'; } - await this.createQueryBuilder() + await this.managerFor(ctx) + .createQueryBuilder() .update(AgentExecutionThread) .set(set) .where('id = :threadId', { threadId }) diff --git a/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts b/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts index 7b8a78f056c..310bf36cb00 100644 --- a/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts @@ -1,7 +1,7 @@ import type { AgentExecutionStatus } from '@n8n/api-types'; import { BaseRepository, TransactionRunner, type OperationContext } from '@n8n/db'; import { Service } from '@n8n/di'; -import { DataSource, IsNull, LessThanOrEqual, Not } from '@n8n/typeorm'; +import { DataSource, IsNull, Not } from '@n8n/typeorm'; import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; import { AgentExecution } from '../entities/agent-execution.entity'; @@ -21,7 +21,7 @@ type AgentExecutionFinalizationValues = Pick< Partial< Pick< AgentExecution, - 'model' | 'promptTokens' | 'completionTokens' | 'totalTokens' | 'cost' | 'hitlStatus' + 'model' | 'promptTokens' | 'completionTokens' | 'totalTokens' | 'hitlStatus' > >; @@ -77,29 +77,83 @@ export class AgentExecutionRepository extends BaseRepository { async updateTimelineIfRunning( executionId: string, timeline: AgentExecution['timeline'], + ctx: OperationContext = {}, ): Promise { - const result = await this.update({ id: executionId, status: 'running' }, { - timeline, - updatedAt: new Date(), - } as QueryDeepPartialEntity); + const result = await this.managerFor(ctx).update( + AgentExecution, + { id: executionId, status: 'running' }, + { + timeline, + updatedAt: new Date(), + } as QueryDeepPartialEntity, + ); return result.affected === 1; } + async findSteerable(threadId: string, ctx: OperationContext = {}) { + return await this.managerFor(ctx).findOne(AgentExecution, { + select: ['id'], + where: { + threadId, + status: 'running', + acceptsSteering: true, + }, + }); + } + + async closeSteering(threadId: string, executionId: string, ctx: OperationContext) { + await this.managerFor(ctx).update( + AgentExecution, + { id: executionId, threadId, acceptsSteering: true }, + { + acceptsSteering: false, + }, + ); + } + async updateIfRunning( executionId: string, values: AgentExecutionFinalizationValues, staleBefore?: Date, ctx: OperationContext = {}, + costIncrement?: number, ): Promise { - const result = await this.managerFor(ctx).update( - AgentExecution, - { - id: executionId, - status: 'running', - ...(staleBefore ? { updatedAt: LessThanOrEqual(staleBefore) } : {}), - }, - values as QueryDeepPartialEntity, + const current = await this.managerFor(ctx).findOne(AgentExecution, { + select: ['timeline'], + where: { id: executionId, status: 'running' }, + }); + const inputIds = new Set( + values.timeline?.filter((event) => event.type === 'input').map((event) => event.messageId), ); + // A failed commit acknowledgement must not let terminal recording erase durable input. + if ( + current?.timeline?.some((event) => event.type === 'input' && !inputIds.has(event.messageId)) + ) + return false; + // Build the SET values once. `cost` is never set as a literal here — + // the only way to move cost is the additive `costIncrement` fragment + // below, which preserves any in-flight side-call `incrementCost` calls + // (`COALESCE(cost, 0) + :costIncrement` instead of `cost = :value`). + const setValues = { + ...values, + acceptsSteering: false, + } as QueryDeepPartialEntity; + const params: Record = { executionId, status: 'running' }; + if (costIncrement !== undefined && costIncrement > 0) { + setValues.cost = () => 'COALESCE(cost, 0) + :costIncrement'; + params.costIncrement = costIncrement; + } + const qb = this.managerFor(ctx) + .createQueryBuilder() + .update(AgentExecution) + .set(setValues) + .where('id = :executionId', { executionId }) + .andWhere('status = :status', { status: 'running' }); + if (staleBefore) { + qb.andWhere('updatedAt <= :staleBefore', { staleBefore }); + params.staleBefore = staleBefore; + } + const result = await qb.setParameters(params).execute(); return result.affected === 1; } @@ -274,6 +328,36 @@ export class AgentExecutionRepository extends BaseRepository { .execute(); } + /** + * Atomically add a side-call model cost (title generation, observation-log + * observer/reflector, episodic-memory model calls) onto an execution row. + * Side calls can settle before or after the terminal row write, so this is + * an unconditional increment — not gated on `status = 'running'`. `cost` is + * nullable (an execution may have no priced main-turn usage yet), so + * `COALESCE` keeps the increment from collapsing to `NULL + :cost = NULL`. + * Pass the `ctx` from `TransactionRunner.run` to apply the increment inside + * the same transaction as the matching thread-total update. + */ + async incrementCost( + executionId: string, + cost: number, + ctx: OperationContext = {}, + ): Promise { + if (cost <= 0) return; + await this.managerFor(ctx) + .createQueryBuilder() + .update(AgentExecution) + .set({ cost: () => 'COALESCE(cost, 0) + :cost' }) + .where('id = :executionId', { executionId }) + .setParameters({ cost }) + .execute(); + } + + /** Delete every run in a thread. Caller must verify ownership first. */ + async deleteByThreadId(threadId: string): Promise { + await this.delete({ threadId }); + } + /** Blob-stored log refs across all of an agent's threads — for log cleanup on agent delete. */ async findBlobRefsByAgentId( agentId: string, diff --git a/packages/cli/src/modules/agents/repositories/agent-message-queue.repository.ts b/packages/cli/src/modules/agents/repositories/agent-message-queue.repository.ts index e2e7e22f83a..ef31a7ad420 100644 --- a/packages/cli/src/modules/agents/repositories/agent-message-queue.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-message-queue.repository.ts @@ -1,11 +1,11 @@ import { BaseRepository, TransactionRunner, type OperationContext } from '@n8n/db'; import { Service } from '@n8n/di'; -import { DataSource, IsNull, Not } from '@n8n/typeorm'; +import { DataSource, In, IsNull, Not } from '@n8n/typeorm'; import { isDraftIntegration } from '@n8n/api-types'; import { AgentMessageQueue } from '../entities/agent-message-queue.entity'; import { Agent } from '../entities/agent.entity'; -import type { AgentQueuedMessage } from '../types/agent-queued-message'; +import type { AgentQueueDispatch } from '../types/agent-queued-message'; @Service() export class AgentMessageQueueRepository extends BaseRepository { @@ -15,22 +15,36 @@ export class AgentMessageQueueRepository extends BaseRepository { + const items = await this.managerFor(ctx).find(AgentMessageQueue, { + select: ['steeringExecutionId'], + where: { threadId, steeringExecutionId: Not(IsNull()) }, + }); + return [ + ...new Set( + items.flatMap((item) => (item.steeringExecutionId ? [item.steeringExecutionId] : [])), + ), + ]; + } + + async releaseSteering(threadId: string, executionId: string, ctx: OperationContext) { + const result = await this.managerFor(ctx).update( + AgentMessageQueue, + { threadId, steeringExecutionId: executionId }, + { steeringExecutionId: null, steeringOrder: null }, + ); + return (result.affected ?? 0) > 0; + } + + async consumeSteering( + threadId: string, + executionId: string, + ids: string[], + ctx: OperationContext, + ) { + if (ids.length === 0) return; + await this.managerFor(ctx).delete(AgentMessageQueue, { + threadId, + steeringExecutionId: executionId, + executionId: IsNull(), + id: In(ids), + }); + } + async findActive(threadId: string, ctx: OperationContext) { return await this.managerFor(ctx).findOneBy(AgentMessageQueue, { threadId, diff --git a/packages/cli/src/modules/agents/repositories/agent-message.repository.ts b/packages/cli/src/modules/agents/repositories/agent-message.repository.ts index c23fa0d3a71..4ea74d04b46 100644 --- a/packages/cli/src/modules/agents/repositories/agent-message.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-message.repository.ts @@ -1,10 +1,16 @@ import { stripHydratedFileData, type AgentDbMessage, type AgentMessage } from '@n8n/agents'; import type { AgentMessageAuthor } from '@n8n/api-types'; -import { BaseRepository, chunkIds, TransactionRunner, type OperationContext } from '@n8n/db'; +import { + BaseRepository, + chunkIds, + isUniqueConstraintError, + TransactionRunner, + type OperationContext, +} from '@n8n/db'; import { Service } from '@n8n/di'; import { DataSource, In } from '@n8n/typeorm'; import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; -import { OperationalError, UnexpectedError } from 'n8n-workflow'; +import { OperationalError, UnexpectedError, UserError } from 'n8n-workflow'; import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; @@ -19,23 +25,30 @@ interface RuntimeMessageScope { resourceId?: string; } +export class AgentMessageIdConflictError extends UserError { + constructor(readonly messageId: string) { + super('A message with this ID already exists'); + } +} + @Service() export class AgentMessageRepository extends BaseRepository { constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { super(AgentMessageEntity, dataSource.manager, transactionRunner); } - async createExecutionInput( + async createInput( params: { - executionId: string; + id?: string; threadId: string; resourceId: string; content: AgentMessage; + modelContent?: AgentMessage; author?: AgentMessageAuthor; origin: AgentMessageOrigin; }, ctx: OperationContext, - ): Promise { + ): Promise { const manager = this.managerFor(ctx); await manager .createQueryBuilder() @@ -52,7 +65,7 @@ export class AgentMessageRepository extends BaseRepository { .orIgnore() .execute(); const message = manager.create(AgentMessageEntity, { - id: randomUUID(), + id: params.id ?? randomUUID(), threadId: params.threadId, resourceId: params.resourceId, role: 'user', @@ -60,17 +73,71 @@ export class AgentMessageRepository extends BaseRepository { content: params.content, author: params.author ?? null, origin: params.origin, + modelContent: + params.modelContent && !isDeepStrictEqual(params.content, params.modelContent) + ? params.modelContent + : null, + modelContextAt: null, + }); + try { + // TypeORM's partial type cannot represent unknown values in JSON message content. + await manager.insert( + AgentMessageEntity, + message as QueryDeepPartialEntity, + ); + } catch (error) { + if (isUniqueConstraintError(error)) throw new AgentMessageIdConflictError(message.id); + throw error; + } + return await manager.findOneByOrFail(AgentMessageEntity, { id: message.id }); + } + + /** The caller holds the session lock so concurrent input cannot take the same position. */ + async linkExecutionInput( + executionId: string, + messageId: string, + scope: { threadId: string; resourceId: string }, + ctx: OperationContext, + ): Promise { + const manager = this.managerFor(ctx); + if ( + !(await manager.existsBy(AgentMessageEntity, { + id: messageId, + threadId: scope.threadId, + resourceId: scope.resourceId, + })) + ) { + throw new UnexpectedError('The input message does not belong to this session and resource'); + } + const inputs = await manager.find(AgentExecutionMessageLink, { + select: ['messageId', 'position'], + where: { executionId, direction: 'input' }, + order: { position: 'DESC' }, + }); + if (inputs.some((input) => input.messageId === messageId)) return; + await manager.insert(AgentExecutionMessageLink, { + executionId, + messageId, + direction: 'input', + position: (inputs[0]?.position ?? -1) + 1, + }); + } + + async updatePendingInput( + id: string, + content: AgentMessage, + ctx: OperationContext, + ): Promise { + await this.managerFor(ctx).save(this.create({ id, content, modelContent: null })); + } + + async clearPendingInput(id: string, ctx: OperationContext): Promise { + await this.managerFor(ctx).update(AgentMessageEntity, id, { + content: { role: 'user', content: [] }, + author: null, modelContent: null, modelContextAt: null, }); - await manager.save(message); - await manager.insert(AgentExecutionMessageLink, { - executionId: params.executionId, - messageId: message.id, - direction: 'input', - position: 0, - }); - return message.id; } async copyExecutionInputs( @@ -123,9 +190,10 @@ export class AgentMessageRepository extends BaseRepository { messages: AgentDbMessage[]; executionId?: string; }, + ctx: OperationContext = {}, ): Promise { if (params.messages.length === 0) return; - await this.runInTransaction({}, async (manager) => { + await this.runInTransaction(ctx, async (manager) => { const now = new Date(); if (params.executionId) { const ownership = await manager.update( diff --git a/packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts b/packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts index 0cc763bca4e..3d8594f9d58 100644 --- a/packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts +++ b/packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts @@ -10,7 +10,7 @@ import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { OperationalError, UserError } from 'n8n-workflow'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { AgentExecutionRecordingError } from '../../agent-execution-recording.error'; import { diff --git a/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-runner.test.ts b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-runner.test.ts index 55f7b19a662..4ce23357ad4 100644 --- a/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-runner.test.ts +++ b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-runner.test.ts @@ -1,3 +1,4 @@ +import type { AgentMessageSteeringService } from '../../agent-message-steering.service'; import { INLINE_SUB_AGENT_ID, type BuiltAgent, @@ -151,6 +152,7 @@ describe('SubAgentRunner', () => { agentExecutionService, mock(), mock(), + mock(), ), checkpointStorage, logger, diff --git a/packages/cli/src/modules/agents/sub-agents/delegate-sub-agent-tool.ts b/packages/cli/src/modules/agents/sub-agents/delegate-sub-agent-tool.ts index 7f11fc5adc7..a538d7ebe8d 100644 --- a/packages/cli/src/modules/agents/sub-agents/delegate-sub-agent-tool.ts +++ b/packages/cli/src/modules/agents/sub-agents/delegate-sub-agent-tool.ts @@ -9,7 +9,7 @@ import { import type { SubAgentRunPolicy, SubAgentSource } from '@n8n/api-types'; import { OperationalError, UserError } from 'n8n-workflow'; -import { ResponseError } from '@/errors/response-errors/abstract/response.error'; +import { ResponseError } from '@n8n/errors'; import { AgentExecutionRecordingError } from '../agent-execution-recording.error'; import { decodeAgentSandboxHostMetadata } from '../agent-sandbox-principal'; diff --git a/packages/cli/src/modules/agents/sub-agents/sub-agent-source-resolver.ts b/packages/cli/src/modules/agents/sub-agents/sub-agent-source-resolver.ts index 108d7fc554e..9c92d290ffa 100644 --- a/packages/cli/src/modules/agents/sub-agents/sub-agent-source-resolver.ts +++ b/packages/cli/src/modules/agents/sub-agents/sub-agent-source-resolver.ts @@ -7,7 +7,7 @@ import { import { Service } from '@n8n/di'; import { UserError } from 'n8n-workflow'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { getAgentOrThrow } from '../utils/get-agent-or-throw'; import { AgentHistoryRepository } from '../repositories/agent-history.repository'; diff --git a/packages/cli/src/modules/agents/tools/__tests__/mark-session-failed.tool.test.ts b/packages/cli/src/modules/agents/tools/__tests__/mark-session-failed.tool.test.ts new file mode 100644 index 00000000000..9bf9b22760c --- /dev/null +++ b/packages/cli/src/modules/agents/tools/__tests__/mark-session-failed.tool.test.ts @@ -0,0 +1,20 @@ +import { + createMarkSessionFailedTool, + MARK_SESSION_FAILED_TOOL_NAME, +} from '../mark-session-failed.tool'; + +describe('createMarkSessionFailedTool', () => { + it('builds a tool with the correct name', () => { + const tool = createMarkSessionFailedTool().build(); + expect(tool.name).toBe(MARK_SESSION_FAILED_TOOL_NAME); + }); + + it('returns a successful mark', async () => { + const tool = createMarkSessionFailedTool().build(); + const result = await tool.handler!( + { reason: 'Could not recover' }, + { parentTelemetry: undefined }, + ); + expect(result).toEqual({ marked: true }); + }); +}); diff --git a/packages/cli/src/modules/agents/tools/mark-session-failed.tool.ts b/packages/cli/src/modules/agents/tools/mark-session-failed.tool.ts new file mode 100644 index 00000000000..210ae438075 --- /dev/null +++ b/packages/cli/src/modules/agents/tools/mark-session-failed.tool.ts @@ -0,0 +1,20 @@ +import { Tool } from '@n8n/agents/tool'; +import { z } from 'zod'; + +export const MARK_SESSION_FAILED_TOOL_NAME = 'mark_session_failed'; + +const DESCRIPTION = + 'Mark this session as failed when you cannot complete the task. ' + + 'Call it after tool errors you cannot fix. ' + + 'Do not call it when you have fixed the tool error. ' + + 'Pass a short reason.'; + +export function createMarkSessionFailedTool() { + return ( + new Tool(MARK_SESSION_FAILED_TOOL_NAME) + .description(DESCRIPTION) + .input(z.object({ reason: z.string().trim().min(1).max(400) })) + // eslint-disable-next-line @typescript-eslint/require-await -- Tool.handler() expects an async callback + .handler(async () => ({ marked: true })) + ); +} diff --git a/packages/cli/src/modules/agents/types/agent-queued-message.ts b/packages/cli/src/modules/agents/types/agent-queued-message.ts index 45612d52919..f439012ce04 100644 --- a/packages/cli/src/modules/agents/types/agent-queued-message.ts +++ b/packages/cli/src/modules/agents/types/agent-queued-message.ts @@ -18,6 +18,7 @@ interface QueuedMessageInput { export interface QueuedPreviewMessage extends QueuedMessageInput { kind: 'preview'; userId: string; + messageId?: string; } export interface QueuedIntegrationMessage extends QueuedMessageInput { @@ -35,6 +36,19 @@ export interface QueuedIntegrationMessage extends QueuedMessageInput { export type AgentQueuedMessage = QueuedPreviewMessage | QueuedIntegrationMessage; +/** Queue storage keeps dispatch data. Conversation input belongs to the referenced message. */ +export type AgentQueueDispatch = + | { kind: 'preview' } + | (Omit< + QueuedIntegrationMessage, + keyof QueuedMessageInput | 'modelMessage' | 'author' | 'platformThreadId' | 'messageContext' + > & { + messageContext: Omit< + IntegrationMessageContext, + 'platform' | 'integrationConnectionId' | 'messageId' + >; + }); + /** A committed execution reservation. Runtime preparation must reuse it. */ export interface AgentExecutionAdmission { executionId: string; diff --git a/packages/cli/src/modules/agents/types/agent-steering.ts b/packages/cli/src/modules/agents/types/agent-steering.ts new file mode 100644 index 00000000000..f169b560bde --- /dev/null +++ b/packages/cli/src/modules/agents/types/agent-steering.ts @@ -0,0 +1,12 @@ +import type { AgentDbMessage, StreamChunk } from '@n8n/agents'; +import type { AgentSseEvent } from '@n8n/api-types'; + +export type SteeredMessageEvent = Extract; + +export type AgentExecutionStreamChunk = StreamChunk | SteeredMessageEvent; + +export interface SteeringConsumption { + messages: AgentDbMessage[]; + events: SteeredMessageEvent[]; + stopped: boolean; +} diff --git a/packages/cli/src/modules/agents/utils/__tests__/fatal-session-outcome.test.ts b/packages/cli/src/modules/agents/utils/__tests__/fatal-session-outcome.test.ts new file mode 100644 index 00000000000..5ba2479f035 --- /dev/null +++ b/packages/cli/src/modules/agents/utils/__tests__/fatal-session-outcome.test.ts @@ -0,0 +1,122 @@ +import type { MessageRecord, TimelineEvent } from '../../execution-recorder'; +import { MARK_SESSION_FAILED_TOOL_NAME } from '../../tools/mark-session-failed.tool'; +import { applyFatalSessionOutcome, MAX_ITERATIONS_STOPPED_MESSAGE } from '../fatal-session-outcome'; + +function toolCall( + overrides: Partial> & + Pick, 'name' | 'success' | 'input'>, +): TimelineEvent { + return { + type: 'tool-call', + kind: 'tool', + toolCallId: 'tc', + output: {}, + startTime: 0, + endTime: 1, + ...overrides, + }; +} + +function record(overrides: Partial = {}): MessageRecord { + return { + assistantResponse: 'Done', + model: null, + finishReason: 'stop', + usage: null, + totalCost: null, + timeline: [], + startTime: 0, + duration: 1, + error: null, + ...overrides, + }; +} + +describe('applyFatalSessionOutcome', () => { + it('keeps an existing execution error', () => { + const existing = record({ + error: 'stream failed', + finishReason: 'max-iterations', + timeline: [ + toolCall({ + name: MARK_SESSION_FAILED_TOOL_NAME, + success: true, + input: { reason: 'gave up' }, + }), + ], + }); + + expect(applyFatalSessionOutcome(existing)).toBe(existing); + }); + + it('uses the last successful mark and ignores a later failed call', () => { + const existing = record({ + timeline: [ + toolCall({ + name: MARK_SESSION_FAILED_TOOL_NAME, + toolCallId: 'older', + success: true, + input: { reason: ' older ' }, + }), + toolCall({ + name: MARK_SESSION_FAILED_TOOL_NAME, + toolCallId: 'latest', + success: true, + input: { reason: ' latest reason ' }, + }), + toolCall({ + name: MARK_SESSION_FAILED_TOOL_NAME, + toolCallId: 'failed', + success: false, + input: { reason: 'nope' }, + }), + ], + }); + + expect(applyFatalSessionOutcome(existing)).toEqual({ ...existing, error: 'latest reason' }); + }); + + it('marks a max-iterations finish when the tool was not called', () => { + const existing = record({ finishReason: 'max-iterations' }); + + expect(applyFatalSessionOutcome(existing)).toEqual({ + ...existing, + error: MAX_ITERATIONS_STOPPED_MESSAGE, + }); + }); + + it('ignores a successful mark whose reason is not a string and keeps scanning', () => { + const existing = record({ + timeline: [ + toolCall({ + name: MARK_SESSION_FAILED_TOOL_NAME, + toolCallId: 'good', + success: true, + input: { reason: 'first reason' }, + }), + toolCall({ + name: MARK_SESSION_FAILED_TOOL_NAME, + toolCallId: 'bad', + success: true, + input: { reason: 12 }, + }), + ], + }); + + expect(applyFatalSessionOutcome(existing).error).toBe('first reason'); + }); + + it('leaves a recovered tool failure unchanged', () => { + const existing = record({ + timeline: [ + toolCall({ + name: 'lookup', + success: false, + input: {}, + }), + ], + }); + + expect(applyFatalSessionOutcome(existing)).toBe(existing); + }); +}); diff --git a/packages/cli/src/modules/agents/utils/__tests__/inbound-attachments.test.ts b/packages/cli/src/modules/agents/utils/__tests__/inbound-attachments.test.ts index 6cf881efd88..a08c0863391 100644 --- a/packages/cli/src/modules/agents/utils/__tests__/inbound-attachments.test.ts +++ b/packages/cli/src/modules/agents/utils/__tests__/inbound-attachments.test.ts @@ -1,7 +1,12 @@ -import type { Message } from '@n8n/agents'; +import type { AgentMessage, Message } from '@n8n/agents'; import { MAX_AGENT_CHAT_ATTACHMENT_MIMETYPE_LENGTH } from '@n8n/api-types'; +import { UnexpectedError } from 'n8n-workflow'; -import { buildInboundUserMessage, resolveInboundMimeType } from '../inbound-attachments'; +import { + buildInboundUserMessage, + readInboundUserMessage, + resolveInboundMimeType, +} from '../inbound-attachments'; // Minimal real PNG header (magic bytes + IHDR chunk) so file-type can sniff it. const PNG_BYTES = Buffer.from([ @@ -33,6 +38,13 @@ describe('buildInboundUserMessage', () => { fileRef: { id: 'att-2', fileName: 'voice.ogg', sizeBytes: 100 }, }, ]); + expect(readInboundUserMessage(message)).toEqual({ + message: 'look at these', + attachments: [ + { id: 'att-1', fileName: 'photo.png', mimeType: 'image/png', sizeBytes: 33 }, + { id: 'att-2', fileName: 'voice.ogg', mimeType: 'audio/ogg', sizeBytes: 100 }, + ], + }); }); it('omits the text part for attachment-only sends', () => { @@ -45,6 +57,18 @@ describe('buildInboundUserMessage', () => { }); }); +describe('readInboundUserMessage', () => { + it.each([ + { role: 'assistant', content: [] }, + { type: 'custom', data: { dummy: 'message' } }, + ])('rejects input without a user role: %j', (input) => { + const readInput = () => readInboundUserMessage(input); + + expect(readInput).toThrow(UnexpectedError); + expect(readInput).toThrow('Queued input must be a user message'); + }); +}); + describe('resolveInboundMimeType', () => { it('confirms a declared image type against magic bytes', async () => { expect(await resolveInboundMimeType('image/png', PNG_BYTES)).toBe('image/png'); 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 cdf87293498..ecdced9246d 100644 --- a/packages/cli/src/modules/agents/utils/agent-draft.utils.ts +++ b/packages/cli/src/modules/agents/utils/agent-draft.utils.ts @@ -1,6 +1,6 @@ import { v4 as uuid } from 'uuid'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ConflictError } from '@n8n/errors'; import type { Agent } from '../entities/agent.entity'; import type { AgentRepository } from '../repositories/agent.repository'; diff --git a/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts b/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts index 38695e8042f..436c606fe35 100644 --- a/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts +++ b/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts @@ -3,6 +3,7 @@ import { isRecord } from '@n8n/utils/is-record'; import type { AgentExecution } from '../entities/agent-execution.entity'; import type { TimelineEvent } from '../execution-recorder'; +import { isFatalSessionOutcomeError } from './fatal-session-outcome'; type ExecutionTranscript = Pick< AgentExecution, @@ -148,6 +149,49 @@ function assistantContentFromExecution( } export function executionToMessagesDto(execution: ExecutionTranscript): AgentPersistedMessageDto[] { + if (!execution.timeline?.some((event) => event.type === 'input')) { + return executionSegmentToMessagesDto(execution); + } + const messages: AgentPersistedMessageDto[] = []; + const steeredIds = new Set( + execution.timeline.filter((event) => event.type === 'input').map((event) => event.messageId), + ); + let segment: ExecutionTranscript = { + ...execution, + inputMessages: execution.inputMessages?.filter(({ id }) => !steeredIds.has(id)), + timeline: [], + }; + let suffix = ''; + const appendSegment = (value: ExecutionTranscript) => { + for (const message of executionSegmentToMessagesDto(value)) { + if (message.role === 'assistant') message.id += suffix; + messages.push(message); + } + }; + for (const event of execution.timeline) { + if (event.type !== 'input') { + segment.timeline?.push(event); + continue; + } + appendSegment({ ...segment, status: 'success', error: null }); + const input = execution.inputMessages?.find(({ id }) => id === event.messageId); + if (input) messages.push({ ...input, executionId: execution.id }); + suffix = `:${event.messageId}`; + segment = { + ...execution, + userMessage: null, + attachments: null, + author: null, + inputMessages: [], + timeline: [], + createdAt: new Date(event.timestamp), + }; + } + appendSegment(segment); + return messages; +} + +function executionSegmentToMessagesDto(execution: ExecutionTranscript): AgentPersistedMessageDto[] { const messages: AgentPersistedMessageDto[] = []; // Canonical inputs keep their message IDs. Trace messages and legacy inputs @@ -190,7 +234,9 @@ export function executionToMessagesDto(execution: ExecutionTranscript): AgentPer // It stays a separate field, not a text part, so the client does not show it // as model output. const executionError = - (execution.status === 'error' || execution.status === 'interrupted') && execution.error + (execution.status === 'error' || execution.status === 'interrupted') && + execution.error && + !isFatalSessionOutcomeError(execution.error, execution.timeline) ? execution.error : undefined; if (backgroundJobSignal || assistantContent.length > 0 || executionError !== undefined) { diff --git a/packages/cli/src/modules/agents/utils/fatal-session-outcome.ts b/packages/cli/src/modules/agents/utils/fatal-session-outcome.ts new file mode 100644 index 00000000000..7825225601b --- /dev/null +++ b/packages/cli/src/modules/agents/utils/fatal-session-outcome.ts @@ -0,0 +1,55 @@ +import { isRecord } from '@n8n/utils/is-record'; + +import type { MessageRecord, TimelineEvent } from '../execution-recorder'; +import { MARK_SESSION_FAILED_TOOL_NAME } from '../tools/mark-session-failed.tool'; + +export const MAX_ITERATIONS_STOPPED_MESSAGE = + 'The agent has reached the maximum number of iterations and has stopped.'; + +const MAX_REASON_LENGTH = 400; + +function markedSessionFailureReason(timeline: TimelineEvent[]): string | null { + for (let index = timeline.length - 1; index >= 0; index--) { + const event = timeline[index]; + if ( + event?.type !== 'tool-call' || + event.name !== MARK_SESSION_FAILED_TOOL_NAME || + !event.success + ) { + continue; + } + if (!isRecord(event.input) || typeof event.input.reason !== 'string') continue; + const reason = event.input.reason.trim().slice(0, MAX_REASON_LENGTH); + if (reason.length > 0) return reason; + } + return null; +} + +/** + * Copy a fatal outcome onto the execution error so the stored status becomes + * `error`. An error already on the record wins. A mark-session-failed call + * wins over a max-iterations stop. + */ +export function applyFatalSessionOutcome(record: MessageRecord): MessageRecord { + if (record.error !== null) return record; + const reason = markedSessionFailureReason(record.timeline); + if (reason !== null) return { ...record, error: reason }; + if (record.finishReason === 'max-iterations') { + return { ...record, error: MAX_ITERATIONS_STOPPED_MESSAGE }; + } + return record; +} + +/** + * Whether a stored execution error came from `applyFatalSessionOutcome`. + * Chat history does not show these as error bubbles, because the live stream + * does not show them. + */ +export function isFatalSessionOutcomeError( + error: string, + timeline: TimelineEvent[] | null, +): boolean { + return ( + error === MAX_ITERATIONS_STOPPED_MESSAGE || error === markedSessionFailureReason(timeline ?? []) + ); +} diff --git a/packages/cli/src/modules/agents/utils/get-agent-or-throw.ts b/packages/cli/src/modules/agents/utils/get-agent-or-throw.ts index d4b3fb34f80..01caa6d3988 100644 --- a/packages/cli/src/modules/agents/utils/get-agent-or-throw.ts +++ b/packages/cli/src/modules/agents/utils/get-agent-or-throw.ts @@ -1,4 +1,4 @@ -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { Agent } from '../entities/agent.entity'; import type { AgentRepository } from '../repositories/agent.repository'; diff --git a/packages/cli/src/modules/agents/utils/inbound-attachments.ts b/packages/cli/src/modules/agents/utils/inbound-attachments.ts index af4dba43a14..6f1b2cba783 100644 --- a/packages/cli/src/modules/agents/utils/inbound-attachments.ts +++ b/packages/cli/src/modules/agents/utils/inbound-attachments.ts @@ -1,5 +1,6 @@ -import type { Message } from '@n8n/agents'; +import type { AgentMessage, Message } from '@n8n/agents'; import { MAX_AGENT_CHAT_ATTACHMENT_MIMETYPE_LENGTH } from '@n8n/api-types'; +import { UnexpectedError } from 'n8n-workflow'; import type { StoredAttachmentRef } from '../types/agent-chat-attachment'; @@ -69,3 +70,30 @@ export function buildInboundUserMessage( } return [{ role: 'user', content }]; } + +/** Read the original input without display normalization or file hydration. */ +export function readInboundUserMessage(input: AgentMessage): { + message: string; + attachments: StoredAttachmentRef[]; +} { + if (!('role' in input) || input.role !== 'user') { + throw new UnexpectedError('Queued input must be a user message'); + } + return { + message: input.content + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join('\n'), + attachments: input.content.flatMap((part) => { + if (part.type !== 'file' || !part.fileRef) return []; + return [ + { + id: part.fileRef.id, + fileName: part.fileRef.fileName ?? '', + mimeType: part.mediaType ?? 'application/octet-stream', + sizeBytes: part.fileRef.sizeBytes ?? 0, + }, + ]; + }), + }; +} diff --git a/packages/cli/src/modules/agents/utils/queued-message-id.ts b/packages/cli/src/modules/agents/utils/queued-message-id.ts new file mode 100644 index 00000000000..ac514999e23 --- /dev/null +++ b/packages/cli/src/modules/agents/utils/queued-message-id.ts @@ -0,0 +1,39 @@ +import { v5 as uuidv5 } from 'uuid'; + +import type { AgentQueuedMessage } from '../types/agent-queued-message'; + +const MESSAGE_NAMESPACE = uuidv5('n8n-agent-message-acceptance-v1', uuidv5.URL); + +/** Keep this contract stable across mains and releases so delivery retries keep the same ID. */ +export function queuedMessageId( + agentId: string, + threadId: string, + payload: AgentQueuedMessage, +): string | undefined { + if (payload.kind === 'preview') { + if (!payload.messageId) return undefined; + return uuidv5( + JSON.stringify([ + 'preview', + agentId, + payload.userId, + threadId, + payload.messageId.toLowerCase(), + ]), + MESSAGE_NAMESPACE, + ); + } + const { platform, integrationConnectionId, messageId } = payload.messageContext; + if (!messageId) return undefined; + return uuidv5( + JSON.stringify([ + 'integration', + agentId, + platform, + integrationConnectionId, + payload.platformThreadId, + messageId, + ]), + MESSAGE_NAMESPACE, + ); +} diff --git a/packages/cli/src/modules/breaking-changes/README.md b/packages/cli/src/modules/breaking-changes/README.md index ed278f03b74..733b65ad70a 100644 --- a/packages/cli/src/modules/breaking-changes/README.md +++ b/packages/cli/src/modules/breaking-changes/README.md @@ -67,7 +67,7 @@ Returns: "ruleId": "process-env-access-v2", "ruleTitle": "Process Environment Access Restrictions", "ruleDescription": "Access to process.env is now restricted", - "ruleSeverity": "high", + "ruleImpact": "executionsFail", "instanceIssues": [ { "title": "Environment access detected", @@ -88,7 +88,7 @@ Returns: "ruleId": "removed-nodes-v2", "ruleTitle": "Removed Deprecated Nodes", "ruleDescription": "Several deprecated nodes have been removed", - "ruleSeverity": "critical", + "ruleImpact": "executionsFail", "affectedWorkflows": [ { "id": "wf-001", @@ -121,6 +121,19 @@ Returns: } ``` +## Rule Impact + +Each rule states what happens if the user does not fix it before the update. Set `impact` in `getMetadata()` to one of these values: + +| Impact | Meaning | Example | +| --- | --- | --- | +| `upgradeBlocked` | The instance does not start, or the update cannot proceed. | A removed storage or deployment mode that the new version refuses to boot with. | +| `executionsFail` | Affected executions error. | A removed node or expression helper. | +| `behaviorChanges` | Executions keep running, but the result changes. | A changed default or fixed semantics. | +| `capabilityRemoved` | No runtime impact. A removed capability is no longer available. | A removed UI option or CLI flag. | + +Pick the impact from the effect on the user, not from how many workflows a rule touches. The migration report sorts rules by impact, from `upgradeBlocked` down to `capabilityRemoved`. + ## Rule Types The system supports three types of rules: @@ -129,7 +142,7 @@ The system supports three types of rules: - **Purpose**: Check individual workflows for breaking changes - **Methods**: - - `getMetadata()`: Returns rule metadata (version, title, description, severity, etc.) + - `getMetadata()`: Returns rule metadata (version, title, description, impact, etc.) - `detectWorkflow(workflow, nodesGroupedByType)`: Checks a single workflow and returns issues - `getRecommendations(workflowResults)`: Returns recommendations based on detected issues - **Returns**: `WorkflowDetectionReport` with workflow-specific issues @@ -139,7 +152,7 @@ The system supports three types of rules: - **Purpose**: Check instance-level configuration and environment - **Methods**: - - `getMetadata()`: Returns rule metadata (version, title, description, severity, etc.) + - `getMetadata()`: Returns rule metadata (version, title, description, impact, etc.) - `detect()`: Checks the entire instance and returns issues - **Returns**: `InstanceDetectionReport` with instance-level issues and recommendations - **Example Use Cases**: Environment variable requirements, database version checks, configuration changes @@ -191,7 +204,7 @@ export class MyWorkflowRule implements IBreakingChangeWorkflowRule { title: 'My Workflow Breaking Change', description: 'Description of what changed in workflows', category: BreakingChangeCategory.workflow, - severity: 'high', + impact: 'executionsFail', documentationUrl: 'https://docs.n8n.io/migration/v2/...', }; } @@ -254,7 +267,7 @@ export class MyInstanceRule implements IBreakingChangeInstanceRule { title: 'My Instance Breaking Change', description: 'Description of what changed at instance level', category: BreakingChangeCategory.instance, - severity: 'medium', + impact: 'behaviorChanges', documentationUrl: 'https://docs.n8n.io/migration/v2/...', }; } diff --git a/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.rule-discovery.test.ts b/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.rule-discovery.test.ts index d0e3197fc80..9ddd0b1cff5 100644 --- a/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.rule-discovery.test.ts +++ b/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.rule-discovery.test.ts @@ -35,7 +35,7 @@ describe('Breaking change rules auto-discovery', () => { expect(ruleMetadata.title).toBeTruthy(); expect(ruleMetadata.description).toBeTruthy(); expect(ruleMetadata.category).toBeTruthy(); - expect(ruleMetadata.severity).toBeTruthy(); + expect(ruleMetadata.impact).toBeTruthy(); } }); diff --git a/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.rule-registry.service.test.ts b/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.rule-registry.service.test.ts index 2f5d37d6521..1f88e1db655 100644 --- a/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.rule-registry.service.test.ts +++ b/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.rule-registry.service.test.ts @@ -31,7 +31,7 @@ describe('RuleRegistry', () => { title, description: `Description for ${title}`, category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'behaviorChanges', }; const mockRule: IBreakingChangeRule = { diff --git a/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.service.test.ts b/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.service.test.ts index f119b69e0d4..b42fdc0df8f 100644 --- a/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.service.test.ts +++ b/packages/cli/src/modules/breaking-changes/__tests__/breaking-changes.service.test.ts @@ -5,7 +5,7 @@ import type { ErrorReporter } from 'n8n-core'; import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { N8N_VERSION } from '../../../constants'; import { MigrationRegistry } from '../breaking-changes.migration-registry.service'; @@ -220,6 +220,15 @@ describe('BreakingChangeService', () => { expect(detectSpy).toHaveBeenCalledTimes(1); }); + it('should key the cache on the n8n version and the target version', async () => { + workflowRepository.find.mockResolvedValue([]); + workflowRepository.count.mockResolvedValue(0); + + await service.getDetectionResults('v2'); + + expect(cacheService.get).toHaveBeenCalledWith(`breaking-changes:results:${N8N_VERSION}:v2`); + }); + it('should clean up ongoing detection promise after completion', async () => { workflowRepository.find.mockResolvedValue([]); workflowRepository.count.mockResolvedValue(0); diff --git a/packages/cli/src/modules/breaking-changes/breaking-changes.controller.ts b/packages/cli/src/modules/breaking-changes/breaking-changes.controller.ts index 7332b2b5eb3..aa430091d06 100644 --- a/packages/cli/src/modules/breaking-changes/breaking-changes.controller.ts +++ b/packages/cli/src/modules/breaking-changes/breaking-changes.controller.ts @@ -10,7 +10,7 @@ import { AuthenticatedRequest } from '@n8n/db'; import { Get, RestController, GlobalScope, Query, Post, Param } from '@n8n/decorators'; import { Response } from 'express'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { BreakingChangeMigrationService } from './breaking-changes.migration.service'; import { BreakingChangeService } from './breaking-changes.service'; diff --git a/packages/cli/src/modules/breaking-changes/breaking-changes.migration.service.ts b/packages/cli/src/modules/breaking-changes/breaking-changes.migration.service.ts index c49f7a42232..57804559a17 100644 --- a/packages/cli/src/modules/breaking-changes/breaking-changes.migration.service.ts +++ b/packages/cli/src/modules/breaking-changes/breaking-changes.migration.service.ts @@ -5,8 +5,7 @@ import { Service } from '@n8n/di'; import { calculateWorkflowChecksum } from 'n8n-workflow'; import type { INode } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { NodeTypes } from '@/node-types'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowValidationService } from '@/workflows/workflow-validation.service'; diff --git a/packages/cli/src/modules/breaking-changes/breaking-changes.service.ts b/packages/cli/src/modules/breaking-changes/breaking-changes.service.ts index 8cee3b091da..7b981b788d0 100644 --- a/packages/cli/src/modules/breaking-changes/breaking-changes.service.ts +++ b/packages/cli/src/modules/breaking-changes/breaking-changes.service.ts @@ -13,7 +13,7 @@ import { Container, Service } from '@n8n/di'; import { In } from '@n8n/typeorm'; import { ErrorReporter } from 'n8n-core'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { MigrationRegistry } from './breaking-changes.migration-registry.service'; import { RuleRegistry } from './breaking-changes.rule-registry.service'; @@ -77,7 +77,7 @@ export class BreakingChangeService { ruleId: rule.id, ruleTitle: rule.getMetadata().title, ruleDescription: rule.getMetadata().description, - ruleSeverity: rule.getMetadata().severity, + ruleImpact: rule.getMetadata().impact, ruleDocumentationUrl: rule.getMetadata().documentationUrl, instanceIssues: ruleResult.instanceIssues, recommendations: ruleResult.recommendations, @@ -105,7 +105,7 @@ export class BreakingChangeService { ruleId: rule.id, ruleTitle: rule.getMetadata().title, ruleDescription: rule.getMetadata().description, - ruleSeverity: rule.getMetadata().severity, + ruleImpact: rule.getMetadata().impact, ruleDocumentationUrl: rule.getMetadata().documentationUrl, affectedWorkflows: workflowResults, recommendations: await rule.getRecommendations(workflowResults), @@ -154,7 +154,7 @@ export class BreakingChangeService { ruleId: rule.id, ruleTitle: rule.getMetadata().title, ruleDescription: rule.getMetadata().description, - ruleSeverity: rule.getMetadata().severity, + ruleImpact: rule.getMetadata().impact, ruleDocumentationUrl: rule.getMetadata().documentationUrl, affectedWorkflows, recommendations: await rule.getRecommendations(affectedWorkflows), @@ -271,7 +271,7 @@ export class BreakingChangeService { async refreshDetectionResults( targetVersion: BreakingChangeVersion, ): Promise { - await this.cacheService.delete(`${BreakingChangeService.CACHE_KEY_PREFIX}_${targetVersion}`); + await this.cacheService.delete(this.getCacheKey(targetVersion)); return await this.getDetectionResults(targetVersion); } @@ -295,10 +295,16 @@ export class BreakingChangeService { } } + // The rule set changes with every release, so a report from an older build is stale. + // The n8n version in the key rotates the entry on deploy; the old key ages out by TTL. + private getCacheKey(targetVersion: BreakingChangeVersion): string { + return `${BreakingChangeService.CACHE_KEY_PREFIX}${N8N_VERSION}:${targetVersion}`; + } + private async detectWithCache( targetVersion: BreakingChangeVersion, ): Promise { - const cacheKey = `${BreakingChangeService.CACHE_KEY_PREFIX}_${targetVersion}`; + const cacheKey = this.getCacheKey(targetVersion); const cachedResult = await this.cacheService.get(cacheKey); if (cachedResult) { diff --git a/packages/cli/src/modules/breaking-changes/migrations/node-migration.ts b/packages/cli/src/modules/breaking-changes/migrations/node-migration.ts index d1c8a7cb64a..5e11ac5ef25 100644 --- a/packages/cli/src/modules/breaking-changes/migrations/node-migration.ts +++ b/packages/cli/src/modules/breaking-changes/migrations/node-migration.ts @@ -1,6 +1,6 @@ import type { IConnections, INode } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; /** * A migration refused a specific node. Carries the node identity in `meta` so the diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/cli-replace-update-workflow-command.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/cli-replace-update-workflow-command.rule.test.ts index 3a6b8753d71..a1ff564d0dd 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/cli-replace-update-workflow-command.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/cli-replace-update-workflow-command.rule.test.ts @@ -13,7 +13,7 @@ describe('CliActivateAllWorkflowsRule', () => { expect(metadata.version).toBe('v2'); expect(metadata.title).toBe('CLI command update:workflow replaced'); - expect(metadata.severity).toBe('low'); + expect(metadata.impact).toBe('capabilityRemoved'); }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/file-access.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/file-access.rule.test.ts index c4f78afcedb..8f903c79cbf 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/file-access.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/file-access.rule.test.ts @@ -19,7 +19,7 @@ describe('FileAccessRule', () => { title: 'File Access Restrictions', description: 'File access is now restricted to a default directory for security purposes', category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'executionsFail', }); }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/process-env-access.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/process-env-access.rule.test.ts index fcb2f0722b6..f380c8b1bc8 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/process-env-access.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/process-env-access.rule.test.ts @@ -32,7 +32,7 @@ describe('ProcessEnvAccessRule', () => { title: 'Block process.env Access in Expressions and Code nodes', description: 'Direct access to process.env is blocked by default for security', category: BreakingChangeCategory.workflow, - severity: 'low', + impact: 'executionsFail', }); }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/pyodide-removed.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/pyodide-removed.rule.test.ts index 7e1e8092c66..43880c0c6fe 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/pyodide-removed.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/pyodide-removed.rule.test.ts @@ -20,7 +20,7 @@ describe('PyodideRemovedRule', () => { description: 'The Pyodide-based Python implementation in the Code node has been removed and replaced with a native Python task runner implementation', category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'executionsFail', }); }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/removed-nodes.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/removed-nodes.rule.test.ts index 8f5207c12cc..543cac834f6 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/removed-nodes.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/removed-nodes.rule.test.ts @@ -19,7 +19,7 @@ describe('RemovedNodesRule', () => { title: 'Removed Deprecated Nodes', description: 'Several deprecated nodes have been removed and will no longer work', category: BreakingChangeCategory.workflow, - severity: 'low', + impact: 'executionsFail', }); }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/start-node-removed.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/start-node-removed.rule.test.ts index 825bded1bfb..7e4e90218a9 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/start-node-removed.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/start-node-removed.rule.test.ts @@ -20,7 +20,7 @@ describe('StartNodeRemovedRule', () => { description: 'The Start node is no longer supported. Replace it with a Manual Trigger for manual executions, or with an Execute Workflow Trigger if used as a sub-workflow.', category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'executionsFail', }); }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/task-runner-docker-image.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/task-runner-docker-image.rule.test.ts index 4b49d71e0d3..16af62d5453 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/task-runner-docker-image.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/task-runner-docker-image.rule.test.ts @@ -19,7 +19,7 @@ describe('TaskRunnerDockerImageRule', () => { expect(metadata.version).toBe('v2'); expect(metadata.title).toBe('Remove task runner from n8nio/n8n docker image'); - expect(metadata.severity).toBe('medium'); + expect(metadata.impact).toBe('executionsFail'); }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/wait-node-subworkflow.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/wait-node-subworkflow.rule.test.ts index 88f0c6f9a00..bccf1d4f507 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/wait-node-subworkflow.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/wait-node-subworkflow.rule.test.ts @@ -28,7 +28,7 @@ describe('WaitNodeSubworkflowRule', () => { title: 'Sub-workflow waiting node output behavior change', description: expect.stringContaining('Parent workflows calling sub-workflows'), category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'behaviorChanges', }); }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/workflow-hooks-deprecated.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/workflow-hooks-deprecated.rule.test.ts index 771cb46c5c4..bfac99ea04a 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/workflow-hooks-deprecated.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/workflow-hooks-deprecated.rule.test.ts @@ -13,7 +13,7 @@ describe('WorkflowHooksDeprecatedRule', () => { expect(metadata.version).toBe('v2'); expect(metadata.title).toBe('Deprecated frontend workflow hooks'); - expect(metadata.severity).toBe('low'); + expect(metadata.impact).toBe('capabilityRemoved'); expect(metadata.documentationUrl).toBe( 'https://docs.n8n.io/2-0-breaking-changes/#deprecated-frontend-workflow-hooks', ); diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/binary-data-storage.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/binary-data-storage.rule.ts index 4b732802493..53e86470224 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/binary-data-storage.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/binary-data-storage.rule.ts @@ -25,7 +25,7 @@ export class BinaryDataStorageRule implements IBreakingChangeInstanceRule { description: 'Binary files are now stored on disk (default in regular mode) or in database (default in queue mode) instead of in memory', category: BreakingChangeCategory.infrastructure, - severity: 'low', + impact: 'behaviorChanges', documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#remove-in-memory-binary-data-mode', }; diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/cli-replace-update-workflow-command.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/cli-replace-update-workflow-command.rule.ts index c382dcdf973..3657ed21da6 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/cli-replace-update-workflow-command.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/cli-replace-update-workflow-command.rule.ts @@ -18,7 +18,7 @@ export class CliActivateAllWorkflowsRule implements IBreakingChangeInstanceRule description: 'The CLI command update:workflow has been replaced with publish:workflow and unpublish:workflow for better clarity.', category: BreakingChangeCategory.instance, - severity: 'low', + impact: 'capabilityRemoved', documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#replace-cli-command-updateworkflow', }; diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/disabled-nodes.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/disabled-nodes.rule.ts index db493703e41..a85fde4568f 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/disabled-nodes.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/disabled-nodes.rule.ts @@ -26,7 +26,7 @@ export class DisabledNodesRule implements IBreakingChangeWorkflowRule { description: 'ExecuteCommand and LocalFileTrigger nodes are now disabled by default for security reasons', category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'executionsFail', documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#disable-executecommand-and-localfiletrigger-nodes-by-default', }; diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/dotenv-upgrade.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/dotenv-upgrade.rule.ts index 7f48a20432e..7b8338de276 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/dotenv-upgrade.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/dotenv-upgrade.rule.ts @@ -20,7 +20,7 @@ export class DotenvUpgradeRule implements IBreakingChangeInstanceRule { description: 'The dotenv library has been upgraded, which may affect how .env files are parsed', category: BreakingChangeCategory.environment, - severity: 'low', + impact: 'behaviorChanges', documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#upgrade-dotenv', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/file-access.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/file-access.rule.ts index de5cc0649d0..719a0ff287d 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/file-access.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/file-access.rule.ts @@ -22,7 +22,7 @@ export class FileAccessRule implements IBreakingChangeWorkflowRule { title: 'File Access Restrictions', description: 'File access is now restricted to a default directory for security purposes', category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'executionsFail', documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#set-default-value-for-n8nrestrictfileaccessto', }; diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/oauth-callback-auth.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/oauth-callback-auth.rule.ts index 6455a79832a..6800af9a900 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/oauth-callback-auth.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/oauth-callback-auth.rule.ts @@ -19,7 +19,7 @@ export class OAuthCallbackAuthRule implements IBreakingChangeInstanceRule { description: 'OAuth callbacks now enforce n8n user authentication by default for improved security', category: BreakingChangeCategory.instance, - severity: 'medium', + impact: 'behaviorChanges', documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#require-authentication-on-oauth-callback-urls-by-default', }; diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/process-env-access.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/process-env-access.rule.ts index 7642e573dbb..c61732b9fdc 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/process-env-access.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/process-env-access.rule.ts @@ -19,7 +19,7 @@ export class ProcessEnvAccessRule implements IBreakingChangeWorkflowRule { title: 'Block process.env Access in Expressions and Code nodes', description: 'Direct access to process.env is blocked by default for security', category: BreakingChangeCategory.workflow, - severity: 'low', + impact: 'executionsFail', documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#block-environment-variable-access-from-code-node-by-default', }; diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/pyodide-removed.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/pyodide-removed.rule.ts index 2f76034f012..2cca7b0c416 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/pyodide-removed.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/pyodide-removed.rule.ts @@ -22,7 +22,7 @@ export class PyodideRemovedRule implements IBreakingChangeWorkflowRule { description: 'The Pyodide-based Python implementation in the Code node has been removed and replaced with a native Python task runner implementation', category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'executionsFail', documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#remove-pyodide-based-python-code-node-and-tool', }; diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/queue-worker-max-stalled-count.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/queue-worker-max-stalled-count.rule.ts index 99ce6cad665..2aeed0489bc 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/queue-worker-max-stalled-count.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/queue-worker-max-stalled-count.rule.ts @@ -18,7 +18,7 @@ export class QueueWorkerMaxStalledCountRule implements IBreakingChangeInstanceRu description: 'The QUEUE_WORKER_MAX_STALLED_COUNT environment variable has been removed and will be ignored', category: BreakingChangeCategory.environment, - severity: 'low', + impact: 'capabilityRemoved', documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#remove-queueworkermaxstalledcount', }; diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/removed-nodes.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/removed-nodes.rule.ts index 3aff2fc1d55..c002facc571 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/removed-nodes.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/removed-nodes.rule.ts @@ -26,7 +26,7 @@ export class RemovedNodesRule implements IBreakingChangeWorkflowRule { title: 'Removed Deprecated Nodes', description: 'Several deprecated nodes have been removed and will no longer work', category: BreakingChangeCategory.workflow, - severity: 'low', + impact: 'executionsFail', documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#removed-nodes-for-retired-services', }; diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/settings-file-permissions.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/settings-file-permissions.rule.ts index 179fd883e23..1374eb0a277 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/settings-file-permissions.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/settings-file-permissions.rule.ts @@ -21,7 +21,7 @@ export class SettingsFilePermissionsRule implements IBreakingChangeInstanceRule description: 'n8n now enforces stricter permissions on configuration files for improved security', category: BreakingChangeCategory.infrastructure, - severity: 'low', + impact: 'behaviorChanges', documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#enforce-settings-file-permissions', }; diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/start-node-removed.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/start-node-removed.rule.ts index 513661f0a36..8cd9231015a 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/start-node-removed.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/start-node-removed.rule.ts @@ -23,7 +23,7 @@ export class StartNodeRemovedRule implements IBreakingChangeWorkflowRule { description: 'The Start node is no longer supported. Replace it with a Manual Trigger for manual executions, or with an Execute Workflow Trigger if used as a sub-workflow.', category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'executionsFail', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/task-runner-docker-image.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/task-runner-docker-image.rule.ts index 63ff9c5f68f..bd411397ea5 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/task-runner-docker-image.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/task-runner-docker-image.rule.ts @@ -21,7 +21,7 @@ export class TaskRunnerDockerImageRule implements IBreakingChangeInstanceRule { description: 'Task runners are no longer included in the n8nio/n8n docker image and must use the separate n8nio/runners image', category: BreakingChangeCategory.infrastructure, - severity: 'medium', + impact: 'executionsFail', documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#remove-task-runner-from-n8nion8n-docker-image', }; diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/tunnel-option.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/tunnel-option.rule.ts index d8090cec76c..e50403dcaf0 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/tunnel-option.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/tunnel-option.rule.ts @@ -17,7 +17,7 @@ export class TunnelOptionRule implements IBreakingChangeInstanceRule { title: 'Remove n8n --tunnel option', description: 'The --tunnel CLI option has been removed and will be ignored', category: BreakingChangeCategory.instance, - severity: 'low', + impact: 'capabilityRemoved', documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#remove-n8n-tunnel-option', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/wait-node-subworkflow.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/wait-node-subworkflow.rule.ts index 7f7201defa1..31f3ab71646 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/wait-node-subworkflow.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/wait-node-subworkflow.rule.ts @@ -69,7 +69,7 @@ export class WaitNodeSubworkflowRule implements IBreakingChangeBatchWorkflowRule description: 'Parent workflows calling sub-workflows with waiting nodes (Wait, Form, HITL) now receive correct data. Previously, incorrect results were returned when the sub-workflow entered a waiting state.', category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'behaviorChanges', documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#return-expected-sub-workflow-data-when-the-sub-workflow-resumes-from-waiting-waiting-for-webhook-for', }; diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/workflow-hooks-deprecated.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/workflow-hooks-deprecated.rule.ts index 43e16cbbdb8..e2fe38b0b17 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/workflow-hooks-deprecated.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/workflow-hooks-deprecated.rule.ts @@ -18,7 +18,7 @@ export class WorkflowHooksDeprecatedRule implements IBreakingChangeInstanceRule description: 'The hooks workflow.activeChange and workflow.activeChangeCurrent are deprecated and replaced by workflow.published', category: BreakingChangeCategory.instance, - severity: 'low', + impact: 'capabilityRemoved', documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#deprecated-frontend-workflow-hooks', }; diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/agent-node-version.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/agent-node-version.rule.test.ts index 911697601b6..78726c47074 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/agent-node-version.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/agent-node-version.rule.test.ts @@ -91,8 +91,8 @@ describe('AgentNodeVersionRule', () => { }); describe('getMetadata()', () => { - it('should report a medium severity for nodes that keep working', () => { - expect(rule.getMetadata().severity).toBe('medium'); + it('should report a behaviorChanges impact for nodes that keep working', () => { + expect(rule.getMetadata().impact).toBe('behaviorChanges'); }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/agent-removed-modes.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/agent-removed-modes.rule.test.ts index f2fcdbaf310..671f6077671 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/agent-removed-modes.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/agent-removed-modes.rule.test.ts @@ -102,8 +102,8 @@ describe('AgentRemovedModesRule', () => { }); describe('getMetadata()', () => { - it('should report a critical severity for nodes that stop working', () => { - expect(rule.getMetadata().severity).toBe('critical'); + it('should report an executionsFail impact for nodes that stop working', () => { + expect(rule.getMetadata().impact).toBe('executionsFail'); }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/ai-transform-deprecated.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/ai-transform-deprecated.rule.test.ts index a1920869b32..aa19f28590a 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/ai-transform-deprecated.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/ai-transform-deprecated.rule.test.ts @@ -36,4 +36,10 @@ describe('AiTransformDeprecatedRule', () => { expect(result.issues[0].nodeId).toBe('node-Transform A'); }); }); + + describe('getMetadata()', () => { + it('should report an executionsFail impact because the node is removed on 3.x', () => { + expect(rule.getMetadata().impact).toBe('executionsFail'); + }); + }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/chat-trigger-embedded-json.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/chat-trigger-embedded-json.rule.test.ts index cda02cbd146..766708d2f89 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/chat-trigger-embedded-json.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/chat-trigger-embedded-json.rule.test.ts @@ -93,4 +93,10 @@ describe('ChatTriggerEmbeddedJsonRule', () => { expect(recommendations[0].action).toContain('embedded chat'); }); }); + + describe('getMetadata()', () => { + it('should report an executionsFail impact for embedded chats that cannot read the new frames', () => { + expect(rule.getMetadata().impact).toBe('executionsFail'); + }); + }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/compression-node-limits.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/compression-node-limits.rule.test.ts index eb8342cd0f5..d9c3c2efaad 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/compression-node-limits.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/compression-node-limits.rule.test.ts @@ -54,4 +54,10 @@ describe('CompressionNodeLimitsRule', () => { expect(result.instanceIssues).toHaveLength(0); }); }); + + describe('getMetadata()', () => { + it('should report an executionsFail impact because oversized archives throw', () => { + expect(rule.getMetadata().impact).toBe('executionsFail'); + }); + }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/gmail-trigger-version.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/gmail-trigger-version.rule.test.ts index bd92b7e910d..a7362ca82c3 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/gmail-trigger-version.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/gmail-trigger-version.rule.test.ts @@ -70,4 +70,10 @@ describe('GmailTriggerVersionRule', () => { expect(recommendations[0].description).toContain('Schedule Trigger'); }); }); + + describe('getMetadata()', () => { + it('should report a behaviorChanges impact because older versions alias 1.4', () => { + expect(rule.getMetadata().impact).toBe('behaviorChanges'); + }); + }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/in-memory-binary-data.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/in-memory-binary-data.rule.test.ts index 98355a684c3..a1ca14f6b76 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/in-memory-binary-data.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/in-memory-binary-data.rule.test.ts @@ -35,4 +35,10 @@ describe('InMemoryBinaryDataRule', () => { expect(result.recommendations).toHaveLength(1); }); }); + + describe('getMetadata()', () => { + it('should report a behaviorChanges impact because the storage mode is migrated automatically', () => { + expect(rule.getMetadata().impact).toBe('behaviorChanges'); + }); + }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/removed-nodes.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/removed-nodes.rule.test.ts index b175695c40c..f86e3d86bf2 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/removed-nodes.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/removed-nodes.rule.test.ts @@ -25,7 +25,7 @@ describe('RemovedNodesV3Rule', () => { title: 'Removed nodes', description: 'Several nodes have been removed and will no longer work', category: BreakingChangeCategory.workflow, - severity: 'low', + impact: 'executionsFail', }); await expect(rule.getRecommendations([])).resolves.toEqual([ { diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/storage-path-rename.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/storage-path-rename.rule.test.ts index 73f37616217..1a8db209f90 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/storage-path-rename.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/storage-path-rename.rule.test.ts @@ -59,4 +59,10 @@ describe('StoragePathRenameRule', () => { expect(result.instanceIssues[0].level).toBe('error'); expect(result.recommendations[0].action).toBe('Resolve the directory conflict'); }); + + describe('getMetadata()', () => { + it('should report a behaviorChanges impact for the automatic rename', () => { + expect(rule.getMetadata().impact).toBe('behaviorChanges'); + }); + }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/task-runner-task-timeout.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/task-runner-task-timeout.rule.test.ts index 2b4f703936f..19e71805fd0 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/task-runner-task-timeout.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/task-runner-task-timeout.rule.test.ts @@ -33,4 +33,10 @@ describe('TaskRunnerTaskTimeoutRule', () => { expect(result.instanceIssues).toHaveLength(0); }); }); + + describe('getMetadata()', () => { + it('should report an executionsFail impact because a timed-out task fails the Code node', () => { + expect(rule.getMetadata().impact).toBe('executionsFail'); + }); + }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/unverified-packages.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/unverified-packages.rule.test.ts index eb7c1efdc38..c1052b36f70 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/unverified-packages.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/unverified-packages.rule.test.ts @@ -56,4 +56,10 @@ describe('UnverifiedPackagesRule', () => { expect(result.isAffected).toBe(false); }); }); + + describe('getMetadata()', () => { + it('should report a behaviorChanges impact', () => { + expect(rule.getMetadata().impact).toBe('behaviorChanges'); + }); + }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/agent-node-version.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/agent-node-version.rule.ts index 60df6ad58a1..d51a487a3a2 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/agent-node-version.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/agent-node-version.rule.ts @@ -27,7 +27,7 @@ export class AgentNodeVersionRule implements IBreakingChangeWorkflowRule { description: 'AI Agent versions below 2 are removed. After the update, AI Agent nodes that are currently on a version below 2 will run with version 2 behavior, which only supports the Tools Agent.', category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'behaviorChanges', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/agent-removed-modes.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/agent-removed-modes.rule.ts index e066cc055f7..2ae3d9b8628 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/agent-removed-modes.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/agent-removed-modes.rule.ts @@ -27,7 +27,7 @@ export class AgentRemovedModesRule implements IBreakingChangeWorkflowRule { description: 'AI Agent versions below 2 are removed, and with them the Conversational, OpenAI Functions, Plan and Execute, ReAct and SQL Agent modes, which version 2 does not offer. After the update, nodes set to one of these modes will fail when executed.', category: BreakingChangeCategory.workflow, - severity: 'critical', + impact: 'executionsFail', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/ai-transform-deprecated.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/ai-transform-deprecated.rule.ts index 32e9f046d75..6018672fb05 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/ai-transform-deprecated.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/ai-transform-deprecated.rule.ts @@ -24,7 +24,7 @@ export class AiTransformDeprecatedRule implements IBreakingChangeWorkflowRule { description: 'The AI Transform node is deprecated. Its generated code runs as a Code node instead.', category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'executionsFail', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/always-output-data-multi-output.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/always-output-data-multi-output.rule.ts index 3969d9a6c32..f8512c53087 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/always-output-data-multi-output.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/always-output-data-multi-output.rule.ts @@ -27,7 +27,7 @@ export class AlwaysOutputDataMultiOutputRule implements IBreakingChangeWorkflowR description: 'On nodes with multiple outputs, "Always Output Data" currently adds an empty item to the first output even when another output produced data, which misroutes items. A future version fixes this so the empty item is only added when every output is empty. Workflows relying on the current behavior will produce different output.', category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'behaviorChanges', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/chat-hub-deprecated.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/chat-hub-deprecated.rule.ts index 403628b312e..18af969f111 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/chat-hub-deprecated.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/chat-hub-deprecated.rule.ts @@ -24,7 +24,7 @@ export class ChatHubDeprecatedRule implements IBreakingChangeInstanceRule { description: 'In v3, the chat hub module is off by default and the Chat section disappears from the UI. You can turn it on again with the N8N_ENABLED_MODULES environment variable, but the feature is removed in v4.', category: BreakingChangeCategory.instance, - severity: 'medium', + impact: 'capabilityRemoved', documentationUrl: 'https://docs.n8n.io/changelog/v30-breaking-changes', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/chat-trigger-embedded-json.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/chat-trigger-embedded-json.rule.ts index 5d9b2a95442..5faa97d306c 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/chat-trigger-embedded-json.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/chat-trigger-embedded-json.rule.ts @@ -31,7 +31,7 @@ export class ChatTriggerEmbeddedJsonRule implements IBreakingChangeWorkflowRule description: 'The chat WebSocket now sends every frame as JSON. Embedded chats using an old @n8n/chat widget pinned to a specific version, or a custom chat client that reads the raw WebSocket, will not understand the new frames until updated. Chats embedded via the unpinned CDN script update automatically, and hosted chats served by n8n are unaffected.', category: BreakingChangeCategory.workflow, - severity: 'low', + impact: 'executionsFail', documentationUrl: 'https://www.npmjs.com/package/@n8n/chat', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/compression-node-limits.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/compression-node-limits.rule.ts index f970f4c0cd7..daa5dab5b97 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/compression-node-limits.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/compression-node-limits.rule.ts @@ -19,7 +19,7 @@ export class CompressionNodeLimitsRule implements IBreakingChangeInstanceRule { description: 'The default maximum decompressed size is reduced from 2 GiB to 256 MiB and the default maximum number of ZIP entries from 5000 to 1000. Compression nodes handling archives beyond the new limits will fail.', category: BreakingChangeCategory.environment, - severity: 'low', + impact: 'executionsFail', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/docker-only-deployment.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/docker-only-deployment.rule.ts index 2c0aa0d0008..87cb62c118d 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/docker-only-deployment.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/docker-only-deployment.rule.ts @@ -22,7 +22,7 @@ export class DockerOnlyDeploymentRule implements IBreakingChangeInstanceRule { description: 'Support for running n8n via npm is removed. The new version must be run via the official Docker image.', category: BreakingChangeCategory.infrastructure, - severity: 'medium', + impact: 'upgradeBlocked', documentationUrl: 'https://docs.n8n.io/deploy/host-n8n', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/execute-workflow-each-mode.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/execute-workflow-each-mode.rule.ts index 71e9c2544db..dda280791e1 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/execute-workflow-each-mode.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/execute-workflow-each-mode.rule.ts @@ -24,7 +24,7 @@ export class ExecuteWorkflowEachModeRule implements IBreakingChangeWorkflowRule description: 'The "Run once for each item" mode of the Execute Sub-workflow node is being removed. Use a Loop Over Items node before the node in "Run once with all items" mode instead.', category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'executionsFail', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/execute-workflow-source-modes.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/execute-workflow-source-modes.rule.ts index a3704630f98..c21e9d4d241 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/execute-workflow-source-modes.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/execute-workflow-source-modes.rule.ts @@ -30,7 +30,7 @@ export class ExecuteWorkflowSourceModesRule implements IBreakingChangeWorkflowRu description: 'The "Local File" and "URL" sources of the Execute Sub-workflow node are being removed. Sub-workflows must be loaded from the database or defined in the node parameters.', category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'executionsFail', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/external-secrets-project-roles-default.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/external-secrets-project-roles-default.rule.ts index 685d40508de..ea8a1ac5490 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/external-secrets-project-roles-default.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/external-secrets-project-roles-default.rule.ts @@ -27,7 +27,7 @@ export class ExternalSecretsProjectRolesDefaultRule implements IBreakingChangeIn description: 'In v3, project editors and admins get external-secrets scopes in their projects by default, and the Settings → External Secrets toggle is removed. Instances that keep this toggle off today widen these role permissions after the update.', category: BreakingChangeCategory.environment, - severity: 'medium', + impact: 'behaviorChanges', documentationUrl: 'https://docs.n8n.io/external-secrets/#access-for-project-roles', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/get-paired-item.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/get-paired-item.rule.ts index 63771f22d89..a5448246bb0 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/get-paired-item.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/get-paired-item.rule.ts @@ -24,7 +24,7 @@ export class GetPairedItemRule implements IBreakingChangeWorkflowRule { description: 'The $getPairedItem expression helper is removed. Expressions using it will fail to evaluate.', category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'executionsFail', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/gmail-trigger-version.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/gmail-trigger-version.rule.ts index 94ff53e486b..0a205505242 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/gmail-trigger-version.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/gmail-trigger-version.rule.ts @@ -25,7 +25,7 @@ export class GmailTriggerVersionRule implements IBreakingChangeWorkflowRule { description: 'Gmail Trigger versions below 1.4 are removed and every Gmail Trigger node runs with v1.4 behavior: the number of emails fetched per poll is limited by "Max Emails per Poll" (default 10, the remainder is picked up in later polls), drafts are excluded unless the "Include Drafts" filter is enabled, and sent (non-inbox) and scheduled emails no longer trigger the workflow.', category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'behaviorChanges', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/in-memory-binary-data.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/in-memory-binary-data.rule.ts index 06a9ba4d92d..20ea832a4cf 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/in-memory-binary-data.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/in-memory-binary-data.rule.ts @@ -22,7 +22,7 @@ export class InMemoryBinaryDataRule implements IBreakingChangeInstanceRule { description: 'The in-memory binary data storage mode (`default`) is removed. Instances using it must switch to `filesystem`, `s3`, or `database`.', category: BreakingChangeCategory.infrastructure, - severity: 'medium', + impact: 'behaviorChanges', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/offload-manual-executions.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/offload-manual-executions.rule.ts index e66409e6d1b..f2187f70a5e 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/offload-manual-executions.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/offload-manual-executions.rule.ts @@ -22,7 +22,7 @@ export class OffloadManualExecutionsRule implements IBreakingChangeInstanceRule description: 'In queue mode, manual executions are always routed to workers. The OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS environment variable is removed and running manual executions on the main instance is no longer possible.', category: BreakingChangeCategory.instance, - severity: 'medium', + impact: 'behaviorChanges', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/pre-execute-error-creates-execution.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/pre-execute-error-creates-execution.rule.ts index 09c3e2be904..9520bd655d6 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/pre-execute-error-creates-execution.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/pre-execute-error-creates-execution.rule.ts @@ -22,7 +22,7 @@ export class PreExecuteErrorCreatesExecutionRule implements IBreakingChangeInsta description: 'The N8N_PRE_EXECUTE_ERROR_CREATES_EXECUTION environment variable is removed. A throw from workflow.preExecute never creates an execution record.', category: BreakingChangeCategory.instance, - severity: 'medium', + impact: 'capabilityRemoved', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/removed-nodes-with-replacements.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/removed-nodes-with-replacements.rule.ts index 1214049ff1b..9292f166065 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/removed-nodes-with-replacements.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/removed-nodes-with-replacements.rule.ts @@ -33,7 +33,7 @@ function createNodeReplacementRule({ title: `${removedNodeName} node removed`, description: `The ${removedNodeName} node is no longer supported. ${recommendations.map(({ description }) => description).join(' ')}`, category: BreakingChangeCategory.workflow, - severity: 'medium', + impact: 'executionsFail', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/removed-nodes.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/removed-nodes.rule.ts index 2930c526a66..3d6d94bb631 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/removed-nodes.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/removed-nodes.rule.ts @@ -30,7 +30,7 @@ export class RemovedNodesV3Rule implements IBreakingChangeWorkflowRule { title: 'Removed nodes', description: 'Several nodes have been removed and will no longer work', category: BreakingChangeCategory.workflow, - severity: 'low', + impact: 'executionsFail', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/ssrf-default-blocked-ranges.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/ssrf-default-blocked-ranges.rule.ts index 4be96cb6388..310e529f0c2 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/ssrf-default-blocked-ranges.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/ssrf-default-blocked-ranges.rule.ts @@ -22,7 +22,7 @@ export class SsrfDefaultBlockedRangesRule implements IBreakingChangeInstanceRule description: 'The built-in blocked IP ranges expand to include the shared address space (100.64.0.0/10) and IPv6 transition ranges. Requests to hosts in these ranges start failing on instances with SSRF protection enabled.', category: BreakingChangeCategory.environment, - severity: 'medium', + impact: 'executionsFail', documentationUrl: 'https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/security', }; diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/storage-path-rename.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/storage-path-rename.rule.ts index 28ccb263ae9..d761e276f56 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/storage-path-rename.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/storage-path-rename.rule.ts @@ -23,7 +23,7 @@ export class StoragePathRenameRule implements IBreakingChangeInstanceRule { description: 'The filesystem storage directory `~/.n8n/binaryData` is renamed to `~/.n8n/storage` on the first start of the new version.', category: BreakingChangeCategory.infrastructure, - severity: 'medium', + impact: 'behaviorChanges', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/task-runner-task-timeout.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/task-runner-task-timeout.rule.ts index 66fa6ac3ebb..20ae754014c 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/task-runner-task-timeout.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/task-runner-task-timeout.rule.ts @@ -19,7 +19,7 @@ export class TaskRunnerTaskTimeoutRule implements IBreakingChangeInstanceRule { description: 'The default of N8N_RUNNERS_TASK_TIMEOUT is reduced from 300 seconds (5 minutes) to 60 seconds (1 minute). Code node tasks running longer than the new default will be aborted.', category: BreakingChangeCategory.environment, - severity: 'low', + impact: 'executionsFail', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/unverified-packages.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/unverified-packages.rule.ts index 39361e76cd4..2846e3ae75a 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/unverified-packages.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/unverified-packages.rule.ts @@ -42,7 +42,7 @@ export class UnverifiedPackagesRule implements IBreakingChangeInstanceRule { description: 'The default of N8N_UNVERIFIED_PACKAGES_ENABLED changes to false. Installed community packages that are not verified by n8n will stop loading unless the variable is explicitly set to true.', category: BreakingChangeCategory.environment, - severity: 'medium', + impact: 'behaviorChanges', }; } diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/workflow-import-url-removed.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/workflow-import-url-removed.rule.ts index 78d9e22c5c1..0c765275e8f 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v3/workflow-import-url-removed.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v3/workflow-import-url-removed.rule.ts @@ -17,7 +17,7 @@ export class WorkflowImportUrlRemovedRule implements IBreakingChangeInstanceRule title: 'Workflow import from URL is removed', description: 'The "Import from URL" option in the editor is removed.', category: BreakingChangeCategory.instance, - severity: 'low', + impact: 'capabilityRemoved', }; } diff --git a/packages/cli/src/modules/breaking-changes/types/rule.types.ts b/packages/cli/src/modules/breaking-changes/types/rule.types.ts index 276cf13e7c5..0ca8d4e4243 100644 --- a/packages/cli/src/modules/breaking-changes/types/rule.types.ts +++ b/packages/cli/src/modules/breaking-changes/types/rule.types.ts @@ -1,7 +1,7 @@ import type { BreakingChangeAffectedWorkflow, BreakingChangeRecommendation, - BreakingChangeRuleSeverity, + BreakingChangeRuleImpact, BreakingChangeVersion, } from '@n8n/api-types'; import type { WorkflowEntity } from '@n8n/db'; @@ -26,7 +26,8 @@ export interface BreakingChangeRuleMetadata { title: string; description: string; category: BreakingChangeCategory; - severity: BreakingChangeRuleSeverity; + /** What happens if the user does not fix this breaking change. */ + impact: BreakingChangeRuleImpact; documentationUrl?: string; } diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-agent.service.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-agent.service.test.ts index f27e0e6bd69..09abac33f19 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-agent.service.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-agent.service.test.ts @@ -5,7 +5,7 @@ import type { IRunExecutionData, IWorkflowBase } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { mock } from 'vitest-mock-extended'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service'; import type { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-credentials.service.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-credentials.service.test.ts index a25d1275aa2..7437c1bc729 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-credentials.service.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-credentials.service.test.ts @@ -11,8 +11,7 @@ import { mock } from 'vitest-mock-extended'; import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import type { CredentialsService } from '@/credentials/credentials.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { ChatHubCredentialsService } from '../chat-hub-credentials.service'; diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-execution-store.service.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-execution-store.service.test.ts index 5953e084373..db6b3d8b9f3 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-execution-store.service.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-execution-store.service.test.ts @@ -7,7 +7,7 @@ import { ChatHubExecutionStore, type ChatHubExecutionContext, } from '@/modules/chat-hub/chat-hub-execution-store.service'; -import type { RedisClientService } from '@/services/redis-client.service'; +import type { RedisClientService } from '@n8n/backend-services'; const EXECUTION_ID = '12345678'; const SESSION_ID = 'bbbbbbbb-2222-4000-8000-000000000002'; diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-settings.service.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-settings.service.test.ts index 2151675c6ae..5eb5d60dc40 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-settings.service.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-settings.service.test.ts @@ -3,7 +3,7 @@ import type { Settings, SettingsRepository } from '@n8n/db'; import type { EntityManager } from '@n8n/typeorm'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { ChatHubSettingsService } from '../chat-hub.settings.service'; diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-tool.service.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-tool.service.test.ts index 09996a77200..23e0b88b24a 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-tool.service.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-tool.service.test.ts @@ -5,8 +5,7 @@ import type { INode, INodeType } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import type { NodeTypes } from '@/node-types'; import type { ChatHubTool } from '../chat-hub-tool.entity'; diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-workflow.service.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-workflow.service.test.ts index 738d6ddb903..1adef174d59 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-workflow.service.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-workflow.service.test.ts @@ -130,6 +130,7 @@ describe('ChatHubWorkflowService', () => { expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledWith( expect.objectContaining({ storedWorkflow: null, projectId: 'project-789' }), + { kind: 'user', user: { id: 'user-123' } }, ); expect(workflowRepository.createContent).toHaveBeenCalledWith( expect.anything(), @@ -174,6 +175,7 @@ describe('ChatHubWorkflowService', () => { expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'project-789' }), + { kind: 'user', user: { id: 'user-123' } }, ); expect(workflowRepository.createContent).toHaveBeenCalledWith( expect.anything(), @@ -207,6 +209,7 @@ describe('ChatHubWorkflowService', () => { workflow: expect.objectContaining({ id: 'workflow-1' }), projectId: 'project-1', }), + { kind: 'user', user: expect.objectContaining({ id: 'user-1' }) }, ); expect(workflowRepository.createContent).toHaveBeenCalledWith( expect.anything(), diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.attachment.service.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.attachment.service.test.ts index 4d43605c66d..e12d82d1187 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.attachment.service.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.attachment.service.test.ts @@ -2,7 +2,7 @@ import type { ChatAttachment } from '@n8n/api-types'; import type { BinaryDataService } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { ChatHubAttachmentService } from '../chat-hub.attachment.service'; import type { ChatHubMessageRepository } from '../chat-message.repository'; diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.controller.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.controller.test.ts index 790dd6a0ec6..fd04514a378 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.controller.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.controller.test.ts @@ -3,7 +3,7 @@ import type { ModuleSettings } from '@n8n/decorators'; import type { NextFunction, Request, Response } from 'express'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { sendErrorResponse } from '@/response-helper'; import { ChatHubController } from '../chat-hub.controller'; diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-stream-state.service.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-stream-state.service.test.ts index 044e2252c46..d4778376761 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-stream-state.service.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-stream-state.service.test.ts @@ -4,7 +4,7 @@ import type { InstanceSettings } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; import { ChatStreamStateService } from '@/modules/chat-hub/chat-stream-state.service'; -import type { RedisClientService } from '@/services/redis-client.service'; +import type { RedisClientService } from '@n8n/backend-services'; describe('ChatStreamStateService', () => { const logger = mock(); diff --git a/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts index 0a40e59d2e8..49e8580ed04 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts @@ -14,8 +14,7 @@ import { type IBinaryData } from 'n8n-workflow'; import { nanoid } from 'nanoid'; import { v4 as uuidv4 } from 'uuid'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service'; import { getBase } from '@/workflow-execute-additional-data'; import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; diff --git a/packages/cli/src/modules/chat-hub/chat-hub-credentials.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-credentials.service.ts index 44ced674b80..f5831c2027d 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-credentials.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-credentials.service.ts @@ -10,8 +10,7 @@ import type { INodeCredentials } from 'n8n-workflow'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { CredentialsService } from '@/credentials/credentials.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; @Service() export class ChatHubCredentialsService { diff --git a/packages/cli/src/modules/chat-hub/chat-hub-event-relay.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-event-relay.service.ts index 6256572d71e..dd6a360a395 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-event-relay.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-event-relay.service.ts @@ -1,8 +1,7 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { Service } from '@n8n/di'; -import { EventService } from '@/events/event.service'; - import { ChatHubAgentService } from './chat-hub-agent.service'; @Service() diff --git a/packages/cli/src/modules/chat-hub/chat-hub-execution-store.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-execution-store.service.ts index 66859dceec0..c59af5f9a5a 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-execution-store.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-execution-store.service.ts @@ -7,7 +7,7 @@ import { Service } from '@n8n/di'; import type { Cluster, Redis } from 'ioredis'; import { InstanceSettings } from 'n8n-core'; -import { RedisClientService } from '@/services/redis-client.service'; +import { RedisClientService } from '@n8n/backend-services'; import type { NonStreamingResponseMode } from './chat-hub.types'; diff --git a/packages/cli/src/modules/chat-hub/chat-hub-execution.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-execution.service.ts index 0497a305b01..149a58ff6e5 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-execution.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-execution.service.ts @@ -28,8 +28,7 @@ import { v4 as uuidv4 } from 'uuid'; import { ActiveExecutions } from '@/active-executions'; import { ChatExecutionManager } from '@/chat/chat-execution-manager'; import { ExecutionNotFoundError } from '@/errors/execution-not-found-error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import { BadRequestError, InternalServerError } from '@n8n/errors'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import { ExecutionService } from '@/executions/execution.service'; import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; diff --git a/packages/cli/src/modules/chat-hub/chat-hub-title.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-title.service.ts index aece99602ed..b0fbab8c127 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-title.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-title.service.ts @@ -22,7 +22,7 @@ import { UserError, } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; diff --git a/packages/cli/src/modules/chat-hub/chat-hub-tool.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-tool.service.ts index f5f13417777..aa53bb1f6b6 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-tool.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-tool.service.ts @@ -9,8 +9,7 @@ import { Service } from '@n8n/di'; import type { INode } from 'n8n-workflow'; import { collectExpressionDefaults, findDisallowedChatToolExpressions } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { NodeTypes } from '@/node-types'; import type { ChatHubTool } from './chat-hub-tool.entity'; diff --git a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts index e4abbfbb97d..0e774e22ad3 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts @@ -49,7 +49,8 @@ import { } from 'n8n-workflow'; import { v4 as uuidv4 } from 'uuid'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; +import type { UserLike } from '@/types/user-like.types'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; @@ -161,7 +162,7 @@ export class ChatHubWorkflowService { executionOrder: 'v1', }; - const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId); + const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId, { id: userId }); return await this.workflowRepository.runInTransaction( { ...ctx, policyCleared: cleared }, @@ -189,12 +190,19 @@ export class ChatHubWorkflowService { * Chat workflows are system-generated but policed like any other: the nodes are real, so a * blocked node type has to block the run rather than reach the engine. */ - private async enforceChatWorkflowSave(workflow: WorkflowEntity, projectId: string) { - return await this.policyEnforcementService.enforceWorkflowSave({ - workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes }, - storedWorkflow: null, - projectId, - }); + private async enforceChatWorkflowSave( + workflow: WorkflowEntity, + projectId: string, + user: UserLike, + ) { + return await this.policyEnforcementService.enforceWorkflowSave( + { + workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes }, + storedWorkflow: null, + projectId, + }, + { kind: 'user', user }, + ); } async createTitleGenerationWorkflow( @@ -241,7 +249,7 @@ export class ChatHubWorkflowService { saveDataSuccessExecution: 'all', }; - const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId); + const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId, { id: userId }); return await this.workflowRepository.runInTransaction( { ...ctx, policyCleared: cleared }, @@ -1889,7 +1897,7 @@ You can update the most recent document using the commands described above, or c executionOrder: 'v1', }; - const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId); + const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId, user); return await this.workflowRepository.runInTransaction( { ...ctx, policyCleared: cleared }, diff --git a/packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts index 266cadead83..ea8a22db6cb 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts @@ -7,8 +7,7 @@ import { BinaryDataService, FileLocation, TEMP_EXECUTION_ID } from 'n8n-core'; import { BINARY_ENCODING, type IBinaryData } from 'n8n-workflow'; import type Stream from 'node:stream'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { ChatHubMessageRepository } from './chat-message.repository'; diff --git a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts index 417fafb0d11..d77ed8bf615 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts @@ -43,8 +43,7 @@ import { sanitizeFilename } from '@n8n/utils/files/sanitize-filename'; import type { NextFunction, Request, Response } from 'express'; import multer from 'multer'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { sendErrorResponse } from '@/response-helper'; import { ChatHubAgentService } from './chat-hub-agent.service'; diff --git a/packages/cli/src/modules/chat-hub/chat-hub.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.service.ts index a40280d091c..e3111197e1b 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.service.ts @@ -27,8 +27,7 @@ import { } from 'n8n-workflow'; import { ChatExecutionManager } from '@/chat/chat-execution-manager'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; diff --git a/packages/cli/src/modules/chat-hub/chat-hub.settings.controller.ts b/packages/cli/src/modules/chat-hub/chat-hub.settings.controller.ts index 811aeefa00c..457d10af78b 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.settings.controller.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.settings.controller.ts @@ -9,7 +9,7 @@ import { ModuleRegistry, Logger } from '@n8n/backend-common'; import { type AuthenticatedRequest } from '@n8n/db'; import { Body, Get, Post, Put, RestController, GlobalScope, Param } from '@n8n/decorators'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { ChatHubSettingsService } from './chat-hub.settings.service'; diff --git a/packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts index 4ddfe22d63d..32efe54f1b6 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts @@ -12,7 +12,7 @@ import type { EntityManager } from '@n8n/db'; import { Service } from '@n8n/di'; import { jsonParse } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { VECTOR_STORE_NODE_TYPE_MAP } from './chat-hub.constants'; import type { SemanticSearchOptions } from './chat-hub.types'; diff --git a/packages/cli/src/modules/chat-hub/chat-session.repository.ts b/packages/cli/src/modules/chat-hub/chat-session.repository.ts index 4be6ce91b5e..f007f81594a 100644 --- a/packages/cli/src/modules/chat-hub/chat-session.repository.ts +++ b/packages/cli/src/modules/chat-hub/chat-session.repository.ts @@ -2,7 +2,7 @@ import { BaseRepository, TransactionRunner } from '@n8n/db'; import { Service } from '@n8n/di'; import { DataSource, EntityManager } from '@n8n/typeorm'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { ChatHubSession, IChatHubSession } from './chat-hub-session.entity'; diff --git a/packages/cli/src/modules/chat-hub/chat-stream-state.service.ts b/packages/cli/src/modules/chat-hub/chat-stream-state.service.ts index c1d6111d81e..b362c02f1cf 100644 --- a/packages/cli/src/modules/chat-hub/chat-stream-state.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-stream-state.service.ts @@ -7,7 +7,7 @@ import { Service } from '@n8n/di'; import type { Cluster, Redis } from 'ioredis'; import { InstanceSettings } from 'n8n-core'; -import { RedisClientService } from '@/services/redis-client.service'; +import { RedisClientService } from '@n8n/backend-services'; /** * Stream state for an active chat session diff --git a/packages/cli/src/modules/community-packages/__tests__/community-node-types.service.test.ts b/packages/cli/src/modules/community-packages/__tests__/community-node-types.service.test.ts index 0d13b676737..3b36d59840c 100644 --- a/packages/cli/src/modules/community-packages/__tests__/community-node-types.service.test.ts +++ b/packages/cli/src/modules/community-packages/__tests__/community-node-types.service.test.ts @@ -23,6 +23,7 @@ describe('CommunityNodeTypesService', () => { let configMock: any; let communityPackagesServiceMock: any; let loggerMock: any; + let globalConfigMock: any; beforeEach(() => { vi.clearAllMocks(); @@ -37,11 +38,17 @@ describe('CommunityNodeTypesService', () => { nodesApiVersion: N8N_NODES_API_VERSION, }; communityPackagesServiceMock = {}; + globalConfigMock = { nodes: { exclude: [], include: [] } }; if (mockDateNow.mockRestore) mockDateNow.mockRestore(); if (mockMathRandom.mockRestore) mockMathRandom.mockRestore(); - service = new CommunityNodeTypesService(loggerMock, configMock, communityPackagesServiceMock); + service = new CommunityNodeTypesService( + loggerMock, + configMock, + communityPackagesServiceMock, + globalConfigMock, + ); }); afterEach(() => { @@ -409,6 +416,48 @@ describe('CommunityNodeTypesService', () => { communityPackagesServiceMock.getAllInstalledPackages = vi.fn().mockResolvedValue([]); }); + it('should not create an AI tool version for a node type the loader would skip', async () => { + globalConfigMock.nodes = { exclude: ['n8n-nodes-test.test'], include: [] }; + (getCommunityNodeTypes as Mock).mockResolvedValueOnce([ + { + name: 'n8n-nodes-test.test', + packageName: 'n8n-nodes-test', + nodeDescription: { + name: 'test-node-preview', + displayName: 'Test Node', + inputs: ['main'], + outputs: ['main'], + usableAsTool: true, + }, + }, + ]); + + const result = await service.getCommunityNodeTypes(); + + expect(result).toEqual([]); + }); + + it('should keep the AI tool version when NODES_INCLUDE lists its base node type', async () => { + globalConfigMock.nodes = { exclude: [], include: ['n8n-nodes-test.test'] }; + (getCommunityNodeTypes as Mock).mockResolvedValueOnce([ + { + name: 'n8n-nodes-test.test', + packageName: 'n8n-nodes-test', + nodeDescription: { + name: 'test-node-preview', + displayName: 'Test Node', + inputs: ['main'], + outputs: ['main'], + usableAsTool: true, + }, + }, + ]); + + const result = await service.getCommunityNodeTypes(); + + expect(result.map((n) => n.name)).toEqual(['n8n-nodes-test.test', 'n8n-nodes-test.testTool']); + }); + it('should create AI tool versions for nodes with usableAsTool flag', async () => { const mockNodeTypes = [ { @@ -903,6 +952,17 @@ describe('CommunityNodeTypesService', () => { expect(result[1].isInstalled).toBe(false); }); + it.each([ + [{ exclude: ['package-2.node2'], include: [] }], + [{ exclude: [], include: ['package-1.node1'] }], + ])('should leave out node types the loader would skip with %j', async (nodes) => { + globalConfigMock.nodes = nodes; + + const result = await service.getCommunityNodeTypes(); + + expect(result.map((n) => n.name)).toEqual(['package-1.node1']); + }); + it('should fetch updates when interval has passed', async () => { (service as any).lastUpdateTimestamp = 0; const fetchSpy = vi.spyOn(service as any, 'fetchNodeTypes').mockResolvedValue(undefined); @@ -949,6 +1009,14 @@ describe('CommunityNodeTypesService', () => { expect(result).toBeNull(); }); + it('should return null for a node type the loader would skip', async () => { + globalConfigMock.nodes = { exclude: ['package-1.node1'], include: [] }; + + const result = await service.getCommunityNodeType('package-1.node1'); + + expect(result).toBeNull(); + }); + it('should determine installation status from package name', async () => { const mockNodeTypes = [ { name: 'package-1.node1', packageName: 'package-1', npmVersion: '1.0.0' }, diff --git a/packages/cli/src/modules/community-packages/__tests__/community-packages.controller.test.ts b/packages/cli/src/modules/community-packages/__tests__/community-packages.controller.test.ts index d13ade26e9c..5f74cc680a7 100644 --- a/packages/cli/src/modules/community-packages/__tests__/community-packages.controller.test.ts +++ b/packages/cli/src/modules/community-packages/__tests__/community-packages.controller.test.ts @@ -1,10 +1,10 @@ import type { CommunityNodeType } from '@n8n/api-types'; import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { InstanceSettingsLoaderConfig } from '@n8n/config'; import type { InstanceSettings } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; import type { Push } from '@/push'; import type { NodeRequest } from '@/requests'; diff --git a/packages/cli/src/modules/community-packages/__tests__/community-packages.lifecycle.service.test.ts b/packages/cli/src/modules/community-packages/__tests__/community-packages.lifecycle.service.test.ts index 82b660b54c5..f1fc0a5eac6 100644 --- a/packages/cli/src/modules/community-packages/__tests__/community-packages.lifecycle.service.test.ts +++ b/packages/cli/src/modules/community-packages/__tests__/community-packages.lifecycle.service.test.ts @@ -1,11 +1,11 @@ import type { CommunityNodeType } from '@n8n/api-types'; import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { InstanceSettingsLoaderConfig } from '@n8n/config'; import { mock } from 'vitest-mock-extended'; import { IncompatibleNodesApiVersionError } from '@/errors/response-errors/incompatible-nodes-api-version.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import type { EventService } from '@/events/event.service'; +import { BadRequestError } from '@n8n/errors'; import type { Push } from '@/push'; import type { CommunityNodeTypesService } from '../community-node-types.service'; diff --git a/packages/cli/src/modules/community-packages/community-node-types.service.ts b/packages/cli/src/modules/community-packages/community-node-types.service.ts index dbbc8f34d56..fa3851fd220 100644 --- a/packages/cli/src/modules/community-packages/community-node-types.service.ts +++ b/packages/cli/src/modules/community-packages/community-node-types.service.ts @@ -1,5 +1,6 @@ import type { CommunityNodeType } from '@n8n/api-types'; import { inProduction, Logger } from '@n8n/backend-common'; +import { GlobalConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import cloneDeep from 'lodash/cloneDeep'; import { ensureError } from '@n8n/utils/errors/ensure-error'; @@ -32,8 +33,25 @@ export class CommunityNodeTypesService { private readonly logger: Logger, private config: CommunityPackagesConfig, private communityPackagesService: CommunityPackagesService, + private readonly globalConfig: GlobalConfig, ) {} + /** + * Same rule the node loader applies, so nothing offers a type that would not load. + * The loader builds AI tool copies from loaded base nodes, so a copy follows its base node. + */ + private isLoadable(nodeTypeName: string) { + const baseName = nodeTypeName.slice(0, -'Tool'.length); + const isToolCopy = + nodeTypeName.endsWith('Tool') && + this.communityNodeTypes.get(baseName)?.nodeDescription.usableAsTool === true; + const loaderName = isToolCopy ? baseName : nodeTypeName; + + const { exclude, include } = this.globalConfig.nodes; + if (exclude.includes(loaderName)) return false; + return include.length === 0 || include.includes(loaderName); + } + private async detectUpdates( environment: 'staging' | 'production', ): Promise<{ typesToUpdate?: number[]; scheduleRetry?: boolean }> { @@ -227,16 +245,18 @@ export class CommunityNodeTypesService { const isInstalled = await this.createIsInstalled(); - return Array.from(this.communityNodeTypes.values()).map((nodeType) => ({ - ...nodeType, - isInstalled: isInstalled(nodeType), - })); + return Array.from(this.communityNodeTypes.values()) + .filter((nodeType) => this.isLoadable(nodeType.name)) + .map((nodeType) => ({ + ...nodeType, + isInstalled: isInstalled(nodeType), + })); } async getCommunityNodeType(type: string): Promise { const nodeType = this.communityNodeTypes.get(type); const isInstalled = await this.createIsInstalled(); - if (!nodeType) return null; + if (!nodeType || !this.isLoadable(nodeType.name)) return null; return { ...nodeType, isInstalled: isInstalled(nodeType) }; } diff --git a/packages/cli/src/modules/community-packages/community-packages.lifecycle.service.ts b/packages/cli/src/modules/community-packages/community-packages.lifecycle.service.ts index e24c8dda4d4..68e733a4334 100644 --- a/packages/cli/src/modules/community-packages/community-packages.lifecycle.service.ts +++ b/packages/cli/src/modules/community-packages/community-packages.lifecycle.service.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { InstanceSettingsLoaderConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; @@ -10,11 +11,8 @@ import { STARTER_TEMPLATE_NAME, UNKNOWN_FAILURE_REASON, } from '@/constants'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError, InternalServerError, NotFoundError } from '@n8n/errors'; import { IncompatibleNodesApiVersionError } from '@/errors/response-errors/incompatible-nodes-api-version.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; import type { UserLike } from '@/events/maps/relay.event-map'; import { Push } from '@/push'; diff --git a/packages/cli/src/modules/data-table/__tests__/data-table.service.test.ts b/packages/cli/src/modules/data-table/__tests__/data-table.service.test.ts index 7e1a4fa88f8..6132435fdcb 100644 --- a/packages/cli/src/modules/data-table/__tests__/data-table.service.test.ts +++ b/packages/cli/src/modules/data-table/__tests__/data-table.service.test.ts @@ -1,5 +1,6 @@ import type { RenameDataTableColumnDto } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { mockInstance, testModules } from '@n8n/backend-test-utils'; import { ProjectRelationRepository, ProjectRepository, type User } from '@n8n/db'; import { In } from '@n8n/typeorm'; @@ -19,7 +20,6 @@ import { DataTableColumnNotFoundError } from '../errors/data-table-column-not-fo import { DataTableNotFoundError } from '../errors/data-table-not-found.error'; import { DataTableValidationError } from '../errors/data-table-validation.error'; -import { EventService } from '@/events/event.service'; import { ProjectNotFoundError, ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; diff --git a/packages/cli/src/modules/data-table/__tests__/multer-upload-middleware.test.ts b/packages/cli/src/modules/data-table/__tests__/multer-upload-middleware.test.ts index 0a39ef8181b..91ff72e0601 100644 --- a/packages/cli/src/modules/data-table/__tests__/multer-upload-middleware.test.ts +++ b/packages/cli/src/modules/data-table/__tests__/multer-upload-middleware.test.ts @@ -33,7 +33,7 @@ vi.mock('multer', () => { return { __esModule: true, default: multerMock }; }); -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { DataTableSizeValidator } from '../data-table-size-validator.service'; import type { DataTableRepository } from '../data-table.repository'; diff --git a/packages/cli/src/modules/data-table/data-table-permissions.ts b/packages/cli/src/modules/data-table/data-table-permissions.ts index a190b10ff55..bd0196ff00e 100644 --- a/packages/cli/src/modules/data-table/data-table-permissions.ts +++ b/packages/cli/src/modules/data-table/data-table-permissions.ts @@ -1,7 +1,7 @@ import type { User } from '@n8n/db'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { userHasScopes } from '@/permissions.ee/check-access'; /** diff --git a/packages/cli/src/modules/data-table/data-table-proxy.service.ts b/packages/cli/src/modules/data-table/data-table-proxy.service.ts index 68783256d9a..d9e5f8080fb 100644 --- a/packages/cli/src/modules/data-table/data-table-proxy.service.ts +++ b/packages/cli/src/modules/data-table/data-table-proxy.service.ts @@ -25,7 +25,7 @@ import { NodeOperationError, } from 'n8n-workflow'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { userHasScopes } from '@/permissions.ee/check-access'; import { InstanceWriteAccessService } from '@/services/instance-write-access.service'; import { OwnershipService } from '@/services/ownership.service'; diff --git a/packages/cli/src/modules/data-table/data-table-uploads.controller.ts b/packages/cli/src/modules/data-table/data-table-uploads.controller.ts index 5b5005784e0..ea907056e61 100644 --- a/packages/cli/src/modules/data-table/data-table-uploads.controller.ts +++ b/packages/cli/src/modules/data-table/data-table-uploads.controller.ts @@ -2,7 +2,7 @@ import { Post, RestController } from '@n8n/decorators'; import { Container } from '@n8n/di'; import multer from 'multer'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { CsvParserService } from './csv-parser.service'; import { MulterUploadMiddleware } from './multer-upload-middleware'; diff --git a/packages/cli/src/modules/data-table/data-table.controller.ts b/packages/cli/src/modules/data-table/data-table.controller.ts index 5022fb4bc48..335e89123fc 100644 --- a/packages/cli/src/modules/data-table/data-table.controller.ts +++ b/packages/cli/src/modules/data-table/data-table.controller.ts @@ -29,12 +29,14 @@ import { import { NextFunction, Response } from 'express'; import { DataTableRowReturn } from 'n8n-workflow'; -import { ResponseError } from '@/errors/response-errors/abstract/response.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { + ResponseError, + BadRequestError, + ConflictError, + ForbiddenError, + InternalServerError, + NotFoundError, +} from '@n8n/errors'; import { InstanceWriteAccessService } from '@/services/instance-write-access.service'; import { ProjectService } from '@/services/project.service.ee'; diff --git a/packages/cli/src/modules/data-table/data-table.service.ts b/packages/cli/src/modules/data-table/data-table.service.ts index 613ecb52c72..56caf40832c 100644 --- a/packages/cli/src/modules/data-table/data-table.service.ts +++ b/packages/cli/src/modules/data-table/data-table.service.ts @@ -11,6 +11,7 @@ import type { UpdateDataTableRowDto, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { ProjectRelationRepository, ProjectRepository, type User } from '@n8n/db'; import { Service } from '@n8n/di'; import { hasGlobalScope, type Scope } from '@n8n/permissions'; @@ -46,7 +47,6 @@ import { DataTableNotFoundError } from './errors/data-table-not-found.error'; import { DataTableValidationError } from './errors/data-table-validation.error'; import { normalizeRows } from './utils/sql-utils'; -import { EventService } from '@/events/event.service'; import { ProjectNotFoundError, ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; diff --git a/packages/cli/src/modules/data-table/errors/data-table-column-not-found.error.ts b/packages/cli/src/modules/data-table/errors/data-table-column-not-found.error.ts index 8c59716dfc6..2ef9d29deca 100644 --- a/packages/cli/src/modules/data-table/errors/data-table-column-not-found.error.ts +++ b/packages/cli/src/modules/data-table/errors/data-table-column-not-found.error.ts @@ -1,4 +1,4 @@ -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; export class DataTableColumnNotFoundError extends NotFoundError { constructor(dataTableId: string, columnId: string) { diff --git a/packages/cli/src/modules/data-table/errors/data-table-not-found.error.ts b/packages/cli/src/modules/data-table/errors/data-table-not-found.error.ts index 83589c09573..f9e08338f5b 100644 --- a/packages/cli/src/modules/data-table/errors/data-table-not-found.error.ts +++ b/packages/cli/src/modules/data-table/errors/data-table-not-found.error.ts @@ -1,4 +1,4 @@ -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; export class DataTableNotFoundError extends NotFoundError { constructor(dataTableId: string) { diff --git a/packages/cli/src/modules/data-table/middleware/branch-write-access-middleware.ts b/packages/cli/src/modules/data-table/middleware/branch-write-access-middleware.ts index e83c364cd40..08eef3c6113 100644 --- a/packages/cli/src/modules/data-table/middleware/branch-write-access-middleware.ts +++ b/packages/cli/src/modules/data-table/middleware/branch-write-access-middleware.ts @@ -1,7 +1,7 @@ import { Container } from '@n8n/di'; import type { RequestHandler } from 'express'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { InstanceWriteAccessService } from '@/services/instance-write-access.service'; /** diff --git a/packages/cli/src/modules/data-table/multer-upload-middleware.ts b/packages/cli/src/modules/data-table/multer-upload-middleware.ts index 758da5e581d..55b8dc06c0c 100644 --- a/packages/cli/src/modules/data-table/multer-upload-middleware.ts +++ b/packages/cli/src/modules/data-table/multer-upload-middleware.ts @@ -9,7 +9,7 @@ import multer from 'multer'; import { nanoid } from 'nanoid'; import { extname } from 'path'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { DataTableSizeValidator } from './data-table-size-validator.service'; import { DataTableRepository } from './data-table.repository'; diff --git a/packages/cli/src/modules/data-table/utils/sql-utils.ts b/packages/cli/src/modules/data-table/utils/sql-utils.ts index 176156daa31..20dffcdc639 100644 --- a/packages/cli/src/modules/data-table/utils/sql-utils.ts +++ b/packages/cli/src/modules/data-table/utils/sql-utils.ts @@ -17,7 +17,7 @@ import type { } from 'n8n-workflow'; import { DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP, UnexpectedError } from 'n8n-workflow'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { DataTableColumn } from '../data-table-column.entity'; import type { DataTableUserTableName } from '../data-table.types'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/__tests__/credential-resolvers.controller.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/__tests__/credential-resolvers.controller.test.ts index 4f89e42fafb..6e857f46bc6 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/__tests__/credential-resolvers.controller.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/__tests__/credential-resolvers.controller.test.ts @@ -4,9 +4,7 @@ import type { Response } from 'express'; import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, InternalServerError, NotFoundError } from '@n8n/errors'; import { SYSTEM_RESOLVER_ID } from '../constants'; import { IdentifierValidationError } from '../credential-resolvers/identifiers/identifier-interface'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials.controller.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials.controller.test.ts index d8c17d4b305..8b333f60f08 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials.controller.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials.controller.test.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService, UrlService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { type AuthenticatedRequest, type CredentialsEntity } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -8,7 +9,6 @@ import { mock } from 'vitest-mock-extended'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; -import { EventService } from '@/events/event.service'; import type { DynamicCredentialResolver } from '@/modules/dynamic-credentials.ee/database/entities/credential-resolver'; import { DynamicCredentialResolverRepository } from '@/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository'; import { DynamicCredentialsController } from '@/modules/dynamic-credentials.ee/dynamic-credentials.controller'; @@ -19,7 +19,6 @@ import { DynamicCredentialService, } from '@/modules/dynamic-credentials.ee/services'; import { OauthService } from '@/oauth/oauth.service'; -import { UrlService } from '@n8n/backend-services'; import { DynamicCredentialWebService } from '../services/dynamic-credential-web.service'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/__tests__/workflow-status.controller.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/__tests__/workflow-status.controller.test.ts index 419cc8c01ef..0c7df837f2a 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/__tests__/workflow-status.controller.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/__tests__/workflow-status.controller.test.ts @@ -2,8 +2,7 @@ import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; import type { WorkflowEntity } from '@n8n/db'; import type { Request, Response } from 'express'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import type { DynamicCredentialCorsService } from '../services/dynamic-credential-cors.service'; import type { DynamicCredentialWebService } from '../services/dynamic-credential-web.service'; import { WorkflowStatusController } from '../workflow-status.controller'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers.controller.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers.controller.ts index 8879734e900..4d05d2a47c8 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers.controller.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers.controller.ts @@ -25,9 +25,7 @@ import { } from '@n8n/decorators'; import { Response } from 'express'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, InternalServerError, NotFoundError } from '@n8n/errors'; import { collectSecretFieldNames, redactSecretConfig } from './config-redaction'; import { CredentialResolutionError } from './errors/credential-resolution.error'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/n8n-identifier.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/n8n-identifier.test.ts index b9e50c91658..a36cc09ba30 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/n8n-identifier.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/n8n-identifier.test.ts @@ -5,7 +5,7 @@ import { CredentialResolverError } from '@n8n/decorators'; import { mock } from 'vitest-mock-extended'; import type { AuthService } from '@/auth/auth.service'; -import { AuthError } from '@/errors/response-errors/auth.error'; +import { AuthError } from '@n8n/errors'; import type { OAuthTokenVerifierProxy } from '@/services/oauth-token-verifier-proxy.service'; import { diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.integration.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.integration.test.ts index 403b767f881..a36e9ecb6a0 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.integration.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.integration.test.ts @@ -10,7 +10,7 @@ import { SsrfProtectionConfig } from '@n8n/config'; import { mock } from 'vitest-mock-extended'; import type { IncomingHttpHeaders } from 'node:http'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { IdentifierValidationError } from '../identifier-interface'; import { OAuth2TokenIntrospectionIdentifier } from '../oauth2-introspection-identifier'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.test.ts index f60b15e172a..8e9c6bb2e41 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.test.ts @@ -4,7 +4,7 @@ import { Time } from '@n8n/constants'; import { mock } from 'vitest-mock-extended'; import type { IHttpRequestOptions } from 'n8n-workflow'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { IdentifierValidationError } from '../identifier-interface'; import { OAuth2TokenIntrospectionIdentifier } from '../oauth2-introspection-identifier'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-metadata-http-client.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-metadata-http-client.test.ts index ceadc3ee3ea..689214e0b20 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-metadata-http-client.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-metadata-http-client.test.ts @@ -3,7 +3,7 @@ import { mockLogger } from '@n8n/backend-test-utils'; import { mock } from 'vitest-mock-extended'; import { z } from 'zod'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { IdentifierValidationError } from '../identifier-interface'; import { OAuth2MetadataHttpClient } from '../oauth2-metadata-http-client'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-userinfo-identifier.integration.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-userinfo-identifier.integration.test.ts index 27283fdb476..8bfaaf03073 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-userinfo-identifier.integration.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-userinfo-identifier.integration.test.ts @@ -12,7 +12,7 @@ import type { JSONWebKeySet, JWTPayload, KeyObject } from 'jose'; import { mock } from 'vitest-mock-extended'; import type { IncomingHttpHeaders } from 'node:http'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { IdentifierValidationError } from '../identifier-interface'; import { OAuth2MetadataHttpClient } from '../oauth2-metadata-http-client'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-userinfo-identifier.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-userinfo-identifier.test.ts index d8c0587d964..5cb721b9bdc 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-userinfo-identifier.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-userinfo-identifier.test.ts @@ -6,7 +6,7 @@ import type { JSONWebKeySet, JWTPayload, KeyObject } from 'jose'; import type { IHttpRequestOptions } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { IdentifierValidationError } from '../identifier-interface'; import { OAuth2MetadataHttpClient } from '../oauth2-metadata-http-client'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-introspection-identifier.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-introspection-identifier.ts index ed67cbc7ff8..2664a0a22a2 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-introspection-identifier.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-introspection-identifier.ts @@ -8,7 +8,7 @@ import { IdentifierValidationError, ITokenIdentifier } from './identifier-interf import { OAuth2MetadataHttpClient } from './oauth2-metadata-http-client'; import { assertAudience, OAuth2OptionsSchema, sha256 } from './oauth2-utils'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; // Cap at 5 minutes to ensure periodic revalidation const MAX_TOKEN_CACHE_TIMEOUT = 5 * Time.minutes.toMilliseconds; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-metadata-http-client.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-metadata-http-client.ts index 26e6f86e9cf..227e679e1af 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-metadata-http-client.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-metadata-http-client.ts @@ -7,7 +7,7 @@ import type { z } from 'zod'; import { IdentifierValidationError } from './identifier-interface'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; const REQUEST_TIMEOUT = 10 * Time.seconds.toMilliseconds; const METADATA_CACHE_TIMEOUT = 1 * Time.hours.toMilliseconds; // 1 hour diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-userinfo-identifier.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-userinfo-identifier.ts index 221b4ab7312..8ae2ff57e50 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-userinfo-identifier.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-userinfo-identifier.ts @@ -5,7 +5,7 @@ import type { JSONWebKeySet, JWTPayload } from 'jose'; import type { ICredentialContext } from 'n8n-workflow'; import { z } from 'zod'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { IdentifierValidationError, ITokenIdentifier } from './identifier-interface'; import { OAuth2MetadataHttpClient } from './oauth2-metadata-http-client'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.controller.ts b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.controller.ts index 050c4ab044d..a53381631c5 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.controller.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.controller.ts @@ -1,3 +1,4 @@ +import { EventService, UrlService } from '@n8n/backend-services'; import { Time } from '@n8n/constants'; import { CredentialsEntity, AuthenticatedRequest, isAuthenticatedRequest } from '@n8n/db'; import { Delete, Get, Options, Param, Post, RestController } from '@n8n/decorators'; @@ -9,11 +10,8 @@ import { type ICredentialContext, jsonParse } from 'n8n-workflow'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { CreateCsrfStateData, OauthService } from '@/oauth/oauth.service'; -import { UrlService } from '@n8n/backend-services'; import { carriesN8nIdentity } from './credential-resolvers/identifiers/n8n-identifier'; import { DynamicCredentialResolverRepository } from './database/repositories/credential-resolver.repository'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/authorize-intent.service.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/authorize-intent.service.test.ts index 383e14afc9b..71c5f731818 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/authorize-intent.service.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/authorize-intent.service.test.ts @@ -1,6 +1,6 @@ import { mock } from 'vitest-mock-extended'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { AuthorizeIntentService, type AuthorizeIntent } from '../authorize-intent.service'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/authorize-intent.service.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/authorize-intent.service.ts index e47d26d3d02..b2d99450805 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/services/authorize-intent.service.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/authorize-intent.service.ts @@ -2,7 +2,7 @@ import { Service } from '@n8n/di'; import { randomBytes } from 'node:crypto'; import { MAX_CSRF_AGE } from '@/oauth/types'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; /** * A pending dynamic-credential authorization, captured at credential-gate time and diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver-workflow.service.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver-workflow.service.ts index 8cc66b031d1..4aacd070bf0 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver-workflow.service.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver-workflow.service.ts @@ -5,8 +5,7 @@ import { Cipher } from 'n8n-core'; import { ICredentialContext, INode, isNodeWithWorkflowSelector, jsonParse } from 'n8n-workflow'; import { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { DynamicCredentialResolverRegistry } from './credential-resolver-registry.service'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential-web.service.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential-web.service.ts index 83c4bc5a3c6..2fa969af325 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential-web.service.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential-web.service.ts @@ -6,7 +6,7 @@ import { ICredentialContext } from 'n8n-workflow'; import { z } from 'zod'; import { AuthService } from '@/auth/auth.service'; -import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; +import { UnauthenticatedError } from '@n8n/errors'; class AuthSourceQuerySchema extends Z.class({ authSource: z.enum(['bearer', 'cookie']).optional(), diff --git a/packages/cli/src/modules/dynamic-credentials.ee/workflow-status.controller.ts b/packages/cli/src/modules/dynamic-credentials.ee/workflow-status.controller.ts index 417b598a9da..9a660e1a85b 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/workflow-status.controller.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/workflow-status.controller.ts @@ -6,8 +6,7 @@ import { Get, Options, RestController } from '@n8n/decorators'; import { Container } from '@n8n/di'; import { Request, Response } from 'express'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { UrlService } from '@n8n/backend-services'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; diff --git a/packages/cli/src/modules/encryption-key-manager/__tests__/encryption-key.controller.test.ts b/packages/cli/src/modules/encryption-key-manager/__tests__/encryption-key.controller.test.ts index 8bcdf5ec3f6..e319c477242 100644 --- a/packages/cli/src/modules/encryption-key-manager/__tests__/encryption-key.controller.test.ts +++ b/packages/cli/src/modules/encryption-key-manager/__tests__/encryption-key.controller.test.ts @@ -2,7 +2,7 @@ import { mockInstance } from '@n8n/backend-test-utils'; import type { DeploymentKey } from '@n8n/db'; import { Container } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { EncryptionKeyController } from '@/modules/encryption-key-manager/encryption-key.controller'; import { KeyManagerService } from '@/encryption/key-manager.service'; diff --git a/packages/cli/src/modules/encryption-key-manager/encryption-key.controller.ts b/packages/cli/src/modules/encryption-key-manager/encryption-key.controller.ts index 797d947210a..4a921248a3e 100644 --- a/packages/cli/src/modules/encryption-key-manager/encryption-key.controller.ts +++ b/packages/cli/src/modules/encryption-key-manager/encryption-key.controller.ts @@ -7,7 +7,7 @@ import { import { type DeploymentKey } from '@n8n/db'; import { Body, Get, GlobalScope, Post, Query, RestController } from '@n8n/decorators'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { KeyManagerService } from '@/encryption/key-manager.service'; diff --git a/packages/cli/src/modules/engine-v2/__tests__/buffer-webhook-response.test.ts b/packages/cli/src/modules/engine-v2/__tests__/buffer-webhook-response.test.ts index 5006b82e4a4..9c87def4636 100644 --- a/packages/cli/src/modules/engine-v2/__tests__/buffer-webhook-response.test.ts +++ b/packages/cli/src/modules/engine-v2/__tests__/buffer-webhook-response.test.ts @@ -1,7 +1,11 @@ import type { Logger } from '@n8n/backend-common'; import { mockLogger } from '@n8n/backend-test-utils'; import type { EngineConfig } from '@n8n/config'; -import type { StepExecutionRequest } from '@n8n/engine'; +import { + createResponseEmitter, + type ResponseExpectation, + type StepExecutionRequest, +} from '@n8n/engine'; import { attachResponseHooks } from '@n8n/node-engine-compatibility'; import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; @@ -22,7 +26,7 @@ describe('a Buffer webhook response through the response channel', () => { const bytes = Buffer.from([0x00, 0xff, 0x10, 0x80]); const headers = { 'content-type': 'image/png', 'content-length': bytes.length }; - const buildPath = async () => { + const buildPath = async (expectation: ResponseExpectation = { kind: 'stepResponse' }) => { const channel = new InMemoryExecutionResponseChannel(); const sender = new InMemoryExecutionResponseSender(channel, mockLogger()); const receiver = new InMemoryExecutionResponseReceiver(channel, mockLogger()); @@ -34,7 +38,7 @@ describe('a Buffer webhook response through the response channel', () => { responder.useReceiver(receiver); const executionId = createExecutionIdV2(); - const pending = await responder.waitForResponse(executionId, true); + const pending = await responder.waitForResponse(executionId, expectation); const additionalData = {} as IWorkflowExecuteAdditionalData; attachResponseHooks(additionalData, { @@ -46,10 +50,10 @@ describe('a Buffer webhook response through the response channel', () => { iteration: 0, callerContext: { hostMode: 'webhook' }, }, - respond: sender.emitterFor(executionId), + respond: createResponseEmitter(sender, { id: executionId, responseExpectation: expectation }), } as unknown as StepExecutionRequest); - return { additionalData, pending }; + return { additionalData, pending, publish: vi.spyOn(channel, 'publish') }; }; it('delivers the original bytes, headers and status code to the control plane', async () => { @@ -80,4 +84,18 @@ describe('a Buffer webhook response through the response channel', () => { response: { body: { ok: true }, headers: {}, statusCode: 200 }, }); }); + + it.each(['none', 'runEnd'] as const)( + 'sends nothing and does not fail the node when the caller expects %s', + async (kind) => { + const { additionalData, publish } = await buildPath({ kind }); + + await expect( + additionalData.hooks?.runHook('sendResponse', [ + { body: Buffer.from(bytes), headers, statusCode: 201 }, + ]), + ).resolves.toBeUndefined(); + expect(publish).not.toHaveBeenCalled(); + }, + ); }); diff --git a/packages/cli/src/modules/engine-v2/__tests__/engine-additional-data.test.ts b/packages/cli/src/modules/engine-v2/__tests__/engine-additional-data.test.ts index 90c35d890c4..be31f0f27c2 100644 --- a/packages/cli/src/modules/engine-v2/__tests__/engine-additional-data.test.ts +++ b/packages/cli/src/modules/engine-v2/__tests__/engine-additional-data.test.ts @@ -1,12 +1,10 @@ import type { SsrfProtectionService } from '@n8n/backend-network'; +import type { EventService, UrlService } from '@n8n/backend-services'; import type { GlobalConfig, SsrfProtectionConfig } from '@n8n/config'; import { UnimplementedError } from '@n8n/engine'; import type { ICredentialsHelper } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; -import type { UrlService } from '@n8n/backend-services'; - import { EngineAdditionalDataBuilder } from '../engine-additional-data'; describe('EngineAdditionalDataBuilder', () => { diff --git a/packages/cli/src/modules/engine-v2/__tests__/engine-credentials.controller.test.ts b/packages/cli/src/modules/engine-v2/__tests__/engine-credentials.controller.test.ts index 9d511f15e11..6e0e9c5599f 100644 --- a/packages/cli/src/modules/engine-v2/__tests__/engine-credentials.controller.test.ts +++ b/packages/cli/src/modules/engine-v2/__tests__/engine-credentials.controller.test.ts @@ -2,8 +2,7 @@ import type { Request, Response } from 'express'; import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import type { ResolveCredentialRequest } from '../engine-credentials.contract'; import { EngineCredentialsController } from '../engine-credentials.controller'; diff --git a/packages/cli/src/modules/engine-v2/__tests__/engine-credentials.service.test.ts b/packages/cli/src/modules/engine-v2/__tests__/engine-credentials.service.test.ts index 695ea28620e..273aac57f9b 100644 --- a/packages/cli/src/modules/engine-v2/__tests__/engine-credentials.service.test.ts +++ b/packages/cli/src/modules/engine-v2/__tests__/engine-credentials.service.test.ts @@ -4,8 +4,7 @@ import { mock } from 'vitest-mock-extended'; import type { CredentialsHelper } from '@/credentials-helper'; import { CredentialNotFoundError } from '@/errors/credential-not-found.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import type { CredentialsPermissionChecker } from '@/executions/pre-execution-checks'; import type { ResolveCredentialRequest } from '../engine-credentials.contract'; diff --git a/packages/cli/src/modules/engine-v2/__tests__/engine-lifecycle-event.controller.test.ts b/packages/cli/src/modules/engine-v2/__tests__/engine-lifecycle-event.controller.test.ts index 750717366c7..69e51d12885 100644 --- a/packages/cli/src/modules/engine-v2/__tests__/engine-lifecycle-event.controller.test.ts +++ b/packages/cli/src/modules/engine-v2/__tests__/engine-lifecycle-event.controller.test.ts @@ -3,7 +3,7 @@ import type { Request, Response } from 'express'; import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { EngineLifecycleEventPushRelay } from '../engine-lifecycle-event-push-relay'; import { EngineLifecycleEventController } from '../engine-lifecycle-event.controller'; diff --git a/packages/cli/src/modules/engine-v2/__tests__/engine-v2.module.test.ts b/packages/cli/src/modules/engine-v2/__tests__/engine-v2.module.test.ts index cd2cff85387..26213c0a2a1 100644 --- a/packages/cli/src/modules/engine-v2/__tests__/engine-v2.module.test.ts +++ b/packages/cli/src/modules/engine-v2/__tests__/engine-v2.module.test.ts @@ -5,7 +5,7 @@ import { mock } from 'vitest-mock-extended'; import { EngineDataPlaneProxyService } from '@/services/engine-data-plane-proxy.service'; import { EngineV2WebhookResponder } from '@/services/engine-v2-webhook-responder.service'; -import { RedisClientService } from '@/services/redis-client.service'; +import { RedisClientService } from '@n8n/backend-services'; import { EngineControlPlaneServer } from '../engine-control-plane-server'; import { EngineDataPlaneClient } from '../engine-data-plane-client'; diff --git a/packages/cli/src/modules/engine-v2/__tests__/in-memory-execution-response.test.ts b/packages/cli/src/modules/engine-v2/__tests__/in-memory-execution-response.test.ts index af60abb5da8..7bcaa85986f 100644 --- a/packages/cli/src/modules/engine-v2/__tests__/in-memory-execution-response.test.ts +++ b/packages/cli/src/modules/engine-v2/__tests__/in-memory-execution-response.test.ts @@ -70,19 +70,6 @@ describe('in-memory execution responses', () => { ); }); - it('stamps the execution ID on a response from a step', () => { - const channel = new InMemoryExecutionResponseChannel(); - const publish = vi.spyOn(channel, 'publish'); - const sender = new InMemoryExecutionResponseSender(channel, mockLogger()); - - sender.emitterFor('exec-1').send({ ok: true }); - - expect(publish).toHaveBeenCalledExactlyOnceWith( - 'exec-1', - JSON.stringify({ type: 'response', executionId: 'exec-1', payload: { ok: true } }), - ); - }); - it('delivers a base64 Buffer envelope unchanged', async () => { const channel = new InMemoryExecutionResponseChannel(); const sender = new InMemoryExecutionResponseSender(channel, mockLogger()); @@ -95,7 +82,7 @@ describe('in-memory execution responses', () => { statusCode: 200, }; - sender.emitterFor('exec-1').send(payload); + sender.send({ type: 'response', executionId: 'exec-1', payload }); expect(seen).toEqual([{ type: 'response', executionId: 'exec-1', payload }]); }); diff --git a/packages/cli/src/modules/engine-v2/engine-additional-data.ts b/packages/cli/src/modules/engine-v2/engine-additional-data.ts index b33d4bd613c..62ff820a6bb 100644 --- a/packages/cli/src/modules/engine-v2/engine-additional-data.ts +++ b/packages/cli/src/modules/engine-v2/engine-additional-data.ts @@ -1,4 +1,5 @@ import { SsrfProtectionService } from '@n8n/backend-network'; +import { EventService, UrlService } from '@n8n/backend-services'; import { GlobalConfig, SsrfProtectionConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import { UnimplementedError } from '@n8n/engine'; @@ -6,9 +7,6 @@ import type { AdditionalDataContext } from '@n8n/node-engine-compatibility'; import { ExternalSecretsProxy } from 'n8n-core'; import type { ICredentialsHelper, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; -import { UrlService } from '@n8n/backend-services'; - /** A capability the data plane does not have yet. The step that reaches it fails and says why. */ function unimplemented(feature: string) { return async (): Promise => { diff --git a/packages/cli/src/modules/engine-v2/engine-control-plane-auth.middleware.ts b/packages/cli/src/modules/engine-v2/engine-control-plane-auth.middleware.ts index b73933447a4..e0d171abcc2 100644 --- a/packages/cli/src/modules/engine-v2/engine-control-plane-auth.middleware.ts +++ b/packages/cli/src/modules/engine-v2/engine-control-plane-auth.middleware.ts @@ -4,7 +4,7 @@ import type { ActionScope } from '@n8n/engine'; import { InvalidActionTokenError, verifyActionToken } from '@n8n/engine'; import type { NextFunction, Request, Response } from 'express'; -import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; +import { UnauthenticatedError } from '@n8n/errors'; const BEARER_PREFIX = /^bearer /i; diff --git a/packages/cli/src/modules/engine-v2/engine-credentials.controller.ts b/packages/cli/src/modules/engine-v2/engine-credentials.controller.ts index 3f05835ddb1..86b81893f19 100644 --- a/packages/cli/src/modules/engine-v2/engine-credentials.controller.ts +++ b/packages/cli/src/modules/engine-v2/engine-credentials.controller.ts @@ -1,7 +1,7 @@ import { Service } from '@n8n/di'; import type { Request, Response } from 'express'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { ResolveCredentialResponse } from './engine-credentials.contract'; import { resolveCredentialRequestSchema } from './engine-credentials.contract'; diff --git a/packages/cli/src/modules/engine-v2/engine-credentials.service.ts b/packages/cli/src/modules/engine-v2/engine-credentials.service.ts index 79fdfb9bb65..31cdaef8b3e 100644 --- a/packages/cli/src/modules/engine-v2/engine-credentials.service.ts +++ b/packages/cli/src/modules/engine-v2/engine-credentials.service.ts @@ -4,8 +4,7 @@ import type { ICredentialDataDecryptedObject, IExecuteData } from 'n8n-workflow' import { CredentialsHelper } from '@/credentials-helper'; import { CredentialNotFoundError } from '@/errors/credential-not-found.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import { CredentialsPermissionChecker } from '@/executions/pre-execution-checks'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; diff --git a/packages/cli/src/modules/engine-v2/engine-lifecycle-event.controller.ts b/packages/cli/src/modules/engine-v2/engine-lifecycle-event.controller.ts index e62c52ea073..83fa93c5852 100644 --- a/packages/cli/src/modules/engine-v2/engine-lifecycle-event.controller.ts +++ b/packages/cli/src/modules/engine-v2/engine-lifecycle-event.controller.ts @@ -2,7 +2,7 @@ import { Service } from '@n8n/di'; import { lifecycleEventBatchSchema } from '@n8n/engine'; import type { Request, Response } from 'express'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { EngineLifecycleEventPushRelay } from './engine-lifecycle-event-push-relay'; diff --git a/packages/cli/src/modules/engine-v2/response-channel/in-memory-execution-response-sender.ts b/packages/cli/src/modules/engine-v2/response-channel/in-memory-execution-response-sender.ts index 5263ed4e192..5260c16e9a4 100644 --- a/packages/cli/src/modules/engine-v2/response-channel/in-memory-execution-response-sender.ts +++ b/packages/cli/src/modules/engine-v2/response-channel/in-memory-execution-response-sender.ts @@ -1,10 +1,5 @@ import type { Logger } from '@n8n/backend-common'; -import type { - ExecutionResponse, - ExecutionResponseSender, - JsonValue, - ResponseEmitter, -} from '@n8n/engine'; +import type { ExecutionResponse, ExecutionResponseSender } from '@n8n/engine'; import { createResultError, createResultOk, type Result } from '@n8n/utils/result'; import { UnexpectedError } from 'n8n-workflow'; @@ -31,13 +26,6 @@ export class InMemoryExecutionResponseSender implements ExecutionResponseSender return frameResult.ok ? createResultOk(undefined) : createResultError(frameResult.error); } - /** Gives one step a response sender without exposing execution routing. */ - emitterFor(executionId: string): ResponseEmitter { - return { - send: (payload: JsonValue) => this.send({ type: 'response', executionId, payload }), - }; - } - async stop(): Promise { this.stopped = true; } diff --git a/packages/cli/src/modules/engine-v2/response-channel/redis-execution-response-channel.ts b/packages/cli/src/modules/engine-v2/response-channel/redis-execution-response-channel.ts index 4c2d41b3c0b..7b09bb4fb4f 100644 --- a/packages/cli/src/modules/engine-v2/response-channel/redis-execution-response-channel.ts +++ b/packages/cli/src/modules/engine-v2/response-channel/redis-execution-response-channel.ts @@ -6,7 +6,7 @@ export type RedisExecutionResponseChannelNameGenerator = (executionId: string) = /** Both planes must derive the same channel name, so they share this helper. */ async function createChannelNameGenerator(): Promise { - const { RedisClientService } = await import('@/services/redis-client.service.js'); + const { RedisClientService } = await import('@n8n/backend-services'); const prefix = Container.get(RedisClientService).toValidPrefix( Container.get(GlobalConfig).redis.prefix, ); @@ -17,7 +17,7 @@ async function createChannelNameGenerator(): Promise this.send({ type: 'response', executionId, payload }), - }; - } - async stop(): Promise { if (this.stopped) return; diff --git a/packages/cli/src/modules/external-secrets.ee/__tests__/secret-provider-access-check.service.ee.test.ts b/packages/cli/src/modules/external-secrets.ee/__tests__/secret-provider-access-check.service.ee.test.ts index 8419fde0b06..67086e15539 100644 --- a/packages/cli/src/modules/external-secrets.ee/__tests__/secret-provider-access-check.service.ee.test.ts +++ b/packages/cli/src/modules/external-secrets.ee/__tests__/secret-provider-access-check.service.ee.test.ts @@ -6,8 +6,7 @@ import type { import type { Scope } from '@n8n/permissions'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import type { ProjectService } from '@/services/project.service.ee'; import type { RoleService } from '@/services/role.service'; diff --git a/packages/cli/src/modules/external-secrets.ee/__tests__/secrets-providers-connections.controller.ee.test.ts b/packages/cli/src/modules/external-secrets.ee/__tests__/secrets-providers-connections.controller.ee.test.ts index 965e59dea30..47e269b45dd 100644 --- a/packages/cli/src/modules/external-secrets.ee/__tests__/secrets-providers-connections.controller.ee.test.ts +++ b/packages/cli/src/modules/external-secrets.ee/__tests__/secrets-providers-connections.controller.ee.test.ts @@ -2,7 +2,7 @@ import type { Logger } from '@n8n/backend-common'; import type { NextFunction, Request, Response } from 'express'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import * as responseHelper from '@/response-helper'; import { ExternalSecretsConfig } from '../external-secrets.config'; diff --git a/packages/cli/src/modules/external-secrets.ee/__tests__/secrets-providers-connections.service.ee.test.ts b/packages/cli/src/modules/external-secrets.ee/__tests__/secrets-providers-connections.service.ee.test.ts index 45d1f1749b0..38935b2b579 100644 --- a/packages/cli/src/modules/external-secrets.ee/__tests__/secrets-providers-connections.service.ee.test.ts +++ b/packages/cli/src/modules/external-secrets.ee/__tests__/secrets-providers-connections.service.ee.test.ts @@ -1,3 +1,4 @@ +import type { EventService } from '@n8n/backend-services'; import { mockLogger } from '@n8n/backend-test-utils'; import type { ProjectSecretsProviderAccess, @@ -10,8 +11,7 @@ import { CREDENTIAL_BLANKING_VALUE, type IDataObject, type INodeProperties } fro import { mock } from 'vitest-mock-extended'; import type { CredentialDependencyService } from '@/credentials/credential-dependency.service'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { EventService } from '@/events/event.service'; +import { NotFoundError } from '@n8n/errors'; import type { ExternalSecretsManager } from '@/modules/external-secrets.ee/external-secrets-manager.ee'; import type { ExternalSecretsProviderRegistry } from '@/modules/external-secrets.ee/provider-registry.service'; import type { RedactionService } from '@/modules/external-secrets.ee/redaction.service.ee'; diff --git a/packages/cli/src/modules/external-secrets.ee/external-secrets-manager.ee.ts b/packages/cli/src/modules/external-secrets.ee/external-secrets-manager.ee.ts index 1513896fae3..13bd8389833 100644 --- a/packages/cli/src/modules/external-secrets.ee/external-secrets-manager.ee.ts +++ b/packages/cli/src/modules/external-secrets.ee/external-secrets-manager.ee.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { Time } from '@n8n/constants'; import { SecretsProviderConnectionRepository } from '@n8n/db'; import { OnPubSubEvent } from '@n8n/decorators'; @@ -6,8 +7,7 @@ import { Service } from '@n8n/di'; import { Cipher, type IExternalSecretsManager } from 'n8n-core'; import { jsonParse, UnexpectedError, type IDataObject, type INodeProperties } from 'n8n-workflow'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { NotFoundError } from '@n8n/errors'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { diff --git a/packages/cli/src/modules/external-secrets.ee/external-secrets-settings.controller.ee.ts b/packages/cli/src/modules/external-secrets.ee/external-secrets-settings.controller.ee.ts index 38c700de0fe..7b403757d85 100644 --- a/packages/cli/src/modules/external-secrets.ee/external-secrets-settings.controller.ee.ts +++ b/packages/cli/src/modules/external-secrets.ee/external-secrets-settings.controller.ee.ts @@ -1,11 +1,11 @@ import { UpdateExternalSecretsSettingsDto } from '@n8n/api-types'; import { ModuleRegistry, Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import type { AuthenticatedRequest } from '@n8n/db'; import { Body, GlobalScope, Middleware, Post, RestController } from '@n8n/decorators'; import type { NextFunction, Request, Response } from 'express'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { ForbiddenError } from '@n8n/errors'; import { sendErrorResponse } from '@/response-helper'; import { ExternalSecretsSettingsService } from './external-secrets-settings.service.ee'; diff --git a/packages/cli/src/modules/external-secrets.ee/external-secrets.controller.ee.ts b/packages/cli/src/modules/external-secrets.ee/external-secrets.controller.ee.ts index e366ed1e459..d551246d02a 100644 --- a/packages/cli/src/modules/external-secrets.ee/external-secrets.controller.ee.ts +++ b/packages/cli/src/modules/external-secrets.ee/external-secrets.controller.ee.ts @@ -2,7 +2,7 @@ import { Logger } from '@n8n/backend-common'; import { Get, Post, RestController, GlobalScope, Middleware } from '@n8n/decorators'; import { Request, Response, NextFunction } from 'express'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { ExternalSecretsProviders } from './external-secrets-providers.ee'; import { ExternalSecretsService } from './external-secrets.service.ee'; diff --git a/packages/cli/src/modules/external-secrets.ee/secret-provider-access-check.service.ee.ts b/packages/cli/src/modules/external-secrets.ee/secret-provider-access-check.service.ee.ts index 76003613cdc..59f96075132 100644 --- a/packages/cli/src/modules/external-secrets.ee/secret-provider-access-check.service.ee.ts +++ b/packages/cli/src/modules/external-secrets.ee/secret-provider-access-check.service.ee.ts @@ -7,8 +7,7 @@ import { Service } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; import { combineScopes, getAuthPrincipalScopes, hasGlobalScope } from '@n8n/permissions'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; diff --git a/packages/cli/src/modules/external-secrets.ee/secrets-providers-completions.controller.ee.ts b/packages/cli/src/modules/external-secrets.ee/secrets-providers-completions.controller.ee.ts index 80f831dccaf..84a855c64d4 100644 --- a/packages/cli/src/modules/external-secrets.ee/secrets-providers-completions.controller.ee.ts +++ b/packages/cli/src/modules/external-secrets.ee/secrets-providers-completions.controller.ee.ts @@ -4,7 +4,7 @@ import type { AuthenticatedRequest } from '@n8n/db'; import { Get, GlobalScope, Middleware, Param, ProjectScope, RestController } from '@n8n/decorators'; import type { NextFunction, Request, Response } from 'express'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { sendErrorResponse } from '@/response-helper'; import { ExternalSecretsConfig } from './external-secrets.config'; diff --git a/packages/cli/src/modules/external-secrets.ee/secrets-providers-connections.controller.ee.ts b/packages/cli/src/modules/external-secrets.ee/secrets-providers-connections.controller.ee.ts index 49edfc8bacf..8dc92bf8ba3 100644 --- a/packages/cli/src/modules/external-secrets.ee/secrets-providers-connections.controller.ee.ts +++ b/packages/cli/src/modules/external-secrets.ee/secrets-providers-connections.controller.ee.ts @@ -21,7 +21,7 @@ import { } from '@n8n/decorators'; import type { NextFunction, Request, Response } from 'express'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { sendErrorResponse } from '@/response-helper'; import { ExternalSecretsConfig } from './external-secrets.config'; diff --git a/packages/cli/src/modules/external-secrets.ee/secrets-providers-connections.service.ee.ts b/packages/cli/src/modules/external-secrets.ee/secrets-providers-connections.service.ee.ts index ce53472e60e..2337f5adaba 100644 --- a/packages/cli/src/modules/external-secrets.ee/secrets-providers-connections.service.ee.ts +++ b/packages/cli/src/modules/external-secrets.ee/secrets-providers-connections.service.ee.ts @@ -10,6 +10,7 @@ import { testSecretProviderConnectionResponseSchema, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import type { SecretsProviderAccessRole, SecretsProviderConnection } from '@n8n/db'; import { ProjectSecretsProviderAccessRepository, @@ -25,9 +26,7 @@ import { CredentialDependencyService, EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, } from '@/credentials/credential-dependency.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import type { ProjectSummary } from '@/events/maps/relay.event-map'; import { ExternalSecretsManager } from '@/modules/external-secrets.ee/external-secrets-manager.ee'; import { RedactionService } from '@/modules/external-secrets.ee/redaction.service.ee'; diff --git a/packages/cli/src/modules/external-secrets.ee/secrets-providers-project.controller.ee.ts b/packages/cli/src/modules/external-secrets.ee/secrets-providers-project.controller.ee.ts index 03f8ff427b9..0909c0e2f1e 100644 --- a/packages/cli/src/modules/external-secrets.ee/secrets-providers-project.controller.ee.ts +++ b/packages/cli/src/modules/external-secrets.ee/secrets-providers-project.controller.ee.ts @@ -20,7 +20,7 @@ import { } from '@n8n/decorators'; import type { NextFunction, Request, Response } from 'express'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { sendErrorResponse } from '@/response-helper'; import { ExternalSecretsConfig } from './external-secrets.config'; diff --git a/packages/cli/src/modules/external-secrets.ee/secrets-providers-types.controller.ee.ts b/packages/cli/src/modules/external-secrets.ee/secrets-providers-types.controller.ee.ts index 754716ac022..8f032139513 100644 --- a/packages/cli/src/modules/external-secrets.ee/secrets-providers-types.controller.ee.ts +++ b/packages/cli/src/modules/external-secrets.ee/secrets-providers-types.controller.ee.ts @@ -4,8 +4,7 @@ import type { AuthenticatedRequest } from '@n8n/db'; import { Get, Middleware, Param, RestController } from '@n8n/decorators'; import type { NextFunction, Request, Response } from 'express'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import { sendErrorResponse } from '@/response-helper'; import { ExternalSecretsProviders } from './external-secrets-providers.ee'; diff --git a/packages/cli/src/modules/favorites/__tests__/favorites.controller.test.ts b/packages/cli/src/modules/favorites/__tests__/favorites.controller.test.ts index c23a6a54822..e7ba2fdf110 100644 --- a/packages/cli/src/modules/favorites/__tests__/favorites.controller.test.ts +++ b/packages/cli/src/modules/favorites/__tests__/favorites.controller.test.ts @@ -1,7 +1,7 @@ import type { AuthenticatedRequest } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { UserFavorite } from '../database/entities/user-favorite.entity'; import { FavoritesController } from '../favorites.controller'; diff --git a/packages/cli/src/modules/favorites/__tests__/favorites.event-relay.test.ts b/packages/cli/src/modules/favorites/__tests__/favorites.event-relay.test.ts index cf074459948..3d2985a933a 100644 --- a/packages/cli/src/modules/favorites/__tests__/favorites.event-relay.test.ts +++ b/packages/cli/src/modules/favorites/__tests__/favorites.event-relay.test.ts @@ -1,6 +1,6 @@ +import { EventService } from '@n8n/backend-services'; import { mock } from 'vitest-mock-extended'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { FavoritesEventRelay } from '../favorites.event-relay'; diff --git a/packages/cli/src/modules/favorites/__tests__/favorites.service.test.ts b/packages/cli/src/modules/favorites/__tests__/favorites.service.test.ts index ca39a15eb2a..30abe23df55 100644 --- a/packages/cli/src/modules/favorites/__tests__/favorites.service.test.ts +++ b/packages/cli/src/modules/favorites/__tests__/favorites.service.test.ts @@ -9,8 +9,7 @@ import { } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import type { UserFavorite } from '../database/entities/user-favorite.entity'; import type { UserFavoriteRepository } from '../database/repositories/user-favorite.repository'; diff --git a/packages/cli/src/modules/favorites/favorites.controller.ts b/packages/cli/src/modules/favorites/favorites.controller.ts index 8ef9f8f23f9..652104ae6cf 100644 --- a/packages/cli/src/modules/favorites/favorites.controller.ts +++ b/packages/cli/src/modules/favorites/favorites.controller.ts @@ -2,7 +2,7 @@ import { FAVORITE_RESOURCE_TYPES, type FavoriteResourceType } from '@n8n/api-typ import type { AuthenticatedRequest } from '@n8n/db'; import { Body, Delete, Get, Param, Post, RestController } from '@n8n/decorators'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { AddFavoriteDto } from './dto/add-favorite.dto'; import { FavoritesService } from './favorites.service'; diff --git a/packages/cli/src/modules/favorites/favorites.event-relay.ts b/packages/cli/src/modules/favorites/favorites.event-relay.ts index c4489511471..398a3f4f1bd 100644 --- a/packages/cli/src/modules/favorites/favorites.event-relay.ts +++ b/packages/cli/src/modules/favorites/favorites.event-relay.ts @@ -1,6 +1,6 @@ +import { EventService } from '@n8n/backend-services'; import { Service } from '@n8n/di'; -import { EventService } from '@/events/event.service'; import { EventRelay } from '@/events/relays/event-relay'; import { FavoritesService } from './favorites.service'; diff --git a/packages/cli/src/modules/favorites/favorites.service.ts b/packages/cli/src/modules/favorites/favorites.service.ts index a7de5200a80..7ba958e591c 100644 --- a/packages/cli/src/modules/favorites/favorites.service.ts +++ b/packages/cli/src/modules/favorites/favorites.service.ts @@ -11,8 +11,7 @@ import { Service } from '@n8n/di'; import { hasGlobalScope } from '@n8n/permissions'; import { In } from '@n8n/typeorm'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { UserFavoriteRepository } from './database/repositories/user-favorite.repository'; import type { 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 new file mode 100644 index 00000000000..923a2982bef --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts @@ -0,0 +1,26 @@ +import { ModuleMetadata } from '@n8n/decorators'; +import { Container } from '@n8n/di'; + +// Importing the module runs the @BackendModule decorator, registering its metadata. +import { InboundAuthCoreModule } from '../inbound-auth-core.module'; + +describe('InboundAuthCoreModule', () => { + it('registers itself on main, webhook and worker instances without a license flag', () => { + const entry = Container.get(ModuleMetadata).get('inbound-auth-core'); + + expect(entry).toBeDefined(); + expect(entry?.instanceTypes).toEqual(['main', 'webhook', 'worker']); + expect(entry?.licenseFlag).toBeUndefined(); + }); + + it('exposes its entities so the datasource picks them up', async () => { + const module = new InboundAuthCoreModule(); + + const entities = (await module.entities()) as unknown as Array<{ name: string }>; + + expect(entities.map((entity) => entity.name)).toEqual([ + 'TrustedSourceEntity', + 'TrustedSourceIdentityEntity', + ]); + }); +}); diff --git a/packages/cli/src/modules/inbound-auth-core/database/entities/trusted-source-identity.entity.ts b/packages/cli/src/modules/inbound-auth-core/database/entities/trusted-source-identity.entity.ts new file mode 100644 index 00000000000..0739bc4113e --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/database/entities/trusted-source-identity.entity.ts @@ -0,0 +1,45 @@ +import { DateTimeColumn, User, WithTimestamps } from '@n8n/db'; +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; + +import { TrustedSourceEntity } from './trusted-source.entity'; + +export type TrustedSourceIdentityProvenance = + | 'user-proven' + | 'claim-match' + | 'admin' + | 'provisioning' + | 'jit'; +export type TrustedSourceIdentityStatus = 'active' | 'suspended' | 'revoked'; + +/** + * Binds a subject at a trusted source to an n8n user. + */ +@Entity('trusted_source_identity') +export class TrustedSourceIdentityEntity extends WithTimestamps { + @PrimaryColumn({ type: 'varchar', length: 36 }) + sourceId: string; + + @PrimaryColumn({ type: 'varchar' }) + subject: string; + + @ManyToOne(() => TrustedSourceEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'sourceId' }) + source: TrustedSourceEntity; + + @Index() + @Column({ type: String }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column({ type: 'varchar', length: 32 }) + provenance: TrustedSourceIdentityProvenance; + + @Column({ type: 'varchar', length: 16 }) + status: TrustedSourceIdentityStatus; + + @DateTimeColumn({ nullable: true }) + lastSeenAt: Date | null; +} 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 new file mode 100644 index 00000000000..c5f7ca9461a --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/database/entities/trusted-source.entity.ts @@ -0,0 +1,40 @@ +import { DateTimeColumn, WithTimestampsAndStringId } from '@n8n/db'; +import { Column, Entity } from '@n8n/typeorm'; + +export type TrustedSourceType = 'oauth2'; +export type TrustedSourceManagedBy = 'system' | 'admin'; +export type TrustedSourceStatus = 'unchecked' | 'healthy' | 'error'; + +/** + * A stored authentication authority that n8n trusts to identify users. + */ +@Entity('trusted_source') +export class TrustedSourceEntity extends WithTimestampsAndStringId { + @Column({ type: 'varchar', length: 128, unique: true }) + name: string; + + @Column({ type: 'varchar', length: 32 }) + type: TrustedSourceType; + + @Column({ type: 'varchar', unique: true }) + issuer: string; + + @Column({ type: 'varchar', length: 16 }) + managedBy: TrustedSourceManagedBy; + + @Column({ type: 'varchar', length: 16 }) + status: TrustedSourceStatus; + + @Column({ type: 'text', nullable: true }) + lastError: string | null; + + @DateTimeColumn({ nullable: true }) + lastCheckedAt: Date | null; + + @Column({ type: 'integer' }) + configVersion: number; + + /** Cipher.encryptV2(JSON.stringify(config)); the store decrypts, never the entity. */ + @Column({ type: 'text' }) + config: string; +} diff --git a/packages/cli/src/modules/inbound-auth-core/database/repositories/trusted-source-identity.repository.ts b/packages/cli/src/modules/inbound-auth-core/database/repositories/trusted-source-identity.repository.ts new file mode 100644 index 00000000000..aca9982c179 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/database/repositories/trusted-source-identity.repository.ts @@ -0,0 +1,21 @@ +import { BaseRepository, TransactionRunner } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; + +import { TrustedSourceIdentityEntity } from '../entities/trusted-source-identity.entity'; + +class TrustedSourceIdentityStore extends BaseRepository { + constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + super(TrustedSourceIdentityEntity, dataSource.manager, transactionRunner); + } +} + +@Service() +export class TrustedSourceIdentityRepository { + // ponytail: protected only so noUnusedLocals accepts a field nothing reads yet; private with the first method + protected readonly store: TrustedSourceIdentityStore; + + constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + this.store = new TrustedSourceIdentityStore(dataSource, transactionRunner); + } +} 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 new file mode 100644 index 00000000000..1837d73b1e5 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/database/repositories/trusted-source.repository.ts @@ -0,0 +1,21 @@ +import { BaseRepository, TransactionRunner } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; + +import { TrustedSourceEntity } from '../entities/trusted-source.entity'; + +class TrustedSourceStore extends BaseRepository { + constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + super(TrustedSourceEntity, dataSource.manager, transactionRunner); + } +} + +@Service() +export class TrustedSourceRepository { + // ponytail: protected only so noUnusedLocals accepts a field nothing reads yet; private with the first method + protected readonly store: TrustedSourceStore; + + constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + this.store = new TrustedSourceStore(dataSource, transactionRunner); + } +} 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 new file mode 100644 index 00000000000..af1eae4477e --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts @@ -0,0 +1,18 @@ +import type { ModuleInterface } from '@n8n/decorators'; +import { BackendModule } from '@n8n/decorators'; + +/** + * Owns the `trusted_source` and `trusted_source_identity` tables. Main, webhook and worker + * instances load it, because workers and webhook processes resolve the same entities as main. + */ +@BackendModule({ name: 'inbound-auth-core', instanceTypes: ['main', 'webhook', 'worker'] }) +export class InboundAuthCoreModule implements ModuleInterface { + async entities() { + const { TrustedSourceEntity } = await import('./database/entities/trusted-source.entity.js'); + const { TrustedSourceIdentityEntity } = await import( + './database/entities/trusted-source-identity.entity.js' + ); + + return [TrustedSourceEntity, TrustedSourceIdentityEntity]; + } +} diff --git a/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts b/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts index 0023e60789c..55e4fd8365c 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts @@ -5,8 +5,7 @@ import { Container } from '@n8n/di'; import { DateTime } from 'luxon'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; import { TypeToNumber, type TypeUnitNumber } from '../database/entities/insights-shared'; diff --git a/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts b/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts index 8396950161f..0d7eb16d860 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts @@ -18,7 +18,7 @@ import type { MockInstance, Mocked } from 'vitest'; import type { MockProxy } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; import { createMember } from '@test-integration/db/users'; @@ -1561,6 +1561,62 @@ describe('InsightsService (Integration)', () => { }); }); + describe('getDailyExecutionTotals', () => { + test('sums succeeded and failed executions of all workflows for each UTC day', async () => { + const insightsService = Container.get(InsightsService); + const workflow1 = await createWorkflow({}, await createTeamProject()); + const workflow2 = await createWorkflow({}, await createTeamProject()); + const at = (iso: string) => DateTime.fromISO(iso, { zone: 'utc' }); + + await createCompactedInsightsEvent(workflow1, { + type: 'success', + value: 3, + periodUnit: 'day', + periodStart: at('2025-06-10'), + }); + await createCompactedInsightsEvent(workflow2, { + type: 'failure', + value: 2, + periodUnit: 'day', + periodStart: at('2025-06-10'), + }); + // Other types on the same day do not count as executions. + for (const type of ['runtime_ms', 'time_saved_min', 'billable'] as const) { + await createCompactedInsightsEvent(workflow1, { + type, + value: 100, + periodUnit: 'day', + periodStart: at('2025-06-10'), + }); + } + // Hourly rows on both sides of the UTC day boundary. + await createCompactedInsightsEvent(workflow1, { + type: 'success', + value: 1, + periodUnit: 'hour', + periodStart: at('2025-06-10T23:00:00'), + }); + await createCompactedInsightsEvent(workflow2, { + type: 'failure', + value: 4, + periodUnit: 'hour', + periodStart: at('2025-06-11T00:00:00'), + }); + + const totals = await insightsService.getDailyExecutionTotals({ + startDate: at('2025-06-10').toJSDate(), + endDate: at('2025-06-11').toJSDate(), + }); + + expect(totals).toEqual( + new Map([ + ['2025-06-10', 6], + ['2025-06-11', 4], + ]), + ); + }); + }); + describe('validateDateFiltersLicense', () => { let licenseStateMock: Mocked; let insightsService: InsightsService; diff --git a/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period.repository.integration.test.ts b/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period.repository.integration.test.ts index d1256c06cbf..0d87a9b1333 100644 --- a/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period.repository.integration.test.ts +++ b/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period.repository.integration.test.ts @@ -525,6 +525,42 @@ describe('InsightsByPeriodRepository', () => { ); }); + describe('getEarliestDataDate', () => { + async function truncateInsights(): Promise { + await testDb.truncate(['InsightsByPeriod', 'InsightsMetadata', 'WorkflowEntity', 'Project']); + } + + beforeEach(async () => { + await truncateInsights(); + }); + + // Later blocks query a recent window, so leave none of these rows. + afterAll(async () => { + await truncateInsights(); + }); + + test('returns the oldest period start of any bucket size as a UTC instant', async () => { + const repository = Container.get(InsightsByPeriodRepository); + const workflow = await createWorkflow({}, await createTeamProject()); + for (const [periodUnit, periodStart] of [ + ['hour', '2026-06-01T10:00:00'], + ['day', '2026-04-08'], + ['week', '2026-03-16'], + ] as const) { + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 1, + periodUnit, + periodStart: DateTime.fromISO(periodStart, { zone: 'utc' }), + }); + } + + await expect(repository.getEarliestDataDate()).resolves.toEqual( + new Date('2026-03-16T00:00:00.000Z'), + ); + }); + }); + describe('access filter', () => { let member: User; let accessibleProject: Project; diff --git a/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts b/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts index e3980e65968..19a5e3d8459 100644 --- a/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts +++ b/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts @@ -65,20 +65,23 @@ const optionalNumberLike = z .optional() .transform((value) => (value !== undefined ? Number(value) : undefined)); +/** A raw `periodStart`: a `Date` on Postgres, a UTC SQL datetime string on SQLite. */ +const periodStartParser = z.union([z.date(), z.string()]).transform((value): string => { + if (value instanceof Date) { + return value.toISOString(); + } + + const parsedDatetime = DateTime.fromSQL(value.toString(), { zone: 'utc' }); + if (parsedDatetime.isValid) { + return parsedDatetime.toISO() ?? new Date(value).toISOString(); + } + + return new Date(value).toISOString(); +}); + const aggregatedInsightsByTimeParser = z .object({ - periodStart: z.union([z.date(), z.string()]).transform((value): string => { - if (value instanceof Date) { - return value.toISOString(); - } - - const parsedDatetime = DateTime.fromSQL(value.toString(), { zone: 'utc' }); - if (parsedDatetime.isValid) { - return parsedDatetime.toISO() ?? new Date(value).toISOString(); - } - - return new Date(value).toISOString(); - }), + periodStart: periodStartParser, runTime: optionalNumberLike, succeeded: optionalNumberLike, failed: optionalNumberLike, @@ -574,6 +577,8 @@ export class InsightsByPeriodRepository extends Repository { const result = await this.createQueryBuilder('ibp') .select('MIN(ibp.periodStart)', 'minDate') .getRawOne<{ minDate: Date | string | null }>(); - return result?.minDate ? new Date(result.minDate) : null; + // SQLite returns a UTC datetime string without a zone, which `new Date()` + // would read as local time. + return result?.minDate ? new Date(periodStartParser.parse(result.minDate)) : null; } } diff --git a/packages/cli/src/modules/insights/insights.controller.ts b/packages/cli/src/modules/insights/insights.controller.ts index 057a1fa92ab..08eedf454ab 100644 --- a/packages/cli/src/modules/insights/insights.controller.ts +++ b/packages/cli/src/modules/insights/insights.controller.ts @@ -11,9 +11,7 @@ import { DateTime } from 'luxon'; import { UserError } from 'n8n-workflow'; import { z } from 'zod'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import { BadRequestError, ForbiddenError, InternalServerError } from '@n8n/errors'; import { InsightsService } from './insights.service'; diff --git a/packages/cli/src/modules/insights/insights.service.ts b/packages/cli/src/modules/insights/insights.service.ts index 7a4876428cb..14869f389f5 100644 --- a/packages/cli/src/modules/insights/insights.service.ts +++ b/packages/cli/src/modules/insights/insights.service.ts @@ -10,7 +10,7 @@ import { DateTime } from 'luxon'; import { InstanceSettings } from 'n8n-core'; import { UserError } from 'n8n-workflow'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { userHasScopes } from '@/permissions.ee/check-access'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; @@ -305,6 +305,35 @@ export class InsightsService { })); } + /** The start of the oldest insights period of any bucket size, or `null` without data. */ + async getEarliestDataDate() { + return await this.insightsByPeriodRepository.getEarliestDataDate(); + } + + /** + * Succeeded plus failed executions of all workflows for each UTC day from + * `startDate` to `endDate`, both inclusive, keyed by `YYYY-MM-DD`. A day + * without executions has no entry. + * + * Unlike {@link getInsightsByTime}, this buckets by day for any range length. + */ + async getDailyExecutionTotals({ + startDate, + endDate, + }: { startDate: Date; endDate: Date }): Promise> { + const rows = await this.insightsByPeriodRepository.getInsightsByTime({ + periodUnit: 'day', + insightTypes: ['success', 'failure'], + startDate, + endDate, + timeZone: 'UTC', + }); + + return new Map( + rows.map((row) => [row.periodStart.slice(0, 10), (row.succeeded ?? 0) + (row.failed ?? 0)]), + ); + } + private async queryInsightsByTime({ user, startDate, diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-event-relay.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-event-relay.service.test.ts index 25316824c5c..e64d07e4c6d 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-event-relay.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-event-relay.service.test.ts @@ -1,8 +1,7 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; - import { InstanceAiEventRelay } from '../instance-ai-event-relay.service'; import type { InstanceAiMemoryService } from '../instance-ai-memory.service'; import type { InstanceAiMcpRegistryConnectionRepository } from '../repositories/instance-ai-mcp-registry-connection.repository'; diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-pending-agent.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-pending-agent.service.test.ts index 573b39c22e8..62620cc1f71 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-pending-agent.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-pending-agent.service.test.ts @@ -4,8 +4,7 @@ import type { User } from '@n8n/db'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import { AgentDefaultModelResolverService } from '@/modules/agents/agent-default-model-resolver.service'; import { AgentRunnableStateService } from '@/modules/agents/agent-runnable-state.service'; import { AgentsService } from '@/modules/agents/agents.service'; diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-settings.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-settings.service.test.ts index e14f7c7ba02..68cec6f50fd 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-settings.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-settings.service.test.ts @@ -1,6 +1,7 @@ import { DEFAULT_INSTANCE_AI_PERMISSIONS } from '@n8n/api-types'; import type { InstanceAiPermissions } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { InstanceAiConfig } from '@n8n/config'; import type { CredentialsEntity, @@ -12,8 +13,7 @@ import type { import { Container } from '@n8n/di'; import { mock } from 'vitest-mock-extended'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; -import type { EventService } from '@/events/event.service'; +import { UnprocessableRequestError } from '@n8n/errors'; import type { AiService } from '@/services/ai.service'; import { INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY, diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-setup-telemetry.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-setup-telemetry.service.test.ts index adc9b675f60..d970bfad062 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-setup-telemetry.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-setup-telemetry.service.test.ts @@ -1,12 +1,12 @@ import type { InstanceAiAdminSettingsResponse } from '@n8n/api-types'; import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { GlobalConfig, InstanceAiConfig } from '@n8n/config'; import type { SettingsRepository } from '@n8n/db'; import { TELEMETRY_EVENT } from '@n8n/telemetry'; import { mock } from 'vitest-mock-extended'; import type { InstanceCredentialBroker } from '@/credentials/instance-credential-broker'; -import type { EventService } from '@/events/event.service'; import type { Telemetry } from '@/telemetry'; import type { diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-test.controller.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-test.controller.test.ts index 412a1b7b31d..e3151ed8609 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-test.controller.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-test.controller.test.ts @@ -15,7 +15,7 @@ import type { ProjectRepository, UserRepository, WorkflowRepository } from '@n8n import type { Request, Response } from 'express'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import type { InstanceAiMemoryService } from '../instance-ai-memory.service'; import { InstanceAiTestController } from '../instance-ai-test.controller'; diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-thread-tabs.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-thread-tabs.service.test.ts new file mode 100644 index 00000000000..00d17a7d320 --- /dev/null +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-thread-tabs.service.test.ts @@ -0,0 +1,62 @@ +import type { InstanceAiThreadTabsState } from '@n8n/api-types'; +import type { Logger } from '@n8n/backend-common'; +import { mock } from 'vitest-mock-extended'; + +import { InstanceAiThreadTabsService } from '../instance-ai-thread-tabs.service'; +import type { InstanceAiThreadTabsRepository } from '../repositories/instance-ai-thread-tabs.repository'; + +const THREAD_ID = 'thread-1'; +const USER_ID = 'user-1'; + +describe('InstanceAiThreadTabsService', () => { + const logger = mock(); + const repository = mock(); + const service = new InstanceAiThreadTabsService(logger, repository); + + const state: InstanceAiThreadTabsState = { + tabs: [{ type: 'agent', id: 'agent-1', name: 'SEO Auditor' }], + closedTabs: [], + activeTab: { type: 'agent', id: 'agent-1' }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns the stored tabs', async () => { + repository.findState.mockResolvedValue(state); + + await expect(service.getState(THREAD_ID, USER_ID)).resolves.toEqual(state); + expect(repository.findState).toHaveBeenCalledWith(THREAD_ID, USER_ID); + }); + + it('returns stored tabs that were saved before previewOpen existed, and keeps previewOpen when present', async () => { + repository.findState.mockResolvedValue(state); + await expect(service.getState(THREAD_ID, USER_ID)).resolves.toEqual(state); + + repository.findState.mockResolvedValue({ ...state, previewOpen: true }); + await expect(service.getState(THREAD_ID, USER_ID)).resolves.toEqual({ + ...state, + previewOpen: true, + }); + }); + + it('returns null when no tabs are stored', async () => { + repository.findState.mockResolvedValue(null); + + await expect(service.getState(THREAD_ID, USER_ID)).resolves.toBeNull(); + }); + + it('returns null and logs a warning when the stored tabs have an invalid shape', async () => { + repository.findState.mockResolvedValue({ tabs: 'not-an-array' }); + + await expect(service.getState(THREAD_ID, USER_ID)).resolves.toBeNull(); + expect(logger.warn).toHaveBeenCalledWith(expect.any(String), { threadId: THREAD_ID }); + }); + + it('saves the tabs for the user and thread', async () => { + await service.saveState(THREAD_ID, USER_ID, state); + + expect(repository.saveState).toHaveBeenCalledWith(THREAD_ID, USER_ID, state); + }); +}); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-workflow-writes.integration.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-workflow-writes.integration.test.ts index 42ec4fdaedb..702d9145198 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-workflow-writes.integration.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-workflow-writes.integration.test.ts @@ -16,7 +16,7 @@ import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { CollaborationService } from '@/collaboration/collaboration.service'; import { License } from '@/license'; import { Push } from '@/push'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { Telemetry } from '@/telemetry'; import { createMember, createOwner } from '@test-integration/db/users'; diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.node-usage.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.node-usage.test.ts index bd36130dc75..65d323163da 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.node-usage.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.node-usage.test.ts @@ -29,6 +29,7 @@ vi.mock('@n8n/ai-utilities', () => ({ import type { Logger } from '@n8n/backend-common'; import { INSTANCE_AI_NODE_USAGE_FLAG } from '@n8n/api-types'; import type { OutboundHttp } from '@n8n/backend-network'; +import type { EventService } from '@n8n/backend-services'; import type { GlobalConfig } from '@n8n/config'; import type { AiBuilderTemporaryWorkflowRepository, @@ -47,7 +48,6 @@ import type { ActiveExecutions } from '@/active-executions'; import type { CollaborationService } from '@/collaboration/collaboration.service'; import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import type { CredentialsService } from '@/credentials/credentials.service'; -import type { EventService } from '@/events/event.service'; import type { ExecutionPersistence } from '@/executions/execution-persistence'; import type { License } from '@/license'; import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts index 406b039ee8d..4d7fd33f1f0 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts @@ -27,6 +27,7 @@ vi.mock('@n8n/instance-ai', async () => { }); import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { GlobalConfig } from '@n8n/config'; import { GLOBAL_MEMBER_ROLE } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -57,7 +58,6 @@ import type { InstanceAiSettingsService } from '../instance-ai-settings.service' import type { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; import type { ExecutionPersistence } from '@/executions/execution-persistence'; import type { CollaborationService } from '@/collaboration/collaboration.service'; -import type { EventService } from '@/events/event.service'; import type { License } from '@/license'; import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import type { DataTableRepository } from '@/modules/data-table/data-table.repository'; diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts index e8e282db8c1..1831bcffa22 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts @@ -1817,12 +1817,14 @@ import { } from '@n8n/instance-ai/errors'; import type { WorkflowService } from '@/workflows/workflow.service'; import { AiPreferenceScopeFullError } from '@/errors/response-errors/ai-preference-scope-full.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { LockedError } from '@/errors/response-errors/locked.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { + BadRequestError, + ConflictError, + ForbiddenError, + InternalServerError, + LockedError, + NotFoundError, +} from '@n8n/errors'; import type { License } from '@/license'; import type { AiPreferenceService } from '@/services/ai-preference.service'; import type { RoleService } from '@/services/role.service'; @@ -3776,11 +3778,14 @@ describe('createWorkflowAdapter', () => { await adapter.createFromWorkflowJSON(minimalWorkflowJSON); - expect(mockPolicyEnforcementService.enforceWorkflowSave).toHaveBeenCalledWith({ - workflow: { id: null, name: minimalWorkflowJSON.name, nodes: [] }, - storedWorkflow: null, - projectId: 'team-project-id', - }); + expect(mockPolicyEnforcementService.enforceWorkflowSave).toHaveBeenCalledWith( + { + workflow: { id: null, name: minimalWorkflowJSON.name, nodes: [] }, + storedWorkflow: null, + projectId: 'team-project-id', + }, + { kind: 'user', user: expect.objectContaining({ id: 'user-1' }) }, + ); expect(mockWorkflowRepository.runInTransaction).toHaveBeenCalledWith( { policyCleared: cleared }, expect.any(Function), diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts index 82a3b97823b..a99ca558b65 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts @@ -55,12 +55,14 @@ import type { InstanceAiThreadInfo, InstanceAiRichMessagesResponse, InstanceAiThreadMessagesResponse, + InstanceAiThreadTabsState, } from '@n8n/api-types'; import type { ModuleRegistry } from '@n8n/backend-common'; import type { GlobalConfig } from '@n8n/config'; import { buildAgentTreeFromEvents, seedAgentBuilderTargetMetadata } from '@n8n/instance-ai'; import { InstanceAiPersistPendingAgentRequest, + InstanceAiThreadTabsRequestDto, MAX_ATTACHMENT_BASE64_BYTES, MAX_TOTAL_ATTACHMENT_BASE64_BYTES, } from '@n8n/api-types'; @@ -72,10 +74,7 @@ import type { Request, Response } from 'express'; import { UserError } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '@n8n/errors'; import type { CredentialsService } from '@/credentials/credentials.service'; import type { Push } from '@/push'; import type { Publisher } from '@/scaling/pubsub/publisher.service'; @@ -96,6 +95,7 @@ import type { InstanceAiMemoryService } from '../instance-ai-memory.service'; import type { InstanceAiOnboardingService } from '../onboarding'; import type { InstanceAiPendingAgentService } from '../instance-ai-pending-agent.service'; import type { InstanceAiPreferenceCardService } from '../instance-ai-preference-card.service'; +import type { InstanceAiThreadTabsService } from '../instance-ai-thread-tabs.service'; import type { InstanceAiModelCatalogService } from '../instance-ai-model-catalog.service'; import type { InstanceAiSettingsService } from '../instance-ai-settings.service'; import { InstanceAiController } from '../instance-ai.controller'; @@ -146,6 +146,7 @@ describe('InstanceAiController', () => { const evalThreadRestore = mock(); const preferenceCardService = mock(); const onboarding = mock(); + const threadTabsService = mock(); const controller = new InstanceAiController( instanceAiService, @@ -173,6 +174,7 @@ describe('InstanceAiController', () => { publisher, preferenceCardService, globalConfig, + threadTabsService, ); const req = mock({ user: { id: USER_ID } }); @@ -1121,6 +1123,7 @@ describe('InstanceAiController', () => { expect(evalThreadRestore.restoreWorkflows).toHaveBeenCalledWith( [seedWorkflow], 'project-1', + expect.objectContaining({ id: USER_ID }), expect.any(Map), undefined, expect.any(Map), @@ -1164,6 +1167,7 @@ describe('InstanceAiController', () => { expect(evalThreadRestore.restoreWorkflows).toHaveBeenCalledWith( [seedWorkflow], 'project-1', + expect.objectContaining({ id: USER_ID }), idMap, undefined, expect.any(Map), @@ -1183,6 +1187,7 @@ describe('InstanceAiController', () => { expect(evalThreadRestore.restoreWorkflows).toHaveBeenCalledWith( [seedWorkflow], 'project-1', + expect.objectContaining({ id: USER_ID }), expect.any(Map), new Set(['cred-1', 'cred-2']), expect.any(Map), @@ -1293,6 +1298,7 @@ describe('InstanceAiController', () => { expect(evalThreadRestore.restoreWorkflows).toHaveBeenCalledWith( [placedWorkflow], 'project-1', + expect.objectContaining({ id: USER_ID }), expect.any(Map), undefined, folderIdMap, @@ -2175,6 +2181,80 @@ describe('InstanceAiController', () => { }); }); + describe('thread tabs', () => { + const tabsState: InstanceAiThreadTabsState = { + tabs: [{ type: 'workflow', id: 'wf-1', name: 'My Workflow', projectId: 'project-1' }], + closedTabs: [{ type: 'data-table', id: 'dt-1' }], + activeTab: { type: 'workflow', id: 'wf-1' }, + }; + + // Later tests read the ownership mock without setting it, so leave it as found. + afterEach(() => { + memoryService.checkThreadOwnership.mockResolvedValue('owned'); + }); + + it('should require instanceAi:message scope to read and save tabs', () => { + expect(scopeOf('getThreadTabs')).toEqual({ scope: 'instanceAi:message', globalOnly: true }); + expect(scopeOf('saveThreadTabs')).toEqual({ scope: 'instanceAi:message', globalOnly: true }); + }); + + it('should return the stored tabs of the user', async () => { + memoryService.checkThreadOwnership.mockResolvedValue('owned'); + threadTabsService.getState.mockResolvedValue(tabsState); + + const result = await controller.getThreadTabs(req, res, THREAD_ID); + + expect(result).toEqual({ state: tabsState }); + expect(threadTabsService.getState).toHaveBeenCalledWith(THREAD_ID, USER_ID); + }); + + it('should save the tabs of the user', async () => { + memoryService.checkThreadOwnership.mockResolvedValue('owned'); + const payload = new InstanceAiThreadTabsRequestDto(tabsState); + + const result = await controller.saveThreadTabs(req, res, THREAD_ID, payload); + + expect(result).toEqual({ state: tabsState }); + expect(threadTabsService.saveState).toHaveBeenCalledWith(THREAD_ID, USER_ID, tabsState); + }); + + it('should save whether the preview is open', async () => { + memoryService.checkThreadOwnership.mockResolvedValue('owned'); + const payload = new InstanceAiThreadTabsRequestDto({ ...tabsState, previewOpen: false }); + + const result = await controller.saveThreadTabs(req, res, THREAD_ID, payload); + + expect(result).toEqual({ state: { ...tabsState, previewOpen: false } }); + expect(threadTabsService.saveState).toHaveBeenCalledWith(THREAD_ID, USER_ID, { + ...tabsState, + previewOpen: false, + }); + }); + + it('should reject tabs of a thread that belongs to another user', async () => { + memoryService.checkThreadOwnership.mockResolvedValue('other_user'); + + await expect(controller.getThreadTabs(req, res, THREAD_ID)).rejects.toThrow(ForbiddenError); + await expect( + controller.saveThreadTabs( + req, + res, + THREAD_ID, + new InstanceAiThreadTabsRequestDto(tabsState), + ), + ).rejects.toThrow(ForbiddenError); + expect(threadTabsService.getState).not.toHaveBeenCalled(); + expect(threadTabsService.saveState).not.toHaveBeenCalled(); + }); + + it('should reject tabs of a thread that does not exist', async () => { + memoryService.checkThreadOwnership.mockResolvedValue('not_found'); + + await expect(controller.getThreadTabs(req, res, THREAD_ID)).rejects.toThrow(NotFoundError); + expect(threadTabsService.getState).not.toHaveBeenCalled(); + }); + }); + describe('getThreadMessages', () => { it('should require instanceAi:message scope', () => { expect(scopeOf('getThreadMessages')).toEqual({ @@ -2727,6 +2807,7 @@ describe('InstanceAiController — durable-log SSE replay', () => { mock(), mock(), globalConfig, + mock(), ); beforeEach(() => { diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts index 2efe3257042..8dd5e76b4c1 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts @@ -288,7 +288,7 @@ import { UserError } from 'n8n-workflow'; import type { Mock, MockedFunction } from 'vitest'; import { InstanceAiBuilderDelegateAdapterService } from '@/modules/agents/instance-ai-builder-delegate.adapter'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { userHasScopes } from '@/permissions.ee/check-access'; import { AI_PREFERENCES_CLEARED_BLOCK, diff --git a/packages/cli/src/modules/instance-ai/__tests__/onboarding.test.ts b/packages/cli/src/modules/instance-ai/__tests__/onboarding.test.ts index 1995bc411df..b0281da2d97 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/onboarding.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/onboarding.test.ts @@ -4,7 +4,7 @@ import { TELEMETRY_EVENT } from '@n8n/telemetry'; import { describe, expect, it } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { Telemetry } from '@/telemetry'; import type { InstanceAiPendingConfirmation } from '../entities/instance-ai-pending-confirmation.entity'; diff --git a/packages/cli/src/modules/instance-ai/eval/__tests__/execution.service.test.ts b/packages/cli/src/modules/instance-ai/eval/__tests__/execution.service.test.ts index 2b98fab241d..16db722ac08 100644 --- a/packages/cli/src/modules/instance-ai/eval/__tests__/execution.service.test.ts +++ b/packages/cli/src/modules/instance-ai/eval/__tests__/execution.service.test.ts @@ -114,6 +114,7 @@ import { generateMockHints, identifyNodesForHints, identifyNodesForPinData, + isDataTableRead, partitionAiRoots, } from '../workflow-analysis'; import type { MockHints } from '../workflow-analysis'; @@ -854,6 +855,7 @@ describe('EvalExecutionService', () => { expect(identifyNodesForPinDataMock).toHaveBeenCalledWith( expect.objectContaining({ id: 'wf-1' }), undefined, + new Set(), ); }); @@ -937,6 +939,7 @@ describe('EvalExecutionService', () => { expect(identifyNodesForPinDataMock).toHaveBeenCalledWith( expect.objectContaining({ id: 'wf-1' }), new Set(['Agent']), + new Set(), ); }); @@ -1925,6 +1928,67 @@ describe('EvalExecutionService', () => { }); }); + describe('seeded Data Table reads (via execution)', () => { + function readNode(name: string, dataTableId: unknown): INode { + return { + id: name, + name, + type: 'n8n-nodes-base.dataTable', + typeVersion: 1, + position: [200, 0], + parameters: { resource: 'row', operation: 'get', dataTableId }, + } as INode; + } + + beforeEach(() => { + vi.mocked(isDataTableRead).mockImplementation( + (node: INode) => node.type === 'n8n-nodes-base.dataTable', + ); + ownershipService.getWorkflowProjectCached.mockResolvedValue({ id: 'proj-1' } as never); + }); + + it('leaves the reads of a seeded table out of the pinned nodes', async () => { + workflowFinderService.findWorkflowForUser.mockResolvedValue( + makeWorkflowEntity({ + nodes: [ + makeStartNode(), + readNode('Read Seeded', { __rl: true, mode: 'list', value: 'dt-seeded' }), + // Spelt in another case: the node resolves names case-insensitively. + readNode('Read Named', { __rl: true, mode: 'name', value: 'stock [SEED 1a2b3c4d]' }), + readNode('Read Other', { __rl: true, mode: 'id', value: 'dt-other' }), + // A name the seed did not create stays pinned. + readNode('Read Unseeded', { __rl: true, mode: 'name', value: 'Orders' }), + ], + }) as never, + ); + dataTableService.findDataTablesByIds.mockResolvedValue([ + { id: 'dt-seeded', name: 'Customers' }, + { id: 'dt-named', name: 'Stock [seed 1a2b3c4d]' }, + ] as never); + + await service.executeWithLlmMock('wf-1', makeUser(), { + seededDataTableIds: ['dt-seeded', 'dt-named'], + }); + + expect(dataTableService.findDataTablesByIds).toHaveBeenCalledWith(['dt-seeded', 'dt-named']); + const liveReads = identifyNodesForPinDataMock.mock.calls[0][2]; + expect([...(liveReads ?? [])]).toEqual(['Read Seeded', 'Read Named']); + }); + + it('pins every read when the caller seeded no table', async () => { + workflowFinderService.findWorkflowForUser.mockResolvedValue( + makeWorkflowEntity({ + nodes: [makeStartNode(), readNode('Read', { __rl: true, mode: 'id', value: 'dt-1' })], + }) as never, + ); + + await service.executeWithLlmMock('wf-1', makeUser()); + + expect([...(identifyNodesForPinDataMock.mock.calls[0][2] ?? [])]).toEqual([]); + expect(ownershipService.getWorkflowProjectCached).not.toHaveBeenCalled(); + }); + }); + // ── hints passthrough ──────────────────────────────────────────── describe('hints in result', () => { diff --git a/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.integration.test.ts b/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.integration.test.ts index 2544c370831..0edf564abd4 100644 --- a/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.integration.test.ts +++ b/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.integration.test.ts @@ -1,6 +1,6 @@ import type { LicenseState } from '@n8n/backend-common'; import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; -import type { Project } from '@n8n/db'; +import type { Project, User } from '@n8n/db'; import { CredentialsRepository, SharedWorkflowRepository, @@ -250,7 +250,11 @@ describe('EvalThreadRestoreService.restoreWorkflows (policy seal)', () => { }); it('persists the seed workflow at its id and makes the project its owner', async () => { - const created = await service.restoreWorkflows([seed('wf-seeded-1', 'Allowed')], project.id); + const created = await service.restoreWorkflows( + [seed('wf-seeded-1', 'Allowed')], + project.id, + mock(), + ); expect(created).toEqual(['wf-seeded-1']); const stored = await workflowRepository.findById('wf-seeded-1'); @@ -264,6 +268,7 @@ describe('EvalThreadRestoreService.restoreWorkflows (policy seal)', () => { const restore = service.restoreWorkflows( [seed('wf-seeded-ok', 'Allowed'), seed('wf-seeded-blocked', 'Blocked')], project.id, + mock(), ); await expect(restore).rejects.toThrow(PolicyViolationError); diff --git a/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.test.ts b/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.test.ts index 15c0a9a4860..70e007ae97e 100644 --- a/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.test.ts +++ b/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.test.ts @@ -15,7 +15,7 @@ import type { PolicyCleared, PolicyViolation } from '@n8n/decorators'; import type { EntityManager } from '@n8n/typeorm'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { AgentsService } from '@/modules/agents/agents.service'; import type { DataTable } from '@/modules/data-table/data-table.entity'; import type { DataTableService } from '@/modules/data-table/data-table.service'; @@ -83,6 +83,7 @@ describe('EvalThreadRestoreService', () => { await service.restoreWorkflows( [{ id: 'wf-original', name: 'Daily digest', nodes: [makeNode()], connections: {} }], 'project-1', + evalUser, ); expect(workflowRepo.createContent).toHaveBeenCalledTimes(1); @@ -104,14 +105,18 @@ describe('EvalThreadRestoreService', () => { await service.restoreWorkflows( [{ id: 'wf-1', name: 'Daily digest', nodes: [makeNode()], connections: {} }], 'project-1', + evalUser, ); const saved = workflowRepo.create.mock.calls[0][0]; - expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith({ - workflow: { id: null, name: 'Daily digest', nodes: saved.nodes }, - storedWorkflow: null, - projectId: 'project-1', - }); + expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith( + { + workflow: { id: null, name: 'Daily digest', nodes: saved.nodes }, + storedWorkflow: null, + projectId: 'project-1', + }, + { kind: 'user', user: evalUser }, + ); expect(workflowRepo.runInTransaction).toHaveBeenCalledExactlyOnceWith( { policyCleared: cleared }, expect.any(Function), @@ -131,6 +136,7 @@ describe('EvalThreadRestoreService', () => { await service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [node], connections: {} }], 'project-1', + evalUser, ); expect(credentialsRepo.findByNameAndTypeInProject).toHaveBeenCalledExactlyOnceWith( @@ -160,6 +166,7 @@ describe('EvalThreadRestoreService', () => { await service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [node], connections: {} }], 'project-1', + evalUser, ); const saved = workflowRepo.create.mock.calls[0][0]; @@ -178,6 +185,7 @@ describe('EvalThreadRestoreService', () => { await service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [node], connections: {} }], 'project-1', + evalUser, new Map(), new Set(['cred-this-thread']), ); @@ -198,6 +206,7 @@ describe('EvalThreadRestoreService', () => { await service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [node], connections: {} }], 'project-1', + evalUser, ); expect(workflowRepo.create.mock.calls[0][0].nodes?.[0]).not.toHaveProperty('credentials'); @@ -210,6 +219,7 @@ describe('EvalThreadRestoreService', () => { service.restoreWorkflows( [{ id: 'wf-live', name: 'wf', nodes: [node], connections: {}, published: true }], 'project-1', + evalUser, ), ).rejects.toThrow( 'Seed workflow wf-live is published, but its slackApi credential "Slak" matched 0 project credentials (need exactly 1)', @@ -228,6 +238,7 @@ describe('EvalThreadRestoreService', () => { service.restoreWorkflows( [{ id: 'wf-live', name: 'wf', nodes: [node], connections: {}, published: true }], 'project-1', + evalUser, ), ).rejects.toThrow( 'Seed workflow wf-live is published, but its slackApi credential "Slack" matched 2 project credentials (need exactly 1)', @@ -243,6 +254,7 @@ describe('EvalThreadRestoreService', () => { service.restoreWorkflows( [{ id: 'wf-live', name: 'wf', nodes: [node], connections: {}, published: true }], 'project-1', + evalUser, ), ).rejects.toThrow( 'Seed workflow wf-live is published, but its slackApi credential reference has no name', @@ -339,16 +351,20 @@ describe('EvalThreadRestoreService', () => { const created = await service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [makeNode()], connections: {} }], 'project-1', + evalUser, ); expect(workflowRepo.findByIds).toHaveBeenCalledWith(['wf-1'], { fields: ['id', 'name', 'nodes'], }); - expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith({ - workflow: { id: 'wf-1', name: 'wf', nodes: expect.any(Array) }, - storedWorkflow: { id: 'wf-1', name: 'Old name', nodes: storedNodes }, - projectId: 'project-1', - }); + expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith( + { + workflow: { id: 'wf-1', name: 'wf', nodes: expect.any(Array) }, + storedWorkflow: { id: 'wf-1', name: 'Old name', nodes: storedNodes }, + projectId: 'project-1', + }, + { kind: 'user', user: evalUser }, + ); expect(workflowRepo.updateContent).toHaveBeenCalledExactlyOnceWith( 'wf-1', expect.objectContaining({ name: 'wf', active: false, versionId: expect.any(String) }), @@ -368,6 +384,7 @@ describe('EvalThreadRestoreService', () => { service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [makeNode()], connections: {} }], 'project-1', + evalUser, ), ).rejects.toThrow(BadRequestError); expect(policyEnforcementService.enforceWorkflowSave).not.toHaveBeenCalled(); @@ -379,6 +396,7 @@ describe('EvalThreadRestoreService', () => { service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [{ name: 'no type' }], connections: {} }], 'project-1', + evalUser, ), ).rejects.toThrow(BadRequestError); expect(policyEnforcementService.enforceWorkflowSave).not.toHaveBeenCalled(); @@ -405,6 +423,7 @@ describe('EvalThreadRestoreService', () => { { id: 'wf-blocked', name: 'Blocked', nodes: [makeNode()], connections: {} }, ], 'project-1', + evalUser, ); await expect(restore).rejects.toThrow(PolicyViolationError); @@ -427,6 +446,7 @@ describe('EvalThreadRestoreService', () => { service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [makeNode()], connections: {} }], 'project-1', + evalUser, ), ).rejects.toBe(failure); expect(workflowRepo.createContent).not.toHaveBeenCalled(); @@ -574,6 +594,7 @@ describe('EvalThreadRestoreService', () => { await service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [node], connections: {} }], 'project-1', + evalUser, new Map([['dt-old', 'dt-new']]), ); @@ -847,6 +868,7 @@ describe('EvalThreadRestoreService', () => { { id: 'wf-1', name: 'Rooted', nodes: [makeNode()], connections: {} }, ], 'project-1', + evalUser, new Map(), undefined, new Map([['odwFolder0001', 'real-odw']]), @@ -981,6 +1003,7 @@ describe('EvalThreadRestoreService', () => { }, ], 'project-1', + evalUser, new Map(), undefined, new Map([['odwFolder0001', 'real-odw']]), @@ -994,6 +1017,7 @@ describe('EvalThreadRestoreService', () => { await service.restoreWorkflows( [{ id: 'wf-1', name: 'Root', nodes: [makeNode()], connections: {} }], 'project-1', + evalUser, new Map(), undefined, new Map([['odwFolder0001', 'real-odw']]), @@ -1016,6 +1040,7 @@ describe('EvalThreadRestoreService', () => { }, ], 'project-1', + evalUser, ), ).rejects.toThrow(BadRequestError); diff --git a/packages/cli/src/modules/instance-ai/eval/__tests__/workflow-analysis.test.ts b/packages/cli/src/modules/instance-ai/eval/__tests__/workflow-analysis.test.ts index 9e0e28d9d68..886eb2100ed 100644 --- a/packages/cli/src/modules/instance-ai/eval/__tests__/workflow-analysis.test.ts +++ b/packages/cli/src/modules/instance-ai/eval/__tests__/workflow-analysis.test.ts @@ -236,6 +236,28 @@ describe('identifyNodesForPinData', () => { expect(result.map((n) => n.name)).toEqual(['Cache']); }); }); + + it('leaves only the Data Table reads in the live set unpinned', () => { + const read = (name: string, operation: string) => + makeNode({ + name, + type: 'n8n-nodes-base.dataTable', + parameters: { resource: 'row', operation }, + }); + const nodes = [ + read('Read Seeded', 'get'), + read('Read Other', 'rowExists'), + makeNode({ name: 'Cache', type: 'n8n-nodes-base.redis' }), + ]; + + const result = identifyNodesForPinData( + makeWorkflow(nodes), + undefined, + new Set(['Read Seeded', 'Cache']), + ); + + expect(result.map((n) => n.name)).toEqual(['Read Other', 'Cache']); + }); }); describe('partitionAiRoots', () => { diff --git a/packages/cli/src/modules/instance-ai/eval/execution.service.ts b/packages/cli/src/modules/instance-ai/eval/execution.service.ts index 36cb44e6407..bcfca8e153d 100644 --- a/packages/cli/src/modules/instance-ai/eval/execution.service.ts +++ b/packages/cli/src/modules/instance-ai/eval/execution.service.ts @@ -73,6 +73,7 @@ import { generateMockHints, identifyNodesForHints, identifyNodesForPinData, + isDataTableRead, type MockHints, partitionAiRoots, type TriggerBinaryRequirement, @@ -93,6 +94,18 @@ interface RunBudget { deadlineAt: number; } +/** A Data Table node's locator: the id it carries, or the name when it is in `name` mode. */ +function dataTableLocator(node: INode): { mode: 'name' | 'id'; value: string } | undefined { + const locator = node.parameters?.dataTableId as + | { mode?: unknown; value?: unknown } + | string + | undefined; + const value = typeof locator === 'string' ? locator : locator?.value; + if (typeof value !== 'string' || value.length === 0) return undefined; + const byName = typeof locator !== 'string' && locator?.mode === 'name'; + return { mode: byName ? 'name' : 'id', value }; +} + // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- @@ -195,7 +208,13 @@ export class EvalExecutionService { const timings = new EvalTimings(); let hints: MockHints; try { - hints = await this.analyzeWorkflow(workflowEntity, timings, options.scenarioHints, unpinSet); + hints = await this.analyzeWorkflow( + workflowEntity, + timings, + options.scenarioHints, + unpinSet, + options.seededDataTableIds, + ); } catch (error) { const message = error instanceof Error ? error.message : String(error); return this.errorResult( @@ -239,6 +258,7 @@ export class EvalExecutionService { timings: EvalTimings, scenarioHints?: string, unpinSet?: Set, + seededDataTableIds?: string[], ): Promise { // Phase 1: Generate mock hints for HTTP-interceptible nodes const hintNodes = identifyNodesForHints(workflowEntity); @@ -278,7 +298,11 @@ export class EvalExecutionService { ); // Phase 1.5: Generate pin data for nodes that bypass the HTTP mock layer - const bypassNodes = identifyNodesForPinData(workflowEntity, unpinSet); + const liveReads = await this.liveDataTableReads(workflowEntity, seededDataTableIds); + if (liveReads.size > 0) { + this.logger.debug(`[EvalMock] Reading seeded Data Tables live: ${[...liveReads].join(', ')}`); + } + const bypassNodes = identifyNodesForPinData(workflowEntity, unpinSet, liveReads); const bypassNodeNames = bypassNodes.map((n) => n.name); if (bypassNodeNames.length > 0) { @@ -400,32 +424,13 @@ export class EvalExecutionService { let projectId: string | undefined; for (const node of readNodes) { try { - const locator = node.parameters?.dataTableId as - | { mode?: unknown; value?: unknown } - | string - | undefined; - const locatorValue = typeof locator === 'string' ? locator : locator?.value; - if (typeof locatorValue !== 'string' || locatorValue.length === 0) continue; - projectId ??= (await this.ownershipService.getWorkflowProjectCached(workflowEntity.id)).id; - - // `name` mode carries a table name, not an id (the node runtime resolves - // it via `resolveDataTableId`) — passing it straight to an id lookup - // dropped named tables to prompt-only generation. Exact name match only; - // a near-miss still degrades gracefully below. - let tableId = locatorValue; - if ((typeof locator === 'string' ? 'id' : locator?.mode) === 'name') { - const matches = await this.dataTableService.findDataTablesByNamesInProject(projectId, [ - locatorValue, - ]); - const resolved = matches.at(0)?.id; - if (!resolved) { - this.logger.warn( - `[EvalMock] No Data Table named "${locatorValue}" for node "${node.name}" — pinned rows fall back to prompt-only generation`, - ); - continue; - } - tableId = resolved; + const tableId = await this.resolveDataTableNodeId(node, projectId); + if (!tableId) { + this.logger.warn( + `[EvalMock] No Data Table found for node "${node.name}" — pinned rows fall back to prompt-only generation`, + ); + continue; } const columns = await this.dataTableService.getColumns(tableId, projectId); @@ -441,6 +446,52 @@ export class EvalExecutionService { return Object.keys(columnsByNode).length > 0 ? columnsByNode : undefined; } + /** Data Table reads bound to a table the caller reseeded for this scenario. That + * table holds the scenario's rows, so the read runs live and also sees the + * writes the run makes before it. */ + private async liveDataTableReads( + workflowEntity: IWorkflowBase, + seededDataTableIds: string[] | undefined, + ): Promise> { + const live = new Set(); + if (!seededDataTableIds?.length) return live; + const seeded = new Set(seededDataTableIds); + // The node resolves a `name` locator case-insensitively at run time + // (`LOWER(name) LIKE LOWER(:name)`), so the seeded tables are matched the + // same way, or a read spelt in another case would stay pinned. + const seededByLowerName = new Map(); + for (const table of await this.dataTableService.findDataTablesByIds(seededDataTableIds)) { + seededByLowerName.set(table.name.toLowerCase(), table.id); + } + for (const node of workflowEntity.nodes) { + if (!isDataTableRead(node)) continue; + const locator = dataTableLocator(node); + if (!locator) continue; + const tableId = + locator.mode === 'name' + ? seededByLowerName.get(locator.value.toLowerCase()) + : locator.value; + if (tableId !== undefined && seeded.has(tableId)) live.add(node.name); + } + return live; + } + + /** The table a Data Table node binds, for the column shapes. `name` mode is + * looked up in the project the way the node does, case-insensitively. */ + private async resolveDataTableNodeId( + node: INode, + projectId: string, + ): Promise { + const locator = dataTableLocator(node); + if (!locator) return undefined; + if (locator.mode !== 'name') return locator.value; + const matches = await this.dataTableService.findDataTablesByNamesInProject(projectId, [ + locator.value, + ]); + const wanted = locator.value.toLowerCase(); + return (matches.find((m) => m.name.toLowerCase() === wanted) ?? matches.at(0))?.id; + } + // ── Phase 2: Mock execution ──────────────────────────────────────────── private async execute( diff --git a/packages/cli/src/modules/instance-ai/eval/thread-restore.service.ts b/packages/cli/src/modules/instance-ai/eval/thread-restore.service.ts index 3ec99f55bef..c9beebbc92c 100644 --- a/packages/cli/src/modules/instance-ai/eval/thread-restore.service.ts +++ b/packages/cli/src/modules/instance-ai/eval/thread-restore.service.ts @@ -26,7 +26,7 @@ import { } from 'n8n-workflow'; import { randomUUID } from 'node:crypto'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { AgentsService } from '@/modules/agents/agents.service'; import { DataTableService } from '@/modules/data-table/data-table.service'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; @@ -356,6 +356,7 @@ export class EvalThreadRestoreService { async restoreWorkflows( workflows: InstanceAiEvalSeedWorkflow[], projectId: string, + user: User, dataTableIdMap: Map = new Map(), allowedCredentialIds?: Set, folderIdMap: Map = new Map(), @@ -366,6 +367,7 @@ export class EvalThreadRestoreService { const isNew = await this.createWorkflowPinnedToId( workflow, projectId, + user, dataTableIdMap, allowedCredentialIds, folderIdMap, @@ -469,6 +471,7 @@ export class EvalThreadRestoreService { private async createWorkflowPinnedToId( workflow: InstanceAiEvalSeedWorkflow, projectId: string, + user: User, dataTableIdMap: Map, allowedCredentialIds: Set | undefined, folderIdMap: Map, @@ -538,7 +541,7 @@ export class EvalThreadRestoreService { versionId: randomUUID(), parentFolder: parentFolderId === null ? null : { id: parentFolderId }, }); - const cleared = await this.enforceSeedWorkflowSave(workflow, entity, stored, projectId); + const cleared = await this.enforceSeedWorkflowSave(workflow, entity, stored, projectId, user); await this.workflowRepo.runInTransaction({ policyCleared: cleared }, async (em, ctx) => { if (stored) { @@ -614,13 +617,17 @@ export class EvalThreadRestoreService { entity: WorkflowEntity, stored: PolicedWorkflow | null, projectId: string, + user: User, ): Promise> { try { - return await this.policyEnforcementService.enforceWorkflowSave({ - workflow: { id: stored?.id ?? null, name: entity.name, nodes: entity.nodes }, - storedWorkflow: stored, - projectId, - }); + return await this.policyEnforcementService.enforceWorkflowSave( + { + workflow: { id: stored?.id ?? null, name: entity.name, nodes: entity.nodes }, + storedWorkflow: stored, + projectId, + }, + { kind: 'user', user }, + ); } catch (error) { if (error instanceof PolicyViolationError && hasViolations(error.violations)) { throw new PolicyViolationError( diff --git a/packages/cli/src/modules/instance-ai/eval/workflow-analysis.ts b/packages/cli/src/modules/instance-ai/eval/workflow-analysis.ts index e023ccc3dd9..04a537e6ac9 100644 --- a/packages/cli/src/modules/instance-ai/eval/workflow-analysis.ts +++ b/packages/cli/src/modules/instance-ai/eval/workflow-analysis.ts @@ -103,7 +103,8 @@ const PROTOCOL_BINARY_SUB_NODE_TYPES = new Set([ /** Data Table row-read operations. Their output is the scenario's "stored state" — left * unpinned they read the REAL eval-instance table, polluted by the builder's own - * verification runs, so scenario outcomes become a coin flip on build-phase leftovers. */ + * verification runs, so scenario outcomes become a coin flip on build-phase leftovers. + * A table the harness reseeded for the scenario holds only its rows, so reads of it run live. */ const DATA_TABLE_READ_OPERATIONS = new Set(['get', 'rowExists', 'rowNotExists']); /** Of the read operations, only `get` emits stored rows — `rowExists`/`rowNotExists` @@ -130,10 +131,11 @@ export function emitsDataTableRows(node: INode): boolean { return DATA_TABLE_ROW_EMITTING_OPERATIONS.has(params?.operation ?? 'insert'); } -/** Returns nodes that need pin data — AI roots (unless in `exclusionSet`), bypass-protocol nodes, and Data Table reads. */ +/** Returns nodes that need pin data — AI roots (unless in `exclusionSet`), bypass-protocol nodes, and Data Table reads (unless in `liveReads`). */ export function identifyNodesForPinData( workflow: IWorkflowBase, exclusionSet?: Set, + liveReads?: Set, ): INode[] { const aiRootNodes = findAiRootNodeNames(workflow.connections); @@ -141,7 +143,7 @@ export function identifyNodesForPinData( if (node.disabled) return false; if (aiRootNodes.has(node.name) && !exclusionSet?.has(node.name)) return true; if (BYPASS_NODE_TYPES.has(node.type)) return true; - if (isDataTableRead(node)) return true; + if (isDataTableRead(node)) return !liveReads?.has(node.name); return false; }); } diff --git a/packages/cli/src/modules/instance-ai/event-bus/__tests__/durable-event-log.test.ts b/packages/cli/src/modules/instance-ai/event-bus/__tests__/durable-event-log.test.ts index 932a0fb8d1a..316927feb9c 100644 --- a/packages/cli/src/modules/instance-ai/event-bus/__tests__/durable-event-log.test.ts +++ b/packages/cli/src/modules/instance-ai/event-bus/__tests__/durable-event-log.test.ts @@ -1,10 +1,9 @@ import type { InstanceAiEvent, InstanceAiSetupItem } from '@n8n/api-types'; import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { QueryFailedError } from '@n8n/typeorm'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; - import type { InstanceAiEventLogRepository } from '../../repositories/instance-ai-event-log.repository'; import { DurableEventLog, type DrainedEvent } from '../durable-event-log'; import { DurableLogMetrics } from '../durable-log-metrics'; diff --git a/packages/cli/src/modules/instance-ai/event-bus/__tests__/interrupted-run-sweeper.test.ts b/packages/cli/src/modules/instance-ai/event-bus/__tests__/interrupted-run-sweeper.test.ts index 140d706e729..c3848c3bcd5 100644 --- a/packages/cli/src/modules/instance-ai/event-bus/__tests__/interrupted-run-sweeper.test.ts +++ b/packages/cli/src/modules/instance-ai/event-bus/__tests__/interrupted-run-sweeper.test.ts @@ -1,10 +1,9 @@ import type { InstanceAiEvent } from '@n8n/api-types'; import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { InstanceSettings } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; - import { DurableLogMetrics } from '../durable-log-metrics'; import { AGENT_INTERRUPTED_MESSAGE, diff --git a/packages/cli/src/modules/instance-ai/event-bus/durable-log-metrics.ts b/packages/cli/src/modules/instance-ai/event-bus/durable-log-metrics.ts index caca75db6b6..bd548cb2c11 100644 --- a/packages/cli/src/modules/instance-ai/event-bus/durable-log-metrics.ts +++ b/packages/cli/src/modules/instance-ai/event-bus/durable-log-metrics.ts @@ -1,7 +1,6 @@ +import { EventService } from '@n8n/backend-services'; import { Service } from '@n8n/di'; -import { EventService } from '@/events/event.service'; - /** * Durable-log instrumentation (RFC: instance-ai durable event log, * "Instrumentation"). Two consumers, one recording point: diff --git a/packages/cli/src/modules/instance-ai/instance-ai-event-relay.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-event-relay.service.ts index 5c5616f8737..12cd7207d51 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai-event-relay.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai-event-relay.service.ts @@ -1,8 +1,7 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { Service } from '@n8n/di'; -import { EventService } from '@/events/event.service'; - import { InstanceAiMemoryService } from './instance-ai-memory.service'; import { InstanceAiMcpRegistryConnectionRepository } from './repositories/instance-ai-mcp-registry-connection.repository'; diff --git a/packages/cli/src/modules/instance-ai/instance-ai-memory.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-memory.service.ts index 59769a445c2..195c67d51d6 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai-memory.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai-memory.service.ts @@ -26,9 +26,7 @@ import { type AgentTreeSnapshot, } from '@n8n/instance-ai'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; import type { InstanceAiCheckpoint } from './entities/instance-ai-checkpoint.entity'; import { DurableLogMetrics } from './event-bus/durable-log-metrics'; diff --git a/packages/cli/src/modules/instance-ai/instance-ai-pending-agent.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-pending-agent.service.ts index 4efb284cc1e..459a7afd766 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai-pending-agent.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai-pending-agent.service.ts @@ -4,8 +4,7 @@ import { Container, Service } from '@n8n/di'; import { threadAuthorizesAgentAdoption } from '@n8n/instance-ai'; import type { Scope } from '@n8n/permissions'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import { AgentDefaultModelResolverService } from '@/modules/agents/agent-default-model-resolver.service'; import { AgentRunnableStateService } from '@/modules/agents/agent-runnable-state.service'; import { AgentsService } from '@/modules/agents/agents.service'; diff --git a/packages/cli/src/modules/instance-ai/instance-ai-run-limit.error.ts b/packages/cli/src/modules/instance-ai/instance-ai-run-limit.error.ts index 023d428155d..51614439321 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai-run-limit.error.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai-run-limit.error.ts @@ -1,6 +1,6 @@ import type { InstanceAiRunLimitMeta } from '@n8n/api-types'; -import { TooManyRequestsError } from '@/errors/response-errors/too-many-requests.error'; +import { TooManyRequestsError } from '@n8n/errors'; /** * A new user turn was refused because a concurrency cap was already full. diff --git a/packages/cli/src/modules/instance-ai/instance-ai-settings.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-settings.service.ts index b70483cdf9c..d6c539118ec 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai-settings.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai-settings.service.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { isDeepStrictEqual } from 'node:util'; import { @@ -42,10 +43,7 @@ import { type InstanceCredentialUse, type ResolvedInstanceCredential, } from '@/credentials/instance-credential-broker'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; -import { EventService } from '@/events/event.service'; +import { ConflictError, ForbiddenError, UnprocessableRequestError } from '@n8n/errors'; import { AiService } from '@/services/ai.service'; import { INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY, diff --git a/packages/cli/src/modules/instance-ai/instance-ai-setup-telemetry.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-setup-telemetry.service.ts index 0079e296386..fa500f0f593 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai-setup-telemetry.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai-setup-telemetry.service.ts @@ -6,6 +6,7 @@ import { type InstanceAiWebSearchSource, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import type { InstanceAiConfig, DeploymentConfig } from '@n8n/config'; import { SettingsRepository } from '@n8n/db'; @@ -15,7 +16,6 @@ import { ensureError } from '@n8n/utils/errors/ensure-error'; import { InstanceCredentialBroker } from '@/credentials/instance-credential-broker'; import type { InstanceCredentialUse } from '@/credentials/instance-credential-broker'; -import { EventService } from '@/events/event.service'; import { Telemetry } from '@/telemetry'; import { diff --git a/packages/cli/src/modules/instance-ai/instance-ai-test.controller.ts b/packages/cli/src/modules/instance-ai/instance-ai-test.controller.ts index b1f75f37eb6..107770cc418 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai-test.controller.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai-test.controller.ts @@ -3,7 +3,7 @@ import { Body, Delete, Get, Param, Post, RestController } from '@n8n/decorators' import type { Request, Response } from 'express'; import { v4 as uuidv4 } from 'uuid'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { InstanceAiMemoryService } from './instance-ai-memory.service'; import { InstanceAiService } from './instance-ai.service'; diff --git a/packages/cli/src/modules/instance-ai/instance-ai-thread-tabs.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-thread-tabs.service.ts new file mode 100644 index 00000000000..c71b05330ad --- /dev/null +++ b/packages/cli/src/modules/instance-ai/instance-ai-thread-tabs.service.ts @@ -0,0 +1,36 @@ +import { instanceAiThreadTabsStateSchema, type InstanceAiThreadTabsState } from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; + +import { InstanceAiThreadTabsRepository } from './repositories/instance-ai-thread-tabs.repository'; + +/** Saves and loads the tabs that each user has open in a thread. */ +@Service() +export class InstanceAiThreadTabsService { + constructor( + private readonly logger: Logger, + private readonly threadTabsRepository: InstanceAiThreadTabsRepository, + ) {} + + async getState(threadId: string, userId: string): Promise { + const stored = await this.threadTabsRepository.findState(threadId, userId); + if (stored === null) return null; + + // A state saved by an older schema must not break the tab bar. The client + // falls back to the default tabs and saves a valid state on the next change. + const parsed = instanceAiThreadTabsStateSchema.safeParse(stored); + if (!parsed.success) { + this.logger.warn('Ignoring invalid stored Instance AI thread tabs', { threadId }); + return null; + } + return parsed.data; + } + + async saveState( + threadId: string, + userId: string, + state: InstanceAiThreadTabsState, + ): Promise { + await this.threadTabsRepository.saveState(threadId, userId, state); + } +} diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index 5e32ce679d5..f764ea90fe6 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -26,6 +26,7 @@ import { import type { AiGatewayConfigDto } from '@n8n/api-types'; import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common'; import { OutboundHttp } from '@n8n/backend-network'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import { Time, TOOL_EXECUTOR_NODE_NAME } from '@n8n/constants'; import type { User, ExecutionSummaries, EvaluationConfig } from '@n8n/db'; @@ -153,12 +154,9 @@ import { CollaborationService } from '@/collaboration/collaboration.service'; import { CredentialsOverwrites } from '@/credentials-overwrites'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { CredentialsService } from '@/credentials/credentials.service'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { LockedError } from '@/errors/response-errors/locked.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ConflictError, LockedError, NotFoundError } from '@n8n/errors'; import { EvaluationConfigService } from '@/evaluation.ee/evaluation-config.service'; import { LlmJudgeProviderRegistry } from '@/evaluation.ee/llm-judge-provider-registry'; -import { EventService } from '@/events/event.service'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import { License } from '@/license'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; @@ -1672,11 +1670,14 @@ export class InstanceAiAdapterService { // The shell has no nodes, so the real content check is the `update()` below. // This call is still needed: `createContent` refuses to write without a clearance. - const cleared = await policyEnforcementService.enforceWorkflowSave({ - workflow: { id: null, name: newWorkflow.name, nodes: newWorkflow.nodes }, - storedWorkflow: null, - projectId, - }); + const cleared = await policyEnforcementService.enforceWorkflowSave( + { + workflow: { id: null, name: newWorkflow.name, nodes: newWorkflow.nodes }, + storedWorkflow: null, + projectId, + }, + { kind: 'user', user }, + ); const saved = await workflowRepository.runInTransaction( { policyCleared: cleared }, diff --git a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts index da7d3692ff7..904734a4353 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts @@ -5,6 +5,7 @@ import { InstanceAiGatewayCreateCredentialDto, InstanceAiFilesystemResponseDto, InstanceAiRenameThreadRequestDto, + InstanceAiThreadTabsRequestDto, InstanceAiPreferenceCardEditRequestDto, InstanceAiPreferenceCardUndoRequestDto, InstanceAiSendMessageRequest, @@ -32,6 +33,7 @@ import type { InstanceAiAdminSettingsResponse, InstanceAiEvalThreadMemoryResponse, InstanceAiEvent, + InstanceAiThreadTabsResponse, } from '@n8n/api-types'; import { ModuleRegistry } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; @@ -81,15 +83,13 @@ import { InstanceAiModelCatalogService } from './instance-ai-model-catalog.servi import { InstanceAiPendingAgentService } from './instance-ai-pending-agent.service'; import { InstanceAiPreferenceCardService } from './instance-ai-preference-card.service'; import { InstanceAiSettingsService } from './instance-ai-settings.service'; +import { InstanceAiThreadTabsService } from './instance-ai-thread-tabs.service'; import { InstanceAiVerificationService } from './instance-ai-verification.service'; import { InstanceAiService } from './instance-ai.service'; import { InstanceAiOnboardingService, startsOnboardingFirstTurn } from './onboarding'; import { CredentialsService } from '@/credentials/credentials.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '@n8n/errors'; import { Push } from '@/push'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { ProjectService } from '@/services/project.service.ee'; @@ -129,6 +129,7 @@ export class InstanceAiController { private readonly publisher: Publisher, private readonly preferenceCardService: InstanceAiPreferenceCardService, globalConfig: GlobalConfig, + private readonly threadTabsService: InstanceAiThreadTabsService, ) { this.gatewayApiKey = globalConfig.instanceAi.gatewayApiKey; } @@ -988,6 +989,34 @@ export class InstanceAiController { return { thread }; } + @Get('/threads/:threadId/tabs') + @GlobalScope('instanceAi:message') + async getThreadTabs( + req: AuthenticatedRequest, + _res: Response, + @Param('threadId') threadId: string, + ): Promise { + this.requireInstanceAiEnabled(); + await this.assertThreadAccess(req.user.id, threadId); + return { state: await this.threadTabsService.getState(threadId, req.user.id) }; + } + + @Put('/threads/:threadId/tabs') + @GlobalScope('instanceAi:message') + async saveThreadTabs( + req: AuthenticatedRequest, + _res: Response, + @Param('threadId') threadId: string, + @Body payload: InstanceAiThreadTabsRequestDto, + ): Promise { + this.requireInstanceAiEnabled(); + await this.assertThreadAccess(req.user.id, threadId); + // The DTO strips unknown keys, so the payload is the state to save. + const state = { ...payload }; + await this.threadTabsService.saveState(threadId, req.user.id, state); + return { state }; + } + /** * Persist the pending new-agent artifact this thread has open, and bind it to * the thread in the same request. Idempotent under a concurrent writer on the @@ -1271,6 +1300,7 @@ export class InstanceAiController { createdWorkflowIds = await this.evalThreadRestore.restoreWorkflows( workflows, projectId, + req.user, idMap, allowedCredentialIds ? new Set(allowedCredentialIds) : undefined, folderIdMap, diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index f2afe6b4da7..2304a3f51d4 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -39,6 +39,7 @@ import { } from '@n8n/api-types'; import { Logger, ModuleRegistry } from '@n8n/backend-common'; import { SsrfProtectionService } from '@n8n/backend-network'; +import { EventService, UrlService } from '@n8n/backend-services'; import { GlobalConfig, SsrfProtectionConfig, @@ -152,9 +153,7 @@ import { OperationalError, UnexpectedError, UserError } from 'n8n-workflow'; import { nanoid } from 'nanoid'; import { N8N_VERSION, WORKFLOW_SDK_VERSION } from '@/constants'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { InstanceAiBuilderDelegateAdapterService } from '@/modules/agents/instance-ai-builder-delegate.adapter'; import { InstanceAiAgentContextAdapterService } from '@/modules/agents/instance-ai-agent-context.adapter'; import { modelStreamStallOptions } from '@/modules/agents/model-stream-stall-options'; @@ -171,7 +170,6 @@ import { import { AiService } from '@/services/ai.service'; import { InstanceWriteAccessService } from '@/services/instance-write-access.service'; import { ProxyTokenManager } from '@/services/proxy-token-manager'; -import { UrlService } from '@n8n/backend-services'; import { Telemetry } from '@/telemetry'; import { assertNever } from '@/utils'; diff --git a/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-connection.controller.test.ts b/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-connection.controller.test.ts index 2bd9b7c3467..f69b2a25a8a 100644 --- a/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-connection.controller.test.ts +++ b/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-connection.controller.test.ts @@ -2,7 +2,7 @@ import type { AuthenticatedRequest, CredentialsEntity, User } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service'; import type { McpRegistryServer } from '@/modules/mcp-registry/registry/mcp-registry.types'; diff --git a/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts b/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts index 39360e6f445..5e2bd995765 100644 --- a/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts +++ b/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts @@ -1,6 +1,7 @@ import type { BuiltTool } from '@n8n/agents'; import type { Logger } from '@n8n/backend-common'; import type { CustomFetch, HttpTransport, OutboundHttp } from '@n8n/backend-network'; +import type { EventService } from '@n8n/backend-services'; import type { CredentialsEntity, User } from '@n8n/db'; import { QueryFailedError } from '@n8n/typeorm'; import { mock } from 'vitest-mock-extended'; @@ -8,10 +9,7 @@ import { mock } from 'vitest-mock-extended'; import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import type { CredentialsService } from '@/credentials/credentials.service'; import type { CredentialTypes } from '@/credential-types'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { EventService } from '@/events/event.service'; +import { BadRequestError, ConflictError, NotFoundError } from '@n8n/errors'; import type { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service'; import type { McpRegistryServer } from '@/modules/mcp-registry/registry/mcp-registry.types'; import type { OauthService } from '@/oauth/oauth.service'; diff --git a/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-connection.controller.ts b/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-connection.controller.ts index e9dbd34adac..9e7da7a869a 100644 --- a/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-connection.controller.ts +++ b/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-connection.controller.ts @@ -20,7 +20,7 @@ import { import type { Response } from 'express'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service'; import type { McpRegistryServer } from '@/modules/mcp-registry/registry/mcp-registry.types'; diff --git a/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts b/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts index 2b3bb93eaa2..5e40d1d61ed 100644 --- a/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts +++ b/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts @@ -8,6 +8,7 @@ import type { import { isObjectLiteral, Logger } from '@n8n/backend-common'; import type { CustomFetch } from '@n8n/backend-network'; import { OutboundHttp } from '@n8n/backend-network'; +import { EventService } from '@n8n/backend-services'; import { isUniqueConstraintError, type CredentialsEntity, type User } from '@n8n/db'; import { Service } from '@n8n/di'; import type { McpServerConfig } from '@n8n/instance-ai'; @@ -18,10 +19,7 @@ import { randomUUID } from 'node:crypto'; import { CredentialTypes } from '@/credential-types'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { CredentialsService } from '@/credentials/credentials.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ConflictError, NotFoundError } from '@n8n/errors'; import { isSupportedMcpRegistryCredentialType, prepareMcpRegistryConnection, diff --git a/packages/cli/src/modules/instance-ai/onboarding-opening.ts b/packages/cli/src/modules/instance-ai/onboarding-opening.ts index 4ed8d7e87ee..e86011dcb99 100644 --- a/packages/cli/src/modules/instance-ai/onboarding-opening.ts +++ b/packages/cli/src/modules/instance-ai/onboarding-opening.ts @@ -6,8 +6,8 @@ interface OnboardingOpening { title: string; /** * First assistant message. Markdown: a blank line starts a paragraph, and the chat shows the - * paragraphs one after the other before the card. `{{firstName}}` becomes the user's first - * name, or "there". + * paragraphs one after the other before the card. A new paragraph needs its own animation rule + * in `InstanceAiConversation.vue`. `{{firstName}}` becomes the user's first name, or "there". */ greeting: string; /** @@ -33,7 +33,7 @@ export const ONBOARDING_OPENING: OnboardingOpening = { greeting: [ "Hi {{firstName}}, I'm your Assistant.", 'Think of me as your n8n expert.', - "Two final questions, then I'll suggest automations that fit how you work.", + 'Two final questions, so I can suggest automations for you.', ].join('\n\n'), followUp: 'Got it. Finally, tell me a little about how you use {{apps}}.', questions: [ diff --git a/packages/cli/src/modules/instance-ai/onboarding.ts b/packages/cli/src/modules/instance-ai/onboarding.ts index 2a66f962295..46e2bbb8832 100644 --- a/packages/cli/src/modules/instance-ai/onboarding.ts +++ b/packages/cli/src/modules/instance-ai/onboarding.ts @@ -17,7 +17,7 @@ import { nanoid } from 'nanoid'; import { randomUUID } from 'node:crypto'; import { z } from 'zod'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { Telemetry } from '@/telemetry'; import { DurableEventLog } from './event-bus/durable-event-log'; diff --git a/packages/cli/src/modules/instance-ai/storage/typeorm-agent-memory.ts b/packages/cli/src/modules/instance-ai/storage/typeorm-agent-memory.ts index 9af030203aa..47dcf58cb4a 100644 --- a/packages/cli/src/modules/instance-ai/storage/typeorm-agent-memory.ts +++ b/packages/cli/src/modules/instance-ai/storage/typeorm-agent-memory.ts @@ -31,8 +31,7 @@ import { In, LessThan, Like } from '@n8n/typeorm'; import { UnexpectedError } from 'n8n-workflow'; import { z } from 'zod'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ConflictError, ForbiddenError } from '@n8n/errors'; import { TypeORMObservationLogStore } from './typeorm-observation-log-store'; import type { InstanceAiMessage } from '../entities/instance-ai-message.entity'; diff --git a/packages/cli/src/modules/instance-registry/__tests__/stale-member-cleanup.task.integration.test.ts b/packages/cli/src/modules/instance-registry/__tests__/stale-member-cleanup.task.integration.test.ts index 6f8f6c00c58..ca163d7e0ca 100644 --- a/packages/cli/src/modules/instance-registry/__tests__/stale-member-cleanup.task.integration.test.ts +++ b/packages/cli/src/modules/instance-registry/__tests__/stale-member-cleanup.task.integration.test.ts @@ -6,7 +6,7 @@ import { Redis } from 'ioredis'; import type { InstanceSettings } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import type { RedisClientService } from '@/services/redis-client.service'; +import type { RedisClientService } from '@n8n/backend-services'; import { InstanceRegistryService } from '../instance-registry.service'; import { REDIS_KEY_PATTERNS } from '../instance-registry.types'; diff --git a/packages/cli/src/modules/instance-registry/storage/__tests__/redis-instance-storage.test.ts b/packages/cli/src/modules/instance-registry/storage/__tests__/redis-instance-storage.test.ts index 0adf63d7eb6..0024a9ba2fd 100644 --- a/packages/cli/src/modules/instance-registry/storage/__tests__/redis-instance-storage.test.ts +++ b/packages/cli/src/modules/instance-registry/storage/__tests__/redis-instance-storage.test.ts @@ -5,7 +5,7 @@ import type { Redis as SingleNodeClient } from 'ioredis'; import { jsonStringify } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import type { RedisClientService } from '@/services/redis-client.service'; +import type { RedisClientService } from '@n8n/backend-services'; import { REDIS_KEY_PATTERNS, REGISTRY_CONSTANTS } from '../../instance-registry.types'; import { diff --git a/packages/cli/src/modules/instance-registry/storage/redis-instance-storage.ts b/packages/cli/src/modules/instance-registry/storage/redis-instance-storage.ts index ef5272355c8..500ccb35c3d 100644 --- a/packages/cli/src/modules/instance-registry/storage/redis-instance-storage.ts +++ b/packages/cli/src/modules/instance-registry/storage/redis-instance-storage.ts @@ -6,7 +6,7 @@ import type { Cluster, Redis } from 'ioredis'; import { ensureError } from '@n8n/utils/errors/ensure-error'; import { jsonParse, jsonStringify } from 'n8n-workflow'; -import { RedisClientService } from '@/services/redis-client.service'; +import { RedisClientService } from '@n8n/backend-services'; import { REDIS_KEY_PATTERNS, REGISTRY_CONSTANTS } from '../instance-registry.types'; import type { InstanceStorage } from './instance-storage.interface'; diff --git a/packages/cli/src/modules/instance-reporting.ee/LOCAL_VERIFICATION.md b/packages/cli/src/modules/instance-reporting.ee/LOCAL_VERIFICATION.md index 58160100308..13464d8b83d 100644 --- a/packages/cli/src/modules/instance-reporting.ee/LOCAL_VERIFICATION.md +++ b/packages/cli/src/modules/instance-reporting.ee/LOCAL_VERIFICATION.md @@ -27,9 +27,8 @@ the test. - Keep the receiver's request log visible. You must see the raw body. - `sqlite3` is installed. The dev instance uses SQLite at `~/.n8n/database.sqlite` unless you set another database. -- An n8n owner account exists on the instance (the report reads insights as the - instance owner). Complete the setup screen first if this is a fresh - `~/.n8n`. +- An n8n owner account exists on the instance, so you can build the workflows + in step 2. Complete the setup screen first if this is a fresh `~/.n8n`. ```bash export N8N_DB=~/.n8n/database.sqlite # used by the snippets below @@ -161,6 +160,13 @@ with: Confirm the numbers match what you recorded in step 2, and that `date` is yesterday's UTC date, not today's. +"Exactly two entries" holds when `insights_by_period` has no rows older than +yesterday, as on a fresh `~/.n8n`. The first report backfills the `insights` +history. With an older database, it gives one `daily` point for every day from +the oldest row to yesterday, oldest first, but for at most 89 days (the +hour-to-day compaction threshold minus one). Days without rows inside that +range carry `0`. + **n8n side.** ```bash diff --git a/packages/cli/src/modules/instance-reporting.ee/README.md b/packages/cli/src/modules/instance-reporting.ee/README.md index 18fcc082b98..9d9677f749a 100644 --- a/packages/cli/src/modules/instance-reporting.ee/README.md +++ b/packages/cli/src/modules/instance-reporting.ee/README.md @@ -14,13 +14,41 @@ rather than taking fresh numbers — the cumulative total's day-to-day diff is only meaningful while every sample sits a fixed 24 hours apart. A report that follows downtime carries one `daily` point for each day the -instance missed, up to 30 days, and one `cumulative` point as always. Across a -gap the `daily` series is the authoritative one: the two cumulative samples -around the gap sit more than 24 hours apart, so their difference covers the -whole outage. A missed day with no executions is reported as `0`, so a gap in -the series always means "not reported", never "nothing ran". A gap longer than -30 days is unrecoverable, since `insights` buckets a longer range by week; the -oldest days are dropped and logged. +instance missed, and one `cumulative` point as always. Across a gap the `daily` +series is the authoritative one: the two cumulative samples around the gap sit +more than 24 hours apart, so their difference covers the whole outage. A missed +day with no executions is reported as `0`, so a gap in the series always means +"not reported", never "nothing ran". The one exception: missed days before the +first `insights` data are not reported at all. + +The first report works the same way: it carries a `daily` point for every day +from the first `insights` data to yesterday. It carries no past `cumulative` +values, since those are unknown. Days before the first `insights` data are not +sent. A report reads only hourly `insights` data, because only hourly rows +split exactly into UTC days. `insights` folds hours older than 90 days (by +default) into daily rows, so a report never carries a day older than 89 days. +Inside the sent range, a day without data is `0`. +[RETRIES.md](./RETRIES.md#type-2-missed-day-backfill) gives the exact rules. + +Known limits: + +- If `insights` was disabled for a time between its first data and the first + report, those days are sent as `0`. +- After this instance's database is restored from a backup, the days since the + backup are sent again, as `0` or as a lower value. Consumers of the receiver's + export must use the highest value for each instance, metric and day. + `insights` stores no row for a day without executions, so a day lost in the + restore looks the same as a day when nothing ran. If `insights` stored an + explicit `0` for each day it ran, the lost days could be left out, and only + the backup day would be sent again. +- On Postgres, `insights` compacts in the session's time zone. When that time + zone is not UTC, two cases put executions on the wrong UTC day: + - Daily rows inside the sent range. They occur only if + `N8N_INSIGHTS_COMPACTION_HOURLY_TO_DAILY_THRESHOLD_DAYS` was lower before. + Each daily row holds a local day, and the report puts it on one UTC day. + - A time zone with a half-hour offset, such as `Asia/Kolkata`. Hourly rows + then start at 30 minutes past the UTC hour, so the row that holds UTC + midnight counts on one day only. **A day is reported once, and 201 is what decides it.** The receiver answers 201 only once it has saved the report, so anything else means nothing was saved and @@ -171,6 +199,3 @@ last accepted a report, or `null` when it never did: ``` The route exists whenever the module is loaded, including without a receiver — the client decides whether to ask by reading `enabled` from the client settings. - -See [.agents/specs/central-instance-monitoring.md](../../../../../.agents/specs/central-instance-monitoring.md) -for the full design. diff --git a/packages/cli/src/modules/instance-reporting.ee/RETRIES.md b/packages/cli/src/modules/instance-reporting.ee/RETRIES.md index f405e27a48a..c16cf007d23 100644 --- a/packages/cli/src/modules/instance-reporting.ee/RETRIES.md +++ b/packages/cli/src/modules/instance-reporting.ee/RETRIES.md @@ -76,16 +76,47 @@ Scope: no `pending` row is the newest, but one or more calendar days have no - `InstanceMonitoringReportRepository.findLastCoveredDay()` counts only `delivered` rows. A day whose row was skipped, or a day the instance was down - for, is still owed. -- `InstanceReportingService.missedDays()` walks back from yesterday to the day - after the last delivered day. It stops at `MAX_BACKFILL_DAYS` (30) because - `insights` buckets a longer range by week, which cannot fill a daily point. - Older days are dropped. + for, is still owed. Skipped rows never shorten the window: only a delivered + row moves its start. +- `InstanceReportingService.missedDays()` returns every day from the first + owed day to yesterday. Yesterday is always included. +- The first owed day is the day after the last delivered day, but never + before the first `insights` data (`InsightsService.getEarliestDataDate()`). + Before the first delivered report, it is that first day. + - A report never carries a day older than + `N8N_INSIGHTS_COMPACTION_HOURLY_TO_DAILY_THRESHOLD_DAYS` minus one: 89 + days with the default settings. Compaction folds older hours into daily + rows. On Postgres, a daily row holds a day in the session's time zone, so + it has no exact value for a UTC day. The day of margin covers Postgres, + which also dates the threshold in the session's time zone. + - Days before the first data are not reported, not even as `0`. Inside the + window, a day without data is reported as `0`. `insights` writes rows only + for executions and never stores a `0`, so a day without executions and a + day without collected data look the same. + - Without any data, the report carries only yesterday, so a new instance + still shows up on the receiver. +- The first report is no special case. It backfills the history that `insights` + holds instead of sending yesterday alone. +- With the default settings, a full report of 89 daily points comes to less + than 20 KB, far below the receiver's size limit. If the receiver + still rejects a report with `413`, the row is skipped. The next report + carries the same days, so the receiver can reject it again. +- `collectDataPoints()` reads the whole window from `insights` in one query, + bucketed by UTC day. `InsightsService.getInsightsByTime()` is not used, + because it buckets a range of more than 30 days by week. The read does not + apply the license's `insights` history limit. Only the `insights` dashboard + applies that limit. - The next report creates one new row. It carries one `daily` point for every missed day and one `cumulative` point. The cumulative point is a fresh lifetime total, not one per missed day. - That new row is then subject to type 1 if its own delivery fails. +Example: the module is enabled, but the receiver cannot be reached for three +days. Each day's report fails three times and is skipped. No row is delivered, +so each new report starts again at the first day with `insights` data. When the +receiver is reachable again, the next delivered report holds every day, from +that first day to yesterday. + Type 2 is the catch-up layer. It reacts to type 1 giving up, and to any other gap in delivered rows. @@ -108,7 +139,7 @@ flowchart TD G --> I["Day stays uncovered\n(not delivered)"] subgraph T2["Type 2: missed-day backfill"] - J["Next cycle: newest row not pending"] --> K["missedDays() = every day after\nlast DELIVERED day, up to 30"] + J["Next cycle: newest row not pending"] --> K["missedDays() = every day after\nlast DELIVERED day (or since first\ninsights data), exact days only"] K --> L{"any missed days?"} L -- no --> M["nothing to send"] L -- yes --> N["new row: one daily point per\nmissed day + 1 cumulative point"] diff --git a/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting-scheduler.service.test.ts b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting-scheduler.service.test.ts index 77d798e45fa..cf972f6439b 100644 --- a/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting-scheduler.service.test.ts +++ b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting-scheduler.service.test.ts @@ -1,11 +1,10 @@ +import type { EventService } from '@n8n/backend-services'; import { mockLogger } from '@n8n/backend-test-utils'; import { Time } from '@n8n/constants'; import type { InstanceSettings } from 'n8n-core'; import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; - import type { InstanceMonitoringReport } from '../database/entities/instance-monitoring-report'; import type { InstanceMonitoringReportRepository } from '../database/repositories/instance-monitoring-report.repository'; import { InstanceReportingScheduler } from '../instance-reporting-scheduler.service'; diff --git a/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.integration.test.ts b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.integration.test.ts index eddc894cf71..ae7cf5b6a02 100644 --- a/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.integration.test.ts +++ b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.integration.test.ts @@ -1,4 +1,5 @@ import { createFakeOutboundHttp, type Route } from '@n8n/backend-network/testing'; +import { EventService } from '@n8n/backend-services'; import { createTeamProject, createWorkflow, @@ -14,12 +15,10 @@ import { InstanceSettings } from 'n8n-core'; import type { MockInstance } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { EventService } from '@/events/event.service'; import type { License } from '@/license'; import { createCompactedInsightsEvent } from '@/modules/insights/database/entities/__tests__/db-utils'; +import { InsightsConfig } from '@/modules/insights/insights.config'; import { InsightsService } from '@/modules/insights/insights.service'; -import { OwnershipService } from '@/services/ownership.service'; -import { createOwner } from '@test-integration/db/users'; import type { InstanceMonitoringReport, @@ -102,13 +101,11 @@ describe('instance reporting retries', () => { await testDb.truncate([ 'InstanceMonitoringReport', 'Settings', - 'User', 'InsightsByPeriod', 'InsightsMetadata', 'WorkflowEntity', 'Project', ]); - await createOwner(); await Container.get(SettingsRepository).upsertByKey( CENTRAL_INSTANCE_MONITORING_SETTINGS_KEY, JSON.stringify({ reportTime: REPORT_TIME }), @@ -145,8 +142,8 @@ describe('instance reporting retries', () => { Container.get(InstanceReportingConfig), repository, Container.get(InsightsService), + Container.get(InsightsConfig), instanceSettings, - Container.get(OwnershipService), Container.get(LicenseMetricsRepository), mock({ loadCertStr: async () => 'license-cert' }), mockLogger(), @@ -216,13 +213,21 @@ describe('instance reporting retries', () => { } async function seedDailyExecutions(totalsByDay: Record) { + await seedCompactedExecutions('day', totalsByDay); + } + + /** Executions as insights holds them after compaction, keyed by period start. */ + async function seedCompactedExecutions( + periodUnit: 'hour' | 'day' | 'week', + totalsByPeriodStart: Record, + ) { const workflow = await createWorkflow({}, await createTeamProject()); - for (const [day, value] of Object.entries(totalsByDay)) { + for (const [periodStart, value] of Object.entries(totalsByPeriodStart)) { await createCompactedInsightsEvent(workflow, { type: 'success', value, - periodUnit: 'day', - periodStart: DateTime.fromISO(day, { zone: 'utc' }), + periodUnit, + periodStart: DateTime.fromISO(periodStart, { zone: 'utc' }), }); } } @@ -411,9 +416,9 @@ describe('instance reporting retries', () => { await expect(repository.findLastCoveredDay()).resolves.toBe('2026-03-25'); }); - test('caps a long gap at 30 days and still reads every day on its own', async () => { + test('backfills a gap longer than 30 days and still reads every day on its own', async () => { await seedDeliveredReport('2026-01-01'); - await seedDailyExecutions({ '2026-02-24': 6, '2026-03-25': 8 }); + await seedDailyExecutions({ '2026-01-10': 4, '2026-02-24': 6, '2026-03-25': 8 }); const harness = makeHarness([accepted()]); harness.scheduler.start(); @@ -421,11 +426,122 @@ describe('instance reporting retries', () => { expect(harness.httpRequest).toHaveBeenCalledTimes(1); const points = dailyPoints(sentPayload(harness, 0)); - expect(points).toHaveLength(30); - // Exact days, not weekly buckets: a 30-day range still reads as days. - expect(points.at(0)).toEqual({ date: '2026-02-24', value: 6 }); + // Every day from the first data on 01-10 to 03-25. Inside that range, a day + // without data is a real 0. + expect(points).toHaveLength(75); + expect(points.at(0)).toEqual({ date: '2026-01-10', value: 4 }); + // Exact days, not weekly buckets, although the gap is longer than 30 days. + expect(points.find((point) => point.date === '2026-02-24')).toEqual({ + date: '2026-02-24', + value: 6, + }); expect(points.at(-1)).toEqual({ date: '2026-03-25', value: 8 }); - expect(points.slice(1, -1).every((point) => point.value === 0)).toBe(true); + expect(points.filter((point) => point.value !== 0)).toHaveLength(3); + }); + + test('carries the insights history on the first report, but no day older than the hourly compaction threshold', async () => { + // With a threshold of 30 days, compaction folded every hour before 02-24 + // into daily rows, and older days into weekly rows. + const insightsConfig = Container.get(InsightsConfig); + const { compactionHourlyToDailyThresholdDays } = insightsConfig; + insightsConfig.compactionHourlyToDailyThresholdDays = 30; + onTestFinished(() => { + insightsConfig.compactionHourlyToDailyThresholdDays = compactionHourlyToDailyThresholdDays; + }); + + await seedCompactedExecutions('week', { '2026-01-19': 700 }); + await seedCompactedExecutions('day', { '2026-02-20': 5, '2026-02-23': 6 }); + await seedCompactedExecutions('hour', { + '2026-02-24T10:00:00': 7, + '2026-02-25T00:00:00': 8, + '2026-03-10T08:00:00': 2, + '2026-03-10T15:00:00': 3, + '2026-03-25T23:00:00': 4, + }); + + const harness = makeHarness([accepted()]); + harness.scheduler.start(); + await armed(harness, 1); + + const points = dailyPoints(sentPayload(harness, 0)); + // From 29 days back to yesterday. 02-24 falls in the one day of margin. + expect(points).toHaveLength(29); + expect(points.at(0)).toEqual({ date: '2026-02-25', value: 8 }); + expect(points.filter((point) => point.value !== 0)).toEqual([ + { date: '2026-02-25', value: 8 }, + { date: '2026-03-10', value: 5 }, + { date: '2026-03-25', value: 4 }, + ]); + }); + + test('carries every day since the first data once the receiver is reachable after skipped first reports', async () => { + await seedDailyExecutions({ + '2026-03-20': 1, + '2026-03-21': 2, + '2026-03-22': 3, + '2026-03-23': 4, + '2026-03-24': 5, + '2026-03-25': 6, + '2026-03-26': 7, + '2026-03-27': 8, + '2026-03-28': 9, + }); + + // The receiver is unreachable for three days, then accepts. + const harness = makeHarness([ + ...Array.from({ length: 3 * MAX_ATTEMPTS }, unreachable), + accepted(), + ]); + harness.scheduler.start(); + + let passes = 0; + for (const nextSlot of [ + '2026-03-27T07:42:00.000Z', + '2026-03-28T07:42:00.000Z', + '2026-03-29T07:42:00.000Z', + ]) { + await armed(harness, ++passes); + for (let attempt = 1; attempt < MAX_ATTEMPTS; attempt++) { + await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS); + await armed(harness, ++passes); + } + + const [skipped] = await repository.find({ + where: { status: 'skipped_after_max_retries' }, + order: { reportDate: 'DESC' }, + take: 1, + }); + expect(skipped.attempts).toBe(MAX_ATTEMPTS); + + await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS); + await armed(harness, ++passes); + expectArmedFor(harness, nextSlot); + await vi.advanceTimersByTimeAsync(new Date(nextSlot).getTime() - Date.now()); + } + await armed(harness, ++passes); + + expect(harness.httpRequest).toHaveBeenCalledTimes(3 * MAX_ATTEMPTS + 1); + // Every report started at the first day with data, not at its own yesterday. + for (const index of [0, MAX_ATTEMPTS, 2 * MAX_ATTEMPTS]) { + expect(dailyPoints(sentPayload(harness, index)).at(0)?.date).toBe('2026-03-20'); + } + + const delivered = sentPayload(harness, 3 * MAX_ATTEMPTS); + expect(dailyPoints(delivered)).toEqual([ + { date: '2026-03-20', value: 1 }, + { date: '2026-03-21', value: 2 }, + { date: '2026-03-22', value: 3 }, + { date: '2026-03-23', value: 4 }, + { date: '2026-03-24', value: 5 }, + { date: '2026-03-25', value: 6 }, + { date: '2026-03-26', value: 7 }, + { date: '2026-03-27', value: 8 }, + { date: '2026-03-28', value: 9 }, + ]); + await expect(repository.findOneByOrFail({ id: delivered.batchId })).resolves.toMatchObject({ + status: 'delivered', + }); + await expect(repository.findLastCoveredDay()).resolves.toBe('2026-03-28'); }); test('resumes the retry budget and the remaining wait after a restart', async () => { @@ -707,8 +823,8 @@ describe('instance reporting retries', () => { // Another process inserts today's report after this one found nothing pending. const insights = Container.get(InsightsService); - const measure = insights.getInsightsByTime.bind(insights); - vi.spyOn(insights, 'getInsightsByTime').mockImplementation(async (args) => { + const measure = insights.getDailyExecutionTotals.bind(insights); + vi.spyOn(insights, 'getDailyExecutionTotals').mockImplementation(async (args) => { other = await createPendingOn(new Date(), otherPoints); return await measure(args); }); diff --git a/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.service.test.ts b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.service.test.ts index f900357e43b..698b054c0d3 100644 --- a/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.service.test.ts +++ b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.service.test.ts @@ -1,20 +1,19 @@ -import type { InsightsByTime } from '@n8n/api-types'; +import type { EventService } from '@n8n/backend-services'; import { mockLogger } from '@n8n/backend-test-utils'; import type { HttpRequestClient, HttpRequestClientOptions, OutboundHttp, } from '@n8n/backend-network'; -import type { LicenseMetricsRepository, User } from '@n8n/db'; +import type { LicenseMetricsRepository } from '@n8n/db'; import type { InstanceSettings } from 'n8n-core'; import type { IHttpRequestOptions } from 'n8n-workflow'; import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; import type { License } from '@/license'; +import { InsightsConfig } from '@/modules/insights/insights.config'; import type { InsightsService } from '@/modules/insights/insights.service'; -import type { OwnershipService } from '@/services/ownership.service'; import type { InstanceMonitoringReport } from '../database/entities/instance-monitoring-report'; import type { InstanceMonitoringReportRepository } from '../database/repositories/instance-monitoring-report.repository'; @@ -43,8 +42,6 @@ const BATCH_ID = 'batch-id-1'; /** Opaque to this module: whatever `License.loadCertStr()` returns is sent as is. */ const LICENSE_CERT = 'base64-license-cert'; -const OWNER_MOCK = mock({ id: 'owner-id' }); - const LICENSE_METRICS_MOCK = { enabledUsers: 1, totalUsers: 1, @@ -57,23 +54,11 @@ const LICENSE_METRICS_MOCK = { evaluations: 0, }; -function byTime(totalsByDay: Record): InsightsByTime[] { - return Object.entries(totalsByDay).map(([date, total]) => ({ - date: `${date}T00:00:00.000Z`, - values: { - total, - succeeded: 0, - failed: 0, - failureRate: 0, - averageRunTime: 0, - timeSaved: 0, - }, - })); +/** Daily execution totals as insights returns them, keyed by `YYYY-MM-DD`. */ +function totalsByDay(totals: Record): Map { + return new Map(Object.entries(totals)); } -/** One `getInsightsByTime` row: the reported day held 42 executions. */ -const BY_TIME_MOCK = byTime({ [REPORT_DATE]: 42 }); - function makeConfig(overrides: Partial = {}): InstanceReportingConfig { const config = new InstanceReportingConfig(); config.instanceReportingBaseUrl = 'https://example.com'; @@ -98,6 +83,7 @@ interface Harness { service: InstanceReportingService; reportRepository: Mocked; insightsService: Mocked; + insightsConfig: InsightsConfig; license: Mocked; http: HttpRequestClient; eventService: Mocked; @@ -115,10 +101,12 @@ function makeHarness(config: InstanceReportingConfig = makeConfig()): Harness { ); const insightsService = mock(); - insightsService.getInsightsByTime.mockResolvedValue(BY_TIME_MOCK); + // The reported day held 42 executions. + insightsService.getDailyExecutionTotals.mockResolvedValue(totalsByDay({ [REPORT_DATE]: 42 })); + // No insights data: an instance that has not compacted anything yet. + insightsService.getEarliestDataDate.mockResolvedValue(null); - const ownershipService = mock(); - ownershipService.getInstanceOwner.mockResolvedValue(OWNER_MOCK); + const insightsConfig = new InsightsConfig(); const licenseMetricsRepository = mock(); licenseMetricsRepository.getLicenseRenewalMetrics.mockResolvedValue(LICENSE_METRICS_MOCK); @@ -143,8 +131,8 @@ function makeHarness(config: InstanceReportingConfig = makeConfig()): Harness { config, reportRepository, insightsService, + insightsConfig, mock({ instanceId: 'abc123' }), - ownershipService, licenseMetricsRepository, license, mockLogger(), @@ -152,7 +140,16 @@ function makeHarness(config: InstanceReportingConfig = makeConfig()): Harness { outboundHttp, ); - return { service, reportRepository, insightsService, license, http, eventService, clientOptions }; + return { + service, + reportRepository, + insightsService, + insightsConfig, + license, + http, + eventService, + clientOptions, + }; } describe('InstanceReportingService', () => { @@ -282,7 +279,7 @@ describe('InstanceReportingService', () => { await expect(service.sendReport()).resolves.toBeUndefined(); expect(http.request).not.toHaveBeenCalled(); - expect(insightsService.getInsightsByTime).not.toHaveBeenCalled(); + expect(insightsService.getDailyExecutionTotals).not.toHaveBeenCalled(); expect(reportRepository.createPending).not.toHaveBeenCalled(); }); @@ -315,16 +312,14 @@ describe('InstanceReportingService', () => { }); }); - test('queries the instance owner insights for the reported UTC day', async () => { + test('reads the daily execution totals for the reported UTC day', async () => { const { service, insightsService } = makeHarness(); await service.sendReport(); - expect(insightsService.getInsightsByTime).toHaveBeenCalledWith({ - user: OWNER_MOCK, + expect(insightsService.getDailyExecutionTotals).toHaveBeenCalledWith({ startDate: new Date('2026-03-25T00:00:00.000Z'), - endDate: new Date('2026-03-26T00:00:00.000Z'), - timeZone: 'UTC', + endDate: new Date('2026-03-25T00:00:00.000Z'), }); }); @@ -482,7 +477,7 @@ describe('InstanceReportingService', () => { // Re-measuring would sample the cumulative total at a different point in // the day and stretch its interval past 24 hours. expect(body(http).dataPoints).toEqual(measured); - expect(insightsService.getInsightsByTime).not.toHaveBeenCalled(); + expect(insightsService.getDailyExecutionTotals).not.toHaveBeenCalled(); expect(reportRepository.createPending).not.toHaveBeenCalled(); }); }); @@ -556,8 +551,8 @@ describe('InstanceReportingService', () => { test('carries a daily point for every day since the last delivered report', async () => { const { service, reportRepository, insightsService, http } = makeHarness(); reportRepository.findLastCoveredDay.mockResolvedValue('2026-03-22'); - insightsService.getInsightsByTime.mockResolvedValue( - byTime({ '2026-03-23': 5, '2026-03-24': 7, '2026-03-25': 9 }), + insightsService.getDailyExecutionTotals.mockResolvedValue( + totalsByDay({ '2026-03-23': 5, '2026-03-24': 7, '2026-03-25': 9 }), ); await service.sendReport(); @@ -568,12 +563,10 @@ describe('InstanceReportingService', () => { { value: 9, date: '2026-03-25' }, ]); // One range query covers the gap, and the cumulative point stays single. - expect(insightsService.getInsightsByTime).toHaveBeenCalledWith( - expect.objectContaining({ - startDate: new Date('2026-03-23T00:00:00.000Z'), - endDate: new Date('2026-03-26T00:00:00.000Z'), - }), - ); + expect(insightsService.getDailyExecutionTotals).toHaveBeenCalledWith({ + startDate: new Date('2026-03-23T00:00:00.000Z'), + endDate: new Date('2026-03-25T00:00:00.000Z'), + }); expect(points(http).filter((point) => point.kind === 'cumulative')).toHaveLength(1); }); @@ -581,7 +574,7 @@ describe('InstanceReportingService', () => { const { service, reportRepository, insightsService, http } = makeHarness(); reportRepository.findLastCoveredDay.mockResolvedValue('2026-03-23'); // Insights returns no row for a day that saw nothing. - insightsService.getInsightsByTime.mockResolvedValue(byTime({ '2026-03-25': 9 })); + insightsService.getDailyExecutionTotals.mockResolvedValue(totalsByDay({ '2026-03-25': 9 })); await service.sendReport(); @@ -591,29 +584,164 @@ describe('InstanceReportingService', () => { ]); }); - test('reports yesterday alone on the first ever report, importing no history', async () => { - const { service, reportRepository, insightsService, http } = makeHarness(); - reportRepository.findLastCoveredDay.mockResolvedValue(null); + describe('on the first report', () => { + test('carries every day of insights history, oldest first, and one cumulative point', async () => { + const { service, reportRepository, insightsService, http } = makeHarness(); + reportRepository.findLastCoveredDay.mockResolvedValue(null); + insightsService.getEarliestDataDate.mockResolvedValue(new Date('2026-01-01T00:00:00.000Z')); - await service.sendReport(); + await service.sendReport(); - expect(dailyPoints(http)).toEqual([{ value: 42, date: REPORT_DATE }]); - expect(insightsService.getInsightsByTime).toHaveBeenCalledWith( - expect.objectContaining({ startDate: new Date('2026-03-25T00:00:00.000Z') }), - ); + const daily = dailyPoints(http); + expect(daily).toHaveLength(84); + expect(daily.at(0)?.date).toBe('2026-01-01'); + expect(daily.at(-1)).toEqual({ value: 42, date: REPORT_DATE }); + expect(daily.map(({ date }) => date)).toEqual(daily.map(({ date }) => date).sort()); + expect(points(http).filter((point) => point.kind === 'cumulative')).toEqual([ + { kind: 'cumulative', name: 'billableExecutions', value: 815 }, + ]); + }); + + test('starts at the first day with data and sends no zeros before it', async () => { + const { service, reportRepository, insightsService, http } = makeHarness(); + reportRepository.findLastCoveredDay.mockResolvedValue(null); + insightsService.getEarliestDataDate.mockResolvedValue(new Date('2026-03-20T00:00:00.000Z')); + insightsService.getDailyExecutionTotals.mockResolvedValue( + totalsByDay({ '2026-03-20': 4, [REPORT_DATE]: 42 }), + ); + + await service.sendReport(); + + // Inside the window, a day without data saw no executions. + expect(dailyPoints(http)).toEqual([ + { value: 4, date: '2026-03-20' }, + { value: 0, date: '2026-03-21' }, + { value: 0, date: '2026-03-22' }, + { value: 0, date: '2026-03-23' }, + { value: 0, date: '2026-03-24' }, + { value: 42, date: REPORT_DATE }, + ]); + }); + + test('reports yesterday as 0 on a new instance without insights data', async () => { + const { service, reportRepository, insightsService, http } = makeHarness(); + reportRepository.findLastCoveredDay.mockResolvedValue(null); + insightsService.getDailyExecutionTotals.mockResolvedValue(new Map()); + + await service.sendReport(); + + expect(points(http)).toEqual([ + { kind: 'cumulative', name: 'billableExecutions', value: 815 }, + { kind: 'daily', name: 'billableExecutions', value: 0, date: REPORT_DATE }, + ]); + }); + + test('reports yesterday as 0 when the first insights data is from today', async () => { + const { service, reportRepository, insightsService, http } = makeHarness(); + reportRepository.findLastCoveredDay.mockResolvedValue(null); + insightsService.getEarliestDataDate.mockResolvedValue(new Date('2026-03-26T00:00:00.000Z')); + insightsService.getDailyExecutionTotals.mockResolvedValue(new Map()); + + await service.sendReport(); + + expect(dailyPoints(http)).toEqual([{ value: 0, date: REPORT_DATE }]); + }); + + test('still starts at the first day with data after earlier reports were skipped', async () => { + const { service, reportRepository, insightsService, http } = makeHarness(); + // Skipped reports never count as delivered, so no day is covered yet. + reportRepository.findPending.mockResolvedValue(null); + reportRepository.findLastCoveredDay.mockResolvedValue(null); + insightsService.getEarliestDataDate.mockResolvedValue(new Date('2026-03-22T00:00:00.000Z')); + + await service.sendReport(); + + expect(dailyPoints(http).map(({ date }) => date)).toEqual([ + '2026-03-22', + '2026-03-23', + '2026-03-24', + REPORT_DATE, + ]); + }); + + test('resends a pending first report as measured, without reading the history again', async () => { + const { service, reportRepository, insightsService, http } = makeHarness(); + reportRepository.findLastCoveredDay.mockResolvedValue(null); + const measured = [ + { kind: 'cumulative', name: 'billableExecutions', value: 800 }, + { kind: 'daily', name: 'billableExecutions', value: 3, date: '2026-03-20' }, + { kind: 'daily', name: 'billableExecutions', value: 40, date: REPORT_DATE }, + ] as InstanceMonitoringReport['dataPoints']; + reportRepository.findPending.mockResolvedValue( + makeReport({ dataPoints: measured, attempts: 1 }), + ); + + await service.sendReport(); + + expect(points(http)).toEqual(measured); + expect(insightsService.getEarliestDataDate).not.toHaveBeenCalled(); + expect(insightsService.getDailyExecutionTotals).not.toHaveBeenCalled(); + }); }); - test('drops the oldest days when the gap is longer than insights can bucket by day', async () => { - const { service, reportRepository, http } = makeHarness(); + test('backfills a gap longer than 30 days, reporting days without data as 0', async () => { + const { service, reportRepository, insightsService, http } = makeHarness(); reportRepository.findLastCoveredDay.mockResolvedValue('2026-01-01'); + insightsService.getEarliestDataDate.mockResolvedValue(new Date('2025-10-01T00:00:00.000Z')); await service.sendReport(); - const points = dailyPoints(http); - expect(points).toHaveLength(30); - // Still ends at yesterday, so the gap is not retried tomorrow. - expect(points.at(0)?.date).toBe('2026-02-24'); - expect(points.at(-1)?.date).toBe(REPORT_DATE); + const daily = dailyPoints(http); + expect(daily).toHaveLength(83); + expect(daily.at(0)).toEqual({ value: 0, date: '2026-01-02' }); + expect(daily.at(-1)).toEqual({ value: 42, date: REPORT_DATE }); + }); + + test('starts a gap no earlier than the first insights data', async () => { + const { service, reportRepository, insightsService, http } = makeHarness(); + reportRepository.findLastCoveredDay.mockResolvedValue('2025-12-30'); + insightsService.getEarliestDataDate.mockResolvedValue(new Date('2026-01-10T00:00:00.000Z')); + + await service.sendReport(); + + expect(dailyPoints(http).at(0)).toEqual({ value: 0, date: '2026-01-10' }); + }); + + test('reads the whole window in one day-bucketed read', async () => { + const { service, reportRepository, insightsService, http } = makeHarness(); + reportRepository.findLastCoveredDay.mockResolvedValue(null); + insightsService.getEarliestDataDate.mockResolvedValue(new Date('2026-01-01T00:00:00.000Z')); + + await service.sendReport(); + + expect(insightsService.getDailyExecutionTotals).toHaveBeenCalledTimes(1); + expect(insightsService.getDailyExecutionTotals).toHaveBeenCalledWith({ + startDate: new Date('2026-01-01T00:00:00.000Z'), + endDate: new Date('2026-03-25T00:00:00.000Z'), + }); + expect(dailyPoints(http)).toHaveLength(84); + }); + + test('carries no day older than the hourly compaction threshold, minus one day of margin', async () => { + const { service, reportRepository, insightsService, insightsConfig, http } = makeHarness(); + insightsConfig.compactionHourlyToDailyThresholdDays = 30; + reportRepository.findLastCoveredDay.mockResolvedValue(null); + insightsService.getEarliestDataDate.mockResolvedValue(new Date('2023-01-01T00:00:00.000Z')); + + await service.sendReport(); + + const daily = dailyPoints(http); + expect(daily).toHaveLength(29); + expect(daily.at(0)?.date).toBe('2026-02-25'); + expect(daily.at(-1)?.date).toBe(REPORT_DATE); + }); + + test('does not read the insights history when only yesterday is owed', async () => { + const { service, insightsService } = makeHarness(); + + await service.sendReport(); + + expect(insightsService.getEarliestDataDate).not.toHaveBeenCalled(); }); test('sends nothing when yesterday is already reported', async () => { diff --git a/packages/cli/src/modules/instance-reporting.ee/instance-reporting-scheduler.service.ts b/packages/cli/src/modules/instance-reporting.ee/instance-reporting-scheduler.service.ts index 92e75fcaf25..34cee517665 100644 --- a/packages/cli/src/modules/instance-reporting.ee/instance-reporting-scheduler.service.ts +++ b/packages/cli/src/modules/instance-reporting.ee/instance-reporting-scheduler.service.ts @@ -1,12 +1,11 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { Time } from '@n8n/constants'; import { OnLeaderStepdown, OnLeaderTakeover, OnShutdown } from '@n8n/decorators'; import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; import { strict } from 'node:assert'; -import { EventService } from '@/events/event.service'; - import type { InstanceMonitoringReport } from './database/entities/instance-monitoring-report'; import { InstanceMonitoringReportRepository } from './database/repositories/instance-monitoring-report.repository'; import { InstanceReportingSettingsService } from './instance-reporting-settings.service'; diff --git a/packages/cli/src/modules/instance-reporting.ee/instance-reporting.service.ts b/packages/cli/src/modules/instance-reporting.ee/instance-reporting.service.ts index 94e465c56f7..45878bcd9fc 100644 --- a/packages/cli/src/modules/instance-reporting.ee/instance-reporting.service.ts +++ b/packages/cli/src/modules/instance-reporting.ee/instance-reporting.service.ts @@ -1,6 +1,7 @@ import { Logger } from '@n8n/backend-common'; import type { HttpRequestClient } from '@n8n/backend-network'; import { OutboundHttp } from '@n8n/backend-network'; +import { EventService } from '@n8n/backend-services'; import { Time } from '@n8n/constants'; import { LicenseMetricsRepository } from '@n8n/db'; import { Service } from '@n8n/di'; @@ -9,10 +10,9 @@ import { InstanceSettings } from 'n8n-core'; import { OperationalError } from 'n8n-workflow'; import { N8N_VERSION } from '@/constants'; -import { EventService } from '@/events/event.service'; import { License } from '@/license'; +import { InsightsConfig } from '@/modules/insights/insights.config'; import { InsightsService } from '@/modules/insights/insights.service'; -import { OwnershipService } from '@/services/ownership.service'; import type { InstanceReportDataPoint } from './database/entities/instance-monitoring-report'; import { InstanceMonitoringReportRepository } from './database/repositories/instance-monitoring-report.repository'; @@ -53,16 +53,6 @@ const SKIP_MESSAGES: Record = { rejected: 'Giving up on the instance report because the receiver rejected its payload', }; -/** - * How many missed days one report may carry. - * - * Insights buckets a range of more than 30 days by week, which cannot fill a - * daily point, so a longer gap is unrecoverable. The oldest days are dropped and - * the report still ends at yesterday, rather than the instance retrying a window - * it can never read. - */ -const MAX_BACKFILL_DAYS = 30; - /** * Measures and delivers one instance report. *When* that happens is * {@link InstanceReportingScheduler}'s concern. @@ -75,8 +65,8 @@ export class InstanceReportingService { private readonly config: InstanceReportingConfig, private readonly reportRepository: InstanceMonitoringReportRepository, private readonly insightsService: InsightsService, + private readonly insightsConfig: InsightsConfig, private readonly instanceSettings: InstanceSettings, - private readonly ownershipService: OwnershipService, private readonly licenseMetricsRepository: LicenseMetricsRepository, private readonly license: License, private readonly logger: Logger, @@ -152,12 +142,6 @@ export class InstanceReportingService { const days = await this.missedDays(now); if (days.length === 0) return; - if (days.length > 1) { - this.logger.info('Reporting days missed since the last delivered instance report', { - days, - }); - } - report = await this.reportRepository.createPending(await this.collectDataPoints(days), now); if (!report) { throw new InstanceReportAlreadyCreatedError(); @@ -243,25 +227,60 @@ export class InstanceReportingService { * The UTC days this report must carry a daily point for, oldest first, ending * yesterday. Empty when yesterday is already reported. * - * A day the instance was down for is still unreported once it comes back, so - * the whole gap since the last delivered report is collected. Without that, - * downtime silently loses days from the daily series. + * Every day after the last delivered one is still owed, whatever happened to + * the reports in between, so neither downtime nor skipped reports lose days + * from the daily series. A first report carries the history insights holds. */ private async missedDays(now: Date): Promise { + const yesterday = utcDayBefore(now, 1); const lastCoveredDay = await this.reportRepository.findLastCoveredDay(); - const days: string[] = []; + if (lastCoveredDay && lastCoveredDay >= yesterday) return []; - // No last covered day means this is the first report: send yesterday alone - // rather than importing however much history insights happens to hold. - for (let back = 1; back <= (lastCoveredDay ? MAX_BACKFILL_DAYS : 1); back++) { - const day = utcDayBefore(now, back); - if (lastCoveredDay && day <= lastCoveredDay) break; - days.unshift(day); + const firstDay = minDay(await this.firstOwedDay(lastCoveredDay, yesterday), yesterday); + // Only hourly rows split exactly into UTC days: Postgres compacts older hours + // into days of the session's time zone, which need not be UTC. One day of + // margin, since Postgres also dates this threshold in that time zone. + const oldestAllowedDay = utcDayBefore( + now, + Math.max(1, this.insightsConfig.compactionHourlyToDailyThresholdDays - 1), + ); + + const days: string[] = []; + for (let day = maxDay(firstDay, oldestAllowedDay); day <= yesterday; day = addUtcDays(day, 1)) { + days.push(day); + } + + if (days.length > 1) { + this.logger.info( + lastCoveredDay + ? 'Reporting days missed since the last delivered instance report' + : 'Reporting the insights history, since no instance report was delivered yet', + { firstDay: days[0], lastDay: days.at(-1), count: days.length }, + ); } return days; } + /** + * The oldest day the next report owes: the day after the last delivered one, + * but never before the first insights data, since nothing before it shows + * that insights was collecting. + */ + private async firstOwedDay(lastCoveredDay: string | null, yesterday: string): Promise { + const dayAfterCovered = lastCoveredDay ? addUtcDays(lastCoveredDay, 1) : null; + + // Only yesterday is owed, which is the everyday case: skip the history read. + if (dayAfterCovered === yesterday) return yesterday; + + const earliest = await this.insightsService.getEarliestDataDate(); + const dataStart = earliest ? earliest.toISOString().slice(0, 10) : null; + + // Without any data, a first report still carries yesterday, so that a new + // instance shows up on the receiver. + return maxDay(dayAfterCovered ?? dataStart ?? yesterday, dataStart); + } + /** * One cumulative point plus one daily point for every day in `days`. * @@ -270,31 +289,16 @@ export class InstanceReportingService { * cumulative ones. */ private async collectDataPoints(days: string[]): Promise { - const startDate = new Date(`${days[0]}T00:00:00.000Z`); - const endDate = new Date( - new Date(`${days.at(-1)}T00:00:00.000Z`).getTime() + Time.days.toMilliseconds, - ); - - // Report instance-wide numbers, so read as the instance owner, whose global - // role grants access to every workflow. - const owner = await this.ownershipService.getInstanceOwner(); - - const [byDay, { productionRootExecutions }] = await Promise.all([ - // Per day rather than one total for the range, since a report may cover - // several days. A range of 1 to 30 days buckets by day; see MAX_BACKFILL_DAYS. - this.insightsService.getInsightsByTime({ - user: owner, - startDate, - endDate, - timeZone: 'UTC', + const [totals, { productionRootExecutions }] = await Promise.all([ + this.insightsService.getDailyExecutionTotals({ + startDate: new Date(`${days[0]}T00:00:00.000Z`), + endDate: new Date(`${days.at(-1)}T00:00:00.000Z`), }), // Same source as the `productionRootExecutions` license metric, so the // reported total matches what the license server sees. this.licenseMetricsRepository.getLicenseRenewalMetrics(), ]); - const totals = new Map(byDay.map((row) => [row.date.slice(0, 10), row.values.total ?? 0])); - return [ { kind: 'cumulative', name: 'billableExecutions', value: productionRootExecutions }, // A day with no executions returns no row, so fill it in as 0. Left out, @@ -313,3 +317,18 @@ export class InstanceReportingService { function utcDayBefore(instant: Date, count: number): string { return new Date(instant.getTime() - count * Time.days.toMilliseconds).toISOString().slice(0, 10); } + +/** The UTC calendar day `count` days after `day`, as `YYYY-MM-DD`. */ +function addUtcDays(day: string, count: number): string { + return utcDayBefore(new Date(`${day}T00:00:00.000Z`), -count); +} + +/** The later of two `YYYY-MM-DD` days; `null` counts as no bound. */ +function maxDay(day: string, other: string | null): string { + return other && other > day ? other : day; +} + +/** The earlier of two `YYYY-MM-DD` days. */ +function minDay(day: string, other: string): string { + return other < day ? other : day; +} diff --git a/packages/cli/src/modules/ldap.ee/__tests__/ldap.controller.ee.test.ts b/packages/cli/src/modules/ldap.ee/__tests__/ldap.controller.ee.test.ts index a05aefae3a8..1d95d83b038 100644 --- a/packages/cli/src/modules/ldap.ee/__tests__/ldap.controller.ee.test.ts +++ b/packages/cli/src/modules/ldap.ee/__tests__/ldap.controller.ee.test.ts @@ -1,9 +1,8 @@ +import type { EventService } from '@n8n/backend-services'; import type { LdapConfig } from '@n8n/constants'; import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; - import { LdapController } from '../ldap.controller.ee'; import type { LdapService } from '../ldap.service.ee'; import type { LdapConfiguration } from '../types'; diff --git a/packages/cli/src/modules/ldap.ee/__tests__/ldap.service.test.ts b/packages/cli/src/modules/ldap.ee/__tests__/ldap.service.test.ts index 7c38a32deac..79d301c5444 100644 --- a/packages/cli/src/modules/ldap.ee/__tests__/ldap.service.test.ts +++ b/packages/cli/src/modules/ldap.ee/__tests__/ldap.service.test.ts @@ -1,4 +1,5 @@ import type { LicenseState } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { mockLogger, mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { LDAP_FEATURE_NAME, type LdapConfig } from '@n8n/constants'; @@ -13,7 +14,6 @@ import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; import config from '@/config'; -import type { EventService } from '@/events/event.service'; import { BINARY_AD_ATTRIBUTES } from '../constants'; import { diff --git a/packages/cli/src/modules/ldap.ee/ldap.controller.ee.ts b/packages/cli/src/modules/ldap.ee/ldap.controller.ee.ts index bbe359859d7..00a260f53c5 100644 --- a/packages/cli/src/modules/ldap.ee/ldap.controller.ee.ts +++ b/packages/cli/src/modules/ldap.ee/ldap.controller.ee.ts @@ -1,10 +1,10 @@ +import { EventService } from '@n8n/backend-services'; import type { LdapConfig } from '@n8n/constants'; import { Get, Post, Put, RestController, GlobalScope, Licensed } from '@n8n/decorators'; import pick from 'lodash/pick'; import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError } from '@n8n/errors'; import { NON_SENSIBLE_LDAP_CONFIG_PROPERTIES } from './constants'; import { getLdapSynchronizations } from './helpers.ee'; diff --git a/packages/cli/src/modules/ldap.ee/ldap.service.ee.ts b/packages/cli/src/modules/ldap.ee/ldap.service.ee.ts index 3b923dbc5c4..99af5b76c14 100644 --- a/packages/cli/src/modules/ldap.ee/ldap.service.ee.ts +++ b/packages/cli/src/modules/ldap.ee/ldap.service.ee.ts @@ -1,4 +1,5 @@ import { LicenseState, Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import type { LdapConfig } from '@n8n/constants'; import { LDAP_FEATURE_NAME } from '@n8n/constants'; @@ -13,8 +14,7 @@ import { Cipher } from 'n8n-core'; import { CREDENTIAL_BLANKING_VALUE, jsonParse, UnexpectedError } from 'n8n-workflow'; import type { ConnectionOptions } from 'tls'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError } from '@n8n/errors'; import { assertAuthenticationMethodCanBeEnabled, getCurrentAuthenticationMethod, diff --git a/packages/cli/src/modules/log-streaming.ee/__tests__/log-streaming.controller.test.ts b/packages/cli/src/modules/log-streaming.ee/__tests__/log-streaming.controller.test.ts index bf71c561197..8cae8ec197d 100644 --- a/packages/cli/src/modules/log-streaming.ee/__tests__/log-streaming.controller.test.ts +++ b/packages/cli/src/modules/log-streaming.ee/__tests__/log-streaming.controller.test.ts @@ -9,8 +9,7 @@ import type { import { mock } from 'vitest-mock-extended'; import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import type { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import type { MessageEventBusDestination } from '../destinations/message-event-bus-destination.ee'; diff --git a/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/destination-credentials-access.test.ts b/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/destination-credentials-access.test.ts index 0070f1d54bf..c4e3d129733 100644 --- a/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/destination-credentials-access.test.ts +++ b/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/destination-credentials-access.test.ts @@ -3,7 +3,7 @@ import type { MessageEventBusDestinationOptions } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { assertUserCanUseDestinationCredentials } from '../destination-credentials-access'; diff --git a/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/message-event-bus-destination-webhook.ee.test.ts b/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/message-event-bus-destination-webhook.ee.test.ts index 5d5431e7279..31df5c08f23 100644 --- a/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/message-event-bus-destination-webhook.ee.test.ts +++ b/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/message-event-bus-destination-webhook.ee.test.ts @@ -156,6 +156,8 @@ describe('MessageEventBusDestinationWebhook', () => { 'internal', undefined, false, + undefined, + { actor: { kind: 'system', reason: 'log-streaming' } }, ); expect(credentialsHelper.authenticate).toHaveBeenCalledWith( decrypted, diff --git a/packages/cli/src/modules/log-streaming.ee/destinations/destination-credentials-access.ts b/packages/cli/src/modules/log-streaming.ee/destinations/destination-credentials-access.ts index 91674143fe7..58a2f981813 100644 --- a/packages/cli/src/modules/log-streaming.ee/destinations/destination-credentials-access.ts +++ b/packages/cli/src/modules/log-streaming.ee/destinations/destination-credentials-access.ts @@ -2,7 +2,7 @@ import type { User } from '@n8n/db'; import type { MessageEventBusDestinationOptions } from 'n8n-workflow'; import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; /** * Verifies the requesting user has access to every credential referenced by a diff --git a/packages/cli/src/modules/log-streaming.ee/destinations/message-event-bus-destination-webhook.ee.ts b/packages/cli/src/modules/log-streaming.ee/destinations/message-event-bus-destination-webhook.ee.ts index 23337a9bbb1..ac71a531392 100644 --- a/packages/cli/src/modules/log-streaming.ee/destinations/message-event-bus-destination-webhook.ee.ts +++ b/packages/cli/src/modules/log-streaming.ee/destinations/message-event-bus-destination-webhook.ee.ts @@ -179,6 +179,8 @@ export class MessageEventBusDestinationWebhook 'internal', undefined, raw, + undefined, + { actor: { kind: 'system', reason: 'log-streaming' } }, ); return credentialsDecrypted; } diff --git a/packages/cli/src/modules/log-streaming.ee/log-streaming.controller.ts b/packages/cli/src/modules/log-streaming.ee/log-streaming.controller.ts index 17c50df76fc..a732de4f234 100644 --- a/packages/cli/src/modules/log-streaming.ee/log-streaming.controller.ts +++ b/packages/cli/src/modules/log-streaming.ee/log-streaming.controller.ts @@ -11,8 +11,7 @@ import { Delete, Get, GlobalScope, Licensed, Post, Query, RestController } from import type { MessageEventBusDestinationOptions } from 'n8n-workflow'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { eventNamesAll } from '@/eventbus/event-message-classes'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; diff --git a/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry-test.controller.test.ts b/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry-test.controller.test.ts index dfee86df35b..e443b128d66 100644 --- a/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry-test.controller.test.ts +++ b/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry-test.controller.test.ts @@ -1,6 +1,6 @@ import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { McpRegistryTestController } from '../mcp-registry-test.controller'; import { McpRegistryServerEntity } from '../registry/mcp-registry-server.entity'; diff --git a/packages/cli/src/modules/mcp-registry/mcp-registry-test.controller.ts b/packages/cli/src/modules/mcp-registry/mcp-registry-test.controller.ts index 1c9cae8eca7..4d81ca65e6a 100644 --- a/packages/cli/src/modules/mcp-registry/mcp-registry-test.controller.ts +++ b/packages/cli/src/modules/mcp-registry/mcp-registry-test.controller.ts @@ -1,6 +1,6 @@ import { Post, RestController } from '@n8n/decorators'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { McpRegistryServerEntity } from './registry/mcp-registry-server.entity'; import { McpRegistryServerRepository } from './registry/mcp-registry-server.repository'; 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 a1b837dbe27..7e48ae655ea 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 @@ -1,6 +1,7 @@ import { zodToJsonSchema } from '@n8n/ai-utilities/json-schema'; import { APPROVAL_RESUME_SCHEMA } from '@n8n/agents/tool'; import type { AgentJsonConfig } from '@n8n/api-types'; +import { type EventService, 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'; @@ -26,8 +27,7 @@ vi.mock('@/modules/agents/json-config/mcp-client-factory', () => ({ })); import { CredentialsService } from '@/credentials/credentials.service'; -import type { EventService } from '@/events/event.service'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ConflictError } from '@n8n/errors'; import { AgentConfigService } from '@/modules/agents/agent-config.service'; import { AgentCustomToolsService } from '@/modules/agents/agent-custom-tools.service'; import { AgentExecutionService } from '@/modules/agents/agent-execution.service'; @@ -49,6 +49,8 @@ import { AgentValidationService } from '@/modules/agents/agent-validation.servic import { AgentsService } from '@/modules/agents/agents.service'; import { AttachableWorkflowsService } from '@/modules/agents/attachable-workflows.service'; import type { Agent } from '@/modules/agents/entities/agent.entity'; +import { SlackManagedSetupService } from '@/modules/agents/integrations/platforms/slack/slack-managed-setup.service'; +import { SlackManualSetupService } from '@/modules/agents/integrations/platforms/slack/slack-manual-setup.service'; import type { NodeToolAiGatewayService } from '@/modules/agents/json-config/node-tool-ai-gateway.service'; import type { AgentTaskRepository } from '@/modules/agents/repositories/agent-task.repository'; import type { AgentRepository } from '@/modules/agents/repositories/agent.repository'; @@ -60,11 +62,11 @@ import { NodeTypes } from '@/node-types'; import { OauthService } from '@/oauth/oauth.service'; import { userHasScopes } from '@/permissions.ee/check-access'; import { ProjectScopeService } from '@/permissions.ee/project-scope.service'; -import { UrlService } from '@n8n/backend-services'; import { Telemetry } from '@/telemetry'; import { AGENT_TOOLS, TOOLS_BY_SCOPE } from '../mcp-scopes'; import { USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants'; +import { McpAgentSlackSetup } from '../tools/agents/agent-slack-setup'; import { McpAgentToolsService } from '../tools/agents/agent-tools.service'; const userHasScopesMock = userHasScopes as Mock; @@ -141,6 +143,15 @@ describe('McpAgentToolsService', () => { const outboundHttp = mockInstance(OutboundHttp); const urlService = mockInstance(UrlService); const projectScopeService = mockInstance(ProjectScopeService); + const slackManagedSetup = mockInstance(SlackManagedSetupService); + const slackManualSetup = mockInstance(SlackManualSetupService); + const slackManifest = { + settings: { + event_subscriptions: { + request_url: 'https://n8n.test/rest/projects/project-1/agents/v2/agent-1/webhooks/slack', + }, + }, + }; const service = new McpAgentToolsService( telemetry, @@ -165,6 +176,7 @@ describe('McpAgentToolsService', () => { outboundHttp, urlService, projectScopeService, + new McpAgentSlackSetup(slackManagedSetup, slackManualSetup, mockLogger()), ); let tools: Map; @@ -177,6 +189,8 @@ describe('McpAgentToolsService', () => { credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([] as never); urlService.getInstanceBaseUrl.mockReturnValue('https://n8n.test'); projectScopeService.getProjectIds.mockResolvedValue(['project-1']); + slackManagedSetup.isAppConfiguredForAgent.mockResolvedValue(false); + slackManualSetup.getManifest.mockResolvedValue({ manifest: slackManifest } as never); tools = new Map(); registerResource = vi.fn(); @@ -2081,6 +2095,22 @@ describe('McpAgentToolsService', () => { }); }); + it('tells the client to use managed setup for Slack in kind=integrations', async () => { + integrationPersistenceService.listChatIntegrations.mockReturnValue([ + { type: 'slack', label: 'Slack', icon: 'slack', credentialTypes: ['slackApi'] }, + { type: 'linear', label: 'Linear', icon: 'linear', credentialTypes: ['linearApi'] }, + ] as never); + + const result = await callTool('discover_agent_assets', { + projectId: 'project-1', + kind: 'integrations', + }); + + const [slack, linear] = result.structuredContent.data as Array>; + expect(slack.setupGuidance).toEqual(expect.stringContaining('no credentialId')); + expect(linear).not.toHaveProperty('setupGuidance'); + }); + it('lists MCP registry servers for kind=mcpServers without a query', async () => { mcpRegistryService.list.mockResolvedValue([{ name: 'github' }] as never); @@ -2499,6 +2529,441 @@ describe('McpAgentToolsService', () => { }); }); + describe('update_agent_integration Slack credential', () => { + const input = { + agentId: 'agent-1', + action: 'connect', + type: 'slack', + credentialId: 'cred-1', + }; + + beforeEach(() => { + agentsService.findByIdForUser.mockResolvedValue(agentEntity({ activeVersionId: 'v1' })); + integrationManagementService.connect.mockResolvedValue({ + integration: { type: 'slack', credentialId: 'cred-1' }, + savedAgent: agentEntity({ + activeVersionId: 'v1', + integrations: [{ type: 'slack', credentialId: 'cred-1' }], + }), + }); + }); + + it('returns the request URL and a warning for a Slack app that n8n did not create', async () => { + const result = await callTool('update_agent_integration', input); + + expect(slackManagedSetup.isAppConfiguredForAgent).toHaveBeenCalledWith( + 'cred-1', + expect.objectContaining({ id: 'agent-1' }), + user, + ); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent).toMatchObject({ + ok: true, + configured: true, + connected: true, + slackApp: { + configuredForAgent: false, + requestUrl: slackManifest.settings.event_subscriptions.request_url, + manifest: slackManifest, + }, + warning: expect.stringContaining('could not confirm'), + }); + }); + + it('reports the app as unverified when the check fails after the connect is saved', async () => { + slackManagedSetup.isAppConfiguredForAgent.mockRejectedValue(new Error('decrypt failed')); + + const result = await callTool('update_agent_integration', input); + + expect(integrationManagementService.connect).toHaveBeenCalled(); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent).toMatchObject({ + ok: true, + configured: true, + slackApp: { + configuredForAgent: false, + requestUrl: slackManifest.settings.event_subscriptions.request_url, + }, + warning: expect.stringContaining('could not confirm'), + }); + }); + + it('returns no warning for a Slack app that n8n created', async () => { + slackManagedSetup.isAppConfiguredForAgent.mockResolvedValue(true); + + const result = await callTool('update_agent_integration', input); + + expect(result.structuredContent).toMatchObject({ + ok: true, + slackApp: { configuredForAgent: true }, + }); + expect(result.structuredContent).not.toHaveProperty('warning'); + expect(slackManualSetup.getManifest).not.toHaveBeenCalled(); + }); + + it('does not describe a Slack app for other integration types', async () => { + integrationManagementService.connect.mockResolvedValue({ + integration: { type: 'linear', credentialId: 'cred-1' }, + savedAgent: agentEntity({ activeVersionId: 'v1' }), + }); + + const result = await callTool('update_agent_integration', { ...input, type: 'linear' }); + + expect(result.structuredContent).not.toHaveProperty('slackApp'); + expect(slackManagedSetup.isAppConfiguredForAgent).not.toHaveBeenCalled(); + }); + }); + + describe('update_agent_integration Slack managed setup', () => { + const input = { agentId: 'agent-1', action: 'connect', type: 'slack' }; + + beforeEach(() => { + // A fresh install stores this Agent's ID on the new bot credential. + slackManagedSetup.isAppConfiguredForAgent.mockResolvedValue(true); + }); + const managerCredential = { + id: 'manager-1', + name: 'Workspace credentials', + connected: true, + reconnectRequired: false, + workspaces: [{ id: 'T1', name: 'Acme', connected: false }], + }; + + it('reports the setup steps when this instance cannot create Slack apps', async () => { + slackManagedSetup.getSetupState.mockResolvedValue({ + managedSetupAvailable: false, + managerCredentials: [], + }); + + const result = await callTool('update_agent_integration', input); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ + ok: false, + code: 'slack_managed_setup_unavailable', + configured: false, + agentUrl: 'https://n8n.test/projects/project-1/agents/agent-1', + slackApp: { requestUrl: slackManifest.settings.event_subscriptions.request_url }, + }); + expect(integrationManagementService.connect).not.toHaveBeenCalled(); + expect(slackManagedSetup.installApp).not.toHaveBeenCalled(); + }); + + it('links to the Agent when no Slack workspace is connected', async () => { + slackManagedSetup.getSetupState.mockResolvedValue({ + managedSetupAvailable: true, + managerCredentials: [], + }); + + const result = await callTool('update_agent_integration', input); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ + ok: false, + code: 'slack_workspace_not_connected', + agentUrl: 'https://n8n.test/projects/project-1/agents/agent-1', + }); + expect(slackManagedSetup.installApp).not.toHaveBeenCalled(); + }); + + it('names the workspace credentials to reconnect when none is ready to use', async () => { + slackManagedSetup.getSetupState.mockResolvedValue({ + managedSetupAvailable: true, + managerCredentials: [ + { ...managerCredential, reconnectRequired: true }, + { ...managerCredential, id: 'manager-2', connected: false }, + { ...managerCredential, id: 'manager-3', workspaces: [] }, + ], + }); + + const result = await callTool('update_agent_integration', input); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ + ok: false, + code: 'slack_manager_reconnect_required', + agentUrl: 'https://n8n.test/projects/project-1/agents/agent-1', + managerCredentials: [ + { managerCredentialId: 'manager-1', name: 'Workspace credentials' }, + { managerCredentialId: 'manager-2', name: 'Workspace credentials' }, + { managerCredentialId: 'manager-3', name: 'Workspace credentials' }, + ], + }); + expect(slackManagedSetup.installApp).not.toHaveBeenCalled(); + }); + + it('lists the workspaces to choose from without installing anything', async () => { + slackManagedSetup.getSetupState.mockResolvedValue({ + managedSetupAvailable: true, + managerCredentials: [ + managerCredential, + { ...managerCredential, id: 'manager-2', reconnectRequired: true }, + ], + }); + + const result = await callTool('update_agent_integration', input); + + expect(slackManagedSetup.getSetupState).toHaveBeenCalledWith({ + projectId: 'project-1', + agentId: 'agent-1', + user, + }); + expect(result.structuredContent).toEqual({ + ok: true, + status: 'workspace_selection_required', + agentId: 'agent-1', + configured: false, + managerCredentials: [ + { + managerCredentialId: 'manager-1', + name: 'Workspace credentials', + workspaces: [{ workspaceId: 'T1', name: 'Acme', connected: false }], + }, + ], + nextStep: expect.any(String), + }); + expect(slackManagedSetup.installApp).not.toHaveBeenCalled(); + expect(integrationManagementService.connect).not.toHaveBeenCalled(); + expect(telemetry.track).toHaveBeenCalledWith( + USER_CALLED_MCP_TOOL_EVENT, + expect.objectContaining({ + tool_name: 'update_agent_integration', + parameters: expect.objectContaining({ setup: 'managed_discovery' }), + }), + ); + }); + + it('installs the Slack app in the chosen workspace and connects the channel', async () => { + const saved = agentEntity({ + activeVersionId: 'v1', + integrations: [{ type: 'slack', credentialId: 'bot-1' }], + }); + agentsService.findByIdForUser + .mockResolvedValueOnce(agentEntity({ activeVersionId: 'v1' })) + .mockResolvedValueOnce(saved); + slackManagedSetup.installApp.mockResolvedValue({ + status: 'connected', + appId: 'A1', + credentialId: 'bot-1', + }); + + const result = await callTool('update_agent_integration', { + ...input, + managerCredentialId: 'manager-1', + workspaceId: 'T1', + }); + + expect(userHasScopesMock).toHaveBeenCalledWith(user, ['credential:create'], false, { + projectId: 'project-1', + }); + expect(slackManagedSetup.installApp).toHaveBeenCalledWith({ + projectId: 'project-1', + agentId: 'agent-1', + user, + managerCredentialId: 'manager-1', + workspaceId: 'T1', + modifiedBy: 'mcp', + }); + expect(result.structuredContent).toEqual({ + ok: true, + agentId: 'agent-1', + integration: { type: 'slack', credentialId: 'bot-1' }, + configured: true, + connected: true, + appId: 'A1', + slackApp: { configuredForAgent: true }, + published: true, + activeVersionId: 'v1', + configHash: getAgentConfigHash({ + ...baseConfig, + integrations: [{ type: 'slack', credentialId: 'bot-1' }], + }), + }); + expect(integrationManagementService.connect).not.toHaveBeenCalled(); + }); + + it('reports an install on an unpublished Agent as configured but not connected', async () => { + agentsService.findByIdForUser.mockResolvedValue( + agentEntity({ integrations: [{ type: 'slack', credentialId: 'bot-1' }] }), + ); + slackManagedSetup.installApp.mockResolvedValue({ + status: 'connected', + appId: 'A1', + credentialId: 'bot-1', + }); + + const result = await callTool('update_agent_integration', { + ...input, + managerCredentialId: 'manager-1', + workspaceId: 'T1', + }); + + expect(result.structuredContent).toMatchObject({ + ok: true, + configured: true, + connected: false, + published: false, + }); + }); + + it('still reports a saved install when MCP access is turned off meanwhile', async () => { + agentsService.findByIdForUser + .mockResolvedValueOnce(agentEntity({ activeVersionId: 'v1' })) + .mockResolvedValueOnce(agentEntity({ activeVersionId: 'v1', availableInMCP: false })); + slackManagedSetup.installApp.mockResolvedValue({ + status: 'connected', + appId: 'A1', + credentialId: 'bot-1', + }); + + const result = await callTool('update_agent_integration', { + ...input, + managerCredentialId: 'manager-1', + workspaceId: 'T1', + }); + + expect(result.isError).toBeUndefined(); + expect(result.structuredContent).toMatchObject({ + ok: true, + configured: true, + published: true, + }); + }); + + it('still reports a saved install when the Agent is gone before the result is read', async () => { + agentsService.findByIdForUser + .mockResolvedValueOnce(agentEntity({ activeVersionId: 'v1' })) + .mockResolvedValueOnce(null); + slackManagedSetup.installApp.mockResolvedValue({ + status: 'connected', + appId: 'A1', + credentialId: 'bot-1', + }); + + const result = await callTool('update_agent_integration', { + ...input, + managerCredentialId: 'manager-1', + workspaceId: 'T1', + }); + + expect(result.structuredContent).toEqual({ + ok: true, + agentId: 'agent-1', + integration: { type: 'slack', credentialId: 'bot-1' }, + configured: true, + appId: 'A1', + slackApp: { configuredForAgent: true }, + }); + }); + + it('rejects an existing bot credential whose Slack app belongs to another Agent', async () => { + slackManagedSetup.installApp.mockResolvedValue({ + status: 'connected', + appId: 'A0', + credentialId: 'leftover-bot', + }); + slackManagedSetup.isAppConfiguredForAgent.mockResolvedValue(false); + + const result = await callTool('update_agent_integration', { + ...input, + managerCredentialId: 'manager-1', + workspaceId: 'T1', + }); + + expect(slackManagedSetup.isAppConfiguredForAgent).toHaveBeenCalledWith( + 'leftover-bot', + expect.objectContaining({ id: 'agent-1' }), + user, + ); + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ + ok: false, + code: 'slack_app_built_for_another_agent', + configured: false, + integration: { type: 'slack', credentialId: 'leftover-bot' }, + nextStep: expect.stringContaining('action=disconnect'), + }); + }); + + it('returns the install URL when Slack requires the user to approve the install', async () => { + slackManagedSetup.installApp.mockResolvedValue({ + status: 'manual_install_required', + appId: 'A1', + installUrl: 'https://slack.com/oauth/v2/authorize?state=abc', + }); + + const result = await callTool('update_agent_integration', { + ...input, + managerCredentialId: 'manager-1', + workspaceId: 'T1', + }); + + expect(result.structuredContent).toMatchObject({ + ok: true, + status: 'install_approval_required', + configured: false, + appId: 'A1', + installUrl: 'https://slack.com/oauth/v2/authorize?state=abc', + }); + }); + + it('rejects an install when the user cannot create credentials', async () => { + userHasScopesMock.mockImplementation( + async (_user: unknown, scopes: string[]) => !scopes.includes('credential:create'), + ); + + const result = await callTool('update_agent_integration', { + ...input, + managerCredentialId: 'manager-1', + workspaceId: 'T1', + }); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ + error: 'You do not have permission to create credentials in this project.', + }); + expect(slackManagedSetup.installApp).not.toHaveBeenCalled(); + }); + + it.each([ + [{ managerCredentialId: 'manager-1' }, 'Pass managerCredentialId and workspaceId together'], + [{ workspaceId: 'T1' }, 'Pass managerCredentialId and workspaceId together'], + [ + { managerCredentialId: 'manager-1', workspaceId: 'T1', credentialId: 'cred-1' }, + 'Managed Slack setup creates its own credential', + ], + [ + { managerCredentialId: 'manager-1', workspaceId: 'T1', replacesCredentialId: 'cred-0' }, + 'Managed Slack setup creates its own credential', + ], + [ + { managerCredentialId: 'manager-1', workspaceId: 'T1', settings: { a: 1 } }, + 'Managed Slack setup creates its own credential', + ], + [ + { managerCredentialId: 'manager-1', workspaceId: 'T1', type: 'linear' }, + 'managerCredentialId and workspaceId apply only to Slack', + ], + ])('rejects an invalid managed setup input %o', async (extra, message) => { + const result = await callTool('update_agent_integration', { ...input, ...extra }); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ error: expect.stringContaining(message) }); + expect(slackManagedSetup.installApp).not.toHaveBeenCalled(); + expect(integrationManagementService.connect).not.toHaveBeenCalled(); + }); + + it('requires a credential for other integration types', async () => { + const result = await callTool('update_agent_integration', { ...input, type: 'linear' }); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ + error: 'credentialId is required to connect this integration', + }); + expect(slackManagedSetup.getSetupState).not.toHaveBeenCalled(); + }); + }); + describe('update_agent_integration disconnect', () => { const input = { projectId: 'project-1', @@ -2539,6 +3004,31 @@ describe('McpAgentToolsService', () => { expect(result.structuredContent).toMatchObject({ ok: true, connected: false }); }); + it('rejects managed setup inputs', async () => { + const result = await callTool('update_agent_integration', { + ...input, + managerCredentialId: 'manager-1', + }); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ + error: 'managerCredentialId and workspaceId apply only to connect', + }); + expect(integrationManagementService.disconnect).not.toHaveBeenCalled(); + }); + + it('requires a credential', async () => { + const { credentialId: _credentialId, ...withoutCredential } = input; + + const result = await callTool('update_agent_integration', withoutCredential); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ + error: 'credentialId is required to disconnect', + }); + expect(integrationManagementService.disconnect).not.toHaveBeenCalled(); + }); + it('falls back to a plain disconnect for an unknown integration type', async () => { agentsService.findByIdForUser.mockResolvedValue(agentEntity({ integrations: [] })); diff --git a/packages/cli/src/modules/mcp/__tests__/create-workflow-from-code.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/create-workflow-from-code.tool.test.ts index 9aca092ab15..0d9a106e053 100644 --- a/packages/cli/src/modules/mcp/__tests__/create-workflow-from-code.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/create-workflow-from-code.tool.test.ts @@ -10,7 +10,7 @@ import { McpPostSaveMetricsService } from '../mcp-post-save-metrics.service'; import { createCreateWorkflowFromCodeTool } from '../tools/workflow-builder/create-workflow-from-code.tool'; import { CredentialsService } from '@/credentials/credentials.service'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { NodeTypes } from '@/node-types'; import type { AiGatewayService } from '@/services/ai-gateway.service'; import { UrlService } from '@n8n/backend-services'; diff --git a/packages/cli/src/modules/mcp/__tests__/credential-validation.test.ts b/packages/cli/src/modules/mcp/__tests__/credential-validation.test.ts index 387a3f56f13..9f6907500dd 100644 --- a/packages/cli/src/modules/mcp/__tests__/credential-validation.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/credential-validation.test.ts @@ -4,7 +4,7 @@ import type { INode, INodeTypeDescription, INodeCredentialDescription } from 'n8 import { validateWorkflowCredentialReferences } from '../tools/workflow-builder/credential-validation'; import type { CredentialsService } from '@/credentials/credentials.service'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { NodeTypes } from '@/node-types'; const user = { id: 'user-1' } as User; diff --git a/packages/cli/src/modules/mcp/__tests__/install-community-node.registration.test.ts b/packages/cli/src/modules/mcp/__tests__/install-community-node.registration.test.ts index 6e1bc5ae37a..b67a72f3823 100644 --- a/packages/cli/src/modules/mcp/__tests__/install-community-node.registration.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/install-community-node.registration.test.ts @@ -1,4 +1,5 @@ import { LicenseState, ModuleRegistry } from '@n8n/backend-common'; +import { EventService, UrlService } from '@n8n/backend-services'; import { mockInstance, mockLogger } from '@n8n/backend-test-utils'; import { ExecutionsConfig, GlobalConfig, WorkflowsConfig } from '@n8n/config'; import { ExecutionRepository, ProjectRepository, SharedWorkflowRepository, User } from '@n8n/db'; @@ -7,7 +8,6 @@ import { InstanceSettings } from 'n8n-core'; import { ActiveExecutions } from '@/active-executions'; import { CollaborationService } from '@/collaboration/collaboration.service'; import { CredentialsService } from '@/credentials/credentials.service'; -import { EventService } from '@/events/event.service'; import { ExecutionService } from '@/executions/execution.service'; import { ExecutionListService } from '@/executions/execution-list.service'; import { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks/subworkflow-policy-checker'; @@ -23,7 +23,6 @@ import { NodeResourceExplorerService } from '@/services/node-resource-explorer.s import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; import { TagService } from '@/services/tag.service'; -import { UrlService } from '@n8n/backend-services'; import { Telemetry } from '@/telemetry'; import { WorkflowRunner } from '@/workflow-runner'; import { WorkflowCreationService } from '@/workflows/workflow-creation.service'; diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-post-save-metrics.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-post-save-metrics.service.test.ts index 55e08b19cc4..7a704284ba4 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-post-save-metrics.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-post-save-metrics.service.test.ts @@ -1,10 +1,9 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { PrometheusMetricsConfig } from '@n8n/config'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; - import { McpPostSaveMetricsService } from '../mcp-post-save-metrics.service'; describe('McpPostSaveMetricsService', () => { diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-scopes.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-scopes.test.ts index 123ca6d7970..587a6f26ba4 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-scopes.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-scopes.test.ts @@ -1,5 +1,6 @@ import { INSTANCE_ACTIVITY_CONTEXT_FLAG } from '@n8n/api-types'; import { LicenseState, ModuleRegistry } from '@n8n/backend-common'; +import { EventService, UrlService } from '@n8n/backend-services'; import { mockInstance, mockLogger } from '@n8n/backend-test-utils'; import { EndpointsConfig, ExecutionsConfig, GlobalConfig, WorkflowsConfig } from '@n8n/config'; import { @@ -28,7 +29,6 @@ import type { McpFeatureFlags } from '../mcp.service'; import { ActiveExecutions } from '@/active-executions'; import { CollaborationService } from '@/collaboration/collaboration.service'; import { CredentialsService } from '@/credentials/credentials.service'; -import { EventService } from '@/events/event.service'; import { ExecutionListService } from '@/executions/execution-list.service'; import { ExecutionService } from '@/executions/execution.service'; import { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks/subworkflow-policy-checker'; @@ -51,7 +51,6 @@ import { NodeResourceExplorerService } from '@/services/node-resource-explorer.s import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; import { TagService } from '@/services/tag.service'; -import { UrlService } from '@n8n/backend-services'; import { Telemetry } from '@/telemetry'; import { WorkflowRunner } from '@/workflow-runner'; import { WorkflowCreationService } from '@/workflows/workflow-creation.service'; diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts index f509d01e116..c67a218eefc 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts @@ -9,6 +9,7 @@ import { INSTANCE_ACTIVITY_CONTEXT_FLAG, } from '@n8n/api-types'; import { LicenseState, ModuleRegistry, type Logger } from '@n8n/backend-common'; +import { EventService, UrlService } from '@n8n/backend-services'; import { mockInstance, mockLogger } from '@n8n/backend-test-utils'; import { EndpointsConfig, ExecutionsConfig, GlobalConfig, WorkflowsConfig } from '@n8n/config'; import { @@ -28,7 +29,6 @@ import { McpPostSaveMetricsService } from '../mcp-post-save-metrics.service'; import { ActiveExecutions } from '@/active-executions'; import { CollaborationService } from '@/collaboration/collaboration.service'; import { CredentialsService } from '@/credentials/credentials.service'; -import { EventService } from '@/events/event.service'; import { ExecutionListService } from '@/executions/execution-list.service'; import { ExecutionService } from '@/executions/execution.service'; import { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks/subworkflow-policy-checker'; @@ -44,7 +44,6 @@ import { NodeResourceExplorerService } from '@/services/node-resource-explorer.s import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; import { TagService } from '@/services/tag.service'; -import { UrlService } from '@n8n/backend-services'; import { Telemetry } from '@/telemetry'; import { WorkflowRunner } from '@/workflow-runner'; import { WorkflowCreationService } from '@/workflows/workflow-creation.service'; diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts index b4dad64a84d..aa6e7c62d5e 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts @@ -1,12 +1,12 @@ import { Logger, ModuleRegistry } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { InstanceSettingsLoaderConfig } from '@n8n/config'; import { type ApiKey, type AuthenticatedRequest, User, Role } from '@n8n/db'; import { Container } from '@n8n/di'; import type { Response } from 'express'; import { mock, mockDeep } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { ForbiddenError } from '@n8n/errors'; import type { ListQuery } from '@/requests'; import { WorkflowService } from '@/workflows/workflow.service'; diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.settings.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.settings.service.test.ts index 266a6d0b2d0..6e010914030 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.settings.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.settings.service.test.ts @@ -8,8 +8,8 @@ import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; import type { CollaborationService } from '@/collaboration/collaboration.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import type { CacheService } from '@/services/cache/cache.service'; +import { BadRequestError } from '@n8n/errors'; +import type { CacheService } from '@n8n/backend-services'; import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { UpdateWorkflowsAvailabilityDto } from '../dto/update-workflows-availability.dto'; diff --git a/packages/cli/src/modules/mcp/__tests__/save-user-preference.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/save-user-preference.tool.test.ts index a98341d8f7f..1d5c48e523d 100644 --- a/packages/cli/src/modules/mcp/__tests__/save-user-preference.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/save-user-preference.tool.test.ts @@ -7,9 +7,7 @@ import { CLIENT_CAPABILITIES_META_KEY } from '@modelcontextprotocol/server'; import type { InputRequiredResult } from '@modelcontextprotocol/server'; import { AiPreferenceScopeFullError } from '@/errors/response-errors/ai-preference-scope-full.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ConflictError, ForbiddenError } from '@n8n/errors'; import { AiPreferenceService } from '@/services/ai-preference.service'; import { UrlService } from '@n8n/backend-services'; import { Telemetry } from '@/telemetry'; diff --git a/packages/cli/src/modules/mcp/__tests__/undo-user-preference.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/undo-user-preference.tool.test.ts index 346dea77e84..87e657cd5f6 100644 --- a/packages/cli/src/modules/mcp/__tests__/undo-user-preference.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/undo-user-preference.tool.test.ts @@ -3,8 +3,7 @@ import { mockInstance, mockLogger } from '@n8n/backend-test-utils'; import { User } from '@n8n/db'; import { TELEMETRY_EVENT } from '@n8n/telemetry'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import { AiPreferenceService } from '@/services/ai-preference.service'; import { Telemetry } from '@/telemetry'; diff --git a/packages/cli/src/modules/mcp/__tests__/update-user-preference.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/update-user-preference.tool.test.ts index ded8ba474ac..ae193f84560 100644 --- a/packages/cli/src/modules/mcp/__tests__/update-user-preference.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/update-user-preference.tool.test.ts @@ -4,8 +4,7 @@ import { mockInstance, mockLogger } from '@n8n/backend-test-utils'; import { User } from '@n8n/db'; import { TELEMETRY_EVENT } from '@n8n/telemetry'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ConflictError, NotFoundError } from '@n8n/errors'; import { AiPreferenceService } from '@/services/ai-preference.service'; import { UrlService } from '@n8n/backend-services'; import { Telemetry } from '@/telemetry'; diff --git a/packages/cli/src/modules/mcp/__tests__/update-workflow.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/update-workflow.tool.test.ts index 7da17f941e6..309e3f1324a 100644 --- a/packages/cli/src/modules/mcp/__tests__/update-workflow.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/update-workflow.tool.test.ts @@ -18,7 +18,7 @@ import { NON_FATAL_OPERATION_TYPES } from '../tools/workflow-builder/workflow-op import { CollaborationService } from '@/collaboration/collaboration.service'; import { CredentialsService } from '@/credentials/credentials.service'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { SubworkflowPolicyDenialError } from '@/errors/subworkflow-policy-denial.error'; import { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks/subworkflow-policy-checker'; import { NodeTypes } from '@/node-types'; diff --git a/packages/cli/src/modules/mcp/mcp-post-save-metrics.service.ts b/packages/cli/src/modules/mcp/mcp-post-save-metrics.service.ts index ba2978176a8..3362689d04a 100644 --- a/packages/cli/src/modules/mcp/mcp-post-save-metrics.service.ts +++ b/packages/cli/src/modules/mcp/mcp-post-save-metrics.service.ts @@ -1,9 +1,8 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { PrometheusMetricsConfig } from '@n8n/config'; import { Service } from '@n8n/di'; -import { EventService } from '@/events/event.service'; - @Service() export class McpPostSaveMetricsService { constructor( diff --git a/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts b/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts index e38247e7b34..3688e459a49 100644 --- a/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts +++ b/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts @@ -3,7 +3,7 @@ import { Service } from '@n8n/di'; import { NextFunction, Response, Request } from 'express'; import { ensureError } from '@n8n/utils/errors/ensure-error'; -import { AuthError } from '@/errors/response-errors/auth.error'; +import { AuthError } from '@n8n/errors'; import { JwtService } from '@/services/jwt.service'; import { OAuthTokenVerifierProxy, diff --git a/packages/cli/src/modules/mcp/mcp.service.ts b/packages/cli/src/modules/mcp/mcp.service.ts index 919e73f39c2..f58854a3e0d 100644 --- a/packages/cli/src/modules/mcp/mcp.service.ts +++ b/packages/cli/src/modules/mcp/mcp.service.ts @@ -7,6 +7,7 @@ import { INSTANCE_ACTIVITY_CONTEXT_FLAG, } from '@n8n/api-types'; import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common'; +import { EventService, UrlService } from '@n8n/backend-services'; import { ExecutionsConfig, GlobalConfig, WorkflowsConfig } from '@n8n/config'; import { ExecutionRepository, ProjectRepository, SharedWorkflowRepository, User } from '@n8n/db'; import { Container, Service } from '@n8n/di'; @@ -26,7 +27,6 @@ import { ActiveExecutions } from '@/active-executions'; import { CollaborationService } from '@/collaboration/collaboration.service'; import { N8N_VERSION } from '@/constants'; import { CredentialsService } from '@/credentials/credentials.service'; -import { EventService } from '@/events/event.service'; import { ExecutionListService } from '@/executions/execution-list.service'; import { ExecutionService } from '@/executions/execution.service'; import { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks/subworkflow-policy-checker'; @@ -42,7 +42,6 @@ import { NodeResourceExplorerService } from '@/services/node-resource-explorer.s import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; import { TagService } from '@/services/tag.service'; -import { UrlService } from '@n8n/backend-services'; import { Telemetry } from '@/telemetry'; import { WorkflowRunner } from '@/workflow-runner'; import { WorkflowCreationService } from '@/workflows/workflow-creation.service'; diff --git a/packages/cli/src/modules/mcp/mcp.settings.controller.ts b/packages/cli/src/modules/mcp/mcp.settings.controller.ts index 1c5385a7c39..91d225462e7 100644 --- a/packages/cli/src/modules/mcp/mcp.settings.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.settings.controller.ts @@ -1,11 +1,11 @@ import { ModuleRegistry, Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { InstanceSettingsLoaderConfig } from '@n8n/config'; import { type AuthenticatedRequest } from '@n8n/db'; import { Body, Post, Get, Patch, RestController, GlobalScope } from '@n8n/decorators'; import type { Response } from 'express'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { ForbiddenError } from '@n8n/errors'; import { listQueryMiddleware } from '@/middlewares'; import type { ListQuery } from '@/requests'; import { WorkflowService } from '@/workflows/workflow.service'; diff --git a/packages/cli/src/modules/mcp/mcp.settings.service.ts b/packages/cli/src/modules/mcp/mcp.settings.service.ts index c164d3b7e53..f107f0c0e7c 100644 --- a/packages/cli/src/modules/mcp/mcp.settings.service.ts +++ b/packages/cli/src/modules/mcp/mcp.settings.service.ts @@ -17,8 +17,8 @@ import { } from 'n8n-workflow'; import { CollaborationService } from '@/collaboration/collaboration.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { CacheService } from '@/services/cache/cache.service'; +import { BadRequestError } from '@n8n/errors'; +import { CacheService } from '@n8n/backend-services'; import { removeDefaultValues } from '@/workflow-helpers'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-reference.ts b/packages/cli/src/modules/mcp/tools/agents/agent-reference.ts index 61f5f54c44f..1400b557fee 100644 --- a/packages/cli/src/modules/mcp/tools/agents/agent-reference.ts +++ b/packages/cli/src/modules/mcp/tools/agents/agent-reference.ts @@ -165,6 +165,34 @@ which validates the credential and persists the configuration without publishing channel stays inactive until publish_agent is called unless the Agent already has an active version; in that case, it connects immediately to the existing active snapshot. +### Slack + +A Slack app sends events only to the request URL in its own configuration, so a Slack channel works +only with a Slack app that points at this Agent. A slackApi credential made for another use, such as a +Slack Trigger or another Agent, passes every credential check but the Agent receives nothing. Connect +Slack in this order: + +1. Call update_agent_integration with action=connect, type=slack, and no credentialId. Nothing changes. + - status=workspace_selection_required lists managerCredentials with their workspaces. Ask the user + which workspace to use and confirm that n8n may create a Slack app there. + - code=slack_workspace_not_connected means no Slack workspace is connected to n8n yet. Give the + user agentUrl and ask them to connect the workspace once through Add channel, then retry. + - code=slack_manager_reconnect_required means the listed Slack workspace credentials must be + reconnected. Give the user agentUrl and ask them to reconnect through Add channel, then retry. + - code=slack_managed_setup_unavailable means this instance cannot create Slack apps. Relay + slackApp.requestUrl and slackApp.manifest so the user can configure a Slack app, then connect + with that app's slackApi credential. +2. Call update_agent_integration again with the chosen managerCredentialId and workspaceId. n8n + creates the Slack app, installs it, and connects the channel. If the result has + status=install_approval_required, give the user installUrl; n8n connects the channel after they + approve the install. Call get_agent to confirm. If the result has + code=slack_app_built_for_another_agent, the Agent already uses a bot credential from another + Slack app: disconnect that credential, then install again. + +When you connect with an explicit slackApi credential, the result has slackApp.configuredForAgent. It +is true only when n8n built the Slack app for this Agent. If it is false, relay the warning and +slackApp.requestUrl to the user instead of reporting the channel as ready. + ## MCP servers The top-level \`mcpServers\` config array connects external MCP tool catalogs to the Agent. Discover diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-slack-setup.ts b/packages/cli/src/modules/mcp/tools/agents/agent-slack-setup.ts new file mode 100644 index 00000000000..76d509d37c6 --- /dev/null +++ b/packages/cli/src/modules/mcp/tools/agents/agent-slack-setup.ts @@ -0,0 +1,168 @@ +import { isSlackManagerCredentialReady } from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import type { User } from '@n8n/db'; +import { Service } from '@n8n/di'; + +import { SlackManagedSetupService } from '@/modules/agents/integrations/platforms/slack/slack-managed-setup.service'; +import { SlackManualSetupService } from '@/modules/agents/integrations/platforms/slack/slack-manual-setup.service'; +import type { Agent } from '@/modules/agents/entities/agent.entity'; + +export const SLACK_INTEGRATION_TYPE = 'slack'; + +const CONNECT_WORKSPACE_STEP = + 'Ask the user to open the Agent in n8n, choose Add channel, pick Slack, and connect their Slack workspace once. Then call update_agent_integration again without credentialId.'; + +const RECONNECT_WORKSPACE_STEP = + 'Ask the user to open the Agent in n8n, choose Add channel, pick Slack, and reconnect the listed Slack workspace credential. Then call update_agent_integration again without credentialId.'; + +const UNVERIFIED_APP_WARNING = + 'n8n could not confirm that it built the Slack app behind this credential for this Agent, so the Agent may receive no events. This happens for an app made for another use, such as a Slack Trigger, or for an app that n8n built for another Agent. Make sure the app sends Event Subscriptions and Interactivity to slackApp.requestUrl, with Socket Mode off and the bot events in slackApp.manifest. To let n8n build a correct app, disconnect this credential and call update_agent_integration without credentialId.'; + +/** + * Slack setup steps for MCP clients. + * + * A Slack app sends events only to the request URL in its own configuration. + * n8n sets that URL itself when it builds the app for an Agent. A bot + * credential from any other Slack app, such as one made for a Slack Trigger + * or for another Agent, passes every credential check but sends its events + * elsewhere. + */ +@Service() +export class McpAgentSlackSetup { + constructor( + private readonly managedSetup: SlackManagedSetupService, + private readonly manualSetup: SlackManualSetupService, + private readonly logger: Logger, + ) {} + + /** + * Report the next managed setup step without changing anything, so the + * client must come back with an explicit workspace before n8n creates a + * Slack app. + */ + async describeSetup(agent: Agent, user: User, agentUrl: string) { + const state = await this.managedSetup.getSetupState({ + projectId: agent.projectId, + agentId: agent.id, + user, + }); + if (!state.managedSetupAvailable) { + return { + ok: false, + code: 'slack_managed_setup_unavailable', + agentId: agent.id, + configured: false, + error: + 'This n8n instance cannot create Slack apps automatically. Connect Slack with a bot credential from a Slack app that sends its events to this Agent.', + agentUrl, + slackApp: await this.appRequirements(agent), + nextStep: + "Ask the user to create a Slack app from slackApp.manifest, or to set its Event Subscriptions and Interactivity request URLs to slackApp.requestUrl with Socket Mode off. Then call update_agent_integration with a slackApi credential that holds that app's Bot User OAuth Token and Signing Secret. The user can also do all of this from agentUrl with Add channel.", + }; + } + + const managerCredentials = state.managerCredentials + .filter((manager) => isSlackManagerCredentialReady(manager) && manager.workspaces.length > 0) + .map((manager) => ({ + managerCredentialId: manager.id, + name: manager.name, + workspaces: manager.workspaces.map((workspace) => ({ + workspaceId: workspace.id, + name: workspace.name, + connected: workspace.connected, + ...(workspace.botCredentialId ? { credentialId: workspace.botCredentialId } : {}), + })), + })); + if (managerCredentials.length === 0 && state.managerCredentials.length > 0) { + return { + ok: false, + code: 'slack_manager_reconnect_required', + agentId: agent.id, + configured: false, + error: 'The Slack workspace credentials in this project must be reconnected.', + managerCredentials: state.managerCredentials.map((manager) => ({ + managerCredentialId: manager.id, + name: manager.name, + })), + agentUrl, + nextStep: RECONNECT_WORKSPACE_STEP, + }; + } + if (managerCredentials.length === 0) { + return { + ok: false, + code: 'slack_workspace_not_connected', + agentId: agent.id, + configured: false, + error: 'No connected Slack workspace is available to this project.', + agentUrl, + nextStep: CONNECT_WORKSPACE_STEP, + }; + } + + return { + ok: true, + status: 'workspace_selection_required', + agentId: agent.id, + configured: false, + managerCredentials, + nextStep: + 'Ask the user which workspace to install the Agent in and confirm that n8n may create a Slack app there. Then call update_agent_integration again with that managerCredentialId and workspaceId. A workspace with connected=true already has this Agent.', + }; + } + + async install( + agent: Agent, + user: User, + target: { managerCredentialId: string; workspaceId: string }, + ) { + return await this.managedSetup.installApp({ + projectId: agent.projectId, + agentId: agent.id, + user, + managerCredentialId: target.managerCredentialId, + workspaceId: target.workspaceId, + modifiedBy: 'mcp', + }); + } + + /** + * n8n cannot read a Slack app's request URL with a bot token, so for a + * credential that n8n did not build for this Agent, return what the app must + * be set to. + */ + async describeBotCredential(agent: Agent, user: User, credentialId: string) { + if (await this.isAppConfiguredForAgent(agent, user, credentialId)) { + return { slackApp: { configuredForAgent: true } }; + } + return { + slackApp: { configuredForAgent: false, ...(await this.appRequirements(agent)) }, + warning: UNVERIFIED_APP_WARNING, + }; + } + + /** + * A failed check counts as unverified: the caller has already saved the + * connect, or can only report the result. + */ + async isAppConfiguredForAgent(agent: Agent, user: User, credentialId: string) { + try { + return await this.managedSetup.isAppConfiguredForAgent(credentialId, agent, user); + } catch (error) { + this.logger.warn('[McpAgentSlackSetup] Could not check the Slack app of a credential', { + agentId: agent.id, + credentialId, + error, + }); + return false; + } + } + + private async appRequirements(agent: Agent) { + const { manifest } = await this.manualSetup.getManifest({ + projectId: agent.projectId, + agentId: agent.id, + }); + return { requestUrl: manifest.settings.event_subscriptions.request_url, manifest }; + } +} 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 a0454af31db..2fcfcd42bfd 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 @@ -30,8 +30,7 @@ import { UserError } from 'n8n-workflow'; import { z } from 'zod'; import { CredentialsService } from '@/credentials/credentials.service'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ConflictError, ForbiddenError } from '@n8n/errors'; import { AgentConfigService } from '@/modules/agents/agent-config.service'; import { AgentCustomToolsService } from '@/modules/agents/agent-custom-tools.service'; import { AgentExecutionService } from '@/modules/agents/agent-execution.service'; @@ -68,6 +67,7 @@ import { UrlService } from '@n8n/backend-services'; import { Telemetry } from '@/telemetry'; import { createAiMcpFetch } from '@/utils/ai-proxy-fetch'; +import { McpAgentSlackSetup, SLACK_INTEGRATION_TYPE } from './agent-slack-setup'; import { AGENT_BUILDER_GUIDE, AGENT_BUILDER_REFERENCE, @@ -427,7 +427,27 @@ const updateIntegrationInput = { ...agentIdentityShape, action: z.enum(['connect', 'disconnect']), type: z.string().min(1).describe('Integration type returned by discover_agent_assets'), - credentialId: z.string().min(1).describe('Accessible credential for this integration'), + credentialId: z + .string() + .min(1) + .optional() + .describe( + 'Accessible credential for this integration. Required for disconnect and for non-Slack connect operations. Omit it to connect Slack through managed setup', + ), + managerCredentialId: z + .string() + .min(1) + .optional() + .describe( + 'Slack managed setup only: the managerCredentialId returned by a Slack connect call without credentialId', + ), + workspaceId: z + .string() + .min(1) + .optional() + .describe( + 'Slack managed setup only: the workspaceId returned by a Slack connect call without credentialId', + ), settings: z .record(z.unknown()) .optional() @@ -474,6 +494,12 @@ type MutateAgentInput = z.infer>; type DiscoverAssetsInput = z.infer>; type VerifyMcpServerInput = z.infer>; type UpdateIntegrationInput = z.infer>; + +/** Telemetry label for the connect flow that an update_agent_integration call takes. */ +function integrationSetupPath(input: UpdateIntegrationInput) { + if (input.action === 'disconnect' || input.credentialId) return 'credential'; + return input.managerCredentialId || input.workspaceId ? 'managed_install' : 'managed_discovery'; +} type CallAgentInput = z.infer>; type MutationResource = { @@ -514,6 +540,7 @@ export class McpAgentToolsService { private readonly outboundHttp: OutboundHttp, private readonly urlService: UrlService, private readonly projectScopeService: ProjectScopeService, + private readonly slackSetup: McpAgentSlackSetup, ) {} /** @@ -1296,7 +1323,7 @@ export class McpAgentToolsService { name: 'update_agent_integration', config: { description: - "Configure or disconnect a Slack, Telegram, or Linear conversation integration. This is the only way to manage integrations; config.replace and config.patch can't change them. Configuration never publishes the Agent. If the Agent is already published, connecting starts the channel immediately. Otherwise, the channel stays inactive until publish_agent is called.", + "Configure or disconnect a Slack, Telegram, or Linear conversation integration. This is the only way to manage integrations; config.replace and config.patch can't change them. Configuration never publishes the Agent. If the Agent is already published, connecting starts the channel immediately. Otherwise, the channel stays inactive until publish_agent is called. For Slack, connect without credentialId first: n8n returns the workspaces where it can create a Slack app for the Agent, or the steps the user must take in n8n. Then call again with managerCredentialId and workspaceId after the user confirms the workspace.", inputSchema: updateIntegrationInput, annotations: { title: 'Update Agent Integration', @@ -1314,6 +1341,7 @@ export class McpAgentToolsService { agentId: input.agentId, action: input.action, type: input.type, + setup: integrationSetupPath(input), }, async () => await this.updateIntegration(user, input), ), @@ -1895,6 +1923,12 @@ export class McpAgentToolsService { return this.integrationPersistenceService.listChatIntegrations().map((integration) => ({ ...integration, settingsRequired: integration.type === 'telegram', + ...(integration.type === SLACK_INTEGRATION_TYPE + ? { + setupGuidance: + 'Call update_agent_integration with action=connect and no credentialId. n8n returns the workspaces where it can create a Slack app for this Agent, or the steps the user must take in n8n. A slackApi credential from another Slack app, such as one made for a Slack Trigger, does not send events to the Agent.', + } + : {}), ...(integration.type === 'telegram' ? { settingsSchema: TELEGRAM_SETTINGS_JSON_SCHEMA, @@ -1968,17 +2002,22 @@ export class McpAgentToolsService { } private async disconnectIntegration(user: User, input: UpdateIntegrationInput, agent: Agent) { + const { credentialId } = input; + if (!credentialId) throw new UserError('credentialId is required to disconnect'); + if (input.managerCredentialId || input.workspaceId) { + throw new UserError('managerCredentialId and workspaceId apply only to connect'); + } const { savedAgent: saved, warning } = await this.integrationManagementService.disconnect({ agent, user, type: input.type, - credentialId: input.credentialId, + credentialId, modifiedBy: 'mcp', }); return { ok: true, agentId: input.agentId, - integration: { type: input.type, credentialId: input.credentialId }, + integration: { type: input.type, credentialId }, connected: false, ...(warning ? { warning } : {}), published: saved.activeVersionId !== null, @@ -1988,9 +2027,121 @@ export class McpAgentToolsService { } private async connectIntegration(user: User, input: UpdateIntegrationInput, agent: Agent) { + const managedTarget = this.managedSlackTarget(input); + if (managedTarget) return await this.installManagedSlackApp(user, agent, managedTarget); + if (!input.credentialId) { + if (input.type !== SLACK_INTEGRATION_TYPE) { + throw new UserError('credentialId is required to connect this integration'); + } + return await this.slackSetup.describeSetup( + agent, + user, + this.getAgentUrl(agent.projectId, agent.id), + ); + } + + const connected = await this.connectWithCredential(user, input, agent, input.credentialId); + if (input.type !== SLACK_INTEGRATION_TYPE) return connected; + return { + ...connected, + ...(await this.slackSetup.describeBotCredential(agent, user, input.credentialId)), + }; + } + + /** + * Returns the managed setup target when the input names one, after it checks + * that the input does not mix managed setup with a credential connect. + */ + private managedSlackTarget(input: UpdateIntegrationInput) { + const { managerCredentialId, workspaceId } = input; + if (!managerCredentialId && !workspaceId) return undefined; + if (input.type !== SLACK_INTEGRATION_TYPE) { + throw new UserError('managerCredentialId and workspaceId apply only to Slack'); + } + if (!managerCredentialId || !workspaceId) { + throw new UserError('Pass managerCredentialId and workspaceId together'); + } + if (input.credentialId || input.replacesCredentialId || input.settings) { + throw new UserError( + 'Managed Slack setup creates its own credential. Omit credentialId, replacesCredentialId, and settings', + ); + } + return { managerCredentialId, workspaceId }; + } + + private async installManagedSlackApp( + user: User, + agent: Agent, + target: { managerCredentialId: string; workspaceId: string }, + ) { + // Managed setup stores the new bot token as a credential in the project. + if ( + !(await userHasScopes(user, ['credential:create'], false, { projectId: agent.projectId })) + ) { + throw new ForbiddenError('You do not have permission to create credentials in this project.'); + } + + const result = await this.slackSetup.install(agent, user, target); + if (result.status === 'manual_install_required') { + return { + ok: true, + status: 'install_approval_required', + agentId: agent.id, + configured: false, + appId: result.appId, + installUrl: result.installUrl, + nextStep: + 'Give installUrl to the user. After they approve the install in Slack, n8n connects the channel. Call get_agent to confirm the Slack integration.', + }; + } + + // installApp returns a bot credential that this Agent already has instead of + // building a new app. That credential can come from another Agent's app. + if (!(await this.slackSetup.isAppConfiguredForAgent(agent, user, result.credentialId))) { + return { + ok: false, + code: 'slack_app_built_for_another_agent', + agentId: agent.id, + configured: false, + integration: { type: SLACK_INTEGRATION_TYPE, credentialId: result.credentialId }, + error: + 'This Agent already uses a Slack bot credential that n8n did not build for this Agent.', + nextStep: + 'Disconnect integration.credentialId from this Agent with update_agent_integration action=disconnect. Then call the install again, and n8n builds a new Slack app for this Agent.', + }; + } + + const installed = { + ok: true, + agentId: agent.id, + integration: { type: SLACK_INTEGRATION_TYPE, credentialId: result.credentialId }, + configured: true, + appId: result.appId, + slackApp: { configuredForAgent: true }, + }; + // The install is already saved, so skip the MCP access guard of resolveAgent: + // a concurrent change to that setting must not turn this result into an error. + const saved = await this.agentsService.findByIdForUser(agent.id, user); + if (!saved) return installed; + const published = saved.activeVersionId !== null; + return { + ...installed, + connected: published, + published, + activeVersionId: saved.activeVersionId, + configHash: getAgentConfigHash(this.configFromEntity(saved)), + }; + } + + private async connectWithCredential( + user: User, + input: UpdateIntegrationInput, + agent: Agent, + credentialId: string, + ) { const candidate = { type: input.type, - credentialId: input.credentialId, + credentialId, ...(input.settings ? { settings: input.settings } : {}), }; const { savedAgent: saved } = await this.integrationManagementService.connect({ @@ -2005,7 +2156,7 @@ export class McpAgentToolsService { const result = { ok: true, agentId: input.agentId, - integration: { type: input.type, credentialId: input.credentialId }, + integration: { type: input.type, credentialId }, configured: true, published: saved.activeVersionId !== null, activeVersionId: saved.activeVersionId, diff --git a/packages/cli/src/modules/mcp/tools/user-preference-tool.utils.ts b/packages/cli/src/modules/mcp/tools/user-preference-tool.utils.ts index 1529df31240..f58043a9a7b 100644 --- a/packages/cli/src/modules/mcp/tools/user-preference-tool.utils.ts +++ b/packages/cli/src/modules/mcp/tools/user-preference-tool.utils.ts @@ -3,7 +3,7 @@ import { aiPreferenceScopeOf } from '@n8n/api-types'; import type { InferTelemetryProps, TELEMETRY_EVENT } from '@n8n/telemetry'; import z from 'zod'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { AiPreferenceWriteRejection } from '@/services/ai-preference-write'; import { secondsSinceSaved, toAiPreferenceWriteRejection } from '@/services/ai-preference-write'; import type { UrlService } from '@n8n/backend-services'; diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/__tests__/error-code.utils.test.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/__tests__/error-code.utils.test.ts index 3dbfca3a2d5..fc67a6c67d0 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/__tests__/error-code.utils.test.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/__tests__/error-code.utils.test.ts @@ -1,4 +1,4 @@ -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { getErrorCode } from '../error-code.utils'; diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/create-workflow-from-code.tool.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/create-workflow-from-code.tool.ts index df39b16c9be..7d1825f2460 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/create-workflow-from-code.tool.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/create-workflow-from-code.tool.ts @@ -47,7 +47,7 @@ import { USER_CALLED_MCP_TOOL_EVENT } from '../../mcp.constants'; import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.types'; import { getSdkReferenceHint } from '../workflow-validation.utils'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { Telemetry } from '@/telemetry'; import { dropInvalidWorkflowGroups, diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/credential-validation.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/credential-validation.ts index ab9416150db..20c0a4e1b49 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/credential-validation.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/credential-validation.ts @@ -3,7 +3,7 @@ import type { INode, INodeTypeDescription, IWorkflowBase } from 'n8n-workflow'; import { NodeHelpers } from 'n8n-workflow'; import type { CredentialsService } from '@/credentials/credentials.service'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { NodeTypes } from '@/node-types'; import type { PartialUpdateOperation } from './workflow-operations'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-folder.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-folder.integration.test.ts index 252e08b0d28..5c2f96d3b44 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-folder.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-folder.integration.test.ts @@ -1,8 +1,8 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, createWorkflow, testDb, testModules } from '@n8n/backend-test-utils'; import { Container } from '@n8n/di'; import { jsonParse } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { saveCredential } from '@test-integration/db/credentials'; import { createFolder } from '@test-integration/db/folders'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-git-blob-parity.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-git-blob-parity.integration.test.ts index 514d4093e7f..87a8ef5913e 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-git-blob-parity.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-git-blob-parity.integration.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, createWorkflow, testDb, testModules } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -8,7 +9,6 @@ import { join } from 'node:path'; import { simpleGit } from 'simple-git'; import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; -import { EventService } from '@/events/event.service'; import { mockDataTableSizeValidator } from '@/modules/data-table/__tests__/test-helpers'; import { DataTableService } from '@/modules/data-table/data-table.service'; import { saveCredential } from '@test-integration/db/credentials'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-project-to-directory.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-project-to-directory.integration.test.ts index 559e6efecb7..a10d4ca97c0 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-project-to-directory.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-project-to-directory.integration.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, createWorkflow, testDb, testModules } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -7,8 +8,6 @@ import path from 'node:path'; import { createOwner } from '@test-integration/db/users'; -import { EventService } from '@/events/event.service'; - import { N8nPackagesService } from '../n8n-packages.service'; import { FORMAT_VERSION } from '../spec/constants'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-project.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-project.integration.test.ts index 9317608201d..85613f7baf4 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-project.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-project.integration.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, createWorkflow, @@ -11,7 +12,6 @@ import type { User } from '@n8n/db'; import { Container } from '@n8n/di'; import { jsonParse, type INode } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { saveCredential } from '@test-integration/db/credentials'; import { createFolder } from '@test-integration/db/folders'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts index 447047f2a7b..d814cd58c90 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, shareWorkflowWithUsers, @@ -7,7 +8,6 @@ import { import { Container } from '@n8n/di'; import { jsonParse } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { saveCredential } from '@test-integration/db/credentials'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-data-tables.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-data-tables.integration.test.ts index cdfa0fa848f..9e647450095 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-data-tables.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-data-tables.integration.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import type { Project, User } from '@n8n/db'; import { createTeamProject, @@ -10,7 +11,6 @@ import { import { Container } from '@n8n/di'; import { jsonParse } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { mockDataTableSizeValidator } from '@/modules/data-table/__tests__/test-helpers'; import { DataTableService } from '@/modules/data-table/data-table.service'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-tags.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-tags.integration.test.ts index 224ec6ff645..68b14d51094 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-tags.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-tags.integration.test.ts @@ -1,10 +1,10 @@ import { LicenseState } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { createTeamProject, createWorkflow, testDb, testModules } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import { jsonParse } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { createFolder } from '@test-integration/db/folders'; import { assignTagToWorkflow, createTag } from '@test-integration/db/tags'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-variables.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-variables.integration.test.ts index 949d36ee348..22884c640ea 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-variables.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-variables.integration.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, shareWorkflowWithUsers, @@ -9,7 +10,6 @@ import { Container } from '@n8n/di'; import { jsonParse } from 'n8n-workflow'; import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { createMember, createOwner } from '@test-integration/db/users'; import { createProjectVariable, createVariable } from '@test-integration/db/variables'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.integration.test.ts index 37242166993..41951115e03 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.integration.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, createWorkflow, @@ -13,7 +14,6 @@ import { ProjectRepository, WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { jsonParse, type INode } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { createMember, createOwner } from '@test-integration/db/users'; import { saveCredential } from '@test-integration/db/credentials'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-data-tables.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-data-tables.integration.test.ts index e0447dd3f16..ba98cc170b4 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-data-tables.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-data-tables.integration.test.ts @@ -1,4 +1,5 @@ import { LicenseState, ModuleRegistry } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; import type { Project, User } from '@n8n/db'; import { FolderRepository, WorkflowRepository } from '@n8n/db'; @@ -7,8 +8,7 @@ import type { INode, INodeParameterResourceLocator, Workflow } from 'n8n-workflo import { NodeOperationError } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { ForbiddenError } from '@n8n/errors'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { mockDataTableSizeValidator } from '@/modules/data-table/__tests__/test-helpers'; import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.service'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-folders.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-folders.integration.test.ts index 4253fe6ef8a..0e4cd7026da 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-folders.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-folders.integration.test.ts @@ -4,9 +4,7 @@ import type { Project, User } from '@n8n/db'; import { FolderRepository, WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { ConflictError, ForbiddenError, UnprocessableRequestError } from '@n8n/errors'; import { createFolder } from '@test-integration/db/folders'; import { createOwner } from '@test-integration/db/users'; import { LicenseMocker } from '@test-integration/license'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-package-policy.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-package-policy.integration.test.ts index de7a606a349..a16df41aa20 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-package-policy.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-package-policy.integration.test.ts @@ -27,7 +27,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { UnprocessableRequestError } from '@n8n/errors'; import { PolicyDecisionService } from '@/modules/policy-infrastructure/policy-decision.service'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { createOwner } from '@test-integration/db/users'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-package-selection.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-package-selection.integration.test.ts index 3b2dfa08365..4e8488a8a2e 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-package-selection.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-package-selection.integration.test.ts @@ -15,9 +15,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { BadRequestError, ConflictError, UnprocessableRequestError } from '@n8n/errors'; import { createCustomRoleWithScopeSlugs } from '@test-integration/db/roles'; import { createMember, createOwner } from '@test-integration/db/users'; import { LicenseMocker } from '@test-integration/license'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-package.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-package.integration.test.ts index 639651681fd..74f7a94bcff 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-package.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-package.integration.test.ts @@ -1,4 +1,5 @@ import { LicenseState } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { createActiveWorkflow, createTeamProject, @@ -21,9 +22,7 @@ import { Container } from '@n8n/di'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { CredentialTypes } from '@/credential-types'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, UnprocessableRequestError } from '@n8n/errors'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { affixRoleToSaveCredential, diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-project-from-directory.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-project-from-directory.integration.test.ts index db996227a1f..dce9235f2de 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-project-from-directory.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-project-from-directory.integration.test.ts @@ -1,4 +1,5 @@ import { LicenseState } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { createTeamProject, createWorkflow, @@ -14,7 +15,6 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; -import { EventService } from '@/events/event.service'; import { createFolder } from '@test-integration/db/folders'; import { createOwner } from '@test-integration/db/users'; import { LicenseMocker } from '@test-integration/license'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-projects.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-projects.integration.test.ts index f8570d18b05..926be6d62ef 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-projects.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-projects.integration.test.ts @@ -1,4 +1,5 @@ import { LicenseState } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { createTeamProject, createWorkflow, @@ -23,10 +24,7 @@ import { pickVariableForProject } from 'n8n-workflow'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; -import { EventService } from '@/events/event.service'; +import { ConflictError, ForbiddenError, UnprocessableRequestError } from '@n8n/errors'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { createFolder } from '@test-integration/db/folders'; import { createTag } from '@test-integration/db/tags'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-tags.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-tags.integration.test.ts index 42fac879db7..c8715661de0 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-tags.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-tags.integration.test.ts @@ -10,8 +10,7 @@ import { } from '@n8n/db'; import { Container } from '@n8n/di'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ConflictError, ForbiddenError } from '@n8n/errors'; import { createFolder } from '@test-integration/db/folders'; import { assignTagToWorkflow, createTag, updateTag } from '@test-integration/db/tags'; import { createOwner } from '@test-integration/db/users'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-variables.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-variables.integration.test.ts index e31ff545dcf..bfd6c78ba94 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-variables.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-variables.integration.test.ts @@ -11,8 +11,7 @@ import { VariablesRepository, WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; -import type { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { type ConflictError, ForbiddenError } from '@n8n/errors'; import { VariableCountLimitReachedError } from '@/errors/variable-count-limit-reached.error'; import { WorkflowCreationService } from '@/workflows/workflow-creation.service'; import { createMember, createOwner } from '@test-integration/db/users'; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/package-failure-classifier.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/package-failure-classifier.test.ts index 5a47472565b..b67e02a85a2 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/package-failure-classifier.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/package-failure-classifier.test.ts @@ -1,10 +1,12 @@ import { UserError } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { + BadRequestError, + ConflictError, + ForbiddenError, + NotFoundError, + UnprocessableRequestError, +} from '@n8n/errors'; import { PackageEntityAccessDeniedError, diff --git a/packages/cli/src/modules/n8n-packages/__tests__/utils/import-package-upload.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/utils/import-package-upload.test.ts index aea7946b1af..7bbee87977e 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/utils/import-package-upload.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/utils/import-package-upload.test.ts @@ -3,7 +3,7 @@ import { GlobalConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import multer from 'multer'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { createN8nPackageMulterOptions, diff --git a/packages/cli/src/modules/n8n-packages/engine/__tests__/content-import-policy.test.ts b/packages/cli/src/modules/n8n-packages/engine/__tests__/content-import-policy.test.ts index 3aa393f15eb..9907f889efd 100644 --- a/packages/cli/src/modules/n8n-packages/engine/__tests__/content-import-policy.test.ts +++ b/packages/cli/src/modules/n8n-packages/engine/__tests__/content-import-policy.test.ts @@ -5,6 +5,7 @@ import { mock } from 'vitest-mock-extended'; import type { PolicyCleared } from '@n8n/decorators'; +import type { PolicyActor } from '@/policy/policy-enforcement-backend'; import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { PolicyViolationError } from '@/policy/policy-violation.error'; @@ -58,6 +59,7 @@ describe('contentImportTransport', () => { describe('ContentImportPolicyGate', () => { const policyEnforcementService = mock(); const gate = new ContentImportPolicyGate(policyEnforcementService, mock()); + const actor: PolicyActor = { kind: 'user', user: { id: 'user-1' } }; beforeEach(() => { vi.clearAllMocks(); @@ -66,7 +68,9 @@ describe('ContentImportPolicyGate', () => { it('admits nothing when no check is registered', async () => { policyEnforcementService.hasChecksFor.mockReturnValue(false); - expect(await gate.refusedWorkflows([createItem('W1')], 'project-1', 'package')).toEqual([]); + expect(await gate.refusedWorkflows([createItem('W1')], 'project-1', 'package', actor)).toEqual( + [], + ); expect(policyEnforcementService.hasChecksFor).toHaveBeenCalledTimes(1); expect(policyEnforcementService.enforceContentImport).not.toHaveBeenCalled(); }); @@ -76,7 +80,12 @@ describe('ContentImportPolicyGate', () => { policyEnforcementService.enforceContentImport.mockResolvedValue(cleared); expect( - await gate.refusedWorkflows([createItem('W1'), createItem('W2')], 'project-1', 'package'), + await gate.refusedWorkflows( + [createItem('W1'), createItem('W2')], + 'project-1', + 'package', + actor, + ), ).toEqual([]); expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledTimes(2); }); @@ -85,7 +94,7 @@ describe('ContentImportPolicyGate', () => { policyEnforcementService.hasChecksFor.mockReturnValue(true); policyEnforcementService.enforceContentImport.mockResolvedValue(cleared); - await gate.refusedWorkflows([skipItem('W1'), createItem('W2')], 'project-1', 'package'); + await gate.refusedWorkflows([skipItem('W1'), createItem('W2')], 'project-1', 'package', actor); expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledTimes(1); }); @@ -98,18 +107,27 @@ describe('ContentImportPolicyGate', () => { [createItem('W1', 'new-id'), updateItem('W2', 'existing-id')], 'project-1', 'git-connection', + actor, ); - expect(policyEnforcementService.enforceContentImport).toHaveBeenNthCalledWith(1, { - workflow: { id: 'new-id', name: 'W1', nodes: [] }, - projectId: 'project-1', - transport: 'git-connection', - }); - expect(policyEnforcementService.enforceContentImport).toHaveBeenNthCalledWith(2, { - workflow: { id: 'existing-id', name: 'W2', nodes: [] }, - projectId: 'project-1', - transport: 'git-connection', - }); + expect(policyEnforcementService.enforceContentImport).toHaveBeenNthCalledWith( + 1, + { + workflow: { id: 'new-id', name: 'W1', nodes: [] }, + projectId: 'project-1', + transport: 'git-connection', + }, + actor, + ); + expect(policyEnforcementService.enforceContentImport).toHaveBeenNthCalledWith( + 2, + { + workflow: { id: 'existing-id', name: 'W2', nodes: [] }, + projectId: 'project-1', + transport: 'git-connection', + }, + actor, + ); }); it('reports every refusal, not just the first', async () => { @@ -122,6 +140,7 @@ describe('ContentImportPolicyGate', () => { [createItem('W1'), createItem('W2')], 'project-1', 'package', + actor, ); expect(refused).toEqual([ @@ -140,6 +159,7 @@ describe('ContentImportPolicyGate', () => { [createItem('W1'), createItem('W2')], 'project-1', 'package', + actor, ); expect(refused).toHaveLength(1); @@ -151,8 +171,8 @@ describe('ContentImportPolicyGate', () => { const checkFailure = new Error('check exploded'); policyEnforcementService.enforceContentImport.mockRejectedValue(checkFailure); - await expect(gate.refusedWorkflows([createItem('W1')], 'project-1', 'package')).rejects.toBe( - checkFailure, - ); + await expect( + gate.refusedWorkflows([createItem('W1')], 'project-1', 'package', actor), + ).rejects.toBe(checkFailure); }); }); diff --git a/packages/cli/src/modules/n8n-packages/engine/__tests__/import-blocked.error.test.ts b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-blocked.error.test.ts index 49bc65c97c8..c0f75b549d1 100644 --- a/packages/cli/src/modules/n8n-packages/engine/__tests__/import-blocked.error.test.ts +++ b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-blocked.error.test.ts @@ -1,5 +1,4 @@ -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { ConflictError, UnprocessableRequestError } from '@n8n/errors'; import type { BlockingIssue } from '../../n8n-packages.types'; import { toImportBlockedError } from '../import-blocked.error'; diff --git a/packages/cli/src/modules/n8n-packages/engine/__tests__/import-gates.test.ts b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-gates.test.ts index 6572914795a..771ace05138 100644 --- a/packages/cli/src/modules/n8n-packages/engine/__tests__/import-gates.test.ts +++ b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-gates.test.ts @@ -1,6 +1,6 @@ import type { WorkflowEntity } from '@n8n/db'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import type { WorkflowImportPlan } from '../../entities/workflow/workflow-import.types'; import { assertArchiveTransitionsAllowed } from '../import-gates'; diff --git a/packages/cli/src/modules/n8n-packages/engine/__tests__/import-telemetry.test.ts b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-telemetry.test.ts index ae315e2fefb..5a87d05777a 100644 --- a/packages/cli/src/modules/n8n-packages/engine/__tests__/import-telemetry.test.ts +++ b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-telemetry.test.ts @@ -1,7 +1,7 @@ +import type { EventService } from '@n8n/backend-services'; import type { WorkflowEntity } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import type { CredentialApplyResult } from '../../entities/credential/credential.types'; diff --git a/packages/cli/src/modules/n8n-packages/engine/content-import-policy.ts b/packages/cli/src/modules/n8n-packages/engine/content-import-policy.ts index bc48f06ac16..08fa784cae1 100644 --- a/packages/cli/src/modules/n8n-packages/engine/content-import-policy.ts +++ b/packages/cli/src/modules/n8n-packages/engine/content-import-policy.ts @@ -2,6 +2,7 @@ import { Logger } from '@n8n/backend-common'; import type { ContentImportTransport } from '@n8n/decorators'; import { Service } from '@n8n/di'; +import type { PolicyActor } from '@/policy/policy-enforcement-backend'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { PolicyViolationError } from '@/policy/policy-violation.error'; @@ -36,6 +37,7 @@ export class ContentImportPolicyGate { items: WorkflowPlanItem[], projectId: string, transport: ContentImportTransport, + actor: PolicyActor, ): Promise { // Skips the whole loop rather than paying a per-workflow guard: a package can hold // hundreds, and having no policy at all is the common case. @@ -50,15 +52,18 @@ export class ContentImportPolicyGate { // The clearance is discarded: these writes still go through `WorkflowCreationService` // and `WorkflowService`, which mint their own `workflowSave` one. Threading this token // into them instead is its own change. - await this.policyEnforcementService.enforceContentImport({ - workflow: { - id: item.action === 'create' ? item.decidedId : item.existing.id, - name: item.entity.name, - nodes: item.entity.nodes, + await this.policyEnforcementService.enforceContentImport( + { + workflow: { + id: item.action === 'create' ? item.decidedId : item.existing.id, + name: item.entity.name, + nodes: item.entity.nodes, + }, + projectId, + transport, }, - projectId, - transport, - }); + actor, + ); } catch (error) { // Every refusal is collected, so the caller reports the whole package's worth at once. // A check that broke is not scoped to one workflow, so it fails the import outright diff --git a/packages/cli/src/modules/n8n-packages/engine/import-blocked.error.ts b/packages/cli/src/modules/n8n-packages/engine/import-blocked.error.ts index 8e1441c3f9c..17306f7193b 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-blocked.error.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-blocked.error.ts @@ -1,5 +1,4 @@ -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { ConflictError, UnprocessableRequestError } from '@n8n/errors'; import type { BlockingIssue } from '../n8n-packages.types'; diff --git a/packages/cli/src/modules/n8n-packages/engine/import-gates.ts b/packages/cli/src/modules/n8n-packages/engine/import-gates.ts index 38c8d44fc1c..43636810a2f 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-gates.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-gates.ts @@ -1,6 +1,6 @@ import type { LicenseState } from '@n8n/backend-common'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import type { TagImportPlan } from '../entities/tag/tag.types'; import type { WorkflowImportPlan } from '../entities/workflow/workflow-import.types'; diff --git a/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts b/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts index 325b2ca5765..0a056a7c34d 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts @@ -257,6 +257,7 @@ export class ImportOrchestrator { workflowPlan.items, context.projectId, contentImportTransport(input.importSource), + { kind: 'user', user: context.user }, ); const blockingIssues = this.collectBlockingIssues({ diff --git a/packages/cli/src/modules/n8n-packages/engine/import-telemetry.ts b/packages/cli/src/modules/n8n-packages/engine/import-telemetry.ts index 1bfe7805cfe..076dfce5828 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-telemetry.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-telemetry.ts @@ -1,4 +1,4 @@ -import type { EventService } from '@/events/event.service'; +import type { EventService } from '@n8n/backend-services'; import type { CredentialBindingRequest } from '../entities/credential/credential.types'; import type { DataTableImportRequest } from '../entities/data-table/data-table.types'; diff --git a/packages/cli/src/modules/n8n-packages/engine/n8n-package-parser.ts b/packages/cli/src/modules/n8n-packages/engine/n8n-package-parser.ts index 9604b4b1613..ca3cc369ccd 100644 --- a/packages/cli/src/modules/n8n-packages/engine/n8n-package-parser.ts +++ b/packages/cli/src/modules/n8n-packages/engine/n8n-package-parser.ts @@ -4,7 +4,7 @@ import { Service } from '@n8n/di'; import { jsonParse, UserError } from 'n8n-workflow'; import { ZodError } from 'zod'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { NodeTypes } from '@/node-types'; import * as WorkflowHelpers from '@/workflow-helpers'; diff --git a/packages/cli/src/modules/n8n-packages/engine/project-package-importer.ts b/packages/cli/src/modules/n8n-packages/engine/project-package-importer.ts index b2d392bb51e..77bc7fd623f 100644 --- a/packages/cli/src/modules/n8n-packages/engine/project-package-importer.ts +++ b/packages/cli/src/modules/n8n-packages/engine/project-package-importer.ts @@ -1,7 +1,7 @@ import { LicenseState } from '@n8n/backend-common'; import { Service } from '@n8n/di'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import type { CredentialBindingRequest } from '../entities/credential/credential.types'; import { removesUnpackagedWorkflows } from '../entities/folder/folder-conflict-policy'; diff --git a/packages/cli/src/modules/n8n-packages/engine/workflow-package-importer.ts b/packages/cli/src/modules/n8n-packages/engine/workflow-package-importer.ts index c1d9a53d23a..2023dafe160 100644 --- a/packages/cli/src/modules/n8n-packages/engine/workflow-package-importer.ts +++ b/packages/cli/src/modules/n8n-packages/engine/workflow-package-importer.ts @@ -4,8 +4,7 @@ import { Service } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; import { UserError } from 'n8n-workflow'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import { FolderService } from '@/services/folder.service'; import { ProjectService } from '@/services/project.service.ee'; diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-importer.test.ts b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-importer.test.ts index 14f07053a14..9ae23e9c52c 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-importer.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-importer.test.ts @@ -2,7 +2,7 @@ import type { Project, User } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import type { CredentialTypes } from '@/credential-types'; import type { CredentialsService } from '@/credentials/credentials.service'; diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-matcher.test.ts b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-matcher.test.ts index 03d56b87563..12c5606f45f 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-matcher.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-matcher.test.ts @@ -4,7 +4,7 @@ import { mock } from 'vitest-mock-extended'; import type { CredentialTypes } from '@/credential-types'; import type { CredentialsService } from '@/credentials/credentials.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { CredentialMatcherContext, UsableCredential } from '../credential-matcher'; import { CredentialMatcherFactory } from '../credential-matcher-factory'; diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential-matcher-factory.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential-matcher-factory.ts index b52816af90e..7cb976519b6 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/credential-matcher-factory.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential-matcher-factory.ts @@ -1,6 +1,6 @@ import { Service } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { CredentialMatcher } from './credential-matcher'; import { IdBasedCredentialMatcher } from './id-based-credential-matcher'; diff --git a/packages/cli/src/modules/n8n-packages/entities/data-table/data-table-importer.ts b/packages/cli/src/modules/n8n-packages/entities/data-table/data-table-importer.ts index 45384189a14..c951485bc91 100644 --- a/packages/cli/src/modules/n8n-packages/entities/data-table/data-table-importer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/data-table/data-table-importer.ts @@ -2,7 +2,7 @@ import { ModuleRegistry } from '@n8n/backend-common'; import { Service } from '@n8n/di'; import { UserError } from 'n8n-workflow'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import type { DataTable } from '@/modules/data-table/data-table.entity'; import { DataTableService } from '@/modules/data-table/data-table.service'; import { userHasScopes } from '@/permissions.ee/check-access'; diff --git a/packages/cli/src/modules/n8n-packages/entities/project/project-importer.ts b/packages/cli/src/modules/n8n-packages/entities/project/project-importer.ts index 16cfbc6b287..11795f1b073 100644 --- a/packages/cli/src/modules/n8n-packages/entities/project/project-importer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/project/project-importer.ts @@ -4,7 +4,7 @@ import { Service } from '@n8n/di'; import { hasGlobalScope } from '@n8n/permissions'; import { UnexpectedError } from 'n8n-workflow'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { ProjectService } from '@/services/project.service.ee'; import { decideMatchedProject } from './project-conflict-policy'; diff --git a/packages/cli/src/modules/n8n-packages/entities/tag/__tests__/tag-importer.test.ts b/packages/cli/src/modules/n8n-packages/entities/tag/__tests__/tag-importer.test.ts index b0803e1ba5d..776db5d9ebb 100644 --- a/packages/cli/src/modules/n8n-packages/entities/tag/__tests__/tag-importer.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/tag/__tests__/tag-importer.test.ts @@ -2,7 +2,7 @@ import type { GlobalConfig } from '@n8n/config'; import type { TagEntity, User } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import type { TagService } from '@/services/tag.service'; import { TagImporter } from '../tag-importer'; diff --git a/packages/cli/src/modules/n8n-packages/entities/tag/tag-importer.ts b/packages/cli/src/modules/n8n-packages/entities/tag/tag-importer.ts index 72e1f8a7f1e..d9b8f91bf64 100644 --- a/packages/cli/src/modules/n8n-packages/entities/tag/tag-importer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/tag/tag-importer.ts @@ -3,7 +3,7 @@ import { Service } from '@n8n/di'; import { hasGlobalScope } from '@n8n/permissions'; import { UserError } from 'n8n-workflow'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { TagService } from '@/services/tag.service'; import { decideTagImportAction } from './tag-import-decision'; diff --git a/packages/cli/src/modules/n8n-packages/entities/variable/variable-importer.ts b/packages/cli/src/modules/n8n-packages/entities/variable/variable-importer.ts index 2b0479b6644..23697a6c4e3 100644 --- a/packages/cli/src/modules/n8n-packages/entities/variable/variable-importer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/variable/variable-importer.ts @@ -3,7 +3,7 @@ import { hasGlobalScope } from '@n8n/permissions'; import { pickVariableForProject } from 'n8n-workflow'; import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { VariableCountLimitReachedError } from '@/errors/variable-count-limit-reached.error'; import { userHasScopes } from '@/permissions.ee/check-access'; diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-publisher.test.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-publisher.test.ts index dfd989ac7ec..78a6dd7bc87 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-publisher.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-publisher.test.ts @@ -3,8 +3,7 @@ import type { Logger } from '@n8n/backend-common'; import type { Project, User, WorkflowEntity } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import type { ProjectService } from '@/services/project.service.ee'; import type { WebhookService } from '@/webhooks/webhook.service'; import type { WorkflowService } from '@/workflows/workflow.service'; diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-remover.test.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-remover.test.ts index 50ddac57252..6e2301f2dfb 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-remover.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-remover.test.ts @@ -1,7 +1,7 @@ import type { User, WorkflowEntity } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ConflictError } from '@n8n/errors'; import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import type { WorkflowService } from '@/workflows/workflow.service'; diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-importer.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-importer.ts index 5d46d9df2eb..f3d6ba55b69 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-importer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-importer.ts @@ -1,7 +1,7 @@ import { WorkflowEntity } from '@n8n/db'; import { Service } from '@n8n/di'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { WorkflowCreationService, type WorkflowCreateBatchContext, diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-publisher.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-publisher.ts index f362f3458f1..5e6f8aeade6 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-publisher.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-publisher.ts @@ -4,8 +4,7 @@ import { ProjectRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import { ensureError } from '@n8n/utils/errors/ensure-error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import { ProjectService } from '@/services/project.service.ee'; import { WEBHOOK_CONFLICT_MESSAGE } from '@/webhooks/constants'; import { WebhookService } from '@/webhooks/webhook.service'; diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-remover.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-remover.ts index d0f896f09e7..7c47c1bba3d 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-remover.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-remover.ts @@ -1,6 +1,6 @@ import { Service } from '@n8n/di'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ConflictError } from '@n8n/errors'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowService } from '@/workflows/workflow.service'; diff --git a/packages/cli/src/modules/n8n-packages/io/__tests__/directory-package-reader.test.ts b/packages/cli/src/modules/n8n-packages/io/__tests__/directory-package-reader.test.ts index cbf72067803..d5f5b499083 100644 --- a/packages/cli/src/modules/n8n-packages/io/__tests__/directory-package-reader.test.ts +++ b/packages/cli/src/modules/n8n-packages/io/__tests__/directory-package-reader.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { DirectoryPackageReader } from '../directory/directory-package-reader'; diff --git a/packages/cli/src/modules/n8n-packages/io/__tests__/tar-package-reader.test.ts b/packages/cli/src/modules/n8n-packages/io/__tests__/tar-package-reader.test.ts index d54d385d4ec..117cf125b58 100644 --- a/packages/cli/src/modules/n8n-packages/io/__tests__/tar-package-reader.test.ts +++ b/packages/cli/src/modules/n8n-packages/io/__tests__/tar-package-reader.test.ts @@ -1,4 +1,4 @@ -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { streamToBuffer } from '../../__tests__/utils/tar-support'; import { TarPackageReader, type TarReaderLimits } from '../tar/tar-package-reader'; diff --git a/packages/cli/src/modules/n8n-packages/io/directory/directory-package-reader.ts b/packages/cli/src/modules/n8n-packages/io/directory/directory-package-reader.ts index 49b4e5e54d1..402bb55b8e1 100644 --- a/packages/cli/src/modules/n8n-packages/io/directory/directory-package-reader.ts +++ b/packages/cli/src/modules/n8n-packages/io/directory/directory-package-reader.ts @@ -3,7 +3,7 @@ import type { Stats } from 'node:fs'; import { lstat, readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { PackageManifest } from '../../spec/manifest.schema'; import type { PackageReader } from '../package-reader'; diff --git a/packages/cli/src/modules/n8n-packages/io/tar/tar-package-reader.ts b/packages/cli/src/modules/n8n-packages/io/tar/tar-package-reader.ts index fc91308d02f..c38faa3889c 100644 --- a/packages/cli/src/modules/n8n-packages/io/tar/tar-package-reader.ts +++ b/packages/cli/src/modules/n8n-packages/io/tar/tar-package-reader.ts @@ -2,7 +2,7 @@ import { jsonParse } from 'n8n-workflow'; import path from 'node:path'; import { Parser, type ReadEntry } from 'tar'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { PackageManifest } from '../../spec/manifest.schema'; import type { PackageReader } from '../package-reader'; diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts index 748405498ef..56dc1798744 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts @@ -1,11 +1,10 @@ +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; import { N8N_VERSION } from '@/constants'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { buildImportResult, toPackageSummary } from './engine/import-result'; import { emitPackageImportedEvent, type ImportOutcome } from './engine/import-telemetry'; diff --git a/packages/cli/src/modules/n8n-packages/package-failure-classifier.ts b/packages/cli/src/modules/n8n-packages/package-failure-classifier.ts index 8a9353632d5..6c25e50578a 100644 --- a/packages/cli/src/modules/n8n-packages/package-failure-classifier.ts +++ b/packages/cli/src/modules/n8n-packages/package-failure-classifier.ts @@ -1,10 +1,12 @@ import { UserError } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { + BadRequestError, + ConflictError, + ForbiddenError, + NotFoundError, + UnprocessableRequestError, +} from '@n8n/errors'; import { PackageEntityAccessDeniedError, diff --git a/packages/cli/src/modules/n8n-packages/utils/import-package-upload.ts b/packages/cli/src/modules/n8n-packages/utils/import-package-upload.ts index 452dfb78fa2..47b0214d51f 100644 --- a/packages/cli/src/modules/n8n-packages/utils/import-package-upload.ts +++ b/packages/cli/src/modules/n8n-packages/utils/import-package-upload.ts @@ -5,7 +5,7 @@ import { import type { GlobalConfig } from '@n8n/config'; import multer from 'multer'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; /** * Allowed keys on `req.body` after multipart parsing. Includes `package` because diff --git a/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-key.service.integration.test.ts b/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-key.service.integration.test.ts index 1eef06ede58..1504ddd915b 100644 --- a/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-key.service.integration.test.ts +++ b/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-key.service.integration.test.ts @@ -6,7 +6,7 @@ import type { CryptoKey } from 'jose'; import { CompactEncrypt, compactDecrypt, exportJWK, generateKeyPair, importJWK } from 'jose'; import { Cipher, InstanceSettings } from 'n8n-core'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { OAuthJweKeyService } from '../oauth-jwe-key.service'; import { diff --git a/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-key.service.test.ts b/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-key.service.test.ts index bff1e26f2d8..a5f8f79be07 100644 --- a/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-key.service.test.ts +++ b/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-key.service.test.ts @@ -7,7 +7,7 @@ import { exportJWK, generateKeyPair } from 'jose'; import type { JWK } from 'jose'; import { Cipher } from 'n8n-core'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { OAuthJweKeyService } from '../oauth-jwe-key.service'; import { JWE_KEY_ALGORITHMS, JWE_PRIVATE_KEY_TYPE } from '../oauth-jwe.constants'; diff --git a/packages/cli/src/modules/oauth-jwe/oauth-jwe-key.service.ts b/packages/cli/src/modules/oauth-jwe/oauth-jwe-key.service.ts index f55a9948d23..b1979c22d7a 100644 --- a/packages/cli/src/modules/oauth-jwe/oauth-jwe-key.service.ts +++ b/packages/cli/src/modules/oauth-jwe/oauth-jwe-key.service.ts @@ -7,7 +7,7 @@ import { exportJWK, generateKeyPair, importJWK } from 'jose'; import { Cipher } from 'n8n-core'; import { jsonParse, UnexpectedError } from 'n8n-workflow'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { JWE_KEY_ALGORITHMS, diff --git a/packages/cli/src/modules/oauth-server/__tests__/chat-trigger-resource.resolver.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/chat-trigger-resource.resolver.api.test.ts index 2cc9192ed3f..c111a3f589c 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/chat-trigger-resource.resolver.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/chat-trigger-resource.resolver.api.test.ts @@ -19,7 +19,7 @@ import { setupTestServer } from '@test-integration/utils'; import { AuthService } from '@/auth/auth.service'; import { AUTH_COOKIE_NAME } from '@/constants'; import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { ProtectedResourceRegistry } from '@/services/protected-resource.registry'; import { UrlService } from '@n8n/backend-services'; diff --git a/packages/cli/src/modules/oauth-server/__tests__/chat-trigger-test-resource.resolver.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/chat-trigger-test-resource.resolver.api.test.ts index ff7617427de..059dffe339c 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/chat-trigger-test-resource.resolver.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/chat-trigger-test-resource.resolver.api.test.ts @@ -16,7 +16,7 @@ import { createMember, createOwner } from '@test-integration/db/users'; import { setupTestServer } from '@test-integration/utils'; import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { ProtectedResourceRegistry } from '@/services/protected-resource.registry'; import { UrlService } from '@n8n/backend-services'; import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service'; diff --git a/packages/cli/src/modules/oauth-server/__tests__/form-trigger-resource.resolver.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/form-trigger-resource.resolver.api.test.ts index ae8df24c347..e26922c5fed 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/form-trigger-resource.resolver.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/form-trigger-resource.resolver.api.test.ts @@ -16,7 +16,7 @@ import { createOwner, createMember } from '@test-integration/db/users'; import { setupTestServer } from '@test-integration/utils'; import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { ProtectedResourceRegistry } from '@/services/protected-resource.registry'; import { UrlService } from '@n8n/backend-services'; diff --git a/packages/cli/src/modules/oauth-server/__tests__/form-trigger-test-resource.resolver.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/form-trigger-test-resource.resolver.api.test.ts index 9794ea970df..ce7f61a1bbb 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/form-trigger-test-resource.resolver.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/form-trigger-test-resource.resolver.api.test.ts @@ -17,7 +17,7 @@ import { setupTestServer } from '@test-integration/utils'; import { OAuthClientRepository } from '../database/repositories/oauth-client.repository'; import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { ProtectedResourceRegistry } from '@/services/protected-resource.registry'; import { UrlService } from '@n8n/backend-services'; import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service'; diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.service.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.service.test.ts index 1b662fbaa04..8d5686731c6 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.service.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.service.test.ts @@ -16,7 +16,7 @@ import { } from '@/services/protected-resource.registry'; import { UrlService } from '@n8n/backend-services'; import type { User } from '@n8n/db'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; const issuer = 'https://n8n.example.com'; diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-flow.service.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-flow.service.api.test.ts index 3a471ae9921..7a7ae612c24 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-flow.service.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-flow.service.api.test.ts @@ -12,7 +12,7 @@ import { } from 'n8n-workflow'; import { randomUUID } from 'node:crypto'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { UrlService } from '@n8n/backend-services'; import { createOwner, createMember } from '@test-integration/db/users'; import { setupTestServer } from '@test-integration/utils'; diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts index 6581dc81114..ae770f00f7b 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts @@ -3,6 +3,7 @@ import { InvalidTargetError, } from '@modelcontextprotocol/sdk/server/auth/errors.js'; import { Logger, type LicenseState, type ModuleRegistry } from '@n8n/backend-common'; +import type { EventService, UrlService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import type { User } from '@n8n/db'; @@ -12,12 +13,10 @@ import { mock } from 'vitest-mock-extended'; import { AuthService } from '@/auth/auth.service'; import type { PostHogClient } from '@/posthog'; -import type { EventService } from '@/events/event.service'; import { McpProtectedResource } from '@/modules/mcp/mcp-protected-resource'; import type { McpConfig } from '@/modules/mcp/mcp.config'; import type { McpSettingsService } from '@/modules/mcp/mcp.settings.service'; import { ProtectedResourceRegistry } from '@/services/protected-resource.registry'; -import type { UrlService } from '@n8n/backend-services'; import { UserManagementMailer } from '@/user-management/email'; import type { AuthorizationCode } from '../database/entities/oauth-authorization-code.entity'; diff --git a/packages/cli/src/modules/oauth-server/__tests__/test-trigger-resource-lifetime.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/test-trigger-resource-lifetime.api.test.ts index 883dbd8ea4f..ea2ea4aca5d 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/test-trigger-resource-lifetime.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/test-trigger-resource-lifetime.api.test.ts @@ -12,7 +12,7 @@ import { setupTestServer } from '@test-integration/utils'; import { OAuthClientRepository } from '@/modules/oauth-server/database/repositories/oauth-client.repository'; import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { ProtectedResourceRegistry } from '@/services/protected-resource.registry'; import { UrlService } from '@n8n/backend-services'; import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service'; diff --git a/packages/cli/src/modules/oauth-server/__tests__/workflow-mcp-test-trigger-resource.resolver.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/workflow-mcp-test-trigger-resource.resolver.api.test.ts index d197ebf1cf7..f37a0d01dd5 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/workflow-mcp-test-trigger-resource.resolver.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/workflow-mcp-test-trigger-resource.resolver.api.test.ts @@ -11,7 +11,7 @@ import { setupTestServer } from '@test-integration/utils'; import { MCP_TRIGGER_NODE_TYPE } from '@/constants'; import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { UrlService } from '@n8n/backend-services'; import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service'; diff --git a/packages/cli/src/modules/oauth-server/__tests__/workflow-mcp-trigger-authorization.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/workflow-mcp-trigger-authorization.api.test.ts index 00dd0d5a605..c71d2fcef2b 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/workflow-mcp-trigger-authorization.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/workflow-mcp-trigger-authorization.api.test.ts @@ -16,7 +16,7 @@ import { setupTestServer } from '@test-integration/utils'; import { MCP_TRIGGER_NODE_TYPE } from '@/constants'; import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { UrlService } from '@n8n/backend-services'; import { JwtService } from '@/services/jwt.service'; diff --git a/packages/cli/src/modules/oauth-server/__tests__/workflow-mcp-trigger-resource.resolver.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/workflow-mcp-trigger-resource.resolver.api.test.ts index 65c0f7dffd7..fd927334d2a 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/workflow-mcp-trigger-resource.resolver.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/workflow-mcp-trigger-resource.resolver.api.test.ts @@ -11,7 +11,7 @@ import { setupTestServer } from '@test-integration/utils'; import { MCP_TRIGGER_NODE_TYPE } from '@/constants'; import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { ProtectedResourceRegistry } from '@/services/protected-resource.registry'; import { UrlService } from '@n8n/backend-services'; diff --git a/packages/cli/src/modules/oauth-server/__tests__/workflow-webhook-test-trigger-resource.resolver.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/workflow-webhook-test-trigger-resource.resolver.api.test.ts index dbca50a5615..7bda07c1a5e 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/workflow-webhook-test-trigger-resource.resolver.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/workflow-webhook-test-trigger-resource.resolver.api.test.ts @@ -16,7 +16,7 @@ import { createOwner, createMember } from '@test-integration/db/users'; import { setupTestServer } from '@test-integration/utils'; import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { ProtectedResourceRegistry } from '@/services/protected-resource.registry'; import { UrlService } from '@n8n/backend-services'; import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service'; diff --git a/packages/cli/src/modules/oauth-server/__tests__/workflow-webhook-trigger-resource.resolver.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/workflow-webhook-trigger-resource.resolver.api.test.ts index edb88938755..dcc27bae4e7 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/workflow-webhook-trigger-resource.resolver.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/workflow-webhook-trigger-resource.resolver.api.test.ts @@ -17,7 +17,7 @@ import { setupTestServer } from '@test-integration/utils'; import { OAuthServerService } from '@/modules/oauth-server/oauth-server.service'; import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { ProtectedResourceRegistry } from '@/services/protected-resource.registry'; import { UrlService } from '@n8n/backend-services'; diff --git a/packages/cli/src/modules/oauth-server/oauth-clients.controller.ts b/packages/cli/src/modules/oauth-server/oauth-clients.controller.ts index 2277f1238e4..4c8573d2210 100644 --- a/packages/cli/src/modules/oauth-server/oauth-clients.controller.ts +++ b/packages/cli/src/modules/oauth-server/oauth-clients.controller.ts @@ -12,8 +12,7 @@ import { Delete, Get, GlobalScope, Param, Query, RestController } from '@n8n/dec import { hasGlobalScope } from '@n8n/permissions'; import type { Response } from 'express'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import { OAuthServerService } from './oauth-server.service'; diff --git a/packages/cli/src/modules/oauth-server/oauth-consent.controller.ts b/packages/cli/src/modules/oauth-server/oauth-consent.controller.ts index 96cca337058..fce3e5259e0 100644 --- a/packages/cli/src/modules/oauth-server/oauth-consent.controller.ts +++ b/packages/cli/src/modules/oauth-server/oauth-consent.controller.ts @@ -7,7 +7,7 @@ import { UserError } from 'n8n-workflow'; import { ApproveConsentRequestDto } from './dto/approve-consent-request.dto'; import { OAuthConsentService } from './oauth-consent.service'; import { OAuthSessionService } from './oauth-session.service'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; @RestController('/consent') export class OAuthConsentController { diff --git a/packages/cli/src/modules/oauth-server/oauth-consent.service.ts b/packages/cli/src/modules/oauth-server/oauth-consent.service.ts index 5829df3bf3d..77c2cec1817 100644 --- a/packages/cli/src/modules/oauth-server/oauth-consent.service.ts +++ b/packages/cli/src/modules/oauth-server/oauth-consent.service.ts @@ -9,7 +9,7 @@ import { UserConsentRepository } from './database/repositories/oauth-user-consen import { OAuthAuthorizationCodeService } from './oauth-authorization-code.service'; import { OAuthSessionService, type OAuthSessionPayload } from './oauth-session.service'; import { OAuthHelpers } from './oauth.helpers'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { ProtectedResourceRegistry, type ProtectedResource, diff --git a/packages/cli/src/modules/oauth-server/oauth-flow.service.ts b/packages/cli/src/modules/oauth-server/oauth-flow.service.ts index 9cfc4ebd7b9..6d6873e0dcc 100644 --- a/packages/cli/src/modules/oauth-server/oauth-flow.service.ts +++ b/packages/cli/src/modules/oauth-server/oauth-flow.service.ts @@ -8,11 +8,10 @@ import { UserError, type N8nOAuth2FlowResult, type N8nOAuth2RefreshResult } from import { createHash, randomBytes } from 'node:crypto'; import pkceChallenge from 'pkce-challenge'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService, UrlService } from '@n8n/backend-services'; import { OAuthTokenVerifierProxy } from '@/services/oauth-token-verifier-proxy.service'; import type { N8nOAuth2Flow } from '@/services/oauth2-flow-proxy.service'; import { ProtectedResourceRegistry } from '@/services/protected-resource.registry'; -import { UrlService } from '@n8n/backend-services'; import { OAuthServerService } from './oauth-server.service'; diff --git a/packages/cli/src/modules/oauth-server/oauth-server.service.ts b/packages/cli/src/modules/oauth-server/oauth-server.service.ts index ec2cd83fdff..3ae38b85755 100644 --- a/packages/cli/src/modules/oauth-server/oauth-server.service.ts +++ b/packages/cli/src/modules/oauth-server/oauth-server.service.ts @@ -16,6 +16,7 @@ import type { import type { McpClientConnectedPeriod, McpClientTypeFilter } from '@n8n/api-types'; import { getMcpClientType, MCP_CLIENT_TYPE_FILTER_BUCKETS } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService, UrlService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import { INSTANCE_MCP_RESOURCE_ID } from '@n8n/constants'; import type { User } from '@n8n/db'; @@ -24,13 +25,11 @@ import { hasGlobalScope } from '@n8n/permissions'; import type { Response } from 'express'; import { AuthService } from '@/auth/auth.service'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { ForbiddenError } from '@n8n/errors'; import { ProtectedResourceRegistry, type ProtectedResource, } from '@/services/protected-resource.registry'; -import { UrlService } from '@n8n/backend-services'; import { UserManagementMailer } from '@/user-management/email'; import { OAuthClient } from './database/entities/oauth-client.entity'; diff --git a/packages/cli/src/modules/oauth-server/oauth.errors.ts b/packages/cli/src/modules/oauth-server/oauth.errors.ts index 1a2a8464e85..a75b4beafc0 100644 --- a/packages/cli/src/modules/oauth-server/oauth.errors.ts +++ b/packages/cli/src/modules/oauth-server/oauth.errors.ts @@ -1,6 +1,6 @@ import { ServerError } from '@modelcontextprotocol/sdk/server/auth/errors.js'; -import { AuthError } from '@/errors/response-errors/auth.error'; +import { AuthError } from '@n8n/errors'; // The wording references MCP because the instance MCP server is currently the // only protected resource; clients registered via its DCR endpoint see this diff --git a/packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts b/packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts index 1f1a38edae6..b77a5548d08 100644 --- a/packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts +++ b/packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts @@ -1,4 +1,5 @@ import type { LicenseState, Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import type { NodeExecuteAfterContext, NodeExecuteBeforeContext, @@ -9,7 +10,6 @@ import { mock } from 'vitest-mock-extended'; import { Workflow } from 'n8n-workflow'; import type { INodeTypes, IRun, IRunExecutionData, WorkflowExecuteMode } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import type { OwnershipService } from '@/services/ownership.service'; diff --git a/packages/cli/src/modules/otel/__tests__/support/otel-integration-utils.ts b/packages/cli/src/modules/otel/__tests__/support/otel-integration-utils.ts index dbc1c192a7f..cea55ad8ae5 100644 --- a/packages/cli/src/modules/otel/__tests__/support/otel-integration-utils.ts +++ b/packages/cli/src/modules/otel/__tests__/support/otel-integration-utils.ts @@ -26,6 +26,7 @@ import { TestNodeWithTracing } from './test-node-with-tracing'; import { OtelSettingsService } from '../../otel-settings.service'; import { OtelConfig } from '../../otel.config'; import { OtelService } from '../../otel.service'; +import type { TracingContext } from '../../tracing-context'; const BASE_DIR = path.resolve(__dirname, '../../../../../..'); @@ -117,7 +118,7 @@ export async function executeWorkflow( options: { mode?: 'webhook' | 'trigger' | 'manual' | 'retry'; retryOf?: string; - tracingContext?: { traceparent: string; tracestate?: string }; + tracingContext?: TracingContext; triggerData?: IDataObject; } = {}, ): Promise { diff --git a/packages/cli/src/modules/otel/otel-lifecycle-handler.ts b/packages/cli/src/modules/otel/otel-lifecycle-handler.ts index dd8e6cab81c..3ec8f1c4029 100644 --- a/packages/cli/src/modules/otel/otel-lifecycle-handler.ts +++ b/packages/cli/src/modules/otel/otel-lifecycle-handler.ts @@ -1,4 +1,5 @@ import { LicenseState, Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { OnLifecycleEvent, OnPubSubEvent } from '@n8n/decorators'; import type { WorkflowExecuteBeforeContext, @@ -10,7 +11,6 @@ import type { import { Service } from '@n8n/di'; import type { ICustomTelemetryTag, WorkflowExecuteMode } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { ExecutionLevelTracer } from './execution-level-tracer'; diff --git a/packages/cli/src/modules/policy-infrastructure/README.md b/packages/cli/src/modules/policy-infrastructure/README.md index c5d2a236dcd..7be6e0d08e1 100644 --- a/packages/cli/src/modules/policy-infrastructure/README.md +++ b/packages/cli/src/modules/policy-infrastructure/README.md @@ -33,7 +33,7 @@ flowchart LR end subgraph module["policy-infrastructure module (default, disable to opt out)"] - pds["PolicyDecisionService
deadline per check · all checks must pass
crash or timeout = fail closed
one audit line per veto"] + pds["PolicyDecisionService
deadline per check · all checks must pass
crash or timeout = fail closed
one audit line + one log streaming event per veto"] registry["PolicyCheckMetadata
registry in @n8n/decorators"] checks["@PolicyCheck() classes
onWorkflowSave · onWorkflowPublish · …"] end @@ -103,6 +103,16 @@ request. A check can compare it with `workflow` to judge only what the save adds Deadlines are tight on the two points that sit inside a running execution. A wedged policy store there pins worker slots instead of failing one request. +A one-off CLI command registers no check unless it calls +`BaseCommand.initPolicyEnforcement()`. `import:workflow`, `import:credentials`, +`execute` and `execute-batch` call it. + +Known gaps: `import:entities`, `publish:workflow` and `update:workflow` run no +check. `import:entities` restores the policy tables in the same run, so a check +against the stored rules would judge the content by the wrong policy. The +`workflowStart` check still refuses to run a blocked workflow that one of these +commands wrote. + ## Contexts Each point hands its check a different context. The types are in @@ -189,6 +199,35 @@ Two logging facts to know before relying on this: Policy _mutation_ audit — who changed a policy — is a different surface, owned by the feature that has a policy to mutate, on the existing audit-event infrastructure. +## The log streaming event + +The same emit site also sends `n8n.audit.policy.decision.blocked` to log streaming, so a +SIEM sees every block but one kind (see below). The payload is the audit line plus the actor. It does not depend on +the log format or on `N8N_LOG_SCOPES`. + +- **The host names the actor.** Every `enforce*` call takes a `PolicyActor` as its second + argument. Checks never see it. It is a user when the host is a request with an + authenticated user. Otherwise it is the system with a reason: `execution`, `cli-import`, + `activation`, `publication`, `integration` or `log-streaming`. +- **A run never names a user.** Not every execution path knows reliably who started the + run, so a block inside a run is `execution` and carries the `executionId`. Use the + execution to find who started it. This includes manual runs and sub-workflows. +- **The payload says which.** `actorType` is `user` or `system`. A system actor adds + `systemReason` and has `userId: null`. +- **`userId` always means the accountable human.** A future agent actor adds its own + fields and fills `userId` with the user it acts for, so SIEM rules on `userId` keep + their meaning. +- **The user fields are the same on every block.** A host that knows only the user id + passes `{ id }`, and the relay reads the rest of the user before it sends the event. The + fields are redactable, the same as on every other `n8n.audit.*` event. +- **A block on a destination credential sends no event.** A webhook destination decrypts + its credential on every delivery. If a policy blocks it, the event would go to the same + destination and block again. So the relay drops `log-streaming` blocks. The audit line + still records them. +- **Violations have fixed keys.** A field a check left out is `null`, not missing. +- **Workers and webhook processes send it too.** The log streaming module runs on + every instance type, so an event from a worker goes straight to the destinations. + ## The seal A cleared write needs a `PolicyCleared` token minted by the enforcement point. @@ -240,7 +279,7 @@ registry is read on every decision, so load order cannot hide a check. | File | Role | | -------------------------------------------- | -------------------------------------------------------------------------------------------- | | `policy-infrastructure.module.ts` | Registers `PolicyDecisionService` into the enforcement point and loads the lifecycle handler | -| `policy-decision.service.ts` | Runs the checks with deadlines, combines their results, and emits the audit line | +| `policy-decision.service.ts` | Runs the checks with deadlines, combines their results, and emits the audit line and event | | `policy-decision-audit.ts` | The audit line's shape and how it reads a target off each context | | `policy-lifecycle-handler.ts` | The `workflowStart` host, one hook for every way an execution starts | | `policy-check-failed.error.ts` | The 503 for a check that did not answer | diff --git a/packages/cli/src/modules/policy-infrastructure/__tests__/policy-decision.service.test.ts b/packages/cli/src/modules/policy-infrastructure/__tests__/policy-decision.service.test.ts index 32adb3aae6f..78f8050fa3c 100644 --- a/packages/cli/src/modules/policy-infrastructure/__tests__/policy-decision.service.test.ts +++ b/packages/cli/src/modules/policy-infrastructure/__tests__/policy-decision.service.test.ts @@ -1,3 +1,4 @@ +import type { EventService } from '@n8n/backend-services'; import { mockLogger } from '@n8n/backend-test-utils'; import { type CredentialDecryptContext, @@ -16,6 +17,7 @@ import { OperationalError } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; import { classifyHttpError } from '@/errors/http-error-classifier'; +import type { PolicyActor } from '@/policy/policy-enforcement-backend'; import { serializeInternalRestError } from '@/errors/http-error-serializers'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { PolicyViolationError } from '@/policy/policy-violation.error'; @@ -198,7 +200,7 @@ class StartOnlyCheck implements RegisteredPolicyCheck { const serviceWith = (...classes: PolicyCheckClass[]) => { const metadata = mock({ getClasses: () => classes }); - return new PolicyDecisionService(mockLogger(), metadata); + return new PolicyDecisionService(mockLogger(), metadata, mock()); }; /** @@ -208,23 +210,33 @@ const serviceWith = (...classes: PolicyCheckClass[]) => { const auditedServiceWith = (...classes: PolicyCheckClass[]) => { const logger = mockLogger(); const metadata = mock({ getClasses: () => classes }); + const eventService = mock(); return { - service: new PolicyDecisionService(logger, metadata), + service: new PolicyDecisionService(logger, metadata, eventService), audit: vi.mocked(logger.scoped('policy').warn), + emit: eventService.emit, }; }; +const alice = { id: 'user-1', email: 'alice@example.com', role: { slug: 'global:member' } }; +const asAlice: PolicyActor = { kind: 'user', user: alice }; +const unattended: PolicyActor = { kind: 'system', reason: 'execution' }; + describe('PolicyDecisionService', () => { describe('enforce', () => { it('allows when no checks are registered', async () => { - const decision = await serviceWith().enforce('workflowSave', saveContext); + const decision = await serviceWith().enforce('workflowSave', saveContext, unattended); expect(decision).toEqual({ violations: [] }); }); it('allows when every registered check is silent', async () => { - const decision = await serviceWith(SilentCheck).enforce('workflowSave', saveContext); + const decision = await serviceWith(SilentCheck).enforce( + 'workflowSave', + saveContext, + unattended, + ); expect(decision).toEqual({ violations: [] }); }); @@ -233,6 +245,7 @@ describe('PolicyDecisionService', () => { const decision = await serviceWith(SlackCheck, CodeCheck).enforce( 'workflowSave', saveContext, + unattended, ); expect(decision.violations).toEqual([slackBlocked, codeBlocked]); @@ -242,6 +255,7 @@ describe('PolicyDecisionService', () => { const decision = await serviceWith(SlackCheck, CodeCheck).enforce( 'workflowSave', saveContext, + unattended, ); expect(decision.policyVersions).toEqual([{ scope: 'instance', version: 4 }]); @@ -249,7 +263,7 @@ describe('PolicyDecisionService', () => { it('blocks when a check throws, without leaking why', async () => { const error = await serviceWith(SilentCheck, BrokenCheck) - .enforce('workflowSave', saveContext) + .enforce('workflowSave', saveContext, unattended) .catch((e: unknown) => e); expect(error).toBeInstanceOf(PolicyCheckFailedError); @@ -265,7 +279,7 @@ describe('PolicyDecisionService', () => { try { const pending = serviceWith(HangingCheck) - .enforce('workflowStart', startContext) + .enforce('workflowStart', startContext, unattended) .catch((e: unknown) => e); await vi.advanceTimersByTimeAsync(250); @@ -281,7 +295,7 @@ describe('PolicyDecisionService', () => { try { const pending = serviceWith(AbortAwareCheck) - .enforce('workflowStart', startContext) + .enforce('workflowStart', startContext, unattended) .catch((e: unknown) => e); await vi.advanceTimersByTimeAsync(250); @@ -296,7 +310,7 @@ describe('PolicyDecisionService', () => { it('only runs checks that implement the point', async () => { const service = serviceWith(StartOnlyCheck); - await service.enforce('workflowSave', saveContext); + await service.enforce('workflowSave', saveContext, unattended); expect(Container.get(StartOnlyCheck).onWorkflowStart).not.toHaveBeenCalled(); }); @@ -319,7 +333,9 @@ describe('PolicyDecisionService', () => { const service = serviceWith(SilentCheck); expect(service.hasChecksFor('workflowSave')).toBe(true); - expect((await service.enforce('workflowSave', saveContext)).violations).toEqual([]); + expect((await service.enforce('workflowSave', saveContext, unattended)).violations).toEqual( + [], + ); expect(service.hasChecksFor('workflowStart')).toBe(false); }); }); @@ -355,7 +371,7 @@ describe('PolicyDecisionService', () => { it('writes one line naming the point, the objecting check and the violation', async () => { const { service, audit } = auditedServiceWith(SlackCheck); - await service.enforce('workflowSave', updateContext); + await service.enforce('workflowSave', updateContext, unattended); expect(audit).toHaveBeenCalledTimes(1); expect(audit).toHaveBeenCalledWith('Policy blocked workflowSave', { @@ -374,7 +390,7 @@ describe('PolicyDecisionService', () => { it('keeps the violation message out of the line', async () => { const { service, audit } = auditedServiceWith(SlackCheck); - await service.enforce('workflowSave', saveContext); + await service.enforce('workflowSave', saveContext, unattended); expect(JSON.stringify(audit.mock.calls[0][1])).not.toContain('is not available'); }); @@ -382,7 +398,7 @@ describe('PolicyDecisionService', () => { it('writes one line for the decision, not one per objecting check', async () => { const { service, audit } = auditedServiceWith(SilentCheck, SlackCheck, CodeCheck); - await service.enforce('workflowSave', saveContext); + await service.enforce('workflowSave', saveContext, unattended); expect(audit).toHaveBeenCalledTimes(1); expect(audit.mock.calls[0][1]).toMatchObject({ @@ -394,7 +410,7 @@ describe('PolicyDecisionService', () => { it('names the workflow when a create has no id to name', async () => { const { service, audit } = auditedServiceWith(SlackCheck); - await service.enforce('workflowSave', createContext); + await service.enforce('workflowSave', createContext, unattended); expect(audit.mock.calls[0][1]).toMatchObject({ workflowId: null, @@ -405,7 +421,7 @@ describe('PolicyDecisionService', () => { it('does not record a create id the client supplied, which the seal discards too', async () => { const { service, audit } = auditedServiceWith(SlackCheck); - await service.enforce('workflowSave', createWithClaimedIdContext); + await service.enforce('workflowSave', createWithClaimedIdContext, unattended); expect(audit.mock.calls[0][1]).toMatchObject({ workflowId: null, @@ -417,7 +433,7 @@ describe('PolicyDecisionService', () => { it('records the project a transfer moves into', async () => { const { service, audit } = auditedServiceWith(OtherPointsCheck); - await service.enforce('workflowTransfer', transferContext); + await service.enforce('workflowTransfer', transferContext, unattended); expect(audit.mock.calls[0][1]).toMatchObject({ projectId: 'proj-2' }); }); @@ -425,7 +441,7 @@ describe('PolicyDecisionService', () => { it('records a credential create by type, with no id', async () => { const { service, audit } = auditedServiceWith(OtherPointsCheck); - await service.enforce('credentialSave', credentialCreateContext); + await service.enforce('credentialSave', credentialCreateContext, unattended); expect(audit.mock.calls[0][1]).toMatchObject({ credentialId: null, @@ -438,7 +454,7 @@ describe('PolicyDecisionService', () => { it('records a credential update by its stored id', async () => { const { service, audit } = auditedServiceWith(OtherPointsCheck); - await service.enforce('credentialSave', credentialUpdateContext); + await service.enforce('credentialSave', credentialUpdateContext, unattended); expect(audit.mock.calls[0][1]).toMatchObject({ credentialId: 'cred-1', @@ -450,7 +466,7 @@ describe('PolicyDecisionService', () => { it('records the credential and the node asking for it', async () => { const { service, audit } = auditedServiceWith(OtherPointsCheck); - await service.enforce('credentialDecrypt', decryptContext); + await service.enforce('credentialDecrypt', decryptContext, unattended); expect(audit.mock.calls[0][1]).toMatchObject({ credentialId: 'cred-1', @@ -465,7 +481,7 @@ describe('PolicyDecisionService', () => { const { service, audit } = auditedServiceWith(SilentCheck, BrokenCheck); const error = (await service - .enforce('workflowSave', saveContext) + .enforce('workflowSave', saveContext, unattended) .catch((e: unknown) => e)) as PolicyCheckFailedError; expect(audit).toHaveBeenCalledTimes(1); @@ -483,7 +499,7 @@ describe('PolicyDecisionService', () => { it('keeps what the answered checks said on a check failure line', async () => { const { service, audit } = auditedServiceWith(SlackCheck, BrokenCheck); - await service.enforce('workflowSave', saveContext).catch(() => {}); + await service.enforce('workflowSave', saveContext, unattended).catch(() => {}); expect(audit.mock.calls[0][1]).toMatchObject({ outcome: 'checkFailure', @@ -495,7 +511,7 @@ describe('PolicyDecisionService', () => { it('stays silent when every check is silent', async () => { const { service, audit } = auditedServiceWith(SilentCheck); - await service.enforce('workflowSave', saveContext); + await service.enforce('workflowSave', saveContext, unattended); expect(audit).not.toHaveBeenCalled(); }); @@ -503,7 +519,7 @@ describe('PolicyDecisionService', () => { it('stays silent when no check is registered', async () => { const { service, audit } = auditedServiceWith(); - await service.enforce('workflowSave', saveContext); + await service.enforce('workflowSave', saveContext, unattended); expect(audit).not.toHaveBeenCalled(); }); @@ -530,7 +546,7 @@ describe('PolicyDecisionService', () => { const proxy = new PolicyEnforcementService(); proxy.setImplementation(service); - await expect(proxy.enforceWorkflowSave(saveContext)).rejects.toBeInstanceOf( + await expect(proxy.enforceWorkflowSave(saveContext, unattended)).rejects.toBeInstanceOf( PolicyViolationError, ); expect(audit).toHaveBeenCalledTimes(1); @@ -541,17 +557,99 @@ describe('PolicyDecisionService', () => { }); }); + describe('log streaming event', () => { + it('emits one event with the point, checks, violations, versions and user', async () => { + const { service, emit } = auditedServiceWith(SlackCheck); + const proxy = new PolicyEnforcementService(); + proxy.setImplementation(service); + + await expect(proxy.enforceWorkflowSave(updateContext, asAlice)).rejects.toBeInstanceOf( + PolicyViolationError, + ); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledWith('policy-decision-blocked', { + point: 'workflowSave', + outcome: 'violation', + durationMs: expect.any(Number), + checkIds: ['node-types'], + violations: [slackAudited], + policyVersions: [{ scope: 'instance', version: 4 }], + workflowId: 'wf-1', + workflowName: 'My workflow', + projectId: 'proj-1', + actorType: 'user', + user: alice, + }); + }); + + it('emits a check failure with its correlation ids and no check internals', async () => { + const { service, emit } = auditedServiceWith(BrokenCheck); + + const error = (await service + .enforce('workflowSave', saveContext, asAlice) + .catch((e: unknown) => e)) as PolicyCheckFailedError; + + expect(emit).toHaveBeenCalledTimes(1); + const [, payload] = emit.mock.calls[0]; + expect(payload).toMatchObject({ + outcome: 'checkFailure', + correlationIds: error.meta.correlationIds, + }); + expect(JSON.stringify(payload)).not.toContain('policy store'); + }); + + it('sends a null user and the reason for a system actor', async () => { + const { service, emit } = auditedServiceWith(SlackCheck); + + await service.enforce('workflowSave', saveContext, unattended); + + expect(emit).toHaveBeenCalledWith( + 'policy-decision-blocked', + expect.objectContaining({ actorType: 'system', user: null, systemReason: 'execution' }), + ); + }); + + it('still writes the decision line alongside the event', async () => { + const { service, audit, emit } = auditedServiceWith(SlackCheck); + + await service.enforce('workflowSave', saveContext, asAlice); + + expect(audit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledTimes(1); + }); + + it('emits nothing for an allowed action', async () => { + const { service, emit } = auditedServiceWith(SilentCheck); + + await service.enforce('workflowSave', saveContext, asAlice); + + expect(emit).not.toHaveBeenCalled(); + }); + + it('emits nothing for an evaluate, even one that finds violations or breaks', async () => { + const { service, emit } = auditedServiceWith(SlackCheck, BrokenCheck); + + const decision = await service.evaluate('workflowSave', saveContext); + + expect(decision.violations).toEqual([slackBlocked]); + expect(emit).not.toHaveBeenCalled(); + }); + }); + describe('check resolution', () => { it('reads the registry per decision, so a check registered later still runs', async () => { const classes: PolicyCheckClass[] = []; const metadata = mock({ getClasses: () => classes }); - const service = new PolicyDecisionService(mockLogger(), metadata); + const service = new PolicyDecisionService(mockLogger(), metadata, mock()); - expect(await service.enforce('workflowSave', saveContext)).toEqual({ violations: [] }); + expect(await service.enforce('workflowSave', saveContext, unattended)).toEqual({ + violations: [], + }); classes.push(SlackCheck); - const decision = await service.enforce('workflowSave', saveContext); + const decision = await service.enforce('workflowSave', saveContext, unattended); expect(decision.violations).toEqual([slackBlocked]); }); }); @@ -561,7 +659,9 @@ describe('PolicyDecisionService', () => { const proxy = new PolicyEnforcementService(); proxy.setImplementation(serviceWith(SlackCheck)); - const error = await proxy.enforceWorkflowSave(saveContext).catch((e: unknown) => e); + const error = await proxy + .enforceWorkflowSave(saveContext, unattended) + .catch((e: unknown) => e); expect(error).toBeInstanceOf(PolicyViolationError); expect((error as PolicyViolationError).violations).toEqual([slackBlocked]); @@ -571,7 +671,7 @@ describe('PolicyDecisionService', () => { const proxy = new PolicyEnforcementService(); proxy.setImplementation(serviceWith(SilentCheck)); - const token = await proxy.enforceWorkflowSave(saveContext); + const token = await proxy.enforceWorkflowSave(saveContext, unattended); expect(token.subject).toEqual(workflowContentSubject(saveContext.workflow)); expect(token.subject.id).not.toBe('wf-1'); @@ -581,10 +681,10 @@ describe('PolicyDecisionService', () => { const proxy = new PolicyEnforcementService(); proxy.setImplementation(serviceWith(SilentCheck)); - const token = await proxy.enforceWorkflowSave({ - ...saveContext, - storedWorkflow: saveContext.workflow, - }); + const token = await proxy.enforceWorkflowSave( + { ...saveContext, storedWorkflow: saveContext.workflow }, + unattended, + ); expect(token.subject).toEqual({ type: 'workflow', id: 'wf-1' }); }); @@ -593,7 +693,7 @@ describe('PolicyDecisionService', () => { const proxy = new PolicyEnforcementService(); proxy.setImplementation(serviceWith(BrokenCheck)); - await expect(proxy.enforceWorkflowSave(saveContext)).rejects.toBeInstanceOf( + await expect(proxy.enforceWorkflowSave(saveContext, unattended)).rejects.toBeInstanceOf( PolicyCheckFailedError, ); }); @@ -602,7 +702,9 @@ describe('PolicyDecisionService', () => { const proxy = new PolicyEnforcementService(); proxy.setImplementation(serviceWith(BrokenCheck)); - const error = await proxy.enforceWorkflowSave(saveContext).catch((e: unknown) => e); + const error = await proxy + .enforceWorkflowSave(saveContext, unattended) + .catch((e: unknown) => e); const { status, body } = serializeInternalRestError(classifyHttpError(error as Error)); expect(status).toBe(503); diff --git a/packages/cli/src/modules/policy-infrastructure/__tests__/policy-lifecycle-handler.test.ts b/packages/cli/src/modules/policy-infrastructure/__tests__/policy-lifecycle-handler.test.ts index f8ef863e9f2..d0be9620307 100644 --- a/packages/cli/src/modules/policy-infrastructure/__tests__/policy-lifecycle-handler.test.ts +++ b/packages/cli/src/modules/policy-infrastructure/__tests__/policy-lifecycle-handler.test.ts @@ -55,9 +55,19 @@ describe('PolicyLifecycleHandler', () => { it('enforces the workflow start point with the workflow and its owning project', async () => { await handler.onWorkflowExecuteBefore(beforeContext()); - expect(policyEnforcementService.enforceWorkflowStart).toHaveBeenCalledExactlyOnceWith({ - workflow, - projectId: 'proj-1', + expect(policyEnforcementService.enforceWorkflowStart).toHaveBeenCalledExactlyOnceWith( + { workflow, projectId: 'proj-1' }, + { kind: 'system', reason: 'execution', executionId: 'exec-1' }, + ); + }); + + it('names the run, not a user, even for a manual run', async () => { + await handler.onWorkflowExecuteBefore(beforeContext({ mode: 'manual' })); + + expect(policyEnforcementService.enforceWorkflowStart).toHaveBeenCalledWith(expect.anything(), { + kind: 'system', + reason: 'execution', + executionId: 'exec-1', }); }); diff --git a/packages/cli/src/modules/policy-infrastructure/policy-decision-audit.ts b/packages/cli/src/modules/policy-infrastructure/policy-decision-audit.ts index 6aada425930..821f99de3f1 100644 --- a/packages/cli/src/modules/policy-infrastructure/policy-decision-audit.ts +++ b/packages/cli/src/modules/policy-infrastructure/policy-decision-audit.ts @@ -7,16 +7,25 @@ import type { PolicyVersionRef, } from '@n8n/decorators'; -import type { PolicyContext } from '@/policy/policy-enforcement-backend'; +import type { UserLike } from '@/types/user-like.types'; +import type { + PolicyActor, + PolicyContext, + PolicySystemReason, +} from '@/policy/policy-enforcement-backend'; /** Every context, as one union — a generic `PolicyContext` narrows against none. */ type AnyPolicyContext = PolicyContext; -/** The violation as the audit line records it. No `message`: free text saying what the fields say. */ -type AuditedViolation = Pick< - PolicyViolation, - 'checkId' | 'kind' | 'subject' | 'subjectType' | 'scope' | 'matchedRuleId' ->; +type OptionalAuditedKey = 'subject' | 'subjectType' | 'scope' | 'matchedRuleId'; + +/** + * The violation as the audit line records it. Every key is present, `null` when the check left + * it out, so a SIEM can map fixed fields. No `message`: free text saying what the fields say. + */ +type AuditedViolation = Pick & { + [Key in OptionalAuditedKey]-?: Exclude | null; +}; /** * One decision-audit line. @@ -53,20 +62,13 @@ export type PolicyDecisionAudit = { projectId: string | null; }; -const auditedViolation = ({ - checkId, - kind, - subject, - subjectType, - scope, - matchedRuleId, -}: PolicyViolation): AuditedViolation => ({ - checkId, - kind, - subject, - subjectType, - scope, - matchedRuleId, +const auditedViolation = (violation: PolicyViolation): AuditedViolation => ({ + checkId: violation.checkId, + kind: violation.kind, + subject: violation.subject ?? null, + subjectType: violation.subjectType ?? null, + scope: violation.scope ?? null, + matchedRuleId: violation.matchedRuleId ?? null, }); /** @@ -157,3 +159,22 @@ export function decisionAudit({ ...targetOf(context), }; } + +/** + * The actor as the log streaming event records it. `user` is always the accountable human, or + * `null` when there is none, and stays a field of its own so the relay can redact it. + */ +export type AuditedActor = + | { actorType: 'user'; user: UserLike } + | { actorType: 'system'; user: null; systemReason: PolicySystemReason; executionId?: string }; + +export function auditedActor(actor: PolicyActor): AuditedActor { + if (actor.kind === 'user') return { actorType: 'user', user: actor.user }; + + return { + actorType: 'system', + user: null, + systemReason: actor.reason, + ...(actor.executionId !== undefined && { executionId: actor.executionId }), + }; +} diff --git a/packages/cli/src/modules/policy-infrastructure/policy-decision.service.ts b/packages/cli/src/modules/policy-infrastructure/policy-decision.service.ts index f92f8ab410a..980adf4268d 100644 --- a/packages/cli/src/modules/policy-infrastructure/policy-decision.service.ts +++ b/packages/cli/src/modules/policy-infrastructure/policy-decision.service.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import type { EnforcementPoint, PolicyCheckFailure, @@ -11,11 +12,15 @@ import { Container, Service } from '@n8n/di'; import { OperationalError } from 'n8n-workflow'; import { randomUUID } from 'node:crypto'; -import type { PolicyContext, PolicyEnforcementBackend } from '@/policy/policy-enforcement-backend'; +import type { + PolicyActor, + PolicyContext, + PolicyEnforcementBackend, +} from '@/policy/policy-enforcement-backend'; import { PolicyCheckFailedError } from './policy-check-failed.error'; import type { DecisionAuditInput } from './policy-decision-audit'; -import { decisionAudit } from './policy-decision-audit'; +import { auditedActor, decisionAudit } from './policy-decision-audit'; /** * How long one check gets. Tight on the two points that sit inside a running execution: a wedged @@ -106,14 +111,16 @@ function decisionFrom(results: PolicyCheckResult[]): PolicyDecision { * Checks are combined by conjunction: all of them have to pass and every violation is reported, * so a user fixing a workflow sees the whole list rather than one at a time. * - * Either way of blocking writes one audit line from here, the single emit site for enforcement - * observability. `evaluate` stays silent, so previews never pollute the trail. + * Either way of blocking writes one audit line and one log streaming event from here, the single + * emit site for enforcement observability. `evaluate` stays silent, so previews never pollute + * the trail. */ @Service() export class PolicyDecisionService implements PolicyEnforcementBackend { constructor( private readonly logger: Logger, private readonly checkMetadata: PolicyCheckMetadata, + private readonly eventService: EventService, ) { this.logger = this.logger.scoped('policy'); } @@ -121,6 +128,7 @@ export class PolicyDecisionService implements PolicyEnforcementBackend { async enforce( point: Point, context: PolicyContext, + actor: PolicyActor, ): Promise { const startedAt = Date.now(); const { checkIds, results, failures } = await this.runChecks(point, context); @@ -129,7 +137,7 @@ export class PolicyDecisionService implements PolicyEnforcementBackend { const decision = decisionFrom(results); if (failures.length > 0) { - this.audit({ point, context, decision, durationMs, checkIds, failures }); + this.audit({ point, context, decision, durationMs, checkIds, failures }, actor); throw new PolicyCheckFailedError( point, @@ -139,7 +147,7 @@ export class PolicyDecisionService implements PolicyEnforcementBackend { // The proxy throws on any violation, so this is the veto — and the only place it is logged. if (decision.violations.length > 0) { - this.audit({ point, context, decision, durationMs, checkIds }); + this.audit({ point, context, decision, durationMs, checkIds }, actor); } return decision; @@ -162,13 +170,13 @@ export class PolicyDecisionService implements PolicyEnforcementBackend { /** * The one emit site for enforcement observability, so every policy feature gets the same - * line without building one. + * line and log streaming event without building them. * * `warn`, not `info`: a blocked action must survive an operator quietening logs. The * structured half only reaches the console under `N8N_LOG_FORMAT=json` — the text format * prints the message alone — so the message names the point on its own. */ - private audit(input: DecisionAuditInput) { + private audit(input: DecisionAuditInput, actor: PolicyActor) { const line = decisionAudit(input); const message = line.outcome === 'violation' @@ -176,6 +184,7 @@ export class PolicyDecisionService implements PolicyEnforcementBackend { : `Policy could not be verified for ${line.point}, so it was blocked`; this.logger.warn(message, line); + this.eventService.emit('policy-decision-blocked', { ...line, ...auditedActor(actor) }); } private async runChecks( diff --git a/packages/cli/src/modules/policy-infrastructure/policy-lifecycle-handler.ts b/packages/cli/src/modules/policy-infrastructure/policy-lifecycle-handler.ts index 74b8f8f5b62..f48a2856108 100644 --- a/packages/cli/src/modules/policy-infrastructure/policy-lifecycle-handler.ts +++ b/packages/cli/src/modules/policy-infrastructure/policy-lifecycle-handler.ts @@ -33,9 +33,9 @@ export class PolicyLifecycleHandler { // lookup fails the run. The other pre-execution gates let this throw too. const project = await this.ownershipService.getWorkflowProjectCached(ctx.workflow.id); - await this.policyEnforcementService.enforceWorkflowStart({ - workflow: ctx.workflow, - projectId: project.id, - }); + await this.policyEnforcementService.enforceWorkflowStart( + { workflow: ctx.workflow, projectId: project.id }, + { kind: 'system', reason: 'execution', executionId: ctx.executionId }, + ); } } diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotion-config.resolver.integration.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotion-config.resolver.integration.test.ts index 13617199737..2d99b951105 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotion-config.resolver.integration.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotion-config.resolver.integration.test.ts @@ -9,7 +9,7 @@ import type { User } from '@n8n/db'; import { ProjectRepository } from '@n8n/db'; import { Container } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { createOwner } from '@test-integration/db/users'; import { PromotionConfigRepository } from '../database/repositories/promotion-config.repository'; diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotion-connections.service.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotion-connections.service.test.ts index 276f6c351ab..a6aa0cdf1ad 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotion-connections.service.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotion-connections.service.test.ts @@ -8,9 +8,7 @@ import path from 'node:path'; import type { MockedFunction } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ConflictError, ForbiddenError } from '@n8n/errors'; import { userHasScopes } from '@/permissions.ee/check-access'; import type { PromotionConnection } from '../database/entities/promotion-connection.entity'; diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotion-providers.service.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotion-providers.service.test.ts index 3179fdee9b2..5b98ec25a2a 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotion-providers.service.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotion-providers.service.test.ts @@ -2,9 +2,7 @@ import type { CreatePromotionProviderDto, UpdatePromotionProviderDto } from '@n8 import type { Cipher } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ConflictError, NotFoundError } from '@n8n/errors'; import type { PromotionProvider } from '../database/entities/promotion-provider.entity'; import type { PromotionConnectionRepository } from '../database/repositories/promotion-connection.repository'; diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotions-git.service.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotions-git.service.test.ts index 3c9f8af6eaf..a6467d0bdbd 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotions-git.service.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotions-git.service.test.ts @@ -6,8 +6,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; +import { BadRequestError, ServiceUnavailableError } from '@n8n/errors'; import { PromotionsGitService } from '../promotions-git.service'; diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotions-package.integration.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotions-package.integration.test.ts index 7d65cf54a6f..7b9a409d3c5 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotions-package.integration.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotions-package.integration.test.ts @@ -37,7 +37,7 @@ import { mock } from 'vitest-mock-extended'; import { CredentialTypes } from '@/credential-types'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { mockDataTableSizeValidator } from '@/modules/data-table/__tests__/test-helpers'; import { DataTableRepository } from '@/modules/data-table/data-table.repository'; import { DataTableColumnRepository } from '@/modules/data-table/data-table-column.repository'; diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts index 7b93c3b7d02..4fa1e057a8f 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts @@ -7,9 +7,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; +import { BadRequestError, NotFoundError, ServiceUnavailableError } from '@n8n/errors'; import type { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service'; import { MissingWorkflowDependencyPolicy, diff --git a/packages/cli/src/modules/promotions.ee/promotion-change.service.ts b/packages/cli/src/modules/promotions.ee/promotion-change.service.ts index 2ab654087bb..240ddb472a7 100644 --- a/packages/cli/src/modules/promotions.ee/promotion-change.service.ts +++ b/packages/cli/src/modules/promotions.ee/promotion-change.service.ts @@ -11,8 +11,7 @@ import { hasGlobalScope, type Scope } from '@n8n/permissions'; import { jsonParse } from 'n8n-workflow'; import { randomUUID } from 'node:crypto'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { HashingPackageWriter } from '@/modules/n8n-packages/io/hashing-package-writer'; import { PACKAGE_ENTITY_LAYOUT, diff --git a/packages/cli/src/modules/promotions.ee/promotion-changes.controller.ts b/packages/cli/src/modules/promotions.ee/promotion-changes.controller.ts index 5ed5615decb..9da545e5e57 100644 --- a/packages/cli/src/modules/promotions.ee/promotion-changes.controller.ts +++ b/packages/cli/src/modules/promotions.ee/promotion-changes.controller.ts @@ -8,7 +8,7 @@ import { AuthenticatedRequest } from '@n8n/db'; import { Get, GlobalScope, Licensed, Param, Query, RestController } from '@n8n/decorators'; import type { Response } from 'express'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { PromotionChangeService } from './promotion-change.service'; diff --git a/packages/cli/src/modules/promotions.ee/promotion-config.resolver.ts b/packages/cli/src/modules/promotions.ee/promotion-config.resolver.ts index a541284ee9a..ed4c285ca47 100644 --- a/packages/cli/src/modules/promotions.ee/promotion-config.resolver.ts +++ b/packages/cli/src/modules/promotions.ee/promotion-config.resolver.ts @@ -3,8 +3,7 @@ import { promotionConnectionTargetSchema, promotionProviderTypeSchema } from '@n import { ProjectRepository } from '@n8n/db'; import { Service } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import type { PromotionConfig } from './database/entities/promotion-config.entity'; import { PromotionConfigRepository } from './database/repositories/promotion-config.repository'; diff --git a/packages/cli/src/modules/promotions.ee/promotion-conflicts.ts b/packages/cli/src/modules/promotions.ee/promotion-conflicts.ts index 51a01c8a006..1d19886e88a 100644 --- a/packages/cli/src/modules/promotions.ee/promotion-conflicts.ts +++ b/packages/cli/src/modules/promotions.ee/promotion-conflicts.ts @@ -1,4 +1,4 @@ -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ConflictError } from '@n8n/errors'; import { PromotionConflictError } from './database/promotion-conflict.error'; diff --git a/packages/cli/src/modules/promotions.ee/promotion-connections.service.ts b/packages/cli/src/modules/promotions.ee/promotion-connections.service.ts index 75389ea50e5..51d124764fd 100644 --- a/packages/cli/src/modules/promotions.ee/promotion-connections.service.ts +++ b/packages/cli/src/modules/promotions.ee/promotion-connections.service.ts @@ -19,10 +19,7 @@ import { Logger } from '@n8n/backend-common'; import { ProjectRepository, TransactionRunner, type OperationContext, type User } from '@n8n/db'; import { Service } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '@n8n/errors'; import { userHasScopes } from '@/permissions.ee/check-access'; import { DIRECTION_LABELS } from './constants'; diff --git a/packages/cli/src/modules/promotions.ee/promotion-providers.service.ts b/packages/cli/src/modules/promotions.ee/promotion-providers.service.ts index 64725492789..dbc2e8aa2e5 100644 --- a/packages/cli/src/modules/promotions.ee/promotion-providers.service.ts +++ b/packages/cli/src/modules/promotions.ee/promotion-providers.service.ts @@ -11,9 +11,7 @@ import { Service } from '@n8n/di'; import { Cipher } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ConflictError, NotFoundError } from '@n8n/errors'; import type { PromotionProvider } from './database/entities/promotion-provider.entity'; import { PromotionConnectionRepository } from './database/repositories/promotion-connection.repository'; diff --git a/packages/cli/src/modules/promotions.ee/promotions-git.service.ts b/packages/cli/src/modules/promotions.ee/promotions-git.service.ts index ddbe299e599..a48bb740c46 100644 --- a/packages/cli/src/modules/promotions.ee/promotions-git.service.ts +++ b/packages/cli/src/modules/promotions.ee/promotions-git.service.ts @@ -14,8 +14,7 @@ import { type SimpleGitOptions, } from 'simple-git'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; +import { BadRequestError, ServiceUnavailableError } from '@n8n/errors'; import { GIT_COMMAND_STALL_TIMEOUT_MS, diff --git a/packages/cli/src/modules/promotions.ee/promotions.service.ts b/packages/cli/src/modules/promotions.ee/promotions.service.ts index 3ff1fa23a0d..4eda6fb7294 100644 --- a/packages/cli/src/modules/promotions.ee/promotions.service.ts +++ b/packages/cli/src/modules/promotions.ee/promotions.service.ts @@ -15,8 +15,7 @@ import { cp, mkdir, mkdtemp, rename, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import { UnexpectedError } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service'; import { MANIFEST_FILE } from '@/modules/n8n-packages/spec/constants'; import { diff --git a/packages/cli/src/modules/promotions.ee/working-copy-updater.ts b/packages/cli/src/modules/promotions.ee/working-copy-updater.ts index 4b650a2c4e2..b34bf7baf06 100644 --- a/packages/cli/src/modules/promotions.ee/working-copy-updater.ts +++ b/packages/cli/src/modules/promotions.ee/working-copy-updater.ts @@ -7,8 +7,7 @@ import * as fs from 'node:fs/promises'; import path from 'node:path'; import { z } from 'zod'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import { BadRequestError, InternalServerError } from '@n8n/errors'; import { MANIFEST_FILE } from '@/modules/n8n-packages/spec/constants'; import type { ManifestEntry, PackageManifest } from '@/modules/n8n-packages/spec/manifest.schema'; diff --git a/packages/cli/src/modules/provisioning.ee/__tests__/provisioning.service.ee.test.ts b/packages/cli/src/modules/provisioning.ee/__tests__/provisioning.service.ee.test.ts index efaf5dea11f..4293b6716c4 100644 --- a/packages/cli/src/modules/provisioning.ee/__tests__/provisioning.service.ee.test.ts +++ b/packages/cli/src/modules/provisioning.ee/__tests__/provisioning.service.ee.test.ts @@ -1,5 +1,6 @@ import { BLOCK_ACCESS_ASSIGNMENT, type ProvisioningConfigDto } from '@n8n/api-types'; import type { Logger } from '@n8n/backend-common'; +import { type EventService } from '@n8n/backend-services'; import { type GlobalConfig } from '@n8n/config'; import { type User, @@ -16,9 +17,7 @@ import type { EntityManager } from '@n8n/typeorm'; import { type InstanceSettings } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { type EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; import { type RoleMappingRuleService } from '@/modules/provisioning.ee/role-mapping-rule.service.ee'; import { type RoleResolverService } from '@/modules/provisioning.ee/role-resolver.service.ee'; diff --git a/packages/cli/src/modules/provisioning.ee/__tests__/role-mapping-rule.service.ee.test.ts b/packages/cli/src/modules/provisioning.ee/__tests__/role-mapping-rule.service.ee.test.ts index 677a9dfa4cd..d11598b100b 100644 --- a/packages/cli/src/modules/provisioning.ee/__tests__/role-mapping-rule.service.ee.test.ts +++ b/packages/cli/src/modules/provisioning.ee/__tests__/role-mapping-rule.service.ee.test.ts @@ -1,11 +1,9 @@ +import type { EventService } from '@n8n/backend-services'; import { QueryFailedError } from '@n8n/typeorm'; import { mock } from 'vitest-mock-extended'; import { RoleMappingRuleService } from '@/modules/provisioning.ee/role-mapping-rule.service.ee'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { EventService } from '@/events/event.service'; +import { BadRequestError, ConflictError, NotFoundError } from '@n8n/errors'; import type { UserLike } from '@/events/maps/relay.event-map'; import type { Project, diff --git a/packages/cli/src/modules/provisioning.ee/__tests__/role-mapping-rule.validation.test.ts b/packages/cli/src/modules/provisioning.ee/__tests__/role-mapping-rule.validation.test.ts index fdc61ec5b13..6741d2cae04 100644 --- a/packages/cli/src/modules/provisioning.ee/__tests__/role-mapping-rule.validation.test.ts +++ b/packages/cli/src/modules/provisioning.ee/__tests__/role-mapping-rule.validation.test.ts @@ -1,6 +1,6 @@ import type { Role } from '@n8n/db'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { assertAndNormalizeProjectIdsForRuleType, diff --git a/packages/cli/src/modules/provisioning.ee/errors/sso-access-denied.error.ts b/packages/cli/src/modules/provisioning.ee/errors/sso-access-denied.error.ts index 04b4d724c74..74f308a7dc9 100644 --- a/packages/cli/src/modules/provisioning.ee/errors/sso-access-denied.error.ts +++ b/packages/cli/src/modules/provisioning.ee/errors/sso-access-denied.error.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; /** * An SSO login denied because role mapping resolved to "Block access". diff --git a/packages/cli/src/modules/provisioning.ee/provisioning.controller.ee.ts b/packages/cli/src/modules/provisioning.ee/provisioning.controller.ee.ts index e065feb3736..9b0a1b3812b 100644 --- a/packages/cli/src/modules/provisioning.ee/provisioning.controller.ee.ts +++ b/packages/cli/src/modules/provisioning.ee/provisioning.controller.ee.ts @@ -4,7 +4,7 @@ import { AuthenticatedRequest } from '@n8n/db'; import { Get, GlobalScope, Patch, RestController } from '@n8n/decorators'; import { Response } from 'express'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { ProvisioningService } from './provisioning.service.ee'; diff --git a/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts b/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts index 5d5ef510f55..62aa3ed9410 100644 --- a/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts +++ b/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts @@ -4,6 +4,7 @@ import { ProvisioningConfigPatchDto, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import { RoleRepository, @@ -24,8 +25,7 @@ import { InstanceSettings } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; import { ZodError } from 'zod'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError } from '@n8n/errors'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { ProjectService } from '@/services/project.service.ee'; import { UserService } from '@/services/user.service'; diff --git a/packages/cli/src/modules/provisioning.ee/role-mapping-rule.service.ee.ts b/packages/cli/src/modules/provisioning.ee/role-mapping-rule.service.ee.ts index fe2de8bcc55..91c4bcc396f 100644 --- a/packages/cli/src/modules/provisioning.ee/role-mapping-rule.service.ee.ts +++ b/packages/cli/src/modules/provisioning.ee/role-mapping-rule.service.ee.ts @@ -3,6 +3,7 @@ import { type ListRoleMappingRuleQueryInput, type PatchRoleMappingRuleInput, } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import { isUniqueConstraintError, ProjectRepository, @@ -15,10 +16,7 @@ import { Service } from '@n8n/di'; import { type EntityManager, type FindOptionsOrder, In } from '@n8n/typeorm'; import type { z } from 'zod'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ConflictError, NotFoundError } from '@n8n/errors'; import type { UserLike } from '@/events/maps/relay.event-map'; import { diff --git a/packages/cli/src/modules/provisioning.ee/role-mapping-rule.validation.ts b/packages/cli/src/modules/provisioning.ee/role-mapping-rule.validation.ts index 63083a2e947..96524f6390f 100644 --- a/packages/cli/src/modules/provisioning.ee/role-mapping-rule.validation.ts +++ b/packages/cli/src/modules/provisioning.ee/role-mapping-rule.validation.ts @@ -1,6 +1,6 @@ import type { Role } from '@n8n/db'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; export function assertRoleCompatibleWithMappingType( role: Role, diff --git a/packages/cli/src/modules/provisioning.ee/role-resolver-types.ts b/packages/cli/src/modules/provisioning.ee/role-resolver-types.ts index edee3d60397..95b038e24f0 100644 --- a/packages/cli/src/modules/provisioning.ee/role-resolver-types.ts +++ b/packages/cli/src/modules/provisioning.ee/role-resolver-types.ts @@ -1,3 +1,7 @@ +import type { RoleMappingRule } from '@n8n/inbound-auth'; + +export type { RoleMappingRule }; + export interface RoleResolverContext { /** Protocol-agnostic merged claims */ $claims: Record; @@ -21,16 +25,6 @@ export interface RoleResolverContext { $project?: ProjectInfo; } -export interface RoleMappingRule { - id: string; - expression: string; - role: string; - /** undefined = instance role rule; set = project role rule */ - projectId?: string; - enabled: boolean; - description?: string; -} - export interface RoleMappingConfig { instanceRoleRules: RoleMappingRule[]; projectRoleRules: RoleMappingRule[]; diff --git a/packages/cli/src/modules/quick-connect/__tests__/quick-connect.service.test.ts b/packages/cli/src/modules/quick-connect/__tests__/quick-connect.service.test.ts index 37736016f2c..7f3b2e7aa96 100644 --- a/packages/cli/src/modules/quick-connect/__tests__/quick-connect.service.test.ts +++ b/packages/cli/src/modules/quick-connect/__tests__/quick-connect.service.test.ts @@ -3,7 +3,7 @@ import type { User } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { IQuickConnectHandler } from '../handlers/handler.interface'; import { QuickConnectConfig } from '../quick-connect.config'; diff --git a/packages/cli/src/modules/quick-connect/quick-connect.service.ts b/packages/cli/src/modules/quick-connect/quick-connect.service.ts index 71ae0aeee5c..a141032facf 100644 --- a/packages/cli/src/modules/quick-connect/quick-connect.service.ts +++ b/packages/cli/src/modules/quick-connect/quick-connect.service.ts @@ -2,7 +2,7 @@ import { Logger } from '@n8n/backend-common'; import type { User } from '@n8n/db'; import { Container, Service } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { IQuickConnectHandler, diff --git a/packages/cli/src/modules/redaction/__tests__/instance-redaction-enforcement.service.test.ts b/packages/cli/src/modules/redaction/__tests__/instance-redaction-enforcement.service.test.ts index 01b5eec91c4..823c6fe2c86 100644 --- a/packages/cli/src/modules/redaction/__tests__/instance-redaction-enforcement.service.test.ts +++ b/packages/cli/src/modules/redaction/__tests__/instance-redaction-enforcement.service.test.ts @@ -8,7 +8,7 @@ import { UserError } from 'n8n-workflow'; import { SELF_SEND_COMMANDS } from '@/scaling/constants'; import type { Publisher } from '@/scaling/pubsub/publisher.service'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { InstanceRedactionEnforcementService } from '../instance-redaction-enforcement.service'; diff --git a/packages/cli/src/modules/redaction/__tests__/redaction-enforcement.service.test.ts b/packages/cli/src/modules/redaction/__tests__/redaction-enforcement.service.test.ts index c0f4d2bd842..e91b9e779e3 100644 --- a/packages/cli/src/modules/redaction/__tests__/redaction-enforcement.service.test.ts +++ b/packages/cli/src/modules/redaction/__tests__/redaction-enforcement.service.test.ts @@ -2,7 +2,7 @@ import type { RedactionFloor } from '@n8n/api-types'; import type { WorkflowSettings } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { UnprocessableRequestError } from '@n8n/errors'; import type { InstanceRedactionEnforcementService } from '../instance-redaction-enforcement.service'; import { RedactionEnforcementService } from '../redaction-enforcement.service'; diff --git a/packages/cli/src/modules/redaction/executions/__tests__/execution-redaction.service.test.ts b/packages/cli/src/modules/redaction/executions/__tests__/execution-redaction.service.test.ts index c4147313f2e..5838e642c98 100644 --- a/packages/cli/src/modules/redaction/executions/__tests__/execution-redaction.service.test.ts +++ b/packages/cli/src/modules/redaction/executions/__tests__/execution-redaction.service.test.ts @@ -1,13 +1,12 @@ import { LicenseState, Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; import type { INode, IRunExecutionData, ITaskData, WorkflowExecuteMode } from 'n8n-workflow'; import { shouldRedactConsoleOutput } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { ScopeForbiddenError } from '@/errors/response-errors/scope-forbidden.error'; -import type { EventService } from '@/events/event.service'; +import { ForbiddenError, ScopeForbiddenError } from '@n8n/errors'; import type { ExecutionRedactionOptions, RedactableExecution, diff --git a/packages/cli/src/modules/redaction/executions/execution-redaction.service.ts b/packages/cli/src/modules/redaction/executions/execution-redaction.service.ts index 04bda02657a..03a9c2ae15c 100644 --- a/packages/cli/src/modules/redaction/executions/execution-redaction.service.ts +++ b/packages/cli/src/modules/redaction/executions/execution-redaction.service.ts @@ -1,4 +1,5 @@ import { LicenseState, Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; import { @@ -9,9 +10,7 @@ import { } from 'n8n-workflow'; import { isCredSharingEnabled } from '@/constants/credential-sharing'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { ScopeForbiddenError } from '@/errors/response-errors/scope-forbidden.error'; -import { EventService } from '@/events/event.service'; +import { ForbiddenError, ScopeForbiddenError } from '@n8n/errors'; import type { ExecutionRedaction, ExecutionRedactionOptions, diff --git a/packages/cli/src/modules/redaction/instance-redaction-enforcement.service.ts b/packages/cli/src/modules/redaction/instance-redaction-enforcement.service.ts index 8bd7298ef71..5cc14701b6d 100644 --- a/packages/cli/src/modules/redaction/instance-redaction-enforcement.service.ts +++ b/packages/cli/src/modules/redaction/instance-redaction-enforcement.service.ts @@ -7,7 +7,7 @@ import { Service } from '@n8n/di'; import { UserError } from 'n8n-workflow'; import { Publisher } from '@/scaling/pubsub/publisher.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; const KEY = 'redaction.enforcement'; diff --git a/packages/cli/src/modules/redaction/redaction-enforcement.service.ts b/packages/cli/src/modules/redaction/redaction-enforcement.service.ts index 2faf008f7cc..0950d9c8979 100644 --- a/packages/cli/src/modules/redaction/redaction-enforcement.service.ts +++ b/packages/cli/src/modules/redaction/redaction-enforcement.service.ts @@ -1,7 +1,7 @@ import { Service } from '@n8n/di'; import type { WorkflowSettings } from 'n8n-workflow'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { UnprocessableRequestError } from '@n8n/errors'; import { InstanceRedactionEnforcementService } from './instance-redaction-enforcement.service'; import { policyMeetsFloor, REDACTION_FLOOR_VIOLATION_MESSAGE } from './redaction-policy'; diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts index f41d30cc537..a1894b6893b 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts @@ -1663,15 +1663,18 @@ describe('SourceControlImportService', () => { await service.importWorkflowFromWorkFolder(candidates, mockUserId); - expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledWith({ - workflow: { - id: mockWorkflowData.id, - name: mockWorkflowData.name, - nodes: mockWorkflowData.nodes, + expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledWith( + { + workflow: { + id: mockWorkflowData.id, + name: mockWorkflowData.name, + nodes: mockWorkflowData.nodes, + }, + projectId: 'personal-project-id-123', + transport: 'source-control', }, - projectId: 'personal-project-id-123', - transport: 'source-control', - }); + { kind: 'user', user: { id: mockUserId } }, + ); }); // The clearance is checked at the write, so enforcing after the upsert would seal @@ -2830,11 +2833,14 @@ describe('SourceControlImportService', () => { await service.importCredentialsFromWorkFolder(candidates, mockUserId); - expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledWith({ - credential: { id: mockCredentialData.id, type: mockCredentialData.type }, - projectId: mockPersonalProject.id, - transport: 'source-control', - }); + expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledWith( + { + credential: { id: mockCredentialData.id, type: mockCredentialData.type }, + projectId: mockPersonalProject.id, + transport: 'source-control', + }, + { kind: 'user', user: { id: mockUserId } }, + ); }); // The clearance is checked at the write, so enforcing after the upsert would seal diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-scoped.service.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-scoped.service.test.ts index 567dc42f7b8..fcb4dd8512e 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-scoped.service.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-scoped.service.test.ts @@ -2,7 +2,7 @@ import type { AuthenticatedRequest, Project } from '@n8n/db'; import { hasGlobalScope } from '@n8n/permissions'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import type { SourceControlContextFactory } from '../source-control-context.factory'; import { SourceControlScopedService } from '../source-control-scoped.service'; diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts index 102cceb7409..aa4d1497d49 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts @@ -1,3 +1,4 @@ +import type { EventService } from '@n8n/backend-services'; import { mockLogger } from '@n8n/backend-test-utils'; import { GLOBAL_ADMIN_ROLE, @@ -16,8 +17,7 @@ import { Container } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import type { EventService } from '@/events/event.service'; +import { ForbiddenError } from '@n8n/errors'; import type { SourceControlContextFactory } from '../source-control-context.factory'; import type { SourceControlGitService } from '../source-control-git.service.ee'; diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control.controller.ee.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control.controller.ee.test.ts index fb643244c66..d02027d421f 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control.controller.ee.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control.controller.ee.test.ts @@ -1,4 +1,5 @@ import type { PullWorkFolderRequestDto, PushWorkFolderRequestDto } from '@n8n/api-types'; +import type { EventService } from '@n8n/backend-services'; import type { AuthenticatedRequest, Project, User } from '@n8n/db'; import { ControllerRegistryMetadata, type Controller } from '@n8n/decorators'; import { Container } from '@n8n/di'; @@ -7,8 +8,7 @@ import type { Response } from 'express'; import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import type { EventService } from '@/events/event.service'; +import { ForbiddenError } from '@n8n/errors'; import type { SourceControlContextFactory } from '../source-control-context.factory'; import type { SourceControlPreferencesService } from '../source-control-preferences.service.ee'; diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control.service.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control.service.test.ts index 3506ab1aedf..26570701eb6 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control.service.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control.service.test.ts @@ -1,3 +1,4 @@ +import type { EventService } from '@n8n/backend-services'; import type { Mock } from 'vitest'; import type { SourceControlledFile } from '@n8n/api-types'; import { isContainedWithin } from '@n8n/backend-common'; @@ -9,8 +10,7 @@ import type { CommitResult, PullResult, PushResult } from 'simple-git'; import { SourceControlPreferencesService } from '@/modules/source-control.ee/source-control-preferences.service.ee'; import { SourceControlService } from '@/modules/source-control.ee/source-control.service.ee'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import type { EventService } from '@/events/event.service'; +import { ForbiddenError } from '@n8n/errors'; import type { SourceControlExportService } from '../source-control-export.service.ee'; import type { SourceControlGitService } from '../source-control-git.service.ee'; import type { SourceControlImportService } from '../source-control-import.service.ee'; diff --git a/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts index b63b7c4f67c..01f45ee0161 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts @@ -863,11 +863,14 @@ export class SourceControlImportService { // skip after that point would leave it stopped with nothing imported in its place. let cleared: PolicyCleared<'contentImport'>; try { - cleared = await this.policyEnforcementService.enforceContentImport({ - workflow: { id, name: importedWorkflow.name, nodes }, - projectId: targetOwnerProject.id, - transport: 'source-control', - }); + cleared = await this.policyEnforcementService.enforceContentImport( + { + workflow: { id, name: importedWorkflow.name, nodes }, + projectId: targetOwnerProject.id, + transport: 'source-control', + }, + { kind: 'user', user: { id: userId } }, + ); } catch (error) { // A blocked workflow is skipped, not fatal — the rest of the pull still lands. A check // that broke is not scoped to one workflow, so it fails the pull rather than silently @@ -1116,11 +1119,14 @@ export class SourceControlImportService { // its stored data can't be read. let cleared: PolicyCleared<'contentImport'>; try { - cleared = await this.policyEnforcementService.enforceContentImport({ - credential: { id: credential.id ?? null, type }, - projectId: targetOwnerProject.id, - transport: 'source-control', - }); + cleared = await this.policyEnforcementService.enforceContentImport( + { + credential: { id: credential.id ?? null, type }, + projectId: targetOwnerProject.id, + transport: 'source-control', + }, + { kind: 'user', user: { id: userId } }, + ); } catch (error) { if (!(error instanceof PolicyViolationError)) throw error; diff --git a/packages/cli/src/modules/source-control.ee/source-control-scoped.service.ts b/packages/cli/src/modules/source-control.ee/source-control-scoped.service.ts index 330c2279605..ec045d18310 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-scoped.service.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-scoped.service.ts @@ -11,7 +11,7 @@ import { Service } from '@n8n/di'; import { hasGlobalScope } from '@n8n/permissions'; import type { FindOptionsWhere } from '@n8n/typeorm'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import type { DataTable } from '@/modules/data-table/data-table.entity'; import { SourceControlContextFactory } from './source-control-context.factory'; diff --git a/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts index 33730c09bb7..d058a1d4786 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts @@ -1,5 +1,6 @@ import type { SourceControlledFile } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { FolderRepository, TagRepository, type User, WorkflowRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import { hasGlobalScope } from '@n8n/permissions'; @@ -7,8 +8,7 @@ import { In } from '@n8n/typeorm'; import pick from 'lodash/pick'; import { UserError } from 'n8n-workflow'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { ForbiddenError } from '@n8n/errors'; import { ExportableTagEntity } from '@/modules/source-control.ee/types/exportable-tags'; import { SOURCE_CONTROL_DATATABLES_EXPORT_FOLDER } from './constants'; diff --git a/packages/cli/src/modules/source-control.ee/source-control.controller.ee.ts b/packages/cli/src/modules/source-control.ee/source-control.controller.ee.ts index 3b8e5b9839e..de235947afa 100644 --- a/packages/cli/src/modules/source-control.ee/source-control.controller.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control.controller.ee.ts @@ -4,6 +4,7 @@ import { type GitCommitInfo, type SourceControlledFile, } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import { AuthenticatedRequest } from '@n8n/db'; import { Get, Post, Patch, RestController, GlobalScope, Body } from '@n8n/decorators'; import { hasGlobalScope } from '@n8n/permissions'; @@ -22,9 +23,7 @@ import { SourceControlRequest } from './types/requests'; import { SourceControlGetStatus } from './types/source-control-get-status'; import type { SourceControlPreferences } from './types/source-control-preferences'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { IWorkflowToImport } from '@/interfaces'; @RestController('/source-control') diff --git a/packages/cli/src/modules/source-control.ee/source-control.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control.service.ee.ts index 41a60c396a8..798996b5c79 100644 --- a/packages/cli/src/modules/source-control.ee/source-control.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control.service.ee.ts @@ -1,5 +1,6 @@ import type { PullWorkFolderRequestDto, SourceControlledFile } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { type User } from '@n8n/db'; import { OnPubSubEvent } from '@n8n/decorators'; import { Service } from '@n8n/di'; @@ -9,9 +10,7 @@ import pLimit from 'p-limit'; import * as path from 'path'; import type { PushResult } from 'simple-git'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { IWorkflowToImport } from '@/interfaces'; import { diff --git a/packages/cli/src/modules/sso-oidc/__tests__/oidc.controller.ee.test.ts b/packages/cli/src/modules/sso-oidc/__tests__/oidc.controller.ee.test.ts index f6b0708ab90..922d34c1732 100644 --- a/packages/cli/src/modules/sso-oidc/__tests__/oidc.controller.ee.test.ts +++ b/packages/cli/src/modules/sso-oidc/__tests__/oidc.controller.ee.test.ts @@ -1,4 +1,5 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService, UrlService } from '@n8n/backend-services'; import type { GlobalConfig, InstanceSettingsLoaderConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { GLOBAL_MEMBER_ROLE, type AuthenticatedRequest, type User } from '@n8n/db'; @@ -7,11 +8,9 @@ import { mock } from 'vitest-mock-extended'; import type { AuthService } from '@/auth/auth.service'; import { OIDC_NONCE_COOKIE_NAME, OIDC_STATE_COOKIE_NAME } from '@/constants'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import type { EventService } from '@/events/event.service'; +import { ForbiddenError } from '@n8n/errors'; import { SsoAccessDeniedError } from '@/modules/provisioning.ee/errors/sso-access-denied.error'; import type { AuthlessRequest } from '@/requests'; -import type { UrlService } from '@n8n/backend-services'; import { isOidcCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers'; diff --git a/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts b/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts index 045deebae4b..0b38af59e38 100644 --- a/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts +++ b/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts @@ -22,8 +22,7 @@ vi.mock('openid-client', async (importOriginal) => { }; }); -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { type ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import type { JwtService } from '@/services/jwt.service'; diff --git a/packages/cli/src/modules/sso-oidc/oidc.controller.ee.ts b/packages/cli/src/modules/sso-oidc/oidc.controller.ee.ts index c071b6dc995..091e39fa367 100644 --- a/packages/cli/src/modules/sso-oidc/oidc.controller.ee.ts +++ b/packages/cli/src/modules/sso-oidc/oidc.controller.ee.ts @@ -1,5 +1,6 @@ import { OidcConfigDto } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService, UrlService } from '@n8n/backend-services'; import { GlobalConfig, InstanceSettingsLoaderConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { AuthenticatedRequest } from '@n8n/db'; @@ -8,16 +9,13 @@ import { Request, Response } from 'express'; import { AuthService } from '@/auth/auth.service'; import { OIDC_NONCE_COOKIE_NAME, OIDC_STATE_COOKIE_NAME } from '@/constants'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { SSO_ACCESS_DENIED_REDIRECT_PATH, SSO_LOGIN_FAILED_REDIRECT_PATH, } from '@/modules/provisioning.ee/constants'; import { SsoAccessDeniedError } from '@/modules/provisioning.ee/errors/sso-access-denied.error'; import { AuthlessRequest } from '@/requests'; -import { UrlService } from '@n8n/backend-services'; import { isOidcCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers'; import { validateRedirectUrl } from '@/utils/validate-redirect-url'; diff --git a/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts b/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts index e615e02bc6f..ad7647bd2b3 100644 --- a/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts +++ b/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts @@ -19,8 +19,7 @@ import { jsonParse, UserError } from 'n8n-workflow'; import type * as openidClientTypes from 'openid-client'; import { inspect } from 'util'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { buildOidcClaimsContext } from '@/modules/provisioning.ee/claims-context.builder'; import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; import { JwtService } from '@/services/jwt.service'; diff --git a/packages/cli/src/modules/sso-saml/__tests__/saml.controller.ee.test.ts b/packages/cli/src/modules/sso-saml/__tests__/saml.controller.ee.test.ts index a34fbc787c1..2c24377982e 100644 --- a/packages/cli/src/modules/sso-saml/__tests__/saml.controller.ee.test.ts +++ b/packages/cli/src/modules/sso-saml/__tests__/saml.controller.ee.test.ts @@ -1,3 +1,4 @@ +import type { EventService, UrlService } from '@n8n/backend-services'; import type { InstanceSettingsLoaderConfig } from '@n8n/config'; import { GLOBAL_OWNER_ROLE, type AuthenticatedRequest, type User } from '@n8n/db'; import { ControllerRegistryMetadata, type Controller } from '@n8n/decorators'; @@ -7,10 +8,8 @@ import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; import type { AuthService } from '@/auth/auth.service'; -import type { EventService } from '@/events/event.service'; import { SsoAccessDeniedError } from '@/modules/provisioning.ee/errors/sso-access-denied.error'; import type { AuthlessRequest } from '@/requests'; -import type { UrlService } from '@n8n/backend-services'; import { isSamlLicensedAndEnabled } from '@/sso.ee/sso-helpers'; import { extractTestIdFromRelayState, isConnectionTestRequest } from '../saml-helpers'; diff --git a/packages/cli/src/modules/sso-saml/__tests__/saml.service.ee.test.ts b/packages/cli/src/modules/sso-saml/__tests__/saml.service.ee.test.ts index 0e30deb43a5..833bf538e2f 100644 --- a/packages/cli/src/modules/sso-saml/__tests__/saml.service.ee.test.ts +++ b/packages/cli/src/modules/sso-saml/__tests__/saml.service.ee.test.ts @@ -12,11 +12,10 @@ import type { Cipher, InstanceSettings } from 'n8n-core'; import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow'; import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import type { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; import { Publisher } from '@/scaling/pubsub/publisher.service'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import type { UrlService } from '@n8n/backend-services'; import * as ssoHelpers from '@/sso.ee/sso-helpers'; diff --git a/packages/cli/src/modules/sso-saml/saml-helpers.ts b/packages/cli/src/modules/sso-saml/saml-helpers.ts index 5c1953463bf..d572b793bb5 100644 --- a/packages/cli/src/modules/sso-saml/saml-helpers.ts +++ b/packages/cli/src/modules/sso-saml/saml-helpers.ts @@ -6,7 +6,7 @@ import { Container } from '@n8n/di'; import { randomString } from 'n8n-workflow'; import type { FlowResult } from 'samlify/types/src/flow'; -import { AuthError } from '@/errors/response-errors/auth.error'; +import { AuthError } from '@n8n/errors'; import { PasswordUtility } from '@/services/password.utility'; import { assertAuthenticationMethodCanBeEnabled, diff --git a/packages/cli/src/modules/sso-saml/saml.controller.ee.ts b/packages/cli/src/modules/sso-saml/saml.controller.ee.ts index 3c0f3cf3a40..c7fd84948cf 100644 --- a/packages/cli/src/modules/sso-saml/saml.controller.ee.ts +++ b/packages/cli/src/modules/sso-saml/saml.controller.ee.ts @@ -1,4 +1,5 @@ import { SamlAcsDto, SamlPreferences, SamlToggleDto } from '@n8n/api-types'; +import { EventService, UrlService } from '@n8n/backend-services'; import { InstanceSettingsLoaderConfig } from '@n8n/config'; import { AuthenticatedRequest } from '@n8n/db'; import { Get, Post, RestController, GlobalScope, Body } from '@n8n/decorators'; @@ -9,14 +10,11 @@ import type { PostBindingContext } from 'samlify/types/src/entity'; import url from 'url'; import { AuthService } from '@/auth/auth.service'; -import { AuthError } from '@/errors/response-errors/auth.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { AuthError, ForbiddenError } from '@n8n/errors'; import { SSO_ACCESS_DENIED_REDIRECT_PATH } from '@/modules/provisioning.ee/constants'; import { SsoAccessDeniedError } from '@/modules/provisioning.ee/errors/sso-access-denied.error'; import { AuthlessRequest } from '@/requests'; import { sendErrorResponse } from '@/response-helper'; -import { UrlService } from '@n8n/backend-services'; import { isSamlLicensedAndEnabled } from '@/sso.ee/sso-helpers'; import { validateRedirectUrl } from '@/utils/validate-redirect-url'; diff --git a/packages/cli/src/modules/sso-saml/saml.service.ee.ts b/packages/cli/src/modules/sso-saml/saml.service.ee.ts index fc799217bb8..2d8a20e3a5c 100644 --- a/packages/cli/src/modules/sso-saml/saml.service.ee.ts +++ b/packages/cli/src/modules/sso-saml/saml.service.ee.ts @@ -17,12 +17,10 @@ import type { PostBindingContext, } from 'samlify/types/src/entity'; -import { AuthError } from '@/errors/response-errors/auth.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { AuthError, BadRequestError } from '@n8n/errors'; import { buildSamlClaimsContext } from '@/modules/provisioning.ee/claims-context.builder'; import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; -import { CacheService } from '@/services/cache/cache.service'; -import { UrlService } from '@n8n/backend-services'; +import { CacheService, UrlService } from '@n8n/backend-services'; import { getSamlLoginLabel, isSamlLicensedAndEnabled, diff --git a/packages/cli/src/modules/token-exchange/controllers/__tests__/embed-auth.controller.test.ts b/packages/cli/src/modules/token-exchange/controllers/__tests__/embed-auth.controller.test.ts index 833ad42c981..0667f594b97 100644 --- a/packages/cli/src/modules/token-exchange/controllers/__tests__/embed-auth.controller.test.ts +++ b/packages/cli/src/modules/token-exchange/controllers/__tests__/embed-auth.controller.test.ts @@ -1,12 +1,11 @@ import { EmbedLoginBodyDto, EmbedLoginQueryDto } from '@n8n/api-types'; +import type { EventService, UrlService } from '@n8n/backend-services'; import { GLOBAL_MEMBER_ROLE, type User } from '@n8n/db'; import type { Response } from 'express'; import { mock } from 'vitest-mock-extended'; import type { AuthService } from '@/auth/auth.service'; -import type { EventService } from '@/events/event.service'; import type { AuthlessRequest } from '@/requests'; -import type { UrlService } from '@n8n/backend-services'; import type { TokenExchangeService } from '../../services/token-exchange.service'; import type { TokenExchangeConfig } from '../../token-exchange.config'; diff --git a/packages/cli/src/modules/token-exchange/controllers/__tests__/token-exchange.controller.test.ts b/packages/cli/src/modules/token-exchange/controllers/__tests__/token-exchange.controller.test.ts index 340b5ed3771..0e277c37f5c 100644 --- a/packages/cli/src/modules/token-exchange/controllers/__tests__/token-exchange.controller.test.ts +++ b/packages/cli/src/modules/token-exchange/controllers/__tests__/token-exchange.controller.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { Container } from '@n8n/di'; import type { Response } from 'express'; @@ -5,9 +6,7 @@ import { ErrorReporter } from 'n8n-core'; import { UnexpectedError } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import { AuthError } from '@/errors/response-errors/auth.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { EventService } from '@/events/event.service'; +import { AuthError, BadRequestError } from '@n8n/errors'; import type { AuthlessRequest } from '@/requests'; import { TokenExchangeService } from '../../services/token-exchange.service'; diff --git a/packages/cli/src/modules/token-exchange/controllers/embed-auth.controller.ts b/packages/cli/src/modules/token-exchange/controllers/embed-auth.controller.ts index c5cc0566c00..ff56d3833d2 100644 --- a/packages/cli/src/modules/token-exchange/controllers/embed-auth.controller.ts +++ b/packages/cli/src/modules/token-exchange/controllers/embed-auth.controller.ts @@ -1,13 +1,12 @@ import { EmbedLoginBodyDto, EmbedLoginQueryDto } from '@n8n/api-types'; +import { EventService, UrlService } from '@n8n/backend-services'; import { Time } from '@n8n/constants'; import { Body, Get, Post, Query, RestController } from '@n8n/decorators'; import { Container } from '@n8n/di'; import type { Response } from 'express'; import { AuthService } from '@/auth/auth.service'; -import { EventService } from '@/events/event.service'; import { AuthlessRequest } from '@/requests'; -import { UrlService } from '@n8n/backend-services'; import { validateRedirectUrl } from '@/utils/validate-redirect-url'; import { TokenExchangeService } from '../services/token-exchange.service'; diff --git a/packages/cli/src/modules/token-exchange/controllers/token-exchange.controller.ts b/packages/cli/src/modules/token-exchange/controllers/token-exchange.controller.ts index c5d48510d50..0ec73da593b 100644 --- a/packages/cli/src/modules/token-exchange/controllers/token-exchange.controller.ts +++ b/packages/cli/src/modules/token-exchange/controllers/token-exchange.controller.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { Time } from '@n8n/constants'; import { Post, RestController } from '@n8n/decorators'; import { Container } from '@n8n/di'; @@ -5,9 +6,7 @@ import type { Response } from 'express'; import { ErrorReporter } from 'n8n-core'; import { z, ZodError } from 'zod'; -import { AuthError } from '@/errors/response-errors/auth.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { EventService } from '@/events/event.service'; +import { AuthError, BadRequestError } from '@n8n/errors'; import { AuthlessRequest } from '@/requests'; import { TokenExchangeService } from '../services/token-exchange.service'; diff --git a/packages/cli/src/modules/token-exchange/database/repositories/trusted-key-source.repository.ts b/packages/cli/src/modules/token-exchange/database/repositories/trusted-key-source.repository.ts index 513bd4fd7c5..7bcba3de054 100644 --- a/packages/cli/src/modules/token-exchange/database/repositories/trusted-key-source.repository.ts +++ b/packages/cli/src/modules/token-exchange/database/repositories/trusted-key-source.repository.ts @@ -1,11 +1,76 @@ +import { BaseRepository, TransactionRunner, type OperationContext } from '@n8n/db'; import { Service } from '@n8n/di'; -import { DataSource, Repository } from '@n8n/typeorm'; +import { DataSource, type EntityManager } from '@n8n/typeorm'; +import { UnexpectedError, jsonParse } from 'n8n-workflow'; +import type { ResolvedSourceKeys } from '../../token-exchange.schemas'; import { TrustedKeySourceEntity } from '../entities/trusted-key-source.entity'; +import { TrustedKeyEntity } from '../entities/trusted-key.entity'; @Service() -export class TrustedKeySourceRepository extends Repository { - constructor(dataSource: DataSource) { - super(TrustedKeySourceEntity, dataSource.manager); +export class TrustedKeySourceRepository extends BaseRepository { + constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + super(TrustedKeySourceEntity, dataSource.manager, transactionRunner); + } + + /** + * Replaces the keys of a source inside the transaction of `ctx`. + * Does nothing when the source no longer exists. + * @throws {UnexpectedError} when `ctx` carries no transaction. + * @throws {Error} when the keys cannot be resolved or written. + */ + async refreshSource( + sourceId: string, + resolveKeys: (source: TrustedKeySourceEntity) => Promise, + ctx: OperationContext, + ): Promise { + if (!ctx.trx) { + throw new UnexpectedError('Trusted key refresh requires a transaction'); + } + const tx = this.managerFor(ctx); + const source = await tx.findOneBy(TrustedKeySourceEntity, { id: sourceId }); + if (!source) { + return; + } + + const result = await resolveKeys(source); + await this.storeResolvedKeys(source, result, tx); + } + + private async storeResolvedKeys( + source: TrustedKeySourceEntity, + result: ResolvedSourceKeys | undefined, + tx: EntityManager, + ): Promise { + if (!result) { + // Skip unsupported source types until the next refresh interval. + await tx.update(TrustedKeySourceEntity, source.id, { + status: 'healthy', + lastRefreshedAt: new Date(), + }); + return; + } + + await tx.delete(TrustedKeyEntity, { sourceId: source.id }); + for (const key of result.keys) { + await tx.save(TrustedKeyEntity, { + sourceId: source.id, + kid: key.kid, + data: JSON.stringify(key.data), + createdAt: new Date(), + }); + } + + const updatePayload: Partial = { + status: 'healthy', + lastError: null, + lastRefreshedAt: new Date(), + }; + if (result.cacheTtlSeconds !== undefined) { + const config = jsonParse>(source.config); + config.cacheTtlSeconds = result.cacheTtlSeconds; + updatePayload.config = JSON.stringify(config); + } + await tx.update(TrustedKeySourceEntity, source.id, updatePayload); } } diff --git a/packages/cli/src/modules/token-exchange/services/__tests__/identity-resolution.service.test.ts b/packages/cli/src/modules/token-exchange/services/__tests__/identity-resolution.service.test.ts index 737be6da7da..06f90273823 100644 --- a/packages/cli/src/modules/token-exchange/services/__tests__/identity-resolution.service.test.ts +++ b/packages/cli/src/modules/token-exchange/services/__tests__/identity-resolution.service.test.ts @@ -1,4 +1,5 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { GLOBAL_MEMBER_ROLE, type AuthIdentity, @@ -9,7 +10,6 @@ import { } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; import type { RoleService } from '@/services/role.service'; import type { UserService } from '@/services/user.service'; diff --git a/packages/cli/src/modules/token-exchange/services/__tests__/token-exchange.service.test.ts b/packages/cli/src/modules/token-exchange/services/__tests__/token-exchange.service.test.ts index 2490d47967a..3431ca7fed1 100644 --- a/packages/cli/src/modules/token-exchange/services/__tests__/token-exchange.service.test.ts +++ b/packages/cli/src/modules/token-exchange/services/__tests__/token-exchange.service.test.ts @@ -3,8 +3,7 @@ import { GLOBAL_MEMBER_ROLE, type User } from '@n8n/db'; import jwt from 'jsonwebtoken'; import { mock } from 'vitest-mock-extended'; -import { AuthError } from '@/errors/response-errors/auth.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { AuthError, BadRequestError } from '@n8n/errors'; import type { JwtService } from '@/services/jwt.service'; import type { TokenExchangeConfig } from '../../token-exchange.config'; diff --git a/packages/cli/src/modules/token-exchange/services/__tests__/trusted-key-refresh.task.test.ts b/packages/cli/src/modules/token-exchange/services/__tests__/trusted-key-refresh.task.test.ts index b6aed9ec102..64f93785c91 100644 --- a/packages/cli/src/modules/token-exchange/services/__tests__/trusted-key-refresh.task.test.ts +++ b/packages/cli/src/modules/token-exchange/services/__tests__/trusted-key-refresh.task.test.ts @@ -7,11 +7,11 @@ describe('TrustedKeyRefreshTask', () => { const trustedKeyService = mock(); const task = new TrustedKeyRefreshTask(trustedKeyService); - it('should declare a 30-second poll cadence', () => { + it('should declare a durable 60-second poll cadence', () => { expect(task.name).toBe('trusted-key-refresh'); - expect(task.schedule).toEqual({ kind: 'interval', intervalSeconds: 30 }); + expect(task.schedule).toEqual({ kind: 'interval', intervalSeconds: 60 }); expect(task.effects).toBe('idempotent'); - expect(task.placement).toEqual({ scope: 'cluster', durable: false }); + expect(task.placement).toEqual({ scope: 'cluster', durable: true }); }); it('should refresh the due sources on run', async () => { diff --git a/packages/cli/src/modules/token-exchange/services/__tests__/trusted-key.service.test.ts b/packages/cli/src/modules/token-exchange/services/__tests__/trusted-key.service.test.ts index bcfa9dce935..a254a6501c7 100644 --- a/packages/cli/src/modules/token-exchange/services/__tests__/trusted-key.service.test.ts +++ b/packages/cli/src/modules/token-exchange/services/__tests__/trusted-key.service.test.ts @@ -57,6 +57,31 @@ function makeTrustedKeyEntity( return entity; } +function makeSource(overrides: Partial = {}): TrustedKeySourceEntity { + return Object.assign(new TrustedKeySourceEntity(), { + id: 'static', + type: 'static' as const, + config: JSON.stringify([]), + status: 'pending' as const, + lastError: null, + lastRefreshedAt: null, + ...overrides, + }); +} + +/** A source whose refresh fails, and another source that is due. */ +function seedFailingAndDueSources(mocks: ReturnType) { + const failing = makeSource({ + id: 'jwks-1', + type: 'jwks', + config: JSON.stringify({ type: 'jwks', url: 'https://idp.example.com/jwks' }), + }); + const due = makeSource({ id: 'static' }); + mocks.sourceRepo.find.mockResolvedValue([failing, due]); + mocks.sourceRepo.refreshSource.mockRejectedValueOnce(new Error('jwks down')); + return { failing, due }; +} + function createMocks() { const config = mock({ trustedKeys: '', @@ -73,6 +98,7 @@ function createMocks() { }, ); + dbLockService.withLockContext.mockImplementation(async (_lockId, fn) => await fn({})); sourceRepo.find.mockResolvedValue([]); const service = new TrustedKeyService( @@ -151,6 +177,44 @@ describe('TrustedKeyService', () => { }); describe('onLeaderTakeover', () => { + it('should refresh the remaining sources and resolve when one source fails', async () => { + const mocks = createMocks(); + seedFailingAndDueSources(mocks); + + await mocks.service.onLeaderTakeover(); + + expect(mocks.dbLockService.withLockContext).toHaveBeenCalledTimes(2); + expect(mocks.sourceRepo.refreshSource).toHaveBeenCalledWith( + 'static', + expect.any(Function), + {}, + ); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + + it.each(['lock', 'write'] as const)( + 'should refresh the remaining sources after a %s failure', + async (failureType) => { + const { service, sourceRepo, dbLockService } = createMocks(); + const sources = [makeSource({ id: 'first' }), makeSource({ id: 'second' })]; + const error = new Error(`${failureType} failed`); + sourceRepo.find.mockResolvedValue(sources); + if (failureType === 'lock') { + dbLockService.withLockContext.mockRejectedValueOnce(error); + } else { + sourceRepo.refreshSource.mockRejectedValueOnce(error); + } + + await expect(service.onLeaderTakeover()).resolves.toBeUndefined(); + + expect(sourceRepo.refreshSource).toHaveBeenCalledWith('second', expect.any(Function), {}); + expect(mockLogger.error).toHaveBeenCalledWith('Failed to refresh trusted key source', { + sourceId: 'first', + error, + }); + }, + ); + it('should refresh keys from sources when a follower is elected leader', async () => { const { service, sourceRepo, dbLockService } = createMocks(); @@ -166,7 +230,7 @@ describe('TrustedKeyService', () => { await service.onLeaderTakeover(); - expect(dbLockService.withLock).toHaveBeenCalled(); + expect(dbLockService.withLockContext).toHaveBeenCalled(); }); }); @@ -188,7 +252,7 @@ describe('TrustedKeyService', () => { await service.refreshDueSources(new AbortController().signal); // Source was recently refreshed — should not trigger a refresh - expect(dbLockService.withLock).not.toHaveBeenCalled(); + expect(dbLockService.withLockContext).not.toHaveBeenCalled(); }); it('should refresh sources whose lastRefreshedAt exceeds the interval', async () => { @@ -207,7 +271,7 @@ describe('TrustedKeyService', () => { await service.refreshDueSources(new AbortController().signal); - expect(dbLockService.withLock).toHaveBeenCalled(); + expect(dbLockService.withLockContext).toHaveBeenCalled(); }); it('should refresh sources that have never been refreshed', async () => { @@ -226,7 +290,57 @@ describe('TrustedKeyService', () => { await service.refreshDueSources(new AbortController().signal); - expect(dbLockService.withLock).toHaveBeenCalled(); + expect(dbLockService.withLockContext).toHaveBeenCalled(); + }); + + it('should reject with the source error and stop at the failed source', async () => { + const mocks = createMocks(); + seedFailingAndDueSources(mocks); + + await expect(mocks.service.refreshDueSources(new AbortController().signal)).rejects.toThrow( + 'jwks down', + ); + + expect(mocks.dbLockService.withLockContext).toHaveBeenCalledTimes(1); + }); + + it('should try the least recently updated source first', async () => { + const mocks = createMocks(); + + await mocks.service.refreshDueSources(new AbortController().signal); + + expect(mocks.sourceRepo.find).toHaveBeenCalledWith({ order: { updatedAt: 'ASC' } }); + }); + + it('should record a refresh failure after rollback and propagate the error', async () => { + const mocks = createMocks(); + const { failing } = seedFailingAndDueSources(mocks); + + await expect(mocks.service.refreshDueSources(new AbortController().signal)).rejects.toThrow( + 'jwks down', + ); + + expect(mocks.sourceRepo.update).toHaveBeenCalledWith(failing.id, { + status: 'error', + lastError: 'jwks down', + }); + }); + + it('should record a lock failure and propagate the error', async () => { + const mocks = createMocks(); + const { failing } = seedFailingAndDueSources(mocks); + const error = new Error('lock failed'); + mocks.dbLockService.withLockContext.mockRejectedValueOnce(error); + + await expect(mocks.service.refreshDueSources(new AbortController().signal)).rejects.toBe( + error, + ); + + expect(mocks.sourceRepo.refreshSource).not.toHaveBeenCalled(); + expect(mocks.sourceRepo.update).toHaveBeenCalledWith(failing.id, { + status: 'error', + lastError: 'lock failed', + }); }); it('should reject when the sources cannot be loaded', async () => { @@ -253,11 +367,11 @@ describe('TrustedKeyService', () => { lastRefreshedAt: null, }); sourceRepo.find.mockResolvedValue([staleSource('first'), staleSource('second')]); - dbLockService.withLock.mockImplementation(async () => controller.abort()); + dbLockService.withLockContext.mockImplementation(async () => controller.abort()); await service.refreshDueSources(controller.signal); - expect(dbLockService.withLock).toHaveBeenCalledTimes(1); + expect(dbLockService.withLockContext).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/cli/src/modules/token-exchange/services/identity-resolution.service.ts b/packages/cli/src/modules/token-exchange/services/identity-resolution.service.ts index a3250ff59d6..e92692e9e00 100644 --- a/packages/cli/src/modules/token-exchange/services/identity-resolution.service.ts +++ b/packages/cli/src/modules/token-exchange/services/identity-resolution.service.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { AuthIdentity, AuthIdentityRepository, @@ -10,7 +11,6 @@ import { Service } from '@n8n/di'; import { GLOBAL_OWNER_ROLE_SLUG, isBuiltInRole } from '@n8n/permissions'; import { createHash } from 'node:crypto'; -import { EventService } from '@/events/event.service'; import { RoleService } from '@/services/role.service'; import { UserService } from '@/services/user.service'; diff --git a/packages/cli/src/modules/token-exchange/services/trusted-key-refresh.task.ts b/packages/cli/src/modules/token-exchange/services/trusted-key-refresh.task.ts index 58eb32e8570..fa046e5dd83 100644 --- a/packages/cli/src/modules/token-exchange/services/trusted-key-refresh.task.ts +++ b/packages/cli/src/modules/token-exchange/services/trusted-key-refresh.task.ts @@ -4,7 +4,7 @@ import type { SystemTaskEffects, SystemTaskPlacement, SystemTaskSchedule } from import { TrustedKeyService } from './trusted-key.service'; /** How often to poll sources to check if any are due for refresh. */ -const REFRESH_POLL_INTERVAL_SECONDS = 30; +const REFRESH_POLL_INTERVAL_SECONDS = 60; /** * Re-fetches trusted public keys whose refresh interval has lapsed, so JWT @@ -18,7 +18,7 @@ export class TrustedKeyRefreshTask implements SystemTask { readonly effects: SystemTaskEffects = 'idempotent'; - readonly placement: SystemTaskPlacement = { scope: 'cluster', durable: false }; + readonly placement: SystemTaskPlacement = { scope: 'cluster', durable: true }; constructor(private readonly trustedKeyService: TrustedKeyService) {} diff --git a/packages/cli/src/modules/token-exchange/services/trusted-key.service.ts b/packages/cli/src/modules/token-exchange/services/trusted-key.service.ts index f65c9421090..1b5dafe1a41 100644 --- a/packages/cli/src/modules/token-exchange/services/trusted-key.service.ts +++ b/packages/cli/src/modules/token-exchange/services/trusted-key.service.ts @@ -3,8 +3,8 @@ import { Time } from '@n8n/constants'; import { DbLock, DbLockService } from '@n8n/db'; import { OnLeaderTakeover } from '@n8n/decorators'; import { Service } from '@n8n/di'; -import type { EntityManager } from '@n8n/typeorm'; import { In, Not } from '@n8n/typeorm'; +import { ensureError } from '@n8n/utils/errors/ensure-error'; import { UnexpectedError, jsonParse } from 'n8n-workflow'; import type { KeyObject } from 'node:crypto'; import { createHash, createPublicKey } from 'node:crypto'; @@ -18,6 +18,7 @@ import { TokenExchangeConfig } from '../token-exchange.config'; import type { JwksKeySource, JwtAlgorithm, + ResolvedSourceKeys, ResolvedTrustedKey, StaticKeySource, TrustedKeyData, @@ -164,6 +165,7 @@ export class TrustedKeyService { /** * Force-refresh a single source. Can be called from any instance — * uses an advisory lock for distributed mutual exclusion. + * @throws When the source does not exist or its keys cannot be resolved. */ async refreshSource(sourceId: string): Promise { const source = await this.trustedKeySourceRepository.findOneBy({ id: sourceId }); @@ -293,13 +295,21 @@ export class TrustedKeyService { // ─── Private: refresh ────────────────────────────────────────────── /** - * Refreshes all sources unconditionally. Never throws: a failed cycle is logged. + * Refreshes all sources unconditionally. Logs each failure and continues. + * Never throws. */ private async refreshAllSources(): Promise { try { const sources = await this.trustedKeySourceRepository.find(); for (const source of sources) { - await this.refreshSourceInternal(source); + try { + await this.refreshSourceInternal(source); + } catch (error) { + this.logger.error('Failed to refresh trusted key source', { + sourceId: source.id, + error, + }); + } } } catch (error) { this.logger.error('Failed to run trusted key refresh cycle', { error }); @@ -308,13 +318,15 @@ export class TrustedKeyService { /** * Refreshes only the sources whose `lastRefreshedAt` is older than their - * configured refresh interval. A failed source is marked as error and does - * not stop the cycle. Stops before the next source once `signal` aborts. + * configured refresh interval. Stops at the first source that fails, and + * before the next source once `signal` aborts. * @throws When the sources cannot be loaded from the database. + * @throws When a source cannot be refreshed. */ async refreshDueSources(signal: AbortSignal): Promise { this.logger.debug('Refreshing due sources'); - const sources = await this.trustedKeySourceRepository.find(); + // A source that failed has the newest `updatedAt`, so the next run tries it last. + const sources = await this.trustedKeySourceRepository.find({ order: { updatedAt: 'ASC' } }); const now = Date.now(); for (const source of sources) { if (signal.aborted) return; @@ -346,78 +358,34 @@ export class TrustedKeyService { /** * Per-source transactional refresh, serialized by an advisory lock. * - * On success: old keys deleted, conflicts resolved, new keys inserted, - * source marked healthy. + * On success: old keys deleted, new keys inserted, source marked healthy. * - * On error: transaction rolls back (preserving existing keys), source - * marked with `status = 'error'` and `lastError` outside the transaction. + * On failure: rolls back, which keeps the existing keys and `lastRefreshedAt`. + * Then marks the source as error and throws. + * @throws {Error} when the lock cannot be taken, or the keys cannot be resolved or written. */ private async refreshSourceInternal(source: TrustedKeySourceEntity): Promise { try { - await this.dbLockService.withLock(DbLock.TRUSTED_KEY_REFRESH, async (tx) => { - const freshSource = await tx.findOneBy(TrustedKeySourceEntity, { id: source.id }); - if (!freshSource) return; - await this.refreshSourceWithinTransaction(freshSource, tx); - }); + await this.dbLockService.withLockContext( + DbLock.TRUSTED_KEY_REFRESH, + async (ctx) => + await this.trustedKeySourceRepository.refreshSource( + source.id, + async (freshSource) => await this.resolveKeysForSource(freshSource), + ctx, + ), + ); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.logger.error('Failed to refresh trusted key source', { - sourceId: source.id, - error: message, - }); + const failure = ensureError(error); + // Written after the rollback: an aborted Postgres transaction rejects every later statement. await this.trustedKeySourceRepository.update(source.id, { status: 'error', - lastError: message, - lastRefreshedAt: new Date(), + lastError: failure.message, }); + throw failure; } } - private async refreshSourceWithinTransaction( - source: TrustedKeySourceEntity, - tx: EntityManager, - ): Promise { - const result = await this.resolveKeysForSource(source); - if (!result) { - // Mark as refreshed so the source is skipped until the next interval, - // even though no keys were resolved (e.g. unsupported source type). - await tx.update(TrustedKeySourceEntity, source.id, { - status: 'healthy', - lastRefreshedAt: new Date(), - }); - return; - } - - const keys = result.keys; - const cacheTtlSeconds = result.cacheTtlSeconds; - - // 1. DELETE old keys for this source - await tx.delete(TrustedKeyEntity, { sourceId: source.id }); - - // 2. INSERT new keys - for (const key of keys) { - await tx.save(TrustedKeyEntity, { - sourceId: source.id, - kid: key.kid, - data: JSON.stringify(key.data), - createdAt: new Date(), - }); - } - - // 3. UPDATE source status, and persist observed cache TTL for refresh scheduling - const updatePayload: Partial = { - status: 'healthy' as const, - lastError: null, - lastRefreshedAt: new Date(), - }; - if (cacheTtlSeconds !== undefined) { - const config = jsonParse>(source.config); - config.cacheTtlSeconds = cacheTtlSeconds; - updatePayload.config = JSON.stringify(config); - } - await tx.update(TrustedKeySourceEntity, source.id, updatePayload); - } - /** * Resolve keys for a source by type. Returns `undefined` for * unsupported types (signalling the caller should skip). @@ -427,9 +395,7 @@ export class TrustedKeyService { */ private async resolveKeysForSource( source: TrustedKeySourceEntity, - ): Promise< - { keys: Array<{ kid: string; data: TrustedKeyData }>; cacheTtlSeconds?: number } | undefined - > { + ): Promise { switch (source.type) { case 'static': return this.resolveKeysForStaticSource(source); @@ -446,7 +412,7 @@ export class TrustedKeyService { private async resolveKeysForJwksSource( source: TrustedKeySourceEntity, - ): Promise<{ keys: Array<{ kid: string; data: TrustedKeyData }>; cacheTtlSeconds: number }> { + ): Promise> { let jwksConfig: JwksKeySource; try { jwksConfig = jsonParse(source.config); @@ -479,10 +445,7 @@ export class TrustedKeyService { }; } - private resolveKeysForStaticSource(source: TrustedKeySourceEntity): { - keys: Array<{ kid: string; data: TrustedKeyData }>; - cacheTtlSeconds?: number; - } { + private resolveKeysForStaticSource(source: TrustedKeySourceEntity): ResolvedSourceKeys { let rawConfig: unknown; try { rawConfig = JSON.parse(source.config); diff --git a/packages/cli/src/modules/token-exchange/token-exchange.errors.ts b/packages/cli/src/modules/token-exchange/token-exchange.errors.ts index 2b98d84b55f..ac5ec25b617 100644 --- a/packages/cli/src/modules/token-exchange/token-exchange.errors.ts +++ b/packages/cli/src/modules/token-exchange/token-exchange.errors.ts @@ -1,5 +1,4 @@ -import { AuthError } from '@/errors/response-errors/auth.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { AuthError, BadRequestError } from '@n8n/errors'; import type { TokenExchangeFailureReason } from './token-exchange.types'; diff --git a/packages/cli/src/modules/token-exchange/token-exchange.schemas.ts b/packages/cli/src/modules/token-exchange/token-exchange.schemas.ts index 5743af9654b..c79b11c9b54 100644 --- a/packages/cli/src/modules/token-exchange/token-exchange.schemas.ts +++ b/packages/cli/src/modules/token-exchange/token-exchange.schemas.ts @@ -1,25 +1,11 @@ import type { Secret } from 'jsonwebtoken'; +import { JwtAlgorithmSchema, type JwtAlgorithm } from '@n8n/inbound-auth'; import { z } from 'zod'; /** RFC 8693 grant type URN for token exchange */ export const TOKEN_EXCHANGE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange' as const; -/** - * Asymmetric-only JWT algorithms accepted for trusted key sources. - * Symmetric (HMAC) and 'none' are excluded by design. - */ -export const JwtAlgorithmSchema = z.enum([ - 'RS256', - 'RS384', - 'RS512', - 'ES256', - 'ES384', - 'ES512', - 'PS256', - 'PS384', - 'PS512', - 'EdDSA', -]); +export { JwtAlgorithmSchema, type JwtAlgorithm }; /** * Validates JWT claims originating from an external identity provider. @@ -78,7 +64,6 @@ export type TrustedKeySource = z.infer; export type StaticKeySource = Extract; export type JwksKeySource = Extract; -export type JwtAlgorithm = z.infer; export type TrustedKeySourceType = 'static' | 'jwks'; export type TrustedKeySourceStatus = 'pending' | 'healthy' | 'error'; @@ -99,6 +84,12 @@ export const TrustedKeyDataSchema = z.object({ export type TrustedKeyData = z.infer; +/** Resolved keys and the optional cache lifetime returned by a JWKS endpoint. */ +export type ResolvedSourceKeys = { + keys: Array<{ kid: string; data: TrustedKeyData }>; + cacheTtlSeconds?: number; +}; + /** * A trusted key that has been normalized and resolved to an in-memory * representation ready for JWT verification. The raw key material from diff --git a/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy.service.test.ts b/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy.service.test.ts index 76ef9c7fc32..6e4e4d18089 100644 --- a/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy.service.test.ts +++ b/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy.service.test.ts @@ -1,14 +1,13 @@ +import type { EventService } from '@n8n/backend-services'; import { mockLogger } from '@n8n/backend-test-utils'; import type { OperationContext, TransactionRunner } from '@n8n/db'; import type { NodeLoader } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { EventService } from '@/events/event.service'; +import { ConflictError, NotFoundError } from '@n8n/errors'; import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import type { NodeTypes } from '@/node-types'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { CREDENTIAL_TYPES_KIND } from '../constants'; import type { TypeAvailabilityPolicyAttachmentRepository } from '../database/repositories/type-availability-policy-attachment.repository'; diff --git a/packages/cli/src/modules/type-availability-policies/credential-type-policy-instance.controller.ts b/packages/cli/src/modules/type-availability-policies/credential-type-policy-instance.controller.ts index 8dbcb5226ec..d29466a9f25 100644 --- a/packages/cli/src/modules/type-availability-policies/credential-type-policy-instance.controller.ts +++ b/packages/cli/src/modules/type-availability-policies/credential-type-policy-instance.controller.ts @@ -20,7 +20,7 @@ import { } from '@n8n/decorators'; import type { Response } from 'express'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { CREDENTIAL_TYPES_KIND } from './constants'; import { TypeAvailabilityPolicyService } from './type-availability-policy.service'; diff --git a/packages/cli/src/modules/type-availability-policies/type-availability-policy-instance.controller.ts b/packages/cli/src/modules/type-availability-policies/type-availability-policy-instance.controller.ts index a3a93540a29..ebba89e2517 100644 --- a/packages/cli/src/modules/type-availability-policies/type-availability-policy-instance.controller.ts +++ b/packages/cli/src/modules/type-availability-policies/type-availability-policy-instance.controller.ts @@ -20,7 +20,7 @@ import { } from '@n8n/decorators'; import type { Response } from 'express'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { NODE_TYPES_KIND } from './constants'; import { TypeAvailabilityPolicyService } from './type-availability-policy.service'; diff --git a/packages/cli/src/modules/type-availability-policies/type-availability-policy.service.ts b/packages/cli/src/modules/type-availability-policies/type-availability-policy.service.ts index 0b308291740..a24ae574284 100644 --- a/packages/cli/src/modules/type-availability-policies/type-availability-policy.service.ts +++ b/packages/cli/src/modules/type-availability-policies/type-availability-policy.service.ts @@ -1,17 +1,15 @@ import type { NodeTypeAvailabilityScope } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { CacheService, EventService } from '@n8n/backend-services'; import { Time } from '@n8n/constants'; import { TransactionRunner, type OperationContext } from '@n8n/db'; import { Service } from '@n8n/di'; import { LRUCache } from 'lru-cache'; import { OperationalError, UserError } from 'n8n-workflow'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { ConflictError, NotFoundError } from '@n8n/errors'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { NodeTypes } from '@/node-types'; -import { CacheService } from '@/services/cache/cache.service'; import { TypeAvailabilityPolicyAttachmentRepository } from './database/repositories/type-availability-policy-attachment.repository'; import { TypeAvailabilityPolicyScopeRepository } from './database/repositories/type-availability-policy-scope.repository'; diff --git a/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts b/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts index 7f33d1b3b34..78bd401f4de 100644 --- a/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts +++ b/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/unbound-method */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import type { WorkflowsConfig } from '@n8n/config'; import { WorkflowDependencyRepository, WorkflowEntity, WorkflowRepository } from '@n8n/db'; @@ -9,8 +10,6 @@ import { ErrorReporter, Tracing } from 'n8n-core'; import type { INode, IWorkflowBase } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import { EventService } from '@/events/event.service'; - import { WorkflowIndexService } from '../workflow-index.service'; describe('WorkflowIndexService', () => { diff --git a/packages/cli/src/modules/workflow-index/workflow-index.service.ts b/packages/cli/src/modules/workflow-index/workflow-index.service.ts index 493bdb3fb5b..c14876bcfb3 100644 --- a/packages/cli/src/modules/workflow-index/workflow-index.service.ts +++ b/packages/cli/src/modules/workflow-index/workflow-index.service.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { WorkflowsConfig } from '@n8n/config'; import type { IWorkflowDb } from '@n8n/db'; import { WorkflowDependencies, WorkflowDependencyRepository, WorkflowRepository } from '@n8n/db'; @@ -13,8 +14,6 @@ import { isNodeWithWorkflowSelector, } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; - // A safety limit to prevent infinite loops in indexing. const LOOP_LIMIT = 1_000_000_000; diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-activity.integration.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-activity.integration.test.ts index 40deca8be5b..a99285395b9 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-activity.integration.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-activity.integration.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, createWorkflow, mockInstance, testDb } from '@n8n/backend-test-utils'; import type { Project, User } from '@n8n/db'; import { @@ -12,7 +13,6 @@ import { import { Container } from '@n8n/di'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; -import { EventService } from '@/events/event.service'; import { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service'; import { WorkflowValidationService } from '@/workflows/workflow-validation.service'; import { createWorkflowHistoryItem } from '@test-integration/db/workflow-history'; diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-authorization.access.service.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-authorization.access.service.test.ts index 1c3b8463009..5bb27b3d4cc 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-authorization.access.service.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-authorization.access.service.test.ts @@ -14,7 +14,7 @@ import { mock } from 'vitest-mock-extended'; import { WorkflowReviewAuthorizationService } from '../workflow-review-authorization.service'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import type { ProjectService } from '@/services/project.service.ee'; import type { RoleService } from '@/services/role.service'; import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-inbox.detail.service.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-inbox.detail.service.test.ts index 659ede95d59..184d4dd8f2c 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-inbox.detail.service.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-inbox.detail.service.test.ts @@ -10,7 +10,7 @@ import type { } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import type { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service'; import type { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-lifecycle.integration.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-lifecycle.integration.test.ts index 98df34ae1a4..e40bafebe27 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-lifecycle.integration.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-lifecycle.integration.test.ts @@ -1,4 +1,5 @@ import type { SourceControlledFile } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import { createTeamProject, mockInstance, testDb } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import type { Project, User } from '@n8n/db'; @@ -21,7 +22,6 @@ import { v4 as uuid } from 'uuid'; import { mock } from 'vitest-mock-extended'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; -import { EventService } from '@/events/event.service'; import { SourceControlImportService } from '@/modules/source-control.ee/source-control-import.service.ee'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service'; @@ -632,8 +632,8 @@ describe('auto-close on source-control pull', () => { mock({ // The repository verifies the token, so it has to be a real one. With no backend // registered the real service clears everything, which is what a default pull does. - enforceContentImport: async (context) => - await Container.get(PolicyEnforcementService).enforceContentImport(context), + enforceContentImport: async (context, actor) => + await Container.get(PolicyEnforcementService).enforceContentImport(context, actor), }), // policyEnforcementService mock(), // dataTableSizeValidator Container.get(WorkflowPublishedVersionRepository), diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-lifecycle.service.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-lifecycle.service.test.ts index 1f52ff380b3..6eaba768e69 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-lifecycle.service.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-lifecycle.service.test.ts @@ -1,4 +1,5 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { DbLock } from '@n8n/db'; import type { DbLockService, @@ -14,7 +15,6 @@ import type { import { mock } from 'vitest-mock-extended'; import type { CollaborationService } from '@/collaboration/collaboration.service'; -import type { EventService } from '@/events/event.service'; import { WorkflowReviewLifecycleService } from '../workflow-review-lifecycle.service'; import { WorkflowReviewStateNotifier } from '../workflow-review-state-notifier.service'; diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-request-decision.service.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-request-decision.service.test.ts index ddb6c405ca2..ab826f20006 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-request-decision.service.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-request-decision.service.test.ts @@ -1,5 +1,6 @@ import type { DecideWorkflowReviewRequestDto } from '@n8n/api-types'; import type { LicenseState, Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { DbLockService, Project, @@ -30,11 +31,7 @@ import { WorkflowReviewRequestMutationGuard } from '../workflow-review-request-m import { WorkflowReviewStateNotifier } from '../workflow-review-state-notifier.service'; import type { CollaborationService } from '@/collaboration/collaboration.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { EventService } from '@/events/event.service'; +import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '@n8n/errors'; import type { ProjectService } from '@/services/project.service.ee'; import type { RoleService } from '@/services/role.service'; import type { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service'; diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-request-status.service.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-request-status.service.test.ts index 0c2576514e6..18782fe709e 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-request-status.service.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-request-status.service.test.ts @@ -9,8 +9,7 @@ import type { } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import type { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service'; import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-request-submission.service.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-request-submission.service.test.ts index fbea5443b89..3434f2254a1 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-request-submission.service.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-request-submission.service.test.ts @@ -3,6 +3,7 @@ import type { GetWorkflowReviewEligibleReviewersQueryDto, } from '@n8n/api-types'; import type { LicenseState, Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { DbLock, User } from '@n8n/db'; import type { AuthIdentity, @@ -25,11 +26,7 @@ import type { import { mock } from 'vitest-mock-extended'; import type { CollaborationService } from '@/collaboration/collaboration.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { EventService } from '@/events/event.service'; +import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '@n8n/errors'; import type { RoleService } from '@/services/role.service'; import type { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service'; import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-request-submission.version-update.service.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-request-submission.version-update.service.test.ts index 9058f01f137..764232da13a 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-request-submission.version-update.service.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-request-submission.version-update.service.test.ts @@ -1,5 +1,6 @@ import type { UpdateWorkflowReviewRequestVersionDto } from '@n8n/api-types'; import type { LicenseState, Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { DbLock } from '@n8n/db'; import type { DbLockService, @@ -24,11 +25,7 @@ import type { import { mock } from 'vitest-mock-extended'; import type { CollaborationService } from '@/collaboration/collaboration.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { EventService } from '@/events/event.service'; +import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '@n8n/errors'; import type { RoleService } from '@/services/role.service'; import type { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service'; import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; diff --git a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-activity.service.ts b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-activity.service.ts index 9d9ad7c360f..3a85d0dbabd 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-activity.service.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-activity.service.ts @@ -6,6 +6,7 @@ import type { WorkflowReviewEligibleReviewer, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { TransactionRunner, UserRepository, @@ -17,10 +18,7 @@ import { } from '@n8n/db'; import { Service } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ConflictError, ForbiddenError } from '@n8n/errors'; import { WorkflowReviewAuthorizationService } from './workflow-review-authorization.service'; import { WorkflowReviewFeatureGate } from './workflow-review-feature-gate.service'; diff --git a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-authorization.service.ts b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-authorization.service.ts index 9fcaf9f3dee..5d24a348ace 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-authorization.service.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-authorization.service.ts @@ -19,7 +19,7 @@ import { hasGlobalScope, } from '@n8n/permissions'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; diff --git a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-feature-gate.service.ts b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-feature-gate.service.ts index d56210750fa..e7f0675e514 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-feature-gate.service.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-feature-gate.service.ts @@ -1,7 +1,7 @@ import { LicenseState } from '@n8n/backend-common'; import { Service } from '@n8n/di'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service'; type WorkflowReviewAvailability = { available: true } | { available: false; errorMessage: string }; diff --git a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-inbox.service.ts b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-inbox.service.ts index b0e549ca1bf..fbdb4f85686 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-inbox.service.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-inbox.service.ts @@ -19,7 +19,7 @@ import { } from '@n8n/db'; import { Service } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import { WorkflowReviewAuthorizationService } from './workflow-review-authorization.service'; diff --git a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-lifecycle.service.ts b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-lifecycle.service.ts index edbc17542e3..dfd7899b1dd 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-lifecycle.service.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-lifecycle.service.ts @@ -1,4 +1,5 @@ import type { WorkflowReviewWorkflowCauseActivityType } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import type { OperationContext } from '@n8n/db'; import { Logger } from '@n8n/backend-common'; import { @@ -11,7 +12,6 @@ import { } from '@n8n/db'; import { Service } from '@n8n/di'; -import { EventService } from '@/events/event.service'; import type { WorkflowMutationHooks } from '@/workflows/workflow-mutation-hooks-proxy.service'; import { WorkflowReviewStateNotifier } from './workflow-review-state-notifier.service'; diff --git a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-request-decision.service.ts b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-request-decision.service.ts index 2c5e8b0e3a0..ae854347178 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-request-decision.service.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-request-decision.service.ts @@ -4,6 +4,7 @@ import type { WorkflowReviewAutoPublishOutcome, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { DbLock, DbLockService, @@ -19,10 +20,7 @@ import { Service } from '@n8n/di'; import { ensureError } from '@n8n/utils/errors/ensure-error'; import { CollaborationService } from '@/collaboration/collaboration.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowService } from '@/workflows/workflow.service'; diff --git a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-request-mutation-guard.service.ts b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-request-mutation-guard.service.ts index 45cbddf43f2..c1c0ad81561 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-request-mutation-guard.service.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-request-mutation-guard.service.ts @@ -6,9 +6,7 @@ import { } from '@n8n/db'; import { Service } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ConflictError, NotFoundError } from '@n8n/errors'; type BlockedAction = 'submit' | 'review' | 'update'; diff --git a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-request-status.service.ts b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-request-status.service.ts index 60488e9732e..3ea17009311 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-request-status.service.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-request-status.service.ts @@ -12,7 +12,7 @@ import { } from '@n8n/db'; import { Service } from '@n8n/di'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowReviewAuthorizationService } from './workflow-review-authorization.service'; diff --git a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-request-submission.service.ts b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-request-submission.service.ts index 395e46823a9..6ae36fbba2f 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/workflow-review-request-submission.service.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/workflow-review-request-submission.service.ts @@ -5,6 +5,7 @@ import type { WorkflowReviewEligibleReviewersList, WorkflowReviewRequestSummary, } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import { DbLock, DbLockService, @@ -22,10 +23,7 @@ import { } from '@n8n/db'; import { Service } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ConflictError, NotFoundError } from '@n8n/errors'; import { RoleService } from '@/services/role.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; diff --git a/packages/cli/src/node-execution/__tests__/execute-node.service.test.ts b/packages/cli/src/node-execution/__tests__/execute-node.service.test.ts index 672a4f02875..b065fff91c5 100644 --- a/packages/cli/src/node-execution/__tests__/execute-node.service.test.ts +++ b/packages/cli/src/node-execution/__tests__/execute-node.service.test.ts @@ -8,8 +8,7 @@ import { mock } from 'vitest-mock-extended'; import { ActiveExecutions } from '@/active-executions'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import { NodeTypes } from '@/node-types'; import { WorkflowRunner } from '@/workflow-runner'; diff --git a/packages/cli/src/node-execution/execute-node.service.ts b/packages/cli/src/node-execution/execute-node.service.ts index 514b52da2de..b0200f534ca 100644 --- a/packages/cli/src/node-execution/execute-node.service.ts +++ b/packages/cli/src/node-execution/execute-node.service.ts @@ -18,8 +18,7 @@ import { v4 as uuid } from 'uuid'; import { ActiveExecutions } from '@/active-executions'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import { NodeTypes } from '@/node-types'; import { WorkflowRunner } from '@/workflow-runner'; diff --git a/packages/cli/src/oauth/__tests__/oauth.service.integration.test.ts b/packages/cli/src/oauth/__tests__/oauth.service.integration.test.ts index 0aed18da66d..67783c3ff1f 100644 --- a/packages/cli/src/oauth/__tests__/oauth.service.integration.test.ts +++ b/packages/cli/src/oauth/__tests__/oauth.service.integration.test.ts @@ -1,6 +1,7 @@ import type { LockService, Logger } from '@n8n/backend-common'; import { OutboundHttp, type SsrfProtectionService } from '@n8n/backend-network'; import { type LocalServer, startServer } from '@n8n/backend-network/testing'; +import type { CacheService, EventService, UrlService } from '@n8n/backend-services'; import type { GlobalConfig, SsrfProtectionConfig } from '@n8n/config'; import type { CredentialsRepository } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; @@ -11,13 +12,10 @@ import type { AuthService } from '@/auth/auth.service'; import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import type { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy'; import type { CredentialsHelper } from '@/credentials-helper'; -import type { EventService } from '@/events/event.service'; import type { ExternalHooks } from '@/external-hooks'; import type { OAuthBrowserBindingService } from '@/oauth/oauth-browser-binding.service'; import type { OAuthJweServiceProxy } from '@/oauth/oauth-jwe-service.proxy'; import { OauthService, type OAuth1CredentialData } from '@/oauth/oauth.service'; -import type { CacheService } from '@/services/cache/cache.service'; -import type { UrlService } from '@n8n/backend-services'; interface Received { method?: string; diff --git a/packages/cli/src/oauth/__tests__/oauth.service.test.ts b/packages/cli/src/oauth/__tests__/oauth.service.test.ts index ebc56ba6ce1..67c3ad4063d 100644 --- a/packages/cli/src/oauth/__tests__/oauth.service.test.ts +++ b/packages/cli/src/oauth/__tests__/oauth.service.test.ts @@ -1,5 +1,6 @@ import { LockAcquisitionTimeoutError, LockService, Logger } from '@n8n/backend-common'; import { OutboundHttp, SsrfProtectionService, type HttpRequestClient } from '@n8n/backend-network'; +import { CacheService, EventService, UrlService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import type { OAuth2CredentialData } from '@n8n/client-oauth2'; import { AuthError as OAuth2AuthError } from '@n8n/client-oauth2'; @@ -19,10 +20,7 @@ import { AuthService } from '@/auth/auth.service'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy'; import { CredentialsHelper } from '@/credentials-helper'; -import { AuthError } from '@/errors/response-errors/auth.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { AuthError, BadRequestError, NotFoundError } from '@n8n/errors'; import { ExternalHooks } from '@/external-hooks'; import { OAuthBrowserBindingService } from '@/oauth/oauth-browser-binding.service'; import { OAuthJweServiceProxy } from '@/oauth/oauth-jwe-service.proxy'; @@ -36,8 +34,6 @@ import { type OAuth1CredentialData, } from '@/oauth/oauth.service'; import type { OAuthRequest } from '@/requests'; -import { CacheService } from '@/services/cache/cache.service'; -import { UrlService } from '@n8n/backend-services'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; vi.mock('@/workflow-execute-additional-data'); @@ -1227,6 +1223,8 @@ describe('OauthService', () => { // Flow state read from cache and consumed (replay protection) expect(result[4]).toEqual({ csrfSecret: 'csrf-secret', codeVerifier: 'code-verifier' }); expect(cacheService.delete).toHaveBeenCalledWith(`oauth:flow:${stateToken}`); + // Names the user on a policy block while the credential is decrypted. + expect(WorkflowExecuteAdditionalData.getBase).toHaveBeenCalledWith({ userId: 'user-id' }); }); it('should reject the callback when the flow state is missing (replay / unknown state)', async () => { @@ -1441,6 +1439,8 @@ describe('OauthService', () => { // Should succeed despite no user because origin is dynamic-credential expect(result[0]).toEqual(mockCredential); + // The starter comes from the state, since the callback carries no user. + expect(WorkflowExecuteAdditionalData.getBase).toHaveBeenCalledWith({ userId: 'user-id' }); expect(result[1]).toEqual(mockDecryptedData); expect(result[2]).toEqual(mockOAuthCredentials); expect(result[3]).toMatchObject({ @@ -2039,6 +2039,7 @@ describe('OauthService', () => { }); expect(authUri).toContain('https://example.domain/oauth2/auth'); + expect(service.getOAuthCredentials).toHaveBeenCalledWith(credential, 'user-id'); // CSRF/PKCE state must not be persisted to the credential; it lives in the cache. expect(service.encryptAndSaveData).not.toHaveBeenCalled(); expect(cacheService.set).toHaveBeenCalledWith( diff --git a/packages/cli/src/oauth/oauth.service.ts b/packages/cli/src/oauth/oauth.service.ts index 12e36d5e66b..42d81fc0224 100644 --- a/packages/cli/src/oauth/oauth.service.ts +++ b/packages/cli/src/oauth/oauth.service.ts @@ -5,6 +5,7 @@ import { type HttpRequestClient, type SsrfBridge, } from '@n8n/backend-network'; +import { CacheService, EventService, UrlService } from '@n8n/backend-services'; import { GlobalConfig, SsrfProtectionConfig } from '@n8n/config'; import type { AuthenticatedRequest, CredentialsEntity, ICredentialsDb } from '@n8n/db'; import { CredentialsRepository } from '@n8n/db'; @@ -23,13 +24,10 @@ import { import { AuthService } from '@/auth/auth.service'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { CredentialsHelper } from '@/credentials-helper'; -import { AuthError } from '@/errors/response-errors/auth.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { AuthError, BadRequestError, NotFoundError } from '@n8n/errors'; import type { OAuthRequest } from '@/requests'; import { extractAccountIdentifierFromData } from '@/oauth/account-identifier'; import { validateOAuthUrl } from '@/oauth/validate-oauth-url'; -import { UrlService } from '@n8n/backend-services'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import { AuthError as OAuth2AuthError, @@ -66,10 +64,8 @@ import { } from './types'; import { CredentialStoreMetadata } from '@/credentials/dynamic-credential-storage.interface'; import { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy'; -import { EventService } from '@/events/event.service'; import { OAuthJweServiceProxy } from '@/oauth/oauth-jwe-service.proxy'; import { OAuthBrowserBindingService } from '@/oauth/oauth-browser-binding.service'; -import { CacheService } from '@/services/cache/cache.service'; import { Time } from '@n8n/constants'; /** @@ -115,6 +111,10 @@ export const skipAuthOnOAuthCallback = shouldSkipAuthOnOAuthCallback(); export { OauthVersion, type OAuth1CredentialData, type CreateCsrfStateData, type CsrfState }; +/** The user who started the flow. An externally started dynamic-credential flow has none. */ +const csrfUserId = (csrfData: CreateCsrfStateData) => + typeof csrfData.userId === 'string' ? csrfData.userId : undefined; + export class InvalidTargetError extends BadRequestError { constructor(message: string) { super(message, undefined, 'invalid_target'); @@ -349,8 +349,9 @@ export class OauthService { }; } - protected async getAdditionalData() { - return await WorkflowExecuteAdditionalData.getBase(); + /** `userId` names the user on a policy block while the credential is decrypted. */ + protected async getAdditionalData(userId?: string) { + return await WorkflowExecuteAdditionalData.getBase({ userId }); } /** @@ -613,7 +614,8 @@ export class OauthService { throw new NotFoundError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL); } - const additionalData = await this.getAdditionalData(); + // The state holds the verified starter; the callback request may carry no user at all. + const additionalData = await this.getAdditionalData(csrfUserId(state)); const decryptedDataOriginal = await this.getDecryptedDataForCallback( credential, additionalData, @@ -665,8 +667,8 @@ export class OauthService { return causes.length ? causes.join(': ') : undefined; } - async getOAuthCredentials(credential: CredentialsEntity): Promise { - const additionalData = await this.getAdditionalData(); + async getOAuthCredentials(credential: CredentialsEntity, userId?: string): Promise { + const additionalData = await this.getAdditionalData(userId); const decryptedDataOriginal = await this.getDecryptedDataForAuthUri(credential, additionalData); // At some point in the past we saved hidden scopes to credentials (but shouldn't) @@ -1017,7 +1019,7 @@ export class OauthService { this.applyBrowserBindingIfEnabled(csrfData, req, res); const oauthCredentials: OAuth2CredentialData = - await this.getOAuthCredentials(credential); + await this.getOAuthCredentials(credential, csrfUserId(csrfData)); const toUpdate: ICredentialDataDecryptedObject = {}; const toDelete: string[] = []; @@ -1256,7 +1258,7 @@ export class OauthService { this.applyBrowserBindingIfEnabled(csrfData, req, res); const oauthCredentials: OAuth1CredentialData = - await this.getOAuthCredentials(credential); + await this.getOAuthCredentials(credential, csrfUserId(csrfData)); this.validateOAuthUrlOrThrow(oauthCredentials.authUrl ?? ''); this.validateOAuthUrlOrThrow(oauthCredentials.requestTokenUrl ?? ''); diff --git a/packages/cli/src/oauth/validate-oauth-url.ts b/packages/cli/src/oauth/validate-oauth-url.ts index 9571eeed9b2..177bd596a06 100644 --- a/packages/cli/src/oauth/validate-oauth-url.ts +++ b/packages/cli/src/oauth/validate-oauth-url.ts @@ -1,4 +1,4 @@ -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; const ALLOWED_OAUTH_URL_PROTOCOLS = ['http:', 'https:']; diff --git a/packages/cli/src/permissions.ee/__tests__/check-access.test.ts b/packages/cli/src/permissions.ee/__tests__/check-access.test.ts index 0f3244610c8..fced27d6675 100644 --- a/packages/cli/src/permissions.ee/__tests__/check-access.test.ts +++ b/packages/cli/src/permissions.ee/__tests__/check-access.test.ts @@ -12,7 +12,7 @@ import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { RoleService } from '@/services/role.service'; import { userHasScopes } from '../check-access'; diff --git a/packages/cli/src/permissions.ee/check-access.ts b/packages/cli/src/permissions.ee/check-access.ts index 858a30c00d0..8fc59d17454 100644 --- a/packages/cli/src/permissions.ee/check-access.ts +++ b/packages/cli/src/permissions.ee/check-access.ts @@ -15,7 +15,7 @@ import { hasGlobalScope, type Scope } from '@n8n/permissions'; import { UnexpectedError } from 'n8n-workflow'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { RoleService } from '@/services/role.service'; const INSTANCE_CREDENTIAL_MANAGEMENT_SCOPES = new Set([ diff --git a/packages/cli/src/policy/__tests__/enforce-workflow-publish.test.ts b/packages/cli/src/policy/__tests__/enforce-workflow-publish.test.ts index 8f7e44bd654..12d46f329b4 100644 --- a/packages/cli/src/policy/__tests__/enforce-workflow-publish.test.ts +++ b/packages/cli/src/policy/__tests__/enforce-workflow-publish.test.ts @@ -4,12 +4,14 @@ import { mock } from 'vitest-mock-extended'; import type { OwnershipService } from '@/services/ownership.service'; import { enforceWorkflowPublishPolicy } from '../enforce-workflow-publish'; +import type { PolicyActor } from '../policy-enforcement-backend'; import type { PolicyEnforcementService } from '../policy-enforcement.service'; describe('enforceWorkflowPublishPolicy', () => { const policyEnforcementService = mock(); const ownershipService = mock(); const workflow = { id: 'workflow-1', name: 'Test Workflow', nodes: [] }; + const actor: PolicyActor = { kind: 'user', user: { id: 'user-1' } }; beforeEach(() => { vi.clearAllMocks(); @@ -20,23 +22,26 @@ describe('enforceWorkflowPublishPolicy', () => { it('skips the project lookup and the check when nothing is registered for workflowPublish', async () => { policyEnforcementService.hasChecksFor.mockReturnValue(false); - await enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow); + await enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow, actor); expect(policyEnforcementService.hasChecksFor).toHaveBeenCalledWith('workflowPublish'); expect(ownershipService.getWorkflowProjectCached).not.toHaveBeenCalled(); expect(policyEnforcementService.enforceWorkflowPublish).not.toHaveBeenCalled(); }); - it('enforces with the owning project when a check is registered', async () => { + it('enforces with the owning project and the actor when a check is registered', async () => { policyEnforcementService.hasChecksFor.mockReturnValue(true); - await enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow); + await enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow, actor); expect(ownershipService.getWorkflowProjectCached).toHaveBeenCalledWith('workflow-1'); - expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledWith({ - workflow, - projectId: 'project-1', - }); + expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledWith( + { + workflow, + projectId: 'project-1', + }, + actor, + ); }); // An unevaluated project rule is not a passed one, so the lookup is unguarded. @@ -45,7 +50,7 @@ describe('enforceWorkflowPublishPolicy', () => { ownershipService.getWorkflowProjectCached.mockRejectedValue(new Error('no owner row')); await expect( - enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow), + enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow, actor), ).rejects.toThrow('no owner row'); expect(policyEnforcementService.enforceWorkflowPublish).not.toHaveBeenCalled(); diff --git a/packages/cli/src/policy/__tests__/policy-enforcement.service.test.ts b/packages/cli/src/policy/__tests__/policy-enforcement.service.test.ts index 65bb1093c17..eba72d59fd1 100644 --- a/packages/cli/src/policy/__tests__/policy-enforcement.service.test.ts +++ b/packages/cli/src/policy/__tests__/policy-enforcement.service.test.ts @@ -2,7 +2,7 @@ import type { PolicedWorkflow, PolicyDecision, PolicyViolation } from '@n8n/deco import { UnexpectedError } from 'n8n-workflow'; import { mock, type MockProxy } from 'vitest-mock-extended'; -import type { PolicyEnforcementBackend } from '../policy-enforcement-backend'; +import type { PolicyActor, PolicyEnforcementBackend } from '../policy-enforcement-backend'; import { PolicyEnforcementService } from '../policy-enforcement.service'; import { PolicyViolationError } from '../policy-violation.error'; @@ -19,39 +19,60 @@ const cleared: PolicyDecision = { violations: [] }; /** One call per point, so a new point can't be left out of the no-op guarantee. */ const enforceCalls = (service: PolicyEnforcementService) => ({ workflowSave: async () => - await service.enforceWorkflowSave({ - workflow: savedWorkflow, - storedWorkflow: null, - projectId: 'proj-1', - }), + await service.enforceWorkflowSave( + { + workflow: savedWorkflow, + storedWorkflow: null, + projectId: 'proj-1', + }, + { kind: 'system', reason: 'execution' }, + ), workflowPublish: async () => - await service.enforceWorkflowPublish({ workflow: savedWorkflow, projectId: 'proj-1' }), + await service.enforceWorkflowPublish( + { workflow: savedWorkflow, projectId: 'proj-1' }, + { kind: 'system', reason: 'execution' }, + ), workflowStart: async () => - await service.enforceWorkflowStart({ workflow: savedWorkflow, projectId: 'proj-1' }), + await service.enforceWorkflowStart( + { workflow: savedWorkflow, projectId: 'proj-1' }, + { kind: 'system', reason: 'execution' }, + ), workflowTransfer: async () => - await service.enforceWorkflowTransfer({ - workflow: savedWorkflow, - targetProjectId: 'proj-2', - }), + await service.enforceWorkflowTransfer( + { + workflow: savedWorkflow, + targetProjectId: 'proj-2', + }, + { kind: 'system', reason: 'execution' }, + ), credentialSave: async () => - await service.enforceCredentialSave({ - credential: { id: null, type: 'slackApi' }, - storedCredential: null, - projectId: 'proj-1', - }), + await service.enforceCredentialSave( + { + credential: { id: null, type: 'slackApi' }, + storedCredential: null, + projectId: 'proj-1', + }, + { kind: 'system', reason: 'execution' }, + ), credentialDecrypt: async () => - await service.enforceCredentialDecrypt({ - credentialType: 'slackApi', - credentialId: 'cred-1', - consumer: null, - projectId: 'proj-1', - }), + await service.enforceCredentialDecrypt( + { + credentialType: 'slackApi', + credentialId: 'cred-1', + consumer: null, + projectId: 'proj-1', + }, + { kind: 'system', reason: 'execution' }, + ), contentImport: async () => - await service.enforceContentImport({ - workflow: savedWorkflow, - projectId: 'proj-1', - transport: 'cli', - }), + await service.enforceContentImport( + { + workflow: savedWorkflow, + projectId: 'proj-1', + transport: 'cli', + }, + { kind: 'system', reason: 'execution' }, + ), }); const evaluateCalls = (service: PolicyEnforcementService) => ({ @@ -166,14 +187,17 @@ describe('PolicyEnforcementService', () => { backend.enforce.mockResolvedValue({ violations: [violation, second] }); const error = await service - .enforceWorkflowSave({ workflow: savedWorkflow, storedWorkflow: null, projectId: null }) + .enforceWorkflowSave( + { workflow: savedWorkflow, storedWorkflow: null, projectId: null }, + { kind: 'system', reason: 'execution' }, + ) .catch((e: unknown) => e); expect(error).toBeInstanceOf(PolicyViolationError); expect((error as PolicyViolationError).violations).toEqual([violation, second]); }); - it('passes the point and context through and mints the decision it got back', async () => { + it('passes the point, context and actor through and mints the decision it got back', async () => { const decision: PolicyDecision = { violations: [], policyVersions: [{ scope: 'instance', version: 7 }], @@ -181,9 +205,11 @@ describe('PolicyEnforcementService', () => { backend.enforce.mockResolvedValue(decision); const context = { workflow: savedWorkflow, storedWorkflow: null, projectId: 'proj-1' }; - const token = await service.enforceWorkflowSave(context); + const actor: PolicyActor = { kind: 'user', user: { id: 'user-1' } }; - expect(backend.enforce).toHaveBeenCalledWith('workflowSave', context); + const token = await service.enforceWorkflowSave(context, actor); + + expect(backend.enforce).toHaveBeenCalledWith('workflowSave', context, actor); expect(token.decision).toBe(decision); expect(token.policyVersions).toEqual([{ scope: 'instance', version: 7 }]); }); @@ -205,10 +231,13 @@ describe('PolicyEnforcementService', () => { const service = new PolicyEnforcementService(); it('binds a saved workflow to its id', async () => { - const token = await service.enforceWorkflowStart({ - workflow: savedWorkflow, - projectId: null, - }); + const token = await service.enforceWorkflowStart( + { + workflow: savedWorkflow, + projectId: null, + }, + { kind: 'system', reason: 'execution' }, + ); expect(token.subject).toEqual({ type: 'workflow', id: 'wf-1' }); }); @@ -216,11 +245,14 @@ describe('PolicyEnforcementService', () => { it('binds an unsaved workflow to a hash of its nodes', async () => { const unsaved: PolicedWorkflow = { id: null, name: 'New', nodes: [] }; - const token = await service.enforceWorkflowSave({ - workflow: unsaved, - storedWorkflow: null, - projectId: null, - }); + const token = await service.enforceWorkflowSave( + { + workflow: unsaved, + storedWorkflow: null, + projectId: null, + }, + { kind: 'system', reason: 'execution' }, + ); expect(token.subject.type).toBe('workflow'); expect(token.subject.id).toMatch(/^[0-9a-f]{64}$/); @@ -231,11 +263,14 @@ describe('PolicyEnforcementService', () => { it('binds a create with a supplied id to a hash of its nodes', async () => { const withClientId: PolicedWorkflow = { id: 'wf-client', name: 'New', nodes: [] }; - const token = await service.enforceWorkflowSave({ - workflow: withClientId, - storedWorkflow: null, - projectId: null, - }); + const token = await service.enforceWorkflowSave( + { + workflow: withClientId, + storedWorkflow: null, + projectId: null, + }, + { kind: 'system', reason: 'execution' }, + ); expect(token.subject.id).toMatch(/^[0-9a-f]{64}$/); expect(token.subject.id).not.toBe('wf-client'); @@ -243,11 +278,14 @@ describe('PolicyEnforcementService', () => { it('gives two unsaved workflows with different nodes different subjects', async () => { const enforce = async (nodes: PolicedWorkflow['nodes']) => - await service.enforceWorkflowSave({ - workflow: { id: null, name: 'New', nodes }, - storedWorkflow: null, - projectId: null, - }); + await service.enforceWorkflowSave( + { + workflow: { id: null, name: 'New', nodes }, + storedWorkflow: null, + projectId: null, + }, + { kind: 'system', reason: 'execution' }, + ); const empty = await enforce([]); const withNode = await enforce([mock({ type: 'slack' })]); @@ -256,33 +294,42 @@ describe('PolicyEnforcementService', () => { }); it('binds a credential create to a hash of its type', async () => { - const token = await service.enforceCredentialSave({ - credential: { id: null, type: 'slackApi' }, - storedCredential: null, - projectId: null, - }); + const token = await service.enforceCredentialSave( + { + credential: { id: null, type: 'slackApi' }, + storedCredential: null, + projectId: null, + }, + { kind: 'system', reason: 'execution' }, + ); expect(token.subject.type).toBe('credential'); expect(token.subject.id).toMatch(/^[0-9a-f]{64}$/); }); it('binds a credential update to the row id', async () => { - const token = await service.enforceCredentialSave({ - credential: { id: 'cred-1', type: 'slackApi' }, - storedCredential: { id: 'cred-1', type: 'slackApi' }, - projectId: null, - }); + const token = await service.enforceCredentialSave( + { + credential: { id: 'cred-1', type: 'slackApi' }, + storedCredential: { id: 'cred-1', type: 'slackApi' }, + projectId: null, + }, + { kind: 'system', reason: 'execution' }, + ); expect(token.subject).toEqual({ type: 'credential', id: 'cred-1' }); }); it('binds a credential decrypt to the credential', async () => { - const token = await service.enforceCredentialDecrypt({ - credentialType: 'slackApi', - credentialId: 'cred-1', - consumer: { nodeType: 'n8n-nodes-base.slack' }, - projectId: null, - }); + const token = await service.enforceCredentialDecrypt( + { + credentialType: 'slackApi', + credentialId: 'cred-1', + consumer: { nodeType: 'n8n-nodes-base.slack' }, + projectId: null, + }, + { kind: 'system', reason: 'execution' }, + ); expect(token.subject).toEqual({ type: 'credential', id: 'cred-1' }); }); diff --git a/packages/cli/src/policy/enforce-workflow-publish.ts b/packages/cli/src/policy/enforce-workflow-publish.ts index db0c25c2372..a513a0785a3 100644 --- a/packages/cli/src/policy/enforce-workflow-publish.ts +++ b/packages/cli/src/policy/enforce-workflow-publish.ts @@ -2,6 +2,7 @@ import type { PolicedWorkflow } from '@n8n/decorators'; import type { OwnershipService } from '@/services/ownership.service'; +import type { PolicyActor } from './policy-enforcement-backend'; import type { PolicyEnforcementService } from './policy-enforcement.service'; /** @@ -20,10 +21,11 @@ export async function enforceWorkflowPublishPolicy( policyEnforcementService: PolicyEnforcementService, ownershipService: OwnershipService, workflow: PolicedWorkflow & { id: string }, + actor: PolicyActor, ): Promise { if (!policyEnforcementService.hasChecksFor('workflowPublish')) return; const project = await ownershipService.getWorkflowProjectCached(workflow.id); - await policyEnforcementService.enforceWorkflowPublish({ workflow, projectId: project.id }); + await policyEnforcementService.enforceWorkflowPublish({ workflow, projectId: project.id }, actor); } diff --git a/packages/cli/src/policy/policy-enforcement-backend.ts b/packages/cli/src/policy/policy-enforcement-backend.ts index a8b2d0d89e9..46b2401eb28 100644 --- a/packages/cli/src/policy/policy-enforcement-backend.ts +++ b/packages/cli/src/policy/policy-enforcement-backend.ts @@ -10,6 +10,8 @@ import type { WorkflowTransferContext, } from '@n8n/decorators'; +import type { UserLike } from '@/types/user-like.types'; + /** Which context each point is called with, mirroring `RegisteredPolicyCheck`. */ type PolicyContexts = { workflowSave: WorkflowSaveContext; @@ -23,6 +25,29 @@ type PolicyContexts = { export type PolicyContext = PolicyContexts[Point]; +/** Why the actor is not a user. */ +export type PolicySystemReason = + /** Inside a run. Runs are not attributed to the user who started them. */ + | 'execution' + | 'cli-import' + /** Trigger registration that no request asked for directly, such as startup or a retry. */ + | 'activation' + /** The publication outbox. The publish request itself was checked with its user. */ + | 'publication' + /** An integration that refreshes its own credential. */ + | 'integration' + /** A log streaming destination that reads its own credential. */ + | 'log-streaming'; + +/** + * Who asked for the policed action, named on the block audit event and never shown to checks. + * An agent lands later as an additive `{ kind: 'agent'; agentId: string; onBehalfOf: UserLike | + * null }`, with `onBehalfOf` as the audited user, so `userId` keeps meaning the accountable human. + */ +export type PolicyActor = + | { kind: 'user'; user: UserLike } + | { kind: 'system'; reason: PolicySystemReason; executionId?: string }; + /** * What the policy infrastructure module registers into the proxy. * @@ -34,6 +59,7 @@ export interface PolicyEnforcementBackend { enforce( point: Point, context: PolicyContext, + actor: PolicyActor, ): Promise; evaluate( diff --git a/packages/cli/src/policy/policy-enforcement.service.ts b/packages/cli/src/policy/policy-enforcement.service.ts index 2c7fec44589..89b94500121 100644 --- a/packages/cli/src/policy/policy-enforcement.service.ts +++ b/packages/cli/src/policy/policy-enforcement.service.ts @@ -21,7 +21,11 @@ import { mintPolicyCleared } from '@n8n/decorators/policy-internal'; import { Service } from '@n8n/di'; import { UnexpectedError } from 'n8n-workflow'; -import type { PolicyContext, PolicyEnforcementBackend } from './policy-enforcement-backend'; +import type { + PolicyActor, + PolicyContext, + PolicyEnforcementBackend, +} from './policy-enforcement-backend'; import { hasViolations, PolicyViolationError } from './policy-violation.error'; /** Fresh each time — `violations` is mutable. */ @@ -57,14 +61,17 @@ export class PolicyEnforcementService { return this.implementation?.hasChecksFor(point) ?? false; } - async enforceWorkflowSave(context: WorkflowSaveContext): Promise> { + async enforceWorkflowSave( + context: WorkflowSaveContext, + actor: PolicyActor, + ): Promise> { // A create has no committed id to bind to, so it binds to its content — even when a client // supplied an id, which is no proof of what was checked. An update binds to the row id. const subject = context.storedWorkflow === null ? workflowContentSubject(context.workflow) : workflowSubject(context.workflow); - return await this.enforce('workflowSave', context, subject); + return await this.enforce('workflowSave', context, actor, subject); } async evaluateWorkflowSave(context: WorkflowSaveContext): Promise { @@ -73,8 +80,9 @@ export class PolicyEnforcementService { async enforceWorkflowPublish( context: WorkflowPublishContext, + actor: PolicyActor, ): Promise> { - return await this.enforce('workflowPublish', context, workflowSubject(context.workflow)); + return await this.enforce('workflowPublish', context, actor, workflowSubject(context.workflow)); } async evaluateWorkflowPublish(context: WorkflowPublishContext): Promise { @@ -83,8 +91,9 @@ export class PolicyEnforcementService { async enforceWorkflowStart( context: WorkflowStartContext, + actor: PolicyActor, ): Promise> { - return await this.enforce('workflowStart', context, workflowSubject(context.workflow)); + return await this.enforce('workflowStart', context, actor, workflowSubject(context.workflow)); } async evaluateWorkflowStart(context: WorkflowStartContext): Promise { @@ -93,8 +102,14 @@ export class PolicyEnforcementService { async enforceWorkflowTransfer( context: WorkflowTransferContext, + actor: PolicyActor, ): Promise> { - return await this.enforce('workflowTransfer', context, workflowSubject(context.workflow)); + return await this.enforce( + 'workflowTransfer', + context, + actor, + workflowSubject(context.workflow), + ); } async evaluateWorkflowTransfer(context: WorkflowTransferContext): Promise { @@ -103,13 +118,14 @@ export class PolicyEnforcementService { async enforceCredentialSave( context: CredentialSaveContext, + actor: PolicyActor, ): Promise> { // Same rule as a workflow save: a create binds to its content, an update to the row id. const subject = context.storedCredential === null ? credentialContentSubject(context.credential) : credentialSubject(context.credential); - return await this.enforce('credentialSave', context, subject); + return await this.enforce('credentialSave', context, actor, subject); } async evaluateCredentialSave(context: CredentialSaveContext): Promise { @@ -118,8 +134,9 @@ export class PolicyEnforcementService { async enforceCredentialDecrypt( context: CredentialDecryptContext, + actor: PolicyActor, ): Promise> { - return await this.enforce('credentialDecrypt', context, { + return await this.enforce('credentialDecrypt', context, actor, { type: 'credential', id: context.credentialId, }); @@ -131,12 +148,13 @@ export class PolicyEnforcementService { async enforceContentImport( context: ContentImportContext, + actor: PolicyActor, ): Promise> { const subject = 'workflow' in context ? workflowSubject(context.workflow) : credentialSubject(context.credential); - return await this.enforce('contentImport', context, subject); + return await this.enforce('contentImport', context, actor, subject); } async evaluateContentImport(context: ContentImportContext): Promise { @@ -146,10 +164,11 @@ export class PolicyEnforcementService { private async enforce( point: Point, context: PolicyContext, + actor: PolicyActor, subject: PolicySubject, ): Promise> { const decision = this.implementation - ? await this.implementation.enforce(point, context) + ? await this.implementation.enforce(point, context, actor) : emptyDecision(); if (hasViolations(decision.violations)) { diff --git a/packages/cli/src/policy/policy-modules.ts b/packages/cli/src/policy/policy-modules.ts new file mode 100644 index 00000000000..9789d1aed2f --- /dev/null +++ b/packages/cli/src/policy/policy-modules.ts @@ -0,0 +1,4 @@ +import type { ModuleName } from '@n8n/backend-common'; + +/** The modules that register the policy implementation and its checks. */ +export const POLICY_MODULES: ModuleName[] = ['policy-infrastructure', 'type-availability-policies']; diff --git a/packages/cli/src/public-api/__tests__/public-api-controller.registry.test.ts b/packages/cli/src/public-api/__tests__/public-api-controller.registry.test.ts index 0fd8ddede75..d9abf174757 100644 --- a/packages/cli/src/public-api/__tests__/public-api-controller.registry.test.ts +++ b/packages/cli/src/public-api/__tests__/public-api-controller.registry.test.ts @@ -1,5 +1,6 @@ import { Z } from '@n8n/api-types'; import { LicenseState } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants'; import type { AuthenticatedRequest, User } from '@n8n/db'; import { @@ -21,8 +22,7 @@ import request from 'supertest'; import { mock } from 'vitest-mock-extended'; import { z } from 'zod'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { EventService } from '@/events/event.service'; +import { NotFoundError } from '@n8n/errors'; import { userHasScopes } from '@/permissions.ee/check-access'; import { markPublicApiController, diff --git a/packages/cli/src/public-api/index.ts b/packages/cli/src/public-api/index.ts index ebdfbc565af..5a4c3c48bac 100644 --- a/packages/cli/src/public-api/index.ts +++ b/packages/cli/src/public-api/index.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService, UrlService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import type { AuthenticatedRequest } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -14,12 +15,10 @@ import { PublicApiControllerRegistry } from './public-api-controller.registry'; import { sendPublicApiErrorResponse } from './v1/public-api-error-response'; import { AUTH_COOKIE_NAME } from '@/constants'; -import { EventService } from '@/events/event.service'; import { License } from '@/license'; import { createN8nPackageMulterOptions } from '@/modules/n8n-packages/utils/import-package-upload'; import { AuthStrategyRegistry } from '@/services/auth-strategy.registry'; import { LastActiveAtService } from '@/services/last-active-at.service'; -import { UrlService } from '@n8n/backend-services'; import './v1/controllers'; diff --git a/packages/cli/src/public-api/public-api-controller.registry.ts b/packages/cli/src/public-api/public-api-controller.registry.ts index b61727d05be..b341d81b203 100644 --- a/packages/cli/src/public-api/public-api-controller.registry.ts +++ b/packages/cli/src/public-api/public-api-controller.registry.ts @@ -1,4 +1,5 @@ import { LicenseState } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import type { BooleanLicenseFeature } from '@n8n/constants'; import { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants'; import type { AuthenticatedRequest } from '@n8n/db'; @@ -11,8 +12,7 @@ import { z } from 'zod'; import type { ZodTypeAny } from 'zod'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError } from '@n8n/errors'; import { License } from '@/license'; import { userHasScopes } from '@/permissions.ee/check-access'; import { USER_QUOTA_FORBIDDEN_MESSAGE } from '@/public-api/constants'; diff --git a/packages/cli/src/public-api/public-api-media-type.ts b/packages/cli/src/public-api/public-api-media-type.ts index d64c737b15b..4ed5fa5f4dd 100644 --- a/packages/cli/src/public-api/public-api-media-type.ts +++ b/packages/cli/src/public-api/public-api-media-type.ts @@ -1,4 +1,4 @@ -import { UnsupportedMediaTypeError } from '@/errors/response-errors/unsupported-media-type.error'; +import { UnsupportedMediaTypeError } from '@n8n/errors'; const JSON_MEDIA_TYPE = 'application/json'; diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index f1f85200166..b640e5785da 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -205,7 +205,6 @@ export declare namespace SsoSamlRequest { // ---------------------------------- export declare namespace OtelSettingsRequest { - type Get = AuthenticatedRequest; type Update = AuthenticatedRequest<{}, {}, UpdateOtelSettingsDto>; type Test = AuthenticatedRequest<{}, {}, TestOtelTraceDto>; } diff --git a/packages/cli/src/public-api/v1/__tests__/public-api-error-response.test.ts b/packages/cli/src/public-api/v1/__tests__/public-api-error-response.test.ts index 5bd30ffb371..48f7fbe9591 100644 --- a/packages/cli/src/public-api/v1/__tests__/public-api-error-response.test.ts +++ b/packages/cli/src/public-api/v1/__tests__/public-api-error-response.test.ts @@ -2,9 +2,7 @@ import type { Response } from 'express'; import { BadRequest, Unauthorized } from 'express-openapi-validator/dist/framework/types'; import { UnexpectedError, UserError, OperationalError } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ConflictError, NotFoundError } from '@n8n/errors'; import { WorkflowPublishBlockedError } from '@/errors/response-errors/workflow-publish-blocked.error'; import { sendPublicApiErrorResponse } from '../public-api-error-response'; diff --git a/packages/cli/src/public-api/v1/controllers/__tests__/credential-type-policies.public.controller.test.ts b/packages/cli/src/public-api/v1/controllers/__tests__/credential-type-policies.public.controller.test.ts index fb66c73b98a..7a921657b03 100644 --- a/packages/cli/src/public-api/v1/controllers/__tests__/credential-type-policies.public.controller.test.ts +++ b/packages/cli/src/public-api/v1/controllers/__tests__/credential-type-policies.public.controller.test.ts @@ -11,7 +11,7 @@ import { Container } from '@n8n/di'; import type { Response } from 'express'; import { mock } from 'vitest-mock-extended'; -import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; +import { ServiceUnavailableError } from '@n8n/errors'; import { CREDENTIAL_TYPES_KIND } from '@/modules/type-availability-policies/constants'; import type { EffectivePolicy } from '@/modules/type-availability-policies/type-availability-policy.service'; import { TypeAvailabilityPolicyService } from '@/modules/type-availability-policies/type-availability-policy.service'; diff --git a/packages/cli/src/public-api/v1/controllers/__tests__/node-type-policies.public.controller.test.ts b/packages/cli/src/public-api/v1/controllers/__tests__/node-type-policies.public.controller.test.ts index 04571cd5cb5..22db9ebc194 100644 --- a/packages/cli/src/public-api/v1/controllers/__tests__/node-type-policies.public.controller.test.ts +++ b/packages/cli/src/public-api/v1/controllers/__tests__/node-type-policies.public.controller.test.ts @@ -11,7 +11,7 @@ import { Container } from '@n8n/di'; import type { Response } from 'express'; import { mock } from 'vitest-mock-extended'; -import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; +import { ServiceUnavailableError } from '@n8n/errors'; import { NODE_TYPES_KIND } from '@/modules/type-availability-policies/constants'; import type { EffectivePolicy } from '@/modules/type-availability-policies/type-availability-policy.service'; import { TypeAvailabilityPolicyService } from '@/modules/type-availability-policies/type-availability-policy.service'; diff --git a/packages/cli/src/public-api/v1/controllers/__tests__/roles.public.controller.test.ts b/packages/cli/src/public-api/v1/controllers/__tests__/roles.public.controller.test.ts index 049a74c9f85..d64b6232800 100644 --- a/packages/cli/src/public-api/v1/controllers/__tests__/roles.public.controller.test.ts +++ b/packages/cli/src/public-api/v1/controllers/__tests__/roles.public.controller.test.ts @@ -1,9 +1,9 @@ import type { CreateRoleDto } from '@n8n/api-types'; +import type { EventService } from '@n8n/backend-services'; import type { AuthenticatedRequest } from '@n8n/db'; import type { Role } from '@n8n/permissions'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; import type { RoleService } from '@/services/role.service'; import { RolesPublicController } from '../roles.public.controller'; diff --git a/packages/cli/src/public-api/v1/controllers/__tests__/users.public.controller.test.ts b/packages/cli/src/public-api/v1/controllers/__tests__/users.public.controller.test.ts index 1055103c237..775a01c670a 100644 --- a/packages/cli/src/public-api/v1/controllers/__tests__/users.public.controller.test.ts +++ b/packages/cli/src/public-api/v1/controllers/__tests__/users.public.controller.test.ts @@ -1,9 +1,9 @@ import type { GetUserQueryDto, ListUsersQueryDto } from '@n8n/api-types'; +import type { EventService } from '@n8n/backend-services'; import type { AuthenticatedRequest, User } from '@n8n/db'; import type { Response } from 'express'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; import type { ProjectService } from '@/services/project.service.ee'; import type { UserService } from '@/services/user.service'; diff --git a/packages/cli/src/public-api/v1/controllers/__tests__/workflows.public.controller.test.ts b/packages/cli/src/public-api/v1/controllers/__tests__/workflows.public.controller.test.ts index 2a6ef23104e..e01dec38d02 100644 --- a/packages/cli/src/public-api/v1/controllers/__tests__/workflows.public.controller.test.ts +++ b/packages/cli/src/public-api/v1/controllers/__tests__/workflows.public.controller.test.ts @@ -3,7 +3,7 @@ import type { AuthenticatedRequest } from '@n8n/db'; import type { Response } from 'express'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { PolicyViolationError } from '@/policy/policy-violation.error'; import type { WorkflowService } from '@/workflows/workflow.service'; diff --git a/packages/cli/src/public-api/v1/controllers/credential-type-policies.public.controller.ts b/packages/cli/src/public-api/v1/controllers/credential-type-policies.public.controller.ts index ba3b9a0e365..f0dbe2c3215 100644 --- a/packages/cli/src/public-api/v1/controllers/credential-type-policies.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/credential-type-policies.public.controller.ts @@ -40,8 +40,7 @@ import { import { Container } from '@n8n/di'; import type { Response } from 'express'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; +import { NotFoundError, ServiceUnavailableError } from '@n8n/errors'; import { CREDENTIAL_TYPES_KIND } from '@/modules/type-availability-policies/constants'; import type { TypeAvailabilityPolicy } from '@/modules/type-availability-policies/database/entities/type-availability-policy.entity'; import { diff --git a/packages/cli/src/public-api/v1/controllers/credentials.public.controller.ts b/packages/cli/src/public-api/v1/controllers/credentials.public.controller.ts index 5c4831f5b3d..27c9a25d786 100644 --- a/packages/cli/src/public-api/v1/controllers/credentials.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/credentials.public.controller.ts @@ -12,6 +12,7 @@ import { TransferCredentialPublicDto, } from '@n8n/api-types'; import { LicenseState } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import type { AuthenticatedRequest, CredentialsEntity, ICredentialsDb, User } from '@n8n/db'; import { ApiDescription, @@ -43,10 +44,7 @@ import { CredentialsService } from '@/credentials/credentials.service'; import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; import { CredentialsHelper } from '@/credentials-helper'; import { CredentialNotFoundError } from '@/errors/credential-not-found.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; import { assertValidUpdateProperties, buildSharedForCredential, @@ -327,6 +325,7 @@ export class CredentialsPublicController { const updatedCredential = await this.credentialsService.update( credentialId, updatePayload, + { kind: 'user', user: req.user }, decryptedDataForDeps, ); diff --git a/packages/cli/src/public-api/v1/controllers/data-tables.public.controller.ts b/packages/cli/src/public-api/v1/controllers/data-tables.public.controller.ts index c20ea36424a..ae8cb5e5a18 100644 --- a/packages/cli/src/public-api/v1/controllers/data-tables.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/data-tables.public.controller.ts @@ -27,10 +27,7 @@ import { } from '@n8n/decorators'; import type { Response } from 'express'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '@n8n/errors'; import { DataTableAggregateService } from '@/modules/data-table/data-table-aggregate.service'; import type { DataTableColumn } from '@/modules/data-table/data-table-column.entity'; import type { DataTable } from '@/modules/data-table/data-table.entity'; diff --git a/packages/cli/src/public-api/v1/controllers/discover.public.controller.ts b/packages/cli/src/public-api/v1/controllers/discover.public.controller.ts index fe2c9a02d85..ac438ffed66 100644 --- a/packages/cli/src/public-api/v1/controllers/discover.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/discover.public.controller.ts @@ -11,7 +11,7 @@ import { } from '@n8n/decorators'; import type { Response } from 'express'; -import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; +import { UnauthenticatedError } from '@n8n/errors'; import { buildDiscoverResponse } from '@/public-api/v1/handlers/discover/discover.service'; @PublicApiController('/discover') diff --git a/packages/cli/src/public-api/v1/controllers/evaluations.public.controller.ts b/packages/cli/src/public-api/v1/controllers/evaluations.public.controller.ts index 84fe5f8fa8e..a4fc4268fdf 100644 --- a/packages/cli/src/public-api/v1/controllers/evaluations.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/evaluations.public.controller.ts @@ -30,9 +30,7 @@ import type { Response } from 'express'; import { ErrorReporter } from 'n8n-core'; import { EVALUATION_TRIGGER_NODE_TYPE } from 'n8n-workflow'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ConflictError, ForbiddenError, NotFoundError } from '@n8n/errors'; import { EvaluationTestRunService } from '@/evaluation.ee/evaluation-test-run.service'; import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee'; import { diff --git a/packages/cli/src/public-api/v1/controllers/executions.public.controller.ts b/packages/cli/src/public-api/v1/controllers/executions.public.controller.ts index ff45fcbf85e..5fd427cf118 100644 --- a/packages/cli/src/public-api/v1/controllers/executions.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/executions.public.controller.ts @@ -15,6 +15,7 @@ import { StoppedExecutionsPublicDto, TagIdsPublicDto, } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import { ExecutionsConfig } from '@n8n/config'; import type { AuthenticatedRequest, IExecutionBase, IExecutionResponse } from '@n8n/db'; import { @@ -33,20 +34,19 @@ import { Put, Query, } from '@n8n/decorators'; +import { isRecord } from '@n8n/utils/is-record'; import type { Response } from 'express'; import { replaceCircularReferences, WorkflowOperationError } from 'n8n-workflow'; import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error'; import { MissingExecutionStopError } from '@/errors/missing-execution-stop.error'; import { QueuedExecutionRetryError } from '@/errors/queued-execution-retry.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ConflictError, NotFoundError } from '@n8n/errors'; import { isRedactableExecution } from '@/executions/execution-redaction'; import { ExecutionRedactionServiceProxy } from '@/executions/execution-redaction-proxy.service'; import { ExecutionService } from '@/executions/execution.service'; import type { StopResult } from '@/executions/execution.types'; +import type { TracingContext } from '@/modules/otel/tracing-context'; import { decodeCursor, encodeNextCursor } from '@/public-api/v1/shared/services/pagination.service'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; @@ -434,6 +434,15 @@ export class ExecutionsPublicController { } } +function toPublicTracingContext(tracingContext: unknown): TracingContext | null { + if (!isRecord(tracingContext)) return null; + + const { traceparent, tracestate } = tracingContext; + if (typeof traceparent !== 'string' || traceparent.length === 0) return null; + + return typeof tracestate === 'string' ? { traceparent, tracestate } : { traceparent }; +} + function toBaseFields(execution: PublicExecution) { return { finished: execution.finished, @@ -448,7 +457,7 @@ function toBaseFields(execution: PublicExecution) { workflowId: execution.workflowId, waitTill: execution.waitTill ? execution.waitTill.toISOString() : null, storedAt: execution.storedAt, - tracingContext: execution.tracingContext ?? null, + tracingContext: toPublicTracingContext(execution.tracingContext), deduplicationKey: execution.deduplicationKey ?? null, jsonSizeBytes: execution.jsonSizeBytes ?? 0, binaryDataSizeBytes: execution.binaryDataSizeBytes ?? 0, diff --git a/packages/cli/src/public-api/v1/controllers/folders.public.controller.ts b/packages/cli/src/public-api/v1/controllers/folders.public.controller.ts index ad9812c4272..365b057c36b 100644 --- a/packages/cli/src/public-api/v1/controllers/folders.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/folders.public.controller.ts @@ -39,8 +39,7 @@ import type { Response } from 'express'; import { UserError } from 'n8n-workflow'; import { FolderNotFoundError } from '@/errors/folder-not-found.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { assertProjectScope } from '@/public-api/v1/shared/services/utils.service'; import { FolderService } from '@/services/folder.service'; import { ProjectService } from '@/services/project.service.ee'; diff --git a/packages/cli/src/public-api/v1/controllers/index.ts b/packages/cli/src/public-api/v1/controllers/index.ts index e2b01b4948a..090513b6a4d 100644 --- a/packages/cli/src/public-api/v1/controllers/index.ts +++ b/packages/cli/src/public-api/v1/controllers/index.ts @@ -14,6 +14,7 @@ import './executions.public.controller'; import './folders.public.controller'; import './insights.public.controller'; import './node-type-policies.public.controller'; +import './otel.public.controller'; import './projects.public.controller'; import './promotions.public.controller'; import './role-mapping-rules.public.controller'; diff --git a/packages/cli/src/public-api/v1/controllers/insights.public.controller.ts b/packages/cli/src/public-api/v1/controllers/insights.public.controller.ts index 55909fbca13..d0122915d4c 100644 --- a/packages/cli/src/public-api/v1/controllers/insights.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/insights.public.controller.ts @@ -14,8 +14,7 @@ import type { Response } from 'express'; import { DateTime } from 'luxon'; import { UserError } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { InsightsService } from '@/modules/insights/insights.service'; @PublicApiController('/insights') diff --git a/packages/cli/src/public-api/v1/controllers/node-type-policies.public.controller.ts b/packages/cli/src/public-api/v1/controllers/node-type-policies.public.controller.ts index f979128e6f3..ae07a0d2194 100644 --- a/packages/cli/src/public-api/v1/controllers/node-type-policies.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/node-type-policies.public.controller.ts @@ -40,8 +40,7 @@ import { import { Container } from '@n8n/di'; import type { Response } from 'express'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; +import { NotFoundError, ServiceUnavailableError } from '@n8n/errors'; import { NODE_TYPES_KIND } from '@/modules/type-availability-policies/constants'; import type { TypeAvailabilityPolicy } from '@/modules/type-availability-policies/database/entities/type-availability-policy.entity'; import { diff --git a/packages/cli/src/public-api/v1/controllers/otel.public.controller.ts b/packages/cli/src/public-api/v1/controllers/otel.public.controller.ts new file mode 100644 index 00000000000..8606477ecb5 --- /dev/null +++ b/packages/cli/src/public-api/v1/controllers/otel.public.controller.ts @@ -0,0 +1,40 @@ +import { GetOtelSettingsQueryPublicDto, OtelSettingsPublicDto } from '@n8n/api-types'; +import type { AuthenticatedRequest } from '@n8n/db'; +import { + ApiDescription, + ApiKeyScope, + ApiResponse, + ApiSummary, + ApiTags, + Get, + PublicApiController, + Query, +} from '@n8n/decorators'; +import type { Response } from 'express'; + +import { OtelSettingsService } from '@/modules/otel/otel-settings.service'; +import { toOtelSettingsResponse } from '@/public-api/v1/shared/otel.mapper'; + +@PublicApiController('/settings/otel') +export class OtelPublicController { + constructor(private readonly settingsService: OtelSettingsService) {} + + @Get('/') + @ApiKeyScope('otel:manage') + @ApiSummary('Retrieve the OpenTelemetry configuration') + @ApiDescription( + 'Retrieve the current OpenTelemetry configuration, including every field exposed in the UI. Requires the `otel:manage` scope.', + ) + @ApiTags(['SettingsOtel']) + @ApiResponse(200, OtelSettingsPublicDto) + async getOtelSettings( + _req: AuthenticatedRequest, + _res: Response, + @Query _query: GetOtelSettingsQueryPublicDto, + ): Promise { + await this.settingsService.loadSettings(); + const settings = this.settingsService.getSettings(); + + return toOtelSettingsResponse(settings); + } +} diff --git a/packages/cli/src/public-api/v1/controllers/projects.public.controller.ts b/packages/cli/src/public-api/v1/controllers/projects.public.controller.ts index f33fb0a736c..5247deec3ea 100644 --- a/packages/cli/src/public-api/v1/controllers/projects.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/projects.public.controller.ts @@ -37,8 +37,7 @@ import { } from '@n8n/decorators'; import type { Response } from 'express'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; import { encodeNextCursor, diff --git a/packages/cli/src/public-api/v1/controllers/promotions.public.controller.ts b/packages/cli/src/public-api/v1/controllers/promotions.public.controller.ts index 2ead342bac5..e3aa9fc5e16 100644 --- a/packages/cli/src/public-api/v1/controllers/promotions.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/promotions.public.controller.ts @@ -57,10 +57,12 @@ import { import { Container } from '@n8n/di'; import type { Response } from 'express'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; +import { + BadRequestError, + ForbiddenError, + NotFoundError, + ServiceUnavailableError, +} from '@n8n/errors'; import { encodeNextCursor, resolveOffsetPagination, diff --git a/packages/cli/src/public-api/v1/controllers/role-mapping-rules.public.controller.ts b/packages/cli/src/public-api/v1/controllers/role-mapping-rules.public.controller.ts index b233acf4e62..2f61a611143 100644 --- a/packages/cli/src/public-api/v1/controllers/role-mapping-rules.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/role-mapping-rules.public.controller.ts @@ -27,7 +27,7 @@ import { } from '@n8n/decorators'; import type { Response } from 'express'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import type { RoleMappingRuleResponse } from '@/modules/provisioning.ee/role-mapping-rule.service.ee'; import { RoleMappingRuleService } from '@/modules/provisioning.ee/role-mapping-rule.service.ee'; import { diff --git a/packages/cli/src/public-api/v1/controllers/roles.public.controller.ts b/packages/cli/src/public-api/v1/controllers/roles.public.controller.ts index 3963b98c727..d02a08d399b 100644 --- a/packages/cli/src/public-api/v1/controllers/roles.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/roles.public.controller.ts @@ -8,6 +8,7 @@ import { UpdateRolePublicDto, roleSlugParamSchema, } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import { LICENSE_FEATURES } from '@n8n/constants'; import { AuthenticatedRequest } from '@n8n/db'; import { @@ -30,8 +31,7 @@ import { import { RoleNamespace, type Role as RoleDTO } from '@n8n/permissions'; import type { Response } from 'express'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { NotFoundError } from '@n8n/errors'; import { assertCanManageRoleType, canReassignUsers } from '@/services/role-authorization'; import { RoleService } from '@/services/role.service'; diff --git a/packages/cli/src/public-api/v1/controllers/security-policy.public.controller.ts b/packages/cli/src/public-api/v1/controllers/security-policy.public.controller.ts index 6fe3c65f221..2b7b71c4558 100644 --- a/packages/cli/src/public-api/v1/controllers/security-policy.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/security-policy.public.controller.ts @@ -17,7 +17,7 @@ import { } from '@n8n/decorators'; import type { Response } from 'express'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ConflictError } from '@n8n/errors'; import type { SecurityPolicyReadResult } from '@/services/security-settings.service'; import { SecuritySettingsService } from '@/services/security-settings.service'; diff --git a/packages/cli/src/public-api/v1/controllers/source-control.public.controller.ts b/packages/cli/src/public-api/v1/controllers/source-control.public.controller.ts index e3fd1fe3e57..73515c5a4e8 100644 --- a/packages/cli/src/public-api/v1/controllers/source-control.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/source-control.public.controller.ts @@ -7,6 +7,7 @@ import { SourceControlStatusPublicDto, SourceControlStatusQueryPublicDto, } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import { LICENSE_FEATURES } from '@n8n/constants'; import type { AuthenticatedRequest } from '@n8n/db'; import { @@ -25,9 +26,7 @@ import { } from '@n8n/decorators'; import type { Response } from 'express'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ConflictError } from '@n8n/errors'; import { getTrackingInformationFromPullResult } from '@/modules/source-control.ee/source-control-helper.ee'; import { SourceControlPreferencesService } from '@/modules/source-control.ee/source-control-preferences.service.ee'; import { SourceControlScopedService } from '@/modules/source-control.ee/source-control-scoped.service'; diff --git a/packages/cli/src/public-api/v1/controllers/sso-oidc.public.controller.ts b/packages/cli/src/public-api/v1/controllers/sso-oidc.public.controller.ts index f1ee163ecca..79f933aea6b 100644 --- a/packages/cli/src/public-api/v1/controllers/sso-oidc.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/sso-oidc.public.controller.ts @@ -17,7 +17,7 @@ import { } from '@n8n/decorators'; import type { Response } from 'express'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ConflictError } from '@n8n/errors'; import { OIDC_CLIENT_SECRET_REDACTED_VALUE } from '@/modules/sso-oidc/constants'; import { OidcService } from '@/modules/sso-oidc/oidc.service.ee'; diff --git a/packages/cli/src/public-api/v1/controllers/tags.public.controller.ts b/packages/cli/src/public-api/v1/controllers/tags.public.controller.ts index 6d39c47e311..3627a5c1ee1 100644 --- a/packages/cli/src/public-api/v1/controllers/tags.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/tags.public.controller.ts @@ -26,8 +26,7 @@ import { } from '@n8n/decorators'; import type { Response } from 'express'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ConflictError, NotFoundError } from '@n8n/errors'; import { encodeNextCursor, resolveOffsetPagination, diff --git a/packages/cli/src/public-api/v1/controllers/users.public.controller.ts b/packages/cli/src/public-api/v1/controllers/users.public.controller.ts index 3989dc8e929..48e43f89892 100644 --- a/packages/cli/src/public-api/v1/controllers/users.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/users.public.controller.ts @@ -10,6 +10,7 @@ import { userIdentifierParamSchema, userUuidParamSchema, } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import type { AuthenticatedRequest, User } from '@n8n/db'; import { ApiDescription, @@ -32,8 +33,7 @@ import { import type { Response } from 'express'; import pick from 'lodash/pick'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { NotFoundError } from '@n8n/errors'; import { encodeNextCursor, resolveOffsetPagination, diff --git a/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts b/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts index 9befb70a7ac..66f3ecacfd1 100644 --- a/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts @@ -23,6 +23,7 @@ import { workflowIdParamSchema, workflowVersionIdParamSchema, } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import type { AuthenticatedRequest, @@ -55,12 +56,9 @@ import type { Response } from 'express'; import { PROJECT_ROOT } from 'n8n-workflow'; import { FolderNotFoundError } from '@/errors/folder-not-found.error'; -import { ResponseError } from '@/errors/response-errors/abstract/response.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ResponseError, BadRequestError, NotFoundError } from '@n8n/errors'; import { SharedWorkflowNotFoundError } from '@/errors/shared-workflow-not-found.error'; import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error'; -import { EventService } from '@/events/event.service'; import { RedactionEnforcementService } from '@/modules/redaction/redaction-enforcement.service'; import { PolicyViolationError } from '@/policy/policy-violation.error'; import { toPublicProject } from '@/public-api/v1/shared/project.mapper'; diff --git a/packages/cli/src/public-api/v1/handlers/credentials/credentials.utils.ts b/packages/cli/src/public-api/v1/handlers/credentials/credentials.utils.ts index bbdf26087ce..ef24cad284c 100644 --- a/packages/cli/src/public-api/v1/handlers/credentials/credentials.utils.ts +++ b/packages/cli/src/public-api/v1/handlers/credentials/credentials.utils.ts @@ -8,7 +8,7 @@ import { } from 'n8n-workflow'; import type { CredentialsHelper } from '@/credentials-helper'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { IDependency, IJsonSchema } from '../../../types'; diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.handler.test.ts b/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.handler.test.ts index ea84fbeae7f..3294c8b408c 100644 --- a/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.handler.test.ts +++ b/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.handler.test.ts @@ -4,8 +4,7 @@ import { GLOBAL_MEMBER_SCOPES, type Scope } from '@n8n/permissions'; import type { Response } from 'express'; import type { Mock, Mocked } from 'vitest'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { DataTableAggregateService } from '@/modules/data-table/data-table-aggregate.service'; import { DataTableService } from '@/modules/data-table/data-table.service'; import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts index 20122d4b9ce..99521057b59 100644 --- a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts @@ -1,8 +1,7 @@ import { AddDataTableColumnDto, updateDataTableColumnSchema } from '@n8n/api-types'; import { Container } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { BadRequestError, ConflictError } from '@n8n/errors'; import { DataTableService } from '@/modules/data-table/data-table.service'; import { DataTableColumnNameConflictError } from '@/modules/data-table/errors/data-table-column-name-conflict.error'; import { DataTableSystemColumnNameConflictError } from '@/modules/data-table/errors/data-table-system-column-name-conflict.error'; diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts index 43b9a883a1a..a6144d13bcf 100644 --- a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts @@ -7,8 +7,7 @@ import { } from '@n8n/api-types'; import { Container } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { assertRowReadAccessIfReturningRows } from '@/modules/data-table/data-table-permissions'; import { DataTableService } from '@/modules/data-table/data-table.service'; import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; diff --git a/packages/cli/src/public-api/v1/handlers/ldap/ldap.handler.ts b/packages/cli/src/public-api/v1/handlers/ldap/ldap.handler.ts index 2312f8e4ef4..71e598bd62a 100644 --- a/packages/cli/src/public-api/v1/handlers/ldap/ldap.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/ldap/ldap.handler.ts @@ -1,8 +1,7 @@ import { LdapSyncDto, UpdateLdapConfigurationDto } from '@n8n/api-types'; import { Container } from '@n8n/di'; -import { ResponseError } from '@/errors/response-errors/abstract/response.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ResponseError, BadRequestError } from '@n8n/errors'; import { getLdapSynchronizationsWithCount } from '@/modules/ldap.ee/helpers.ee'; import { LdapConnectionError, LdapRejectionError } from '@/modules/ldap.ee/ldap.errors'; import { LdapService } from '@/modules/ldap.ee/ldap.service.ee'; diff --git a/packages/cli/src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts b/packages/cli/src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts index 39d1a84f30d..54e42e16d7f 100644 --- a/packages/cli/src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts @@ -5,9 +5,7 @@ import { Container } from '@n8n/di'; import type { MessageEventBusDestinationOptions } from 'n8n-workflow'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ConflictError, NotFoundError } from '@n8n/errors'; import { eventNamesAll } from '@/eventbus/event-message-classes'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { createMessageEventBusDestination } from '@/modules/log-streaming.ee/create-message-event-bus-destination'; diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts b/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts index 0a3cd09675b..0eaf5117529 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import type { AuthenticatedRequest } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -6,11 +7,7 @@ import { UserError } from 'n8n-workflow'; import { PassThrough } from 'node:stream'; import type { Mocked } from 'vitest'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '@n8n/errors'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { PackageEntityAccessDeniedError, diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts b/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts index 08112ce007c..a9f5000c380 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts @@ -3,15 +3,14 @@ import { ImportPackageRequestDto, ImportPackageSelectionRequestDto, } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import type { AuthenticatedRequest } from '@n8n/db'; import { Container } from '@n8n/di'; import type { ApiKeyScope } from '@n8n/permissions'; import type { Response } from 'express'; import { UserError } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { PackageEntityAccessDeniedError, PackageEntityNotFoundError, diff --git a/packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts b/packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts index 8a31f2d4c2d..3b8ff9e3fc9 100644 --- a/packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts @@ -2,35 +2,23 @@ import { TestOtelTraceDto, UpdateOtelSettingsDto } from '@n8n/api-types'; import { ModuleRegistry } from '@n8n/backend-common'; import { Container } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { BadRequestError, ConflictError } from '@n8n/errors'; import { OtelLifecycleHandler } from '@/modules/otel/otel-lifecycle-handler'; import { OtelSettingsService } from '@/modules/otel/otel-settings.service'; import { OtelService } from '@/modules/otel/otel.service'; import { Publisher } from '@/scaling/pubsub/publisher.service'; -import { toOtelSettingsResponse } from './otel.mapper'; +import { toOtelSettingsResponse } from '../../shared/otel.mapper'; import type { OtelSettingsRequest } from '../../../types'; import type { PublicAPIEndpoint } from '../../shared/handler.types'; import { apiKeyHasScopeWithGlobalScopeFallback } from '../../shared/middlewares/global.middleware'; type OtelHandlers = { - getOtelSettings: PublicAPIEndpoint; updateOtelSettings: PublicAPIEndpoint; testOtelTrace: PublicAPIEndpoint; }; const otelHandlers: OtelHandlers = { - getOtelSettings: [ - apiKeyHasScopeWithGlobalScopeFallback({ scope: 'otel:manage' }), - async (_req, res) => { - const settingsService = Container.get(OtelSettingsService); - await settingsService.loadSettings(); - - return res.json(toOtelSettingsResponse(settingsService.getSettings())); - }, - ], - updateOtelSettings: [ apiKeyHasScopeWithGlobalScopeFallback({ scope: 'otel:manage' }), async (req, res) => { diff --git a/packages/cli/src/public-api/v1/handlers/otel/spec/paths/settings.otel.yml b/packages/cli/src/public-api/v1/handlers/otel/spec/paths/settings.otel.yml index 87ae25b7f8b..183fc88f0f5 100644 --- a/packages/cli/src/public-api/v1/handlers/otel/spec/paths/settings.otel.yml +++ b/packages/cli/src/public-api/v1/handlers/otel/spec/paths/settings.otel.yml @@ -1,24 +1,3 @@ -get: - x-eov-operation-id: getOtelSettings - x-required-scope: otel:manage - x-eov-operation-handler: v1/handlers/otel/otel.handler - tags: - - SettingsOtel - summary: Retrieve the OpenTelemetry configuration - description: > - Retrieve the current OpenTelemetry configuration, including every field exposed in the UI. - Requires the `otel:manage` scope. - responses: - '200': - description: Operation successful. - content: - application/json: - schema: - $ref: '../schemas/otel-settings.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '403': - $ref: '../../../../shared/spec/responses/forbidden.yml' put: x-eov-operation-id: updateOtelSettings x-required-scope: otel:manage diff --git a/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-settings.yml b/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-settings.yml index d48ce74cf2f..05b951cf09d 100644 --- a/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-settings.yml +++ b/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-settings.yml @@ -4,7 +4,7 @@ description: > The OpenTelemetry configuration, matching the fields exposed in the UI. On a write this is a full replacement: every field must be provided, except `exporterProtocol`, which defaults to `http/protobuf` when omitted. Fields managed declaratively via environment variables are - returned with their effective value and ignored on write. + returned with their effective value. A write can repeat these values, but changing them returns 409. required: - enabled - exporterEndpoint diff --git a/packages/cli/src/public-api/v1/handlers/settings/spec/paths/getOtelSettings.generated.yml b/packages/cli/src/public-api/v1/handlers/settings/spec/paths/getOtelSettings.generated.yml new file mode 100644 index 00000000000..fdcaa6f3f44 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/settings/spec/paths/getOtelSettings.generated.yml @@ -0,0 +1,90 @@ +operationId: getOtelSettings +tags: + - SettingsOtel +summary: Retrieve the OpenTelemetry configuration +description: Retrieve the current OpenTelemetry configuration, including every field exposed in the UI. Requires the `otel:manage` scope. +x-required-scope: otel:manage +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + description: Whether OpenTelemetry tracing is enabled. + example: true + exporterProtocol: + type: string + default: http/protobuf + description: 'The wire protocol used to export spans. Collectors conventionally serve OTLP/HTTP on port 4318 and OTLP/gRPC on port 4317. The endpoint scheme (`http://` or `https://`) controls TLS for both protocols. Optional: a write that omits it selects `http/protobuf`, but an instance that sets this field to another value with an environment variable rejects that write with 409.' + example: http/protobuf + enum: + - http/protobuf + - grpc + exporterEndpoint: + type: string + format: uri + description: The base URL of the OTLP collector to export traces to. The value must be an `http://` or `https://` URL, because the scheme selects TLS — for gRPC, `https://` uses SSL and `http://` is plaintext. + example: http://localhost:4318 + exporterTracingPath: + type: string + description: The path appended to the endpoint for the OTLP traces signal. Ignored when `exporterProtocol` is `grpc`, which takes no URL path. + example: /v1/traces + exporterServiceName: + type: string + minLength: 1 + description: The `service.name` resource attribute reported on every span. + example: n8n + exporterHeaders: + type: string + description: Additional headers sent to the OTLP collector, as a single string of comma-separated `key=value` pairs (e.g. `authorization=Bearer my-token,x-tenant-id=acme`). Whitespace around each key and value is trimmed; a value may contain spaces but not commas. Use an empty string when unused. + example: authorization=Bearer my-token,x-tenant-id=acme + tracesSampleRate: + type: number + description: The ratio of traces to sample, between 0 (none) and 1 (all). + minimum: 0 + maximum: 1 + example: 1 + startupConnectivityTimeoutMs: + type: integer + description: How long, in milliseconds, to wait when checking the collector is reachable. Also used as the timeout for the test-trace endpoint. + minimum: 0 + example: 2000 + includeNodeSpans: + type: boolean + description: Whether to emit a span for each node execution in addition to the workflow span. + example: true + injectOutbound: + type: boolean + description: Whether to inject trace context headers into outbound HTTP requests made by nodes. + example: true + productionExecutionsOnly: + type: boolean + description: When true, only production executions of published (active) workflows are traced, not manual/test runs. + example: true + required: + - enabled + - exporterProtocol + - exporterEndpoint + - exporterTracingPath + - exporterServiceName + - exporterHeaders + - tracesSampleRate + - startupConnectivityTimeoutMs + - includeNodeSpans + - injectOutbound + - productionExecutionsOnly + additionalProperties: false + description: 'The OpenTelemetry configuration, matching the fields exposed in the UI. On a write this is a full replacement: every field must be provided, except `exporterProtocol`, which defaults to `http/protobuf` when omitted. Fields managed declaratively via environment variables are returned with their effective value. A write can repeat these values, but changing them returns 409.' + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml diff --git a/packages/cli/src/public-api/v1/handlers/sso-saml/sso-saml.handler.ts b/packages/cli/src/public-api/v1/handlers/sso-saml/sso-saml.handler.ts index 1fbf4e575e7..db3e1525ce0 100644 --- a/packages/cli/src/public-api/v1/handlers/sso-saml/sso-saml.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/sso-saml/sso-saml.handler.ts @@ -2,8 +2,7 @@ import { UpdateSamlConfigurationDto } from '@n8n/api-types'; import { InstanceSettingsLoaderConfig } from '@n8n/config'; import { Container } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { BadRequestError, ConflictError } from '@n8n/errors'; import { SamlService } from '@/modules/sso-saml/saml.service.ee'; import { toSamlConfigurationResponse, toSamlPreferencesUpdate } from './sso-saml.mapper'; diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts index d80674dc815..3019c2e1cac 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts @@ -1,7 +1,7 @@ +import { EventService } from '@n8n/backend-services'; import { Container } from '@n8n/di'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { NotFoundError } from '@n8n/errors'; import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import type { WorkflowRequest } from '../../../types'; diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index 53b54a58533..8ee48200de8 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -156,6 +156,9 @@ paths: /node-type-policies/scopes/{scopeId}/attachments: put: $ref: ./handlers/node-type-policies/spec/paths/replaceNodeTypePolicyAttachments.generated.yml + /settings/otel: + get: + $ref: ./handlers/settings/spec/paths/getOtelSettings.generated.yml /projects: get: $ref: ./handlers/projects/spec/paths/getProjects.generated.yml diff --git a/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts b/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts index da9b01f1edc..be3f9d2db14 100644 --- a/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts +++ b/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts @@ -8,7 +8,7 @@ import type express from 'express'; import type { NextFunction, Request, Response } from 'express'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { License } from '@/license'; import { userHasScopes } from '@/permissions.ee/check-access'; import { USER_QUOTA_FORBIDDEN_MESSAGE } from '@/public-api/constants'; diff --git a/packages/cli/src/public-api/v1/handlers/otel/otel.mapper.ts b/packages/cli/src/public-api/v1/shared/otel.mapper.ts similarity index 100% rename from packages/cli/src/public-api/v1/handlers/otel/otel.mapper.ts rename to packages/cli/src/public-api/v1/shared/otel.mapper.ts diff --git a/packages/cli/src/public-api/v1/shared/services/__tests__/pagination.service.test.ts b/packages/cli/src/public-api/v1/shared/services/__tests__/pagination.service.test.ts index 209ce60e79d..f0ad41f71b1 100644 --- a/packages/cli/src/public-api/v1/shared/services/__tests__/pagination.service.test.ts +++ b/packages/cli/src/public-api/v1/shared/services/__tests__/pagination.service.test.ts @@ -1,4 +1,4 @@ -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { resolveOffsetPagination } from '../pagination.service'; diff --git a/packages/cli/src/public-api/v1/shared/services/pagination.service.ts b/packages/cli/src/public-api/v1/shared/services/pagination.service.ts index 80aefb4ebdc..2816a82b4d9 100644 --- a/packages/cli/src/public-api/v1/shared/services/pagination.service.ts +++ b/packages/cli/src/public-api/v1/shared/services/pagination.service.ts @@ -1,6 +1,6 @@ import { jsonParse } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { CursorPagination, diff --git a/packages/cli/src/public-api/v1/shared/services/utils.service.ts b/packages/cli/src/public-api/v1/shared/services/utils.service.ts index d6e91b2d58c..473c613fbdf 100644 --- a/packages/cli/src/public-api/v1/shared/services/utils.service.ts +++ b/packages/cli/src/public-api/v1/shared/services/utils.service.ts @@ -2,8 +2,7 @@ import { ProjectRepository, type User } from '@n8n/db'; import { Container } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import { ProjectService } from '@/services/project.service.ee'; export async function assertProjectScope( diff --git a/packages/cli/src/push/__tests__/index.test.ts b/packages/cli/src/push/__tests__/index.test.ts index bb0ae7155c3..155079551c2 100644 --- a/packages/cli/src/push/__tests__/index.test.ts +++ b/packages/cli/src/push/__tests__/index.test.ts @@ -8,7 +8,7 @@ import type { MockInstance } from 'vitest'; import { captor, mock } from 'vitest-mock-extended'; import { type WebSocket, Server as WSServer } from 'ws'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { Push } from '@/push'; import { SSEPush } from '@/push/sse.push'; import type { WebSocketPushRequest, SSEPushRequest, PushResponse } from '@/push/types'; diff --git a/packages/cli/src/push/index.ts b/packages/cli/src/push/index.ts index 29beab45980..21a11d97ff9 100644 --- a/packages/cli/src/push/index.ts +++ b/packages/cli/src/push/index.ts @@ -13,8 +13,7 @@ import { parse as parseUrl } from 'url'; import { Server as WSServer } from 'ws'; import { AuthService } from '@/auth/auth.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import { BadRequestError, InternalServerError } from '@n8n/errors'; import { MAX_PUBSUB_PAYLOAD_BYTES } from '@/scaling/constants'; import { Publisher } from '@/scaling/pubsub/publisher.service'; diff --git a/packages/cli/src/response-helper.ts b/packages/cli/src/response-helper.ts index 598c131719a..a8505f80684 100644 --- a/packages/cli/src/response-helper.ts +++ b/packages/cli/src/response-helper.ts @@ -1,7 +1,7 @@ import { inDevelopment, Logger } from '@n8n/backend-common'; import { isUniqueConstraintError, type User } from '@n8n/db'; import { Container } from '@n8n/di'; -import type { ReportingOptions } from '@n8n/errors'; +import { type ReportingOptions } from '@n8n/errors'; import type { Request, Response } from 'express'; import { ErrorReporter } from 'n8n-core'; import { ensureError } from '@n8n/utils/errors/ensure-error'; diff --git a/packages/cli/src/scaling/__tests__/job-processor.service.test.ts b/packages/cli/src/scaling/__tests__/job-processor.service.test.ts index a9bdbdcb8d3..b7f24aaf2e8 100644 --- a/packages/cli/src/scaling/__tests__/job-processor.service.test.ts +++ b/packages/cli/src/scaling/__tests__/job-processor.service.test.ts @@ -1,5 +1,6 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { ExecutionsConfig } from '@n8n/config'; import type { IExecutionResponse, ExecutionRepository, Project } from '@n8n/db'; import { WorkflowPublishHistoryRepository } from '@n8n/db'; @@ -38,7 +39,6 @@ import { z } from 'zod'; import { CredentialsHelper } from '@/credentials-helper'; import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; import { WebhookResponseTooLargeError } from '@/errors/webhook-response-too-large.error'; -import type { EventService } from '@/events/event.service'; import type { ExecutionPersistence } from '@/executions/execution-persistence'; import { ExternalHooks } from '@/external-hooks'; import type { ManualExecutionService } from '@/manual-execution.service'; diff --git a/packages/cli/src/scaling/__tests__/pool-config.service.ee.test.ts b/packages/cli/src/scaling/__tests__/pool-config.service.ee.test.ts index 4d8ee199a99..18481af9878 100644 --- a/packages/cli/src/scaling/__tests__/pool-config.service.ee.test.ts +++ b/packages/cli/src/scaling/__tests__/pool-config.service.ee.test.ts @@ -5,7 +5,7 @@ import { mock } from 'vitest-mock-extended'; import { PoolConfigService } from '@/scaling/pool-config.service.ee'; import type { WorkerPoolsService } from '@/scaling/worker-pools.service.ee'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; describe('PoolConfigService', () => { const projectPoolSettingsRepository = mock(); diff --git a/packages/cli/src/scaling/__tests__/redis-lock.service.test.ts b/packages/cli/src/scaling/__tests__/redis-lock.service.test.ts index f9750a5708a..12ad8f8a64b 100644 --- a/packages/cli/src/scaling/__tests__/redis-lock.service.test.ts +++ b/packages/cli/src/scaling/__tests__/redis-lock.service.test.ts @@ -6,7 +6,7 @@ import { OperationalError } from 'n8n-workflow'; import { createHash } from 'node:crypto'; import { mock } from 'vitest-mock-extended'; -import type { RedisClientService } from '@/services/redis-client.service'; +import type { RedisClientService } from '@n8n/backend-services'; import { RedisLockService } from '../redis-lock.service'; diff --git a/packages/cli/src/scaling/__tests__/worker-server.test.ts b/packages/cli/src/scaling/__tests__/worker-server.test.ts index 58d0a6d3367..69feaacd507 100644 --- a/packages/cli/src/scaling/__tests__/worker-server.test.ts +++ b/packages/cli/src/scaling/__tests__/worker-server.test.ts @@ -12,7 +12,7 @@ import type { CredentialsOverwrites } from '@/credentials-overwrites'; import type { ExternalHooks } from '@/external-hooks'; import type { PrometheusMetricsService } from '@/metrics/prometheus'; import { bodyParser, rawBodyReader } from '@/middlewares'; -import type { RedisClientService } from '@/services/redis-client.service'; +import type { RedisClientService } from '@n8n/backend-services'; import { WorkerServer } from '../worker-server'; diff --git a/packages/cli/src/scaling/job-processor.ts b/packages/cli/src/scaling/job-processor.ts index 616a823ec96..5b80be39e78 100644 --- a/packages/cli/src/scaling/job-processor.ts +++ b/packages/cli/src/scaling/job-processor.ts @@ -1,6 +1,7 @@ import type { Tool } from '@langchain/core/tools'; import type { RunningJobSummary } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { ExecutionsConfig } from '@n8n/config'; import { MAX_INTEGER_32BITS_SIGNED } from '@n8n/constants'; import { ExecutionRepository, WorkflowRepository } from '@n8n/db'; @@ -40,7 +41,6 @@ import type { } from 'n8n-workflow'; import type PCancelable from 'p-cancelable'; -import { EventService } from '@/events/event.service'; import { getLifecycleHooksForScalingWorker } from '@/execution-lifecycle/execution-lifecycle-hooks'; import { prepareExecutionDataForDbUpdate } from '@/execution-lifecycle/shared/shared-hook-functions'; import { ExecutionPersistence } from '@/executions/execution-persistence'; diff --git a/packages/cli/src/scaling/leader-election-client.ts b/packages/cli/src/scaling/leader-election-client.ts index 7b889e4ab1a..48826b80d09 100644 --- a/packages/cli/src/scaling/leader-election-client.ts +++ b/packages/cli/src/scaling/leader-election-client.ts @@ -5,7 +5,7 @@ import { InstanceSettings } from 'n8n-core'; import { ensureError } from '@n8n/utils/errors/ensure-error'; import { type Result, createResultOk, createResultError } from '@n8n/utils/result'; -import { RedisClientService } from '@/services/redis-client.service'; +import { RedisClientService } from '@n8n/backend-services'; const COMMAND_TIMEOUT_MS = 5_000; diff --git a/packages/cli/src/scaling/pool-config.service.ee.ts b/packages/cli/src/scaling/pool-config.service.ee.ts index f180d14eb8f..947e18e87df 100644 --- a/packages/cli/src/scaling/pool-config.service.ee.ts +++ b/packages/cli/src/scaling/pool-config.service.ee.ts @@ -5,7 +5,7 @@ import { ProjectPoolSettingsRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import type { IWorkflowExecutionDataProcess } from 'n8n-workflow'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { DEFAULT_QUEUE_NAME, poolQueueName } from './queue-name'; import { WorkerPoolsService } from './worker-pools.service.ee'; diff --git a/packages/cli/src/scaling/pubsub/__tests__/publisher.service.test.ts b/packages/cli/src/scaling/pubsub/__tests__/publisher.service.test.ts index 747c8a8d787..8d2268378c3 100644 --- a/packages/cli/src/scaling/pubsub/__tests__/publisher.service.test.ts +++ b/packages/cli/src/scaling/pubsub/__tests__/publisher.service.test.ts @@ -4,7 +4,7 @@ import type { Redis as SingleNodeClient } from 'ioredis'; import type { InstanceSettings } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import type { RedisClientService } from '@/services/redis-client.service'; +import type { RedisClientService } from '@n8n/backend-services'; import { Publisher } from '../publisher.service'; import type { PubSub } from '../pubsub.types'; diff --git a/packages/cli/src/scaling/pubsub/__tests__/subscriber.service.test.ts b/packages/cli/src/scaling/pubsub/__tests__/subscriber.service.test.ts index a377362a3ef..e18819960e8 100644 --- a/packages/cli/src/scaling/pubsub/__tests__/subscriber.service.test.ts +++ b/packages/cli/src/scaling/pubsub/__tests__/subscriber.service.test.ts @@ -4,7 +4,7 @@ import { ExecutionsConfig, GlobalConfig } from '@n8n/config'; import type { Redis as SingleNodeClient } from 'ioredis'; import { mock } from 'vitest-mock-extended'; -import type { RedisClientService } from '@/services/redis-client.service'; +import type { RedisClientService } from '@n8n/backend-services'; import type { PubSubEventBus } from '../pubsub.eventbus'; import type { McpRelayMessage } from '../subscriber.service'; diff --git a/packages/cli/src/scaling/pubsub/publisher.service.ts b/packages/cli/src/scaling/pubsub/publisher.service.ts index 152b64c064c..4f00fe2768e 100644 --- a/packages/cli/src/scaling/pubsub/publisher.service.ts +++ b/packages/cli/src/scaling/pubsub/publisher.service.ts @@ -5,7 +5,7 @@ import type { Redis as SingleNodeClient, Cluster as MultiNodeClient } from 'iore import { InstanceSettings } from 'n8n-core'; import type { LogMetadata } from 'n8n-workflow'; -import { RedisClientService } from '@/services/redis-client.service'; +import { RedisClientService } from '@n8n/backend-services'; import type { PubSub } from './pubsub.types'; import { diff --git a/packages/cli/src/scaling/pubsub/subscriber.service.ts b/packages/cli/src/scaling/pubsub/subscriber.service.ts index 80cebb9595a..2923b231966 100644 --- a/packages/cli/src/scaling/pubsub/subscriber.service.ts +++ b/packages/cli/src/scaling/pubsub/subscriber.service.ts @@ -7,7 +7,7 @@ import { InstanceSettings } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; import type { LogMetadata } from 'n8n-workflow'; -import { RedisClientService } from '@/services/redis-client.service'; +import { RedisClientService } from '@n8n/backend-services'; import { PubSubEventBus } from './pubsub.eventbus'; import type { PubSub } from './pubsub.types'; diff --git a/packages/cli/src/scaling/redis-lock.service.ts b/packages/cli/src/scaling/redis-lock.service.ts index ec8398ec2db..50e9bcea65a 100644 --- a/packages/cli/src/scaling/redis-lock.service.ts +++ b/packages/cli/src/scaling/redis-lock.service.ts @@ -11,7 +11,7 @@ import { ensureError } from '@n8n/utils/errors/ensure-error'; import { sleep } from '@n8n/utils/sleep'; import { createHash, randomUUID } from 'node:crypto'; -import { RedisClientService } from '@/services/redis-client.service'; +import { RedisClientService } from '@n8n/backend-services'; import { OnShutdown } from '@n8n/decorators'; const COMMAND_TIMEOUT_MS = 5_000; diff --git a/packages/cli/src/scaling/scaling.service.ts b/packages/cli/src/scaling/scaling.service.ts index 0f4a277681a..9288de3acec 100644 --- a/packages/cli/src/scaling/scaling.service.ts +++ b/packages/cli/src/scaling/scaling.service.ts @@ -1,4 +1,5 @@ import { isObjectLiteral, Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { ExecutionRepository } from '@n8n/db'; @@ -13,7 +14,6 @@ import assert, { strict } from 'node:assert'; import { ActiveExecutions } from '@/active-executions'; import { HIGHEST_SHUTDOWN_PRIORITY } from '@/constants'; -import { EventService } from '@/events/event.service'; import { ExecutionCrashService } from '@/executions/execution-crash.service'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import { assertNever } from '@/utils'; @@ -98,7 +98,7 @@ export class ScalingService { if (this.queueByName.size > 0) return; const { default: BullQueue } = await import('bull'); - const { RedisClientService } = await import('@/services/redis-client.service.js'); + const { RedisClientService } = await import('@n8n/backend-services'); const service = Container.get(RedisClientService); diff --git a/packages/cli/src/scaling/worker-server.ts b/packages/cli/src/scaling/worker-server.ts index d2da517bded..41e339e319f 100644 --- a/packages/cli/src/scaling/worker-server.ts +++ b/packages/cli/src/scaling/worker-server.ts @@ -17,7 +17,7 @@ import type { ICredentialsOverwrite } from '@/interfaces'; import { PrometheusMetricsService } from '@/metrics/prometheus'; import { rawBodyReader, bodyParser } from '@/middlewares'; import * as ResponseHelper from '@/response-helper'; -import { RedisClientService } from '@/services/redis-client.service'; +import { RedisClientService } from '@n8n/backend-services'; import { resolveBackendHealthEndpointPath } from '@/utils/health-endpoint.util'; export type WorkerServerEndpointsConfig = { 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 f2582ac7797..c6ea51207c2 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 @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/unbound-method */ 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'; @@ -10,7 +11,6 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { Mock, MockInstance } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; import { createNodeTypes } from '@/workflows/triggers/__tests__/trigger-test-utils'; import type { PollBackoffService } from '@/workflows/triggers/poll-backoff.service'; import type { TriggerExecutionContextFactory } from '@/workflows/triggers/trigger-execution-context.factory'; diff --git a/packages/cli/src/scheduling/poll-trigger-node/poll-trigger-job-registrar.ts b/packages/cli/src/scheduling/poll-trigger-node/poll-trigger-job-registrar.ts index e69301b9974..4df15a9005c 100644 --- a/packages/cli/src/scheduling/poll-trigger-node/poll-trigger-job-registrar.ts +++ b/packages/cli/src/scheduling/poll-trigger-node/poll-trigger-job-registrar.ts @@ -6,13 +6,13 @@ import { Service } from '@n8n/di'; import type { DesiredJob, Schedule } from '@n8n/scheduler'; import { computeFirstRunAt } from '@n8n/scheduler'; import { PollJobManager } from 'n8n-core'; -import type { CronExpression, INode, TriggerTime } from 'n8n-workflow'; -import { createHash } from 'node:crypto'; +import type { INode, TriggerTime } from 'n8n-workflow'; import { PollBackoffService } from '@/workflows/triggers/poll-backoff.service'; import { nameDesiredJobs } from '../desired-job-name'; import { DurableJobProvisioner } from '../durable-job-provisioner'; +import { seededCron } from '../seeded-cron'; import { WorkflowScheduledJobOwner } from '../workflow-scheduled-job-owner'; import type { PollTriggerTaskPayload } from './poll-trigger-task'; import { POLL_TRIGGER_TASK_TYPE } from './poll-trigger-task'; @@ -147,45 +147,3 @@ export class PollTriggerJobRegistrar extends PollJobManager { function resolveTimezone(timezone: string, defaultTimezone: string): string { return !timezone || timezone === 'DEFAULT' ? defaultTimezone : timezone; } - -/** - * Deterministic integer in `[min, max)` from `seed`+`label`, filling a generated poll - * time's unspecified cron fields. Seeded on node identity so the cron string (and thus the - * job's reconcile-in-place identity) stays stable across re-activation. - */ -function stableInt(seed: string, label: string, min: number, max: number): number { - const hash = createHash('sha256').update(`${seed}:${label}`).digest(); - return min + (hash.readUInt32BE(0) % (max - min)); -} - -/** - * Build a 6-field cron for a poll time. Generated cadences get a node-seeded (not random) - * seconds field for a stable job identity; a custom cron is used as-is, widened from 5 to - * 6 fields when it omits seconds, so every stored expression is one shape. - */ -function seededCron(item: TriggerTime, seed: string): CronExpression { - if (item.mode === 'custom') { - const trimmed = item.cronExpression.trim(); - return (trimmed.split(/\s+/).length === 5 ? `0 ${trimmed}` : trimmed) as CronExpression; - } - - const second = stableInt(seed, 'second', 0, 60); - - switch (item.mode) { - case 'everyMinute': - return `${second} * * * * *`; - case 'everyHour': - return `${second} ${item.minute} * * * *`; - case 'everyX': { - if (item.unit === 'minutes') return `${second} */${item.value} * * * *`; - const minute = stableInt(seed, 'minute', 0, 60); - return `${second} ${minute} */${item.value} * * *`; - } - case 'everyDay': - return `${second} ${item.minute} ${item.hour} * * *`; - case 'everyWeek': - return `${second} ${item.minute} ${item.hour} * * ${item.weekday}`; - case 'everyMonth': - return `${second} ${item.minute} ${item.hour} ${item.dayOfMonth} * *`; - } -} 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 c763cca3f66..a454de7a559 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 @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { WorkflowRepository } from '@n8n/db'; @@ -14,7 +15,6 @@ import { import type { Failure, IWorkflowBase } from 'n8n-workflow'; import { OperationalError, UnexpectedError } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; import { PollBackoffService } from '@/workflows/triggers/poll-backoff.service'; import { TriggerExecutionContextFactory } from '@/workflows/triggers/trigger-execution-context.factory'; diff --git a/packages/cli/src/scheduling/schedule-trigger-node/__tests__/schedule-trigger-job-registrar.test.ts b/packages/cli/src/scheduling/schedule-trigger-node/__tests__/schedule-trigger-job-registrar.test.ts index cb958d3ef69..c14619847df 100644 --- a/packages/cli/src/scheduling/schedule-trigger-node/__tests__/schedule-trigger-job-registrar.test.ts +++ b/packages/cli/src/scheduling/schedule-trigger-node/__tests__/schedule-trigger-job-registrar.test.ts @@ -5,9 +5,18 @@ import { mockLogger } from '@n8n/backend-test-utils'; import type { GlobalConfig, WorkflowsConfig } from '@n8n/config'; import type { EntityManager } from '@n8n/db'; import type { CronDefinition } from '@n8n/scheduler'; +import { Cron as CronNode } from 'n8n-nodes-base/nodes/Cron/Cron.node'; import { ScheduleTrigger } from 'n8n-nodes-base/nodes/Schedule/ScheduleTrigger.node'; -import type { Cron, CronExpression, INode, INodeParameters, INodeTypes } from 'n8n-workflow'; -import { SCHEDULE_TRIGGER_NODE_TYPE, Workflow } from 'n8n-workflow'; +import type { + Cron, + CronExpression, + INode, + INodeParameters, + INodeTypes, + ITriggerFunctions, + TriggerTime, +} from 'n8n-workflow'; +import { CRON_NODE_TYPE, SCHEDULE_TRIGGER_NODE_TYPE, Workflow } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; import type { DurableJobProvisioner } from '../../durable-job-provisioner'; @@ -27,6 +36,7 @@ const jobNamePattern = new RegExp(`^${WORKFLOW_ID}:${NODE_ID}:[0-9a-f]{16}:\\d+$ const workflow = { id: WORKFLOW_ID, settings: {} } as unknown as Workflow; const scheduleNode = mock({ id: NODE_ID, type: SCHEDULE_TRIGGER_NODE_TYPE }); +const cronNode = mock({ id: NODE_ID, type: CRON_NODE_TYPE }); const makeNode = ({ id = NODE_ID, @@ -111,24 +121,28 @@ describe('ScheduleTriggerJobRegistrar', () => { vi.useRealTimers(); }); - describe('interceptsNode', () => { - it('intercepts a schedule trigger node when the durable scheduler and publication path are on', () => { - expect(makeRegistrar().interceptsNode(scheduleNode)).toBe(true); + describe.each([scheduleNode, cronNode])('interceptsNode ($type)', (triggerNode) => { + it('intercepts the node when the durable scheduler and publication path are on', () => { + expect(makeRegistrar().interceptsNode(triggerNode)).toBe(true); }); it('does not intercept when the durable scheduler is off', () => { - expect(makeRegistrar({ schedulerEnabled: false }).interceptsNode(scheduleNode)).toBe(false); + expect(makeRegistrar({ schedulerEnabled: false }).interceptsNode(triggerNode)).toBe(false); }); it('does not intercept on the legacy activation path', () => { - expect(makeRegistrar({ publicationEnabled: false }).interceptsNode(scheduleNode)).toBe(false); + expect(makeRegistrar({ publicationEnabled: false }).interceptsNode(triggerNode)).toBe(false); }); + }); + describe('interceptsNode', () => { it('does not intercept other node types', () => { const other = mock({ id: NODE_ID, type: 'n8n-nodes-base.gmailTrigger' }); expect(makeRegistrar().interceptsNode(other)).toBe(false); }); + // Only the Schedule Trigger declares `skipDurableScheduler`; the Workflow + // constructor strips it from any other node, so the toggle cases use that type. it('does not intercept a node opted out via skip when the escape hatch is enabled', () => { const skippingNode = mock({ id: NODE_ID, @@ -163,6 +177,220 @@ describe('ScheduleTriggerJobRegistrar', () => { }); }); + describe('Cron node', () => { + const node = new CronNode(); + const register = async (triggerTimes: TriggerTime[], targetWorkflow = workflow) => { + const session = makeRegistrar().createSession(); + const collector = session.createCollector(targetWorkflow, cronNode); + const context = mock({ + getNodeParameter: vi.fn().mockReturnValue({ item: triggerTimes }), + helpers: mock(collector), + }); + await node.trigger.call(context); + await session.commit(WORKFLOW_ID, NODE_ID); + return lastRequest(); + }; + + it.each<{ triggerTime: TriggerTime; pattern: RegExp }>([ + { triggerTime: { mode: 'everyMinute' }, pattern: /^\d+ \* \* \* \* \*$/ }, + { triggerTime: { mode: 'everyHour', minute: 15 }, pattern: /^\d+ 15 \* \* \* \*$/ }, + { + triggerTime: { mode: 'everyX', unit: 'minutes', value: 5 }, + pattern: /^\d+ \*\/5 \* \* \* \*$/, + }, + { + triggerTime: { mode: 'everyX', unit: 'hours', value: 2 }, + pattern: /^\d+ \d+ \*\/2 \* \* \*$/, + }, + { + triggerTime: { mode: 'everyDay', hour: 9, minute: 30 }, + pattern: /^\d+ 30 9 \* \* \*$/, + }, + { + triggerTime: { mode: 'everyWeek', hour: 9, minute: 30, weekday: 1 }, + pattern: /^\d+ 30 9 \* \* 1$/, + }, + { + triggerTime: { mode: 'everyMonth', hour: 9, minute: 30, dayOfMonth: 5 }, + pattern: /^\d+ 30 9 5 \* \*$/, + }, + ])('registers $triggerTime as a durable cron', async ({ triggerTime, pattern }) => { + const request = await register([triggerTime]); + + expect(request).toMatchObject({ + owner: OWNER, + taskType: SCHEDULE_TRIGGER_TASK_TYPE, + payload: { workflowId: WORKFLOW_ID, nodeId: NODE_ID }, + misfirePolicy: ScheduledJobMisfirePolicy.Skip, + }); + expect(request.desired).toHaveLength(1); + expect(request.desired[0]).toEqual({ + name: expect.stringMatching(jobNamePattern), + schedule: { + kind: 'cron', + cronExpression: expect.stringMatching(pattern), + timezone: null, + }, + firstRunAt: expect.any(Date), + }); + }); + + it('keeps job names and schedules stable when generated offsets change', async () => { + const triggerTime: TriggerTime = { mode: 'everyX', unit: 'hours', value: 2 }; + const collect = async (expression: CronExpression) => { + const session = makeRegistrar().createSession(); + session + .createCollector(workflow, cronNode) + .registerCron({ expression, triggerTime }, vi.fn()); + await session.commit(WORKFLOW_ID, NODE_ID); + return lastDesired()[0]; + }; + + const first = await collect('1 2 */2 * * *'); + vi.setSystemTime(new Date('2026-01-06T00:00:00.000Z')); + const second = await collect('3 4 */2 * * *'); + + expect(second.name).toBe(first.name); + expect(second.schedule).toEqual(first.schedule); + }); + + it.each([ + ['*/5 * * * *', '0 */5 * * * *'], + ['15 */5 * * * *', '15 */5 * * * *'], + ['@daily', '0 0 0 * * *'], + ['@weekdays', '0 0 0 * * 1,2,3,4,5'], + ])('preserves custom cron timing for %s', async (expression, expected) => { + await register([{ mode: 'custom', cronExpression: expression as CronExpression }]); + + expect(lastDesired()[0].schedule).toEqual({ + kind: 'cron', + cronExpression: expected, + timezone: null, + }); + }); + + it('uses the workflow timezone to plan the first occurrence', async () => { + await register( + [{ mode: 'custom', cronExpression: '0 0 9 * * *' }], + mock({ id: WORKFLOW_ID, settings: { timezone: 'Europe/Berlin' } }), + ); + + expect(lastDesired()[0]).toMatchObject({ + schedule: { timezone: 'Europe/Berlin' }, + firstRunAt: new Date('2026-01-05T08:00:00.000Z'), + }); + }); + + it('retains duplicate rules and job names when rules are reordered', async () => { + const hourly: TriggerTime = { mode: 'everyHour', minute: 15 }; + const daily: TriggerTime = { mode: 'everyDay', hour: 9, minute: 30 }; + const first = await register([hourly, daily, hourly]); + const second = await register([daily, hourly, hourly]); + + expect(new Set(first.desired.map(({ name }) => name)).size).toBe(3); + expect(second.desired.map(({ name }) => name).sort()).toEqual( + first.desired.map(({ name }) => name).sort(), + ); + }); + + it.each([ + ['0 0 9 * * *', '0 0 9 * * *', '0 0 9 * * *'], + ['0 9 * * *', '0 9 * * *', '0 0 9 * * *'], + ['@daily', '@daily', '0 0 0 * * *'], + ['0 0 9 * * *', ' 0 0 9 * * * ', '0 0 9 * * *'], + ])( + 'registers repeated custom expression %s only once', + async (expression, repeated, expected) => { + const request = await register([ + { mode: 'custom', cronExpression: expression as CronExpression }, + { mode: 'custom', cronExpression: repeated as CronExpression }, + ]); + + expect(request.desired).toHaveLength(1); + expect(request.desired[0].schedule).toEqual({ + kind: 'cron', + cronExpression: expected, + timezone: null, + }); + }, + ); + + it.each([ + ['0 9 * * *', '0 0 9 * * *'], + ['@daily', '0 0 0 * * *'], + ['@daily', '@DAILY'], + ])('keeps distinct custom expressions %s and %s', async (first, second) => { + const rules: TriggerTime[] = [first, second].map((expression) => ({ + mode: 'custom', + cronExpression: expression as CronExpression, + })); + const request = await register([...rules, ...rules]); + + expect(request.desired).toHaveLength(2); + expect(request.desired[0].schedule).toEqual(request.desired[1].schedule); + expect(request.desired[0].name).not.toBe(request.desired[1].name); + }); + + it('registers the same custom expression independently for each Cron node', async () => { + const session = makeRegistrar().createSession(); + const otherNode = mock({ id: 'node-2', type: CRON_NODE_TYPE }); + const cron: Cron = { + expression: '0 0 9 * * *', + triggerTime: { mode: 'custom', cronExpression: '0 0 9 * * *' }, + }; + + for (const targetNode of [cronNode, otherNode]) { + const collector = session.createCollector(workflow, targetNode); + collector.registerCron(cron, vi.fn()); + collector.registerCron(cron, vi.fn()); + await session.commit(WORKFLOW_ID, targetNode.id); + + expect(lastDesired()).toHaveLength(1); + expect(lastRequest().owner).toEqual(ownerOf(WORKFLOW_ID, targetNode.id)); + } + }); + + it('registers the same custom expression independently in overlapping activation sessions', async () => { + const registrar = makeRegistrar(); + const sessions = [registrar.createSession(), registrar.createSession()]; + const cron: Cron = { + expression: '0 0 9 * * *', + triggerTime: { mode: 'custom', cronExpression: '0 0 9 * * *' }, + }; + + for (const session of sessions) { + const collector = session.createCollector(workflow, cronNode); + collector.registerCron(cron, vi.fn()); + collector.registerCron(cron, vi.fn()); + } + + for (const session of sessions) { + await session.commit(WORKFLOW_ID, NODE_ID); + expect(lastDesired()).toHaveLength(1); + } + }); + + it('reconciles an empty rule list', async () => { + await register([]); + + expect(lastDesired()).toEqual([]); + }); + + it('uses the existing node and workflow cleanup paths', async () => { + await register([{ mode: 'everyMinute' }]); + const registrar = makeRegistrar({ schedulerEnabled: false }); + + await registrar.remove(WORKFLOW_ID, cronNode.id); + await registrar.removeWorkflow(WORKFLOW_ID); + + expect(jobProvisioner.deprovisionOwnerMember).toHaveBeenCalledWith(OWNER); + expect(jobProvisioner.deprovisionOwnerTaskType).toHaveBeenCalledWith( + OWNER_REF, + SCHEDULE_TRIGGER_TASK_TYPE, + ); + }); + }); + describe('collect and commit', () => { it('provisions one desired job per rule, named by its definition, with the first fire planned', async () => { const session = makeRegistrar().createSession(); diff --git a/packages/cli/src/scheduling/schedule-trigger-node/__tests__/schedule-trigger-task-handler.test.ts b/packages/cli/src/scheduling/schedule-trigger-node/__tests__/schedule-trigger-task-handler.test.ts index c49df827455..015e84d2229 100644 --- a/packages/cli/src/scheduling/schedule-trigger-node/__tests__/schedule-trigger-task-handler.test.ts +++ b/packages/cli/src/scheduling/schedule-trigger-node/__tests__/schedule-trigger-task-handler.test.ts @@ -1,16 +1,16 @@ /* eslint-disable @typescript-eslint/unbound-method */ /* eslint-disable @typescript-eslint/naming-convention -- item keys are pinned to the legacy ScheduleTrigger emit shape */ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { GlobalConfig } from '@n8n/config'; import type { ExecutionEntity, ExecutionRepository, Project } from '@n8n/db'; import { createDispatchReporter, type ClaimedTask } from '@n8n/scheduler'; import type { ErrorReporter } from 'n8n-core'; import type { INode, IWorkflowBase, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; -import { UnexpectedError } from 'n8n-workflow'; +import { CRON_NODE_TYPE, SCHEDULE_TRIGGER_NODE_TYPE, UnexpectedError } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; import { DuplicateExecutionError } from '@/errors/duplicate-execution.error'; -import type { EventService } from '@/events/event.service'; import type { OwnershipService } from '@/services/ownership.service'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import type { TriggerExecutionContextFactory } from '@/workflows/triggers/trigger-execution-context.factory'; @@ -51,7 +51,12 @@ describe('ScheduleTriggerTaskHandler', () => { // The reporter the executor hands to `execute`; `dispatched()` fires the spy above. const report = createDispatchReporter(onDispatch); - const triggerNode = mock({ id: 'node-1', name: 'Schedule Trigger', disabled: false }); + const triggerNode = mock({ + id: 'node-1', + name: 'Schedule Trigger', + type: SCHEDULE_TRIGGER_NODE_TYPE, + disabled: false, + }); // Plain data objects, not mock proxies: the handler reads them as values. const buildWorkflowData = (overrides: Partial = {}): IWorkflowBase => @@ -124,6 +129,32 @@ describe('ScheduleTriggerTaskHandler', () => { }); describe('handoff', () => { + test('dispatches a Cron occurrence with an empty item and the existing dedup key', async () => { + const cronNode = mock({ + id: 'node-1', + name: 'Cron', + type: CRON_NODE_TYPE, + disabled: false, + }); + triggerExecutionContextFactory.findPublishedWorkflowData.mockResolvedValue( + buildWorkflowData({ nodes: [cronNode] }), + ); + + const decision = await handler.execute(buildTask(), report); + + expect(workflowExecutionService.runWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ id: 'wf-1' }), + cronNode, + [[{ json: {} }]], + additionalData, + 'trigger', + undefined, + '7:2026-07-06T07:30:00.000Z', + ); + expect(decision).toBe(createDispatchReporter(vi.fn()).dispatched()); + expect(onDispatch).toHaveBeenCalledTimes(1); + }); + test('creates a trigger execution with the occurrence-derived dedup key', async () => { await handler.execute(buildTask(), report); diff --git a/packages/cli/src/scheduling/schedule-trigger-node/schedule-trigger-job-registrar.ts b/packages/cli/src/scheduling/schedule-trigger-node/schedule-trigger-job-registrar.ts index fbd28747a56..3d7ec793ec5 100644 --- a/packages/cli/src/scheduling/schedule-trigger-node/schedule-trigger-job-registrar.ts +++ b/packages/cli/src/scheduling/schedule-trigger-node/schedule-trigger-job-registrar.ts @@ -6,10 +6,11 @@ import { Service } from '@n8n/di'; import type { Schedule } from '@n8n/scheduler'; import { computeFirstRunAt, validateSchedule } from '@n8n/scheduler'; import type { Cron, INode, SchedulingFunctions, Workflow } from 'n8n-workflow'; -import { SCHEDULE_TRIGGER_NODE_TYPE } from 'n8n-workflow'; +import { CRON_NODE_TYPE, SCHEDULE_TRIGGER_NODE_TYPE } from 'n8n-workflow'; import { nameDesiredJobs } from '../desired-job-name'; import { DurableJobProvisioner } from '../durable-job-provisioner'; +import { seededCron } from '../seeded-cron'; import { WorkflowScheduledJobOwner } from '../workflow-scheduled-job-owner'; import type { ScheduleTriggerTaskPayload } from './schedule-trigger-task'; import { SCHEDULE_TRIGGER_TASK_TYPE } from './schedule-trigger-task'; @@ -75,7 +76,7 @@ export interface ScheduleTriggerCollectionSession { } /** - * Registers a Schedule Trigger node's rules as durable `scheduled_job` rows. + * Registers a Schedule Trigger or legacy Cron node's rules as durable `scheduled_job` rows. * The publication activation path's counterpart to the in-memory `ScheduledTaskManager`. * * Each activation attempt works through its own {@link ScheduleTriggerCollectionSession}: @@ -101,7 +102,7 @@ export interface ScheduleTriggerCollectionSession { */ @Service() export class ScheduleTriggerJobRegistrar { - /** Whether this instance diverts schedule trigger registrations to durable jobs. */ + /** Whether this instance diverts Schedule Trigger and Cron node registrations to durable jobs. */ private readonly intercepting: boolean; /** Instance-default timezone, used to resolve a null cron timezone for the first-run math only. */ @@ -129,19 +130,22 @@ export class ScheduleTriggerJobRegistrar { if (globalConfig.scheduler.enabled && !workflowsConfig.useWorkflowPublicationService) { this.logger.warn( - 'N8N_SCHEDULER_ENABLED is set but the workflow publication service is disabled. The durable scheduler cannot take over schedule triggers, which keep using the legacy in-memory engine.', + 'N8N_SCHEDULER_ENABLED is set but the workflow publication service is disabled. The durable scheduler cannot take over schedule and cron triggers, which keep using the legacy in-memory engine.', ); } } /** + * Only Schedule Trigger and legacy Cron nodes are diverted; their rules are + * plain crons the durable engine can hold. + * * @param node The trigger node about to register its cron rules. * @returns `true` to hand the node a durable collector, `false` to leave it on the legacy path. */ interceptsNode(node: INode): boolean { if ( !this.intercepting || - node.type !== SCHEDULE_TRIGGER_NODE_TYPE || + (node.type !== SCHEDULE_TRIGGER_NODE_TYPE && node.type !== CRON_NODE_TYPE) || (this.allowSkipDurableScheduler && node.parameters?.skipDurableScheduler === true) ) { return false; @@ -169,6 +173,7 @@ export class ScheduleTriggerJobRegistrar { createCollector: (workflow: Workflow, node: INode): SchedulingFunctions => { const timezone = explicitTimezone(workflow); const collected: CollectedSchedule[] = []; + const customCronExpressions = new Set(); pending.set(pendingKey(workflow.id, node.id), { misfirePolicy: resolveMisfirePolicy(node), misfireGraceSeconds: resolveMisfireGraceSeconds(node, workflow.id, this.logger), @@ -176,8 +181,18 @@ export class ScheduleTriggerJobRegistrar { }); return { - registerCron: ({ expression, recurrence, source }: Cron) => { - const schedule = this.toSchedule(expression, timezone, recurrence, source); + registerCron: ({ expression, recurrence, source, triggerTime }: Cron) => { + const isCustomCron = node.type === CRON_NODE_TYPE && triggerTime?.mode === 'custom'; + // The legacy engine compares expressions before presets and five-field crons are normalized. + if (isCustomCron && customCronExpressions.has(expression)) { + return; + } + + const cronExpression = + node.type === CRON_NODE_TYPE && triggerTime + ? seededCron(triggerTime, `${workflow.id}:${node.id}`) + : expression; + const schedule = this.toSchedule(cronExpression, timezone, recurrence, source); if (isDegenerateRecurrence(recurrence)) { // The legacy engine never fires such a rule (its recurrence check @@ -200,7 +215,7 @@ export class ScheduleTriggerJobRegistrar { // cron tick, not activation + interval — seed from the cron. const seedSchedule: Schedule = this.triggerNodeMode === 'legacy' && schedule.kind === 'interval' - ? { kind: 'cron', cronExpression: expression, timezone } + ? { kind: 'cron', cronExpression, timezone } : schedule; // Validates the expression/timezone and returns the first instant. @@ -211,6 +226,9 @@ export class ScheduleTriggerJobRegistrar { collected.push({ schedule, firstRunAt: computed }); } + if (isCustomCron) { + customCronExpressions.add(expression); + } }, }; }, diff --git a/packages/cli/src/scheduling/schedule-trigger-node/schedule-trigger-task-handler.ts b/packages/cli/src/scheduling/schedule-trigger-node/schedule-trigger-task-handler.ts index f8a37748df5..500ffbf4778 100644 --- a/packages/cli/src/scheduling/schedule-trigger-node/schedule-trigger-task-handler.ts +++ b/packages/cli/src/scheduling/schedule-trigger-node/schedule-trigger-task-handler.ts @@ -1,13 +1,13 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import { ExecutionRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import type { ClaimedTask, DispatchDecision, DispatchReporter, TaskHandler } from '@n8n/scheduler'; import { ErrorReporter } from 'n8n-core'; -import { UnexpectedError } from 'n8n-workflow'; +import { CRON_NODE_TYPE, UnexpectedError } from 'n8n-workflow'; import { DuplicateExecutionError } from '@/errors/duplicate-execution.error'; -import { EventService } from '@/events/event.service'; import { OwnershipService } from '@/services/ownership.service'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import { TriggerExecutionContextFactory } from '@/workflows/triggers/trigger-execution-context.factory'; @@ -77,7 +77,10 @@ export class ScheduleTriggerTaskHandler implements TaskHandler { settingsTimezone && settingsTimezone !== 'DEFAULT' ? settingsTimezone : this.globalConfig.generic.timezone; - const item = buildScheduleTriggerItem(task.scheduledFor, timezone); + const item = + node.type === CRON_NODE_TYPE + ? { json: {} } + : buildScheduleTriggerItem(task.scheduledFor, timezone); const additionalData = await WorkflowExecuteAdditionalData.getBase({ workflowId, diff --git a/packages/cli/src/scheduling/seeded-cron.ts b/packages/cli/src/scheduling/seeded-cron.ts new file mode 100644 index 00000000000..591b6b7d912 --- /dev/null +++ b/packages/cli/src/scheduling/seeded-cron.ts @@ -0,0 +1,47 @@ +import { CronTime } from 'cron'; +import type { CronExpression, TriggerTime } from 'n8n-workflow'; +import { createHash } from 'node:crypto'; + +/** + * Deterministic integer in `[min, max)` from `seed`+`label`, filling a generated trigger + * time's unspecified cron fields. Seeded on node identity so the cron string (and thus the + * job's reconcile-in-place identity) stays stable across re-activation. + */ +function stableInt(seed: string, label: string, min: number, max: number): number { + const hash = createHash('sha256').update(`${seed}:${label}`).digest(); + return min + (hash.readUInt32BE(0) % (max - min)); +} + +/** + * Build a six-field cron. Use node-seeded offsets to keep generated schedules stable. + * Expand legacy presets and add seconds to five-field custom expressions. + */ +export function seededCron(item: TriggerTime, seed: string): CronExpression { + if (item.mode === 'custom') { + const trimmed = item.cronExpression.trim(); + const expression = trimmed.startsWith('@') ? new CronTime(trimmed).toString() : trimmed; + return ( + expression.split(/\s+/).length === 5 ? `0 ${expression}` : expression + ) as CronExpression; + } + + const second = stableInt(seed, 'second', 0, 60); + + switch (item.mode) { + case 'everyMinute': + return `${second} * * * * *`; + case 'everyHour': + return `${second} ${item.minute} * * * *`; + case 'everyX': { + if (item.unit === 'minutes') return `${second} */${item.value} * * * *`; + const minute = stableInt(seed, 'minute', 0, 60); + return `${second} ${minute} */${item.value} * * *`; + } + case 'everyDay': + return `${second} ${item.minute} ${item.hour} * * *`; + case 'everyWeek': + return `${second} ${item.minute} ${item.hour} * * ${item.weekday}`; + case 'everyMonth': + return `${second} ${item.minute} ${item.hour} ${item.dayOfMonth} * *`; + } +} 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 a8f7d47c069..068bef3e993 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,11 +1,10 @@ +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 { Tracing } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; - import { SystemTaskHandler } from '../system-task-handler'; import { DummySystemTask } from './dummy.task'; diff --git a/packages/cli/src/scheduling/system-tasks/__tests__/system-task-job-registrar.test.ts b/packages/cli/src/scheduling/system-tasks/__tests__/system-task-job-registrar.test.ts index 645c7eaded0..eb9203e54c8 100644 --- a/packages/cli/src/scheduling/system-tasks/__tests__/system-task-job-registrar.test.ts +++ b/packages/cli/src/scheduling/system-tasks/__tests__/system-task-job-registrar.test.ts @@ -1,4 +1,5 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { GlobalConfig } from '@n8n/config'; import { DEFAULT_MISFIRE_GRACE_SECONDS, ScheduledJobMisfirePolicy } from '@n8n/constants'; import type { ScheduledJobRepository } from '@n8n/db'; @@ -9,7 +10,6 @@ import { inc } from 'semver'; import { mock } from 'vitest-mock-extended'; import { N8N_VERSION } from '@/constants'; -import type { EventService } from '@/events/event.service'; import type { DurableJobProvisioner } from '../../durable-job-provisioner'; import { SystemTaskJobRegistrar, systemTaskProvisionRequest } from '../system-task-job-registrar'; 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 972328e0858..0f0e3067223 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 @@ -1,9 +1,8 @@ +import type { EventService } from '@n8n/backend-services'; import type { SystemTask } from '@n8n/decorators'; import { SpanStatus, type Span, type StartSpanOpts, type Tracing } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; - import { observeSystemTaskRun } from '../system-task-run-observer'; describe('observeSystemTaskRun', () => { 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 6a9767520ef..726e124a03d 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 @@ -1,4 +1,5 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { GlobalConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import type { ScheduledJobRepository } from '@n8n/db'; @@ -9,8 +10,6 @@ import { createDispatchReporter } from '@n8n/scheduler'; import { Tracing, type ErrorReporter, type InstanceSettings } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; - import type { DurableScheduler } from '../../durable-scheduler'; import { SystemTaskHandler } from '../system-task-handler'; import type { SystemTaskJobRegistrar } from '../system-task-job-registrar'; diff --git a/packages/cli/src/scheduling/system-tasks/emit-system-task-metric.ts b/packages/cli/src/scheduling/system-tasks/emit-system-task-metric.ts index 2d1a5767078..785d9f03312 100644 --- a/packages/cli/src/scheduling/system-tasks/emit-system-task-metric.ts +++ b/packages/cli/src/scheduling/system-tasks/emit-system-task-metric.ts @@ -1,4 +1,5 @@ -import type { EventMap, EventService } from '@/events/event.service'; +import type { EventMap, EventService } from '@n8n/backend-services'; + import type { SystemTaskMetricsEventMap } from '@/events/maps/system-task-metrics.event-map'; /** diff --git a/packages/cli/src/scheduling/system-tasks/in-memory-system-task-runner.ts b/packages/cli/src/scheduling/system-tasks/in-memory-system-task-runner.ts index ad4dec1bff3..b9d92e1a556 100644 --- a/packages/cli/src/scheduling/system-tasks/in-memory-system-task-runner.ts +++ b/packages/cli/src/scheduling/system-tasks/in-memory-system-task-runner.ts @@ -1,10 +1,10 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { Time } from '@n8n/constants'; import type { SystemTask, SystemTaskSchedule } from '@n8n/decorators'; import { scheduleFromDefinition } from '@n8n/scheduler'; import type { Tracing } from 'n8n-core'; -import type { EventService } from '@/events/event.service'; import type { SystemTaskMode, SystemTaskSkipReason, 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 7a0c8fb46d3..903c43274a0 100644 --- a/packages/cli/src/scheduling/system-tasks/system-task-handler.ts +++ b/packages/cli/src/scheduling/system-tasks/system-task-handler.ts @@ -1,10 +1,9 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { SystemTask } from '@n8n/decorators'; import type { ClaimedTask, DispatchDecision, DispatchReporter, TaskHandler } from '@n8n/scheduler'; import type { Tracing } from 'n8n-core'; -import type { EventService } from '@/events/event.service'; - import { observeSystemTaskRun } from './system-task-run-observer'; /** diff --git a/packages/cli/src/scheduling/system-tasks/system-task-job-registrar.ts b/packages/cli/src/scheduling/system-tasks/system-task-job-registrar.ts index 28b2f447b68..c2e24316524 100644 --- a/packages/cli/src/scheduling/system-tasks/system-task-job-registrar.ts +++ b/packages/cli/src/scheduling/system-tasks/system-task-job-registrar.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import type { ScheduledJob } from '@n8n/db'; import { ScheduledJobRepository } from '@n8n/db'; @@ -8,8 +9,6 @@ import { Service } from '@n8n/di'; import { computeFirstRunAt, scheduleFromDefinition } from '@n8n/scheduler'; import { ErrorReporter } from 'n8n-core'; -import { EventService } from '@/events/event.service'; - import { DurableJobProvisioner } from '../durable-job-provisioner'; import type { ProvisionRequest } from '../durable-job-provisioner'; import { emitSystemTaskMetric } from './emit-system-task-metric'; 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 111d7571569..1deaed1dacf 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 @@ -1,8 +1,8 @@ +import type { EventService } from '@n8n/backend-services'; import type { SystemTask } from '@n8n/decorators'; import { ensureError } from '@n8n/utils/errors/ensure-error'; import { SpanStatus, type Span, type Tracing } from 'n8n-core'; -import type { EventService } from '@/events/event.service'; import type { SystemTaskMode } from '@/events/maps/system-task-metrics.event-map'; import { emitSystemTaskMetric } from './emit-system-task-metric'; diff --git a/packages/cli/src/scheduling/system-tasks/system-task-runner.ts b/packages/cli/src/scheduling/system-tasks/system-task-runner.ts index a64175e563c..f68ce4482ab 100644 --- a/packages/cli/src/scheduling/system-tasks/system-task-runner.ts +++ b/packages/cli/src/scheduling/system-tasks/system-task-runner.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import type { InstanceType } from '@n8n/constants'; import type { @@ -20,8 +21,6 @@ import { ErrorReporter, InstanceSettings, Tracing } from 'n8n-core'; import { UnexpectedError } from 'n8n-workflow'; import { strict } from 'node:assert'; -import { EventService } from '@/events/event.service'; - import { DurableScheduler } from '../durable-scheduler'; import { emitSystemTaskMetric } from './emit-system-task-metric'; import { SystemTaskHandler } from './system-task-handler'; diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 4f353813ead..d2eef8d8702 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -1,4 +1,5 @@ import { inDevelopment, inProduction, ModuleRegistry } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { SecurityConfig } from '@n8n/config'; import { HTML_NONCE_PLACEHOLDER, Time } from '@n8n/constants'; import type { APIRequest, AuthenticatedRequest } from '@n8n/db'; @@ -16,7 +17,6 @@ import { CLI_DIR, EDITOR_UI_DIST_DIR, inE2ETests } from '@/constants'; import { ControllerRegistry } from '@/controller.registry'; import { CredentialsOverwrites } from '@/credentials-overwrites'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; -import { EventService } from '@/events/event.service'; import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay'; import type { ICredentialsOverwrite } from '@/interfaces'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; diff --git a/packages/cli/src/services/__tests__/active-workflows.service.test.ts b/packages/cli/src/services/__tests__/active-workflows.service.test.ts index 4c6b23ed7c3..d4e884f2dbd 100644 --- a/packages/cli/src/services/__tests__/active-workflows.service.test.ts +++ b/packages/cli/src/services/__tests__/active-workflows.service.test.ts @@ -3,7 +3,7 @@ import type { User, WorkflowRepository } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; import type { ActivationErrorsService } from '@/activation-errors.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { ProjectScopeService } from '@/permissions.ee/project-scope.service'; import { ActiveWorkflowsService } from '@/services/active-workflows.service'; import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; diff --git a/packages/cli/src/services/__tests__/ai-gateway.service.test.ts b/packages/cli/src/services/__tests__/ai-gateway.service.test.ts index 1fea8b1c159..2ac42799e0b 100644 --- a/packages/cli/src/services/__tests__/ai-gateway.service.test.ts +++ b/packages/cli/src/services/__tests__/ai-gateway.service.test.ts @@ -9,7 +9,7 @@ import { mock } from 'vitest-mock-extended'; import { N8N_VERSION, AI_ASSISTANT_SDK_VERSION } from '@/constants'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import type { License } from '@/license'; import { AiGatewayService } from '@/services/ai-gateway.service'; import type { OwnershipService } from '@/services/ownership.service'; diff --git a/packages/cli/src/services/__tests__/ai-preference-write.test.ts b/packages/cli/src/services/__tests__/ai-preference-write.test.ts index 24339fe873c..ea3b21f975b 100644 --- a/packages/cli/src/services/__tests__/ai-preference-write.test.ts +++ b/packages/cli/src/services/__tests__/ai-preference-write.test.ts @@ -5,11 +5,13 @@ import { TELEMETRY_EVENT } from '@n8n/telemetry'; import { mock } from 'vitest-mock-extended'; import { AiPreferenceScopeFullError } from '@/errors/response-errors/ai-preference-scope-full.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { + BadRequestError, + ConflictError, + ForbiddenError, + InternalServerError, + NotFoundError, +} from '@n8n/errors'; import { toAiPreferenceWriteRejection, writeAssistantPreference, diff --git a/packages/cli/src/services/__tests__/ai-preference.service.test.ts b/packages/cli/src/services/__tests__/ai-preference.service.test.ts index d1d2ee26880..c125ad0da3f 100644 --- a/packages/cli/src/services/__tests__/ai-preference.service.test.ts +++ b/packages/cli/src/services/__tests__/ai-preference.service.test.ts @@ -13,8 +13,7 @@ import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; import { AiPreferenceScopeFullError } from '@/errors/response-errors/ai-preference-scope-full.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { AI_PREFERENCES_CLEARED_BLOCK, AI_PREFERENCES_REPLACES_EARLIER, diff --git a/packages/cli/src/services/__tests__/ai-usage.service.test.ts b/packages/cli/src/services/__tests__/ai-usage.service.test.ts index e428d526d1c..69a79cae7db 100644 --- a/packages/cli/src/services/__tests__/ai-usage.service.test.ts +++ b/packages/cli/src/services/__tests__/ai-usage.service.test.ts @@ -4,7 +4,7 @@ import { SettingsRepository } from '@n8n/db'; import config from '@/config'; import { AiUsageService } from '@/services/ai-usage.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; vi.mock('@/config', () => ({ default: { set: vi.fn() } })); diff --git a/packages/cli/src/services/__tests__/dynamic-node-parameters.service.test.ts b/packages/cli/src/services/__tests__/dynamic-node-parameters.service.test.ts index eecf05faba6..1c76e97ec26 100644 --- a/packages/cli/src/services/__tests__/dynamic-node-parameters.service.test.ts +++ b/packages/cli/src/services/__tests__/dynamic-node-parameters.service.test.ts @@ -30,8 +30,7 @@ import { DynamicNodeParametersService } from '../dynamic-node-parameters.service import { WorkflowLoaderService } from '../workflow-loader.service'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { NodeTypes } from '@/node-types'; import * as checkAccess from '@/permissions.ee/check-access'; diff --git a/packages/cli/src/services/__tests__/email-change.service.test.ts b/packages/cli/src/services/__tests__/email-change.service.test.ts index bee805bf907..52619a7f378 100644 --- a/packages/cli/src/services/__tests__/email-change.service.test.ts +++ b/packages/cli/src/services/__tests__/email-change.service.test.ts @@ -4,8 +4,7 @@ import type { AuthIdentity, User } from '@n8n/db'; import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error'; import type { MfaService } from '@/mfa/mfa.service'; import type { PasswordUtility } from '@/services/password.utility'; diff --git a/packages/cli/src/services/__tests__/engine-v2-dispatcher.service.test.ts b/packages/cli/src/services/__tests__/engine-v2-dispatcher.service.test.ts index b7ee5e5fba0..e5d6fb87f19 100644 --- a/packages/cli/src/services/__tests__/engine-v2-dispatcher.service.test.ts +++ b/packages/cli/src/services/__tests__/engine-v2-dispatcher.service.test.ts @@ -17,6 +17,7 @@ import { mock } from 'vitest-mock-extended'; import type { CredentialsPermissionChecker } from '@/executions/pre-execution-checks'; import type { ResumableExecution } from '@/interfaces'; import type { EngineDataPlaneProxyService } from '@/services/engine-data-plane-proxy.service'; +import { createExecutionIdV2 } from '@/executions/execution-id'; import { EngineV2Dispatcher } from '@/services/engine-v2-dispatcher.service'; import type { EngineV2PushRegistry } from '@/services/engine-v2-push-registry.service'; @@ -194,6 +195,37 @@ describe('EngineV2Dispatcher', () => { ); }); + it.each([ + ['a manual run', runData()], + ['an active trigger run', triggerRunData()], + ['a webhook run that answers on receipt', webhookRunData()], + ])('tells the data plane that nobody listens for %s', async (_name, data) => { + await dispatcher.start(data); + + expect(proxy.startExecution).toHaveBeenCalledWith( + expect.objectContaining({ responseExpectation: { kind: 'none' } }), + ); + }); + + it.each([ + ['lastNode', 'runEnd'], + ['responseNode', 'stepResponse'], + ] as const)( + 'runs under the caller id and expects %s to answer with %s', + async (responseMode, kind) => { + const executionId = createExecutionIdV2(); + + const started = await dispatcher.start( + webhookRunData(undefined, { engineV2Response: { executionId, responseMode } }), + ); + + expect(started).toBe(executionId); + expect(proxy.startExecution).toHaveBeenCalledWith( + expect.objectContaining({ executionId, responseExpectation: { kind } }), + ); + }, + ); + it('sends the workflow beside the graph, narrowed to what a read reports', async () => { const workflowData = workflow(); diff --git a/packages/cli/src/services/__tests__/engine-v2-webhook-responder.service.test.ts b/packages/cli/src/services/__tests__/engine-v2-webhook-responder.service.test.ts index 0bab9187437..2a719fe1edc 100644 --- a/packages/cli/src/services/__tests__/engine-v2-webhook-responder.service.test.ts +++ b/packages/cli/src/services/__tests__/engine-v2-webhook-responder.service.test.ts @@ -15,6 +15,9 @@ import { const TIMEOUT_MS = 50_000; +const runEnd = { kind: 'runEnd' } as const; +const stepResponse = { kind: 'stepResponse' } as const; + /** * Stands in for the receiver. Only `src/modules/engine-v2/**` may import * `@n8n/engine` at runtime, and the receiver's own suite covers the frame round @@ -80,22 +83,22 @@ describe('EngineV2WebhookResponder', () => { it('listens under the id the run is started with', async () => { const executionId = createExecutionIdV2(); - expect((await responder.waitForResponse(executionId)).executionId).toBe(executionId); + expect((await responder.waitForResponse(executionId, runEnd)).executionId).toBe(executionId); }); it('refuses to listen before the host hands over a receiver', async () => { - await expect(newResponder().waitForResponse(createExecutionIdV2())).rejects.toThrow( + await expect(newResponder().waitForResponse(createExecutionIdV2(), runEnd)).rejects.toThrow( 'without a receiver', ); }); it('refuses a run once it listens for as many as it can hold', async () => { for (let i = 0; i < MAX_PENDING_WEBHOOKS; i++) { - await responder.waitForResponse(createExecutionIdV2()); + await responder.waitForResponse(createExecutionIdV2(), runEnd); } // Refused before dispatch, so no run starts that nothing can answer. - await expect(responder.waitForResponse(createExecutionIdV2())).rejects.toThrow( + await expect(responder.waitForResponse(createExecutionIdV2(), runEnd)).rejects.toThrow( 'Try again later', ); }); @@ -104,18 +107,18 @@ describe('EngineV2WebhookResponder', () => { const pending = await Promise.all( Array.from( { length: MAX_PENDING_WEBHOOKS }, - async () => await responder.waitForResponse(createExecutionIdV2()), + async () => await responder.waitForResponse(createExecutionIdV2(), runEnd), ), ); pending[0].release(); - await expect(responder.waitForResponse(createExecutionIdV2())).resolves.toBeDefined(); + await expect(responder.waitForResponse(createExecutionIdV2(), runEnd)).resolves.toBeDefined(); }); it('leaves an unrelated pending run unaffected', async () => { - const other = await responder.waitForResponse(createExecutionIdV2()); - const pending = await responder.waitForResponse(createExecutionIdV2()); + const other = await responder.waitForResponse(createExecutionIdV2(), runEnd); + const pending = await responder.waitForResponse(createExecutionIdV2(), runEnd); deliver(endedResponse(pending.executionId)); @@ -128,7 +131,7 @@ describe('EngineV2WebhookResponder', () => { }); it('reports the step the run ended with', async () => { - const pending = await responder.waitForResponse(createExecutionIdV2()); + const pending = await responder.waitForResponse(createExecutionIdV2(), runEnd); deliver(endedResponse(pending.executionId)); @@ -139,7 +142,7 @@ describe('EngineV2WebhookResponder', () => { }); it('reports no last node when that step produced nothing', async () => { - const pending = await responder.waitForResponse(createExecutionIdV2()); + const pending = await responder.waitForResponse(createExecutionIdV2(), runEnd); deliver( endedResponse(pending.executionId, { @@ -151,7 +154,7 @@ describe('EngineV2WebhookResponder', () => { }); it('reports the response produced by the Respond node', async () => { - const pending = await responder.waitForResponse(createExecutionIdV2(), true); + const pending = await responder.waitForResponse(createExecutionIdV2(), stepResponse); deliver({ type: 'response', @@ -166,7 +169,7 @@ describe('EngineV2WebhookResponder', () => { }); it('restores a Buffer body the data plane sent as a base64 envelope', async () => { - const pending = await responder.waitForResponse(createExecutionIdV2(), true); + const pending = await responder.waitForResponse(createExecutionIdV2(), stepResponse); const bytes = Buffer.from([0x00, 0xff, 0x10]); const headers = { 'content-type': 'application/octet-stream', 'content-length': 3 }; @@ -189,7 +192,7 @@ describe('EngineV2WebhookResponder', () => { }); it('keeps the first terminal outcome', async () => { - const pending = await responder.waitForResponse(createExecutionIdV2(), true); + const pending = await responder.waitForResponse(createExecutionIdV2(), stepResponse); deliver({ type: 'response', @@ -201,21 +204,24 @@ describe('EngineV2WebhookResponder', () => { await expect(pending.settled).resolves.toMatchObject({ status: 'response' }); }); - it('ignores a Respond node result when the response mode waits for the last node', async () => { - const pending = await responder.waitForResponse(createExecutionIdV2()); + it.each([runEnd, { kind: 'none' } as const])( + 'ignores a Respond node result when the expectation is %j', + async (expectation) => { + const pending = await responder.waitForResponse(createExecutionIdV2(), expectation); - deliver({ - type: 'response', - executionId: pending.executionId, - payload: { body: { ignored: true }, headers: {}, statusCode: 200 }, - }); - deliver(endedResponse(pending.executionId)); + deliver({ + type: 'response', + executionId: pending.executionId, + payload: { body: { ignored: true }, headers: {}, statusCode: 200 }, + }); + deliver(endedResponse(pending.executionId)); - await expect(pending.settled).resolves.toMatchObject({ status: 'completed' }); - }); + await expect(pending.settled).resolves.toMatchObject({ status: 'completed' }); + }, + ); it('reports a failure with the node that caused it', async () => { - const pending = await responder.waitForResponse(createExecutionIdV2()); + const pending = await responder.waitForResponse(createExecutionIdV2(), runEnd); deliver( endedResponse(pending.executionId, { @@ -238,7 +244,7 @@ describe('EngineV2WebhookResponder', () => { }); it('reports a response failure without attributing it to a node', async () => { - const pending = await responder.waitForResponse(createExecutionIdV2()); + const pending = await responder.waitForResponse(createExecutionIdV2(), runEnd); deliver({ type: 'undeliverable', @@ -256,7 +262,7 @@ describe('EngineV2WebhookResponder', () => { const impatient = newResponder(1); impatient.useReceiver(fakeReceiver().receiver); - const pending = await impatient.waitForResponse(createExecutionIdV2()); + const pending = await impatient.waitForResponse(createExecutionIdV2(), runEnd); await vi.advanceTimersByTimeAsync(1); await expect(pending.settled).resolves.toEqual({ status: 'timeout', @@ -264,7 +270,7 @@ describe('EngineV2WebhookResponder', () => { }); it('drops later responses for a released run', async () => { - const pending = await responder.waitForResponse(createExecutionIdV2()); + const pending = await responder.waitForResponse(createExecutionIdV2(), runEnd); pending.release(); deliver(endedResponse(pending.executionId)); @@ -276,9 +282,9 @@ describe('EngineV2WebhookResponder', () => { it('refuses a second wait for the same execution', async () => { const executionId = createExecutionIdV2(); - await responder.waitForResponse(executionId); + await responder.waitForResponse(executionId, runEnd); - await expect(responder.waitForResponse(executionId)).rejects.toThrow( + await expect(responder.waitForResponse(executionId, runEnd)).rejects.toThrow( 'already waits for a response for this execution', ); }); @@ -316,10 +322,10 @@ describe('EngineV2WebhookResponder while the receiver subscribes', () => { responder.useReceiver(receiver); for (let i = 0; i < MAX_PENDING_WEBHOOKS; i++) { - void responder.waitForResponse(createExecutionIdV2()); + void responder.waitForResponse(createExecutionIdV2(), runEnd); } - await expect(responder.waitForResponse(createExecutionIdV2())).rejects.toThrow( + await expect(responder.waitForResponse(createExecutionIdV2(), runEnd)).rejects.toThrow( 'Try again later', ); }); @@ -330,14 +336,14 @@ describe('EngineV2WebhookResponder while the receiver subscribes', () => { responder.useReceiver(receiver); const executionId = createExecutionIdV2(); - const failed = responder.waitForResponse(executionId); + const failed = responder.waitForResponse(executionId, runEnd); await vi.waitFor(() => expect(subscriptions).toHaveLength(1)); subscriptions[0].reject(new Error('Redis is unavailable')); await expect(failed).rejects.toThrow('Redis is unavailable'); expect(vi.getTimerCount()).toBe(0); - const retried = responder.waitForResponse(executionId); + const retried = responder.waitForResponse(executionId, runEnd); await vi.waitFor(() => expect(subscriptions).toHaveLength(2)); subscriptions[1].resolve(() => {}); await expect(retried).resolves.toBeDefined(); @@ -351,7 +357,7 @@ describe('EngineV2WebhookResponder while the receiver subscribes', () => { const executionId = createExecutionIdV2(); let settled = false; - const waiting = responder.waitForResponse(executionId).finally(() => { + const waiting = responder.waitForResponse(executionId, runEnd).finally(() => { settled = true; }); const rejection = expect(waiting).rejects.toThrow(`within ${SUBSCRIBE_TIMEOUT_MS / 1000}s`); @@ -364,7 +370,7 @@ describe('EngineV2WebhookResponder while the receiver subscribes', () => { expect(vi.getTimerCount()).toBe(0); // The slot is free before the late subscription completes. - const retried = responder.waitForResponse(executionId); + const retried = responder.waitForResponse(executionId, runEnd); await vi.waitFor(() => expect(subscriptions).toHaveLength(2)); const unsubscribe = vi.fn(); diff --git a/packages/cli/src/services/__tests__/ownership.service.test.ts b/packages/cli/src/services/__tests__/ownership.service.test.ts index 6b760592863..daa54eb3eac 100644 --- a/packages/cli/src/services/__tests__/ownership.service.test.ts +++ b/packages/cli/src/services/__tests__/ownership.service.test.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { Project, @@ -18,13 +19,12 @@ import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; import { v4 as uuid } from 'uuid'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import type { EventService } from '@/events/event.service'; +import { BadRequestError } from '@n8n/errors'; import { OwnershipService } from '@/services/ownership.service'; import { PasswordUtility } from '@/services/password.utility'; import { mockCredential, mockProject } from '@test/mock-objects'; -import { CacheService } from '../cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; describe('OwnershipService', () => { const userRepository = mockInstance(UserRepository); diff --git a/packages/cli/src/services/__tests__/project.service.ee.test.ts b/packages/cli/src/services/__tests__/project.service.ee.test.ts index 0f3c6281ce1..4be13850c66 100644 --- a/packages/cli/src/services/__tests__/project.service.ee.test.ts +++ b/packages/cli/src/services/__tests__/project.service.ee.test.ts @@ -1,5 +1,6 @@ import type { ProjectRelation } from '@n8n/api-types'; import type { Logger, ModuleRegistry } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { type Project, type ProjectRepository, @@ -23,9 +24,7 @@ import { ProjectService } from '../project.service.ee'; import type { RoleService } from '../role.service'; import type { ICredentialConnectionStatusProvider } from '@/credentials/credential-connection-status-provider.interface'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import type { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import type { AgentChatAttachmentService } from '@/modules/agents/agent-chat-attachment.service'; import type { AgentExecutionService } from '@/modules/agents/agent-execution.service'; import type { AgentKnowledgeService } from '@/modules/agents/agent-knowledge.service'; diff --git a/packages/cli/src/services/__tests__/public-api-key.service.test.ts b/packages/cli/src/services/__tests__/public-api-key.service.test.ts index f434c41a677..3163df279fc 100644 --- a/packages/cli/src/services/__tests__/public-api-key.service.test.ts +++ b/packages/cli/src/services/__tests__/public-api-key.service.test.ts @@ -3,8 +3,7 @@ import type { ApiKey, ApiKeyRepository, User } from '@n8n/db'; import { hasGlobalScope } from '@n8n/permissions'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import type { UserManagementMailer } from '@/user-management/email'; import type { JwtService } from '../jwt.service'; diff --git a/packages/cli/src/services/__tests__/role-authorization.test.ts b/packages/cli/src/services/__tests__/role-authorization.test.ts index 51becdf94e2..94532bc0c27 100644 --- a/packages/cli/src/services/__tests__/role-authorization.test.ts +++ b/packages/cli/src/services/__tests__/role-authorization.test.ts @@ -2,7 +2,7 @@ import type { User } from '@n8n/db'; import type { Scope } from '@n8n/permissions'; import { mock } from 'vitest-mock-extended'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { assertCanManageRoleType } from '@/services/role-authorization'; describe('assertCanManageRoleType', () => { diff --git a/packages/cli/src/services/__tests__/role-cache.service.test.ts b/packages/cli/src/services/__tests__/role-cache.service.test.ts index 9f5276824fb..be5026a848d 100644 --- a/packages/cli/src/services/__tests__/role-cache.service.test.ts +++ b/packages/cli/src/services/__tests__/role-cache.service.test.ts @@ -6,7 +6,7 @@ import { staticRolesWithScope } from '@n8n/permissions'; import type { MockedFunction } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { RoleCacheService } from '@/services/role-cache.service'; // Mock static function diff --git a/packages/cli/src/services/__tests__/role.service.addScopes.test.ts b/packages/cli/src/services/__tests__/role.service.addScopes.test.ts index 9d7a9fa037a..d18e7803215 100644 --- a/packages/cli/src/services/__tests__/role.service.addScopes.test.ts +++ b/packages/cli/src/services/__tests__/role.service.addScopes.test.ts @@ -1,11 +1,11 @@ import type { LicenseState } from '@n8n/backend-common'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import type { CredentialsEntity, ScopesField, User } from '@n8n/db'; import { RoleRepository, ScopeRepository } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import { EventService } from '@/events/event.service'; import { RoleCacheService } from '@/services/role-cache.service'; import { RoleDeletionCheckProxy } from '@/services/role-deletion-check-proxy.service'; import { RoleService } from '@/services/role.service'; diff --git a/packages/cli/src/services/__tests__/role.service.assignments.test.ts b/packages/cli/src/services/__tests__/role.service.assignments.test.ts index 0292a3c6bfd..ca1352db4bd 100644 --- a/packages/cli/src/services/__tests__/role.service.assignments.test.ts +++ b/packages/cli/src/services/__tests__/role.service.assignments.test.ts @@ -1,11 +1,11 @@ import type { LicenseState } from '@n8n/backend-common'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { RoleRepository, ScopeRepository } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { NotFoundError } from '@n8n/errors'; import { RoleCacheService } from '@/services/role-cache.service'; import { RoleDeletionCheckProxy } from '@/services/role-deletion-check-proxy.service'; import { RoleService } from '@/services/role.service'; diff --git a/packages/cli/src/services/__tests__/role.service.customRoles.test.ts b/packages/cli/src/services/__tests__/role.service.customRoles.test.ts index 4d071b2e275..1022125bc78 100644 --- a/packages/cli/src/services/__tests__/role.service.customRoles.test.ts +++ b/packages/cli/src/services/__tests__/role.service.customRoles.test.ts @@ -1,14 +1,14 @@ import type { CreateRoleDto, UpdateRoleDto } from '@n8n/api-types'; import type { LicenseState } from '@n8n/backend-common'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import type { Role, Scope as DBScope } from '@n8n/db'; import { RoleRepository, ScopeRepository } from '@n8n/db'; import { MANDATORY_INSTANCE_SCOPES } from '@n8n/permissions'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError } from '@n8n/errors'; import { RoleCacheService } from '@/services/role-cache.service'; import { RoleDeletionCheckProxy } from '@/services/role-deletion-check-proxy.service'; import { RoleService } from '@/services/role.service'; diff --git a/packages/cli/src/services/__tests__/role.service.rolesWithScope.test.ts b/packages/cli/src/services/__tests__/role.service.rolesWithScope.test.ts index 11c0065122e..262d302c780 100644 --- a/packages/cli/src/services/__tests__/role.service.rolesWithScope.test.ts +++ b/packages/cli/src/services/__tests__/role.service.rolesWithScope.test.ts @@ -1,10 +1,10 @@ import type { LicenseState } from '@n8n/backend-common'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { RoleRepository, ScopeRepository } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import { EventService } from '@/events/event.service'; import { RoleCacheService } from '@/services/role-cache.service'; import { RoleDeletionCheckProxy } from '@/services/role-deletion-check-proxy.service'; import { RoleService } from '@/services/role.service'; diff --git a/packages/cli/src/services/__tests__/security-settings.service.test.ts b/packages/cli/src/services/__tests__/security-settings.service.test.ts index 94f86026e02..81c1aa666fa 100644 --- a/packages/cli/src/services/__tests__/security-settings.service.test.ts +++ b/packages/cli/src/services/__tests__/security-settings.service.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { SettingsRepository, @@ -10,7 +11,6 @@ import { PERSONAL_SPACE_SHARING_SETTING, } from '@n8n/permissions'; -import { EventService } from '@/events/event.service'; import { InstanceRedactionEnforcementService } from '@/modules/redaction/instance-redaction-enforcement.service'; import { RoleService } from '@/services/role.service'; import { SecuritySettingsService } from '@/services/security-settings.service'; diff --git a/packages/cli/src/services/__tests__/user.service.test.ts b/packages/cli/src/services/__tests__/user.service.test.ts index fd700db60e3..d99076537c9 100644 --- a/packages/cli/src/services/__tests__/user.service.test.ts +++ b/packages/cli/src/services/__tests__/user.service.test.ts @@ -1,4 +1,5 @@ import { InviteUsersRequestDto } from '@n8n/api-types'; +import { type EventService, UrlService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { @@ -19,14 +20,10 @@ import { v4 as uuid } from 'uuid'; import { mock } from 'vitest-mock-extended'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; import type { ExternalHooks } from '@/external-hooks'; import type { License } from '@/license'; import type { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; -import { UrlService } from '@n8n/backend-services'; import { UserService } from '@/services/user.service'; import * as ssoHelpers from '@/sso.ee/sso-helpers'; import type { UserManagementMailer } from '@/user-management/email'; diff --git a/packages/cli/src/services/__tests__/workflow-statistics.service.integration.test.ts b/packages/cli/src/services/__tests__/workflow-statistics.service.integration.test.ts index 5f4246986d5..21dd6cc6a1a 100644 --- a/packages/cli/src/services/__tests__/workflow-statistics.service.integration.test.ts +++ b/packages/cli/src/services/__tests__/workflow-statistics.service.integration.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import type { Mock } from 'vitest'; import { getPersonalProject, @@ -27,7 +28,6 @@ import { type WorkflowExecutionSource, } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; import { INSTANCE_ACTIVATED_SETTINGS_KEY } from '@/services/instance-activation.service'; import { OwnershipService } from '@/services/ownership.service'; import { UserService } from '@/services/user.service'; diff --git a/packages/cli/src/services/active-workflows.service.ts b/packages/cli/src/services/active-workflows.service.ts index 232449f05a5..b81682ad51b 100644 --- a/packages/cli/src/services/active-workflows.service.ts +++ b/packages/cli/src/services/active-workflows.service.ts @@ -5,7 +5,7 @@ import { Service } from '@n8n/di'; import { hasGlobalScope } from '@n8n/permissions'; import { ActivationErrorsService } from '@/activation-errors.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; diff --git a/packages/cli/src/services/ai-gateway.service.ts b/packages/cli/src/services/ai-gateway.service.ts index 0debdd3e27f..b9f9c9a254a 100644 --- a/packages/cli/src/services/ai-gateway.service.ts +++ b/packages/cli/src/services/ai-gateway.service.ts @@ -16,7 +16,7 @@ import { OperationalError, UserError } from 'n8n-workflow'; import { N8N_VERSION, AI_ASSISTANT_SDK_VERSION } from '@/constants'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { License } from '@/license'; import { checkAiGatewayEligibility } from '@/services/ai-gateway-eligibility'; import { OwnershipService } from '@/services/ownership.service'; diff --git a/packages/cli/src/services/ai-preference-write.ts b/packages/cli/src/services/ai-preference-write.ts index 3aaaef20569..95c642c2a41 100644 --- a/packages/cli/src/services/ai-preference-write.ts +++ b/packages/cli/src/services/ai-preference-write.ts @@ -3,10 +3,8 @@ import type { Logger } from '@n8n/backend-common'; import type { User } from '@n8n/db'; import { TELEMETRY_EVENT } from '@n8n/telemetry'; -import { ResponseError } from '@/errors/response-errors/abstract/response.error'; +import { ResponseError, ConflictError, ForbiddenError } from '@n8n/errors'; import { AiPreferenceScopeFullError } from '@/errors/response-errors/ai-preference-scope-full.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import type { AiPreferenceService } from '@/services/ai-preference.service'; import type { Telemetry } from '@/telemetry'; diff --git a/packages/cli/src/services/ai-preference.service.ts b/packages/cli/src/services/ai-preference.service.ts index 67ecb5224ef..8de5c8d49ca 100644 --- a/packages/cli/src/services/ai-preference.service.ts +++ b/packages/cli/src/services/ai-preference.service.ts @@ -22,10 +22,7 @@ import { hasGlobalScope } from '@n8n/permissions'; import { randomUUID } from 'node:crypto'; import { AiPreferenceScopeFullError } from '@/errors/response-errors/ai-preference-scope-full.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '@n8n/errors'; /** `type` lets the prompt name a personal project without its owner's name. */ export type AiPreferenceProjectRef = { id: string; name: string; type?: Project['type'] }; diff --git a/packages/cli/src/services/ai-usage.service.ts b/packages/cli/src/services/ai-usage.service.ts index 1436936a2ac..88be621005b 100644 --- a/packages/cli/src/services/ai-usage.service.ts +++ b/packages/cli/src/services/ai-usage.service.ts @@ -2,7 +2,7 @@ import { SettingsRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import config from '@/config'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; const KEY = 'ai.allowSendingParameterValues'; diff --git a/packages/cli/src/services/dynamic-node-parameters.service.ts b/packages/cli/src/services/dynamic-node-parameters.service.ts index 52affc0b931..4d3b651dadf 100644 --- a/packages/cli/src/services/dynamic-node-parameters.service.ts +++ b/packages/cli/src/services/dynamic-node-parameters.service.ts @@ -29,8 +29,7 @@ import { } from 'n8n-workflow'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { NodeTypes } from '@/node-types'; import { userHasScopes } from '@/permissions.ee/check-access'; import { withExpressionIsolate } from '@/utils'; diff --git a/packages/cli/src/services/email-change.service.ts b/packages/cli/src/services/email-change.service.ts index 264f6b934d2..4ebe702d1e2 100644 --- a/packages/cli/src/services/email-change.service.ts +++ b/packages/cli/src/services/email-change.service.ts @@ -3,8 +3,7 @@ import { GlobalConfig } from '@n8n/config'; import type { AuthIdentity, User } from '@n8n/db'; import { Service } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error'; import { MfaService } from '@/mfa/mfa.service'; import { PasswordUtility } from '@/services/password.utility'; diff --git a/packages/cli/src/services/engine-v2-dispatcher.service.ts b/packages/cli/src/services/engine-v2-dispatcher.service.ts index f56ec0b568c..6640f94ddd5 100644 --- a/packages/cli/src/services/engine-v2-dispatcher.service.ts +++ b/packages/cli/src/services/engine-v2-dispatcher.service.ts @@ -16,6 +16,7 @@ import { CredentialsPermissionChecker } from '@/executions/pre-execution-checks' import type { ResumableExecution } from '@/interfaces'; import { EngineDataPlaneProxyService } from '@/services/engine-data-plane-proxy.service'; import { EngineV2PushRegistry } from '@/services/engine-v2-push-registry.service'; +import { toResponseExpectation } from '@/webhooks/engine-v2-response-expectation'; type ToStepOutputs = (outputs: INodeExecutionData[][]) => StepSlots; @@ -104,7 +105,7 @@ export class EngineV2Dispatcher { const graph = new V1WorkflowConverter().convert(workflowData, trigger.name); - const executionId = data.engineExecutionId ?? createExecutionIdV2(); + const executionId = data.engineV2Response?.executionId ?? createExecutionIdV2(); // A caller that minted the id is waiting on that exact run. assert(isExecutionIdV2(executionId), 'Engine v2 was given an id it cannot run'); // At the session cap this can evict another run's session, uncaught below. Rare; not worth fixing. @@ -128,6 +129,7 @@ export class EngineV2Dispatcher { userId: data.userId, projectId: data.projectId, }, + responseExpectation: toResponseExpectation(data.engineV2Response?.responseMode), }); } catch (error) { // Assumes rejection: a dropped success response also releases a still-live session. diff --git a/packages/cli/src/services/engine-v2-webhook-responder.service.ts b/packages/cli/src/services/engine-v2-webhook-responder.service.ts index 9bd628365c3..fcba539be6a 100644 --- a/packages/cli/src/services/engine-v2-webhook-responder.service.ts +++ b/packages/cli/src/services/engine-v2-webhook-responder.service.ts @@ -1,7 +1,7 @@ import { Logger } from '@n8n/backend-common'; import { EngineConfig } from '@n8n/config'; import { Service } from '@n8n/di'; -import type { EndedMessage, ExecutionResponse } from '@n8n/engine'; +import type { EndedMessage, ExecutionResponse, ResponseExpectation } from '@n8n/engine'; import { decodeBufferBody } from 'n8n-core'; import { OperationalError, UnexpectedError } from 'n8n-workflow'; @@ -61,6 +61,8 @@ export class EngineV2WebhookResponder { * * @param executionId The caller must mint this ID, use it here, and pass it to * `StartExecution`. + * @param expectation What the request waits for. Pass the same value to + * `StartExecution`. * @throws {UnexpectedError} If the execution response receiver is not set, or * if the service already waits for this execution. * @throws {OperationalError} If the service is at capacity, or if it cannot @@ -68,7 +70,7 @@ export class EngineV2WebhookResponder { */ async waitForResponse( executionId: ExecutionIdV2, - acceptsResponse = false, + expectation: ResponseExpectation, ): Promise { const { receiver } = this; if (!receiver) { @@ -90,7 +92,7 @@ export class EngineV2WebhookResponder { const response = new PendingWebhookResponse({ executionId, - acceptsResponse, + expectation, timeoutMs: this.engineConfig.webhookResponseTimeout, onRelease: (id) => this.release(id), }); diff --git a/packages/cli/src/services/folder.service.ts b/packages/cli/src/services/folder.service.ts index 469f38674cc..58d53d450e8 100644 --- a/packages/cli/src/services/folder.service.ts +++ b/packages/cli/src/services/folder.service.ts @@ -1,4 +1,5 @@ import type { CreateFolderDto, DeleteFolderDto, UpdateFolderDto } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import type { FolderWithWorkflowAndSubFolderCount, FolderWithWorkflowAndSubFolderCountAndPath, @@ -10,7 +11,6 @@ import type { EntityManager } from '@n8n/typeorm'; import { UserError, PROJECT_ROOT } from 'n8n-workflow'; import { FolderNotFoundError } from '@/errors/folder-not-found.error'; -import { EventService } from '@/events/event.service'; import type { ListQuery } from '@/requests'; import { WorkflowService } from '@/workflows/workflow.service'; diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts index 7599ba17485..25df754f004 100644 --- a/packages/cli/src/services/import.service.ts +++ b/packages/cli/src/services/import.service.ts @@ -173,11 +173,14 @@ export class ImportService { let cleared: PolicyCleared<'contentImport'>; try { - cleared = await this.policyEnforcementService.enforceContentImport({ - workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes }, - projectId: policyProjectId, - transport: 'cli', - }); + cleared = await this.policyEnforcementService.enforceContentImport( + { + workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes }, + projectId: policyProjectId, + transport: 'cli', + }, + { kind: 'system', reason: 'cli-import' }, + ); } catch (error) { // A blocked workflow is skipped, not fatal — the operator gets the rest of the batch. // A check that broke is not scoped to one workflow, so it fails the whole import diff --git a/packages/cli/src/services/ownership.service.ts b/packages/cli/src/services/ownership.service.ts index b0ba9f07fda..39701960b7a 100644 --- a/packages/cli/src/services/ownership.service.ts +++ b/packages/cli/src/services/ownership.service.ts @@ -1,5 +1,6 @@ import { OwnerSetupRequestDto } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { CacheService, EventService } from '@n8n/backend-services'; import type { ListQueryDb } from '@n8n/db'; import { GLOBAL_OWNER_ROLE, @@ -18,9 +19,7 @@ import { IsNull } from '@n8n/typeorm/find-options/operator/IsNull'; import { Not } from '@n8n/typeorm/find-options/operator/Not'; import config from '@/config'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { EventService } from '@/events/event.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { BadRequestError } from '@n8n/errors'; import { PasswordUtility } from './password.utility'; diff --git a/packages/cli/src/services/pending-webhook-response.ts b/packages/cli/src/services/pending-webhook-response.ts index fde9cd5e1c8..6e9f324398e 100644 --- a/packages/cli/src/services/pending-webhook-response.ts +++ b/packages/cli/src/services/pending-webhook-response.ts @@ -1,4 +1,4 @@ -import type { StepSlots } from '@n8n/engine'; +import type { ResponseExpectation, StepSlots } from '@n8n/engine'; import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; import type { ExecutionIdV2 } from '@/executions/execution-id'; @@ -18,8 +18,12 @@ export type WebhookRunOutcome = export type PendingWebhookResponseOptions = { /** The run whose answer this handle carries. */ executionId: ExecutionIdV2; - /** Whether a Respond to Webhook result answers this request. */ - acceptsResponse: boolean; + /** + * What the request waits for. Only `stepResponse` lets a Respond to Webhook + * result answer it. The engine obeys this too, so this filter is a second + * defense. + */ + expectation: ResponseExpectation; /** How long to hold the request before it is answered without the run. */ timeoutMs: number; /** Called once the answer is no longer needed, so the listener can go. */ @@ -36,7 +40,7 @@ export class PendingWebhookResponse { readonly executionId: ExecutionIdV2; - private readonly acceptsResponse: boolean; + private readonly expectation: ResponseExpectation; private readonly onRelease: (executionId: string) => void; @@ -45,14 +49,9 @@ export class PendingWebhookResponse { /** Held so an answered run does not leave a timer behind for the whole hold. */ private readonly timer: NodeJS.Timeout; - constructor({ - executionId, - acceptsResponse, - timeoutMs, - onRelease, - }: PendingWebhookResponseOptions) { + constructor({ executionId, expectation, timeoutMs, onRelease }: PendingWebhookResponseOptions) { this.executionId = executionId; - this.acceptsResponse = acceptsResponse; + this.expectation = expectation; this.onRelease = onRelease; this.settled = this.answer.promise; this.timer = setTimeout(() => this.answer.resolve({ status: 'timeout' }), timeoutMs).unref(); @@ -63,7 +62,9 @@ export class PendingWebhookResponse { } resolveResponse(response: unknown): void { - if (this.acceptsResponse) this.answer.resolve({ status: 'response', response }); + if (this.expectation.kind === 'stepResponse') { + this.answer.resolve({ status: 'response', response }); + } } release(): void { diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index 6ac9b2cc6e7..c52d0f4c1d4 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -1,5 +1,6 @@ import type { CreateProjectDto, ProjectType, UpdateProjectDto } from '@n8n/api-types'; import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { type User, FolderRepository, @@ -32,11 +33,7 @@ import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm'; import { In } from '@n8n/typeorm'; import { UserError } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '@n8n/errors'; import { UserManagementMailer } from '@/user-management/email'; import { OwnershipService } from './ownership.service'; @@ -561,11 +558,13 @@ export class ProjectService { return await this.projectRepository.getPersonalProjectForUser(user.id); } + /** + * The user's project relations with project, role and role scopes. This runs on + * most editor requests (scope resolution for lists and single resources), so the + * repository keeps the row count proportional to the number of relations. + */ async getProjectRelationsForUser(user: User): Promise { - return await this.projectRelationRepository.find({ - where: { userId: user.id }, - relations: ['project', 'role'], - }); + return await this.projectRelationRepository.findAllByUser(user.id, { withProject: true }); } async syncProjectRelations( diff --git a/packages/cli/src/services/pruning/__tests__/workflow-history-compaction.service.test.ts b/packages/cli/src/services/pruning/__tests__/workflow-history-compaction.service.test.ts index a87f438b77a..d24b856d6d8 100644 --- a/packages/cli/src/services/pruning/__tests__/workflow-history-compaction.service.test.ts +++ b/packages/cli/src/services/pruning/__tests__/workflow-history-compaction.service.test.ts @@ -1,3 +1,4 @@ +import type { EventService } from '@n8n/backend-services'; import { mockLogger } from '@n8n/backend-test-utils'; import type { GlobalConfig, WorkflowHistoryCompactionConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; @@ -5,8 +6,6 @@ import type { DbConnection, WorkflowHistoryRepository } from '@n8n/db'; import type { InstanceSettings } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; - import { getCompactionWindowDeltas, WorkflowHistoryCompactionService, diff --git a/packages/cli/src/services/pruning/workflow-history-compaction.service.ts b/packages/cli/src/services/pruning/workflow-history-compaction.service.ts index e063be7c775..d335c22250c 100644 --- a/packages/cli/src/services/pruning/workflow-history-compaction.service.ts +++ b/packages/cli/src/services/pruning/workflow-history-compaction.service.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig, WorkflowHistoryCompactionConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { DbConnection, WorkflowHistoryRepository } from '@n8n/db'; @@ -10,7 +11,6 @@ import { sleep } from '@n8n/utils/sleep'; import { DiffMetaData, DiffRule, RULES, SKIP_RULES } from 'n8n-workflow'; import { strict } from 'node:assert'; -import { EventService } from '@/events/event.service'; import { RelayEventMap } from '@/events/maps/relay.event-map'; export function getCompactionWindowDeltas(minimumAge: number, timeWindow: number, unitMs: number) { diff --git a/packages/cli/src/services/public-api-key.service.ts b/packages/cli/src/services/public-api-key.service.ts index 131b027c05c..8fc36453038 100644 --- a/packages/cli/src/services/public-api-key.service.ts +++ b/packages/cli/src/services/public-api-key.service.ts @@ -21,8 +21,7 @@ import { } from '@n8n/typeorm'; import { randomUUID } from 'crypto'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { UserManagementMailer } from '@/user-management/email'; import { JwtService } from './jwt.service'; diff --git a/packages/cli/src/services/role-authorization.ts b/packages/cli/src/services/role-authorization.ts index 9212d263612..6bf6e370e4b 100644 --- a/packages/cli/src/services/role-authorization.ts +++ b/packages/cli/src/services/role-authorization.ts @@ -2,7 +2,7 @@ import type { User } from '@n8n/db'; import { getAuthPrincipalScopes, type Role as RoleDTO, type RoleNamespace } from '@n8n/permissions'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; /** * Whether a caller can manage a role requires `role:manage`; `role:manageProject` grants it for diff --git a/packages/cli/src/services/role-cache.service.ts b/packages/cli/src/services/role-cache.service.ts index 43dd151a706..1aabf3dc95d 100644 --- a/packages/cli/src/services/role-cache.service.ts +++ b/packages/cli/src/services/role-cache.service.ts @@ -5,7 +5,7 @@ import type { EntityManager } from '@n8n/db'; import { Container, Service } from '@n8n/di'; import { staticRolesWithScope, type Scope } from '@n8n/permissions'; -import { CacheService } from './cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; type RoleInfo = { scopes: string[]; // array of scope slugs diff --git a/packages/cli/src/services/role.service.ts b/packages/cli/src/services/role.service.ts index 3e5f34fe106..5cbfe62fc2d 100644 --- a/packages/cli/src/services/role.service.ts +++ b/packages/cli/src/services/role.service.ts @@ -5,6 +5,7 @@ import type { } from '@n8n/api-types'; import { CreateRoleDto } from '@n8n/api-types'; import { LicenseState, Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { CredentialsEntity, SharedCredentials, @@ -41,9 +42,7 @@ import { } from '@n8n/permissions'; import { UnexpectedError, UserError } from 'n8n-workflow'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { isUniqueConstraintError } from '@/response-helper'; import { RoleCacheService } from './role-cache.service'; diff --git a/packages/cli/src/services/sandbox-settings.service.ts b/packages/cli/src/services/sandbox-settings.service.ts index 228635a43b3..ae3bce9cccc 100644 --- a/packages/cli/src/services/sandbox-settings.service.ts +++ b/packages/cli/src/services/sandbox-settings.service.ts @@ -13,7 +13,7 @@ import { type InstanceCredentialUse, type ResolvedInstanceCredential, } from '@/credentials/instance-credential-broker'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { UnprocessableRequestError } from '@n8n/errors'; const N8N_SANDBOX_HEADER_NAME = 'x-api-key'; diff --git a/packages/cli/src/services/security-settings.service.ts b/packages/cli/src/services/security-settings.service.ts index 71c4b14df77..76d92cc03f7 100644 --- a/packages/cli/src/services/security-settings.service.ts +++ b/packages/cli/src/services/security-settings.service.ts @@ -1,4 +1,5 @@ import type { RedactionFloor } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import { SettingsRepository, SharedCredentialsRepository, @@ -12,7 +13,6 @@ import { } from '@n8n/permissions'; import type { DistributiveOmit } from '@n8n/utils/types'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap, UserLike } from '@/events/maps/relay.event-map'; import { InstanceRedactionEnforcementService } from '@/modules/redaction/instance-redaction-enforcement.service'; import { RoleService } from '@/services/role.service'; diff --git a/packages/cli/src/services/session-cookie-auth.strategy.ts b/packages/cli/src/services/session-cookie-auth.strategy.ts index 66d4db01dc3..4aaf65540cd 100644 --- a/packages/cli/src/services/session-cookie-auth.strategy.ts +++ b/packages/cli/src/services/session-cookie-auth.strategy.ts @@ -5,7 +5,7 @@ import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; import { AuthService } from '@/auth/auth.service'; import { AUTH_COOKIE_NAME } from '@/constants'; -import { AuthError } from '@/errors/response-errors/auth.error'; +import { AuthError } from '@n8n/errors'; import type { AuthStrategy, AuthStrategyOptions } from './auth-strategy.types'; diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 7714031f95f..21e7585154e 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -1,5 +1,6 @@ import type { RoleChangeRequestDto } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService, UrlService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import type { PublicUser } from '@n8n/db'; import { @@ -36,17 +37,12 @@ import { PublicApiKeyService } from './public-api-key.service'; import { RoleService } from './role.service'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError } from '@n8n/errors'; import { ExternalHooks } from '@/external-hooks'; import type { Invitation } from '@/interfaces'; import { License } from '@/license'; import { PostHogClient } from '@/posthog'; import type { UserRequest } from '@/requests'; -import { UrlService } from '@n8n/backend-services'; import { isSsoCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers'; import { UserManagementMailer } from '@/user-management/email'; diff --git a/packages/cli/src/services/workflow-statistics.service.ts b/packages/cli/src/services/workflow-statistics.service.ts index 1c3b2512a04..9d156bf4196 100644 --- a/packages/cli/src/services/workflow-statistics.service.ts +++ b/packages/cli/src/services/workflow-statistics.service.ts @@ -1,4 +1,5 @@ import { Logger, TypedEmitter } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { DatabaseConfig } from '@n8n/config'; import type { CrashedExecution } from '@n8n/db'; import { @@ -19,7 +20,6 @@ import { type WorkflowExecutionSource, } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; import { UserService } from '@/services/user.service'; import { isBillableExecution } from '@/utils/is-billable-execution'; diff --git a/packages/cli/src/sso.ee/sso-helpers.ts b/packages/cli/src/sso.ee/sso-helpers.ts index 2b146a462ee..4b3fc5c575f 100644 --- a/packages/cli/src/sso.ee/sso-helpers.ts +++ b/packages/cli/src/sso.ee/sso-helpers.ts @@ -4,7 +4,7 @@ import { isAuthProviderType, SettingsRepository, type AuthProviderType } from '@ import { Container } from '@n8n/di'; import config from '@/config'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; /** * Only one authentication method can be active at a time. This function sets diff --git a/packages/cli/src/task-runners/__tests__/task-runner-module.test.ts b/packages/cli/src/task-runners/__tests__/task-runner-module.test.ts index 73dad81ded1..b63bc7839d1 100644 --- a/packages/cli/src/task-runners/__tests__/task-runner-module.test.ts +++ b/packages/cli/src/task-runners/__tests__/task-runner-module.test.ts @@ -1,9 +1,9 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { TaskRunnersConfig } from '@n8n/config'; import type { ErrorReporter } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; import type { TaskBrokerServer } from '@/task-runners/task-broker/task-broker-server'; import type { JsTaskRunnerProcess } from '@/task-runners/task-runner-process-js'; import type { PyTaskRunnerProcess } from '@/task-runners/task-runner-process-py'; diff --git a/packages/cli/src/task-runners/task-broker/__tests__/task-broker-ws-server.test.ts b/packages/cli/src/task-runners/task-broker/__tests__/task-broker-ws-server.test.ts index 544219057f8..d3f65c1b49d 100644 --- a/packages/cli/src/task-runners/task-broker/__tests__/task-broker-ws-server.test.ts +++ b/packages/cli/src/task-runners/task-broker/__tests__/task-broker-ws-server.test.ts @@ -1,4 +1,5 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { GlobalConfig, TaskRunnersConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { mock } from 'vitest-mock-extended'; @@ -8,7 +9,6 @@ import { ShutdownMetadata } from '@n8n/decorators'; import { Container } from '@n8n/di'; import { HIGHEST_SHUTDOWN_PRIORITY, WsStatusCodes } from '@/constants'; -import type { EventService } from '@/events/event.service'; import type { DefaultTaskRunnerDisconnectAnalyzer } from '@/task-runners/default-task-runner-disconnect-analyzer'; import { SHUTDOWN_TASK_BUDGET_RATIO, diff --git a/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.controller.test.ts b/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.controller.test.ts index dec7304afb9..dbb789e0d33 100644 --- a/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.controller.test.ts +++ b/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.controller.test.ts @@ -1,10 +1,9 @@ import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; -import { BadRequestError } from '../../../../errors/response-errors/bad-request.error'; -import { ForbiddenError } from '../../../../errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import type { AuthlessRequest } from '../../../../requests'; import { TaskBrokerAuthController } from '../task-broker-auth.controller'; import { TaskBrokerAuthService } from '../task-broker-auth.service'; diff --git a/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.service.test.ts b/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.service.test.ts index 031881b0f3f..5f50cc52ba1 100644 --- a/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.service.test.ts +++ b/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.service.test.ts @@ -4,7 +4,7 @@ import { Time } from '@n8n/constants'; import { sleep } from '@n8n/utils/sleep'; import config from '@/config'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { retryUntil } from '@test-integration/retry-until'; import { TaskBrokerAuthService } from '../task-broker-auth.service'; diff --git a/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.controller.ts b/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.controller.ts index c60cf2440c4..b0dda748970 100644 --- a/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.controller.ts +++ b/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.controller.ts @@ -4,8 +4,7 @@ import type { AuthlessRequest } from '@/requests'; import { bearerTokenSchema, taskBrokerAuthRequestBodySchema } from './task-broker-auth.schema'; import { TaskBrokerAuthService } from './task-broker-auth.service'; -import { BadRequestError } from '../../../errors/response-errors/bad-request.error'; -import { ForbiddenError } from '../../../errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; /** * Controller responsible for authenticating Task Runner connections diff --git a/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.service.ts b/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.service.ts index ba133caf0ad..2aff4401cbc 100644 --- a/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.service.ts +++ b/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.service.ts @@ -3,7 +3,7 @@ import { Time } from '@n8n/constants'; import { Service } from '@n8n/di'; import { randomBytes, timingSafeEqual } from 'crypto'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; /** * Cached in place of a runner ID for a grant token minted without one bound. diff --git a/packages/cli/src/task-runners/task-broker/task-broker-ws-server.ts b/packages/cli/src/task-runners/task-broker/task-broker-ws-server.ts index 46234799f37..c8cf23fbc9f 100644 --- a/packages/cli/src/task-runners/task-broker/task-broker-ws-server.ts +++ b/packages/cli/src/task-runners/task-broker/task-broker-ws-server.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig, TaskRunnersConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { OnShutdown } from '@n8n/decorators'; @@ -9,7 +10,6 @@ import { jsonStringify, UserError } from 'n8n-workflow'; import type WebSocket from 'ws'; import { HIGHEST_SHUTDOWN_PRIORITY, WsStatusCodes } from '@/constants'; -import { EventService } from '@/events/event.service'; import { DefaultTaskRunnerDisconnectAnalyzer } from '@/task-runners/default-task-runner-disconnect-analyzer'; import type { DisconnectAnalyzer, diff --git a/packages/cli/src/task-runners/task-managers/__tests__/task-manager.test.ts b/packages/cli/src/task-runners/task-managers/__tests__/task-manager.test.ts index 9386df5a011..881ee4183c8 100644 --- a/packages/cli/src/task-runners/task-managers/__tests__/task-manager.test.ts +++ b/packages/cli/src/task-runners/task-managers/__tests__/task-manager.test.ts @@ -1,10 +1,10 @@ +import type { EventService } from '@n8n/backend-services'; import type { GlobalConfig, TaskRunnersConfig } from '@n8n/config'; import get from 'lodash/get'; import set from 'lodash/set'; import type { ErrorReporter } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; import type { NodeTypes } from '@/node-types'; import { TaskCancelledError } from '@/task-runners/errors/task-cancelled.error'; import type { Task } from '@/task-runners/task-managers/task-requester'; diff --git a/packages/cli/src/task-runners/task-managers/local-task-requester.ts b/packages/cli/src/task-runners/task-managers/local-task-requester.ts index dd088885129..91d5b6e9aa0 100644 --- a/packages/cli/src/task-runners/task-managers/local-task-requester.ts +++ b/packages/cli/src/task-runners/task-managers/local-task-requester.ts @@ -1,9 +1,9 @@ +import { EventService } from '@n8n/backend-services'; import { GlobalConfig, TaskRunnersConfig } from '@n8n/config'; import { Container, Service } from '@n8n/di'; import type { RequesterMessage } from '@n8n/task-runner'; import { ErrorReporter } from 'n8n-core'; -import { EventService } from '@/events/event.service'; import { NodeTypes } from '@/node-types'; import { TaskBroker } from '@/task-runners/task-broker/task-broker.service'; diff --git a/packages/cli/src/task-runners/task-managers/task-requester.ts b/packages/cli/src/task-runners/task-managers/task-requester.ts index 3a44d4f7ca5..7bf6f101810 100644 --- a/packages/cli/src/task-runners/task-managers/task-requester.ts +++ b/packages/cli/src/task-runners/task-managers/task-requester.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { GlobalConfig, TaskRunnersConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import type { TaskResultData, RequesterMessage, BrokerMessage, TaskData } from '@n8n/task-runner'; @@ -20,7 +21,6 @@ import type { } from 'n8n-workflow'; import { nanoid } from 'nanoid'; -import { EventService } from '@/events/event.service'; import { NodeTypes } from '@/node-types'; import { TaskCancelledError } from '@/task-runners/errors/task-cancelled.error'; import { TaskRequestTimeoutError } from '@/task-runners/errors/task-request-timeout.error'; diff --git a/packages/cli/src/task-runners/task-runner-module.ts b/packages/cli/src/task-runners/task-runner-module.ts index f3a57c7c64a..9c41fb82702 100644 --- a/packages/cli/src/task-runners/task-runner-module.ts +++ b/packages/cli/src/task-runners/task-runner-module.ts @@ -1,4 +1,5 @@ import { inTest, Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { TaskRunnersConfig } from '@n8n/config'; import { OnShutdown } from '@n8n/decorators'; import { Container, Service } from '@n8n/di'; @@ -6,7 +7,6 @@ import { sleep } from '@n8n/utils/sleep'; import { ErrorReporter } from 'n8n-core'; import * as a from 'node:assert/strict'; -import { EventService } from '@/events/event.service'; import type { TaskRunnerRestartLoopError } from '@/task-runners/errors/task-runner-restart-loop-error'; import { TaskBrokerWsServer } from '@/task-runners/task-broker/task-broker-ws-server'; import type { JsTaskRunnerProcess } from '@/task-runners/task-runner-process-js'; diff --git a/packages/cli/src/types/user-like.types.ts b/packages/cli/src/types/user-like.types.ts new file mode 100644 index 00000000000..172f3a639fc --- /dev/null +++ b/packages/cli/src/types/user-like.types.ts @@ -0,0 +1,10 @@ +/** The user fields an event or a policy actor carries. Only `id` is guaranteed. */ +export type UserLike = { + id: string; + email?: string; + firstName?: string; + lastName?: string; + role?: { + slug: string; + }; +}; diff --git a/packages/cli/src/user-management/email/user-management-mailer.ts b/packages/cli/src/user-management/email/user-management-mailer.ts index 1d3e26c5bf8..3f57346da65 100644 --- a/packages/cli/src/user-management/email/user-management-mailer.ts +++ b/packages/cli/src/user-management/email/user-management-mailer.ts @@ -1,4 +1,5 @@ import { inTest, Logger } from '@n8n/backend-common'; +import { EventService, UrlService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import type { ApiKey, User } from '@n8n/db'; import { UserRepository } from '@n8n/db'; @@ -19,10 +20,8 @@ import type { } from './interfaces'; import { NodeMailer } from './node-mailer'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { EventService } from '@/events/event.service'; +import { InternalServerError } from '@n8n/errors'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; -import { UrlService } from '@n8n/backend-services'; import { toError } from '@/utils'; const REVOKED_AT_FORMATTER = new Intl.DateTimeFormat('en-GB', { diff --git a/packages/cli/src/wait-tracker.ts b/packages/cli/src/wait-tracker.ts index dcbc793b614..508b12f91c8 100644 --- a/packages/cli/src/wait-tracker.ts +++ b/packages/cli/src/wait-tracker.ts @@ -10,6 +10,7 @@ import { UnexpectedError, UserError, type IRun, + type IWorkflowBase, type IWorkflowExecutionDataProcess, type RelatedExecution, } from 'n8n-workflow'; @@ -186,6 +187,7 @@ export class WaitTracker { parentExecution, this.activeExecutions.getPostExecutePromise(executionId), { executionId, workflowId }, + fullExecutionData.workflowData, ); } } @@ -204,6 +206,7 @@ export class WaitTracker { parentExecution: RelatedExecution, executePromise: Promise, childExecution?: RelatedExecution, + childWorkflowData?: IWorkflowBase, ): Promise { try { const subworkflowResults = await executePromise; @@ -214,6 +217,7 @@ export class WaitTracker { parentExecution.executionId, subworkflowResults, childExecution, + childWorkflowData, ); // An unpatched parent has nothing of this child's to resume on: it is parked on a @@ -241,6 +245,7 @@ export class WaitTracker { parentExecutionId: string, subworkflowResults: IRun, childExecution?: RelatedExecution, + childWorkflowData?: IWorkflowBase, ): Promise { let patched = false; @@ -250,6 +255,7 @@ export class WaitTracker { parentExecutionId, subworkflowResults, childExecution, + childWorkflowData, ); }, MAX_PARENT_RESUME_ATTEMPTS, @@ -376,7 +382,7 @@ export class WaitTracker { // A crashed or cancelled child is terminal but often carries neither an error nor node // output. Resuming on it would re-run the parent's node disabled, passing the parent's // own input off as the sub-workflow's result, so leave the parent parked instead. - if (!(await this.patchParent(parentId, childRun, childExecution))) { + if (!(await this.patchParent(parentId, childRun, childExecution, child.workflowData))) { this.logger.warn('Parent not patched with the sub-execution result, leaving it parked', { parentExecutionId: parentId, childExecutionId: child.id, diff --git a/packages/cli/src/webhooks/__tests__/slack-interaction-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/slack-interaction-webhooks.test.ts index 374983e7365..3d7c208ff6d 100644 --- a/packages/cli/src/webhooks/__tests__/slack-interaction-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/slack-interaction-webhooks.test.ts @@ -1,3 +1,4 @@ +import type { EventService } from '@n8n/backend-services'; import type { IExecutionResponse } from '@n8n/db'; import type express from 'express'; import type { InstanceSettings } from 'n8n-core'; @@ -5,7 +6,6 @@ import { buildHitlCallbackReference, isSlackInteractionRequest } from 'n8n-core' import type { IWorkflowBase } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; import type { ExecutionPersistence } from '@/executions/execution-persistence'; import { SlackInteractionWebhooks } from '@/webhooks/slack-interaction-webhooks'; import type { IWebhookResponseCallbackData, WaitingWebhookRequest } from '@/webhooks/webhook.types'; diff --git a/packages/cli/src/webhooks/__tests__/telegram-interaction-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/telegram-interaction-webhooks.test.ts index 946ad026162..ead69a61af9 100644 --- a/packages/cli/src/webhooks/__tests__/telegram-interaction-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/telegram-interaction-webhooks.test.ts @@ -1,10 +1,10 @@ +import type { EventService } from '@n8n/backend-services'; import type { IExecutionResponse } from '@n8n/db'; import type express from 'express'; import type { InstanceSettings } from 'n8n-core'; import { buildHitlCallbackReference, isTelegramInteractionRequest } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; import type { ExecutionPersistence } from '@/executions/execution-persistence'; import { TelegramInteractionWebhooks } from '@/webhooks/telegram-interaction-webhooks'; import type { WebhookService } from '@/webhooks/webhook.service'; diff --git a/packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts b/packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts index e04e297bd36..0e187de7857 100644 --- a/packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts +++ b/packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts @@ -6,7 +6,7 @@ import { TEST_WEBHOOK_TIMEOUT, TEST_WEBHOOK_TIMEOUT_BUFFER, } from '@/constants'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import type { TestWebhookRegistration } from '@/webhooks/test-webhook-registrations.service'; import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service'; diff --git a/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts index 9d63e6a45a4..cdaee35e591 100644 --- a/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts @@ -23,7 +23,7 @@ import { TEST_WEBHOOK_TIMEOUT, TEST_WEBHOOK_TIMEOUT_BUFFER, } from '@/constants'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error'; import type { TestWebhookRegistrationsService, diff --git a/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts index 89494520302..560958afac8 100644 --- a/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts @@ -1,3 +1,4 @@ +import type { EventService } from '@n8n/backend-services'; import type { EndpointsConfig } from '@n8n/config'; import type { IExecutionResponse } from '@n8n/db'; import type express from 'express'; @@ -7,9 +8,7 @@ import type { INodeParameters, IWorkflowBase, Workflow } from 'n8n-workflow'; import { SEND_AND_WAIT_OPERATION } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { EventService } from '@/events/event.service'; +import { ConflictError, NotFoundError } from '@n8n/errors'; import type { ExecutionPersistence } from '@/executions/execution-persistence'; import { WaitingWebhooks } from '@/webhooks/waiting-webhooks'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; diff --git a/packages/cli/src/webhooks/__tests__/webhook-form-data.test.ts b/packages/cli/src/webhooks/__tests__/webhook-form-data.test.ts index ee5870e53d9..6ec555eff53 100644 --- a/packages/cli/src/webhooks/__tests__/webhook-form-data.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook-form-data.test.ts @@ -6,8 +6,7 @@ import { createServer } from 'node:http'; import request from 'supertest'; import type TestAgent from 'supertest/lib/agent'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ContentTooLargeError } from '@/errors/response-errors/content-too-large.error'; +import { BadRequestError, ContentTooLargeError } from '@n8n/errors'; import { rawBodyReader } from '@/middlewares'; import { createMultiFormDataParser } from '../webhook-form-data'; diff --git a/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts b/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts index ac4b7ea4993..ebecfab8491 100644 --- a/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import type { Project, User } from '@n8n/db'; import { UserRepository } from '@n8n/db'; @@ -58,8 +59,7 @@ import { mock, type MockProxy } from 'vitest-mock-extended'; import { ActiveExecutions } from '@/active-executions'; import { AuthService } from '@/auth/auth.service'; -import type { ResponseError } from '@/errors/response-errors/abstract/response.error'; -import { EventService } from '@/events/event.service'; +import { type ResponseError } from '@n8n/errors'; import { WebhookResponseRelay } from '@/scaling/webhook-response-relay'; import { EngineDataPlaneProxyService } from '@/services/engine-data-plane-proxy.service'; import { EngineV2Dispatcher } from '@/services/engine-v2-dispatcher.service'; @@ -83,6 +83,7 @@ import { _privateGetWebhookErrorMessage, invokeWebhook, handleImmediateWebhookResponse, + checkTriggerCredentialGate, } from '../webhook-helpers'; import { WebhookResponder } from '../webhook-responder'; import { WebhookService } from '../webhook.service'; @@ -1411,6 +1412,57 @@ describe('handleImmediateWebhookResponse', () => { }); }); +describe('checkTriggerCredentialGate', () => { + const oauthWebhook = mock({ + name: 'Webhook', + type: WEBHOOK_NODE_TYPE, + parameters: { authentication: 'n8nOAuth2' }, + }); + + it.each([ + { name: 'a response was sent', didSendResponse: true, headersSent: false }, + { name: 'headers were sent', didSendResponse: false, headersSent: true }, + ])('skips the gate when $name', async ({ didSendResponse, headersSent }) => { + const checkTriggerCredentialStatus = vi.fn(); + const responseCallback = vi.fn(); + const responder = new WebhookResponder(responseCallback); + if (didSendResponse) responder.markResponded(); + + const result = await checkTriggerCredentialGate({ + workflowStartNode: oauthWebhook, + additionalData: mock({ checkTriggerCredentialStatus }), + res: mock({ headersSent }), + responder, + }); + + expect(checkTriggerCredentialStatus).not.toHaveBeenCalled(); + expect(responseCallback).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('sends 428 when trigger credentials are not ready', async () => { + const credentialGate = { readyToExecute: false }; + const responseCallback = vi.fn(); + const responder = new WebhookResponder(responseCallback); + + const result = await checkTriggerCredentialGate({ + workflowStartNode: oauthWebhook, + additionalData: mock({ + checkTriggerCredentialStatus: vi.fn().mockResolvedValue(credentialGate), + }), + res: mock({ headersSent: false }), + responder, + }); + + expect(responseCallback).toHaveBeenCalledWith(null, { + data: credentialGate, + responseCode: 428, + }); + expect(responder.hasResponded).toBe(true); + expect(result).toBe(false); + }); +}); + describe('executeWebhook form content type', () => { const runRequest = async (startNode: INode, rawBody = '{') => { ownershipService.getWorkflowProjectCached.mockResolvedValue( @@ -2432,7 +2484,7 @@ describe('executeWebhook on engine v2', () => { /** Ends the run the request is waiting on, as the data plane would. */ const answerRun = (status: EndedMessage['status'], lastStep: EndedMessage['lastStep']): void => { - const executionId = workflowRunner.run.mock.calls[0][0].engineExecutionId as string; + const executionId = workflowRunner.run.mock.calls[0][0].engineV2Response?.executionId as string; dataPlane.get(executionId)?.({ type: 'ended', executionId, @@ -2475,6 +2527,13 @@ describe('executeWebhook on engine v2', () => { expect(workflowRunner.run.mock.calls[0][0].destinationNode).toEqual(destinationNode); }); + it('dispatches the run without a response expectation, because nobody listens', async () => { + await startWebhook(); + + expect(workflowRunner.run.mock.calls[0][0].engineV2Response).toBeUndefined(); + expect(dataPlane.size).toBe(0); + }); + it('does not touch the control-plane execution registry', async () => { await startWebhook(); @@ -2490,10 +2549,48 @@ describe('executeWebhook on engine v2', () => { // The listener is created before the run starts, and the run has to use // the id it listens under, so a fast answer is not lost. expect(workflowRunner.run).toHaveBeenCalledTimes(1); - const dispatchedId = workflowRunner.run.mock.calls[0][0].engineExecutionId as string; + const dispatchedId = workflowRunner.run.mock.calls[0][0].engineV2Response + ?.executionId as string; expect(dataPlane.has(dispatchedId)).toBe(true); }); + it('tells the dispatcher that the caller waits for the run end', async () => { + const waitForResponse = vi.spyOn(Container.get(EngineV2WebhookResponder), 'waitForResponse'); + + await startWebhook({ responseMode: 'lastNode' }); + + const { engineV2Response } = workflowRunner.run.mock.calls[0][0]; + expect(engineV2Response).toEqual({ + executionId: expect.any(String), + responseMode: 'lastNode', + }); + expect(waitForResponse).toHaveBeenCalledWith(engineV2Response?.executionId, { + kind: 'runEnd', + }); + }); + + it('answers with a 500 when a Respond node fails because the caller waits for the run end', async () => { + const { responseCallback } = await startWebhook({ responseMode: 'lastNode' }); + + // The engine refuses the Respond node's response, so the node fails the run. + answerRun('failed', { + nodeId: 'respond', + nodeName: 'Respond to Webhook', + status: 'failed', + outputs: null, + error: { + name: 'UserError', + message: 'The Webhook node answers when the last node finishes, not with this node.', + }, + }); + + await vi.waitFor(() => expect(responseCallback).toHaveBeenCalledTimes(1)); + expect(responseCallback.mock.calls[0]).toEqual([ + null, + { data: { message: 'Error in workflow' }, responseCode: 500 }, + ]); + }); + it('answers with the data of the step the run ended on', async () => { const { responseCallback } = await startWebhook({ responseMode: 'lastNode' }); @@ -2592,9 +2689,25 @@ describe('executeWebhook on engine v2', () => { }); describe('responseNode', () => { + it('tells the dispatcher that the caller waits for a step response', async () => { + const waitForResponse = vi.spyOn(Container.get(EngineV2WebhookResponder), 'waitForResponse'); + + await startWebhook({ responseMode: 'responseNode' }); + + const { engineV2Response } = workflowRunner.run.mock.calls[0][0]; + expect(engineV2Response).toEqual({ + executionId: expect.any(String), + responseMode: 'responseNode', + }); + expect(waitForResponse).toHaveBeenCalledWith(engineV2Response?.executionId, { + kind: 'stepResponse', + }); + }); + it('answers with the response published by the data plane', async () => { const { responseCallback } = await startWebhook({ responseMode: 'responseNode' }); - const executionId = workflowRunner.run.mock.calls[0][0].engineExecutionId as string; + const executionId = workflowRunner.run.mock.calls[0][0].engineV2Response + ?.executionId as string; dataPlane.get(executionId)?.({ type: 'response', @@ -2613,7 +2726,8 @@ describe('executeWebhook on engine v2', () => { it('answers with an empty body when the response has no body key', async () => { const { responseCallback } = await startWebhook({ responseMode: 'responseNode' }); - const executionId = workflowRunner.run.mock.calls[0][0].engineExecutionId as string; + const executionId = workflowRunner.run.mock.calls[0][0].engineV2Response + ?.executionId as string; dataPlane.get(executionId)?.({ type: 'response', @@ -2632,11 +2746,12 @@ describe('executeWebhook on engine v2', () => { it('answers with an empty body when the Respond node never runs', async () => { const { responseCallback } = await startWebhook({ responseMode: 'responseNode' }); + // The engine leaves the outputs out, because this caller waits for a step response. answerRun('completed', { nodeId: 'edit-fields', nodeName: 'Edit Fields', status: 'completed', - outputs: [[{ json: { ignored: true } }]], + outputs: null, }); await vi.waitFor(() => expect(responseCallback).toHaveBeenCalledTimes(1)); @@ -2648,7 +2763,8 @@ describe('executeWebhook on engine v2', () => { it('answers with the channel error when the response is undeliverable', async () => { const { responseCallback } = await startWebhook({ responseMode: 'responseNode' }); - const executionId = workflowRunner.run.mock.calls[0][0].engineExecutionId as string; + const executionId = workflowRunner.run.mock.calls[0][0].engineV2Response + ?.executionId as string; dataPlane.get(executionId)?.({ type: 'undeliverable', diff --git a/packages/cli/src/webhooks/__tests__/webhook-request-handler.test.ts b/packages/cli/src/webhooks/__tests__/webhook-request-handler.test.ts index 02f5e604e37..63e4aef1258 100644 --- a/packages/cli/src/webhooks/__tests__/webhook-request-handler.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook-request-handler.test.ts @@ -6,7 +6,7 @@ import { OperationalError, randomString } from 'n8n-workflow'; import type { IHttpRequestMethods } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import { ResponseError } from '@/errors/response-errors/abstract/response.error'; +import { ResponseError } from '@n8n/errors'; import { createWebhookHandlerFor } from '@/webhooks/webhook-request-handler'; import type { IWebhookManager, diff --git a/packages/cli/src/webhooks/__tests__/webhook.service.test.ts b/packages/cli/src/webhooks/__tests__/webhook.service.test.ts index 35b412a29f5..f70d6c27658 100644 --- a/packages/cli/src/webhooks/__tests__/webhook.service.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook.service.test.ts @@ -18,7 +18,7 @@ import { mock } from 'vitest-mock-extended'; import config from '@/config'; import type { NodeTypes } from '@/node-types'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { WebhookService } from '@/webhooks/webhook.service'; const createWebhook = (method: string, path: string, webhookId?: string, pathSegments?: number) => diff --git a/packages/cli/src/webhooks/engine-v2-response-expectation.ts b/packages/cli/src/webhooks/engine-v2-response-expectation.ts new file mode 100644 index 00000000000..269e119e7b8 --- /dev/null +++ b/packages/cli/src/webhooks/engine-v2-response-expectation.ts @@ -0,0 +1,21 @@ +import type { ResponseExpectation } from '@n8n/engine'; +import type { IWorkflowExecutionDataProcess } from 'n8n-workflow'; + +type EngineV2ResponseMode = NonNullable< + IWorkflowExecutionDataProcess['engineV2Response'] +>['responseMode']; + +/** + * Tells engine v2 which answer the caller waits for. + */ +export function toResponseExpectation(responseMode?: EngineV2ResponseMode): ResponseExpectation { + switch (responseMode) { + case 'lastNode': + return { kind: 'runEnd' }; + case 'responseNode': + return { kind: 'stepResponse' }; + default: + // `onReceived`, triggers, pollers and manual runs: nobody listens. + return { kind: 'none' }; + } +} diff --git a/packages/cli/src/webhooks/live-webhooks.ts b/packages/cli/src/webhooks/live-webhooks.ts index ba87bd94024..0ab5303e2e3 100644 --- a/packages/cli/src/webhooks/live-webhooks.ts +++ b/packages/cli/src/webhooks/live-webhooks.ts @@ -13,7 +13,7 @@ import { } from 'n8n-workflow'; import type { INode, IWebhookData, IHttpRequestMethods, IWorkflowBase } from 'n8n-workflow'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error'; import { NodeTypes } from '@/node-types'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; diff --git a/packages/cli/src/webhooks/test-webhook-registrations.service.ts b/packages/cli/src/webhooks/test-webhook-registrations.service.ts index 9b1f35d2e98..a0ae0080611 100644 --- a/packages/cli/src/webhooks/test-webhook-registrations.service.ts +++ b/packages/cli/src/webhooks/test-webhook-registrations.service.ts @@ -13,7 +13,7 @@ import { TEST_WEBHOOK_TIMEOUT, TEST_WEBHOOK_TIMEOUT_BUFFER, } from '@/constants'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; const TEST_WEBHOOK_REGISTRATION_VERSION = 1; diff --git a/packages/cli/src/webhooks/test-webhooks.ts b/packages/cli/src/webhooks/test-webhooks.ts index bea1d482b14..a09535d6536 100644 --- a/packages/cli/src/webhooks/test-webhooks.ts +++ b/packages/cli/src/webhooks/test-webhooks.ts @@ -23,7 +23,7 @@ import { TEST_WEBHOOK_TIMEOUT, TEST_WEBHOOK_TIMEOUT_BUFFER, } from '@/constants'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error'; import { SingleWebhookTriggerError } from '@/errors/single-webhook-trigger.error'; import { WorkflowMissingIdError } from '@/errors/workflow-missing-id.error'; diff --git a/packages/cli/src/webhooks/waiting-forms.ts b/packages/cli/src/webhooks/waiting-forms.ts index 61e5317550a..0ffb433b765 100644 --- a/packages/cli/src/webhooks/waiting-forms.ts +++ b/packages/cli/src/webhooks/waiting-forms.ts @@ -4,8 +4,7 @@ import type express from 'express'; import type { IRunData } from 'n8n-workflow'; import { FORM_NODE_TYPE, WAITING_FORMS_EXECUTION_STATUS, Workflow } from 'n8n-workflow'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ConflictError, NotFoundError } from '@n8n/errors'; import { applyCors } from '@/utils/cors.util'; import { WaitingWebhooks } from '@/webhooks/waiting-webhooks'; import { applyFormSandboxCSP } from '@/webhooks/webhook-response-headers'; diff --git a/packages/cli/src/webhooks/waiting-webhooks.ts b/packages/cli/src/webhooks/waiting-webhooks.ts index a470bf53a76..a8d69d9c7fa 100644 --- a/packages/cli/src/webhooks/waiting-webhooks.ts +++ b/packages/cli/src/webhooks/waiting-webhooks.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { EndpointsConfig } from '@n8n/config'; import type { IExecutionResponse } from '@n8n/db'; import { Service } from '@n8n/di'; @@ -24,10 +25,7 @@ import type { WaitingWebhookRequest, } from './webhook.types'; -import { EventService } from '@/events/event.service'; - -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ConflictError, NotFoundError } from '@n8n/errors'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import { getWorkflowActiveStatusFromWorkflowData } from '@/executions/execution.utils'; import { NodeTypes } from '@/node-types'; diff --git a/packages/cli/src/webhooks/webhook-form-data.ts b/packages/cli/src/webhooks/webhook-form-data.ts index 2435d5e0586..700c457749a 100644 --- a/packages/cli/src/webhooks/webhook-form-data.ts +++ b/packages/cli/src/webhooks/webhook-form-data.ts @@ -2,8 +2,7 @@ import formidable from 'formidable'; import { rm } from 'node:fs/promises'; import type { IncomingMessage } from 'node:http'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ContentTooLargeError } from '@/errors/response-errors/content-too-large.error'; +import { BadRequestError, ContentTooLargeError } from '@n8n/errors'; import { discardBlankFileInputs } from '@/webhooks/webhook-blank-file-inputs'; // formidable reports the status code it considers appropriate on `httpCode` diff --git a/packages/cli/src/webhooks/webhook-helpers.ts b/packages/cli/src/webhooks/webhook-helpers.ts index c14b93e3cfd..05145e43720 100644 --- a/packages/cli/src/webhooks/webhook-helpers.ts +++ b/packages/cli/src/webhooks/webhook-helpers.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { ExecutionsConfig, GlobalConfig } from '@n8n/config'; import type { Project } from '@n8n/db'; import { UserRepository } from '@n8n/db'; @@ -62,12 +63,13 @@ import { finished } from 'stream/promises'; import { ActiveExecutions } from '@/active-executions'; import { AuthService } from '@/auth/auth.service'; import { MCP_TRIGGER_NODE_TYPE } from '@/constants'; -import { ResponseError } from '@/errors/response-errors/abstract/response.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { UnsupportedMediaTypeError } from '@/errors/response-errors/unsupported-media-type.error'; -import { EventService } from '@/events/event.service'; +import { + ResponseError, + BadRequestError, + InternalServerError, + NotFoundError, + UnsupportedMediaTypeError, +} from '@n8n/errors'; import { createExecutionIdV2 } from '@/executions/execution-id'; import { parseBody } from '@/middlewares'; import { WebhookResponseRelay } from '@/scaling/webhook-response-relay'; @@ -92,6 +94,7 @@ import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-da import * as WorkflowHelpers from '@/workflow-helpers'; import { WorkflowRunner } from '@/workflow-runner'; +import { toResponseExpectation } from './engine-v2-response-expectation'; import { EngineV2Webhooks } from './engine-v2-webhooks'; import { applySandboxCSP, @@ -602,6 +605,39 @@ export async function invokeWebhook({ } } +/** Stops OAuth webhook execution when required trigger credentials are not ready. */ +export async function checkTriggerCredentialGate({ + workflowStartNode, + additionalData, + res, + responder, +}: { + workflowStartNode: INode; + additionalData: IWorkflowExecuteAdditionalData; + res: express.Response; + responder: WebhookResponder; +}): Promise { + if ( + responder.hasResponded || + res.headersSent || + !shouldEstablishTriggerIdentity(workflowStartNode) + ) { + return true; + } + + const credentialGate = await additionalData.checkTriggerCredentialStatus?.(); + if (!credentialGate || credentialGate.readyToExecute) { + return true; + } + + responder.respondWith({ + data: credentialGate, + responseCode: 428, + }); + + return false; +} + /** * Sends the immediate webhook response unless a response was already sent. When the * node answered the request itself (`noWebhookResponse`), it only reports that to the @@ -1105,20 +1141,13 @@ export async function executeWebhook( // user's resolvable (private) credentials are still unconnected, responding // 428 Precondition Required with the missing-credential list and a signed // connect link for each. - if ( - !responder.hasResponded && - !res.headersSent && - shouldEstablishTriggerIdentity(workflowStartNode) - ) { - const credentialGate = await additionalData.checkTriggerCredentialStatus?.(); - if (credentialGate && !credentialGate.readyToExecute) { - responder.respondWith({ - data: credentialGate, - responseCode: 428, - }); - return; - } - } + const shouldContinueAfterCredentialGate = await checkTriggerCredentialGate({ + workflowStartNode, + additionalData, + res, + responder, + }); + if (!shouldContinueAfterCredentialGate) return; // For "onReceived" mode, we need to defer response sending until after the execution // is created, so that `$execution.id` is available in response data expressions. @@ -1211,13 +1240,13 @@ export async function executeWebhook( // Before the run, because a short workflow answers before `startExecution` // returns and nothing replays a missed response. The id is minted here, so // the run and the listener agree on it. - if (routesToEngineV2 && responseMode !== 'onReceived') { + if (routesToEngineV2 && (responseMode === 'lastNode' || responseMode === 'responseNode')) { const engineExecutionId = createExecutionIdV2(); pendingEngineV2Response = await Container.get(EngineV2WebhookResponder).waitForResponse( engineExecutionId, - responseMode === 'responseNode', + toResponseExpectation(responseMode), ); - runData.engineExecutionId = engineExecutionId; + runData.engineV2Response = { executionId: engineExecutionId, responseMode }; } // Extract W3C trace context from webhook headers for OTEL propagation. @@ -1389,10 +1418,12 @@ export async function executeWebhook( const { parentExecution } = runExecutionData; if (WorkflowHelpers.shouldRestartParentExecution(parentExecution)) { // on child execution completion, resume parent execution - void Container.get(WaitTracker).resumeParentExecution(parentExecution, executePromise, { - executionId, - workflowId: workflowData.id, - }); + void Container.get(WaitTracker).resumeParentExecution( + parentExecution, + executePromise, + { executionId, workflowId: workflowData.id }, + workflowData, + ); } if (!responder.hasResponded) { diff --git a/packages/cli/src/webhooks/webhook.service.ts b/packages/cli/src/webhooks/webhook.service.ts index 300ccfd67e2..be68734ba3c 100644 --- a/packages/cli/src/webhooks/webhook.service.ts +++ b/packages/cli/src/webhooks/webhook.service.ts @@ -26,7 +26,7 @@ import type { } from 'n8n-workflow'; import { NodeTypes } from '@/node-types'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import type { Method } from './webhook.types'; diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index c187202f745..4272c1b77e2 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -3,6 +3,7 @@ import type { PushMessage, PushType } from '@n8n/api-types'; import { Logger, ModuleRegistry } from '@n8n/backend-common'; import { SsrfProtectionService } from '@n8n/backend-network'; +import { EventService, UrlService } from '@n8n/backend-services'; import { ExecutionsConfig, GlobalConfig, SsrfProtectionConfig, WorkflowsConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { ExecutionRepository, WorkflowRepository } from '@n8n/db'; @@ -43,6 +44,8 @@ import { Workflow, createRunExecutionData, mergeRunsPerBranch, + collectSubWorkflowOutput, + getSubWorkflowOutputPolicy, attachDynamicCredentialsUsage, summarizeDynamicCredentialsUsage, } from 'n8n-workflow'; @@ -56,7 +59,6 @@ import { RuntimeCredentialProxyService } from './services/runtime-credential-pro import { ActiveExecutions } from '@/active-executions'; import { CredentialsHelper } from '@/credentials-helper'; import { PreExecuteBlockedError } from '@/errors/pre-execute-blocked.error'; -import { EventService } from '@/events/event.service'; import type { AiEventPayload } from '@/events/maps/ai.event-map'; import { getLifecycleHooksForSubExecutions } from '@/execution-lifecycle/execution-lifecycle-hooks'; import { ExecutionPersistence } from '@/executions/execution-persistence'; @@ -70,7 +72,6 @@ import { import type { UpdateExecutionPayload } from '@/interfaces'; import { NodeTypes } from '@/node-types'; import { Push } from '@/push'; -import { UrlService } from '@n8n/backend-services'; import { TaskRequester } from '@/task-runners/task-managers/task-requester'; import { findSubworkflowStart } from '@/utils'; import { objectToError } from '@/utils/object-to-error'; @@ -323,6 +324,13 @@ export async function executeWorkflow( const runData = options.loadedRunData ?? getRunData(workflowData, options.inputData, options.parentExecution); + if (runData.executionData) { + runData.executionData.subWorkflowOutput ??= getSubWorkflowOutputPolicy( + workflowData.nodes, + options.returnLastRunOnly ?? false, + ); + } + try { await Container.get(WorkflowPreExecute).run( workflowData, @@ -731,7 +739,9 @@ async function startExecution( return { executionId, - data: buildSubWorkflowOutput(data, workflowData.nodes, options.returnLastRunOnly ?? false), + data: data.data.subWorkflowOutput + ? await collectSubWorkflowOutput(data, workflow, data.data.subWorkflowOutput) + : buildSubWorkflowOutput(data, workflowData.nodes, options.returnLastRunOnly ?? false), waitTill: data.waitTill, // Report private-credential usage to the caller (detached runs return earlier, skipping this). ...summarizeDynamicCredentialsUsage(data.data), diff --git a/packages/cli/src/workflow-helpers.ts b/packages/cli/src/workflow-helpers.ts index 5b29a6b0f9b..81028bb0d39 100644 --- a/packages/cli/src/workflow-helpers.ts +++ b/packages/cli/src/workflow-helpers.ts @@ -3,6 +3,10 @@ import { CredentialsRepository } from '@n8n/db'; import type { WorkflowEntity, WorkflowHistory } from '@n8n/db'; import { Container } from '@n8n/di'; import { + collectSubWorkflowOutput, + getLastExecutedNodeData, + Workflow, + UnexpectedError, dropInvalidWorkflowGroups, formatWorkflowStructureIssuePath, GROUP_DESCRIPTION_MAX_LENGTH, @@ -28,13 +32,15 @@ import { } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; import { ExecutionPersistence } from '@/executions/execution-persistence'; +import { NodeTypes } from '@/node-types'; import { OwnershipService } from './services/ownership.service'; export { dropInvalidWorkflowGroups, makeGetNodeTypeForGrouping }; +export { getLastExecutedNodeData, getLastExecutedNodeRuns } from 'n8n-workflow'; /** * Validates that pinned data does not exceed size limits. @@ -66,56 +72,6 @@ export function validatePinDataSize(workflow: IWorkflowBase): void { } } -/** - * All runs of the last executed node, ordered by `executionIndex` (raw, no pinData substitution). - */ -export function getLastExecutedNodeRuns(inputData: IRun): ITaskData[] { - const { runData, lastNodeExecuted } = inputData.data.resultData; - if (lastNodeExecuted === undefined) { - return []; - } - const runs = runData[lastNodeExecuted]; - return runs?.toSorted((a, b) => (a.executionIndex ?? 0) - (b.executionIndex ?? 0)) ?? []; -} - -/** - * Final-run output of the last executed node, with pinData substituted in manual mode. - */ -export function getLastExecutedNodeData(inputData: IRun): ITaskData | undefined { - const { runData, lastNodeExecuted } = inputData.data.resultData; - const pinData = inputData.data.resultData.pinData ?? {}; - - if (lastNodeExecuted === undefined) { - return undefined; - } - - if (runData[lastNodeExecuted] === undefined) { - return undefined; - } - - const lastNodeRunData = runData[lastNodeExecuted][runData[lastNodeExecuted].length - 1]; - - let lastNodePinData = pinData[lastNodeExecuted]; - - if (lastNodePinData && inputData.mode === 'manual') { - if (!Array.isArray(lastNodePinData)) lastNodePinData = [lastNodePinData]; - - const itemsPerRun = lastNodePinData.map((item, index) => { - return { json: item, pairedItem: { item: index } }; - }); - - return { - startTime: 0, - executionIndex: 0, - executionTime: 0, - data: { main: [itemsPerRun] }, - source: lastNodeRunData.source, - }; - } - - return lastNodeRunData; -} - /** * Set node ids if not already set */ @@ -493,6 +449,7 @@ export async function updateParentExecutionWithChildResults( parentExecutionId: string, subworkflowResults: IRun, childExecution?: RelatedExecution, + childWorkflowData?: IWorkflowBase, ): Promise { const subworkflowError = subworkflowResults.data.resultData.error; const lastExecutedNodeData = getLastExecutedNodeData(subworkflowResults); @@ -580,7 +537,22 @@ export async function updateParentExecutionWithChildResults( // Copy the sub workflow result to the parent execution's Execute Workflow node inputs // so that the Execute Workflow node returns the correct data when parent execution is resumed // and the Execute Workflow node is executed again in disabled mode. - nodeExecutionStack[0].data = lastExecutedNodeData.data; + const policy = subworkflowResults.data.subWorkflowOutput; + if (policy) { + if (!childWorkflowData) { + throw new UnexpectedError('The saved child workflow is required to collect its output.'); + } + const workflow = new Workflow({ + ...childWorkflowData, + nodeTypes: Container.get(NodeTypes), + }); + nodeExecutionStack[0].data = { + main: await collectSubWorkflowOutput(subworkflowResults, workflow, policy), + }; + } else { + // Executions saved without a policy keep the legacy resume behavior. + nodeExecutionStack[0].data = lastExecutedNodeData.data; + } } await executionPersistence.updateExistingExecution( diff --git a/packages/cli/src/workflow-runner.ts b/packages/cli/src/workflow-runner.ts index cf2edfe3d51..4c710afafcf 100644 --- a/packages/cli/src/workflow-runner.ts +++ b/packages/cli/src/workflow-runner.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { ExecutionsConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { ExecutionRepository } from '@n8n/db'; @@ -37,8 +38,6 @@ import { } from 'n8n-workflow'; import PCancelable from 'p-cancelable'; -import { EventService } from './events/event.service'; - import { ActiveExecutions } from '@/active-executions'; import { ExecutionNotFoundError } from '@/errors/execution-not-found-error'; import { MaxStalledCountError } from '@/errors/max-stalled-count.error'; diff --git a/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts index 96375f94d1e..1d79ae091f2 100644 --- a/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts @@ -14,9 +14,7 @@ import type { MockProxy } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended'; import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; import type { ExternalHooks, WorkflowLifecycleHookActor } from '@/external-hooks'; import type { McpSettingsService } from '@/modules/mcp/mcp.settings.service'; import type { InstanceRedactionEnforcementService } from '@/modules/redaction/instance-redaction-enforcement.service'; @@ -528,11 +526,14 @@ describe('WorkflowCreationService', () => { projectId: 'project-1', }); - expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith({ - workflow: { id: null, name: 'My workflow', nodes: [] }, - storedWorkflow: null, - projectId: 'project-1', - }); + expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith( + { + workflow: { id: null, name: 'My workflow', nodes: [] }, + storedWorkflow: null, + projectId: 'project-1', + }, + expect.anything(), + ); }); it("falls back to the user's personal project when no project is given", async () => { @@ -545,6 +546,7 @@ describe('WorkflowCreationService', () => { expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'personal-project' }), + expect.anything(), ); }); diff --git a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts index 4cb9bd70c7f..8d7322770aa 100644 --- a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts @@ -1,4 +1,5 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { GlobalConfig, WorkflowsConfig } from '@n8n/config'; import type { CreateExecutionPayload, @@ -31,8 +32,7 @@ import type { WorkflowRequest } from '../workflow.request'; import { DuplicateExecutionError } from '@/errors/duplicate-execution.error'; import { ExecutionAlreadyResumingError } from '@/errors/execution-already-resuming.error'; import { PreExecuteBlockedError } from '@/errors/pre-execute-blocked.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import type { EventService } from '@/events/event.service'; +import { ForbiddenError } from '@n8n/errors'; import type { ExecutionCrashService } from '@/executions/execution-crash.service'; import type { IWorkflowErrorData } from '@/interfaces'; import type { NodeTypes } from '@/node-types'; diff --git a/packages/cli/src/workflows/__tests__/workflow-published-data.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-published-data.service.test.ts index 3b34e7f20bc..bb7b65e5d08 100644 --- a/packages/cli/src/workflows/__tests__/workflow-published-data.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-published-data.service.test.ts @@ -2,7 +2,7 @@ import type { Logger } from '@n8n/backend-common'; import type { WorkflowPublishedVersionRepository, WorkflowPublishedVersion } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import type { CacheService } from '@/services/cache/cache.service'; +import type { CacheService } from '@n8n/backend-services'; import { WorkflowPublishedDataService, type PublishedWorkflowDataForExecution, diff --git a/packages/cli/src/workflows/__tests__/workflow.service.ee.test.ts b/packages/cli/src/workflows/__tests__/workflow.service.ee.test.ts index b0ee3c4a4c9..50bad5222ef 100644 --- a/packages/cli/src/workflows/__tests__/workflow.service.ee.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow.service.ee.test.ts @@ -1,5 +1,6 @@ import type { CredentialsEntity, + CredentialsRepository, FolderRepository, Project, SharedWorkflow, @@ -14,12 +15,20 @@ import { WorkflowActivationError } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; import type { ActiveWorkflowManager } from '@/active-workflow-manager'; +import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; +import type { CredentialsService } from '@/credentials/credentials.service'; import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; +import type { OwnershipService } from '@/services/ownership.service'; import type { ProjectService } from '@/services/project.service.ee'; import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import type { WorkflowMutationHooksProxy } from '@/workflows/workflow-mutation-hooks-proxy.service'; import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; +const flags = { credSharingEnabled: false }; +vi.mock('@/constants/credential-sharing', () => ({ + isCredSharingEnabled: () => flags.credSharingEnabled, +})); + describe('EnterpriseWorkflowService', () => { let service: EnterpriseWorkflowService; const workflowRepository = mock(); @@ -30,19 +39,24 @@ describe('EnterpriseWorkflowService', () => { const projectService = mock(); const folderRepository = mock(); const policyEnforcementService = mock(); + const credentialsRepository = mock(); + const credentialsService = mock(); + const ownershipService = mock(); + const credentialsFinderService = mock(); beforeEach(() => { vi.clearAllMocks(); + flags.credSharingEnabled = false; service = new EnterpriseWorkflowService( mock(), // logger mock(), // sharedWorkflowRepository workflowRepository, - mock(), // credentialsRepository - mock(), // credentialsService - mock(), // ownershipService + credentialsRepository, + credentialsService, + ownershipService, projectService, activeWorkflowManager, - mock(), // credentialsFinderService + credentialsFinderService, mock(), // enterpriseCredentialsService workflowFinderService, folderRepository, @@ -250,6 +264,78 @@ describe('EnterpriseWorkflowService', () => { }); }); + describe('addCredentialsToWorkflow()', () => { + const user = mock({ id: 'user-1' }); + + const buildWorkflow = () => + mock[0]>({ + id: 'workflow-1', + nodes: [{ credentials: { googlePalmApi: { id: 'cred-1', name: 'Google' } } }], + usedCredentials: undefined, + }); + + beforeEach(() => { + credentialsRepository.getManyByIds.mockResolvedValue([ + mock({ id: 'cred-1', name: 'Google', type: 'googlePalmApi' }), + ]); + ownershipService.addOwnedByAndSharedWith.mockImplementation((entity) => + Object.assign(entity, { homeProject: null, sharedWithProjects: [] }), + ); + }); + + it('describes every referenced credential regardless of access', async () => { + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([]); + const workflow = buildWorkflow(); + + await service.addCredentialsToWorkflow(workflow, user); + + expect(workflow.usedCredentials).toMatchObject([ + { id: 'cred-1', name: 'Google', type: 'googlePalmApi', currentUserCanUse: false }, + ]); + }); + + it('when the flag is off, asks the project-scoped check', async () => { + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + mock({ id: 'cred-1' }), + ]); + const workflow = buildWorkflow(); + + await service.addCredentialsToWorkflow(workflow, user); + + expect(credentialsService.getCredentialsAUserCanUseInAWorkflow).toHaveBeenCalledWith(user, { + workflowId: 'workflow-1', + }); + expect(credentialsFinderService.findUnusableCredentialsForUser).not.toHaveBeenCalled(); + expect(workflow.usedCredentials).toMatchObject([{ id: 'cred-1', currentUserCanUse: true }]); + }); + + it('when the flag is on, asks the identity-based check instead', async () => { + flags.credSharingEnabled = true; + credentialsFinderService.findUnusableCredentialsForUser.mockResolvedValue([]); + const workflow = buildWorkflow(); + + await service.addCredentialsToWorkflow(workflow, user); + + expect(credentialsFinderService.findUnusableCredentialsForUser).toHaveBeenCalledWith(user, [ + 'cred-1', + ]); + expect(credentialsService.getCredentialsAUserCanUseInAWorkflow).not.toHaveBeenCalled(); + expect(workflow.usedCredentials).toMatchObject([{ id: 'cred-1', currentUserCanUse: true }]); + }); + + it('when the flag is on, a credential the identity check rejects is unusable', async () => { + flags.credSharingEnabled = true; + credentialsFinderService.findUnusableCredentialsForUser.mockResolvedValue([ + mock({ id: 'cred-1' }), + ]); + const workflow = buildWorkflow(); + + await service.addCredentialsToWorkflow(workflow, user); + + expect(workflow.usedCredentials).toMatchObject([{ id: 'cred-1', currentUserCanUse: false }]); + }); + }); + describe('validateWorkflowCredentialUsage() - unresolved credentials', () => { // Real objects, not mock, so the explicit null id survives. const nodeWithNullCred = (id: string, name: string) => @@ -406,10 +492,13 @@ describe('EnterpriseWorkflowService', () => { await service.transferWorkflow(user, 'wf-1', 'proj-dest'); - expect(policyEnforcementService.enforceWorkflowTransfer).toHaveBeenCalledExactlyOnceWith({ - workflow, - targetProjectId: destinationProject.id, - }); + expect(policyEnforcementService.enforceWorkflowTransfer).toHaveBeenCalledExactlyOnceWith( + { + workflow, + targetProjectId: destinationProject.id, + }, + { kind: 'user', user }, + ); }); it('proceeds with the transfer unchanged when the policy check clears', async () => { diff --git a/packages/cli/src/workflows/__tests__/workflow.service.test.ts b/packages/cli/src/workflows/__tests__/workflow.service.test.ts index 8f3a30d1613..0b316a53735 100644 --- a/packages/cli/src/workflows/__tests__/workflow.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow.service.test.ts @@ -1,4 +1,5 @@ import type { LicenseState } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { GlobalConfig, WorkflowsConfig } from '@n8n/config'; import type { Project, @@ -21,14 +22,15 @@ import { mock } from 'vitest-mock-extended'; import type { MockProxy } from 'vitest-mock-extended'; import type { ActiveWorkflowManager } from '@/active-workflow-manager'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { + BadRequestError, + ConflictError, + NotFoundError, + UnprocessableRequestError, +} from '@n8n/errors'; import { WorkflowActivationBadRequestError } from '@/errors/response-errors/workflow-activation-bad-request.error'; import { WorkflowDeactivationBadRequestError } from '@/errors/response-errors/workflow-deactivation-bad-request.error'; import { WorkflowPublishBlockedError } from '@/errors/response-errors/workflow-publish-blocked.error'; -import type { EventService } from '@/events/event.service'; import type { SharedWorkflowRepository } from '@n8n/db'; import type { ExecutionPersistence } from '@/executions/execution-persistence'; import type { ExternalHooks, WorkflowLifecycleHookActor } from '@/external-hooks'; @@ -1881,7 +1883,7 @@ describe('WorkflowService', () => { arrangeSuccessfulActivation(workflow); workflowHistoryServiceMock.getVersion.mockResolvedValue(versionToActivate); - await workflowService.activateWorkflow(mock(), WORKFLOW_ID, { + await workflowService.activateWorkflow(mock({ id: 'user-1' }), WORKFLOW_ID, { versionId: TARGET_VERSION_ID, }); @@ -1894,6 +1896,7 @@ describe('WorkflowService', () => { }, projectId: 'project-1', }, + { kind: 'user', user: expect.objectContaining({ id: 'user-1' }) }, ); }); @@ -1911,7 +1914,7 @@ describe('WorkflowService', () => { candidate.nodes.push({ name: 'Injected by hook' } as INode); }); - await workflowService.activateWorkflow(mock(), WORKFLOW_ID, { + await workflowService.activateWorkflow(mock({ id: 'user-1' }), WORKFLOW_ID, { versionId: TARGET_VERSION_ID, }); @@ -1919,6 +1922,7 @@ describe('WorkflowService', () => { expect.objectContaining({ workflow: expect.objectContaining({ nodes: originalNodes }), }), + { kind: 'user', user: expect.objectContaining({ id: 'user-1' }) }, ); }); @@ -1935,7 +1939,7 @@ describe('WorkflowService', () => { candidate.nodes = [{ name: 'Rewritten by hook' } as INode]; }); - await workflowService.activateWorkflow(mock(), WORKFLOW_ID, { + await workflowService.activateWorkflow(mock({ id: 'user-1' }), WORKFLOW_ID, { versionId: TARGET_VERSION_ID, }); @@ -1943,6 +1947,7 @@ describe('WorkflowService', () => { expect.objectContaining({ workflow: expect.objectContaining({ nodes: versionToActivate.nodes }), }), + { kind: 'user', user: expect.objectContaining({ id: 'user-1' }) }, ); }); @@ -2738,11 +2743,14 @@ describe('WorkflowService', () => { WORKFLOW_ID, ); - expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith({ - workflow: { id: WORKFLOW_ID, name: 'New name', nodes: submittedNodes }, - storedWorkflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes }, - projectId: 'project-1', - }); + expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith( + { + workflow: { id: WORKFLOW_ID, name: 'New name', nodes: submittedNodes }, + storedWorkflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes }, + projectId: 'project-1', + }, + { kind: 'user', user: expect.objectContaining({ id: 'user-1' }) }, + ); }); // A partial update (e.g. renaming only) omits `nodes` entirely. The check still needs @@ -2754,11 +2762,14 @@ describe('WorkflowService', () => { WORKFLOW_ID, ); - expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith({ - workflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes }, - storedWorkflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes }, - projectId: 'project-1', - }); + expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith( + { + workflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes }, + storedWorkflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes }, + projectId: 'project-1', + }, + { kind: 'user', user: expect.objectContaining({ id: 'user-1' }) }, + ); }); it('updates the workflow unchanged when the check clears', async () => { diff --git a/packages/cli/src/workflows/__tests__/workflows.controller.test.ts b/packages/cli/src/workflows/__tests__/workflows.controller.test.ts index 30465234082..0d0602b0870 100644 --- a/packages/cli/src/workflows/__tests__/workflows.controller.test.ts +++ b/packages/cli/src/workflows/__tests__/workflows.controller.test.ts @@ -9,8 +9,7 @@ import { mock } from 'vitest-mock-extended'; import { WorkflowsController } from '../workflows.controller'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import type { ExecutionService } from '@/executions/execution.service'; import type { ProjectService } from '@/services/project.service.ee'; diff --git a/packages/cli/src/workflows/publication/__tests__/published-workflow-trigger-deactivator.test.ts b/packages/cli/src/workflows/publication/__tests__/published-workflow-trigger-deactivator.test.ts index 235eb03662a..4cf6e9ab543 100644 --- a/packages/cli/src/workflows/publication/__tests__/published-workflow-trigger-deactivator.test.ts +++ b/packages/cli/src/workflows/publication/__tests__/published-workflow-trigger-deactivator.test.ts @@ -1,9 +1,9 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { WorkflowsConfig } from '@n8n/config'; import { mock } from 'vitest-mock-extended'; import type { ActiveWorkflowTriggers, ErrorReporter, InstanceSettings } from 'n8n-core'; -import type { EventService } from '@/events/event.service'; import { PublishedWorkflowTriggerDeactivator } from '@/workflows/publication/published-workflow-trigger-deactivator'; import type { WorkflowPublicationLifecycleLock } from '@/workflows/publication/workflow-publication-lifecycle-lock'; import type { WorkflowPublicationOutboxConsumer } from '@/workflows/publication/workflow-publication-outbox-consumer'; diff --git a/packages/cli/src/workflows/publication/__tests__/workflow-publication-applier.test.ts b/packages/cli/src/workflows/publication/__tests__/workflow-publication-applier.test.ts index 540c9c56532..01341cc0995 100644 --- a/packages/cli/src/workflows/publication/__tests__/workflow-publication-applier.test.ts +++ b/packages/cli/src/workflows/publication/__tests__/workflow-publication-applier.test.ts @@ -301,14 +301,17 @@ describe('WorkflowPublicationApplier', () => { await applier.apply(makeRecord(), abort); - expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledExactlyOnceWith({ - workflow: { - id: 'wf-1', - name: 'My workflow', - nodes: versionWithNodes.nodes, + expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledExactlyOnceWith( + { + workflow: { + id: 'wf-1', + name: 'My workflow', + nodes: versionWithNodes.nodes, + }, + projectId: 'project-1', }, - projectId: 'project-1', - }); + { kind: 'system', reason: 'publication' }, + ); }); // The no-change branch still advances the published version, which running diff --git a/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-cleanup.service.test.ts b/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-cleanup.service.test.ts index 229707e1673..f3d601ef819 100644 --- a/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-cleanup.service.test.ts +++ b/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-cleanup.service.test.ts @@ -1,11 +1,10 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { WorkflowsConfig } from '@n8n/config'; import type { WorkflowPublicationOutboxRepository } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; import type { Span, Tracing } from 'n8n-core'; -import type { EventService } from '@/events/event.service'; - import { WorkflowPublicationOutboxCleanupService } from '../workflow-publication-outbox-cleanup.service'; const logger = mock({ scoped: vi.fn().mockReturnThis() }); diff --git a/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-consumer.test.ts b/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-consumer.test.ts index 679524fb1d2..2ab01851733 100644 --- a/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-consumer.test.ts +++ b/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-consumer.test.ts @@ -1,11 +1,11 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { WorkflowsConfig } from '@n8n/config'; import type { WorkflowPublicationOutbox, WorkflowPublicationOutboxRepository } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; import type { ErrorReporter, InstanceSettings, Span, Tracing } from 'n8n-core'; import { UserError } from 'n8n-workflow'; -import type { EventService } from '@/events/event.service'; import type { PublicationResult } from '@/workflows/publication/publication-result'; import type { PublicationStatusReporter } from '@/workflows/publication/publication-status-reporter'; import { WorkflowPublicationLifecycleLock } from '@/workflows/publication/workflow-publication-lifecycle-lock'; diff --git a/packages/cli/src/workflows/publication/__tests__/workflow-publication-reconciler.service.test.ts b/packages/cli/src/workflows/publication/__tests__/workflow-publication-reconciler.service.test.ts index aee711e5e79..8a2e22d8908 100644 --- a/packages/cli/src/workflows/publication/__tests__/workflow-publication-reconciler.service.test.ts +++ b/packages/cli/src/workflows/publication/__tests__/workflow-publication-reconciler.service.test.ts @@ -1,4 +1,5 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { WorkflowsConfig } from '@n8n/config'; import type { WorkflowEntity, @@ -16,7 +17,6 @@ import type { Tracing, } from 'n8n-core'; -import type { EventService } from '@/events/event.service'; import type { NonWebhookTriggerRegistrar } from '@/workflows/triggers/non-webhook-trigger-registrar'; import type { PublishedWorkflowTriggerDeactivator } from '../published-workflow-trigger-deactivator'; diff --git a/packages/cli/src/workflows/publication/published-workflow-trigger-deactivator.ts b/packages/cli/src/workflows/publication/published-workflow-trigger-deactivator.ts index dd079a99c74..d946c860936 100644 --- a/packages/cli/src/workflows/publication/published-workflow-trigger-deactivator.ts +++ b/packages/cli/src/workflows/publication/published-workflow-trigger-deactivator.ts @@ -1,11 +1,11 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { WorkflowsConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { OnLeaderStepdown, OnShutdown } from '@n8n/decorators'; import { Service } from '@n8n/di'; import { ActiveWorkflowTriggers, ErrorReporter, InstanceSettings } from 'n8n-core'; -import { EventService } from '@/events/event.service'; import { WorkflowPublicationLifecycleLock } from '@/workflows/publication/workflow-publication-lifecycle-lock'; import { WorkflowPublicationOutboxConsumer } from '@/workflows/publication/workflow-publication-outbox-consumer'; diff --git a/packages/cli/src/workflows/publication/workflow-publication-applier.ts b/packages/cli/src/workflows/publication/workflow-publication-applier.ts index a9875af419f..b3d1000edc6 100644 --- a/packages/cli/src/workflows/publication/workflow-publication-applier.ts +++ b/packages/cli/src/workflows/publication/workflow-publication-applier.ts @@ -443,11 +443,12 @@ export class WorkflowPublicationApplier { newVersion: WorkflowHistory, ): Promise { try { - await enforceWorkflowPublishPolicy(this.policyEnforcementService, this.ownershipService, { - id: workflow.id, - name: workflow.name, - nodes: newVersion.nodes, - }); + await enforceWorkflowPublishPolicy( + this.policyEnforcementService, + this.ownershipService, + { id: workflow.id, name: workflow.name, nodes: newVersion.nodes }, + { kind: 'system', reason: 'publication' }, + ); return null; } catch (e) { diff --git a/packages/cli/src/workflows/publication/workflow-publication-outbox-cleanup.service.ts b/packages/cli/src/workflows/publication/workflow-publication-outbox-cleanup.service.ts index a0890bc5e52..c49acd6edfa 100644 --- a/packages/cli/src/workflows/publication/workflow-publication-outbox-cleanup.service.ts +++ b/packages/cli/src/workflows/publication/workflow-publication-outbox-cleanup.service.ts @@ -1,11 +1,11 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { WorkflowsConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { WorkflowPublicationOutboxRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import { SpanStatus, Tracing } from 'n8n-core'; -import { EventService } from '@/events/event.service'; import type { PublicationOperationResult } from '@/events/maps/workflow-publication-metrics.event-map'; /** diff --git a/packages/cli/src/workflows/publication/workflow-publication-outbox-consumer.ts b/packages/cli/src/workflows/publication/workflow-publication-outbox-consumer.ts index 87c294d8d49..5f9b91e944c 100644 --- a/packages/cli/src/workflows/publication/workflow-publication-outbox-consumer.ts +++ b/packages/cli/src/workflows/publication/workflow-publication-outbox-consumer.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { WorkflowsConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { WorkflowPublicationOutbox, WorkflowPublicationOutboxRepository } from '@n8n/db'; @@ -8,7 +9,6 @@ import { ErrorReporter, InstanceSettings, SpanStatus, Tracing } from 'n8n-core'; import { ensureError } from '@n8n/utils/errors/ensure-error'; import { OperationalError, UnexpectedError, UserError } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; import type { PublicationOutcomeReason, PublicationOutcomeResult, diff --git a/packages/cli/src/workflows/publication/workflow-publication-reconciler.service.ts b/packages/cli/src/workflows/publication/workflow-publication-reconciler.service.ts index 625f1c9c845..54dd48186f7 100644 --- a/packages/cli/src/workflows/publication/workflow-publication-reconciler.service.ts +++ b/packages/cli/src/workflows/publication/workflow-publication-reconciler.service.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { WorkflowsConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { @@ -18,7 +19,6 @@ import { } from 'n8n-core'; import type { WorkflowId } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; import { NonWebhookTriggerRegistrar } from '@/workflows/triggers/non-webhook-trigger-registrar'; import { PublishedWorkflowTriggerDeactivator } from './published-workflow-trigger-deactivator'; diff --git a/packages/cli/src/workflows/triggers/__tests__/poll-cursor.service.test.ts b/packages/cli/src/workflows/triggers/__tests__/poll-cursor.service.test.ts index 66ddaff84e5..000a9de9e2f 100644 --- a/packages/cli/src/workflows/triggers/__tests__/poll-cursor.service.test.ts +++ b/packages/cli/src/workflows/triggers/__tests__/poll-cursor.service.test.ts @@ -1,4 +1,5 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { SchedulerConfig, WorkflowsConfig } from '@n8n/config'; import type { CreateExecutionPayload, @@ -11,7 +12,6 @@ import type { IWorkflowBase } from 'n8n-workflow'; import { mock, type MockProxy } from 'vitest-mock-extended'; import { DuplicateExecutionError } from '@/errors/duplicate-execution.error'; -import type { EventService } from '@/events/event.service'; import type { ExecutionPersistence } from '@/executions/execution-persistence'; import { PollCursorService } from '@/workflows/triggers/poll-cursor.service'; 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 45bf706d41f..3103f30cd00 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 @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/unbound-method */ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import type { GlobalConfig } from '@n8n/config'; import type { Project, WorkflowEntity } from '@n8n/db'; import type { IDeferredPromise } from '@n8n/utils/promise/deferred-promise'; @@ -32,7 +33,6 @@ import { mock, type MockProxy } from 'vitest-mock-extended'; import type { ActiveExecutions } from '@/active-executions'; import { DuplicateExecutionError } from '@/errors/duplicate-execution.error'; -import type { EventService } from '@/events/event.service'; import { executeErrorWorkflow } from '@/execution-lifecycle/execute-error-workflow'; import type { ExecutionService } from '@/executions/execution.service'; import type { diff --git a/packages/cli/src/workflows/triggers/__tests__/workflow-trigger-activator.test.ts b/packages/cli/src/workflows/triggers/__tests__/workflow-trigger-activator.test.ts index 8bcca7e3cd6..90c68807d24 100644 --- a/packages/cli/src/workflows/triggers/__tests__/workflow-trigger-activator.test.ts +++ b/packages/cli/src/workflows/triggers/__tests__/workflow-trigger-activator.test.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/unbound-method */ +import type { EventService } from '@n8n/backend-services'; import type { WorkflowsConfig } from '@n8n/config'; import type { IWorkflowDb, WorkflowEntity, WorkflowRepository } from '@n8n/db'; import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; @@ -15,7 +16,6 @@ import { mock, type MockProxy } from 'vitest-mock-extended'; import type { ActivationErrorsService } from '@/activation-errors.service'; import { TRIGGER_ACTIVATION_MAX_ATTEMPTS, TRIGGER_TEARDOWN_MAX_ATTEMPTS } from '@/constants'; -import type { EventService } from '@/events/event.service'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import type { NonWebhookTriggerRegistrar, diff --git a/packages/cli/src/workflows/triggers/poll-cursor.service.ts b/packages/cli/src/workflows/triggers/poll-cursor.service.ts index a84f6f100e4..a1ec10dc695 100644 --- a/packages/cli/src/workflows/triggers/poll-cursor.service.ts +++ b/packages/cli/src/workflows/triggers/poll-cursor.service.ts @@ -1,11 +1,11 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { SchedulerConfig, WorkflowsConfig } from '@n8n/config'; import type { CreateExecutionPayload, PollerCursor, PollLeaseFence } from '@n8n/db'; import { PollerStateRepository, TransactionRunner } from '@n8n/db'; import { Service } from '@n8n/di'; import type { PollCursor } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; import type { PollCursorCommitOperation, PollCursorCommitResult, 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 1366eb90aae..22b965be556 100644 --- a/packages/cli/src/workflows/triggers/trigger-execution-context.factory.ts +++ b/packages/cli/src/workflows/triggers/trigger-execution-context.factory.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import type { IWorkflowDb, PollerCursor, PollLeaseFence } from '@n8n/db'; @@ -33,7 +34,6 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { ActiveExecutions } from '@/active-executions'; import { DuplicateExecutionError } from '@/errors/duplicate-execution.error'; -import { EventService } from '@/events/event.service'; import { executeErrorWorkflow } from '@/execution-lifecycle/execute-error-workflow'; import { ExecutionService } from '@/executions/execution.service'; import { NodeTypes } from '@/node-types'; diff --git a/packages/cli/src/workflows/triggers/workflow-trigger-activator.ts b/packages/cli/src/workflows/triggers/workflow-trigger-activator.ts index c26f2a03d97..aa537eda02c 100644 --- a/packages/cli/src/workflows/triggers/workflow-trigger-activator.ts +++ b/packages/cli/src/workflows/triggers/workflow-trigger-activator.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import assert from 'node:assert/strict'; import { Logger } from '@n8n/backend-common'; @@ -33,7 +34,6 @@ import { TRIGGER_TEARDOWN_MAX_ATTEMPTS, TRIGGER_TEARDOWN_RETRY_INITIAL_DELAY_MS, } from '@/constants'; -import { EventService } from '@/events/event.service'; import type { PublicationOperationResult, PublicationTriggerOperation, diff --git a/packages/cli/src/workflows/workflow-creation.service.ts b/packages/cli/src/workflows/workflow-creation.service.ts index 8490bb12afd..2ab4c0214fd 100644 --- a/packages/cli/src/workflows/workflow-creation.service.ts +++ b/packages/cli/src/workflows/workflow-creation.service.ts @@ -1,5 +1,6 @@ import type { RedactionFloor } from '@n8n/api-types'; import { LicenseState, Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import type { EntityManager, User, Project, Folder } from '@n8n/db'; import { @@ -15,12 +16,8 @@ import { PROJECT_ROOT } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError } from '@n8n/errors'; import { WorkflowValidationError } from '@/errors/response-errors/workflow-validation.error'; -import { EventService } from '@/events/event.service'; import type { WorkflowActionSource } from '@/events/maps/relay.event-map'; import { ExternalHooks, toWorkflowLifecycleHookActor } from '@/external-hooks'; import { validateEntity } from '@/generic-helpers'; @@ -299,11 +296,14 @@ export class WorkflowCreationService { // while editing rather than at runtime. No stored workflow: this one is new. // The clearance binds to the node hash while the row has no id, so nothing below // may touch `newWorkflow.nodes` — the sealed write would reject it. - const cleared = await this.policyEnforcementService.enforceWorkflowSave({ - workflow: { id: newWorkflow.id ?? null, name: newWorkflow.name, nodes: newWorkflow.nodes }, - storedWorkflow: null, - projectId: effectiveProjectId, - }); + const cleared = await this.policyEnforcementService.enforceWorkflowSave( + { + workflow: { id: newWorkflow.id ?? null, name: newWorkflow.name, nodes: newWorkflow.nodes }, + storedWorkflow: null, + projectId: effectiveProjectId, + }, + { kind: 'user', user }, + ); const floor = batchContext?.redactionFloor ?? (await this.readActiveRedactionFloor()); diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index 094b1426e17..a483b9b93a8 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig, WorkflowsConfig } from '@n8n/config'; import type { Project, @@ -41,8 +42,7 @@ import { import { ExecutionAlreadyResumingError } from '@/errors/execution-already-resuming.error'; import { PreExecuteBlockedError } from '@/errors/pre-execute-blocked.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { ForbiddenError } from '@n8n/errors'; import { ExecutionCrashService } from '@/executions/execution-crash.service'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import { FailedRunFactory } from '@/executions/failed-run-factory'; diff --git a/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.controller.test.ts b/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.controller.test.ts index e584a2b7082..d1d82608d09 100644 --- a/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.controller.test.ts +++ b/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.controller.test.ts @@ -4,7 +4,7 @@ import type { AuthenticatedRequest, User } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'vitest-mock-extended'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { SharedWorkflowNotFoundError } from '@/errors/shared-workflow-not-found.error'; import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error'; import type { WorkflowHistoryRequest } from '@/requests'; diff --git a/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.test.ts b/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.test.ts index 57794419e80..9aadaeab976 100644 --- a/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.test.ts +++ b/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { mockLogger, mockInstance } from '@n8n/backend-test-utils'; import type { WorkflowHistory } from '@n8n/db'; import { @@ -11,7 +12,6 @@ import { mockClear } from 'vitest-mock-extended'; import { SharedWorkflowNotFoundError } from '@/errors/shared-workflow-not-found.error'; import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error'; -import { EventService } from '@/events/event.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import { getWorkflow, getWorkflowHistory } from '@test-integration/workflow'; diff --git a/packages/cli/src/workflows/workflow-history/workflow-history.controller.ts b/packages/cli/src/workflows/workflow-history/workflow-history.controller.ts index a6e7bda7dae..e7e866f5920 100644 --- a/packages/cli/src/workflows/workflow-history/workflow-history.controller.ts +++ b/packages/cli/src/workflows/workflow-history/workflow-history.controller.ts @@ -6,7 +6,7 @@ import { import { AuthenticatedRequest } from '@n8n/db'; import { RestController, Get, Post, Query, Body, Patch, Param, Licensed } from '@n8n/decorators'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotFoundError } from '@n8n/errors'; import { SharedWorkflowNotFoundError } from '@/errors/shared-workflow-not-found.error'; import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error'; import { WorkflowHistoryRequest } from '@/requests'; diff --git a/packages/cli/src/workflows/workflow-history/workflow-history.service.ts b/packages/cli/src/workflows/workflow-history/workflow-history.service.ts index 41a840ce788..350166bcc88 100644 --- a/packages/cli/src/workflows/workflow-history/workflow-history.service.ts +++ b/packages/cli/src/workflows/workflow-history/workflow-history.service.ts @@ -1,5 +1,6 @@ import { UpdateWorkflowHistoryVersionDto } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import type { User } from '@n8n/db'; import { WorkflowHistory, @@ -16,7 +17,6 @@ import { UnexpectedError } from 'n8n-workflow'; import { SharedWorkflowNotFoundError } from '@/errors/shared-workflow-not-found.error'; import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error'; -import { EventService } from '@/events/event.service'; import type { WorkflowActionSource } from '@/events/maps/relay.event-map'; import { WorkflowFinderService } from '../workflow-finder.service'; diff --git a/packages/cli/src/workflows/workflow-published-data.service.ts b/packages/cli/src/workflows/workflow-published-data.service.ts index 01b066c8fb3..34c755ea491 100644 --- a/packages/cli/src/workflows/workflow-published-data.service.ts +++ b/packages/cli/src/workflows/workflow-published-data.service.ts @@ -8,7 +8,7 @@ import { import { Service } from '@n8n/di'; import { ensureError } from '@n8n/utils/errors/ensure-error'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; export type { PublishedWorkflowDataForExecution }; diff --git a/packages/cli/src/workflows/workflow.service.ee.ts b/packages/cli/src/workflows/workflow.service.ee.ts index 7e85acc4d4a..5dda862cd80 100644 --- a/packages/cli/src/workflows/workflow.service.ee.ts +++ b/packages/cli/src/workflows/workflow.service.ee.ts @@ -30,12 +30,12 @@ import { } from 'n8n-workflow'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; +import { isCredSharingEnabled } from '@/constants/credential-sharing'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { CredentialsService } from '@/credentials/credentials.service'; import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; import { FolderNotFoundError } from '@/errors/folder-not-found.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { TransferWorkflowError } from '@/errors/response-errors/transfer-workflow.error'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { OwnershipService } from '@/services/ownership.service'; @@ -114,10 +114,6 @@ export class EnterpriseWorkflowService { currentUser: User, ): Promise { workflow.usedCredentials = []; - const userCredentials = await this.credentialsService.getCredentialsAUserCanUseInAWorkflow( - currentUser, - { workflowId: workflow.id }, - ); const credentialIdsUsedByWorkflow = new Set(); workflow.nodes.forEach((node) => { if (!node.credentials) { @@ -131,11 +127,15 @@ export class EnterpriseWorkflowService { credentialIdsUsedByWorkflow.add(credential.id); }); }); - const workflowCredentials = await this.credentialsRepository.getManyByIds( - Array.from(credentialIdsUsedByWorkflow), - { withSharings: true }, + const credentialIds = Array.from(credentialIdsUsedByWorkflow); + const workflowCredentials = await this.credentialsRepository.getManyByIds(credentialIds, { + withSharings: true, + }); + const usableCredentialIds = await this.findUsableCredentialIds( + currentUser, + credentialIds, + workflow.id, ); - const userCredentialIds = userCredentials.map((credential) => credential.id); workflowCredentials.forEach((credential) => { const credentialId = credential.id; const filledCred = this.ownershipService.addOwnedByAndSharedWith(credential); @@ -143,13 +143,36 @@ export class EnterpriseWorkflowService { id: credentialId, name: credential.name, type: credential.type, - currentUserHasAccess: userCredentialIds.includes(credentialId), + currentUserCanUse: usableCredentialIds.has(credentialId), homeProject: filledCred.homeProject, sharedWithProjects: filledCred.sharedWithProjects, }); }); } + private async findUsableCredentialIds( + user: User, + credentialIds: string[], + workflowId: WorkflowId, + ): Promise> { + if (credentialIds.length === 0) return new Set(); + + if (isCredSharingEnabled()) { + const unusable = await this.credentialsFinderService.findUnusableCredentialsForUser( + user, + credentialIds, + ); + const unusableIds = new Set(unusable.map((c) => c.id)); + return new Set(credentialIds.filter((id) => !unusableIds.has(id))); + } + + const userCredentials = await this.credentialsService.getCredentialsAUserCanUseInAWorkflow( + user, + { workflowId }, + ); + return new Set(userCredentials.map((credential) => credential.id)); + } + validateCredentialPermissionsToUser( workflow: IWorkflowBase, allowedCredentials: Array<{ id: string }> | ReadonlySet, @@ -497,10 +520,10 @@ export class EnterpriseWorkflowService { } // 6. validate against the destination project's policy - await this.policyEnforcementService.enforceWorkflowTransfer({ - workflow, - targetProjectId: destinationProject.id, - }); + await this.policyEnforcementService.enforceWorkflowTransfer( + { workflow, targetProjectId: destinationProject.id }, + { kind: 'user', user }, + ); const wasActive = this.isActiveWorkflow(workflow); @@ -656,7 +679,9 @@ export class EnterpriseWorkflowService { private async attemptWorkflowReactivation(workflowId: string, versionId: string, userId: string) { try { - await this.activeWorkflowManager.add(workflowId, 'update'); + await this.activeWorkflowManager.add(workflowId, 'update', undefined, { + actor: { kind: 'user', user: { id: userId } }, + }); return; } catch (error) { diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index d00e5b66895..5acd1fa2b40 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -1,6 +1,7 @@ import { UpdateWorkflowHistoryVersionDto } from '@n8n/api-types'; import type { WorkflowListPublicationStatus } from '@n8n/api-types'; import { LicenseState, Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import type { User, ListQueryDb, Project, WorkflowFolderUnionFull, WorkflowHistory } from '@n8n/db'; import { @@ -42,16 +43,12 @@ import { WorkflowValidationService } from './workflow-validation.service'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { FolderNotFoundError } from '@/errors/folder-not-found.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '@n8n/errors'; import { WorkflowActivationBadRequestError } from '@/errors/response-errors/workflow-activation-bad-request.error'; import { WorkflowDeactivationBadRequestError } from '@/errors/response-errors/workflow-deactivation-bad-request.error'; import { WorkflowPublishForbiddenError } from '@/errors/response-errors/workflow-publish-forbidden.error'; import { WorkflowValidationError } from '@/errors/response-errors/workflow-validation.error'; import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error'; -import { EventService } from '@/events/event.service'; import type { WorkflowActionSource } from '@/events/maps/relay.event-map'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import { ExternalHooks, toWorkflowLifecycleHookActor } from '@/external-hooks'; @@ -615,15 +612,18 @@ export class WorkflowService { // Gate the save on policy before persisting, so the author learns about a violation // while editing rather than at runtime. Carries the stored workflow alongside the // submitted one so a check can restrict its verdict to what this save adds. - const cleared = await this.policyEnforcementService.enforceWorkflowSave({ - workflow: { - id: workflow.id, - name: workflowUpdateData.name ?? workflow.name, - nodes: workflowUpdateData.nodes ?? workflow.nodes, + const cleared = await this.policyEnforcementService.enforceWorkflowSave( + { + workflow: { + id: workflow.id, + name: workflowUpdateData.name ?? workflow.name, + nodes: workflowUpdateData.nodes ?? workflow.nodes, + }, + storedWorkflow: { id: workflow.id, name: workflow.name, nodes: workflow.nodes }, + projectId: ownerProject.id, }, - storedWorkflow: { id: workflow.id, name: workflow.name, nodes: workflow.nodes }, - projectId: ownerProject.id, - }); + { kind: 'user', user }, + ); const fieldsToUpdate = [ 'name', @@ -796,7 +796,9 @@ export class WorkflowService { ): Promise { let didPublish = false; try { - await this.activeWorkflowManager.add(workflowId, mode); + await this.activeWorkflowManager.add(workflowId, mode, undefined, { + actor: { kind: 'user', user }, + }); didPublish = true; } catch (error) { // Activation failed partway through. It may already have registered triggers @@ -1033,11 +1035,12 @@ export class WorkflowService { // Polices what gets registered — the version row, not the hook's candidate. // Enforced on a same-version republish too. - await enforceWorkflowPublishPolicy(this.policyEnforcementService, this.ownershipService, { - id: workflowId, - name: workflow.name, - nodes: nodesToPublish, - }); + await enforceWorkflowPublishPolicy( + this.policyEnforcementService, + this.ownershipService, + { id: workflowId, name: workflow.name, nodes: nodesToPublish }, + { kind: 'user', user }, + ); // re-applying the already-published version (e.g. a settings-only update) // publishes no new version, so the review gate must not block it. diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 4ecf49e9a63..537330a9a4b 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -12,6 +12,7 @@ import { } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { OutboundHttp, SsrfBlockedIpError } from '@n8n/backend-network'; +import { EventService } from '@n8n/backend-services'; import { GlobalConfig } from '@n8n/config'; import { AuthenticatedRequest, @@ -41,10 +42,7 @@ import express from 'express'; import { calculateWorkflowChecksum } from 'n8n-workflow'; import { AuthService } from '@/auth/auth.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; import { ExecutionService } from '@/executions/execution.service'; import { IWorkflowResponse } from '@/interfaces'; import { License } from '@/license'; diff --git a/packages/cli/test/integration/activation-errors.service.test.ts b/packages/cli/test/integration/activation-errors.service.test.ts index fd1bae94c18..3274ff9d8d6 100644 --- a/packages/cli/test/integration/activation-errors.service.test.ts +++ b/packages/cli/test/integration/activation-errors.service.test.ts @@ -2,7 +2,7 @@ import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { ActivationErrorsService } from '@/activation-errors.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; describe('ActivationErrorsService', () => { const globalConfig = mockInstance(GlobalConfig, { diff --git a/packages/cli/test/integration/active-workflow-manager.test.ts b/packages/cli/test/integration/active-workflow-manager.test.ts index 450bc664825..29b21f85df3 100644 --- a/packages/cli/test/integration/active-workflow-manager.test.ts +++ b/packages/cli/test/integration/active-workflow-manager.test.ts @@ -150,6 +150,10 @@ describe('init()', () => { await activeWorkflowManager.init(); expect(enforceSpy).toHaveBeenCalledTimes(2); + expect(enforceSpy).toHaveBeenCalledWith(expect.anything(), { + kind: 'system', + reason: 'activation', + }); expect(activeWorkflowManager.allActiveInMemory()).toHaveLength(2); }); }); diff --git a/packages/cli/test/integration/activity-event-relay.test.ts b/packages/cli/test/integration/activity-event-relay.test.ts index 4dec6233971..81b0f7a2b6c 100644 --- a/packages/cli/test/integration/activity-event-relay.test.ts +++ b/packages/cli/test/integration/activity-event-relay.test.ts @@ -1,5 +1,6 @@ import { INSTANCE_ACTIVITY_CONTEXT_FLAG } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { createTeamProject, createWorkflow, testDb } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; @@ -8,7 +9,6 @@ import type { Project, User, WorkflowEntity, ActivityEvent } from '@n8n/db'; import { Container } from '@n8n/di'; import type { INode } from 'n8n-workflow'; -import { EventService } from '@/events/event.service'; import { ActivityEventRelay } from '@/events/relays/activity.event-relay'; import { PostHogClient } from '@/posthog'; import { ActivityPruningTask } from '@/services/pruning/activity-pruning.task'; diff --git a/packages/cli/test/integration/agents/cassettes/n8n-chat-production/allows-only-the-session-owner-to-cancel-a-suspended-run.json b/packages/cli/test/integration/agents/cassettes/n8n-chat-production/allows-only-the-session-owner-to-cancel-a-suspended-run.json index 59650226f2c..8f76104a489 100644 --- a/packages/cli/test/integration/agents/cassettes/n8n-chat-production/allows-only-the-session-owner-to-cancel-a-suspended-run.json +++ b/packages/cli/test/integration/agents/cassettes/n8n-chat-production/allows-only-the-session-owner-to-cancel-a-suspended-run.json @@ -3,10 +3,10 @@ "scope": "https://api.openai.com:443", "method": "POST", "path": "/v1/responses", - "body": {"model":"gpt-4o-mini","input":[{"role":"system","content":"\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 10 child sub-agent runs can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n- write_todos helps you plan and track complex objectives before and during execution. It updates the current task list only; it does not complete tasks, run sub-agents, or answer the user.\nWHEN TO USE write_todos:\n- The user request has 3+ meaningful steps or multiple deliverables.\n- The request decomposes into 2+ independent workstreams.\n- Some workstreams are good candidates for delegate_subagent.\n- You need to track progress, revise the plan, or avoid losing context.\nWHEN NOT TO USE write_todos:\n- The request is trivial, conversational, or informational.\n- The task can be completed directly in one or two simple steps.\n- You would only create a todo list to restate the user's request.\nHOW TO USE write_todos:\n- Write concrete, self-contained tasks, not vague phases.\n- Assign difficulty to every task: low for simple mechanical or localized work, medium for normal implementation or review requiring judgment, high for complex, broad, ambiguous, or high-risk work.\n- Mark the first active task, or independent active tasks, as in_progress immediately.\n- For sub-agent-worthy work, create one todo per bounded workstream, then call delegate_subagent separately for that task; pass the same difficulty on the delegate call.\n- Do not delegate the entire user request as one task.\n- Update task status as soon as work completes; do not batch completions at the end.\n- Revise the list when new information changes the plan.\n- Do not call write_todos multiple times in parallel; send one full list update at a time.\n- After all work is done, send the final answer as normal assistant text after the last write_todos call.\n\n\nWhen asked to record a marker, call approve_marker with that marker."},{"role":"user","content":[{"type":"input_text","text":"Record marker B17 using approve_marker."}]}],"tools":[{"type":"function","name":"approve_marker","description":"Approve a marker before recording it","parameters":{"type":"object","properties":{"marker":{"type":"string"}},"required":["marker"]}},{"type":"function","name":"get_environment","description":"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \"today\", deadlines, or scheduling. Tool always requires an empty object as input.","parameters":{"type":"object","properties":{},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_context","description":"Read context from the n8n_chat integration connection.\n\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\n\nQuery inputs:\n\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\n\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\n\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\n\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\n\nUse this tool for read-only lookup before choosing an action target.","parameters":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}},"queries":{"type":"array","items":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}}},"required":["query"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_action","description":"Take actions in the n8n_chat integration connection.\n\nAvailable actions: respond.\n\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\n\nAction inputs:\n\n- respond: input.message is required. Responds in the latest message context for this integration connection.\n\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\n\nrespond uses the latest message context for this integration connection.\n\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\n\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\n\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\n\nFor fields components, use { type: \"fields\", fields: [{ label: \"Account\", value: \"Acme\" }] }. The key items is also accepted as a fields alias.\n\nFor button components, use { type: \"button\", label: \"Approve\", value: \"approve\" }. If label is omitted, text is used as the button label.\n\nFor radio-style choices, use { type: \"radio_select\", label: \"Next step\", options: [{ label: \"Approve\", value: \"approve\" }] }.\n\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\n\nGeneric card examples:\n\nRadio choice card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Acme Executive Briefing — Next Steps\",\n \"card\": {\n \"title\": \"Acme Corporation — Executive Briefing Next Steps\",\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\n\\nWhat should happen next?\"\n },\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"radio_select\",\n \"id\": \"next_step_selection\",\n \"label\": \"Next step\",\n \"options\": [\n {\n \"label\": \"Schedule exec briefing prep call with Paul G\",\n \"value\": \"schedule_prep_call\"\n },\n {\n \"label\": \"Draft briefing agenda doc for Mike D\",\n \"value\": \"draft_briefing_doc\"\n }\n ]\n }\n ]\n }\n }\n }\n}\n```\n\nButton card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Approve or revise the briefing draft\",\n \"card\": {\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"Review the briefing draft.\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Approve\",\n \"value\": \"approve\",\n \"style\": \"primary\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Revise\",\n \"value\": \"revise\",\n \"style\": \"default\"\n }\n ]\n }\n }\n }\n}\n```\n\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \"plain_text\", text: \"...\" } or { type: \"mrkdwn\", text: \"...\" }.\n\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\n\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.","parameters":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}},"actions":{"type":"array","items":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}}},"required":["action"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"delegate_subagent","description":"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.","parameters":{"type":"object","properties":{"subAgentId":{"type":"string","minLength":1,"description":"Required. Use \"inline\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task."},"taskName":{"type":"string","minLength":1,"description":"Short human-readable name for this delegated task, e.g. \"research_api\"."},"goal":{"type":"string","minLength":1,"description":"The concrete goal the sub-agent should accomplish."},"context":{"type":"string","description":"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out."},"expectedOutput":{"type":"string","description":"The expected shape or contents of the answer."},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning."}},"required":["subAgentId","taskName","goal"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"write_todos","description":"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.","parameters":{"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Stable identifier for this task within the current plan."},"content":{"type":"string","minLength":1,"description":"Concrete, self-contained task description."},"status":{"type":"string","enum":["pending","in_progress","completed","blocked","cancelled"]},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Task difficulty: \"low\", \"medium\", or \"high\". Use the same value when delegating this task with delegate_subagent."},"delegateHint":{"type":"object","properties":{"subAgentId":{"type":"string","description":"Optional sub-agent id when this task is a delegate_subagent candidate. Use \"inline\" for one-off inline sub-agents."},"expectedOutput":{"type":"string","description":"Optional expected output shape when delegating this task."}},"additionalProperties":false}},"required":["id","content","status","difficulty"],"additionalProperties":false},"description":"Full task list for the current run. Replaces any previous list."}},"required":["todos"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}],"tool_choice":"auto","stream":true}, + "body": {"model":"gpt-4o-mini","input":[{"role":"system","content":"\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 10 child sub-agent runs can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n- write_todos helps you plan and track complex objectives before and during execution. It updates the current task list only; it does not complete tasks, run sub-agents, or answer the user.\nWHEN TO USE write_todos:\n- The user request has 3+ meaningful steps or multiple deliverables.\n- The request decomposes into 2+ independent workstreams.\n- Some workstreams are good candidates for delegate_subagent.\n- You need to track progress, revise the plan, or avoid losing context.\nWHEN NOT TO USE write_todos:\n- The request is trivial, conversational, or informational.\n- The task can be completed directly in one or two simple steps.\n- You would only create a todo list to restate the user's request.\nHOW TO USE write_todos:\n- Write concrete, self-contained tasks, not vague phases.\n- Assign difficulty to every task: low for simple mechanical or localized work, medium for normal implementation or review requiring judgment, high for complex, broad, ambiguous, or high-risk work.\n- Mark the first active task, or independent active tasks, as in_progress immediately.\n- For sub-agent-worthy work, create one todo per bounded workstream, then call delegate_subagent separately for that task; pass the same difficulty on the delegate call.\n- Do not delegate the entire user request as one task.\n- Update task status as soon as work completes; do not batch completions at the end.\n- Revise the list when new information changes the plan.\n- Do not call write_todos multiple times in parallel; send one full list update at a time.\n- After all work is done, send the final answer as normal assistant text after the last write_todos call.\n\n\nWhen asked to record a marker, call approve_marker with that marker."},{"role":"user","content":[{"type":"input_text","text":"Record marker B17 using approve_marker."}]}],"tools":[{"type":"function","name":"approve_marker","description":"Approve a marker before recording it","parameters":{"type":"object","properties":{"marker":{"type":"string"}},"required":["marker"]}},{"type":"function","name":"get_environment","description":"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \"today\", deadlines, or scheduling. Tool always requires an empty object as input.","parameters":{"type":"object","properties":{},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_context","description":"Read context from the n8n_chat integration connection.\n\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\n\nQuery inputs:\n\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\n\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\n\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\n\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\n\nUse this tool for read-only lookup before choosing an action target.","parameters":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}},"queries":{"type":"array","items":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}}},"required":["query"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_action","description":"Take actions in the n8n_chat integration connection.\n\nAvailable actions: respond.\n\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\n\nAction inputs:\n\n- respond: input.message is required. Responds in the latest message context for this integration connection.\n\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\n\nrespond uses the latest message context for this integration connection.\n\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\n\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\n\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\n\nFor fields components, use { type: \"fields\", fields: [{ label: \"Account\", value: \"Acme\" }] }. The key items is also accepted as a fields alias.\n\nFor button components, use { type: \"button\", label: \"Approve\", value: \"approve\" }. If label is omitted, text is used as the button label.\n\nFor radio-style choices, use { type: \"radio_select\", label: \"Next step\", options: [{ label: \"Approve\", value: \"approve\" }] }.\n\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\n\nGeneric card examples:\n\nRadio choice card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Acme Executive Briefing — Next Steps\",\n \"card\": {\n \"title\": \"Acme Corporation — Executive Briefing Next Steps\",\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\n\\nWhat should happen next?\"\n },\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"radio_select\",\n \"id\": \"next_step_selection\",\n \"label\": \"Next step\",\n \"options\": [\n {\n \"label\": \"Schedule exec briefing prep call with Paul G\",\n \"value\": \"schedule_prep_call\"\n },\n {\n \"label\": \"Draft briefing agenda doc for Mike D\",\n \"value\": \"draft_briefing_doc\"\n }\n ]\n }\n ]\n }\n }\n }\n}\n```\n\nButton card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Approve or revise the briefing draft\",\n \"card\": {\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"Review the briefing draft.\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Approve\",\n \"value\": \"approve\",\n \"style\": \"primary\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Revise\",\n \"value\": \"revise\",\n \"style\": \"default\"\n }\n ]\n }\n }\n }\n}\n```\n\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \"plain_text\", text: \"...\" } or { type: \"mrkdwn\", text: \"...\" }.\n\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\n\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.","parameters":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}},"actions":{"type":"array","items":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}}},"required":["action"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"delegate_subagent","description":"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.","parameters":{"type":"object","properties":{"subAgentId":{"type":"string","minLength":1,"description":"Required. Use \"inline\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task."},"taskName":{"type":"string","minLength":1,"description":"Short human-readable name for this delegated task, e.g. \"research_api\"."},"goal":{"type":"string","minLength":1,"description":"The concrete goal the sub-agent should accomplish."},"context":{"type":"string","description":"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out."},"expectedOutput":{"type":"string","description":"The expected shape or contents of the answer."},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning."}},"required":["subAgentId","taskName","goal"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"write_todos","description":"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.","parameters":{"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Stable identifier for this task within the current plan."},"content":{"type":"string","minLength":1,"description":"Concrete, self-contained task description."},"status":{"type":"string","enum":["pending","in_progress","completed","blocked","cancelled"]},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Task difficulty: \"low\", \"medium\", or \"high\". Use the same value when delegating this task with delegate_subagent."},"delegateHint":{"type":"object","properties":{"subAgentId":{"type":"string","description":"Optional sub-agent id when this task is a delegate_subagent candidate. Use \"inline\" for one-off inline sub-agents."},"expectedOutput":{"type":"string","description":"Optional expected output shape when delegating this task."}},"additionalProperties":false}},"required":["id","content","status","difficulty"],"additionalProperties":false},"description":"Full task list for the current run. Replaces any previous list."}},"required":["todos"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"mark_session_failed","description":"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.","parameters":{"type":"object","properties":{"reason":{"type":"string","minLength":1,"maxLength":400}},"required":["reason"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}],"tool_choice":"auto","stream":true}, "status": 200, - "response": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0a26a3fd93c0bead006ab433ef57c487d29b83f02b3985d03a\",\"object\":\"response\",\"created_at\":1790194671,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0a26a3fd93c0bead006ab433ef57c487d29b83f02b3985d03a\",\"object\":\"response\",\"created_at\":1790194671,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_0a26a3fd93c0bead006ab433effa3c87d29a57424f1021a889\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_Tx01tWwCOMBZW8w8JTYsCYFQ\",\"name\":\"approve_marker\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_0a26a3fd93c0bead006ab433effa3c87d29a57424f1021a889\",\"obfuscation\":\"edo8es2ddc4jp8\",\"output_index\":0,\"sequence_number\":3}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"marker\",\"item_id\":\"fc_0a26a3fd93c0bead006ab433effa3c87d29a57424f1021a889\",\"obfuscation\":\"XVG9iPHlnV\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_0a26a3fd93c0bead006ab433effa3c87d29a57424f1021a889\",\"obfuscation\":\"WOizNWSI1lj9w\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"B\",\"item_id\":\"fc_0a26a3fd93c0bead006ab433effa3c87d29a57424f1021a889\",\"obfuscation\":\"o5tuZBiUHeSGpSC\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"17\",\"item_id\":\"fc_0a26a3fd93c0bead006ab433effa3c87d29a57424f1021a889\",\"obfuscation\":\"rOBIBCaMT1XUVO\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_0a26a3fd93c0bead006ab433effa3c87d29a57424f1021a889\",\"obfuscation\":\"dgjRiSIVvYzeer\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"marker\\\":\\\"B17\\\"}\",\"item_id\":\"fc_0a26a3fd93c0bead006ab433effa3c87d29a57424f1021a889\",\"output_index\":0,\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_0a26a3fd93c0bead006ab433effa3c87d29a57424f1021a889\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"marker\\\":\\\"B17\\\"}\",\"call_id\":\"call_Tx01tWwCOMBZW8w8JTYsCYFQ\",\"name\":\"approve_marker\"},\"output_index\":0,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0a26a3fd93c0bead006ab433ef57c487d29b83f02b3985d03a\",\"object\":\"response\",\"created_at\":1790194671,\"status\":\"completed\",\"access_programs\":null,\"background\":false,\"completed_at\":1790194672,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[{\"id\":\"fc_0a26a3fd93c0bead006ab433effa3c87d29a57424f1021a889\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"marker\\\":\\\"B17\\\"}\",\"call_id\":\"call_Tx01tWwCOMBZW8w8JTYsCYFQ\",\"name\":\"approve_marker\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":2604,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":17,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":2621},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n", - "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a3fc3c370d8e223b-MAN","connection":"keep-alive","content-type":"text/event-stream; charset=utf-8","date":"Wed, 23 Sep 2026 20:17:51 GMT","openai-processing-ms":"535","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","transfer-encoding":"chunked","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149997180","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"1ms","x-request-id":"req_18ac0db7461c47acbba8b504843939d7"}, + "response": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0e1a4b3b3ac636de006aba7afa6aec87d298bf59ae917c1d59\",\"object\":\"response\",\"created_at\":1790606074,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0e1a4b3b3ac636de006aba7afa6aec87d298bf59ae917c1d59\",\"object\":\"response\",\"created_at\":1790606074,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_0e1a4b3b3ac636de006aba7afb10a487d28df9be7646438775\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_jwjVijmlrMNFx37ePAbeNmVU\",\"name\":\"approve_marker\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_0e1a4b3b3ac636de006aba7afb10a487d28df9be7646438775\",\"obfuscation\":\"AVRs3KE9Deb9A2\",\"output_index\":0,\"sequence_number\":3}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"marker\",\"item_id\":\"fc_0e1a4b3b3ac636de006aba7afb10a487d28df9be7646438775\",\"obfuscation\":\"zeHu9iz65L\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_0e1a4b3b3ac636de006aba7afb10a487d28df9be7646438775\",\"obfuscation\":\"xYp5KGp9wJVRQ\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"B\",\"item_id\":\"fc_0e1a4b3b3ac636de006aba7afb10a487d28df9be7646438775\",\"obfuscation\":\"B7BwI7UxUWhoc5P\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"17\",\"item_id\":\"fc_0e1a4b3b3ac636de006aba7afb10a487d28df9be7646438775\",\"obfuscation\":\"AGToTmRWH3VAnr\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_0e1a4b3b3ac636de006aba7afb10a487d28df9be7646438775\",\"obfuscation\":\"gb0BGEEc08ig41\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"marker\\\":\\\"B17\\\"}\",\"item_id\":\"fc_0e1a4b3b3ac636de006aba7afb10a487d28df9be7646438775\",\"output_index\":0,\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_0e1a4b3b3ac636de006aba7afb10a487d28df9be7646438775\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"marker\\\":\\\"B17\\\"}\",\"call_id\":\"call_jwjVijmlrMNFx37ePAbeNmVU\",\"name\":\"approve_marker\"},\"output_index\":0,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0e1a4b3b3ac636de006aba7afa6aec87d298bf59ae917c1d59\",\"object\":\"response\",\"created_at\":1790606074,\"status\":\"completed\",\"access_programs\":null,\"background\":false,\"completed_at\":1790606075,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[{\"id\":\"fc_0e1a4b3b3ac636de006aba7afb10a487d28df9be7646438775\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"marker\\\":\\\"B17\\\"}\",\"call_id\":\"call_jwjVijmlrMNFx37ePAbeNmVU\",\"name\":\"approve_marker\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":2671,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":17,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":2688},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n", + "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a423783c189848e7-LHR","connection":"keep-alive","content-type":"text/event-stream; charset=utf-8","date":"Mon, 28 Sep 2026 14:34:34 GMT","openai-processing-ms":"521","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","transfer-encoding":"chunked","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149997112","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"1ms","x-request-id":"req_85c321926a2744cc8a29c1a8de94fc4a"}, "responseIsBinary": false } ] diff --git a/packages/cli/test/integration/agents/cassettes/n8n-chat-production/sends-an-image-to-the-model-and-reloads-the-stored-attachment.json b/packages/cli/test/integration/agents/cassettes/n8n-chat-production/sends-an-image-to-the-model-and-reloads-the-stored-attachment.json index 7c2ac5baa27..65e27d5c970 100644 --- a/packages/cli/test/integration/agents/cassettes/n8n-chat-production/sends-an-image-to-the-model-and-reloads-the-stored-attachment.json +++ b/packages/cli/test/integration/agents/cassettes/n8n-chat-production/sends-an-image-to-the-model-and-reloads-the-stored-attachment.json @@ -3,10 +3,10 @@ "scope": "https://api.openai.com:443", "method": "POST", "path": "/v1/responses", - "body": {"model":"gpt-4o-mini","input":[{"role":"system","content":"\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 10 child sub-agent runs can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n- write_todos helps you plan and track complex objectives before and during execution. It updates the current task list only; it does not complete tasks, run sub-agents, or answer the user.\nWHEN TO USE write_todos:\n- The user request has 3+ meaningful steps or multiple deliverables.\n- The request decomposes into 2+ independent workstreams.\n- Some workstreams are good candidates for delegate_subagent.\n- You need to track progress, revise the plan, or avoid losing context.\nWHEN NOT TO USE write_todos:\n- The request is trivial, conversational, or informational.\n- The task can be completed directly in one or two simple steps.\n- You would only create a todo list to restate the user's request.\nHOW TO USE write_todos:\n- Write concrete, self-contained tasks, not vague phases.\n- Assign difficulty to every task: low for simple mechanical or localized work, medium for normal implementation or review requiring judgment, high for complex, broad, ambiguous, or high-risk work.\n- Mark the first active task, or independent active tasks, as in_progress immediately.\n- For sub-agent-worthy work, create one todo per bounded workstream, then call delegate_subagent separately for that task; pass the same difficulty on the delegate call.\n- Do not delegate the entire user request as one task.\n- Update task status as soon as work completes; do not batch completions at the end.\n- Revise the list when new information changes the plan.\n- Do not call write_todos multiple times in parallel; send one full list update at a time.\n- After all work is done, send the final answer as normal assistant text after the last write_todos call.\n\n\nDescribe the color of an attached image in one word."},{"role":"user","content":[{"type":"input_text","text":"What color is this image?"},{"type":"input_image","image_url":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"}]}],"tools":[{"type":"function","name":"get_environment","description":"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \"today\", deadlines, or scheduling. Tool always requires an empty object as input.","parameters":{"type":"object","properties":{},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_context","description":"Read context from the n8n_chat integration connection.\n\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\n\nQuery inputs:\n\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\n\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\n\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\n\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\n\nUse this tool for read-only lookup before choosing an action target.","parameters":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}},"queries":{"type":"array","items":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}}},"required":["query"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_action","description":"Take actions in the n8n_chat integration connection.\n\nAvailable actions: respond.\n\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\n\nAction inputs:\n\n- respond: input.message is required. Responds in the latest message context for this integration connection.\n\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\n\nrespond uses the latest message context for this integration connection.\n\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\n\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\n\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\n\nFor fields components, use { type: \"fields\", fields: [{ label: \"Account\", value: \"Acme\" }] }. The key items is also accepted as a fields alias.\n\nFor button components, use { type: \"button\", label: \"Approve\", value: \"approve\" }. If label is omitted, text is used as the button label.\n\nFor radio-style choices, use { type: \"radio_select\", label: \"Next step\", options: [{ label: \"Approve\", value: \"approve\" }] }.\n\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\n\nGeneric card examples:\n\nRadio choice card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Acme Executive Briefing — Next Steps\",\n \"card\": {\n \"title\": \"Acme Corporation — Executive Briefing Next Steps\",\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\n\\nWhat should happen next?\"\n },\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"radio_select\",\n \"id\": \"next_step_selection\",\n \"label\": \"Next step\",\n \"options\": [\n {\n \"label\": \"Schedule exec briefing prep call with Paul G\",\n \"value\": \"schedule_prep_call\"\n },\n {\n \"label\": \"Draft briefing agenda doc for Mike D\",\n \"value\": \"draft_briefing_doc\"\n }\n ]\n }\n ]\n }\n }\n }\n}\n```\n\nButton card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Approve or revise the briefing draft\",\n \"card\": {\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"Review the briefing draft.\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Approve\",\n \"value\": \"approve\",\n \"style\": \"primary\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Revise\",\n \"value\": \"revise\",\n \"style\": \"default\"\n }\n ]\n }\n }\n }\n}\n```\n\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \"plain_text\", text: \"...\" } or { type: \"mrkdwn\", text: \"...\" }.\n\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\n\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.","parameters":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}},"actions":{"type":"array","items":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}}},"required":["action"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"delegate_subagent","description":"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.","parameters":{"type":"object","properties":{"subAgentId":{"type":"string","minLength":1,"description":"Required. Use \"inline\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task."},"taskName":{"type":"string","minLength":1,"description":"Short human-readable name for this delegated task, e.g. \"research_api\"."},"goal":{"type":"string","minLength":1,"description":"The concrete goal the sub-agent should accomplish."},"context":{"type":"string","description":"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out."},"expectedOutput":{"type":"string","description":"The expected shape or contents of the answer."},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning."}},"required":["subAgentId","taskName","goal"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"write_todos","description":"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.","parameters":{"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Stable identifier for this task within the current plan."},"content":{"type":"string","minLength":1,"description":"Concrete, self-contained task description."},"status":{"type":"string","enum":["pending","in_progress","completed","blocked","cancelled"]},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Task difficulty: \"low\", \"medium\", or \"high\". Use the same value when delegating this task with delegate_subagent."},"delegateHint":{"type":"object","properties":{"subAgentId":{"type":"string","description":"Optional sub-agent id when this task is a delegate_subagent candidate. Use \"inline\" for one-off inline sub-agents."},"expectedOutput":{"type":"string","description":"Optional expected output shape when delegating this task."}},"additionalProperties":false}},"required":["id","content","status","difficulty"],"additionalProperties":false},"description":"Full task list for the current run. Replaces any previous list."}},"required":["todos"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}],"tool_choice":"auto","stream":true}, + "body": {"model":"gpt-4o-mini","input":[{"role":"system","content":"\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 10 child sub-agent runs can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n- write_todos helps you plan and track complex objectives before and during execution. It updates the current task list only; it does not complete tasks, run sub-agents, or answer the user.\nWHEN TO USE write_todos:\n- The user request has 3+ meaningful steps or multiple deliverables.\n- The request decomposes into 2+ independent workstreams.\n- Some workstreams are good candidates for delegate_subagent.\n- You need to track progress, revise the plan, or avoid losing context.\nWHEN NOT TO USE write_todos:\n- The request is trivial, conversational, or informational.\n- The task can be completed directly in one or two simple steps.\n- You would only create a todo list to restate the user's request.\nHOW TO USE write_todos:\n- Write concrete, self-contained tasks, not vague phases.\n- Assign difficulty to every task: low for simple mechanical or localized work, medium for normal implementation or review requiring judgment, high for complex, broad, ambiguous, or high-risk work.\n- Mark the first active task, or independent active tasks, as in_progress immediately.\n- For sub-agent-worthy work, create one todo per bounded workstream, then call delegate_subagent separately for that task; pass the same difficulty on the delegate call.\n- Do not delegate the entire user request as one task.\n- Update task status as soon as work completes; do not batch completions at the end.\n- Revise the list when new information changes the plan.\n- Do not call write_todos multiple times in parallel; send one full list update at a time.\n- After all work is done, send the final answer as normal assistant text after the last write_todos call.\n\n\nDescribe the color of an attached image in one word."},{"role":"user","content":[{"type":"input_text","text":"What color is this image?"},{"type":"input_image","image_url":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"}]}],"tools":[{"type":"function","name":"get_environment","description":"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \"today\", deadlines, or scheduling. Tool always requires an empty object as input.","parameters":{"type":"object","properties":{},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_context","description":"Read context from the n8n_chat integration connection.\n\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\n\nQuery inputs:\n\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\n\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\n\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\n\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\n\nUse this tool for read-only lookup before choosing an action target.","parameters":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}},"queries":{"type":"array","items":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}}},"required":["query"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_action","description":"Take actions in the n8n_chat integration connection.\n\nAvailable actions: respond.\n\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\n\nAction inputs:\n\n- respond: input.message is required. Responds in the latest message context for this integration connection.\n\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\n\nrespond uses the latest message context for this integration connection.\n\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\n\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\n\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\n\nFor fields components, use { type: \"fields\", fields: [{ label: \"Account\", value: \"Acme\" }] }. The key items is also accepted as a fields alias.\n\nFor button components, use { type: \"button\", label: \"Approve\", value: \"approve\" }. If label is omitted, text is used as the button label.\n\nFor radio-style choices, use { type: \"radio_select\", label: \"Next step\", options: [{ label: \"Approve\", value: \"approve\" }] }.\n\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\n\nGeneric card examples:\n\nRadio choice card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Acme Executive Briefing — Next Steps\",\n \"card\": {\n \"title\": \"Acme Corporation — Executive Briefing Next Steps\",\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\n\\nWhat should happen next?\"\n },\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"radio_select\",\n \"id\": \"next_step_selection\",\n \"label\": \"Next step\",\n \"options\": [\n {\n \"label\": \"Schedule exec briefing prep call with Paul G\",\n \"value\": \"schedule_prep_call\"\n },\n {\n \"label\": \"Draft briefing agenda doc for Mike D\",\n \"value\": \"draft_briefing_doc\"\n }\n ]\n }\n ]\n }\n }\n }\n}\n```\n\nButton card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Approve or revise the briefing draft\",\n \"card\": {\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"Review the briefing draft.\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Approve\",\n \"value\": \"approve\",\n \"style\": \"primary\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Revise\",\n \"value\": \"revise\",\n \"style\": \"default\"\n }\n ]\n }\n }\n }\n}\n```\n\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \"plain_text\", text: \"...\" } or { type: \"mrkdwn\", text: \"...\" }.\n\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\n\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.","parameters":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}},"actions":{"type":"array","items":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}}},"required":["action"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"delegate_subagent","description":"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.","parameters":{"type":"object","properties":{"subAgentId":{"type":"string","minLength":1,"description":"Required. Use \"inline\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task."},"taskName":{"type":"string","minLength":1,"description":"Short human-readable name for this delegated task, e.g. \"research_api\"."},"goal":{"type":"string","minLength":1,"description":"The concrete goal the sub-agent should accomplish."},"context":{"type":"string","description":"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out."},"expectedOutput":{"type":"string","description":"The expected shape or contents of the answer."},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning."}},"required":["subAgentId","taskName","goal"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"write_todos","description":"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.","parameters":{"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Stable identifier for this task within the current plan."},"content":{"type":"string","minLength":1,"description":"Concrete, self-contained task description."},"status":{"type":"string","enum":["pending","in_progress","completed","blocked","cancelled"]},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Task difficulty: \"low\", \"medium\", or \"high\". Use the same value when delegating this task with delegate_subagent."},"delegateHint":{"type":"object","properties":{"subAgentId":{"type":"string","description":"Optional sub-agent id when this task is a delegate_subagent candidate. Use \"inline\" for one-off inline sub-agents."},"expectedOutput":{"type":"string","description":"Optional expected output shape when delegating this task."}},"additionalProperties":false}},"required":["id","content","status","difficulty"],"additionalProperties":false},"description":"Full task list for the current run. Replaces any previous list."}},"required":["todos"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"mark_session_failed","description":"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.","parameters":{"type":"object","properties":{"reason":{"type":"string","minLength":1,"maxLength":400}},"required":["reason"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}],"tool_choice":"auto","stream":true}, "status": 200, - "response": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_01fb8a948ff3bb94006ab433ae2df487d2ab13e08eb208a913\",\"object\":\"response\",\"created_at\":1790194606,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_01fb8a948ff3bb94006ab433ae2df487d2ab13e08eb208a913\",\"object\":\"response\",\"created_at\":1790194606,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_01fb8a948ff3bb94006ab433aee08c87d2b76bc5430bf85a80\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_01fb8a948ff3bb94006ab433aee08c87d2b76bc5430bf85a80\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Red\",\"item_id\":\"msg_01fb8a948ff3bb94006ab433aee08c87d2b76bc5430bf85a80\",\"logprobs\":[],\"obfuscation\":\"DD5aFMVoe59LZ\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_01fb8a948ff3bb94006ab433aee08c87d2b76bc5430bf85a80\",\"logprobs\":[],\"obfuscation\":\"9YBk7ReLA1rpJow\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_01fb8a948ff3bb94006ab433aee08c87d2b76bc5430bf85a80\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":6,\"text\":\"Red.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_01fb8a948ff3bb94006ab433aee08c87d2b76bc5430bf85a80\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Red.\"},\"sequence_number\":7}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_01fb8a948ff3bb94006ab433aee08c87d2b76bc5430bf85a80\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Red.\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":8}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_01fb8a948ff3bb94006ab433ae2df487d2ab13e08eb208a913\",\"object\":\"response\",\"created_at\":1790194606,\"status\":\"completed\",\"access_programs\":null,\"background\":false,\"completed_at\":1790194606,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[{\"id\":\"msg_01fb8a948ff3bb94006ab433aee08c87d2b76bc5430bf85a80\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Red.\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":11077,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":4,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":11081},\"user\":null,\"metadata\":{}},\"sequence_number\":9}\n\n", - "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a3fc3a9fbd9b58b3-MAN","connection":"keep-alive","content-type":"text/event-stream; charset=utf-8","date":"Wed, 23 Sep 2026 20:16:46 GMT","openai-processing-ms":"727","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","transfer-encoding":"chunked","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149999235","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"0s","x-request-id":"req_19f22d7bfc784a6c8bc36df92a19cf98"}, + "response": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_08e058162827d321006aba7af4dc5887d2a26719f5a20cab70\",\"object\":\"response\",\"created_at\":1790606068,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_08e058162827d321006aba7af4dc5887d2a26719f5a20cab70\",\"object\":\"response\",\"created_at\":1790606068,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_08e058162827d321006aba7af5a8c887d2806c04baf5671db1\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_08e058162827d321006aba7af5a8c887d2806c04baf5671db1\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Red\",\"item_id\":\"msg_08e058162827d321006aba7af5a8c887d2806c04baf5671db1\",\"logprobs\":[],\"obfuscation\":\"1ZBCCMroO9kBK\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_08e058162827d321006aba7af5a8c887d2806c04baf5671db1\",\"logprobs\":[],\"obfuscation\":\"o0xYYpVIIzwZxJP\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_08e058162827d321006aba7af5a8c887d2806c04baf5671db1\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":6,\"text\":\"Red.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_08e058162827d321006aba7af5a8c887d2806c04baf5671db1\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Red.\"},\"sequence_number\":7}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_08e058162827d321006aba7af5a8c887d2806c04baf5671db1\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Red.\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":8}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_08e058162827d321006aba7af4dc5887d2a26719f5a20cab70\",\"object\":\"response\",\"created_at\":1790606068,\"status\":\"completed\",\"access_programs\":null,\"background\":false,\"completed_at\":1790606069,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[{\"id\":\"msg_08e058162827d321006aba7af5a8c887d2806c04baf5671db1\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Red.\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":11144,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":4,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":11148},\"user\":null,\"metadata\":{}},\"sequence_number\":9}\n\n", + "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a42378198e4148e7-LHR","connection":"keep-alive","content-type":"text/event-stream; charset=utf-8","date":"Mon, 28 Sep 2026 14:34:29 GMT","openai-processing-ms":"822","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","transfer-encoding":"chunked","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149999235","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"0s","x-request-id":"req_54c6a86a955c4df796a53da6567cc460"}, "responseIsBinary": false }, { @@ -15,8 +15,8 @@ "path": "/v1/responses", "body": {"model":"gpt-4o-mini","input":[{"role":"system","content":"Generate a short title and a single emoji for a conversation based on the user message.\nRespond with ONLY a JSON object: {\"title\": \"...\", \"emoji\": \"...\"}\nRules:\n- Title: 2 to 6 words, max 50 characters, sentence case\n- Title must not contain emoji, quotes, colons, or markdown formatting\n- Emoji: exactly one emoji that represents the topic\n- Respond with the JSON object only, no other text"},{"role":"user","content":[{"type":"input_text","text":"\nHere's the conversation:\n\nWhat color is this image?\n"}]}]}, "status": 200, - "response": "{\n \"id\": \"resp_09781f301d6c7b2e006ab433af735c87d2a012bace042c5b14\",\n \"object\": \"response\",\n \"created_at\": 1790194607,\n \"status\": \"completed\",\n \"access_programs\": null,\n \"background\": false,\n \"billing\": {\n \"payer\": \"developer\"\n },\n \"completed_at\": 1790194607,\n \"error\": null,\n \"frequency_penalty\": 0.0,\n \"incomplete_details\": null,\n \"instructions\": null,\n \"max_output_tokens\": null,\n \"max_tool_calls\": null,\n \"model\": \"gpt-4o-mini-2024-07-18\",\n \"moderation\": null,\n \"output\": [\n {\n \"id\": \"msg_09781f301d6c7b2e006ab433afd9d087d29ee2a11b27599dac\",\n \"type\": \"message\",\n \"status\": \"completed\",\n \"content\": [\n {\n \"type\": \"output_text\",\n \"annotations\": [],\n \"logprobs\": [],\n \"text\": \"{\\\"title\\\":\\\"Identifying colors in images\\\",\\\"emoji\\\":\\\"\\ud83c\\udfa8\\\"}\"\n }\n ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\": true,\n \"presence_penalty\": 0.0,\n \"previous_response_id\": null,\n \"prompt_cache_key\": null,\n \"prompt_cache_retention\": \"in_memory\",\n \"reasoning\": {\n \"context\": null,\n \"effort\": null,\n \"summary\": null\n },\n \"safety_identifier\": null,\n \"service_tier\": \"default\",\n \"store\": true,\n \"temperature\": 1.0,\n \"text\": {\n \"format\": {\n \"type\": \"text\"\n },\n \"verbosity\": \"medium\"\n },\n \"tool_choice\": \"auto\",\n \"tool_usage\": {\n \"image_gen\": {\n \"input_tokens\": 0,\n \"input_tokens_details\": {\n \"image_tokens\": 0,\n \"text_tokens\": 0\n },\n \"output_tokens\": 0,\n \"output_tokens_details\": {\n \"image_tokens\": 0,\n \"text_tokens\": 0\n },\n \"total_tokens\": 0\n },\n \"web_search\": {\n \"num_requests\": 0\n }\n },\n \"tools\": [],\n \"top_logprobs\": 0,\n \"top_p\": 1.0,\n \"truncation\": \"disabled\",\n \"usage\": {\n \"input_tokens\": 120,\n \"input_tokens_details\": {\n \"cache_write_tokens\": 0,\n \"cached_tokens\": 0\n },\n \"output_tokens\": 15,\n \"output_tokens_details\": {\n \"reasoning_tokens\": 0\n },\n \"total_tokens\": 135\n },\n \"user\": null,\n \"metadata\": {}\n}", - "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a3fc3aa7d9d758b3-MAN","connection":"keep-alive","content-type":"application/json","date":"Wed, 23 Sep 2026 20:16:48 GMT","openai-processing-ms":"681","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149999860","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"0s","x-request-id":"req_1c62575c0ca84de583ae51c00e6c7ed3"}, + "response": "{\n \"id\": \"resp_0bc1ed2dc46dda09006aba7af616e487d29cc3782847b4d4b7\",\n \"object\": \"response\",\n \"created_at\": 1790606070,\n \"status\": \"completed\",\n \"access_programs\": null,\n \"background\": false,\n \"billing\": {\n \"payer\": \"developer\"\n },\n \"completed_at\": 1790606073,\n \"error\": null,\n \"frequency_penalty\": 0.0,\n \"incomplete_details\": null,\n \"instructions\": null,\n \"max_output_tokens\": null,\n \"max_tool_calls\": null,\n \"model\": \"gpt-4o-mini-2024-07-18\",\n \"moderation\": null,\n \"output\": [\n {\n \"id\": \"msg_0bc1ed2dc46dda09006aba7af7b86c87d2aa4f84f9a7ad5967\",\n \"type\": \"message\",\n \"status\": \"completed\",\n \"content\": [\n {\n \"type\": \"output_text\",\n \"annotations\": [],\n \"logprobs\": [],\n \"text\": \"{\\\"title\\\":\\\"Discussing image color\\\",\\\"emoji\\\":\\\"\\ud83c\\udfa8\\\"}\"\n }\n ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\": true,\n \"presence_penalty\": 0.0,\n \"previous_response_id\": null,\n \"prompt_cache_key\": null,\n \"prompt_cache_retention\": \"in_memory\",\n \"reasoning\": {\n \"context\": null,\n \"effort\": null,\n \"summary\": null\n },\n \"safety_identifier\": null,\n \"service_tier\": \"default\",\n \"store\": true,\n \"temperature\": 1.0,\n \"text\": {\n \"format\": {\n \"type\": \"text\"\n },\n \"verbosity\": \"medium\"\n },\n \"tool_choice\": \"auto\",\n \"tool_usage\": {\n \"image_gen\": {\n \"input_tokens\": 0,\n \"input_tokens_details\": {\n \"image_tokens\": 0,\n \"text_tokens\": 0\n },\n \"output_tokens\": 0,\n \"output_tokens_details\": {\n \"image_tokens\": 0,\n \"text_tokens\": 0\n },\n \"total_tokens\": 0\n },\n \"web_search\": {\n \"num_requests\": 0\n }\n },\n \"tools\": [],\n \"top_logprobs\": 0,\n \"top_p\": 1.0,\n \"truncation\": \"disabled\",\n \"usage\": {\n \"input_tokens\": 120,\n \"input_tokens_details\": {\n \"cache_write_tokens\": 0,\n \"cached_tokens\": 0\n },\n \"output_tokens\": 14,\n \"output_tokens_details\": {\n \"reasoning_tokens\": 0\n },\n \"total_tokens\": 134\n },\n \"user\": null,\n \"metadata\": {}\n}", + "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a423782159c148e7-LHR","connection":"keep-alive","content-type":"application/json","date":"Mon, 28 Sep 2026 14:34:33 GMT","openai-processing-ms":"3835","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149999860","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"0s","x-request-id":"req_8b402bf25548490e92f5a1c96829e9c3"}, "responseIsBinary": false } ] diff --git a/packages/cli/test/integration/agents/cassettes/n8n-chat-production/streams-the-published-instructions-and-records-a-private-production-session.json b/packages/cli/test/integration/agents/cassettes/n8n-chat-production/streams-the-published-instructions-and-records-a-private-production-session.json index 36ef0e25588..f57d3993590 100644 --- a/packages/cli/test/integration/agents/cassettes/n8n-chat-production/streams-the-published-instructions-and-records-a-private-production-session.json +++ b/packages/cli/test/integration/agents/cassettes/n8n-chat-production/streams-the-published-instructions-and-records-a-private-production-session.json @@ -3,10 +3,10 @@ "scope": "https://api.openai.com:443", "method": "POST", "path": "/v1/responses", - "body": {"model":"gpt-4o-mini","input":[{"role":"system","content":"\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 10 child sub-agent runs can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n- write_todos helps you plan and track complex objectives before and during execution. It updates the current task list only; it does not complete tasks, run sub-agents, or answer the user.\nWHEN TO USE write_todos:\n- The user request has 3+ meaningful steps or multiple deliverables.\n- The request decomposes into 2+ independent workstreams.\n- Some workstreams are good candidates for delegate_subagent.\n- You need to track progress, revise the plan, or avoid losing context.\nWHEN NOT TO USE write_todos:\n- The request is trivial, conversational, or informational.\n- The task can be completed directly in one or two simple steps.\n- You would only create a todo list to restate the user's request.\nHOW TO USE write_todos:\n- Write concrete, self-contained tasks, not vague phases.\n- Assign difficulty to every task: low for simple mechanical or localized work, medium for normal implementation or review requiring judgment, high for complex, broad, ambiguous, or high-risk work.\n- Mark the first active task, or independent active tasks, as in_progress immediately.\n- For sub-agent-worthy work, create one todo per bounded workstream, then call delegate_subagent separately for that task; pass the same difficulty on the delegate call.\n- Do not delegate the entire user request as one task.\n- Update task status as soon as work completes; do not batch completions at the end.\n- Revise the list when new information changes the plan.\n- Do not call write_todos multiple times in parallel; send one full list update at a time.\n- After all work is done, send the final answer as normal assistant text after the last write_todos call.\n\n\nReply with exactly PUBLISHED_MARKER to greeting requests."},{"role":"user","content":[{"type":"input_text","text":"Give me the greeting marker."}]}],"tools":[{"type":"function","name":"get_environment","description":"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \"today\", deadlines, or scheduling. Tool always requires an empty object as input.","parameters":{"type":"object","properties":{},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_context","description":"Read context from the n8n_chat integration connection.\n\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\n\nQuery inputs:\n\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\n\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\n\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\n\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\n\nUse this tool for read-only lookup before choosing an action target.","parameters":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}},"queries":{"type":"array","items":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}}},"required":["query"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_action","description":"Take actions in the n8n_chat integration connection.\n\nAvailable actions: respond.\n\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\n\nAction inputs:\n\n- respond: input.message is required. Responds in the latest message context for this integration connection.\n\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\n\nrespond uses the latest message context for this integration connection.\n\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\n\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\n\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\n\nFor fields components, use { type: \"fields\", fields: [{ label: \"Account\", value: \"Acme\" }] }. The key items is also accepted as a fields alias.\n\nFor button components, use { type: \"button\", label: \"Approve\", value: \"approve\" }. If label is omitted, text is used as the button label.\n\nFor radio-style choices, use { type: \"radio_select\", label: \"Next step\", options: [{ label: \"Approve\", value: \"approve\" }] }.\n\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\n\nGeneric card examples:\n\nRadio choice card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Acme Executive Briefing — Next Steps\",\n \"card\": {\n \"title\": \"Acme Corporation — Executive Briefing Next Steps\",\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\n\\nWhat should happen next?\"\n },\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"radio_select\",\n \"id\": \"next_step_selection\",\n \"label\": \"Next step\",\n \"options\": [\n {\n \"label\": \"Schedule exec briefing prep call with Paul G\",\n \"value\": \"schedule_prep_call\"\n },\n {\n \"label\": \"Draft briefing agenda doc for Mike D\",\n \"value\": \"draft_briefing_doc\"\n }\n ]\n }\n ]\n }\n }\n }\n}\n```\n\nButton card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Approve or revise the briefing draft\",\n \"card\": {\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"Review the briefing draft.\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Approve\",\n \"value\": \"approve\",\n \"style\": \"primary\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Revise\",\n \"value\": \"revise\",\n \"style\": \"default\"\n }\n ]\n }\n }\n }\n}\n```\n\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \"plain_text\", text: \"...\" } or { type: \"mrkdwn\", text: \"...\" }.\n\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\n\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.","parameters":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}},"actions":{"type":"array","items":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}}},"required":["action"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"delegate_subagent","description":"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.","parameters":{"type":"object","properties":{"subAgentId":{"type":"string","minLength":1,"description":"Required. Use \"inline\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task."},"taskName":{"type":"string","minLength":1,"description":"Short human-readable name for this delegated task, e.g. \"research_api\"."},"goal":{"type":"string","minLength":1,"description":"The concrete goal the sub-agent should accomplish."},"context":{"type":"string","description":"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out."},"expectedOutput":{"type":"string","description":"The expected shape or contents of the answer."},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning."}},"required":["subAgentId","taskName","goal"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"write_todos","description":"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.","parameters":{"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Stable identifier for this task within the current plan."},"content":{"type":"string","minLength":1,"description":"Concrete, self-contained task description."},"status":{"type":"string","enum":["pending","in_progress","completed","blocked","cancelled"]},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Task difficulty: \"low\", \"medium\", or \"high\". Use the same value when delegating this task with delegate_subagent."},"delegateHint":{"type":"object","properties":{"subAgentId":{"type":"string","description":"Optional sub-agent id when this task is a delegate_subagent candidate. Use \"inline\" for one-off inline sub-agents."},"expectedOutput":{"type":"string","description":"Optional expected output shape when delegating this task."}},"additionalProperties":false}},"required":["id","content","status","difficulty"],"additionalProperties":false},"description":"Full task list for the current run. Replaces any previous list."}},"required":["todos"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}],"tool_choice":"auto","stream":true}, + "body": {"model":"gpt-4o-mini","input":[{"role":"system","content":"\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 10 child sub-agent runs can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n- write_todos helps you plan and track complex objectives before and during execution. It updates the current task list only; it does not complete tasks, run sub-agents, or answer the user.\nWHEN TO USE write_todos:\n- The user request has 3+ meaningful steps or multiple deliverables.\n- The request decomposes into 2+ independent workstreams.\n- Some workstreams are good candidates for delegate_subagent.\n- You need to track progress, revise the plan, or avoid losing context.\nWHEN NOT TO USE write_todos:\n- The request is trivial, conversational, or informational.\n- The task can be completed directly in one or two simple steps.\n- You would only create a todo list to restate the user's request.\nHOW TO USE write_todos:\n- Write concrete, self-contained tasks, not vague phases.\n- Assign difficulty to every task: low for simple mechanical or localized work, medium for normal implementation or review requiring judgment, high for complex, broad, ambiguous, or high-risk work.\n- Mark the first active task, or independent active tasks, as in_progress immediately.\n- For sub-agent-worthy work, create one todo per bounded workstream, then call delegate_subagent separately for that task; pass the same difficulty on the delegate call.\n- Do not delegate the entire user request as one task.\n- Update task status as soon as work completes; do not batch completions at the end.\n- Revise the list when new information changes the plan.\n- Do not call write_todos multiple times in parallel; send one full list update at a time.\n- After all work is done, send the final answer as normal assistant text after the last write_todos call.\n\n\nReply with exactly PUBLISHED_MARKER to greeting requests."},{"role":"user","content":[{"type":"input_text","text":"Give me the greeting marker."}]}],"tools":[{"type":"function","name":"get_environment","description":"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \"today\", deadlines, or scheduling. Tool always requires an empty object as input.","parameters":{"type":"object","properties":{},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_context","description":"Read context from the n8n_chat integration connection.\n\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\n\nQuery inputs:\n\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\n\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\n\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\n\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\n\nUse this tool for read-only lookup before choosing an action target.","parameters":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}},"queries":{"type":"array","items":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}}},"required":["query"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_action","description":"Take actions in the n8n_chat integration connection.\n\nAvailable actions: respond.\n\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\n\nAction inputs:\n\n- respond: input.message is required. Responds in the latest message context for this integration connection.\n\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\n\nrespond uses the latest message context for this integration connection.\n\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\n\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\n\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\n\nFor fields components, use { type: \"fields\", fields: [{ label: \"Account\", value: \"Acme\" }] }. The key items is also accepted as a fields alias.\n\nFor button components, use { type: \"button\", label: \"Approve\", value: \"approve\" }. If label is omitted, text is used as the button label.\n\nFor radio-style choices, use { type: \"radio_select\", label: \"Next step\", options: [{ label: \"Approve\", value: \"approve\" }] }.\n\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\n\nGeneric card examples:\n\nRadio choice card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Acme Executive Briefing — Next Steps\",\n \"card\": {\n \"title\": \"Acme Corporation — Executive Briefing Next Steps\",\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\n\\nWhat should happen next?\"\n },\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"radio_select\",\n \"id\": \"next_step_selection\",\n \"label\": \"Next step\",\n \"options\": [\n {\n \"label\": \"Schedule exec briefing prep call with Paul G\",\n \"value\": \"schedule_prep_call\"\n },\n {\n \"label\": \"Draft briefing agenda doc for Mike D\",\n \"value\": \"draft_briefing_doc\"\n }\n ]\n }\n ]\n }\n }\n }\n}\n```\n\nButton card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Approve or revise the briefing draft\",\n \"card\": {\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"Review the briefing draft.\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Approve\",\n \"value\": \"approve\",\n \"style\": \"primary\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Revise\",\n \"value\": \"revise\",\n \"style\": \"default\"\n }\n ]\n }\n }\n }\n}\n```\n\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \"plain_text\", text: \"...\" } or { type: \"mrkdwn\", text: \"...\" }.\n\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\n\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.","parameters":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}},"actions":{"type":"array","items":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}}},"required":["action"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"delegate_subagent","description":"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.","parameters":{"type":"object","properties":{"subAgentId":{"type":"string","minLength":1,"description":"Required. Use \"inline\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task."},"taskName":{"type":"string","minLength":1,"description":"Short human-readable name for this delegated task, e.g. \"research_api\"."},"goal":{"type":"string","minLength":1,"description":"The concrete goal the sub-agent should accomplish."},"context":{"type":"string","description":"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out."},"expectedOutput":{"type":"string","description":"The expected shape or contents of the answer."},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning."}},"required":["subAgentId","taskName","goal"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"write_todos","description":"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.","parameters":{"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Stable identifier for this task within the current plan."},"content":{"type":"string","minLength":1,"description":"Concrete, self-contained task description."},"status":{"type":"string","enum":["pending","in_progress","completed","blocked","cancelled"]},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Task difficulty: \"low\", \"medium\", or \"high\". Use the same value when delegating this task with delegate_subagent."},"delegateHint":{"type":"object","properties":{"subAgentId":{"type":"string","description":"Optional sub-agent id when this task is a delegate_subagent candidate. Use \"inline\" for one-off inline sub-agents."},"expectedOutput":{"type":"string","description":"Optional expected output shape when delegating this task."}},"additionalProperties":false}},"required":["id","content","status","difficulty"],"additionalProperties":false},"description":"Full task list for the current run. Replaces any previous list."}},"required":["todos"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"mark_session_failed","description":"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.","parameters":{"type":"object","properties":{"reason":{"type":"string","minLength":1,"maxLength":400}},"required":["reason"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}],"tool_choice":"auto","stream":true}, "status": 200, - "response": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_05d7c5655947a4ef006ab433a454b487d2b525830aeb4b3a69\",\"object\":\"response\",\"created_at\":1790194596,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_05d7c5655947a4ef006ab433a454b487d2b525830aeb4b3a69\",\"object\":\"response\",\"created_at\":1790194596,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_05d7c5655947a4ef006ab433a5acd087d2969ebc2719d3a73a\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_05d7c5655947a4ef006ab433a5acd087d2969ebc2719d3a73a\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"P\",\"item_id\":\"msg_05d7c5655947a4ef006ab433a5acd087d2969ebc2719d3a73a\",\"logprobs\":[],\"obfuscation\":\"1jQi7neYoQKHYeZ\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"UBLISHED\",\"item_id\":\"msg_05d7c5655947a4ef006ab433a5acd087d2969ebc2719d3a73a\",\"logprobs\":[],\"obfuscation\":\"YBdBMnrW\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"_MARK\",\"item_id\":\"msg_05d7c5655947a4ef006ab433a5acd087d2969ebc2719d3a73a\",\"logprobs\":[],\"obfuscation\":\"dt8Awj96mJg\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"ER\",\"item_id\":\"msg_05d7c5655947a4ef006ab433a5acd087d2969ebc2719d3a73a\",\"logprobs\":[],\"obfuscation\":\"deg4zxgOZ5OlS5\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_05d7c5655947a4ef006ab433a5acd087d2969ebc2719d3a73a\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":8,\"text\":\"PUBLISHED_MARKER\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_05d7c5655947a4ef006ab433a5acd087d2969ebc2719d3a73a\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"PUBLISHED_MARKER\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_05d7c5655947a4ef006ab433a5acd087d2969ebc2719d3a73a\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"PUBLISHED_MARKER\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_05d7c5655947a4ef006ab433a454b487d2b525830aeb4b3a69\",\"object\":\"response\",\"created_at\":1790194596,\"status\":\"completed\",\"access_programs\":null,\"background\":false,\"completed_at\":1790194597,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[{\"id\":\"msg_05d7c5655947a4ef006ab433a5acd087d2969ebc2719d3a73a\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"PUBLISHED_MARKER\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":2577,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":6,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":2583},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n", - "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a3fc3a61eae458b3-MAN","connection":"keep-alive","content-type":"text/event-stream; charset=utf-8","date":"Wed, 23 Sep 2026 20:16:37 GMT","openai-processing-ms":"1365","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","transfer-encoding":"chunked","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149997205","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"1ms","x-request-id":"req_88a56315f3bb45749fcff961afedd604"}, + "response": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0396f3e36230d8a8006aba7ae66c7887d299ab5aa3591c2916\",\"object\":\"response\",\"created_at\":1790606054,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0396f3e36230d8a8006aba7ae66c7887d299ab5aa3591c2916\",\"object\":\"response\",\"created_at\":1790606054,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0396f3e36230d8a8006aba7ae73bc887d2aa6af3d8303a67b3\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0396f3e36230d8a8006aba7ae73bc887d2aa6af3d8303a67b3\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"P\",\"item_id\":\"msg_0396f3e36230d8a8006aba7ae73bc887d2aa6af3d8303a67b3\",\"logprobs\":[],\"obfuscation\":\"Z5VCgYndxPjwLtj\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"UBLISHED\",\"item_id\":\"msg_0396f3e36230d8a8006aba7ae73bc887d2aa6af3d8303a67b3\",\"logprobs\":[],\"obfuscation\":\"WnZJmOLz\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"_MARK\",\"item_id\":\"msg_0396f3e36230d8a8006aba7ae73bc887d2aa6af3d8303a67b3\",\"logprobs\":[],\"obfuscation\":\"NEUdGMN0ZJ7\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"ER\",\"item_id\":\"msg_0396f3e36230d8a8006aba7ae73bc887d2aa6af3d8303a67b3\",\"logprobs\":[],\"obfuscation\":\"F02SFlLHIIMwKa\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0396f3e36230d8a8006aba7ae73bc887d2aa6af3d8303a67b3\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":8,\"text\":\"PUBLISHED_MARKER\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0396f3e36230d8a8006aba7ae73bc887d2aa6af3d8303a67b3\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"PUBLISHED_MARKER\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0396f3e36230d8a8006aba7ae73bc887d2aa6af3d8303a67b3\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"PUBLISHED_MARKER\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0396f3e36230d8a8006aba7ae66c7887d299ab5aa3591c2916\",\"object\":\"response\",\"created_at\":1790606054,\"status\":\"completed\",\"access_programs\":null,\"background\":false,\"completed_at\":1790606055,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[{\"id\":\"msg_0396f3e36230d8a8006aba7ae73bc887d2aa6af3d8303a67b3\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"PUBLISHED_MARKER\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":2644,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":6,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":2650},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n", + "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a42377bf1b2448e7-LHR","connection":"keep-alive","content-type":"text/event-stream; charset=utf-8","date":"Mon, 28 Sep 2026 14:34:15 GMT","openai-processing-ms":"827","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","transfer-encoding":"chunked","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149997140","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"1ms","x-request-id":"req_3488577c4c2c4b25a39cf0a0b1539b13"}, "responseIsBinary": false }, { @@ -15,18 +15,18 @@ "path": "/v1/responses", "body": {"model":"gpt-4o-mini","input":[{"role":"system","content":"Generate a short title and a single emoji for a conversation based on the user message.\nRespond with ONLY a JSON object: {\"title\": \"...\", \"emoji\": \"...\"}\nRules:\n- Title: 2 to 6 words, max 50 characters, sentence case\n- Title must not contain emoji, quotes, colons, or markdown formatting\n- Emoji: exactly one emoji that represents the topic\n- Respond with the JSON object only, no other text"},{"role":"user","content":[{"type":"input_text","text":"\nHere's the conversation:\n\nGive me the greeting marker.\n"}]}]}, "status": 200, - "response": "{\n \"id\": \"resp_09f7cd384e3dba65006ab433a67ad487d2813823dfe09b0361\",\n \"object\": \"response\",\n \"created_at\": 1790194598,\n \"status\": \"completed\",\n \"access_programs\": null,\n \"background\": false,\n \"billing\": {\n \"payer\": \"developer\"\n },\n \"completed_at\": 1790194599,\n \"error\": null,\n \"frequency_penalty\": 0.0,\n \"incomplete_details\": null,\n \"instructions\": null,\n \"max_output_tokens\": null,\n \"max_tool_calls\": null,\n \"model\": \"gpt-4o-mini-2024-07-18\",\n \"moderation\": null,\n \"output\": [\n {\n \"id\": \"msg_09f7cd384e3dba65006ab433a6e4c887d2a888d9edc3dda99b\",\n \"type\": \"message\",\n \"status\": \"completed\",\n \"content\": [\n {\n \"type\": \"output_text\",\n \"annotations\": [],\n \"logprobs\": [],\n \"text\": \"{\\\"title\\\":\\\"Request for a greeting marker\\\",\\\"emoji\\\":\\\"\\ud83d\\udc4b\\\"}\"\n }\n ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\": true,\n \"presence_penalty\": 0.0,\n \"previous_response_id\": null,\n \"prompt_cache_key\": null,\n \"prompt_cache_retention\": \"in_memory\",\n \"reasoning\": {\n \"context\": null,\n \"effort\": null,\n \"summary\": null\n },\n \"safety_identifier\": null,\n \"service_tier\": \"default\",\n \"store\": true,\n \"temperature\": 1.0,\n \"text\": {\n \"format\": {\n \"type\": \"text\"\n },\n \"verbosity\": \"medium\"\n },\n \"tool_choice\": \"auto\",\n \"tool_usage\": {\n \"image_gen\": {\n \"input_tokens\": 0,\n \"input_tokens_details\": {\n \"image_tokens\": 0,\n \"text_tokens\": 0\n },\n \"output_tokens\": 0,\n \"output_tokens_details\": {\n \"image_tokens\": 0,\n \"text_tokens\": 0\n },\n \"total_tokens\": 0\n },\n \"web_search\": {\n \"num_requests\": 0\n }\n },\n \"tools\": [],\n \"top_logprobs\": 0,\n \"top_p\": 1.0,\n \"truncation\": \"disabled\",\n \"usage\": {\n \"input_tokens\": 120,\n \"input_tokens_details\": {\n \"cache_write_tokens\": 0,\n \"cached_tokens\": 0\n },\n \"output_tokens\": 15,\n \"output_tokens_details\": {\n \"reasoning_tokens\": 0\n },\n \"total_tokens\": 135\n },\n \"user\": null,\n \"metadata\": {}\n}", - "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a3fc3a6fefa558b3-MAN","connection":"keep-alive","content-type":"application/json","date":"Wed, 23 Sep 2026 20:16:39 GMT","openai-processing-ms":"718","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149999862","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"0s","x-request-id":"req_6179ad09461045f38ee4c43faa21bfe3"}, + "response": "{\n \"id\": \"resp_0bd178c64aad347c006aba7ae7f39087d2ade77a4dee5d108a\",\n \"object\": \"response\",\n \"created_at\": 1790606055,\n \"status\": \"completed\",\n \"access_programs\": null,\n \"background\": false,\n \"billing\": {\n \"payer\": \"developer\"\n },\n \"completed_at\": 1790606056,\n \"error\": null,\n \"frequency_penalty\": 0.0,\n \"incomplete_details\": null,\n \"instructions\": null,\n \"max_output_tokens\": null,\n \"max_tool_calls\": null,\n \"model\": \"gpt-4o-mini-2024-07-18\",\n \"moderation\": null,\n \"output\": [\n {\n \"id\": \"msg_0bd178c64aad347c006aba7ae8713087d2b7fbe2ed44ad53bf\",\n \"type\": \"message\",\n \"status\": \"completed\",\n \"content\": [\n {\n \"type\": \"output_text\",\n \"annotations\": [],\n \"logprobs\": [],\n \"text\": \"{\\\"title\\\":\\\"Request for greeting marker\\\",\\\"emoji\\\":\\\"\\ud83d\\udc4b\\\"}\"\n }\n ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\": true,\n \"presence_penalty\": 0.0,\n \"previous_response_id\": null,\n \"prompt_cache_key\": null,\n \"prompt_cache_retention\": \"in_memory\",\n \"reasoning\": {\n \"context\": null,\n \"effort\": null,\n \"summary\": null\n },\n \"safety_identifier\": null,\n \"service_tier\": \"default\",\n \"store\": true,\n \"temperature\": 1.0,\n \"text\": {\n \"format\": {\n \"type\": \"text\"\n },\n \"verbosity\": \"medium\"\n },\n \"tool_choice\": \"auto\",\n \"tool_usage\": {\n \"image_gen\": {\n \"input_tokens\": 0,\n \"input_tokens_details\": {\n \"image_tokens\": 0,\n \"text_tokens\": 0\n },\n \"output_tokens\": 0,\n \"output_tokens_details\": {\n \"image_tokens\": 0,\n \"text_tokens\": 0\n },\n \"total_tokens\": 0\n },\n \"web_search\": {\n \"num_requests\": 0\n }\n },\n \"tools\": [],\n \"top_logprobs\": 0,\n \"top_p\": 1.0,\n \"truncation\": \"disabled\",\n \"usage\": {\n \"input_tokens\": 120,\n \"input_tokens_details\": {\n \"cache_write_tokens\": 0,\n \"cached_tokens\": 0\n },\n \"output_tokens\": 14,\n \"output_tokens_details\": {\n \"reasoning_tokens\": 0\n },\n \"total_tokens\": 134\n },\n \"user\": null,\n \"metadata\": {}\n}", + "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a42377c90bd148e7-LHR","connection":"keep-alive","content-type":"application/json","date":"Mon, 28 Sep 2026 14:34:16 GMT","openai-processing-ms":"915","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149999860","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"0s","x-request-id":"req_0e14d2c8ad884b768729fcd792d22f85"}, "responseIsBinary": false }, { "scope": "https://api.openai.com:443", "method": "POST", "path": "/v1/responses", - "body": {"model":"gpt-4o-mini","input":[{"role":"system","content":"\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 10 child sub-agent runs can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n- write_todos helps you plan and track complex objectives before and during execution. It updates the current task list only; it does not complete tasks, run sub-agents, or answer the user.\nWHEN TO USE write_todos:\n- The user request has 3+ meaningful steps or multiple deliverables.\n- The request decomposes into 2+ independent workstreams.\n- Some workstreams are good candidates for delegate_subagent.\n- You need to track progress, revise the plan, or avoid losing context.\nWHEN NOT TO USE write_todos:\n- The request is trivial, conversational, or informational.\n- The task can be completed directly in one or two simple steps.\n- You would only create a todo list to restate the user's request.\nHOW TO USE write_todos:\n- Write concrete, self-contained tasks, not vague phases.\n- Assign difficulty to every task: low for simple mechanical or localized work, medium for normal implementation or review requiring judgment, high for complex, broad, ambiguous, or high-risk work.\n- Mark the first active task, or independent active tasks, as in_progress immediately.\n- For sub-agent-worthy work, create one todo per bounded workstream, then call delegate_subagent separately for that task; pass the same difficulty on the delegate call.\n- Do not delegate the entire user request as one task.\n- Update task status as soon as work completes; do not batch completions at the end.\n- Revise the list when new information changes the plan.\n- Do not call write_todos multiple times in parallel; send one full list update at a time.\n- After all work is done, send the final answer as normal assistant text after the last write_todos call.\n\n\nReply with exactly DRAFT_MARKER to greeting requests.\n\n### Preview chat policy\nThis conversation only runs you. You cannot change your own setup — instructions, model, tools, skills, knowledge, channels, integrations, schedules, name or any other configuration — and you have no tool that can. This holds whether or not the user names you as the owner of the thing: \"add a tool\" and \"connect a Slack channel\" are setup changes too. If the user asks for such a change, say plainly that you cannot make it here, and tell them to ask the AI Assistant, which edits the agent for them. Never claim a setup change was applied."},{"role":"user","content":[{"type":"input_text","text":"Give me the greeting marker."}]}],"tools":[{"type":"function","name":"get_environment","description":"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \"today\", deadlines, or scheduling. Tool always requires an empty object as input.","parameters":{"type":"object","properties":{},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_context","description":"Read context from the n8n_chat integration connection.\n\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\n\nQuery inputs:\n\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\n\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\n\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\n\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\n\nUse this tool for read-only lookup before choosing an action target.","parameters":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}},"queries":{"type":"array","items":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}}},"required":["query"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_action","description":"Take actions in the n8n_chat integration connection.\n\nAvailable actions: respond.\n\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\n\nAction inputs:\n\n- respond: input.message is required. Responds in the latest message context for this integration connection.\n\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\n\nrespond uses the latest message context for this integration connection.\n\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\n\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\n\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\n\nFor fields components, use { type: \"fields\", fields: [{ label: \"Account\", value: \"Acme\" }] }. The key items is also accepted as a fields alias.\n\nFor button components, use { type: \"button\", label: \"Approve\", value: \"approve\" }. If label is omitted, text is used as the button label.\n\nFor radio-style choices, use { type: \"radio_select\", label: \"Next step\", options: [{ label: \"Approve\", value: \"approve\" }] }.\n\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\n\nGeneric card examples:\n\nRadio choice card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Acme Executive Briefing — Next Steps\",\n \"card\": {\n \"title\": \"Acme Corporation — Executive Briefing Next Steps\",\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\n\\nWhat should happen next?\"\n },\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"radio_select\",\n \"id\": \"next_step_selection\",\n \"label\": \"Next step\",\n \"options\": [\n {\n \"label\": \"Schedule exec briefing prep call with Paul G\",\n \"value\": \"schedule_prep_call\"\n },\n {\n \"label\": \"Draft briefing agenda doc for Mike D\",\n \"value\": \"draft_briefing_doc\"\n }\n ]\n }\n ]\n }\n }\n }\n}\n```\n\nButton card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Approve or revise the briefing draft\",\n \"card\": {\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"Review the briefing draft.\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Approve\",\n \"value\": \"approve\",\n \"style\": \"primary\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Revise\",\n \"value\": \"revise\",\n \"style\": \"default\"\n }\n ]\n }\n }\n }\n}\n```\n\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \"plain_text\", text: \"...\" } or { type: \"mrkdwn\", text: \"...\" }.\n\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\n\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.","parameters":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}},"actions":{"type":"array","items":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}}},"required":["action"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"delegate_subagent","description":"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.","parameters":{"type":"object","properties":{"subAgentId":{"type":"string","minLength":1,"description":"Required. Use \"inline\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task."},"taskName":{"type":"string","minLength":1,"description":"Short human-readable name for this delegated task, e.g. \"research_api\"."},"goal":{"type":"string","minLength":1,"description":"The concrete goal the sub-agent should accomplish."},"context":{"type":"string","description":"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out."},"expectedOutput":{"type":"string","description":"The expected shape or contents of the answer."},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning."}},"required":["subAgentId","taskName","goal"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"write_todos","description":"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.","parameters":{"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Stable identifier for this task within the current plan."},"content":{"type":"string","minLength":1,"description":"Concrete, self-contained task description."},"status":{"type":"string","enum":["pending","in_progress","completed","blocked","cancelled"]},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Task difficulty: \"low\", \"medium\", or \"high\". Use the same value when delegating this task with delegate_subagent."},"delegateHint":{"type":"object","properties":{"subAgentId":{"type":"string","description":"Optional sub-agent id when this task is a delegate_subagent candidate. Use \"inline\" for one-off inline sub-agents."},"expectedOutput":{"type":"string","description":"Optional expected output shape when delegating this task."}},"additionalProperties":false}},"required":["id","content","status","difficulty"],"additionalProperties":false},"description":"Full task list for the current run. Replaces any previous list."}},"required":["todos"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}],"tool_choice":"auto","stream":true}, + "body": {"model":"gpt-4o-mini","input":[{"role":"system","content":"\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 10 child sub-agent runs can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n- write_todos helps you plan and track complex objectives before and during execution. It updates the current task list only; it does not complete tasks, run sub-agents, or answer the user.\nWHEN TO USE write_todos:\n- The user request has 3+ meaningful steps or multiple deliverables.\n- The request decomposes into 2+ independent workstreams.\n- Some workstreams are good candidates for delegate_subagent.\n- You need to track progress, revise the plan, or avoid losing context.\nWHEN NOT TO USE write_todos:\n- The request is trivial, conversational, or informational.\n- The task can be completed directly in one or two simple steps.\n- You would only create a todo list to restate the user's request.\nHOW TO USE write_todos:\n- Write concrete, self-contained tasks, not vague phases.\n- Assign difficulty to every task: low for simple mechanical or localized work, medium for normal implementation or review requiring judgment, high for complex, broad, ambiguous, or high-risk work.\n- Mark the first active task, or independent active tasks, as in_progress immediately.\n- For sub-agent-worthy work, create one todo per bounded workstream, then call delegate_subagent separately for that task; pass the same difficulty on the delegate call.\n- Do not delegate the entire user request as one task.\n- Update task status as soon as work completes; do not batch completions at the end.\n- Revise the list when new information changes the plan.\n- Do not call write_todos multiple times in parallel; send one full list update at a time.\n- After all work is done, send the final answer as normal assistant text after the last write_todos call.\n\n\nReply with exactly DRAFT_MARKER to greeting requests.\n\n### Preview chat policy\nThis conversation only runs you. You cannot change your own setup — instructions, model, tools, skills, knowledge, channels, integrations, schedules, name or any other configuration — and you have no tool that can. This holds whether or not the user names you as the owner of the thing: \"add a tool\" and \"connect a Slack channel\" are setup changes too. If the user asks for such a change, say plainly that you cannot make it here, and tell them to ask the AI Assistant, which edits the agent for them. Never claim a setup change was applied."},{"role":"user","content":[{"type":"input_text","text":"Give me the greeting marker."}]}],"tools":[{"type":"function","name":"get_environment","description":"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \"today\", deadlines, or scheduling. Tool always requires an empty object as input.","parameters":{"type":"object","properties":{},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_context","description":"Read context from the n8n_chat integration connection.\n\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\n\nQuery inputs:\n\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\n\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\n\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\n\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\n\nUse this tool for read-only lookup before choosing an action target.","parameters":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}},"queries":{"type":"array","items":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}}},"required":["query"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_action","description":"Take actions in the n8n_chat integration connection.\n\nAvailable actions: respond.\n\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\n\nAction inputs:\n\n- respond: input.message is required. Responds in the latest message context for this integration connection.\n\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\n\nrespond uses the latest message context for this integration connection.\n\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\n\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\n\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\n\nFor fields components, use { type: \"fields\", fields: [{ label: \"Account\", value: \"Acme\" }] }. The key items is also accepted as a fields alias.\n\nFor button components, use { type: \"button\", label: \"Approve\", value: \"approve\" }. If label is omitted, text is used as the button label.\n\nFor radio-style choices, use { type: \"radio_select\", label: \"Next step\", options: [{ label: \"Approve\", value: \"approve\" }] }.\n\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\n\nGeneric card examples:\n\nRadio choice card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Acme Executive Briefing — Next Steps\",\n \"card\": {\n \"title\": \"Acme Corporation — Executive Briefing Next Steps\",\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\n\\nWhat should happen next?\"\n },\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"radio_select\",\n \"id\": \"next_step_selection\",\n \"label\": \"Next step\",\n \"options\": [\n {\n \"label\": \"Schedule exec briefing prep call with Paul G\",\n \"value\": \"schedule_prep_call\"\n },\n {\n \"label\": \"Draft briefing agenda doc for Mike D\",\n \"value\": \"draft_briefing_doc\"\n }\n ]\n }\n ]\n }\n }\n }\n}\n```\n\nButton card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Approve or revise the briefing draft\",\n \"card\": {\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"Review the briefing draft.\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Approve\",\n \"value\": \"approve\",\n \"style\": \"primary\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Revise\",\n \"value\": \"revise\",\n \"style\": \"default\"\n }\n ]\n }\n }\n }\n}\n```\n\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \"plain_text\", text: \"...\" } or { type: \"mrkdwn\", text: \"...\" }.\n\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\n\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.","parameters":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}},"actions":{"type":"array","items":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}}},"required":["action"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"delegate_subagent","description":"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.","parameters":{"type":"object","properties":{"subAgentId":{"type":"string","minLength":1,"description":"Required. Use \"inline\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task."},"taskName":{"type":"string","minLength":1,"description":"Short human-readable name for this delegated task, e.g. \"research_api\"."},"goal":{"type":"string","minLength":1,"description":"The concrete goal the sub-agent should accomplish."},"context":{"type":"string","description":"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out."},"expectedOutput":{"type":"string","description":"The expected shape or contents of the answer."},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning."}},"required":["subAgentId","taskName","goal"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"write_todos","description":"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.","parameters":{"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Stable identifier for this task within the current plan."},"content":{"type":"string","minLength":1,"description":"Concrete, self-contained task description."},"status":{"type":"string","enum":["pending","in_progress","completed","blocked","cancelled"]},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Task difficulty: \"low\", \"medium\", or \"high\". Use the same value when delegating this task with delegate_subagent."},"delegateHint":{"type":"object","properties":{"subAgentId":{"type":"string","description":"Optional sub-agent id when this task is a delegate_subagent candidate. Use \"inline\" for one-off inline sub-agents."},"expectedOutput":{"type":"string","description":"Optional expected output shape when delegating this task."}},"additionalProperties":false}},"required":["id","content","status","difficulty"],"additionalProperties":false},"description":"Full task list for the current run. Replaces any previous list."}},"required":["todos"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"mark_session_failed","description":"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.","parameters":{"type":"object","properties":{"reason":{"type":"string","minLength":1,"maxLength":400}},"required":["reason"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}],"tool_choice":"auto","stream":true}, "status": 200, - "response": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0e5e65a777e98645006ab433a7811887d28292e359c4522d04\",\"object\":\"response\",\"created_at\":1790194599,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0e5e65a777e98645006ab433a7811887d28292e359c4522d04\",\"object\":\"response\",\"created_at\":1790194599,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0e5e65a777e98645006ab433a80bb487d2a1ec484fb2f09cac\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0e5e65a777e98645006ab433a80bb487d2a1ec484fb2f09cac\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"D\",\"item_id\":\"msg_0e5e65a777e98645006ab433a80bb487d2a1ec484fb2f09cac\",\"logprobs\":[],\"obfuscation\":\"0PNXtJhpCKOv1bX\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"RAFT\",\"item_id\":\"msg_0e5e65a777e98645006ab433a80bb487d2a1ec484fb2f09cac\",\"logprobs\":[],\"obfuscation\":\"yhtHlQCKEZhY\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"_MARK\",\"item_id\":\"msg_0e5e65a777e98645006ab433a80bb487d2a1ec484fb2f09cac\",\"logprobs\":[],\"obfuscation\":\"JSzvnOCANW4\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"ER\",\"item_id\":\"msg_0e5e65a777e98645006ab433a80bb487d2a1ec484fb2f09cac\",\"logprobs\":[],\"obfuscation\":\"qWFqSz6xG5W4sf\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0e5e65a777e98645006ab433a80bb487d2a1ec484fb2f09cac\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":8,\"text\":\"DRAFT_MARKER\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0e5e65a777e98645006ab433a80bb487d2a1ec484fb2f09cac\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"DRAFT_MARKER\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0e5e65a777e98645006ab433a80bb487d2a1ec484fb2f09cac\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"DRAFT_MARKER\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0e5e65a777e98645006ab433a7811887d28292e359c4522d04\",\"object\":\"response\",\"created_at\":1790194599,\"status\":\"completed\",\"access_programs\":null,\"background\":false,\"completed_at\":1790194600,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[{\"id\":\"msg_0e5e65a777e98645006ab433a80bb487d2a1ec484fb2f09cac\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"DRAFT_MARKER\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":2700,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":6,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":2706},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n", - "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a3fc3a762e7d58b3-MAN","connection":"keep-alive","content-type":"text/event-stream; charset=utf-8","date":"Wed, 23 Sep 2026 20:16:40 GMT","openai-processing-ms":"535","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","transfer-encoding":"chunked","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149997082","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"1ms","x-request-id":"req_d5fd298ef86b4ae29288e7ce1206dc04"}, + "response": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0acf38438ec7f959006aba7ae91f2087d28095921cd29bbc05\",\"object\":\"response\",\"created_at\":1790606057,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0acf38438ec7f959006aba7ae91f2087d28095921cd29bbc05\",\"object\":\"response\",\"created_at\":1790606057,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0acf38438ec7f959006aba7ae9cc6c87d2af5d861dc01a9a1a\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0acf38438ec7f959006aba7ae9cc6c87d2af5d861dc01a9a1a\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"D\",\"item_id\":\"msg_0acf38438ec7f959006aba7ae9cc6c87d2af5d861dc01a9a1a\",\"logprobs\":[],\"obfuscation\":\"RU4X5uLQvXwqtPS\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"RAFT\",\"item_id\":\"msg_0acf38438ec7f959006aba7ae9cc6c87d2af5d861dc01a9a1a\",\"logprobs\":[],\"obfuscation\":\"yA9gtkzVqs48\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"_MARK\",\"item_id\":\"msg_0acf38438ec7f959006aba7ae9cc6c87d2af5d861dc01a9a1a\",\"logprobs\":[],\"obfuscation\":\"2jd08qhrIsm\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"ER\",\"item_id\":\"msg_0acf38438ec7f959006aba7ae9cc6c87d2af5d861dc01a9a1a\",\"logprobs\":[],\"obfuscation\":\"Ipq2yh3VOHTpxj\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0acf38438ec7f959006aba7ae9cc6c87d2af5d861dc01a9a1a\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":8,\"text\":\"DRAFT_MARKER\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0acf38438ec7f959006aba7ae9cc6c87d2af5d861dc01a9a1a\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"DRAFT_MARKER\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0acf38438ec7f959006aba7ae9cc6c87d2af5d861dc01a9a1a\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"DRAFT_MARKER\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0acf38438ec7f959006aba7ae91f2087d28095921cd29bbc05\",\"object\":\"response\",\"created_at\":1790606057,\"status\":\"completed\",\"access_programs\":null,\"background\":false,\"completed_at\":1790606057,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[{\"id\":\"msg_0acf38438ec7f959006aba7ae9cc6c87d2af5d861dc01a9a1a\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"DRAFT_MARKER\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":2767,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":6,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":2773},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n", + "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a42377d0285a48e7-LHR","connection":"keep-alive","content-type":"text/event-stream; charset=utf-8","date":"Mon, 28 Sep 2026 14:34:17 GMT","openai-processing-ms":"701","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","transfer-encoding":"chunked","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149997017","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"1ms","x-request-id":"req_fbebe46cbb6d46419d947977ed514bed"}, "responseIsBinary": false }, { @@ -35,8 +35,8 @@ "path": "/v1/responses", "body": {"model":"gpt-4o-mini","input":[{"role":"system","content":"Generate a short title and a single emoji for a conversation based on the user message.\nRespond with ONLY a JSON object: {\"title\": \"...\", \"emoji\": \"...\"}\nRules:\n- Title: 2 to 6 words, max 50 characters, sentence case\n- Title must not contain emoji, quotes, colons, or markdown formatting\n- Emoji: exactly one emoji that represents the topic\n- Respond with the JSON object only, no other text"},{"role":"user","content":[{"type":"input_text","text":"\nHere's the conversation:\n\nGive me the greeting marker.\n"}]}]}, "status": 200, - "response": "{\n \"id\": \"resp_0c77553d6e0766fb006ab433a88de487d28be1f0015050ef57\",\n \"object\": \"response\",\n \"created_at\": 1790194600,\n \"status\": \"completed\",\n \"access_programs\": null,\n \"background\": false,\n \"billing\": {\n \"payer\": \"developer\"\n },\n \"completed_at\": 1790194601,\n \"error\": null,\n \"frequency_penalty\": 0.0,\n \"incomplete_details\": null,\n \"instructions\": null,\n \"max_output_tokens\": null,\n \"max_tool_calls\": null,\n \"model\": \"gpt-4o-mini-2024-07-18\",\n \"moderation\": null,\n \"output\": [\n {\n \"id\": \"msg_0c77553d6e0766fb006ab433a9824887d29423d862d11c7a76\",\n \"type\": \"message\",\n \"status\": \"completed\",\n \"content\": [\n {\n \"type\": \"output_text\",\n \"annotations\": [],\n \"logprobs\": [],\n \"text\": \"{\\\"title\\\":\\\"Request for greeting marker\\\",\\\"emoji\\\":\\\"\\ud83d\\udc4b\\\"}\"\n }\n ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\": true,\n \"presence_penalty\": 0.0,\n \"previous_response_id\": null,\n \"prompt_cache_key\": null,\n \"prompt_cache_retention\": \"in_memory\",\n \"reasoning\": {\n \"context\": null,\n \"effort\": null,\n \"summary\": null\n },\n \"safety_identifier\": null,\n \"service_tier\": \"default\",\n \"store\": true,\n \"temperature\": 1.0,\n \"text\": {\n \"format\": {\n \"type\": \"text\"\n },\n \"verbosity\": \"medium\"\n },\n \"tool_choice\": \"auto\",\n \"tool_usage\": {\n \"image_gen\": {\n \"input_tokens\": 0,\n \"input_tokens_details\": {\n \"image_tokens\": 0,\n \"text_tokens\": 0\n },\n \"output_tokens\": 0,\n \"output_tokens_details\": {\n \"image_tokens\": 0,\n \"text_tokens\": 0\n },\n \"total_tokens\": 0\n },\n \"web_search\": {\n \"num_requests\": 0\n }\n },\n \"tools\": [],\n \"top_logprobs\": 0,\n \"top_p\": 1.0,\n \"truncation\": \"disabled\",\n \"usage\": {\n \"input_tokens\": 120,\n \"input_tokens_details\": {\n \"cache_write_tokens\": 0,\n \"cached_tokens\": 0\n },\n \"output_tokens\": 14,\n \"output_tokens_details\": {\n \"reasoning_tokens\": 0\n },\n \"total_tokens\": 134\n },\n \"user\": null,\n \"metadata\": {}\n}", - "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a3fc3a7ca81f58b3-MAN","connection":"keep-alive","content-type":"application/json","date":"Wed, 23 Sep 2026 20:16:42 GMT","openai-processing-ms":"1428","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149999860","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"0s","x-request-id":"req_615d5d533d52499c9708a039a8ee3c0f"}, + "response": "{\n \"id\": \"resp_08e9b832720923cd006aba7aeb533887d2a0792ca40d3274db\",\n \"object\": \"response\",\n \"created_at\": 1790606059,\n \"status\": \"completed\",\n \"access_programs\": null,\n \"background\": false,\n \"billing\": {\n \"payer\": \"developer\"\n },\n \"completed_at\": 1790606059,\n \"error\": null,\n \"frequency_penalty\": 0.0,\n \"incomplete_details\": null,\n \"instructions\": null,\n \"max_output_tokens\": null,\n \"max_tool_calls\": null,\n \"model\": \"gpt-4o-mini-2024-07-18\",\n \"moderation\": null,\n \"output\": [\n {\n \"id\": \"msg_08e9b832720923cd006aba7aebcbdc87d2a28fb3b373cdbf9c\",\n \"type\": \"message\",\n \"status\": \"completed\",\n \"content\": [\n {\n \"type\": \"output_text\",\n \"annotations\": [],\n \"logprobs\": [],\n \"text\": \"{\\\"title\\\":\\\"Request for greeting marker\\\",\\\"emoji\\\":\\\"\\ud83d\\udc4b\\\"}\"\n }\n ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\": true,\n \"presence_penalty\": 0.0,\n \"previous_response_id\": null,\n \"prompt_cache_key\": null,\n \"prompt_cache_retention\": \"in_memory\",\n \"reasoning\": {\n \"context\": null,\n \"effort\": null,\n \"summary\": null\n },\n \"safety_identifier\": null,\n \"service_tier\": \"default\",\n \"store\": true,\n \"temperature\": 1.0,\n \"text\": {\n \"format\": {\n \"type\": \"text\"\n },\n \"verbosity\": \"medium\"\n },\n \"tool_choice\": \"auto\",\n \"tool_usage\": {\n \"image_gen\": {\n \"input_tokens\": 0,\n \"input_tokens_details\": {\n \"image_tokens\": 0,\n \"text_tokens\": 0\n },\n \"output_tokens\": 0,\n \"output_tokens_details\": {\n \"image_tokens\": 0,\n \"text_tokens\": 0\n },\n \"total_tokens\": 0\n },\n \"web_search\": {\n \"num_requests\": 0\n }\n },\n \"tools\": [],\n \"top_logprobs\": 0,\n \"top_p\": 1.0,\n \"truncation\": \"disabled\",\n \"usage\": {\n \"input_tokens\": 120,\n \"input_tokens_details\": {\n \"cache_write_tokens\": 0,\n \"cached_tokens\": 0\n },\n \"output_tokens\": 14,\n \"output_tokens_details\": {\n \"reasoning_tokens\": 0\n },\n \"total_tokens\": 134\n },\n \"user\": null,\n \"metadata\": {}\n}", + "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a42377ddf9c448e7-LHR","connection":"keep-alive","content-type":"application/json","date":"Mon, 28 Sep 2026 14:34:22 GMT","openai-processing-ms":"3120","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149999860","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"0s","x-request-id":"req_9c8a66fe3a8745f6abb555c664878baf"}, "responseIsBinary": false } ] diff --git a/packages/cli/test/integration/agents/cassettes/n8n-chat-production/suspends-a-tool-call-and-resumes-it-only-for-the-session-owner.json b/packages/cli/test/integration/agents/cassettes/n8n-chat-production/suspends-a-tool-call-and-resumes-it-only-for-the-session-owner.json index 3be7ce2fe50..c1fc334c8f7 100644 --- a/packages/cli/test/integration/agents/cassettes/n8n-chat-production/suspends-a-tool-call-and-resumes-it-only-for-the-session-owner.json +++ b/packages/cli/test/integration/agents/cassettes/n8n-chat-production/suspends-a-tool-call-and-resumes-it-only-for-the-session-owner.json @@ -3,20 +3,20 @@ "scope": "https://api.openai.com:443", "method": "POST", "path": "/v1/responses", - "body": {"model":"gpt-4o-mini","input":[{"role":"system","content":"\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 10 child sub-agent runs can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n- write_todos helps you plan and track complex objectives before and during execution. It updates the current task list only; it does not complete tasks, run sub-agents, or answer the user.\nWHEN TO USE write_todos:\n- The user request has 3+ meaningful steps or multiple deliverables.\n- The request decomposes into 2+ independent workstreams.\n- Some workstreams are good candidates for delegate_subagent.\n- You need to track progress, revise the plan, or avoid losing context.\nWHEN NOT TO USE write_todos:\n- The request is trivial, conversational, or informational.\n- The task can be completed directly in one or two simple steps.\n- You would only create a todo list to restate the user's request.\nHOW TO USE write_todos:\n- Write concrete, self-contained tasks, not vague phases.\n- Assign difficulty to every task: low for simple mechanical or localized work, medium for normal implementation or review requiring judgment, high for complex, broad, ambiguous, or high-risk work.\n- Mark the first active task, or independent active tasks, as in_progress immediately.\n- For sub-agent-worthy work, create one todo per bounded workstream, then call delegate_subagent separately for that task; pass the same difficulty on the delegate call.\n- Do not delegate the entire user request as one task.\n- Update task status as soon as work completes; do not batch completions at the end.\n- Revise the list when new information changes the plan.\n- Do not call write_todos multiple times in parallel; send one full list update at a time.\n- After all work is done, send the final answer as normal assistant text after the last write_todos call.\n\n\nWhen asked to record a marker, call approve_marker with the exact marker. Then confirm the tool result."},{"role":"user","content":[{"type":"input_text","text":"Record marker A42 using approve_marker."}]}],"tools":[{"type":"function","name":"approve_marker","description":"Approve a marker before recording it","parameters":{"type":"object","properties":{"marker":{"type":"string"}},"required":["marker"]}},{"type":"function","name":"get_environment","description":"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \"today\", deadlines, or scheduling. Tool always requires an empty object as input.","parameters":{"type":"object","properties":{},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_context","description":"Read context from the n8n_chat integration connection.\n\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\n\nQuery inputs:\n\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\n\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\n\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\n\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\n\nUse this tool for read-only lookup before choosing an action target.","parameters":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}},"queries":{"type":"array","items":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}}},"required":["query"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_action","description":"Take actions in the n8n_chat integration connection.\n\nAvailable actions: respond.\n\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\n\nAction inputs:\n\n- respond: input.message is required. Responds in the latest message context for this integration connection.\n\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\n\nrespond uses the latest message context for this integration connection.\n\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\n\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\n\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\n\nFor fields components, use { type: \"fields\", fields: [{ label: \"Account\", value: \"Acme\" }] }. The key items is also accepted as a fields alias.\n\nFor button components, use { type: \"button\", label: \"Approve\", value: \"approve\" }. If label is omitted, text is used as the button label.\n\nFor radio-style choices, use { type: \"radio_select\", label: \"Next step\", options: [{ label: \"Approve\", value: \"approve\" }] }.\n\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\n\nGeneric card examples:\n\nRadio choice card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Acme Executive Briefing — Next Steps\",\n \"card\": {\n \"title\": \"Acme Corporation — Executive Briefing Next Steps\",\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\n\\nWhat should happen next?\"\n },\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"radio_select\",\n \"id\": \"next_step_selection\",\n \"label\": \"Next step\",\n \"options\": [\n {\n \"label\": \"Schedule exec briefing prep call with Paul G\",\n \"value\": \"schedule_prep_call\"\n },\n {\n \"label\": \"Draft briefing agenda doc for Mike D\",\n \"value\": \"draft_briefing_doc\"\n }\n ]\n }\n ]\n }\n }\n }\n}\n```\n\nButton card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Approve or revise the briefing draft\",\n \"card\": {\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"Review the briefing draft.\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Approve\",\n \"value\": \"approve\",\n \"style\": \"primary\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Revise\",\n \"value\": \"revise\",\n \"style\": \"default\"\n }\n ]\n }\n }\n }\n}\n```\n\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \"plain_text\", text: \"...\" } or { type: \"mrkdwn\", text: \"...\" }.\n\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\n\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.","parameters":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}},"actions":{"type":"array","items":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}}},"required":["action"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"delegate_subagent","description":"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.","parameters":{"type":"object","properties":{"subAgentId":{"type":"string","minLength":1,"description":"Required. Use \"inline\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task."},"taskName":{"type":"string","minLength":1,"description":"Short human-readable name for this delegated task, e.g. \"research_api\"."},"goal":{"type":"string","minLength":1,"description":"The concrete goal the sub-agent should accomplish."},"context":{"type":"string","description":"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out."},"expectedOutput":{"type":"string","description":"The expected shape or contents of the answer."},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning."}},"required":["subAgentId","taskName","goal"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"write_todos","description":"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.","parameters":{"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Stable identifier for this task within the current plan."},"content":{"type":"string","minLength":1,"description":"Concrete, self-contained task description."},"status":{"type":"string","enum":["pending","in_progress","completed","blocked","cancelled"]},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Task difficulty: \"low\", \"medium\", or \"high\". Use the same value when delegating this task with delegate_subagent."},"delegateHint":{"type":"object","properties":{"subAgentId":{"type":"string","description":"Optional sub-agent id when this task is a delegate_subagent candidate. Use \"inline\" for one-off inline sub-agents."},"expectedOutput":{"type":"string","description":"Optional expected output shape when delegating this task."}},"additionalProperties":false}},"required":["id","content","status","difficulty"],"additionalProperties":false},"description":"Full task list for the current run. Replaces any previous list."}},"required":["todos"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}],"tool_choice":"auto","stream":true}, + "body": {"model":"gpt-4o-mini","input":[{"role":"system","content":"\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 10 child sub-agent runs can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n- write_todos helps you plan and track complex objectives before and during execution. It updates the current task list only; it does not complete tasks, run sub-agents, or answer the user.\nWHEN TO USE write_todos:\n- The user request has 3+ meaningful steps or multiple deliverables.\n- The request decomposes into 2+ independent workstreams.\n- Some workstreams are good candidates for delegate_subagent.\n- You need to track progress, revise the plan, or avoid losing context.\nWHEN NOT TO USE write_todos:\n- The request is trivial, conversational, or informational.\n- The task can be completed directly in one or two simple steps.\n- You would only create a todo list to restate the user's request.\nHOW TO USE write_todos:\n- Write concrete, self-contained tasks, not vague phases.\n- Assign difficulty to every task: low for simple mechanical or localized work, medium for normal implementation or review requiring judgment, high for complex, broad, ambiguous, or high-risk work.\n- Mark the first active task, or independent active tasks, as in_progress immediately.\n- For sub-agent-worthy work, create one todo per bounded workstream, then call delegate_subagent separately for that task; pass the same difficulty on the delegate call.\n- Do not delegate the entire user request as one task.\n- Update task status as soon as work completes; do not batch completions at the end.\n- Revise the list when new information changes the plan.\n- Do not call write_todos multiple times in parallel; send one full list update at a time.\n- After all work is done, send the final answer as normal assistant text after the last write_todos call.\n\n\nWhen asked to record a marker, call approve_marker with the exact marker. Then confirm the tool result."},{"role":"user","content":[{"type":"input_text","text":"Record marker A42 using approve_marker."}]}],"tools":[{"type":"function","name":"approve_marker","description":"Approve a marker before recording it","parameters":{"type":"object","properties":{"marker":{"type":"string"}},"required":["marker"]}},{"type":"function","name":"get_environment","description":"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \"today\", deadlines, or scheduling. Tool always requires an empty object as input.","parameters":{"type":"object","properties":{},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_context","description":"Read context from the n8n_chat integration connection.\n\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\n\nQuery inputs:\n\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\n\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\n\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\n\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\n\nUse this tool for read-only lookup before choosing an action target.","parameters":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}},"queries":{"type":"array","items":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}}},"required":["query"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_action","description":"Take actions in the n8n_chat integration connection.\n\nAvailable actions: respond.\n\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\n\nAction inputs:\n\n- respond: input.message is required. Responds in the latest message context for this integration connection.\n\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\n\nrespond uses the latest message context for this integration connection.\n\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\n\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\n\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\n\nFor fields components, use { type: \"fields\", fields: [{ label: \"Account\", value: \"Acme\" }] }. The key items is also accepted as a fields alias.\n\nFor button components, use { type: \"button\", label: \"Approve\", value: \"approve\" }. If label is omitted, text is used as the button label.\n\nFor radio-style choices, use { type: \"radio_select\", label: \"Next step\", options: [{ label: \"Approve\", value: \"approve\" }] }.\n\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\n\nGeneric card examples:\n\nRadio choice card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Acme Executive Briefing — Next Steps\",\n \"card\": {\n \"title\": \"Acme Corporation — Executive Briefing Next Steps\",\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\n\\nWhat should happen next?\"\n },\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"radio_select\",\n \"id\": \"next_step_selection\",\n \"label\": \"Next step\",\n \"options\": [\n {\n \"label\": \"Schedule exec briefing prep call with Paul G\",\n \"value\": \"schedule_prep_call\"\n },\n {\n \"label\": \"Draft briefing agenda doc for Mike D\",\n \"value\": \"draft_briefing_doc\"\n }\n ]\n }\n ]\n }\n }\n }\n}\n```\n\nButton card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Approve or revise the briefing draft\",\n \"card\": {\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"Review the briefing draft.\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Approve\",\n \"value\": \"approve\",\n \"style\": \"primary\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Revise\",\n \"value\": \"revise\",\n \"style\": \"default\"\n }\n ]\n }\n }\n }\n}\n```\n\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \"plain_text\", text: \"...\" } or { type: \"mrkdwn\", text: \"...\" }.\n\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\n\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.","parameters":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}},"actions":{"type":"array","items":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}}},"required":["action"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"delegate_subagent","description":"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.","parameters":{"type":"object","properties":{"subAgentId":{"type":"string","minLength":1,"description":"Required. Use \"inline\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task."},"taskName":{"type":"string","minLength":1,"description":"Short human-readable name for this delegated task, e.g. \"research_api\"."},"goal":{"type":"string","minLength":1,"description":"The concrete goal the sub-agent should accomplish."},"context":{"type":"string","description":"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out."},"expectedOutput":{"type":"string","description":"The expected shape or contents of the answer."},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning."}},"required":["subAgentId","taskName","goal"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"write_todos","description":"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.","parameters":{"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Stable identifier for this task within the current plan."},"content":{"type":"string","minLength":1,"description":"Concrete, self-contained task description."},"status":{"type":"string","enum":["pending","in_progress","completed","blocked","cancelled"]},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Task difficulty: \"low\", \"medium\", or \"high\". Use the same value when delegating this task with delegate_subagent."},"delegateHint":{"type":"object","properties":{"subAgentId":{"type":"string","description":"Optional sub-agent id when this task is a delegate_subagent candidate. Use \"inline\" for one-off inline sub-agents."},"expectedOutput":{"type":"string","description":"Optional expected output shape when delegating this task."}},"additionalProperties":false}},"required":["id","content","status","difficulty"],"additionalProperties":false},"description":"Full task list for the current run. Replaces any previous list."}},"required":["todos"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"mark_session_failed","description":"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.","parameters":{"type":"object","properties":{"reason":{"type":"string","minLength":1,"maxLength":400}},"required":["reason"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}],"tool_choice":"auto","stream":true}, "status": 200, - "response": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_00273462e81f002f006ab433aa570487d28109ea2524ddb2f6\",\"object\":\"response\",\"created_at\":1790194602,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_00273462e81f002f006ab433aa570487d28109ea2524ddb2f6\",\"object\":\"response\",\"created_at\":1790194602,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_00273462e81f002f006ab433aae6c887d28a46ffdb99050e4c\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_Kbj0Mkj4xg873yySYMKeODPu\",\"name\":\"approve_marker\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_00273462e81f002f006ab433aae6c887d28a46ffdb99050e4c\",\"obfuscation\":\"DpE5I5thaZccLQ\",\"output_index\":0,\"sequence_number\":3}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"marker\",\"item_id\":\"fc_00273462e81f002f006ab433aae6c887d28a46ffdb99050e4c\",\"obfuscation\":\"9G1yR4CS5w\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_00273462e81f002f006ab433aae6c887d28a46ffdb99050e4c\",\"obfuscation\":\"z4Wk6B2hszACz\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"A\",\"item_id\":\"fc_00273462e81f002f006ab433aae6c887d28a46ffdb99050e4c\",\"obfuscation\":\"vQH0g65lUxjnSpF\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"42\",\"item_id\":\"fc_00273462e81f002f006ab433aae6c887d28a46ffdb99050e4c\",\"obfuscation\":\"CSLVHG36Uqsyk6\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_00273462e81f002f006ab433aae6c887d28a46ffdb99050e4c\",\"obfuscation\":\"5IHNXtYSKmo8br\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"marker\\\":\\\"A42\\\"}\",\"item_id\":\"fc_00273462e81f002f006ab433aae6c887d28a46ffdb99050e4c\",\"output_index\":0,\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_00273462e81f002f006ab433aae6c887d28a46ffdb99050e4c\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"marker\\\":\\\"A42\\\"}\",\"call_id\":\"call_Kbj0Mkj4xg873yySYMKeODPu\",\"name\":\"approve_marker\"},\"output_index\":0,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_00273462e81f002f006ab433aa570487d28109ea2524ddb2f6\",\"object\":\"response\",\"created_at\":1790194602,\"status\":\"completed\",\"access_programs\":null,\"background\":false,\"completed_at\":1790194602,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[{\"id\":\"fc_00273462e81f002f006ab433aae6c887d28a46ffdb99050e4c\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"marker\\\":\\\"A42\\\"}\",\"call_id\":\"call_Kbj0Mkj4xg873yySYMKeODPu\",\"name\":\"approve_marker\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":2611,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":17,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":2628},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n", - "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a3fc3a87ffb758b3-MAN","connection":"keep-alive","content-type":"text/event-stream; charset=utf-8","date":"Wed, 23 Sep 2026 20:16:42 GMT","openai-processing-ms":"504","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","transfer-encoding":"chunked","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149997172","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"1ms","x-request-id":"req_0827926c13e94356aab501a2e9a688e2"}, + "response": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_086d8cb4bf9f2d20006aba7aeefab487d28143084ee1b221a3\",\"object\":\"response\",\"created_at\":1790606063,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_086d8cb4bf9f2d20006aba7aeefab487d28143084ee1b221a3\",\"object\":\"response\",\"created_at\":1790606063,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_086d8cb4bf9f2d20006aba7aefa32887d2847299c2b7c331cd\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_8jMqbDwlKHN8uVoCQUO7rg4S\",\"name\":\"approve_marker\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_086d8cb4bf9f2d20006aba7aefa32887d2847299c2b7c331cd\",\"obfuscation\":\"HEyTJUQtwdHuBo\",\"output_index\":0,\"sequence_number\":3}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"marker\",\"item_id\":\"fc_086d8cb4bf9f2d20006aba7aefa32887d2847299c2b7c331cd\",\"obfuscation\":\"MBBF66kwJ7\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_086d8cb4bf9f2d20006aba7aefa32887d2847299c2b7c331cd\",\"obfuscation\":\"PTDy0oIY1FqVi\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"A\",\"item_id\":\"fc_086d8cb4bf9f2d20006aba7aefa32887d2847299c2b7c331cd\",\"obfuscation\":\"m22L4oxSTMCXtMy\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"42\",\"item_id\":\"fc_086d8cb4bf9f2d20006aba7aefa32887d2847299c2b7c331cd\",\"obfuscation\":\"H7M4BPJsDS2zcI\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_086d8cb4bf9f2d20006aba7aefa32887d2847299c2b7c331cd\",\"obfuscation\":\"aeWWy15vP3alXC\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"marker\\\":\\\"A42\\\"}\",\"item_id\":\"fc_086d8cb4bf9f2d20006aba7aefa32887d2847299c2b7c331cd\",\"output_index\":0,\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_086d8cb4bf9f2d20006aba7aefa32887d2847299c2b7c331cd\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"marker\\\":\\\"A42\\\"}\",\"call_id\":\"call_8jMqbDwlKHN8uVoCQUO7rg4S\",\"name\":\"approve_marker\"},\"output_index\":0,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_086d8cb4bf9f2d20006aba7aeefab487d28143084ee1b221a3\",\"object\":\"response\",\"created_at\":1790606063,\"status\":\"completed\",\"access_programs\":null,\"background\":false,\"completed_at\":1790606063,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[{\"id\":\"fc_086d8cb4bf9f2d20006aba7aefa32887d2847299c2b7c331cd\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"marker\\\":\\\"A42\\\"}\",\"call_id\":\"call_8jMqbDwlKHN8uVoCQUO7rg4S\",\"name\":\"approve_marker\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":2678,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":17,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":2695},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n", + "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a42377f37c4d48e7-LHR","connection":"keep-alive","content-type":"text/event-stream; charset=utf-8","date":"Mon, 28 Sep 2026 14:34:23 GMT","openai-processing-ms":"797","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","transfer-encoding":"chunked","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149997105","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"1ms","x-request-id":"req_111e06cfa79b4ebb89ecf34364a9b7ea"}, "responseIsBinary": false }, { "scope": "https://api.openai.com:443", "method": "POST", "path": "/v1/responses", - "body": {"model":"gpt-4o-mini","input":[{"role":"system","content":"\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 10 child sub-agent runs can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n- write_todos helps you plan and track complex objectives before and during execution. It updates the current task list only; it does not complete tasks, run sub-agents, or answer the user.\nWHEN TO USE write_todos:\n- The user request has 3+ meaningful steps or multiple deliverables.\n- The request decomposes into 2+ independent workstreams.\n- Some workstreams are good candidates for delegate_subagent.\n- You need to track progress, revise the plan, or avoid losing context.\nWHEN NOT TO USE write_todos:\n- The request is trivial, conversational, or informational.\n- The task can be completed directly in one or two simple steps.\n- You would only create a todo list to restate the user's request.\nHOW TO USE write_todos:\n- Write concrete, self-contained tasks, not vague phases.\n- Assign difficulty to every task: low for simple mechanical or localized work, medium for normal implementation or review requiring judgment, high for complex, broad, ambiguous, or high-risk work.\n- Mark the first active task, or independent active tasks, as in_progress immediately.\n- For sub-agent-worthy work, create one todo per bounded workstream, then call delegate_subagent separately for that task; pass the same difficulty on the delegate call.\n- Do not delegate the entire user request as one task.\n- Update task status as soon as work completes; do not batch completions at the end.\n- Revise the list when new information changes the plan.\n- Do not call write_todos multiple times in parallel; send one full list update at a time.\n- After all work is done, send the final answer as normal assistant text after the last write_todos call.\n\n\nWhen asked to record a marker, call approve_marker with the exact marker. Then confirm the tool result."},{"role":"user","content":[{"type":"input_text","text":"Record marker A42 using approve_marker."}]},{"type":"function_call","call_id":"call_Kbj0Mkj4xg873yySYMKeODPu","name":"approve_marker","arguments":"{\"marker\":\"A42\"}"},{"type":"function_call_output","call_id":"call_Kbj0Mkj4xg873yySYMKeODPu","output":"{\"recorded\":\"A42\"}"}],"tools":[{"type":"function","name":"approve_marker","description":"Approve a marker before recording it","parameters":{"type":"object","properties":{"marker":{"type":"string"}},"required":["marker"]}},{"type":"function","name":"get_environment","description":"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \"today\", deadlines, or scheduling. Tool always requires an empty object as input.","parameters":{"type":"object","properties":{},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_context","description":"Read context from the n8n_chat integration connection.\n\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\n\nQuery inputs:\n\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\n\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\n\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\n\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\n\nUse this tool for read-only lookup before choosing an action target.","parameters":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}},"queries":{"type":"array","items":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}}},"required":["query"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_action","description":"Take actions in the n8n_chat integration connection.\n\nAvailable actions: respond.\n\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\n\nAction inputs:\n\n- respond: input.message is required. Responds in the latest message context for this integration connection.\n\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\n\nrespond uses the latest message context for this integration connection.\n\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\n\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\n\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\n\nFor fields components, use { type: \"fields\", fields: [{ label: \"Account\", value: \"Acme\" }] }. The key items is also accepted as a fields alias.\n\nFor button components, use { type: \"button\", label: \"Approve\", value: \"approve\" }. If label is omitted, text is used as the button label.\n\nFor radio-style choices, use { type: \"radio_select\", label: \"Next step\", options: [{ label: \"Approve\", value: \"approve\" }] }.\n\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\n\nGeneric card examples:\n\nRadio choice card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Acme Executive Briefing — Next Steps\",\n \"card\": {\n \"title\": \"Acme Corporation — Executive Briefing Next Steps\",\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\n\\nWhat should happen next?\"\n },\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"radio_select\",\n \"id\": \"next_step_selection\",\n \"label\": \"Next step\",\n \"options\": [\n {\n \"label\": \"Schedule exec briefing prep call with Paul G\",\n \"value\": \"schedule_prep_call\"\n },\n {\n \"label\": \"Draft briefing agenda doc for Mike D\",\n \"value\": \"draft_briefing_doc\"\n }\n ]\n }\n ]\n }\n }\n }\n}\n```\n\nButton card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Approve or revise the briefing draft\",\n \"card\": {\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"Review the briefing draft.\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Approve\",\n \"value\": \"approve\",\n \"style\": \"primary\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Revise\",\n \"value\": \"revise\",\n \"style\": \"default\"\n }\n ]\n }\n }\n }\n}\n```\n\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \"plain_text\", text: \"...\" } or { type: \"mrkdwn\", text: \"...\" }.\n\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\n\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.","parameters":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}},"actions":{"type":"array","items":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}}},"required":["action"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"delegate_subagent","description":"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.","parameters":{"type":"object","properties":{"subAgentId":{"type":"string","minLength":1,"description":"Required. Use \"inline\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task."},"taskName":{"type":"string","minLength":1,"description":"Short human-readable name for this delegated task, e.g. \"research_api\"."},"goal":{"type":"string","minLength":1,"description":"The concrete goal the sub-agent should accomplish."},"context":{"type":"string","description":"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out."},"expectedOutput":{"type":"string","description":"The expected shape or contents of the answer."},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning."}},"required":["subAgentId","taskName","goal"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"write_todos","description":"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.","parameters":{"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Stable identifier for this task within the current plan."},"content":{"type":"string","minLength":1,"description":"Concrete, self-contained task description."},"status":{"type":"string","enum":["pending","in_progress","completed","blocked","cancelled"]},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Task difficulty: \"low\", \"medium\", or \"high\". Use the same value when delegating this task with delegate_subagent."},"delegateHint":{"type":"object","properties":{"subAgentId":{"type":"string","description":"Optional sub-agent id when this task is a delegate_subagent candidate. Use \"inline\" for one-off inline sub-agents."},"expectedOutput":{"type":"string","description":"Optional expected output shape when delegating this task."}},"additionalProperties":false}},"required":["id","content","status","difficulty"],"additionalProperties":false},"description":"Full task list for the current run. Replaces any previous list."}},"required":["todos"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}],"tool_choice":"auto","stream":true}, + "body": {"model":"gpt-4o-mini","input":[{"role":"system","content":"\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 10 child sub-agent runs can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Provider-defined tools are loaded for the inline child's selected model provider.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n- write_todos helps you plan and track complex objectives before and during execution. It updates the current task list only; it does not complete tasks, run sub-agents, or answer the user.\nWHEN TO USE write_todos:\n- The user request has 3+ meaningful steps or multiple deliverables.\n- The request decomposes into 2+ independent workstreams.\n- Some workstreams are good candidates for delegate_subagent.\n- You need to track progress, revise the plan, or avoid losing context.\nWHEN NOT TO USE write_todos:\n- The request is trivial, conversational, or informational.\n- The task can be completed directly in one or two simple steps.\n- You would only create a todo list to restate the user's request.\nHOW TO USE write_todos:\n- Write concrete, self-contained tasks, not vague phases.\n- Assign difficulty to every task: low for simple mechanical or localized work, medium for normal implementation or review requiring judgment, high for complex, broad, ambiguous, or high-risk work.\n- Mark the first active task, or independent active tasks, as in_progress immediately.\n- For sub-agent-worthy work, create one todo per bounded workstream, then call delegate_subagent separately for that task; pass the same difficulty on the delegate call.\n- Do not delegate the entire user request as one task.\n- Update task status as soon as work completes; do not batch completions at the end.\n- Revise the list when new information changes the plan.\n- Do not call write_todos multiple times in parallel; send one full list update at a time.\n- After all work is done, send the final answer as normal assistant text after the last write_todos call.\n\n\nWhen asked to record a marker, call approve_marker with the exact marker. Then confirm the tool result."},{"role":"user","content":[{"type":"input_text","text":"Record marker A42 using approve_marker."}]},{"type":"function_call","call_id":"call_8jMqbDwlKHN8uVoCQUO7rg4S","name":"approve_marker","arguments":"{\"marker\":\"A42\"}"},{"type":"function_call_output","call_id":"call_8jMqbDwlKHN8uVoCQUO7rg4S","output":"{\"recorded\":\"A42\"}"}],"tools":[{"type":"function","name":"approve_marker","description":"Approve a marker before recording it","parameters":{"type":"object","properties":{"marker":{"type":"string"}},"required":["marker"]}},{"type":"function","name":"get_environment","description":"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \"today\", deadlines, or scheduling. Tool always requires an empty object as input.","parameters":{"type":"object","properties":{},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_context","description":"Read context from the n8n_chat integration connection.\n\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\n\nQuery inputs:\n\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\n\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\n\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\n\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\n\nUse this tool for read-only lookup before choosing an action target.","parameters":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}},"queries":{"type":"array","items":{"type":"object","properties":{"query":{"type":"string","enum":["get_current_message_context","get_current_subject","get_current_user"]},"input":{"type":"object","additionalProperties":{}}},"required":["query"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"chat_action","description":"Take actions in the n8n_chat integration connection.\n\nAvailable actions: respond.\n\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\n\nAction inputs:\n\n- respond: input.message is required. Responds in the latest message context for this integration connection.\n\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\n\nrespond uses the latest message context for this integration connection.\n\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\n\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\n\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\n\nFor fields components, use { type: \"fields\", fields: [{ label: \"Account\", value: \"Acme\" }] }. The key items is also accepted as a fields alias.\n\nFor button components, use { type: \"button\", label: \"Approve\", value: \"approve\" }. If label is omitted, text is used as the button label.\n\nFor radio-style choices, use { type: \"radio_select\", label: \"Next step\", options: [{ label: \"Approve\", value: \"approve\" }] }.\n\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\n\nGeneric card examples:\n\nRadio choice card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Acme Executive Briefing — Next Steps\",\n \"card\": {\n \"title\": \"Acme Corporation — Executive Briefing Next Steps\",\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\n\\nWhat should happen next?\"\n },\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"radio_select\",\n \"id\": \"next_step_selection\",\n \"label\": \"Next step\",\n \"options\": [\n {\n \"label\": \"Schedule exec briefing prep call with Paul G\",\n \"value\": \"schedule_prep_call\"\n },\n {\n \"label\": \"Draft briefing agenda doc for Mike D\",\n \"value\": \"draft_briefing_doc\"\n }\n ]\n }\n ]\n }\n }\n }\n}\n```\n\nButton card:\n```json\n{\n \"action\": \"respond\",\n \"input\": {\n \"message\": {\n \"text\": \"Approve or revise the briefing draft\",\n \"card\": {\n \"components\": [\n {\n \"type\": \"section\",\n \"text\": \"Review the briefing draft.\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Approve\",\n \"value\": \"approve\",\n \"style\": \"primary\"\n },\n {\n \"type\": \"button\",\n \"label\": \"Revise\",\n \"value\": \"revise\",\n \"style\": \"default\"\n }\n ]\n }\n }\n }\n}\n```\n\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \"plain_text\", text: \"...\" } or { type: \"mrkdwn\", text: \"...\" }.\n\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\n\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.","parameters":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}},"actions":{"type":"array","items":{"type":"object","properties":{"action":{"type":"string","enum":["respond"]},"input":{"type":"object","additionalProperties":{}}},"required":["action"],"additionalProperties":false},"minItems":1,"maxItems":20}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"delegate_subagent","description":"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.","parameters":{"type":"object","properties":{"subAgentId":{"type":"string","minLength":1,"description":"Required. Use \"inline\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task."},"taskName":{"type":"string","minLength":1,"description":"Short human-readable name for this delegated task, e.g. \"research_api\"."},"goal":{"type":"string","minLength":1,"description":"The concrete goal the sub-agent should accomplish."},"context":{"type":"string","description":"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out."},"expectedOutput":{"type":"string","description":"The expected shape or contents of the answer."},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning."}},"required":["subAgentId","taskName","goal"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"write_todos","description":"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.","parameters":{"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Stable identifier for this task within the current plan."},"content":{"type":"string","minLength":1,"description":"Concrete, self-contained task description."},"status":{"type":"string","enum":["pending","in_progress","completed","blocked","cancelled"]},"difficulty":{"type":"string","enum":["low","medium","high"],"description":"Task difficulty: \"low\", \"medium\", or \"high\". Use the same value when delegating this task with delegate_subagent."},"delegateHint":{"type":"object","properties":{"subAgentId":{"type":"string","description":"Optional sub-agent id when this task is a delegate_subagent candidate. Use \"inline\" for one-off inline sub-agents."},"expectedOutput":{"type":"string","description":"Optional expected output shape when delegating this task."}},"additionalProperties":false}},"required":["id","content","status","difficulty"],"additionalProperties":false},"description":"Full task list for the current run. Replaces any previous list."}},"required":["todos"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"type":"function","name":"mark_session_failed","description":"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.","parameters":{"type":"object","properties":{"reason":{"type":"string","minLength":1,"maxLength":400}},"required":["reason"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}],"tool_choice":"auto","stream":true}, "status": 200, - "response": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0ee12346a33d4462006ab433abab0487d2a882637d2104f2d9\",\"object\":\"response\",\"created_at\":1790194603,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0ee12346a33d4462006ab433abab0487d2a882637d2104f2d9\",\"object\":\"response\",\"created_at\":1790194603,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0ee12346a33d4462006ab433ac4c3887d289ada5ec465f99d6\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0ee12346a33d4462006ab433ac4c3887d289ada5ec465f99d6\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"The\",\"item_id\":\"msg_0ee12346a33d4462006ab433ac4c3887d289ada5ec465f99d6\",\"logprobs\":[],\"obfuscation\":\"U1atBmpnNITNi\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" marker\",\"item_id\":\"msg_0ee12346a33d4462006ab433ac4c3887d289ada5ec465f99d6\",\"logprobs\":[],\"obfuscation\":\"ZtFEkluN0\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" A\",\"item_id\":\"msg_0ee12346a33d4462006ab433ac4c3887d289ada5ec465f99d6\",\"logprobs\":[],\"obfuscation\":\"dviYZYGKZSbd6P\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"42\",\"item_id\":\"msg_0ee12346a33d4462006ab433ac4c3887d289ada5ec465f99d6\",\"logprobs\":[],\"obfuscation\":\"MxgcYdqY3z3D1J\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" has\",\"item_id\":\"msg_0ee12346a33d4462006ab433ac4c3887d289ada5ec465f99d6\",\"logprobs\":[],\"obfuscation\":\"50DMzM5EuYkI\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" been\",\"item_id\":\"msg_0ee12346a33d4462006ab433ac4c3887d289ada5ec465f99d6\",\"logprobs\":[],\"obfuscation\":\"bHywtgenkXy\",\"output_index\":0,\"sequence_number\":9}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" successfully\",\"item_id\":\"msg_0ee12346a33d4462006ab433ac4c3887d289ada5ec465f99d6\",\"logprobs\":[],\"obfuscation\":\"u85\",\"output_index\":0,\"sequence_number\":10}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" recorded\",\"item_id\":\"msg_0ee12346a33d4462006ab433ac4c3887d289ada5ec465f99d6\",\"logprobs\":[],\"obfuscation\":\"UXP0AAG\",\"output_index\":0,\"sequence_number\":11}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_0ee12346a33d4462006ab433ac4c3887d289ada5ec465f99d6\",\"logprobs\":[],\"obfuscation\":\"pwLSdd3UdYlsfmY\",\"output_index\":0,\"sequence_number\":12}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0ee12346a33d4462006ab433ac4c3887d289ada5ec465f99d6\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":13,\"text\":\"The marker A42 has been successfully recorded.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0ee12346a33d4462006ab433ac4c3887d289ada5ec465f99d6\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"The marker A42 has been successfully recorded.\"},\"sequence_number\":14}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0ee12346a33d4462006ab433ac4c3887d289ada5ec465f99d6\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"The marker A42 has been successfully recorded.\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":15}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0ee12346a33d4462006ab433abab0487d2a882637d2104f2d9\",\"object\":\"response\",\"created_at\":1790194603,\"status\":\"completed\",\"access_programs\":null,\"background\":false,\"completed_at\":1790194604,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[{\"id\":\"msg_0ee12346a33d4462006ab433ac4c3887d289ada5ec465f99d6\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"The marker A42 has been successfully recorded.\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":2641,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":11,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":2652},\"user\":null,\"metadata\":{}},\"sequence_number\":16}\n\n", - "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a3fc3a904e8f58b3-MAN","connection":"keep-alive","content-type":"text/event-stream; charset=utf-8","date":"Wed, 23 Sep 2026 20:16:44 GMT","openai-processing-ms":"656","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","transfer-encoding":"chunked","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149997142","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"1ms","x-request-id":"req_b1b542975df0451782117c25f1858ca0"}, + "response": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_02b196cbf25b2e2c006aba7af03c8887d2bc7375469fcd7eb4\",\"object\":\"response\",\"created_at\":1790606064,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_02b196cbf25b2e2c006aba7af03c8887d2bc7375469fcd7eb4\",\"object\":\"response\",\"created_at\":1790606064,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_02b196cbf25b2e2c006aba7af1a66487d280af4e325fff0ec6\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_02b196cbf25b2e2c006aba7af1a66487d280af4e325fff0ec6\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Marker\",\"item_id\":\"msg_02b196cbf25b2e2c006aba7af1a66487d280af4e325fff0ec6\",\"logprobs\":[],\"obfuscation\":\"NdakS1JL3O\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" **\",\"item_id\":\"msg_02b196cbf25b2e2c006aba7af1a66487d280af4e325fff0ec6\",\"logprobs\":[],\"obfuscation\":\"LLa3AHA9qZGwI\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"A\",\"item_id\":\"msg_02b196cbf25b2e2c006aba7af1a66487d280af4e325fff0ec6\",\"logprobs\":[],\"obfuscation\":\"m2lKbU5CSD2uZs8\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"42\",\"item_id\":\"msg_02b196cbf25b2e2c006aba7af1a66487d280af4e325fff0ec6\",\"logprobs\":[],\"obfuscation\":\"un8h2stfrSCoUn\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"**\",\"item_id\":\"msg_02b196cbf25b2e2c006aba7af1a66487d280af4e325fff0ec6\",\"logprobs\":[],\"obfuscation\":\"fQzjxxBgZ3s4cu\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" has\",\"item_id\":\"msg_02b196cbf25b2e2c006aba7af1a66487d280af4e325fff0ec6\",\"logprobs\":[],\"obfuscation\":\"b8rR6RWC4Ils\",\"output_index\":0,\"sequence_number\":9}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" been\",\"item_id\":\"msg_02b196cbf25b2e2c006aba7af1a66487d280af4e325fff0ec6\",\"logprobs\":[],\"obfuscation\":\"qqdxFaTZUjf\",\"output_index\":0,\"sequence_number\":10}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" successfully\",\"item_id\":\"msg_02b196cbf25b2e2c006aba7af1a66487d280af4e325fff0ec6\",\"logprobs\":[],\"obfuscation\":\"vXX\",\"output_index\":0,\"sequence_number\":11}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" recorded\",\"item_id\":\"msg_02b196cbf25b2e2c006aba7af1a66487d280af4e325fff0ec6\",\"logprobs\":[],\"obfuscation\":\"cXepB86\",\"output_index\":0,\"sequence_number\":12}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_02b196cbf25b2e2c006aba7af1a66487d280af4e325fff0ec6\",\"logprobs\":[],\"obfuscation\":\"tN8ks5H9o8TakGP\",\"output_index\":0,\"sequence_number\":13}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_02b196cbf25b2e2c006aba7af1a66487d280af4e325fff0ec6\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":14,\"text\":\"Marker **A42** has been successfully recorded.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_02b196cbf25b2e2c006aba7af1a66487d280af4e325fff0ec6\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Marker **A42** has been successfully recorded.\"},\"sequence_number\":15}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_02b196cbf25b2e2c006aba7af1a66487d280af4e325fff0ec6\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Marker **A42** has been successfully recorded.\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":16}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_02b196cbf25b2e2c006aba7af03c8887d2bc7375469fcd7eb4\",\"object\":\"response\",\"created_at\":1790606064,\"status\":\"completed\",\"access_programs\":null,\"background\":false,\"completed_at\":1790606065,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"moderation\":null,\"output\":[{\"id\":\"msg_02b196cbf25b2e2c006aba7af1a66487d280af4e325fff0ec6\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Marker **A42** has been successfully recorded.\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":null,\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Approve a marker before recording it\",\"name\":\"approve_marker\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"marker\":{\"type\":\"string\"}},\"required\":[\"marker\"],\"additionalProperties\":false},\"strict\":true},{\"type\":\"function\",\"description\":\"Returns runtime info that the LLM cannot know on its own: current ISO date/time, instance timezone (IANA), and day of week. Call when reasoning about \\\"today\\\", deadlines, or scheduling. Tool always requires an empty object as input.\",\"name\":\"get_environment\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[]},\"strict\":true},{\"type\":\"function\",\"description\":\"Read context from the n8n_chat integration connection.\\n\\nAvailable queries: get_current_message_context, get_current_subject, get_current_user.\\n\\nQuery inputs:\\n\\n- get_current_message_context: no input. Returns the latest place this agent communicated in this thread and any allow-listed platformMessage metadata. For Slack, context.agentUserId is the bot user ID for this agent; do not look it up as another user.\\n\\n- get_current_subject: no input. Returns the subject of the latest message context, such as a Linear issue, when available.\\n\\n- get_current_user: no input. Returns the latest user who interacted in the current message context.\\n\\nBatch form: pass queries as an array of up to 20 { query, input } objects to fetch multiple pieces of context in one tool call.\\n\\nUse this tool for read-only lookup before choosing an action target.\",\"name\":\"chat_context\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"queries\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"enum\":[\"get_current_message_context\",\"get_current_subject\",\"get_current_user\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"query\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Take actions in the n8n_chat integration connection.\\n\\nAvailable actions: respond.\\n\\nThis is the built-in n8n chat: your normal assistant reply already reaches the user. NEVER call respond with only message.text — write that text directly in your reply instead. Call this tool only with message.card, to render a rich card or collect structured input.\\n\\nAction inputs:\\n\\n- respond: input.message is required. Responds in the latest message context for this integration connection.\\n\\nBatch form: pass actions as an array of up to 20 { action, input } objects. Batch actions run sequentially and cannot include cards that wait for a user response.\\n\\nrespond uses the latest message context for this integration connection.\\n\\nNever state that an action has been performed before its tool call returns. Before calling an action, describe what you intend to do, not a completed outcome. Only report an action as done after the tool returns ok. If a tool fails, report the failure honestly.\\n\\nIf this turn was triggered by an inbound chat message, your final reply text is posted to that conversation automatically — do not duplicate it with a text-only respond. Call respond only with message.card, or after switching to a different target.\\n\\nUse message.card for cards, images, key-value summaries, and feedback requests. Include components such as section, fields, image, divider, button, select, or radio_select.\\n\\nFor fields components, use { type: \\\"fields\\\", fields: [{ label: \\\"Account\\\", value: \\\"Acme\\\" }] }. The key items is also accepted as a fields alias.\\n\\nFor button components, use { type: \\\"button\\\", label: \\\"Approve\\\", value: \\\"approve\\\" }. If label is omitted, text is used as the button label.\\n\\nFor radio-style choices, use { type: \\\"radio_select\\\", label: \\\"Next step\\\", options: [{ label: \\\"Approve\\\", value: \\\"approve\\\" }] }.\\n\\nUse only the generic shape: message.text plus optional message.card. Do not provide platform-native component payloads, formatted text objects, or attachments; rendering is handled internally.\\n\\nGeneric card examples:\\n\\nRadio choice card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Acme Executive Briefing — Next Steps\\\",\\n \\\"card\\\": {\\n \\\"title\\\": \\\"Acme Corporation — Executive Briefing Next Steps\\\",\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"30X Expansion Briefing ($3.75M) — Internal owner: Paul G | Briefing contact: Mike D\\\\n\\\\nWhat should happen next?\\\"\\n },\\n {\\n \\\"type\\\": \\\"divider\\\"\\n },\\n {\\n \\\"type\\\": \\\"radio_select\\\",\\n \\\"id\\\": \\\"next_step_selection\\\",\\n \\\"label\\\": \\\"Next step\\\",\\n \\\"options\\\": [\\n {\\n \\\"label\\\": \\\"Schedule exec briefing prep call with Paul G\\\",\\n \\\"value\\\": \\\"schedule_prep_call\\\"\\n },\\n {\\n \\\"label\\\": \\\"Draft briefing agenda doc for Mike D\\\",\\n \\\"value\\\": \\\"draft_briefing_doc\\\"\\n }\\n ]\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nButton card:\\n```json\\n{\\n \\\"action\\\": \\\"respond\\\",\\n \\\"input\\\": {\\n \\\"message\\\": {\\n \\\"text\\\": \\\"Approve or revise the briefing draft\\\",\\n \\\"card\\\": {\\n \\\"components\\\": [\\n {\\n \\\"type\\\": \\\"section\\\",\\n \\\"text\\\": \\\"Review the briefing draft.\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Approve\\\",\\n \\\"value\\\": \\\"approve\\\",\\n \\\"style\\\": \\\"primary\\\"\\n },\\n {\\n \\\"type\\\": \\\"button\\\",\\n \\\"label\\\": \\\"Revise\\\",\\n \\\"value\\\": \\\"revise\\\",\\n \\\"style\\\": \\\"default\\\"\\n }\\n ]\\n }\\n }\\n }\\n}\\n```\\n\\nNever send message.blocks, components of type actions, elements arrays, action_id, radio_buttons, or formatted text objects such as { type: \\\"plain_text\\\", text: \\\"...\\\" } or { type: \\\"mrkdwn\\\", text: \\\"...\\\" }.\\n\\nInteractive message.card components (button, select, or radio_select) send the message first, then suspend this action until the user responds.\\n\\nDisplay-only message.card components without buttons/selects render the card and let the agent continue immediately.\",\"name\":\"chat_action\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}},\"actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"respond\"]},\"input\":{\"type\":\"object\",\"additionalProperties\":{}}},\"required\":[\"action\"],\"additionalProperties\":false},\"minItems\":1,\"maxItems\":20}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":false},{\"type\":\"function\",\"description\":\"Delegate a bounded, self-contained subtask to a focused child agent that runs in an isolated context and returns only a concise final result. Use it for reasoning-heavy subtasks, context-flooding investigations, or independent workstreams inside a larger deliverable. Do not use it for trivial work, single tool calls, mechanical steps, tasks that need hidden conversation context, or pass-through delegation of the entire user request.\",\"name\":\"delegate_subagent\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Required. Use \\\"inline\\\" for a one-off inline sub-agent. Use an exact configured sub-agent ID only when one is listed and fits the task.\"},\"taskName\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Short human-readable name for this delegated task, e.g. \\\"research_api\\\".\"},\"goal\":{\"type\":\"string\",\"minLength\":1,\"description\":\"The concrete goal the sub-agent should accomplish.\"},\"context\":{\"type\":\"string\",\"description\":\"All details the child needs, since it sees nothing else: constraints, paths, data, prior decisions, acceptance criteria, and what you have already tried or ruled out.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"The expected shape or contents of the answer.\"},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Optional difficulty estimate for this delegated task. Use low for simple bounded work, medium for moderate analysis or implementation, and high for complex research, architecture, or multi-step reasoning.\"}},\"required\":[\"subAgentId\",\"taskName\",\"goal\",\"context\",\"expectedOutput\",\"difficulty\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with delegate_subagent. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.\",\"name\":\"write_todos\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"todos\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Stable identifier for this task within the current plan.\"},\"content\":{\"type\":\"string\",\"minLength\":1,\"description\":\"Concrete, self-contained task description.\"},\"status\":{\"type\":\"string\",\"enum\":[\"pending\",\"in_progress\",\"completed\",\"blocked\",\"cancelled\"]},\"difficulty\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"],\"description\":\"Task difficulty: \\\"low\\\", \\\"medium\\\", or \\\"high\\\". Use the same value when delegating this task with delegate_subagent.\"},\"delegateHint\":{\"type\":\"object\",\"properties\":{\"subAgentId\":{\"type\":\"string\",\"description\":\"Optional sub-agent id when this task is a delegate_subagent candidate. Use \\\"inline\\\" for one-off inline sub-agents.\"},\"expectedOutput\":{\"type\":\"string\",\"description\":\"Optional expected output shape when delegating this task.\"}},\"additionalProperties\":false,\"required\":[\"subAgentId\",\"expectedOutput\"]}},\"required\":[\"id\",\"content\",\"status\",\"difficulty\",\"delegateHint\"],\"additionalProperties\":false},\"description\":\"Full task list for the current run. Replaces any previous list.\"}},\"required\":[\"todos\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true},{\"type\":\"function\",\"description\":\"Mark this session as failed when you cannot complete the task. Call it after tool errors you cannot fix. Do not call it when you have fixed the tool error. Pass a short reason.\",\"name\":\"mark_session_failed\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"reason\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":400}},\"required\":[\"reason\"],\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":2708,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":12,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":2720},\"user\":null,\"metadata\":{}},\"sequence_number\":17}\n\n", + "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a42377fc8fb248e7-LHR","connection":"keep-alive","content-type":"text/event-stream; charset=utf-8","date":"Mon, 28 Sep 2026 14:34:25 GMT","openai-processing-ms":"1381","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","transfer-encoding":"chunked","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149997075","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"1ms","x-request-id":"req_83d72ba52ac448dba8f0098c8afd7bdd"}, "responseIsBinary": false }, { @@ -25,8 +25,8 @@ "path": "/v1/responses", "body": {"model":"gpt-4o-mini","input":[{"role":"system","content":"Generate a short title and a single emoji for a conversation based on the user message.\nRespond with ONLY a JSON object: {\"title\": \"...\", \"emoji\": \"...\"}\nRules:\n- Title: 2 to 6 words, max 50 characters, sentence case\n- Title must not contain emoji, quotes, colons, or markdown formatting\n- Emoji: exactly one emoji that represents the topic\n- Respond with the JSON object only, no other text"},{"role":"user","content":[{"type":"input_text","text":"\nHere's the conversation:\n\nRecord marker A42 using approve_marker.\n"}]}]}, "status": 200, - "response": "{\n \"id\": \"resp_0845830b9da5818b006ab433acefc487d2a274ad8d9bf95cb3\",\n \"object\": \"response\",\n \"created_at\": 1790194604,\n \"status\": \"completed\",\n \"access_programs\": null,\n \"background\": false,\n \"billing\": {\n \"payer\": \"developer\"\n },\n \"completed_at\": 1790194605,\n \"error\": null,\n \"frequency_penalty\": 0.0,\n \"incomplete_details\": null,\n \"instructions\": null,\n \"max_output_tokens\": null,\n \"max_tool_calls\": null,\n \"model\": \"gpt-4o-mini-2024-07-18\",\n \"moderation\": null,\n \"output\": [\n {\n \"id\": \"msg_0845830b9da5818b006ab433ad619087d299a061f91b519842\",\n \"type\": \"message\",\n \"status\": \"completed\",\n \"content\": [\n {\n \"type\": \"output_text\",\n \"annotations\": [],\n \"logprobs\": [],\n \"text\": \"{\\\"title\\\":\\\"Record marker A42\\\",\\\"emoji\\\":\\\"\\u2705\\\"}\"\n }\n ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\": true,\n \"presence_penalty\": 0.0,\n \"previous_response_id\": null,\n \"prompt_cache_key\": null,\n \"prompt_cache_retention\": \"in_memory\",\n \"reasoning\": {\n \"context\": null,\n \"effort\": null,\n \"summary\": null\n },\n \"safety_identifier\": null,\n \"service_tier\": \"default\",\n \"store\": true,\n \"temperature\": 1.0,\n \"text\": {\n \"format\": {\n \"type\": \"text\"\n },\n \"verbosity\": \"medium\"\n },\n \"tool_choice\": \"auto\",\n \"tool_usage\": {\n \"image_gen\": {\n \"input_tokens\": 0,\n \"input_tokens_details\": {\n \"image_tokens\": 0,\n \"text_tokens\": 0\n },\n \"output_tokens\": 0,\n \"output_tokens_details\": {\n \"image_tokens\": 0,\n \"text_tokens\": 0\n },\n \"total_tokens\": 0\n },\n \"web_search\": {\n \"num_requests\": 0\n }\n },\n \"tools\": [],\n \"top_logprobs\": 0,\n \"top_p\": 1.0,\n \"truncation\": \"disabled\",\n \"usage\": {\n \"input_tokens\": 122,\n \"input_tokens_details\": {\n \"cache_write_tokens\": 0,\n \"cached_tokens\": 0\n },\n \"output_tokens\": 13,\n \"output_tokens_details\": {\n \"reasoning_tokens\": 0\n },\n \"total_tokens\": 135\n },\n \"user\": null,\n \"metadata\": {}\n}", - "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a3fc3a983ba458b3-MAN","connection":"keep-alive","content-type":"application/json","date":"Wed, 23 Sep 2026 20:16:45 GMT","openai-processing-ms":"893","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149999860","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"0s","x-request-id":"req_6b1d07c182d84a5191d2ba30a00418ac"}, + "response": "{\n \"id\": \"resp_0c9914e033879f2b006aba7af3cc0087d2820a78e69e69329a\",\n \"object\": \"response\",\n \"created_at\": 1790606067,\n \"status\": \"completed\",\n \"access_programs\": null,\n \"background\": false,\n \"billing\": {\n \"payer\": \"developer\"\n },\n \"completed_at\": 1790606068,\n \"error\": null,\n \"frequency_penalty\": 0.0,\n \"incomplete_details\": null,\n \"instructions\": null,\n \"max_output_tokens\": null,\n \"max_tool_calls\": null,\n \"model\": \"gpt-4o-mini-2024-07-18\",\n \"moderation\": null,\n \"output\": [\n {\n \"id\": \"msg_0c9914e033879f2b006aba7af4454887d2bb4ecc109213765d\",\n \"type\": \"message\",\n \"status\": \"completed\",\n \"content\": [\n {\n \"type\": \"output_text\",\n \"annotations\": [],\n \"logprobs\": [],\n \"text\": \"{\\\"title\\\":\\\"Recording marker A42\\\",\\\"emoji\\\":\\\"\\ud83d\\udcdd\\\"}\"\n }\n ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\": true,\n \"presence_penalty\": 0.0,\n \"previous_response_id\": null,\n \"prompt_cache_key\": null,\n \"prompt_cache_retention\": \"in_memory\",\n \"reasoning\": {\n \"context\": null,\n \"effort\": null,\n \"summary\": null\n },\n \"safety_identifier\": null,\n \"service_tier\": \"default\",\n \"store\": true,\n \"temperature\": 1.0,\n \"text\": {\n \"format\": {\n \"type\": \"text\"\n },\n \"verbosity\": \"medium\"\n },\n \"tool_choice\": \"auto\",\n \"tool_usage\": {\n \"image_gen\": {\n \"input_tokens\": 0,\n \"input_tokens_details\": {\n \"image_tokens\": 0,\n \"text_tokens\": 0\n },\n \"output_tokens\": 0,\n \"output_tokens_details\": {\n \"image_tokens\": 0,\n \"text_tokens\": 0\n },\n \"total_tokens\": 0\n },\n \"web_search\": {\n \"num_requests\": 0\n }\n },\n \"tools\": [],\n \"top_logprobs\": 0,\n \"top_p\": 1.0,\n \"truncation\": \"disabled\",\n \"usage\": {\n \"input_tokens\": 122,\n \"input_tokens_details\": {\n \"cache_write_tokens\": 0,\n \"cached_tokens\": 0\n },\n \"output_tokens\": 14,\n \"output_tokens_details\": {\n \"reasoning_tokens\": 0\n },\n \"total_tokens\": 136\n },\n \"user\": null,\n \"metadata\": {}\n}", + "rawHeaders": {"access-control-expose-headers":"X-Request-ID, CF-Ray, CF-Ray","alt-svc":"h3=\":443\"; ma=86400","cf-cache-status":"DYNAMIC","cf-ray":"a42378131a7348e7-LHR","connection":"keep-alive","content-type":"application/json","date":"Mon, 28 Sep 2026 14:34:28 GMT","openai-processing-ms":"752","openai-version":"2020-10-01","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","x-content-type-options":"nosniff","x-ratelimit-limit-requests":"30000","x-ratelimit-limit-tokens":"150000000","x-ratelimit-remaining-requests":"29999","x-ratelimit-remaining-tokens":"149999857","x-ratelimit-reset-requests":"2ms","x-ratelimit-reset-tokens":"0s","x-request-id":"req_8d485454bd0b4dfbaebce508dc4b5c87"}, "responseIsBinary": false } ] diff --git a/packages/cli/test/integration/agents/n8n-chat-production.api.test.ts b/packages/cli/test/integration/agents/n8n-chat-production.api.test.ts index 6bf5279d8e8..6084cae5c3a 100644 --- a/packages/cli/test/integration/agents/n8n-chat-production.api.test.ts +++ b/packages/cli/test/integration/agents/n8n-chat-production.api.test.ts @@ -56,7 +56,13 @@ describe('production n8n Chat HTTP route', () => { await Container.get(AgentRepository).update({ id: agentId }, { activeVersionId: versionId }); } - async function createThread(agentId: string, projectId: string, ownerId: string, source: string) { + async function createThread( + agentId: string, + projectId: string, + ownerId: string, + source: string, + accessScope: 'user' | 'project' = 'user', + ) { const threadRepository = Container.get(AgentExecutionThreadRepository); const thread = await threadRepository.save( threadRepository.create({ @@ -64,7 +70,7 @@ describe('production n8n Chat HTTP route', () => { agentId, agentName: 'Agent', projectId, - accessScope: 'user', + accessScope, ownerId, sessionNumber: 1, }), @@ -163,6 +169,34 @@ describe('production n8n Chat HTTP route', () => { expect(detail.body.data.thread.canContinueInPreview).toBe(false); }); + it('scopes the production session list to the requesting user', async () => { + const owner = await createOwner(); + const other = await createMember(); + const { project, agent } = await createAgent(owner.id); + await linkUserToProject(other, project, 'project:editor'); + await activateChat(agent.id); + const mine = await createThread(agent.id, project.id, owner.id, 'n8n_chat_production'); + // A shared session stays visible to the whole project, so only `scope` filters it out. + const theirs = await createThread( + agent.id, + project.id, + other.id, + 'n8n_chat_production', + 'project', + ); + const url = `/projects/${project.id}/agents/v2/${agent.id}/threads?origin=n8n_chat_production`; + + const all = await server.authAgentFor(owner).get(url).expect(200); + expect(all.body.data.threads).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: mine.thread.id }), + expect.objectContaining({ id: theirs.thread.id }), + ]), + ); + const scoped = await server.authAgentFor(owner).get(`${url}&scope=mine`).expect(200); + expect(scoped.body.data.threads).toEqual([expect.objectContaining({ id: mine.thread.id })]); + }); + it('keeps another project member out of a private production session', async () => { const owner = await createOwner(); const other = await createMember(); diff --git a/packages/cli/test/integration/agents/n8n-chat-production.model.test.ts b/packages/cli/test/integration/agents/n8n-chat-production.model.test.ts index 7c917ab2b4a..2b1597271a2 100644 --- a/packages/cli/test/integration/agents/n8n-chat-production.model.test.ts +++ b/packages/cli/test/integration/agents/n8n-chat-production.model.test.ts @@ -132,6 +132,7 @@ function formatCassette(definitions: nock.Definition[]): string { ${definitions .map((definition) => { const fields = Object.entries(definition) + .filter(([, value]) => value !== undefined) .map(([key, value]) => `\t\t${JSON.stringify(key)}: ${JSON.stringify(value)}`) .join(',\n'); return `\t{\n${fields}\n\t}`; diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index 105a3b35c7c..ee76455231c 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { randomValidPassword, testDb } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, UserRepository } from '@n8n/db'; @@ -6,7 +7,6 @@ import validator from 'validator'; import config from '@/config'; import { AUTH_COOKIE_NAME } from '@/constants'; -import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { MfaService } from '@/mfa/mfa.service'; import { JwtService } from '@/services/jwt.service'; diff --git a/packages/cli/test/integration/chat-trigger-test-webhook-auth.api.test.ts b/packages/cli/test/integration/chat-trigger-test-webhook-auth.api.test.ts index 9f0400045c6..66491ca775a 100644 --- a/packages/cli/test/integration/chat-trigger-test-webhook-auth.api.test.ts +++ b/packages/cli/test/integration/chat-trigger-test-webhook-auth.api.test.ts @@ -26,7 +26,7 @@ import path from 'node:path'; import { agent as testAgent } from 'supertest'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { Telemetry } from '@/telemetry'; import { TestWebhooks } from '@/webhooks/test-webhooks'; import { WebhookServer } from '@/webhooks/webhook-server'; diff --git a/packages/cli/test/integration/collaboration/collaboration.service.test.ts b/packages/cli/test/integration/collaboration/collaboration.service.test.ts index 8bbb33bce9a..b82d56aae68 100644 --- a/packages/cli/test/integration/collaboration/collaboration.service.test.ts +++ b/packages/cli/test/integration/collaboration/collaboration.service.test.ts @@ -19,7 +19,7 @@ import type { import { CollaborationService } from '@/collaboration/collaboration.service'; import { CollaborationState } from '@/collaboration/collaboration.state'; import { Push } from '@/push'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; describe('CollaborationService', () => { mockInstance(Push, new Push(mock(), mock(), mock(), mock(), mock())); diff --git a/packages/cli/test/integration/commands/credentials.cmd.test.ts b/packages/cli/test/integration/commands/credentials.cmd.test.ts index 1023757b5d7..b132b74d3f7 100644 --- a/packages/cli/test/integration/commands/credentials.cmd.test.ts +++ b/packages/cli/test/integration/commands/credentials.cmd.test.ts @@ -855,11 +855,11 @@ describe('content-import policy', () => { const service = Container.get(PolicyEnforcementService); // The prototype method, not `service.enforceContentImport`: that is the spy itself. const clearance = PolicyEnforcementService.prototype.enforceContentImport; - enforceContentImport.mockImplementation(async (context: ContentImportContext) => { + enforceContentImport.mockImplementation(async (context: ContentImportContext, actor) => { if ('credential' in context && context.credential.type === blockedType) { throw new PolicyViolationError([violation]); } - return await clearance.call(service, context); + return await clearance.call(service, context, actor); }); }; @@ -893,11 +893,14 @@ describe('content-import policy', () => { await command.run([`--input=${inputPath}`]); - expect(enforceContentImport).toHaveBeenCalledWith({ - credential: { id: 'blocked', type: 'slackApi' }, - projectId: ownerProject.id, - transport: 'cli', - }); + expect(enforceContentImport).toHaveBeenCalledWith( + { + credential: { id: 'blocked', type: 'slackApi' }, + projectId: ownerProject.id, + transport: 'cli', + }, + { kind: 'system', reason: 'cli-import' }, + ); const after = { credentials: await getAllCredentials(), sharings: await getAllSharedCredentials(), @@ -921,11 +924,14 @@ describe('content-import policy', () => { // Flagless re-import: the batch target is the owner's project, but the row stays the member's. await command.run([`--input=${inputPath}`]); - expect(enforceContentImport).toHaveBeenCalledWith({ - credential: { id: 'existing', type: 'aws' }, - projectId: memberProject.id, - transport: 'cli', - }); + expect(enforceContentImport).toHaveBeenCalledWith( + { + credential: { id: 'existing', type: 'aws' }, + projectId: memberProject.id, + transport: 'cli', + }, + { kind: 'system', reason: 'cli-import' }, + ); }); test('fails the import when the policy layer errors', async () => { diff --git a/packages/cli/test/integration/commands/import-credentials.policy.cmd.test.ts b/packages/cli/test/integration/commands/import-credentials.policy.cmd.test.ts new file mode 100644 index 00000000000..6a855664135 --- /dev/null +++ b/packages/cli/test/integration/commands/import-credentials.policy.cmd.test.ts @@ -0,0 +1,143 @@ +/** + * One command run per file: the policy module registers its implementation once per process, + * so a second `init()` in the same file would throw. + */ +import { Logger } from '@n8n/backend-common'; +import { getPersonalProject, mockInstance, testDb, testModules } from '@n8n/backend-test-utils'; +import { LICENSE_FEATURES, type BooleanLicenseFeature } from '@n8n/constants'; +import { PolicyCheckMetadata } from '@n8n/decorators'; +import { Container } from '@n8n/di'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import '@/zod-alias-support'; +import { ImportCredentialsCommand } from '@/commands/import/credentials'; +import { License } from '@/license'; +import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; +import { CREDENTIAL_TYPES_KIND } from '@/modules/type-availability-policies/constants'; +import { TypeAvailabilityPolicyService } from '@/modules/type-availability-policies/type-availability-policy.service'; +import { setupTestCommand } from '@test-integration/utils/test-command'; + +import { createCredentials, getAllCredentials } from '../shared/db/credentials'; +import { createMember, createOwner } from '../shared/db/users'; + +const BLOCKED = 'githubApi'; +const BLOCKED_IN_OTHER_PROJECT = 'slackApi'; +const ALLOWED = 'httpBasicAuth'; + +beforeAll(async () => { + await testModules.loadModules(['policy-infrastructure', 'type-availability-policies']); +}); + +mockInstance(LoadNodesAndCredentials, { loaders: {} }); +mockInstance(License, { + isLicensed: (feature: BooleanLicenseFeature) => + feature === LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES, +}); +const command = setupTestCommand(ImportCredentialsCommand); + +afterAll(async () => { + await testDb.truncate([ + 'TypeAvailabilityPolicyAttachment', + 'TypeAvailabilityPolicyScope', + 'TypeAvailabilityPolicy', + 'SharedCredentials', + 'CredentialsEntity', + ]); +}); + +test('import:credentials skips blocked credentials and imports the rest', async () => { + const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); + const otherProject = await getPersonalProject(await createMember()); + await createCredentials( + { id: 'stored-github', name: 'old', type: BLOCKED, data: '' }, + ownerProject, + ); + // Owned elsewhere: judging it before the ownership check would abort the whole import. + await createCredentials( + { id: 'stored-basic', name: 'old-basic', type: ALLOWED, data: '' }, + otherProject, + ); + await Container.get(TypeAvailabilityPolicyService).setEffectivePolicy( + CREDENTIAL_TYPES_KIND, + null, + { + rules: [{ id: 'deny-github', action: 'deny', selector: { kind: 'name', value: BLOCKED } }], + defaultAction: 'allow', + }, + 0, + owner.id, + ); + // Only the owning project denies it, so judging it in the target project would admit it. + await Container.get(TypeAvailabilityPolicyService).setEffectivePolicy( + CREDENTIAL_TYPES_KIND, + otherProject.id, + { + rules: [ + { + id: 'deny-slack', + action: 'deny', + selector: { kind: 'name', value: BLOCKED_IN_OTHER_PROJECT }, + }, + ], + defaultAction: 'allow', + }, + 0, + owner.id, + ); + + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'n8n-credential-import-')); + const inputPath = path.join(directory, 'credentials.json'); + fs.writeFileSync( + inputPath, + JSON.stringify([ + { id: 'new-github', name: 'new-github', type: BLOCKED, data: { accessToken: 'x' } }, + { id: 'stored-github', name: 'renamed', type: BLOCKED, data: { accessToken: 'x' } }, + { + id: 'stored-basic', + name: 'retyped', + type: BLOCKED_IN_OTHER_PROJECT, + data: { accessToken: 'x' }, + }, + { id: 'new-basic', name: 'new-basic', type: ALLOWED, data: { user: 'u', password: 'p' } }, + { name: 'no-id', type: ALLOWED, data: { user: 'u', password: 'p' } }, + ]), + ); + const warn = vi.spyOn(Container.get(Logger), 'warn'); + + try { + await command.run([`--input=${inputPath}`, `--projectId=${ownerProject.id}`]); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + + // By id, not by importing the class: the import would register the check itself. + const registeredIds = Container.get(PolicyCheckMetadata) + .getClasses() + .map((checkClass) => Container.get(checkClass).id); + expect(registeredIds).toContain('credential-type-availability'); + + const credentials = await getAllCredentials(); + expect( + credentials + .map(({ name, type }) => ({ name, type })) + .sort((a, b) => a.name.localeCompare(b.name)), + ).toEqual([ + { name: 'new-basic', type: ALLOWED }, + { name: 'no-id', type: ALLOWED }, + // An import is judged on its own type, so an unchanged blocked type is not grandfathered. + { name: 'old', type: BLOCKED }, + { name: 'old-basic', type: ALLOWED }, + ]); + + const skipped = warn.mock.calls + .map(([message]) => message) + .filter((message) => message.startsWith('Skipping credential')); + expect(skipped).toEqual([ + expect.stringContaining('new-github'), + expect.stringContaining('stored-github'), + expect.stringContaining('stored-basic'), + ]); +}); diff --git a/packages/cli/test/integration/commands/import-workflows.policy.cmd.test.ts b/packages/cli/test/integration/commands/import-workflows.policy.cmd.test.ts new file mode 100644 index 00000000000..53fd96d25fb --- /dev/null +++ b/packages/cli/test/integration/commands/import-workflows.policy.cmd.test.ts @@ -0,0 +1,114 @@ +/** + * One command run per file: the policy module registers its implementation once per process, + * so a second `init()` in the same file would throw. + */ +import { Logger } from '@n8n/backend-common'; +import { getAllWorkflows, mockInstance, testDb, testModules } from '@n8n/backend-test-utils'; +import { LICENSE_FEATURES, type BooleanLicenseFeature } from '@n8n/constants'; +import { WorkflowPublishHistoryRepository } from '@n8n/db'; +import { PolicyCheckMetadata } from '@n8n/decorators'; +import { Container } from '@n8n/di'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import '@/zod-alias-support'; +import { ActiveWorkflowManager } from '@/active-workflow-manager'; +import { ImportWorkflowsCommand } from '@/commands/import/workflow'; +import { License } from '@/license'; +import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; +import { NODE_TYPES_KIND } from '@/modules/type-availability-policies/constants'; +import { TypeAvailabilityPolicyService } from '@/modules/type-availability-policies/type-availability-policy.service'; +import { NodeTypes } from '@/node-types'; +import { setupTestCommand } from '@test-integration/utils/test-command'; + +import { createOwner } from '../shared/db/users'; + +const BLOCKED = 'n8n-nodes-base.code'; +const ALLOWED = 'n8n-nodes-base.set'; + +beforeAll(async () => { + await testModules.loadModules(['policy-infrastructure', 'type-availability-policies']); +}); + +mockInstance(LoadNodesAndCredentials, { loaders: {} }); +mockInstance(ActiveWorkflowManager); +mockInstance(WorkflowPublishHistoryRepository); +mockInstance(NodeTypes, { + resolveBaseName: (name: string) => ({ baseName: name, isSyntheticTool: false }), +}); +mockInstance(License, { + isLicensed: (feature: BooleanLicenseFeature) => + feature === LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES, +}); +const command = setupTestCommand(ImportWorkflowsCommand); + +afterAll(async () => { + await testDb.truncate([ + 'TypeAvailabilityPolicyAttachment', + 'TypeAvailabilityPolicyScope', + 'TypeAvailabilityPolicy', + 'SharedWorkflow', + 'WorkflowEntity', + ]); +}); + +const workflowWith = (id: string, nodeType: string) => ({ + id, + name: id, + nodes: [ + { + id: `${id}-node`, + name: 'Node', + type: nodeType, + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + connections: {}, + active: false, + settings: {}, + versionId: `${id}-version`, +}); + +test('import:workflow skips a workflow with a blocked node and imports the rest', async () => { + const owner = await createOwner(); + await Container.get(TypeAvailabilityPolicyService).setEffectivePolicy( + NODE_TYPES_KIND, + null, + { + rules: [{ id: 'deny-code', action: 'deny', selector: { kind: 'name', value: BLOCKED } }], + defaultAction: 'allow', + }, + 0, + owner.id, + ); + + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'n8n-workflow-import-')); + const inputPath = path.join(directory, 'workflows.json'); + fs.writeFileSync( + inputPath, + JSON.stringify([workflowWith('with-code', BLOCKED), workflowWith('with-set', ALLOWED)]), + ); + const warn = vi.spyOn(Container.get(Logger), 'warn'); + + try { + await command.run([`--input=${inputPath}`]); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + + // By id, not by importing the class: the import would register the check itself. + const registeredIds = Container.get(PolicyCheckMetadata) + .getClasses() + .map((checkClass) => Container.get(checkClass).id); + expect(registeredIds).toContain('node-type-availability'); + + const workflows = await getAllWorkflows(); + expect(workflows.map(({ id }) => id)).toEqual(['with-set']); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Skipped workflow "with-code"'), + expect.anything(), + ); +}); diff --git a/packages/cli/test/integration/commands/import.cmd.test.ts b/packages/cli/test/integration/commands/import.cmd.test.ts index 750bcd3f7ab..f863adb5b28 100644 --- a/packages/cli/test/integration/commands/import.cmd.test.ts +++ b/packages/cli/test/integration/commands/import.cmd.test.ts @@ -525,7 +525,9 @@ describe('--activeState flag', () => { expect(inactiveWorkflow).toMatchObject({ active: false, activeVersionId: null }); const activeWorkflowManager = Container.get(ActiveWorkflowManager); - expect(activeWorkflowManager.add).toHaveBeenCalledWith('998', 'activate'); + expect(activeWorkflowManager.add).toHaveBeenCalledWith('998', 'activate', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); expect(activeWorkflowManager.add).not.toHaveBeenCalledWith('999', expect.anything()); }); @@ -558,7 +560,9 @@ describe('--activeState flag', () => { const activeWorkflowManager = Container.get(ActiveWorkflowManager); expect(activeWorkflowManager.remove).toHaveBeenCalledWith('998'); - expect(activeWorkflowManager.add).toHaveBeenLastCalledWith('998', 'activate'); + expect(activeWorkflowManager.add).toHaveBeenLastCalledWith('998', 'activate', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); const publishHistoryRepo = Container.get(WorkflowPublishHistoryRepository); expect(publishHistoryRepo.addRecord).toHaveBeenCalledTimes(3); diff --git a/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts b/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts index 517aabe9e31..a02626a9a94 100644 --- a/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts +++ b/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { mockInstance, randomEmail, @@ -16,7 +17,6 @@ import { Container } from '@n8n/di'; import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; import { Not } from '@n8n/typeorm'; -import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { JwtService } from '@/services/jwt.service'; import { PasswordUtility } from '@/services/password.utility'; diff --git a/packages/cli/test/integration/controllers/oauth/oauth1.api.test.ts b/packages/cli/test/integration/controllers/oauth/oauth1.api.test.ts index 502632d9d85..7457d6ef472 100644 --- a/packages/cli/test/integration/controllers/oauth/oauth1.api.test.ts +++ b/packages/cli/test/integration/controllers/oauth/oauth1.api.test.ts @@ -7,7 +7,7 @@ import nock from 'nock'; import { CredentialsHelper } from '@/credentials-helper'; import { OauthService, type OauthFlowState } from '@/oauth/oauth.service'; import { MAX_CSRF_AGE } from '@/oauth/types'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { decryptCredentialData, getCredentialById, diff --git a/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts b/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts index 8bcf94e18df..db4a0631d5c 100644 --- a/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts +++ b/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts @@ -11,7 +11,7 @@ import { CredentialsHelper } from '@/credentials-helper'; import { ExternalHooks } from '@/external-hooks'; import { OauthService, type OauthFlowState } from '@/oauth/oauth.service'; import { MAX_CSRF_AGE } from '@/oauth/types'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { decryptCredentialData, getCredentialById, diff --git a/packages/cli/test/integration/credentials/instance-credentials.api.test.ts b/packages/cli/test/integration/credentials/instance-credentials.api.test.ts index 42daa33a265..dd60890a331 100644 --- a/packages/cli/test/integration/credentials/instance-credentials.api.test.ts +++ b/packages/cli/test/integration/credentials/instance-credentials.api.test.ts @@ -11,7 +11,7 @@ import { Container } from '@n8n/di'; import { CredentialsService } from '@/credentials/credentials.service'; import { InstanceCredentialBroker } from '@/credentials/instance-credential-broker'; import type { InstanceCredentialUse } from '@/credentials/instance-credential-use.registry'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { UnprocessableRequestError } from '@n8n/errors'; import { ExternalHooks } from '@/external-hooks'; import { 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 7bd2a5c12d3..4745239c551 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 @@ -3,15 +3,10 @@ import type { AgentSessionQueryFilters, AgentSessionStatus, } from '@n8n/api-types'; -import { - Agent as RuntimeAgent, - Tool, - type SerializableAgentState, - type StreamChunk, -} from '@n8n/agents'; +import { Agent as RuntimeAgent, Tool, type SerializableAgentState } from '@n8n/agents'; import { createTeamProject, mockLogger, testDb, testModules } from '@n8n/backend-test-utils'; import { AgentsConfig, AiConfig } from '@n8n/config'; -import type { OperationContext, User } from '@n8n/db'; +import { UserRepository, type OperationContext, type User } from '@n8n/db'; import { Container } from '@n8n/di'; import { DataSource, EntityManager } from '@n8n/typeorm'; import { generateNanoId } from '@n8n/utils/generate-nano-id'; @@ -45,16 +40,26 @@ import { AgentExecutionService } from '@/modules/agents/agent-execution.service' import type { AgentExecutionUpdateBroadcaster } from '@/modules/agents/agent-execution-update-broadcaster'; import { AgentInterruptedExecutionSweeper } from '@/modules/agents/agent-interrupted-execution-sweeper'; import { AgentTurnExecutionService } from '@/modules/agents/agent-turn-execution.service'; +import type { AgentExecutionStreamChunk } from '@/modules/agents/types/agent-steering'; import { AgentMessageQueueService, type ClaimedAgentMessage, } from '@/modules/agents/agent-message-queue.service'; -import { AgentMessageRepository } from '@/modules/agents/repositories/agent-message.repository'; +import { + AgentMessageIdConflictError, + AgentMessageRepository, +} from '@/modules/agents/repositories/agent-message.repository'; +import { AgentResourceEntity } from '@/modules/agents/entities/agent-resource.entity'; import { AgentThreadRepository } from '@/modules/agents/repositories/agent-thread.repository'; import { AgentExecutionMessageLink } from '@/modules/agents/entities/agent-execution-message-link.entity'; +import { AgentMessageSteeringService } from '@/modules/agents/agent-message-steering.service'; import { AgentMessageQueueRepository } from '@/modules/agents/repositories/agent-message-queue.repository'; import { AgentTurnAlreadyRunningError } from '@/modules/agents/agent-turn-already-running.error'; -import { EXECUTION_METADATA_KEY } from '@/modules/agents/types/agent-queued-message'; +import { + EXECUTION_METADATA_KEY, + type AgentExecutionAdmission, + type QueuedIntegrationMessage, +} from '@/modules/agents/types/agent-queued-message'; import type { AgentBackgroundJobService } from '@/modules/agents/background/agent-background-job.service'; import type { AgentWakeService } from '@/modules/agents/background/agent-wake.service'; import { ExecutionRecorder, type TimelineEvent } from '@/modules/agents/execution-recorder'; @@ -70,6 +75,7 @@ import { AgentChatAttachmentRepository } from '@/modules/agents/repositories/age 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, createAdmin } from '../../shared/db/users'; @@ -183,6 +189,17 @@ describe('AgentExecutionRepository', () => { queueRepository, messageRepository, ); + const queueUpdates = mock(); + const steering = new AgentMessageSteeringService( + txRunner, + queueRepository, + threads, + executions, + messageRepository, + checkpointStorage, + executionService, + mock(), + ); const queue = new AgentMessageQueueService( txRunner, queueRepository, @@ -192,12 +209,20 @@ describe('AgentExecutionRepository', () => { checkpointStorage, connection ? new AgentRepository(connection) : agentRepo, attachmentService, - mock(), + queueUpdates, + messageRepository, + steering, ); + const chatExecutionService = mock(); + chatExecutionService.settle.mockImplementation(async (_executionId, finalize) => { + await finalize(); + }); return { + steering, txRunner, threads, queue, + queueUpdates, queueRepository, checkpointStorage, executionService, @@ -207,12 +232,22 @@ describe('AgentExecutionRepository', () => { turns: new AgentTurnExecutionService( mockLogger(), executionService, - mock(), + chatExecutionService, queue, + steering, ), }; } + async function enqueue( + services: ReturnType, + input: Parameters[0], + ) { + const result = await services.queue.enqueue(input); + if (result.status !== 'accepted') throw new Error('Expected queue acceptance'); + return result.item; + } + function observePeerTransaction() { const started = createDeferredPromise(); const querySpy = vi.spyOn(peer.logger, 'logQuery').mockImplementation((query) => { @@ -269,8 +304,8 @@ describe('AgentExecutionRepository', () => { return spy; } - async function collect(stream: AsyncIterable) { - const chunks: StreamChunk[] = []; + async function collect(stream: AsyncIterable) { + const chunks: AgentExecutionStreamChunk[] = []; for await (const chunk of stream) chunks.push(chunk); return chunks; } @@ -1064,9 +1099,15 @@ describe('AgentExecutionRepository', () => { recorder.record({ type: 'text-delta', id: 'text-1', delta: 'Unsaved final output' }); recorder.record({ type: 'finish', finishReason: 'stop' }); const record = recorder.getMessageRecord(); + // `writeTerminalExecution` retries a transient `updateIfRunning` failure + // (3 attempts, linear backoff) before rethrowing, so a single blip is + // recovered. To exercise the reject-and-sweeper-recovery path, every + // retry attempt must fail: mock a persistent rejection for the duration of + // this phase (the `finally` restores the real method before the sweeper + // runs). const update = vi .spyOn(repository, 'updateIfRunning') - .mockRejectedValueOnce(new Error('database unavailable')); + .mockRejectedValue(new Error('database unavailable')); try { await expect( turns.finalizeExecution({ @@ -1116,6 +1157,7 @@ describe('AgentExecutionRepository', () => { threadId: string, message: string, sessionMode: 'new' | 'existing' = 'existing', + messageId?: string, ): Parameters[0] { return { agentId, @@ -1127,6 +1169,7 @@ describe('AgentExecutionRepository', () => { kind: 'preview', message, userId: owner.id, + messageId, resourceId: `draft-chat:${owner.id}`, }, }; @@ -1141,27 +1184,609 @@ describe('AgentExecutionRepository', () => { async function finish( services: ReturnType, item: ClaimedAgentMessage, + finishReason: 'stop' | 'error' | 'cancelled' = 'stop', ) { const recorder = new ExecutionRecorder(); - recorder.record({ type: 'finish', finishReason: 'stop' }); + if (finishReason === 'error') recorder.record({ type: 'error', error: new Error('Failed') }); await services.executionService.finalizeExecution(item.admission.executionId, { ...item.recording, - record: recorder.getMessageRecord(), + record: { ...recorder.getMessageRecord(), finishReason }, }); await services.queue.settle(item.thread.id, item.admission.executionId); } - it('lists only pending Preview input and removes its attachments without affecting the active run', async () => { + it.each([ + { automaticPreviewContinuation: false, legacy: false }, + { automaticPreviewContinuation: true, legacy: false }, + { automaticPreviewContinuation: false, legacy: true }, + ])( + 'consumes steering through a Preview resume (automatic=$automaticPreviewContinuation, legacy=$legacy)', + async ({ automaticPreviewContinuation, legacy }) => { + const fixture = await startSuspendedApprovalRun(owner); + const services = recordingServices(); + const { threadId, suspension, recording, common } = fixture; + const [predecessor] = await repository.findByThreadIdOrdered(threadId); + if (legacy) { + await repository.update(predecessor.id, { userMessage: 'Start' }); + await repository.manager.delete(AgentExecutionMessageLink, { + executionId: predecessor.id, + }); + } + const initialInputs = await services.messageRepository.findExecutionInputs([ + predecessor.id, + ]); + const target = { projectId, agentId, threadId, userId: owner.id }; + const b = await enqueue(services, input(threadId, 'B')); + const c = await enqueue(services, input(threadId, 'C')); + const runtime = fixture.makeAgent(services.checkpointStorage.getStorage(agentId)); + let executionId = ''; + try { + const chunks = await collect( + services.turns.execute({ + ...common, + agentInstance: runtime, + previewChat: true, + automaticPreviewContinuation, + onExecutionStarted: (id) => { + executionId = id; + }, + prepare: async () => ({ + type: 'resume', + resumeData: { approved: true }, + options: { + runId: suspension.runId, + toolCallId: suspension.toolCallId, + onResumeClaimed: async () => { + await services.queue.steer({ ...target, queueId: c.id, executionId }); + }, + }, + recording: { ...recording, userMessage: null, sessionMode: 'existing' }, + }), + }), + ); + expect(chunks).toContainEqual( + expect.objectContaining({ + type: 'message-steered', + queueId: c.id, + executionId, + message: expect.objectContaining({ + id: c.messageId, + content: [{ type: 'text', text: 'C' }], + }), + }), + ); + expect(await repository.findOneByOrFail({ id: executionId })).toMatchObject({ + status: 'success', + acceptsSteering: false, + timeline: expect.arrayContaining([ + expect.objectContaining({ type: 'input', messageId: c.messageId }), + ]), + }); + const inputs = await services.messageRepository.findExecutionInputs([executionId]); + expect(inputs.get(executionId)?.map(({ id }) => id)).toEqual([ + ...(initialInputs.get(predecessor.id) ?? []).map(({ id }) => id), + c.messageId, + ]); + const detail = await services.executionService.getThreadDetail( + threadId, + projectId, + agentId, + owner.id, + ); + expect( + executionsToMessagesDto(detail!.executions) + .filter(({ role }) => role === 'user') + .map(({ content }) => content), + ).toEqual([[{ type: 'text', text: 'Start' }], [{ type: 'text', text: 'C' }]]); + expect( + formatPreviewSessionContext(detail!.thread, detail!.executions, executionId), + ).toContain('User: Start'); + expect((await services.queue.listPending(target)).items.map(({ id }) => id)).toEqual([ + b.id, + ]); + await finish(services, await claim(services, threadId)); + } finally { + await runtime.close(); + } + }, + ); + + it('consumes reserved messages in acceptance order and retains their history after interruption', async () => { + const local = recordingServices(); + const remote = recordingServices(undefined, peer); + const threadId = uuid(); + const target = { projectId, agentId, threadId, userId: owner.id }; + await enqueue(local, input(threadId, 'A', 'new')); + const active = await claim(local, threadId); + const executionId = active.admission.executionId; + const b = await enqueue(local, input(threadId, 'B')); + const data = input(threadId, 'C', 'existing', uuid()); + data.payload.attachments = [ + { id: 'file-c', fileName: 'notes.txt', mimeType: 'text/plain', sizeBytes: 3 }, + ]; + const c = await enqueue(local, data); + const original = await local.messageRepository.findOneByOrFail({ id: c.messageId }); + const d = await enqueue(local, input(threadId, 'D')); + expect((await remote.queue.listPending(target)).items.map(({ id }) => id)).toEqual([ + b.id, + c.id, + d.id, + ]); + await local.queue.steer({ ...target, queueId: d.id, executionId }); + const concurrent = await Promise.allSettled([ + local.queue.steer({ ...target, queueId: c.id, executionId }), + remote.queue.steer({ ...target, queueId: c.id, executionId }), + ]); + expect(concurrent.map(({ status }) => status).sort()).toEqual(['fulfilled', 'rejected']); + expect(await remote.queue.enqueue(data)).toEqual({ status: 'duplicate' }); + await expect( + remote.queue.steer({ ...target, userId: 'other-user', queueId: b.id, executionId }), + ).rejects.toThrow('Session not found'); + await expect(remote.queue.removePending({ ...target, queueId: c.id })).rejects.toThrow(); + await expect( + remote.queue.updatePending({ ...target, queueId: c.id, message: 'changed' }), + ).rejects.toThrow(); + expect((await remote.queue.listPending(target)).items.map(({ id }) => id)).toEqual([ + d.id, + c.id, + b.id, + ]); + expect(await remote.queue.claimNext(threadId, async () => true)).toBeNull(); + const recorder = local.turns.createRecorder(undefined, () => executionId, target); + recorder.record({ type: 'text-delta', id: 'before', delta: 'before' }); + const outputId = uuid(); + const boundaryTimestamp = Date.now(); + const result = await local.steering.consume( + { ...target, executionId }, + { + messages: [ + { + id: active.admission.inputMessageIds[0], + createdAt: new Date(boundaryTimestamp - 1), + role: 'user', + content: [ + { type: 'text', text: 'A' }, + { + type: 'file', + mediaType: 'text/plain', + data: new Uint8Array([1, 2, 3]), + fileRef: { id: 'file-a', fileName: 'a.txt' }, + }, + ], + }, + { + id: outputId, + createdAt: new Date(boundaryTimestamp), + role: 'assistant', + content: [{ type: 'text', text: 'before' }], + }, + ], + lastCreatedAt: boundaryTimestamp, + completing: false, + canContinue: true, + }, + recorder, + local.executionService.getAbortSignal(executionId), + ); + expect(result.events.map(({ queueId }) => queueId)).toEqual([d.id, c.id]); + expect(result.messages.map(({ id }) => id)).toEqual([d.messageId, c.messageId]); + expect(result.events[1].message).toMatchObject({ + id: c.messageId, + createdAt: original.createdAt.toISOString(), + }); + const stored = await local.messageRepository.findBy({ threadId }); + expect(stored).toHaveLength(5); + expect(stored.find(({ id }) => id === c.messageId)).toMatchObject({ + content: original.content, + createdAt: original.createdAt, + author: original.author, + origin: original.origin, + modelContextAt: result.messages[1].createdAt, + }); + expect( + (await local.messageRepository.findRuntimeMessages({ threadId })).map(({ id }) => id), + ).toEqual([active.admission.inputMessageIds[0], outputId, d.messageId, c.messageId]); + expect( + await repository.manager.find(AgentExecutionMessageLink, { + where: { executionId }, + order: { direction: 'ASC', position: 'ASC' }, + }), + ).toMatchObject([ + { messageId: active.admission.inputMessageIds[0], direction: 'input', position: 0 }, + { messageId: d.messageId, direction: 'input', position: 1 }, + { messageId: c.messageId, direction: 'input', position: 2 }, + { messageId: outputId, direction: 'output', position: 0 }, + ]); + expect(await remote.queue.enqueue(data)).toEqual({ status: 'duplicate' }); + expect(JSON.stringify(stored)).not.toContain('"data"'); + expect(JSON.stringify(stored)).toContain('file-c'); + expect(local.attachmentService.deleteByIds).not.toHaveBeenCalled(); + expect((await remote.queue.listPending(target)).items.map(({ id }) => id)).toEqual([b.id]); + await expect( + remote.queue.steer({ ...target, queueId: c.id, executionId }), + ).rejects.toMatchObject({ httpStatusCode: 409 }); + await expect(finish(remote, active)).rejects.toThrow('no longer running'); + expect( + (await repository.findExecution(executionId))?.timeline?.filter( + ({ type }) => type === 'input', + ), + ).toHaveLength(2); + const cutoff = new Date(Date.now() - 120_000); + await repository.update(executionId, { updatedAt: new Date(cutoff.getTime() - 1) }); + const abandoned = await repository.findOneByOrFail({ id: executionId }); + expect(await remote.executionService.finalizeInterruptedExecution(abandoned, cutoff)).toBe( + true, + ); + const next = await claim(remote, threadId); + expect(next.item.id).toBe(b.id); + expect( + (await repository.findExecution(executionId))?.timeline?.filter( + ({ type }) => type === 'input', + ), + ).toHaveLength(2); + await finish(remote, next); + }); + + it('rolls back messages and timeline markers if consumption fails', async () => { const services = recordingServices(); const threadId = uuid(); const target = { projectId, agentId, threadId, userId: owner.id }; - await services.queue.enqueue(input(threadId, 'active', 'new')); + await enqueue(services, input(threadId, 'A', 'new')); const active = await claim(services, threadId); - const pendingInput = input(threadId, 'pending'); + const executionId = active.admission.executionId; + const c = await enqueue(services, input(threadId, 'C')); + await services.queue.steer({ ...target, queueId: c.id, executionId }); + const before = await services.messageRepository.find({ + where: { threadId }, + order: { id: 'ASC' }, + }); + const consume = services.queueRepository.consumeSteering.bind(services.queueRepository); + const failure = vi + .spyOn(services.queueRepository, 'consumeSteering') + .mockImplementationOnce(async (...args) => { + await consume(...args); + throw new Error('consumption failed'); + }); + const recorder = new ExecutionRecorder(); + await expect( + services.steering.consume( + { ...target, executionId }, + { + messages: [], + lastCreatedAt: 0, + completing: false, + canContinue: true, + }, + recorder, + new AbortController().signal, + ), + ).rejects.toThrow('consumption failed'); + failure.mockRestore(); + expect( + await services.messageRepository.find({ where: { threadId }, order: { id: 'ASC' } }), + ).toEqual(before); + expect( + (await services.messageRepository.findExecutionInputs([executionId])) + .get(executionId) + ?.map(({ id }) => id), + ).toEqual(active.admission.inputMessageIds); + expect((await repository.findExecution(executionId))?.timeline ?? []).toEqual([]); + expect(recorder.getMessageRecord().timeline).toEqual([]); + expect(await services.queueRepository.findOneByOrFail({ id: c.id })).toMatchObject({ + steeringExecutionId: executionId, + }); + await finish(services, active); + }); + + it.each(['stop', 'failure', 'suspension', 'restart', 'limit'] as const)( + 'restores original FIFO positions after %s before consumption', + async (reason) => { + const local = recordingServices(); + const remote = recordingServices(undefined, peer); + const threadId = uuid(); + const target = { projectId, agentId, threadId, userId: owner.id }; + await enqueue(local, input(threadId, 'A', 'new')); + const active = await claim(local, threadId); + const executionId = active.admission.executionId; + const b = await enqueue(local, input(threadId, 'B')); + const c = await enqueue(local, input(threadId, 'C')); + await remote.queue.steer({ ...target, queueId: c.id, executionId }); + expect((await local.queue.listPending(target)).items.map(({ id }) => id)).toEqual([ + c.id, + b.id, + ]); + if (reason === 'stop') await remote.steering.close(threadId, executionId); + if (reason === 'limit') { + await local.steering.consume( + { ...target, executionId }, + { + messages: [], + lastCreatedAt: 0, + completing: true, + canContinue: false, + }, + new ExecutionRecorder(), + new AbortController().signal, + ); + } + if (reason === 'suspension') { + await local.checkpointStorage.save( + uuid(), + { + status: 'suspended', + messageList: { messages: [], historyIds: [], inputIds: [], responseIds: [] }, + pendingToolCalls: {}, + persistence: { + threadId, + resourceId: `draft-chat:${owner.id}`, + hostMetadata: { [EXECUTION_METADATA_KEY]: executionId }, + }, + }, + agentId, + ); + } + if (reason === 'restart') { + const cutoff = new Date(Date.now() - 120_000); + await repository.update(executionId, { updatedAt: new Date(cutoff.getTime() - 1) }); + await remote.executionService.finalizeInterruptedExecution( + await repository.findOneByOrFail({ id: executionId }), + cutoff, + ); + } else if (reason === 'stop') { + await finish(local, active, 'cancelled'); + } else if (reason === 'failure') { + await finish(local, active, 'error'); + } else { + await finish(local, active); + } + if (reason !== 'restart') { + expect((await remote.queue.listPending(target)).items.map(({ id }) => id)).toEqual([ + b.id, + c.id, + ]); + } + const next = await remote.queue.claimNext(threadId, async () => true); + expect( + (await remote.queueRepository.findOneByOrFail({ id: c.id })).steeringExecutionId, + ).toBeNull(); + await expect( + remote.queue.steer({ ...target, queueId: c.id, executionId }), + ).rejects.toThrow(); + if (reason === 'suspension') { + expect(next).toBeNull(); + expect((await remote.queue.listPending(target)).items.map(({ id }) => id)).toEqual([ + b.id, + c.id, + ]); + } else { + expect(next?.item.id).toBe(b.id); + await finish(remote, next!); + await finish(remote, await claim(remote, threadId)); + } + }, + ); + + it.each(['steer', 'stop', 'completion', 'failure', 'edit', 'remove'] as const)( + 'serializes steering with %s on another database connection', + async (operation) => { + const local = recordingServices(); + const remote = recordingServices(undefined, peer); + const threadId = uuid(); + await enqueue(local, input(threadId, 'A', 'new')); + const active = await claim(local, threadId); + const executionId = active.admission.executionId; + const c = await enqueue(local, input(threadId, 'C')); + const target = { + projectId, + agentId, + threadId, + userId: owner.id, + queueId: c.id, + executionId, + }; + const acquired = createDeferredPromise(); + const release = createDeferredPromise(); + const lock = local.threads.lockById.bind(local.threads); + vi.spyOn(local.threads, 'lockById').mockImplementationOnce(async (...args) => { + const thread = await lock(...args); + acquired.resolve(); + await release.promise; + return thread; + }); + const first = (async () => { + switch (operation) { + case 'steer': + return await local.queue.steer(target); + case 'stop': + return await local.steering.close(threadId, executionId); + case 'completion': + return await local.steering.consume( + target, + { messages: [], lastCreatedAt: 0, completing: true, canContinue: true }, + new ExecutionRecorder(), + new AbortController().signal, + ); + case 'failure': + return await local.executionService.finalizeExecution(executionId, { + ...active.recording, + record: { + ...new ExecutionRecorder().getMessageRecord(), + finishReason: 'error', + error: 'Failed', + }, + }); + case 'edit': + return await local.queue.updatePending({ ...target, message: 'edited' }); + case 'remove': + return await local.queue.removePending(target); + } + })(); + await acquired.promise; + const observe = observePeerTransaction(); + const results = Promise.allSettled([first, remote.queue.steer(target)]); + try { + await observe.started; + } finally { + release.resolve(); + observe.restore(); + } + const settled = await results; + expect(settled[0].status).toBe('fulfilled'); + expect(settled[1].status).toBe(operation === 'edit' ? 'fulfilled' : 'rejected'); + if (operation === 'edit') + expect( + (await remote.messageRepository.findOneByOrFail({ id: c.messageId })).content, + ).toEqual(buildInboundUserMessage('edited', [])[0]); + if (operation === 'failure') { + expect(settled[1]).toMatchObject({ + reason: { message: 'This message cannot be added to that execution' }, + }); + await local.queue.settle(threadId, executionId); + } else { + await finish(local, active); + } + }, + ); + + it.each(['preview', 'integration'] as const)( + 'accepts one %s delivery across connections and rolls back the losing transaction', + async (kind) => { + const local = recordingServices(); + const remote = recordingServices(undefined, peer); + const threadId = uuid(); + const original = input(threadId, 'first', 'new', uuid()); + if (kind === 'integration') { + original.source = 'slack'; + original.payload = { + kind, + message: 'first', + modelMessage: '[Ada]: first', + resourceId: uuid(), + author: { id: 'ada', name: 'Ada' }, + sender: { userId: 'ada', userName: 'ada', fullName: 'Ada', isBot: false, isMe: false }, + credentialId: 'credential', + platformThreadId: 'native-thread', + messageContext: { + platform: 'slack', + integrationConnectionId: 'slack:credential', + messageId: 'native-message', + target: { type: 'thread', threadId: 'native-thread' }, + updatedAt: new Date().toISOString(), + }, + contextConversation: { threadId, resourceId: 'ada' }, + }; + } + const retry = { + ...original, + threadId: kind === 'integration' ? uuid() : threadId, + payload: { ...original.payload, message: 'changed retry', resourceId: uuid() }, + }; + const inserted = createDeferredPromise(); + const release = createDeferredPromise(); + const insert = local.queueRepository.enqueue.bind(local.queueRepository); + vi.spyOn(local.queueRepository, 'enqueue').mockImplementationOnce(async (...args) => { + const item = await insert(...args); + inserted.resolve(args[3]); + await release.promise; + return item; + }); + const registered = vi.fn(); + const available = vi.fn(); + local.queue.onAvailable = available; + remote.queue.onAvailable = available; + const competing = observePeerTransaction(); + const first = local.queue.enqueue(original, registered); + const ctx = await inserted.promise; + const second = remote.queue.enqueue(retry, registered); + try { + await competing.started; + await waitForPeerLock(ctx); + expect(available).not.toHaveBeenCalled(); + release.resolve(); + const result = await first; + if (result.status !== 'accepted') throw new Error('Expected queue acceptance'); + expect(await second).toEqual({ status: 'duplicate' }); + expect(await local.queueRepository.count()).toBe(1); + expect(await local.messageRepository.countBy({ threadId })).toBe(1); + expect(await threadRepo.countBy({ agentId })).toBe(1); + expect( + await local.messageRepository.findOneByOrFail({ id: result.item.messageId }), + ).toMatchObject({ threadId, content: buildInboundUserMessage('first', [])[0] }); + expect( + await repository.manager.findOneBy(AgentResourceEntity, { + id: retry.payload.resourceId, + }), + ).toBeNull(); + if (kind === 'integration') { + expect(await threadRepo.findOneBy({ id: retry.threadId })).toBeNull(); + expect( + await Container.get(AgentThreadRepository).findOneBy({ id: retry.threadId }), + ).toBeNull(); + } + expect(registered).toHaveBeenCalledExactlyOnceWith(result.item.id); + expect(available).toHaveBeenCalledExactlyOnceWith(threadId); + expect(remote.queueUpdates.notifyQueueUpdated).not.toHaveBeenCalled(); + } finally { + release.resolve(); + await Promise.allSettled([first, second]); + competing.restore(); + } + }, + ); + + it('accepts identical text with separate IDs and preserves requests without an ID', async () => { + const services = recordingServices(); + const threadId = uuid(); + for (const id of [uuid(), uuid(), undefined, undefined]) { + await enqueue(services, input(threadId, 'same text', 'new', id)); + } + expect(await services.messageRepository.countBy({ threadId })).toBe(4); + expect(await services.queueRepository.countBy({ threadId })).toBe(4); + }); + + it.each(['success', 'error', 'cancelled'] as const)( + 'retains acceptance through pending, running, and %s settlement', + async (outcome) => { + const services = recordingServices(); + const remote = recordingServices(undefined, peer); + const threadId = uuid(); + const data = input(threadId, 'original', 'new', uuid()); + const accepted = await enqueue(services, data); + const original = await services.messageRepository.findOneByOrFail({ + id: accepted.messageId, + }); + expect(await remote.queue.enqueue(data)).toEqual({ status: 'duplicate' }); + const active = await claim(services, threadId); + expect(await remote.queue.enqueue(data)).toEqual({ status: 'duplicate' }); + if (outcome === 'error') { + await services.queue.recordFailure(active, new Error('Model unavailable')); + await services.queue.settle(threadId, active.admission.executionId); + } else { + await finish(services, active, outcome === 'cancelled' ? 'cancelled' : 'stop'); + } + expect(await remote.queue.enqueue(data)).toEqual({ status: 'duplicate' }); + expect(await services.queueRepository.countBy({ threadId })).toBe(0); + expect(await repository.findByThreadIdOrdered(threadId)).toMatchObject([ + { id: active.admission.executionId, status: outcome }, + ]); + expect( + await services.messageRepository.findOneByOrFail({ id: accepted.messageId }), + ).toEqual(original); + }, + ); + + it('lists only pending Preview input and removes its attachments without affecting the active run', async () => { + const services = recordingServices(Container.get(N8nMemory).getImplementation(agentId)); + const threadId = uuid(); + const target = { projectId, agentId, threadId, userId: owner.id }; + await enqueue(services, input(threadId, 'active', 'new')); + const active = await claim(services, threadId); + const pendingInput = input(threadId, 'pending', 'existing', uuid()); pendingInput.payload.attachments = [ { id: 'pending-file', fileName: 'notes.txt', mimeType: 'text/plain', sizeBytes: 5 }, ]; - const pending = await services.queue.enqueue(pendingInput); + const pending = await enqueue(services, pendingInput); + const original = await services.messageRepository.findOneByOrFail({ id: pending.messageId }); expect((await services.queue.listPending(target)).items.map(({ id }) => id)).toEqual([ pending.id, ]); @@ -1176,23 +1801,50 @@ describe('AgentExecutionRepository', () => { ).rejects.toThrow('Session not found'); expect(services.attachmentService.deleteByIds).not.toHaveBeenCalled(); await services.queue.removePending({ ...target, queueId: pending.id }); - expect(await services.queue.listPending(target)).toEqual({ items: [] }); + expect(await services.queue.enqueue(pendingInput)).toEqual({ status: 'duplicate' }); + expect(await services.queue.listPending(target)).toEqual({ + items: [], + steerableExecutionId: active.admission.executionId, + }); expect(services.attachmentService.deleteByIds).toHaveBeenCalledWith(['pending-file']); + expect( + await services.messageRepository.findOneByOrFail({ id: pending.messageId }), + ).toMatchObject({ + id: original.id, + threadId, + resourceId: original.resourceId, + createdAt: original.createdAt, + origin: original.origin, + content: { role: 'user', content: [] }, + author: null, + modelContent: null, + modelContextAt: null, + }); + expect(await services.messageRepository.findRuntimeMessages({ threadId })).toEqual([]); expect((await repository.findOneByOrFail({ id: active.admission.executionId })).status).toBe( 'running', ); await finish(services, active); + await services.executionService.deleteThread(projectId, agentId, threadId, owner.id); + expect(await services.messageRepository.countBy({ threadId })).toBe(0); }); it('edits pending text without changing identity, attachments, or queue position', async () => { const services = recordingServices(); const threadId = uuid(); - const data = input(threadId, 'original', 'new'); + const data = input(threadId, 'original', 'new', uuid()); data.payload.attachments = [ { id: 'file', fileName: 'notes.txt', mimeType: 'text/plain', sizeBytes: 5 }, ]; - const first = await services.queue.enqueue(data); - const second = await services.queue.enqueue(input(threadId, 'second')); + const first = await enqueue(services, data); + await expect( + services.queue.enqueue({ + ...data, + payload: { ...data.payload, kind: 'preview', userId: uuid() }, + }), + ).rejects.toThrow('Session not found'); + const original = await services.messageRepository.findOneByOrFail({ id: first.messageId }); + const second = await enqueue(services, input(threadId, 'second')); const target = { projectId, agentId, @@ -1216,20 +1868,36 @@ describe('AgentExecutionRepository', () => { attachments: data.payload.attachments, }); await services.queue.updatePending(target); + expect(await services.queue.enqueue(data)).toEqual({ status: 'duplicate' }); const stored = await services.queueRepository.findOneByOrFail({ id: first.id }); expect(stored).toMatchObject({ id: first.id, createdAt: first.createdAt, - source: first.source, - payload: { ...first.payload, message: 'updated' }, + payload: { kind: 'preview' }, + messageId: first.messageId, }); + expect(stored.payload).toEqual({ kind: 'preview' }); expect((await services.queue.listPending(target)).items.map(({ id }) => id)).toEqual([ first.id, second.id, ]); expect(services.attachmentService.deleteByIds).not.toHaveBeenCalled(); const claimed = await claim(services, threadId); - expect(claimed.item.payload.message).toBe('updated'); + expect(claimed.payload).toEqual({ + kind: 'preview', + message: 'updated', + resourceId: data.payload.resourceId, + attachments: data.payload.attachments, + }); + expect(claimed.thread.ownerId).toBe(owner.id); + expect(claimed.admission.inputMessageIds).toEqual([first.messageId]); + expect( + await services.messageRepository.findOneByOrFail({ id: first.messageId }), + ).toMatchObject({ + createdAt: original.createdAt, + content: buildInboundUserMessage('updated', data.payload.attachments)[0], + modelContextAt: null, + }); await finish(services, claimed); await expect(services.queue.updatePending(target)).rejects.toThrow( 'Queued message not found', @@ -1247,7 +1915,7 @@ describe('AgentExecutionRepository', () => { const local = recordingServices(); const remote = recordingServices(undefined, peer); const threadId = uuid(); - const item = await local.queue.enqueue(input(threadId, 'first', 'new')); + const item = await enqueue(local, input(threadId, 'first', 'new')); const target = { projectId, agentId, threadId, userId: owner.id, queueId: item.id }; const acquired = createDeferredPromise(); const release = createDeferredPromise(); @@ -1292,7 +1960,7 @@ describe('AgentExecutionRepository', () => { } else if (operation === 'edit') { const claimed = await second; if (!claimed) throw new Error('Expected a claim'); - expect(claimed.item.payload.message).toBe('edited'); + expect(claimed.payload.message).toBe('edited'); await finish(remote, claimed); } else { expect(settled[1]).toEqual({ status: 'fulfilled', value: null }); @@ -1306,7 +1974,7 @@ describe('AgentExecutionRepository', () => { const local = recordingServices(); const remote = recordingServices(undefined, peer); const threadId = uuid(); - const first = await local.queue.enqueue(input(threadId, 'first', 'new')); + const first = await enqueue(local, input(threadId, 'first', 'new')); const inserted = createDeferredPromise(); const release = createDeferredPromise(); const insert = local.queueRepository.enqueue.bind(local.queueRepository); @@ -1320,9 +1988,9 @@ describe('AgentExecutionRepository', () => { }); const competing = observePeerTransaction(); try { - const second = local.queue.enqueue(input(threadId, 'second')); + const second = enqueue(local, input(threadId, 'second')); const ctx = await inserted.promise; - const third = remote.queue.enqueue(input(threadId, 'third')); + const third = enqueue(remote, input(threadId, 'third')); await competing.started; await waitForPeerLock(ctx); release.resolve(); @@ -1346,62 +2014,95 @@ describe('AgentExecutionRepository', () => { expect(claims.filter(({ item }) => item !== null)).toHaveLength(1); expect(winner.item.item.id).toBe(first.id); expect(await repository.countBy({ threadId, status: 'running' })).toBe(1); - expect(await local.messageRepository.countBy({ threadId })).toBe(1); + expect(await local.messageRepository.countBy({ threadId })).toBe(3); await expect( local.executionService.startExecutionRecording(winner.item.recording, new Date()), ).rejects.toBeInstanceOf(AgentTurnAlreadyRunningError); const independentId = uuid(); - await remote.queue.enqueue(input(independentId, 'independent', 'new')); + await enqueue(remote, input(independentId, 'independent', 'new')); const independent = await claim(remote, independentId); await finish(remote, independent); await finish(winner.services, winner.item); for (const message of ['second', 'third']) { const next = await claim(local, threadId); - expect(next.item.payload.message).toBe(message); + expect(next.payload.message).toBe(message); await finish(local, next); } expect(await local.queueRepository.count()).toBe(0); - const later = await local.queue.enqueue(input(threadId, 'later')); + const later = await enqueue(local, input(threadId, 'later')); expect(BigInt(later.id)).toBeGreaterThan(BigInt(independent.item.id)); }); - it('rolls back acceptance and claim without partial session or execution records', async () => { + it.each([new Error('acceptance failed'), new AgentMessageIdConflictError('another-message')])( + 'rolls back failed acceptance and claim: %s', + async (error) => { + const services = recordingServices(); + const threadId = uuid(); + const data = input(threadId, 'first', 'new', uuid()); + const insert = services.queueRepository.enqueue.bind(services.queueRepository); + const failedInsert = vi + .spyOn(services.queueRepository, 'enqueue') + .mockImplementationOnce(async (...args) => { + await insert(...args); + throw error; + }); + await expect(services.queue.enqueue(data)).rejects.toBe(error); + failedInsert.mockRestore(); + expect(await threadRepo.findOneBy({ id: threadId })).toBeNull(); + expect(await services.messageRepository.countBy({ threadId })).toBe(0); + expect(await Container.get(AgentThreadRepository).findOneBy({ id: threadId })).toBeNull(); + expect(await services.queueRepository.count()).toBe(0); + const accepted = await enqueue(services, data); + const failedLink = vi + .spyOn(services.queueRepository, 'linkExecution') + .mockResolvedValueOnce(false); + await expect(claim(services, threadId)).rejects.toBeInstanceOf( + AgentTurnAlreadyRunningError, + ); + failedLink.mockRestore(); + expect(await services.messageRepository.countBy({ threadId })).toBe(1); + expect( + await Container.get(AgentThreadRepository).findOneBy({ id: threadId }), + ).not.toBeNull(); + expect(await repository.manager.count(AgentExecutionMessageLink)).toBe(0); + expect(await repository.countBy({ threadId })).toBe(0); + expect(await services.queueRepository.findDeliveryState(accepted.id)).toMatchObject({ + executionId: null, + messageId: accepted.messageId, + }); + await finish(services, await claim(services, threadId)); + }, + ); + + it('settles accepted Preview input after its session owner is deleted', async () => { const services = recordingServices(); const threadId = uuid(); - const insert = services.queueRepository.enqueue.bind(services.queueRepository); - const failedInsert = vi - .spyOn(services.queueRepository, 'enqueue') - .mockImplementationOnce(async (...args) => { - await insert(...args); - throw new Error('acceptance failed'); - }); - await expect(services.queue.enqueue(input(threadId, 'first', 'new'))).rejects.toThrow( - 'acceptance failed', - ); - failedInsert.mockRestore(); - expect(await threadRepo.findOneBy({ id: threadId })).toBeNull(); - expect(await services.queueRepository.count()).toBe(0); - const accepted = await services.queue.enqueue(input(threadId, 'first', 'new')); - const failedLink = vi - .spyOn(services.queueRepository, 'linkExecution') - .mockResolvedValueOnce(false); - await expect(claim(services, threadId)).rejects.toBeInstanceOf(AgentTurnAlreadyRunningError); - failedLink.mockRestore(); - expect(await services.messageRepository.countBy({ threadId })).toBe(0); - expect(await Container.get(AgentThreadRepository).findOneBy({ id: threadId })).toBeNull(); - expect(await repository.manager.count(AgentExecutionMessageLink)).toBe(0); - expect(await repository.countBy({ threadId })).toBe(0); - expect(await services.queueRepository.findDeliveryState(accepted.id)).toMatchObject({ - executionId: null, + const queued = await enqueue(services, input(threadId, 'pending', 'new')); + await Container.get(UserRepository).delete(owner.id); + + const active = await claim(services, threadId); + expect(active.thread.ownerId).toBeNull(); + await services.queue.recordFailure(active, new Error('Owner deleted')); + await services.queue.settle(threadId, active.admission.executionId); + + expect(await services.queueRepository.countBy({ threadId })).toBe(0); + expect(await repository.findOneByOrFail({ id: active.admission.executionId })).toMatchObject({ + status: 'error', + error: 'Owner deleted', + }); + expect( + await services.messageRepository.findOneByOrFail({ id: queued.messageId }), + ).toMatchObject({ + content: buildInboundUserMessage('pending', [])[0], + modelContextAt: null, }); - await finish(services, await claim(services, threadId)); }); it('enforces active-slot uniqueness and prevents execution deletion from requeuing a message', async () => { const services = recordingServices(); const threadId = uuid(); - await services.queue.enqueue(input(threadId, 'first', 'new')); - const pending = await services.queue.enqueue(input(threadId, 'second')); + await enqueue(services, input(threadId, 'first', 'new')); + const pending = await enqueue(services, input(threadId, 'second')); const active = await claim(services, threadId); await expect( services.queueRepository.update(pending.id, { executionId: active.admission.executionId }), @@ -1417,8 +2118,8 @@ describe('AgentExecutionRepository', () => { const local = recordingServices(); const remote = recordingServices(undefined, peer); const threadId = uuid(); - await local.queue.enqueue(input(threadId, 'interrupted', 'new')); - await local.queue.enqueue(input(threadId, 'next')); + await enqueue(local, input(threadId, 'interrupted', 'new')); + await enqueue(local, input(threadId, 'next')); const active = await claim(remote, threadId); const cutoff = new Date(Date.now() - 120_000); await repository.update(active.admission.executionId, { @@ -1430,7 +2131,7 @@ describe('AgentExecutionRepository', () => { await repository.update(stale.id, { updatedAt: new Date(cutoff.getTime() - 1) }); expect(await local.executionService.finalizeInterruptedExecution(stale, cutoff)).toBe(true); const next = await claim(local, threadId); - expect(next.item.payload.message).toBe('next'); + expect(next.payload.message).toBe('next'); await expect(finish(remote, active)).rejects.toThrow('no longer running'); await finish(local, next); expect( @@ -1452,9 +2153,18 @@ describe('AgentExecutionRepository', () => { const local = recordingServices(); const remote = recordingServices(undefined, peer); const threadId = uuid(); - await local.queue.enqueue(input(threadId, 'approval', 'new')); - const pending = await local.queue.enqueue(input(threadId, 'later')); + const original = input(threadId, 'approval', 'new', uuid()); + await enqueue(local, original); + const pending = await enqueue(local, input(threadId, 'later')); const active = await claim(local, threadId); + await local.queue.steer({ + projectId, + agentId, + threadId, + userId: owner.id, + queueId: pending.id, + executionId: active.admission.executionId, + }); const runId = uuid(); const state: SerializableAgentState = { status: 'suspended', @@ -1467,6 +2177,14 @@ describe('AgentExecutionRepository', () => { }, }; await local.checkpointStorage.save(runId, state, agentId); + expect(await remote.queue.enqueue(original)).toEqual({ status: 'duplicate' }); + expect(await repository.findExecution(active.admission.executionId)).toMatchObject({ + acceptsSteering: false, + }); + expect(await remote.queueRepository.findOneByOrFail({ id: pending.id })).toMatchObject({ + steeringExecutionId: null, + steeringOrder: null, + }); if (predecessor === 'settled') await finish(local, active); if (predecessor === 'interrupted') { const cutoff = new Date(Date.now() - 120_000); @@ -1485,6 +2203,8 @@ describe('AgentExecutionRepository', () => { new Date(), ); const resumedId = admission.executionId; + expect(admission.inputMessageIds).toEqual(active.admission.inputMessageIds); + expect(await remote.queue.enqueue(original)).toEqual({ status: 'duplicate' }); await local.queue.settle(threadId, active.admission.executionId); expect(await local.queueRepository.findDeliveryState(active.item.id)).toMatchObject({ executionId: resumedId, @@ -1516,14 +2236,15 @@ describe('AgentExecutionRepository', () => { executionId: next.admission.executionId, }); await finish(local, next); + expect(await remote.queue.enqueue(original)).toEqual({ status: 'duplicate' }); }, ); it('advances after approval expiry without waiting for pruning', async () => { const services = recordingServices(); const threadId = uuid(); - await services.queue.enqueue(input(threadId, 'approval', 'new')); - const pending = await services.queue.enqueue(input(threadId, 'later')); + await enqueue(services, input(threadId, 'approval', 'new')); + const pending = await enqueue(services, input(threadId, 'later')); const active = await claim(services, threadId); const runId = uuid(); await services.checkpointStorage.save( @@ -1550,11 +2271,11 @@ describe('AgentExecutionRepository', () => { await finish(services, next); }); - it('removes pending input and attachments on session deletion and never recreates it', async () => { - const services = recordingServices(); + it('removes pending input, attachments, and acceptance identity on session deletion', async () => { + const services = recordingServices(Container.get(N8nMemory).getImplementation(agentId)); const threadId = uuid(); const attachment = await attachmentRepo.save(buildAttachment(threadId)); - const accepted = input(threadId, 'pending', 'new'); + const accepted = input(threadId, 'pending', 'new', uuid()); accepted.payload.attachments = [ { id: attachment.id, @@ -1563,25 +2284,28 @@ describe('AgentExecutionRepository', () => { sizeBytes: attachment.fileSizeBytes, }, ]; - await services.queue.enqueue(accepted); + await enqueue(services, accepted); expect( await services.executionService.deleteThread(projectId, agentId, threadId, owner.id), ).toBe(true); expect(await services.queueRepository.countBy({ threadId })).toBe(0); expect(await attachmentRepo.findOneBy({ id: attachment.id })).toBeNull(); + expect(await services.messageRepository.countBy({ threadId })).toBe(0); expect(services.attachmentService.deleteStoredData).toHaveBeenCalledWith( [attachment.binaryDataId], { threadId }, ); expect(await services.queue.claimNext(threadId, async () => true)).toBeNull(); - await expect(services.queue.enqueue(input(threadId, 'late'))).rejects.toThrow(); + await expect(enqueue(services, input(threadId, 'late'))).rejects.toThrow(); expect(await threadRepo.findOneBy({ id: threadId })).toBeNull(); + await enqueue(services, { ...accepted, payload: { ...accepted.payload, attachments: [] } }); + expect(await services.messageRepository.countBy({ threadId })).toBe(1); }); it('cascades agent deletion through pending queue rows', async () => { const services = recordingServices(); const threadId = uuid(); - await services.queue.enqueue(input(threadId, 'pending', 'new')); + await enqueue(services, input(threadId, 'pending', 'new')); await agentRepo.delete(agentId); expect(await services.queueRepository.countBy({ threadId })).toBe(0); }); @@ -1594,9 +2318,8 @@ describe('AgentExecutionRepository', () => { const expected = new Map(); for (let position = 0; position < 2; position++) { const execution = await createExecution({ threadId: thread.id }); - const messageId = await messageRepository.createExecutionInput( + const message = await messageRepository.createInput( { - executionId: execution.id, threadId: thread.id, resourceId: 'user-1', content: { role: 'user', content: [{ type: 'text', text: 'Input' }] }, @@ -1605,7 +2328,13 @@ describe('AgentExecutionRepository', () => { {}, ); executionIds.push(execution.id); - expected.set(execution.id, messageId); + await messageRepository.linkExecutionInput( + execution.id, + message.id, + { threadId: thread.id, resourceId: 'user-1' }, + {}, + ); + expected.set(execution.id, message.id); } executionIds.splice(1, 0, ...Array.from({ length: 33_000 }, () => uuid())); @@ -1617,7 +2346,11 @@ describe('AgentExecutionRepository', () => { } }); - it('keeps canonical input through enrichment, runtime persistence, mixed history, and session deletion', async () => { + it.each([ + ['direct', 'message-42'], + ['queued', 'message-42'], + ['queued', undefined], + ] as const)('preserves %s canonical input (%s)', async (path, platformMessageId) => { const thread = await createThread(); const threadId = thread.id; const resourceId = 'user-1'; @@ -1664,17 +2397,96 @@ describe('AgentExecutionRepository', () => { source: 'slack', messageOrigin: { integrationConnectionId: 'slack:credential', - platformMessageId: 'message-42', + ...(platformMessageId !== undefined ? { platformMessageId } : {}), }, }; - const admission = await services.executionService.startExecutionRecording(params, new Date()); + let admission: AgentExecutionAdmission; + if (path === 'direct') { + admission = await services.executionService.startExecutionRecording(params, new Date()); + } else { + const payload: QueuedIntegrationMessage = { + kind: 'integration', + message: params.userMessage, + resourceId, + attachments, + modelMessage: '[Ada]: Original input', + author: params.author, + credentialId: 'credential', + platformThreadId: 'slack:channel:thread', + sender: { + userId: 'author-1', + userName: 'ada', + fullName: 'Ada', + isBot: false, + isMe: false, + }, + messageContext: { + integrationConnectionId: 'slack:credential', + ...(platformMessageId !== undefined ? { messageId: platformMessageId } : {}), + platform: 'slack', + target: { type: 'thread', threadId: 'slack:channel:thread' }, + replyTarget: { type: 'thread', threadId: 'slack:channel:reply' }, + replyMessageId: 'reply-41', + updatedAt: '2026-06-01T12:00:00.000Z', + }, + contextConversation: { threadId: 'integration-context', resourceId: 'integration-user' }, + forceBuffered: true, + }; + const queued = await enqueue(services, { + agentId, + projectId, + threadId, + sessionMode: 'existing', + source: 'slack', + payload, + }); + for (const field of [ + 'message', + 'resourceId', + 'attachments', + 'author', + 'modelMessage', + 'platformThreadId', + 'messageContext.platform', + 'messageContext.integrationConnectionId', + 'messageContext.messageId', + ]) { + expect(queued.payload).not.toHaveProperty(field); + } + expect((await memory.getMessages(threadId)).map(({ id }) => id)).not.toContain( + queued.messageId, + ); + const origin = { + source: 'slack', + ...params.messageOrigin, + platformThreadId: payload.platformThreadId, + }; + for (const invalidOrigin of [ + null, + { ...origin, source: null }, + { ...origin, integrationConnectionId: undefined }, + { ...origin, platformThreadId: undefined }, + ]) { + await services.messageRepository.update(queued.messageId, { origin: invalidOrigin }); + await expect(services.queue.claimNext(threadId, async () => true)).rejects.toThrow( + 'Queued integration input has incomplete origin', + ); + } + await services.messageRepository.update(queued.messageId, { origin }); + const claimed = await services.queue.claimNext(threadId, async () => true); + expect(claimed?.payload).toStrictEqual(payload); + expect(claimed?.recording.source).toBe('slack'); + admission = claimed!.admission; + expect(admission.inputMessageIds).toEqual([queued.messageId]); + } const [id] = admission.inputMessageIds; const canonical = await services.messageRepository.findOneByOrFail({ id }); expect(canonical).toMatchObject({ content: buildInboundUserMessage(params.userMessage, attachments)[0], author: params.author, modelContextAt: null, - modelContent: null, + modelContent: + path === 'queued' ? buildInboundUserMessage('[Ada]: Original input', attachments)[0] : null, origin: { source: 'slack', ...params.messageOrigin }, }); expect( @@ -1789,7 +2601,7 @@ describe('AgentExecutionRepository', () => { expect(await memory.getMessages('builder-without-session')).toHaveLength(1); }); - it('keeps output membership and positions on repeated saves and hides internal input', async () => { + it('keeps input and output positions on repeated saves and hides internal input', async () => { const threadId = uuid(); const resourceId = 'user-1'; const memory = Container.get(N8nMemory).getImplementation(agentId); @@ -1806,6 +2618,23 @@ describe('AgentExecutionRepository', () => { }; const admission = await executionService.startExecutionRecording(params, new Date()); const [inputId] = admission.inputMessageIds; + const additional = await messageRepository.createInput( + { + threadId, + resourceId, + content: { role: 'user', content: [{ type: 'text', text: 'Additional internal result' }] }, + origin: { source: null, hidden: true }, + }, + {}, + ); + for (const id of [additional.id, inputId, additional.id]) { + await messageRepository.linkExecutionInput( + admission.executionId, + id, + { threadId, resourceId }, + {}, + ); + } const input = { ...buildInboundUserMessage(params.userMessage, [])[0], id: inputId, @@ -1830,7 +2659,16 @@ describe('AgentExecutionRepository', () => { hostMetadata: { [EXECUTION_METADATA_KEY]: admission.executionId }, }; await memory.saveMessages({ ...scope, messages: [input, output] }); - await memory.saveMessages({ ...scope, messages: [later, latest, output, input] }); + await memory.saveMessages({ + ...scope, + messages: [ + later, + latest, + output, + input, + { ...additional.content, id: additional.id, createdAt: additional.createdAt }, + ], + }); expect((await messageRepository.findOneByOrFail({ id: inputId })).modelContent).toBeNull(); const links = await repository.manager.find(AgentExecutionMessageLink, { where: { executionId: admission.executionId }, @@ -1840,13 +2678,14 @@ describe('AgentExecutionRepository', () => { links.map(({ messageId, direction, position }) => ({ messageId, direction, position })), ).toEqual([ { messageId: inputId, direction: 'input', position: 0 }, + { messageId: additional.id, direction: 'input', position: 1 }, { messageId: output.id, direction: 'output', position: 0 }, { messageId: later.id, direction: 'output', position: 1 }, { messageId: latest.id, direction: 'output', position: 2 }, ]); const detail = await executionService.getThreadDetail(threadId, projectId, agentId, viewerId); expect(detail?.executions[0]).toMatchObject({ - inputMessageIds: [inputId], + inputMessageIds: [inputId, additional.id], inputMessages: [], userMessage: null, }); @@ -1977,6 +2816,56 @@ describe('AgentExecutionRepository', () => { return await repository.save(execution); }; + it('paginates sessions with whole-second and millisecond timestamps', async () => { + const newest = await createThread(); + const middle = await createThread(); + const oldest = await createThread(); + for (const [id, timestamp] of [ + [newest.id, '2026-01-03 00:00:01'], + [middle.id, '2026-01-03 00:00:00.500'], + [oldest.id, '2026-01-03 00:00:00'], + ]) { + await threadRepo + .createQueryBuilder() + .update() + .set({ updatedAt: () => `'${timestamp}'` }) + .where('id = :id', { id }) + .execute(); + } + + const first = await threadRepo.findByProjectIdPaginated(projectId, agentId, viewerId, 1); + const second = await threadRepo.findByProjectIdPaginated( + projectId, + agentId, + viewerId, + 1, + first.nextCursor ?? undefined, + ); + const third = await threadRepo.findByProjectIdPaginated( + projectId, + agentId, + viewerId, + 1, + second.nextCursor ?? undefined, + ); + const after = await threadRepo.findByProjectIdPaginated( + projectId, + agentId, + viewerId, + 10, + undefined, + { + updatedAfter: new Date('2026-01-03T00:00:00Z'), + }, + ); + + expect(first.threads.map(({ id }) => id)).toEqual([newest.id]); + expect(second.threads.map(({ id }) => id)).toEqual([middle.id]); + expect(third.threads.map(({ id }) => id)).toEqual([oldest.id]); + expect(third.nextCursor).toBeNull(); + expect(after.threads.map(({ id }) => id)).toEqual([newest.id, middle.id, oldest.id]); + }); + it('filters private sessions before pagination and preserves their owner on reuse', async () => { const owner = await createMember(); const other = await createAdmin(); @@ -2395,7 +3284,7 @@ describe('AgentExecutionRepository', () => { }); describe('session filters', () => { - it('filters all composite statuses using the latest execution and recovered failures', async () => { + it('filters session status from the latest execution', async () => { const running = await createThread({ sessionNumber: 1 }); const succeeded = await createThread({ sessionNumber: 2 }); const recovered = await createThread({ sessionNumber: 3 }); @@ -2440,8 +3329,8 @@ describe('AgentExecutionRepository', () => { ).threads.map(({ id }) => id); expect(await idsFor('running')).toEqual([running.id]); - expect(await idsFor('succeeded')).toEqual([succeeded.id]); - expect(new Set(await idsFor('error'))).toEqual(new Set([recovered.id, errored.id])); + expect(new Set(await idsFor('succeeded'))).toEqual(new Set([succeeded.id, recovered.id])); + expect(await idsFor('error')).toEqual([errored.id]); const latestStatuses = await repository.findLatestStatusesByThreadIds([running.id]); expect(latestStatuses.get(running.id)).toBe('running'); @@ -2469,6 +3358,11 @@ describe('AgentExecutionRepository', () => { { sessionNumber: 5, source: null, expected: 'preview' }, { sessionNumber: 6, source: 'chat', expected: 'preview' }, { sessionNumber: 7, source: 'slack', laterSource: 'workflow', expected: 'slack' }, + { + sessionNumber: 8, + source: N8N_CHAT_PRODUCTION_SOURCE, + expected: N8N_CHAT_PRODUCTION_SOURCE, + }, ]; const expectedIds = new Map(); @@ -2506,6 +3400,33 @@ describe('AgentExecutionRepository', () => { } }); + it('keeps only the sessions owned by the requesting user with scope=mine', async () => { + const owner = await createMember(); + const other = await createMember(); + const mine = await createThread({ sessionNumber: 1, ownerId: owner.id }); + const theirs = await createThread({ sessionNumber: 2, ownerId: other.id }); + const unowned = await createThread({ sessionNumber: 3 }); + for (const thread of [mine, theirs, unowned]) { + await createExecution({ threadId: thread.id, source: N8N_CHAT_PRODUCTION_SOURCE }); + } + const list = async (filters: AgentSessionQueryFilters) => + ( + await threadRepo.findByProjectIdPaginated( + projectId, + agentId, + owner.id, + 20, + undefined, + filters, + ) + ).threads.map(({ id }) => id); + + expect(await list({ origin: N8N_CHAT_PRODUCTION_SOURCE, scope: 'mine' })).toEqual([mine.id]); + expect(new Set(await list({ origin: N8N_CHAT_PRODUCTION_SOURCE }))).toEqual( + new Set([mine.id, theirs.id, unowned.id]), + ); + }); + it('applies inclusive date and status filters before cursor pagination', async () => { const start = new Date('2026-01-01T00:00:00Z'); const middle = new Date('2026-01-02T00:00:00Z'); diff --git a/packages/cli/test/integration/database/repositories/execution.repository.test.ts b/packages/cli/test/integration/database/repositories/execution.repository.test.ts index 58879b1bec3..22cf7d7c149 100644 --- a/packages/cli/test/integration/database/repositories/execution.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/execution.repository.test.ts @@ -6,6 +6,8 @@ import { stringify } from 'flatted'; import type { ExecutionStatus, IRunExecutionData, IRunExecutionDataAll } from 'n8n-workflow'; import { WAIT_FOR_SUB_EXECUTION, WAIT_INDEFINITELY } from 'n8n-workflow'; +import type { TracingContext } from '@/modules/otel/tracing-context'; + describe('ExecutionRepository', () => { beforeAll(async () => { await testDb.init(); @@ -192,7 +194,7 @@ describe('ExecutionRepository', () => { status: ExecutionStatus, extra: { waitTill?: Date; - tracingContext?: { traceparent: string }; + tracingContext?: TracingContext; workflowVersionId?: string; retryOf?: string; } = {}, diff --git a/packages/cli/test/integration/database/services/db-lock.service.test.ts b/packages/cli/test/integration/database/services/db-lock.service.test.ts index fd17ad93f29..ab0a43ef7b6 100644 --- a/packages/cli/test/integration/database/services/db-lock.service.test.ts +++ b/packages/cli/test/integration/database/services/db-lock.service.test.ts @@ -88,11 +88,14 @@ describe('DbLockService', () => { const settingsKey = `test.atomic-settings.${randomUUID()}`; // The credential writes are sealed; with no check registered, enforcement clears. - const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave({ - credential: { id: null, type: 'openAiApi' }, - storedCredential: null, - projectId: null, - }); + const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave( + { + credential: { id: null, type: 'openAiApi' }, + storedCredential: null, + projectId: null, + }, + { kind: 'system', reason: 'integration' }, + ); await expect( dbLockService.withLockContext(DbLock.TEST, async (ctx) => { @@ -130,11 +133,14 @@ describe('DbLockService', () => { }), ); const settingsKey = `test.atomic-settings.${randomUUID()}`; - const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave({ - credential: { id: credential.id, type: credential.type }, - storedCredential: { id: credential.id, type: credential.type }, - projectId: null, - }); + const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave( + { + credential: { id: credential.id, type: credential.type }, + storedCredential: { id: credential.id, type: credential.type }, + projectId: null, + }, + { kind: 'system', reason: 'integration' }, + ); await expect( dbLockService.withLockContext(DbLock.TEST, async (ctx) => { diff --git a/packages/cli/test/integration/dynamic-credentials.ee/chat-trigger-test-run.api.test.ts b/packages/cli/test/integration/dynamic-credentials.ee/chat-trigger-test-run.api.test.ts index ea9a8e291fb..b11393b51c9 100644 --- a/packages/cli/test/integration/dynamic-credentials.ee/chat-trigger-test-run.api.test.ts +++ b/packages/cli/test/integration/dynamic-credentials.ee/chat-trigger-test-run.api.test.ts @@ -39,7 +39,7 @@ import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { SYSTEM_RESOLVER_ID } from '@/modules/dynamic-credentials.ee/constants'; import { DynamicCredentialUserEntryStorage } from '@/modules/dynamic-credentials.ee/credential-resolvers/storage/dynamic-credential-user-entry-storage'; import { N8nResolverSeeder } from '@/modules/dynamic-credentials.ee/services/n8n-resolver-seeder.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { Telemetry } from '@/telemetry'; import { WebhookServer } from '@/webhooks/webhook-server'; diff --git a/packages/cli/test/integration/dynamic-credentials.ee/form-trigger-submit-gate.api.test.ts b/packages/cli/test/integration/dynamic-credentials.ee/form-trigger-submit-gate.api.test.ts index 6abb62c49fe..23a270f8f31 100644 --- a/packages/cli/test/integration/dynamic-credentials.ee/form-trigger-submit-gate.api.test.ts +++ b/packages/cli/test/integration/dynamic-credentials.ee/form-trigger-submit-gate.api.test.ts @@ -24,7 +24,7 @@ import { DynamicCredentialUserEntryStorage } from '@/modules/dynamic-credentials import { N8nResolverSeeder } from '@/modules/dynamic-credentials.ee/services/n8n-resolver-seeder.service'; import { OAuthClientRepository } from '@/modules/oauth-server/database/repositories/oauth-client.repository'; import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { UrlService } from '@n8n/backend-services'; import { WebhookServer } from '@/webhooks/webhook-server'; diff --git a/packages/cli/test/integration/dynamic-credentials.ee/my-connection.api.test.ts b/packages/cli/test/integration/dynamic-credentials.ee/my-connection.api.test.ts index 6e1e41c2f20..a7b79d70102 100644 --- a/packages/cli/test/integration/dynamic-credentials.ee/my-connection.api.test.ts +++ b/packages/cli/test/integration/dynamic-credentials.ee/my-connection.api.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, linkUserToProject, @@ -8,7 +9,6 @@ import { import type { Project, User } from '@n8n/db'; import { Container } from '@n8n/di'; -import { EventService } from '@/events/event.service'; import { SYSTEM_RESOLVER_ID, SYSTEM_RESOLVER_NAME, diff --git a/packages/cli/test/integration/engine-v2-webhooks.test.ts b/packages/cli/test/integration/engine-v2-webhooks.test.ts index cb1b3db725f..cebf8e23fd4 100644 --- a/packages/cli/test/integration/engine-v2-webhooks.test.ts +++ b/packages/cli/test/integration/engine-v2-webhooks.test.ts @@ -17,7 +17,7 @@ import { WEBHOOK_NODE_TYPE } from 'n8n-workflow'; import { randomUUID } from 'node:crypto'; import { agent as testAgent } from 'supertest'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { InMemoryExecutionResponseChannel } from '@/modules/engine-v2/response-channel/in-memory-execution-response-channel'; import { InMemoryExecutionResponseReceiver } from '@/modules/engine-v2/response-channel/in-memory-execution-response-receiver'; import { EngineDataPlaneProxyService } from '@/services/engine-data-plane-proxy.service'; diff --git a/packages/cli/test/integration/environments/source-control-access-control.test.ts b/packages/cli/test/integration/environments/source-control-access-control.test.ts index 6860b04eb4d..b87d8f8bfc3 100644 --- a/packages/cli/test/integration/environments/source-control-access-control.test.ts +++ b/packages/cli/test/integration/environments/source-control-access-control.test.ts @@ -5,7 +5,7 @@ import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, type User } from '@n8n/db'; import { Container } from '@n8n/di'; import { hasGlobalScope } from '@n8n/permissions'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ForbiddenError } from '@n8n/errors'; import { SourceControlPreferencesService } from '@/modules/source-control.ee/source-control-preferences.service.ee'; import { SourceControlService } from '@/modules/source-control.ee/source-control.service.ee'; import { Telemetry } from '@/telemetry'; diff --git a/packages/cli/test/integration/environments/source-control-import.service.test.ts b/packages/cli/test/integration/environments/source-control-import.service.test.ts index c502afcfb35..e205514566e 100644 --- a/packages/cli/test/integration/environments/source-control-import.service.test.ts +++ b/packages/cli/test/integration/environments/source-control-import.service.test.ts @@ -112,8 +112,8 @@ describe('SourceControlImportService', () => { // The repository verifies the token, so it has to be a real one. With no backend // registered the real service clears everything, which is what a default pull does. mockPolicyEnforcementService.enforceContentImport.mockImplementation( - async (context) => - await Container.get(PolicyEnforcementService).enforceContentImport(context), + async (context, actor) => + await Container.get(PolicyEnforcementService).enforceContentImport(context, actor), ); service = new SourceControlImportService( mock(), @@ -2097,11 +2097,14 @@ describe('SourceControlImportService', () => { ); expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledTimes(1); - expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith({ - workflow: { id: workflow.id, name: workflow.name, nodes: workflow.nodes }, - projectId: importingUserProject.id, - transport: 'source-control', - }); + expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith( + { + workflow: { id: workflow.id, name: workflow.name, nodes: workflow.nodes }, + projectId: importingUserProject.id, + transport: 'source-control', + }, + { kind: 'user', user: { id: importingUser.id } }, + ); }); it('skips a blocked workflow, attaches the reason, and persists nothing', async () => { diff --git a/packages/cli/test/integration/environments/source-control.service.test.ts b/packages/cli/test/integration/environments/source-control.service.test.ts index e5706e10e5f..606c68395ba 100644 --- a/packages/cli/test/integration/environments/source-control.service.test.ts +++ b/packages/cli/test/integration/environments/source-control.service.test.ts @@ -1,4 +1,5 @@ import type { SourceControlledFile } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import { createTeamProject, createWorkflow, testDb, testModules } from '@n8n/backend-test-utils'; import { CredentialsEntity, @@ -20,9 +21,7 @@ import { basename, isAbsolute } from 'node:path'; import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { DataTable } from '@/modules/data-table/data-table.entity'; import { SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, diff --git a/packages/cli/test/integration/eventbus.ee.test.ts b/packages/cli/test/integration/eventbus.ee.test.ts index 70f250ed0e7..b28de4b6831 100644 --- a/packages/cli/test/integration/eventbus.ee.test.ts +++ b/packages/cli/test/integration/eventbus.ee.test.ts @@ -336,16 +336,10 @@ test('should send message to sentry ', async () => { sentryDestination.enabled = true; const mockedSentryCaptureMessage = vi.spyOn(sentryDestination.sentryClient!, 'captureMessage'); - mockedSentryCaptureMessage.mockImplementation((_m, _level, _hint, _scope) => { - eventBus.confirmMessageDelivered(testMessage, { - id: sentryDestination.id, - name: sentryDestination.label, - }); - return testMessage.id; - }); + mockedSentryCaptureMessage.mockReturnValue(testMessage.id); await eventBus.send(testMessage); - await onFirstWorkerMessage('confirmMessageSent', async () => { + await vi.waitFor(async () => { await confirmIdInAll(testMessage.id); await confirmIdSent(testMessage.id); expect(mockedSentryCaptureMessage).toHaveBeenCalled(); diff --git a/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts b/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts index 313275be522..6e419df0890 100644 --- a/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts +++ b/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts @@ -1,4 +1,5 @@ import { LicenseState } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { mockInstance, mockLogger } from '@n8n/backend-test-utils'; import { SecretsProviderConnectionRepository } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -6,7 +7,6 @@ import { Cipher } from 'n8n-core'; import { CREDENTIAL_BLANKING_VALUE, type IDataObject } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; import { ExternalSecretsManager } from '@/modules/external-secrets.ee/external-secrets-manager.ee'; import { ExternalSecretsProviders } from '@/modules/external-secrets.ee/external-secrets-providers.ee'; import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config'; diff --git a/packages/cli/test/integration/import.service.test.ts b/packages/cli/test/integration/import.service.test.ts index 31b30797673..f488aa3f057 100644 --- a/packages/cli/test/integration/import.service.test.ts +++ b/packages/cli/test/integration/import.service.test.ts @@ -68,8 +68,8 @@ describe('ImportService', () => { // The repository verifies the token, so it has to be a real one. With no backend // registered the real service clears everything, which is what a default import does. mockPolicyEnforcementService.enforceContentImport.mockImplementation( - async (context) => - await Container.get(PolicyEnforcementService).enforceContentImport(context), + async (context, actor) => + await Container.get(PolicyEnforcementService).enforceContentImport(context, actor), ); importService = new ImportService( @@ -495,7 +495,10 @@ describe('ImportService', () => { describe('content-import policy', () => { const clearance = async (context: ContentImportContext) => - await Container.get(PolicyEnforcementService).enforceContentImport(context); + await Container.get(PolicyEnforcementService).enforceContentImport(context, { + kind: 'system', + reason: 'cli-import', + }); beforeEach(() => { mockPolicyEnforcementService.enforceContentImport.mockClear(); @@ -510,16 +513,22 @@ describe('ImportService', () => { await importService.importWorkflows([first, second], ownerPersonalProject.id, owner.id, {}); expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledTimes(2); - expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith({ - workflow: { id: first.id, name: first.name, nodes: first.nodes }, - projectId: ownerPersonalProject.id, - transport: 'cli', - }); - expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith({ - workflow: { id: second.id, name: second.name, nodes: second.nodes }, - projectId: ownerPersonalProject.id, - transport: 'cli', - }); + expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith( + { + workflow: { id: first.id, name: first.name, nodes: first.nodes }, + projectId: ownerPersonalProject.id, + transport: 'cli', + }, + { kind: 'system', reason: 'cli-import' }, + ); + expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith( + { + workflow: { id: second.id, name: second.name, nodes: second.nodes }, + projectId: ownerPersonalProject.id, + transport: 'cli', + }, + { kind: 'system', reason: 'cli-import' }, + ); }); test('skips a blocked workflow, reports it, and still imports the rest of the batch', async () => { @@ -591,15 +600,18 @@ describe('ImportService', () => { {}, ); - expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith({ - workflow: { - id: workflowToReimport.id, - name: workflowToReimport.name, - nodes: workflowToReimport.nodes, + expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith( + { + workflow: { + id: workflowToReimport.id, + name: workflowToReimport.name, + nodes: workflowToReimport.nodes, + }, + projectId: memberPersonalProject.id, + transport: 'cli', }, - projectId: memberPersonalProject.id, - transport: 'cli', - }); + { kind: 'system', reason: 'cli-import' }, + ); }); // A check that cannot answer is an infrastructure fault, not a property of one workflow. diff --git a/packages/cli/test/integration/instance-ai-settings.service.test.ts b/packages/cli/test/integration/instance-ai-settings.service.test.ts index 29a92c4bd7f..6e54b0ba70c 100644 --- a/packages/cli/test/integration/instance-ai-settings.service.test.ts +++ b/packages/cli/test/integration/instance-ai-settings.service.test.ts @@ -173,11 +173,14 @@ describe('InstanceAiSettingsService (integration)', () => { ); // A real clearance, so the row is protected by its usage scope alone, not by the seal. - const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave({ - credential: { id: workflowCredential.id, type: workflowCredential.type }, - storedCredential: { id: workflowCredential.id, type: workflowCredential.type }, - projectId: null, - }); + const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave( + { + credential: { id: workflowCredential.id, type: workflowCredential.type }, + storedCredential: { id: workflowCredential.id, type: workflowCredential.type }, + projectId: null, + }, + { kind: 'system', reason: 'integration' }, + ); const updated = await credentialsRepository.updateInstanceCredential( workflowCredential.id, diff --git a/packages/cli/test/integration/mfa/mfa.api.test.ts b/packages/cli/test/integration/mfa/mfa.api.test.ts index 1fdcf0b5197..97f2743dd28 100644 --- a/packages/cli/test/integration/mfa/mfa.api.test.ts +++ b/packages/cli/test/integration/mfa/mfa.api.test.ts @@ -7,12 +7,12 @@ import { randomString } from 'n8n-workflow'; import { AuthService } from '@/auth/auth.service'; import config from '@/config'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { ExternalHooks } from '@/external-hooks'; import { MFA_ENFORCE_SETTING } from '@/mfa/constants'; import { MFA_CACHE_KEY } from '@/mfa/mfa.service'; import { TOTPService } from '@/mfa/totp.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { createOwner, createUser, createUserWithMfaEnabled } from '../shared/db/users'; import * as utils from '../shared/utils'; diff --git a/packages/cli/test/integration/middlewares/body-parser.test.ts b/packages/cli/test/integration/middlewares/body-parser.test.ts index de3bcdb80e5..bf2d9467dcb 100644 --- a/packages/cli/test/integration/middlewares/body-parser.test.ts +++ b/packages/cli/test/integration/middlewares/body-parser.test.ts @@ -4,8 +4,7 @@ import { createServer } from 'http'; import request from 'supertest'; import { gzipSync, deflateSync } from 'zlib'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { BadRequestError, UnprocessableRequestError } from '@n8n/errors'; import { rawBodyReader, bodyParser, parseBody } from '@/middlewares/body-parser'; describe('bodyParser', () => { diff --git a/packages/cli/test/integration/oidc/oidc.service.ee.test.ts b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts index 68618045f0e..447ef1ba300 100644 --- a/packages/cli/test/integration/oidc/oidc.service.ee.test.ts +++ b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts @@ -23,8 +23,7 @@ beforeAll(async () => { real_odic_client = await vi.importActual('openid-client'); }); -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { License } from '@/license'; import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; import { OIDC_CLIENT_SECRET_REDACTED_VALUE } from '@/modules/sso-oidc/constants'; diff --git a/packages/cli/test/integration/policy/credential-type-policy-instance.controller.test.ts b/packages/cli/test/integration/policy/credential-type-policy-instance.controller.test.ts index 199346fe9c7..93140296f9a 100644 --- a/packages/cli/test/integration/policy/credential-type-policy-instance.controller.test.ts +++ b/packages/cli/test/integration/policy/credential-type-policy-instance.controller.test.ts @@ -1,9 +1,9 @@ +import { EventService } from '@n8n/backend-services'; import { testDb } from '@n8n/backend-test-utils'; import { LICENSE_FEATURES } from '@n8n/constants'; import type { User } from '@n8n/db'; import { Container } from '@n8n/di'; -import { EventService } from '@/events/event.service'; import { TypeAvailabilityPolicyRepository } from '@/modules/type-availability-policies/database/repositories/type-availability-policy.repository'; import { createMember, createOwner } from '@test-integration/db/users'; import * as utils from '@test-integration/utils'; diff --git a/packages/cli/test/integration/policy/credential-type-policy-project.controller.test.ts b/packages/cli/test/integration/policy/credential-type-policy-project.controller.test.ts index d334537ec7b..c3aa78f5cb7 100644 --- a/packages/cli/test/integration/policy/credential-type-policy-project.controller.test.ts +++ b/packages/cli/test/integration/policy/credential-type-policy-project.controller.test.ts @@ -1,9 +1,9 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, linkUserToProject, testDb } from '@n8n/backend-test-utils'; import { LICENSE_FEATURES } from '@n8n/constants'; import type { Project, User } from '@n8n/db'; import { Container } from '@n8n/di'; -import { EventService } from '@/events/event.service'; import { CREDENTIAL_TYPES_KIND } from '@/modules/type-availability-policies/constants'; import { TypeAvailabilityPolicyService } from '@/modules/type-availability-policies/type-availability-policy.service'; import { createMember, createOwner } from '@test-integration/db/users'; diff --git a/packages/cli/test/integration/policy/node-type-policy.fan-out.test.ts b/packages/cli/test/integration/policy/node-type-policy.fan-out.test.ts index 291972bd618..f9124982503 100644 --- a/packages/cli/test/integration/policy/node-type-policy.fan-out.test.ts +++ b/packages/cli/test/integration/policy/node-type-policy.fan-out.test.ts @@ -1,9 +1,9 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; import { TransactionRunner, type OperationContext } from '@n8n/db'; import { Container } from '@n8n/di'; -import { EventService } from '@/events/event.service'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { NodeTypes } from '@/node-types'; import { TypeAvailabilityPolicyAttachmentRepository } from '@/modules/type-availability-policies/database/repositories/type-availability-policy-attachment.repository'; @@ -11,7 +11,7 @@ import { TypeAvailabilityPolicyScopeRepository } from '@/modules/type-availabili import { TypeAvailabilityPolicyRepository } from '@/modules/type-availability-policies/database/repositories/type-availability-policy.repository'; import type { PolicyRule } from '@/modules/type-availability-policies/policy-rule.types'; import { TypeAvailabilityPolicyService } from '@/modules/type-availability-policies/type-availability-policy.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { clearPolicyCache } from './shared/policy-cache'; diff --git a/packages/cli/test/integration/policy/node-type-policy.store-reads.test.ts b/packages/cli/test/integration/policy/node-type-policy.store-reads.test.ts index a208695133e..cc26e7b7bfa 100644 --- a/packages/cli/test/integration/policy/node-type-policy.store-reads.test.ts +++ b/packages/cli/test/integration/policy/node-type-policy.store-reads.test.ts @@ -7,7 +7,7 @@ import { TypeAvailabilityPolicyScopeRepository } from '@/modules/type-availabili import { TypeAvailabilityPolicyRepository } from '@/modules/type-availability-policies/database/repositories/type-availability-policy.repository'; import type { PolicyRule } from '@/modules/type-availability-policies/policy-rule.types'; import { TypeAvailabilityPolicyService } from '@/modules/type-availability-policies/type-availability-policy.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { clearPolicyCache } from './shared/policy-cache'; diff --git a/packages/cli/test/integration/policy/shared/policy-cache.ts b/packages/cli/test/integration/policy/shared/policy-cache.ts index c0f759f6bbd..961a0e0aebb 100644 --- a/packages/cli/test/integration/policy/shared/policy-cache.ts +++ b/packages/cli/test/integration/policy/shared/policy-cache.ts @@ -1,7 +1,7 @@ import { Container } from '@n8n/di'; import { TypeAvailabilityPolicyService } from '@/modules/type-availability-policies/type-availability-policy.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; /** * Drops the policy read-through cache, both layers: the shared `CacheService` entries and the diff --git a/packages/cli/test/integration/policy/type-availability-policy-instance.controller.test.ts b/packages/cli/test/integration/policy/type-availability-policy-instance.controller.test.ts index 8751968769d..7a1c1a2d3d1 100644 --- a/packages/cli/test/integration/policy/type-availability-policy-instance.controller.test.ts +++ b/packages/cli/test/integration/policy/type-availability-policy-instance.controller.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { testDb } from '@n8n/backend-test-utils'; import { LICENSE_FEATURES } from '@n8n/constants'; import type { User } from '@n8n/db'; @@ -5,7 +6,6 @@ import { Container } from '@n8n/di'; import type { NodeLoader } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; -import { EventService } from '@/events/event.service'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { TypeAvailabilityPolicyScopeRepository } from '@/modules/type-availability-policies/database/repositories/type-availability-policy-scope.repository'; import { TypeAvailabilityPolicyRepository } from '@/modules/type-availability-policies/database/repositories/type-availability-policy.repository'; diff --git a/packages/cli/test/integration/policy/type-availability-policy-project.controller.test.ts b/packages/cli/test/integration/policy/type-availability-policy-project.controller.test.ts index 84d083421e9..9701633b43c 100644 --- a/packages/cli/test/integration/policy/type-availability-policy-project.controller.test.ts +++ b/packages/cli/test/integration/policy/type-availability-policy-project.controller.test.ts @@ -1,9 +1,9 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, linkUserToProject, testDb } from '@n8n/backend-test-utils'; import { LICENSE_FEATURES } from '@n8n/constants'; import type { Project, User } from '@n8n/db'; import { Container } from '@n8n/di'; -import { EventService } from '@/events/event.service'; import { createMember, createOwner } from '@test-integration/db/users'; import * as utils from '@test-integration/utils'; diff --git a/packages/cli/test/integration/project-relations.benchmark.test.ts b/packages/cli/test/integration/project-relations.benchmark.test.ts new file mode 100644 index 00000000000..c67703dc49f --- /dev/null +++ b/packages/cli/test/integration/project-relations.benchmark.test.ts @@ -0,0 +1,415 @@ +import { createWorkflow, mockInstance, testDb } from '@n8n/backend-test-utils'; +import type { User } from '@n8n/db'; +import { Project, ProjectRelation } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; +import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; +import { generateNanoId } from '@n8n/utils/generate-nano-id'; +import { appendFileSync, mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; + +import { ActiveWorkflowManager } from '@/active-workflow-manager'; +import { CredentialsService } from '@/credentials/credentials.service'; +import { NodeTypes } from '@/node-types'; +import { ProjectService } from '@/services/project.service.ee'; +import { WorkflowService } from '@/workflows/workflow.service'; + +import { createAdmin, createMember } from './shared/db/users'; + +/** + * Query-level profile of `ProjectService.getProjectRelationsForUser` and the editor + * endpoints that call it on every page load: + * + * GET /rest/projects/my-projects -> ProjectService.getMyProjects + * GET /rest/workflows/:id -> WorkflowService.getWorkflowScopes + * + CredentialsService.getCredentialsAUserCanUseInAWorkflow + * GET /rest/credentials/for-workflow -> CredentialsService.getCredentialsAUserCanUseInAWorkflow + * GET /rest/workflows (includeScopes) -> RoleService.addScopes per row + * + * `Role.scopes` is eager, so a relation query that joins `role` also joins + * `role_scope -> scope`: the rows the database materializes and TypeORM hydrates grow with + * (projects the user is a member of) x (scopes of their project role), not with the size + * of the response. `ProjectRelationRepository.findAllByUser` now loads the relations + * without that join and attaches the scopes from one query over the distinct roles. + * + * This suite seeds tiers that differ only in how many team projects one instance admin + * created (and so is `project:admin` in), plus a member with `project:editor` in the same + * projects, and reports for each call: + * - STATEMENTS: SQL statements and the rows each one materializes before hydration. + * - LATENCY: p50/p95 wall time and the longest event-loop stall during the timed runs. + * + * Only one thing is asserted: the relation statement materializes at most one row per + * relation, so a return of the scope join fails the suite. Timing is report-only. Opt in with: + * + * N8N_PROJECT_RELATIONS_BENCHMARK=1 pnpm --filter n8n test:sqlite project-relations.benchmark --silent=false + * N8N_PROJECT_RELATIONS_BENCHMARK=1 pnpm --filter n8n test:postgres:integration:tc project-relations.benchmark --silent=false + * + * Knobs: N8N_PR_BENCH_TIERS ("100,500,1000"), N8N_PR_BENCH_ITERS, N8N_PR_BENCH_OUT (JSONL file). + */ + +const runBenchmarks = process.env.N8N_PROJECT_RELATIONS_BENCHMARK === '1'; + +// Constructed by `WorkflowService`'s dependency graph, never called by the profiled paths. +mockInstance(ActiveWorkflowManager); +mockInstance(NodeTypes); +const dialect = process.env.DB_TYPE === 'postgresdb' ? 'postgres' : 'sqlite'; + +const envInt = (name: string, fallback: number): number => { + const parsed = Number(process.env[name]); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +}; +const envIntList = (name: string, fallback: number[]): number[] => { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = raw + .split(',') + .map((v) => Number(v.trim())) + .filter((v) => Number.isFinite(v) && v > 0) + .map(Math.floor); + return parsed.length > 0 ? parsed : fallback; +}; + +const TIERS = envIntList('N8N_PR_BENCH_TIERS', [100, 500, 1000]); +const ITERS = envInt('N8N_PR_BENCH_ITERS', 10); +const OUT_FILE = process.env.N8N_PR_BENCH_OUT; +const INSERT_CHUNK = 500; +const TEST_TIMEOUT_MS = 900_000; + +const commas = (n: number) => Math.round(n).toLocaleString('en-US'); +const ms = (n: number) => `${n.toFixed(1)}ms`; +const percentiles = (samples: number[]) => { + const sorted = [...samples].sort((a, b) => a - b); + const at = (p: number) => + sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]; + return { p50: at(50), p95: at(95) }; +}; +const report = (title: string, lines: string[]) => { + console.log( + `\n [project-relations · ${dialect}] ${title}\n${lines.map((l) => ` ${l}`).join('\n')}\n`, + ); +}; + +type Statement = { sql: string; parameters: unknown[] }; + +type Tier = { + projects: number; + projectIds: string[]; + /** Instance admin who created every project of the tier: `project:admin` in each. */ + admin: User; + /** Global member with `project:editor` in every project of the tier. */ + member: User; + workflowId: string; +}; + +type ScenarioResult = { + statements: Statement[]; + rows: number[]; + durations: number[]; + /** Longest event-loop stall observed while the timed iterations ran. */ + maxEventLoopDelayMs: number; + returned: number; + /** Wall time of the captured statements replayed through the driver, without hydration. */ + driverOnlyP50?: number; +}; + +describe.runIf(runBenchmarks)('project relations benchmarks', () => { + let dataSource: DataSource; + let projectService: ProjectService; + let workflowService: WorkflowService; + let credentialsService: CredentialsService; + const tiers: Tier[] = []; + + async function bulkInsert( + entity: new () => T, + rows: Array>, + ): Promise { + for (let i = 0; i < rows.length; i += INSERT_CHUNK) { + await dataSource + .createQueryBuilder() + .insert() + .into(entity) + .values(rows.slice(i, i + INSERT_CHUNK)) + .orIgnore() + .execute(); + } + } + + // Both drivers call `logger.logQuery` for every statement regardless of the `logging` + // option, so swapping the logger captures statements without reconfiguring the connection. + async function captureStatements( + run: () => Promise, + ): Promise<{ result: T; statements: Statement[] }> { + const original = dataSource.logger; + const statements: Statement[] = []; + const capturing = Object.create(original) as DataSource['logger']; + capturing.logQuery = (sql: string, parameters?: unknown[]) => { + statements.push({ sql, parameters: parameters ?? [] }); + }; + dataSource.logger = capturing; + try { + const result = await run(); + return { result, statements }; + } finally { + dataSource.logger = original; + } + } + + // Rows the database hands back for a statement, before TypeORM collapses the joined + // rows into entities. + async function rowsMaterialized({ sql, parameters }: Statement): Promise { + if (!/^\s*SELECT/i.test(sql)) return 0; + const inner = sql.replace(/^\s*SELECT COUNT\(.*?\) AS "cnt"/i, 'SELECT 1'); + const rows = await dataSource.query>( + `SELECT COUNT(*) AS n FROM (${inner}) AS bench_rows`, + parameters, + ); + return Number(rows[0].n); + } + + const describeStatement = (sql: string): string => { + const normalized = sql.replace(/\s+/g, ' '); + if (/^SELECT COUNT\(/i.test(normalized)) return 'count'; + const from = /FROM (?:"?\w+"?\.)?"?(\w+)"?/i.exec(normalized)?.[1] ?? '?'; + const joins = (normalized.match(/ JOIN /gi) ?? []).length; + return joins > 0 ? `${from} +${joins} joins` : from; + }; + + async function runScenario( + call: () => Promise, + { measureDriverOnly = false }: { measureDriverOnly?: boolean } = {}, + ): Promise { + // warm-up: role cache, driver plan cache + await call(); + + const { result, statements } = await captureStatements(call); + const rows: number[] = []; + for (const statement of statements) rows.push(await rowsMaterialized(statement)); + + const histogram = monitorEventLoopDelay({ resolution: 1 }); + histogram.enable(); + const durations: number[] = []; + for (let i = 0; i < ITERS; i++) { + const start = performance.now(); + await call(); + durations.push(performance.now() - start); + } + histogram.disable(); + + let driverOnlyP50: number | undefined; + if (measureDriverOnly) { + const raw: number[] = []; + for (let i = 0; i < ITERS; i++) { + const start = performance.now(); + for (const { sql, parameters } of statements) await dataSource.query(sql, parameters); + raw.push(performance.now() - start); + } + driverOnlyP50 = percentiles(raw).p50; + } + + return { + statements, + rows, + durations, + maxEventLoopDelayMs: histogram.max / 1e6, + returned: Array.isArray(result) ? result.length : 1, + driverOnlyP50, + }; + } + + function reportScenario(title: string, tier: Tier, who: string, result: ScenarioResult) { + const { p50, p95 } = percentiles(result.durations); + const rowsTotal = result.rows.reduce((a, b) => a + b, 0); + const lines = [ + `tier: ${commas(tier.projects)} team projects, caller: ${who}`, + `statements: ${result.statements.length}`, + ...result.statements.map( + (s, i) => + ` #${i + 1} ${describeStatement(s.sql).padEnd(32)} rows materialized: ${commas(result.rows[i])}`, + ), + `rows materialized total: ${commas(rowsTotal)} returned: ${result.returned}`, + `latency (n=${ITERS}): p50 ${ms(p50)} p95 ${ms(p95)} max event-loop stall ${ms(result.maxEventLoopDelayMs)}`, + ...(result.driverOnlyP50 !== undefined + ? [ + `driver only (same statements, no hydration): p50 ${ms(result.driverOnlyP50)} => hydration ~${ms(Math.max(0, p50 - result.driverOnlyP50))}`, + ] + : []), + ]; + report(title, lines); + + if (OUT_FILE) { + mkdirSync(dirname(OUT_FILE), { recursive: true }); + appendFileSync( + OUT_FILE, + JSON.stringify({ + recordedAt: new Date().toISOString(), + dialect, + scenario: title, + caller: who, + projects: tier.projects, + iterations: ITERS, + statements: result.statements.map((s, i) => ({ + kind: describeStatement(s.sql), + rows: result.rows[i], + })), + rowsTotal, + returned: result.returned, + p50: Math.round(p50 * 10) / 10, + p95: Math.round(p95 * 10) / 10, + maxEventLoopDelayMs: Math.round(result.maxEventLoopDelayMs * 10) / 10, + driverOnlyP50: + result.driverOnlyP50 !== undefined + ? Math.round(result.driverOnlyP50 * 10) / 10 + : undefined, + }) + '\n', + ); + } + } + + async function seedTier(projects: number): Promise { + const admin = await createAdmin(); + const member = await createMember(); + + const projectIds = Array.from({ length: projects }, () => generateNanoId()); + await bulkInsert( + Project, + projectIds.map((id, i) => ({ + id, + name: `bench-${projects}-${i}`, + type: 'team' as const, + creatorId: admin.id, + })), + ); + + // `createTeamProject` links the creating user as `project:admin`; mirror that, and add + // a plain member so the two callers differ only in global role and project role. + const relations: Array> = []; + for (const projectId of projectIds) { + relations.push({ projectId, userId: admin.id, role: { slug: 'project:admin' } }); + relations.push({ projectId, userId: member.id, role: { slug: 'project:editor' } }); + } + await bulkInsert(ProjectRelation, relations); + + const firstProject = await dataSource + .getRepository(Project) + .findOneByOrFail({ id: projectIds[0] }); + const workflow = await createWorkflow({ name: `bench-${projects}` }, firstProject); + + return { projects, projectIds, admin, member, workflowId: workflow.id }; + } + + beforeAll(async () => { + await testDb.init(); + dataSource = Container.get(DataSource); + projectService = Container.get(ProjectService); + workflowService = Container.get(WorkflowService); + credentialsService = Container.get(CredentialsService); + + await testDb.truncate([ + 'SharedWorkflow', + 'WorkflowEntity', + 'SharedCredentials', + 'CredentialsEntity', + 'ProjectRelation', + 'Project', + 'User', + ]); + + const seedStart = performance.now(); + for (const projects of TIERS) tiers.push(await seedTier(projects)); + + const counts = await Promise.all( + [Project, ProjectRelation].map( + async (entity) => await dataSource.getRepository(entity).count(), + ), + ); + report('corpus seeded', [ + `tiers (team projects): ${TIERS.join(', ')}`, + `project rows: ${commas(counts[0])} project_relation rows: ${commas(counts[1])}`, + `seed time: ${ms(performance.now() - seedStart)}`, + ]); + }, TEST_TIMEOUT_MS); + + afterAll(async () => { + await testDb.terminate(); + }); + + test( + 'ProjectService.getProjectRelationsForUser (shared primitive)', + async () => { + for (const tier of tiers) { + for (const [who, user] of [ + ['instance admin, project:admin everywhere', tier.admin], + ['member, project:editor everywhere', tier.member], + ] as const) { + const result = await runScenario( + async () => await projectService.getProjectRelationsForUser(user), + { measureDriverOnly: true }, + ); + reportScenario('getProjectRelationsForUser', tier, who, result); + // The relation statement must not fan out over the scopes of each role. + const relationRows = result.rows.find((_, i) => + /project_relation"\s+"ProjectRelation"/.test(result.statements[i].sql), + ); + expect(relationRows).toBeLessThanOrEqual(result.returned); + } + } + }, + TEST_TIMEOUT_MS, + ); + + test( + 'GET /rest/projects/my-projects -> ProjectService.getMyProjects', + async () => { + for (const tier of tiers) { + for (const [who, user] of [ + ['instance admin', tier.admin], + ['member', tier.member], + ] as const) { + const result = await runScenario(async () => await projectService.getMyProjects(user)); + reportScenario('getMyProjects', tier, who, result); + } + } + }, + TEST_TIMEOUT_MS, + ); + + test( + 'GET /rest/workflows/:id -> WorkflowService.getWorkflowScopes', + async () => { + for (const tier of tiers) { + for (const [who, user] of [ + ['instance admin', tier.admin], + ['member', tier.member], + ] as const) { + const result = await runScenario( + async () => await workflowService.getWorkflowScopes(user, tier.workflowId), + ); + reportScenario('getWorkflowScopes', tier, who, result); + } + } + }, + TEST_TIMEOUT_MS, + ); + + test( + 'GET /rest/credentials/for-workflow (also inside GET /rest/workflows/:id) -> getCredentialsAUserCanUseInAWorkflow', + async () => { + for (const tier of tiers) { + for (const [who, user] of [ + ['instance admin', tier.admin], + ['member', tier.member], + ] as const) { + const result = await runScenario( + async () => + await credentialsService.getCredentialsAUserCanUseInAWorkflow(user, { + workflowId: tier.workflowId, + }), + ); + reportScenario('getCredentialsAUserCanUseInAWorkflow', tier, who, result); + } + } + }, + TEST_TIMEOUT_MS, + ); +}); diff --git a/packages/cli/test/integration/project.service.integration.test.ts b/packages/cli/test/integration/project.service.integration.test.ts index 3e5c0017531..bf974475bac 100644 --- a/packages/cli/test/integration/project.service.integration.test.ts +++ b/packages/cli/test/integration/project.service.integration.test.ts @@ -6,8 +6,9 @@ import { createWorkflow, testDb, } from '@n8n/backend-test-utils'; -import { SharedWorkflowRepository } from '@n8n/db'; +import { ProjectRelationRepository, RoleRepository, SharedWorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; import { LicenseMocker } from '@test-integration/license'; import { createUser } from './shared/db/users'; @@ -188,4 +189,115 @@ describe('ProjectService', () => { }); }); }); + + describe('getProjectRelationsForUser', () => { + type Statement = { sql: string; parameters: unknown[] }; + + // Both drivers call `logger.logQuery` for every statement, so swapping the logger + // captures what a call runs without reconfiguring the connection. + async function captureStatements(run: () => Promise) { + const dataSource = Container.get(DataSource); + const original = dataSource.logger; + const statements: Statement[] = []; + const capturing = Object.create(original) as DataSource['logger']; + capturing.logQuery = (sql: string, parameters?: unknown[]) => { + statements.push({ sql, parameters: parameters ?? [] }); + }; + dataSource.logger = capturing; + try { + return { result: await run(), statements }; + } finally { + dataSource.logger = original; + } + } + + // Rows the database returns for a statement, before TypeORM collapses them into entities. + async function rowsMaterialized({ sql, parameters }: Statement) { + const rows = await Container.get(DataSource).query>( + `SELECT COUNT(*) AS n FROM (${sql}) AS materialized`, + parameters, + ); + return Number(rows[0].n); + } + + const normalize = ( + relations: Awaited>, + ) => + relations + .map((relation) => ({ + projectId: relation.projectId, + project: relation.project.id, + role: relation.role.slug, + scopes: relation.role.scopes.map((scope) => scope.slug).sort(), + })) + .sort((a, b) => a.projectId.localeCompare(b.projectId)); + + it('returns every relation with its project, role and the scopes of that role', async () => { + const user = await createUser(); + const adminProject = await createTeamProject('Admin project'); + const editorProject = await createTeamProject('Editor project'); + await linkUserToProject(user, adminProject, 'project:admin'); + await linkUserToProject(user, editorProject, 'project:editor'); + + const relations = await projectService.getProjectRelationsForUser(user); + + expect(relations).toHaveLength(3); + const byRole = new Map(relations.map((relation) => [relation.role.slug, relation])); + expect(byRole.get('project:admin')?.project.id).toBe(adminProject.id); + expect(byRole.get('project:editor')?.project.id).toBe(editorProject.id); + expect(byRole.get('project:personalOwner')?.project.type).toBe('personal'); + + const roleRepository = Container.get(RoleRepository); + for (const slug of ['project:personalOwner', 'project:admin', 'project:editor']) { + const role = await roleRepository.findBySlug(slug); + const expectedScopes = role!.scopes.map((scope) => scope.slug).sort(); + expect(expectedScopes.length).toBeGreaterThan(0); + expect( + byRole + .get(slug) + ?.role.scopes.map((scope) => scope.slug) + .sort(), + ).toEqual(expectedScopes); + } + }); + + it('matches a find with eager role scopes relation for relation', async () => { + const user = await createUser(); + const projects = await Promise.all( + ['P1', 'P2', 'P3'].map(async (name) => await createTeamProject(name)), + ); + await linkUserToProject(user, projects[0], 'project:admin'); + await linkUserToProject(user, projects[1], 'project:editor'); + await linkUserToProject(user, projects[2], 'project:viewer'); + + const eager = await Container.get(ProjectRelationRepository).find({ + where: { userId: user.id }, + relations: ['project', 'role'], + }); + const actual = await projectService.getProjectRelationsForUser(user); + + expect(normalize(actual)).toEqual(normalize(eager)); + expect(actual).toHaveLength(4); + }); + + it('materializes one row per relation instead of one row per scope', async () => { + const user = await createUser(); + for (const name of ['P1', 'P2', 'P3']) { + await linkUserToProject(user, await createTeamProject(name), 'project:admin'); + } + + const { result, statements } = await captureStatements( + async () => await projectService.getProjectRelationsForUser(user), + ); + + expect(result).toHaveLength(4); + // One statement for the relations, one for the distinct roles and their scopes. + expect(statements).toHaveLength(2); + const [relationStatement, roleStatement] = statements; + expect(relationStatement.sql).toMatch(/project_relation"\s+"ProjectRelation"/); + expect(relationStatement.sql).not.toMatch(/role_scope/); + expect(await rowsMaterialized(relationStatement)).toBe(4); + expect(roleStatement.sql).toMatch(/role_scope/); + }); + }); }); diff --git a/packages/cli/test/integration/prometheus-metrics.test.ts b/packages/cli/test/integration/prometheus-metrics.test.ts index eb6996af261..539cdc35907 100644 --- a/packages/cli/test/integration/prometheus-metrics.test.ts +++ b/packages/cli/test/integration/prometheus-metrics.test.ts @@ -1,3 +1,4 @@ +import { CacheService, EventService } from '@n8n/backend-services'; import { createActiveWorkflow } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { WorkflowRepository } from '@n8n/db'; @@ -8,8 +9,6 @@ import { parse as semverParse } from 'semver'; import request, { type Response } from 'supertest'; import { N8N_VERSION } from '@/constants'; -import { EventService } from '@/events/event.service'; -import { CacheService } from '@/services/cache/cache.service'; import { setupTestServer } from './shared/utils'; diff --git a/packages/cli/test/integration/public-api/credential-type-policies.test.ts b/packages/cli/test/integration/public-api/credential-type-policies.test.ts index ad0da2ab447..2c2948d9b3f 100644 --- a/packages/cli/test/integration/public-api/credential-type-policies.test.ts +++ b/packages/cli/test/integration/public-api/credential-type-policies.test.ts @@ -1,9 +1,9 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, linkUserToProject, testDb } from '@n8n/backend-test-utils'; import { LICENSE_FEATURES } from '@n8n/constants'; import type { Project, User } from '@n8n/db'; import { Container } from '@n8n/di'; -import { EventService } from '@/events/event.service'; import { TypeAvailabilityPolicyRepository } from '@/modules/type-availability-policies/database/repositories/type-availability-policy.repository'; import { createMemberWithApiKey, createOwnerWithApiKey } from '@test-integration/db/users'; import * as utils from '@test-integration/utils'; diff --git a/packages/cli/test/integration/public-api/executions.test.ts b/packages/cli/test/integration/public-api/executions.test.ts index 41dbb7046c6..a3995de8fec 100644 --- a/packages/cli/test/integration/public-api/executions.test.ts +++ b/packages/cli/test/integration/public-api/executions.test.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'node:crypto'; import { createManyWorkflows, createTeamProject, @@ -17,7 +18,7 @@ import { ActiveExecutions } from '@/active-executions'; import type { ActiveWorkflowManager } from '@/active-workflow-manager'; import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error'; import { QueuedExecutionRetryError } from '@/errors/queued-execution-retry.error'; -import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { ConflictError } from '@n8n/errors'; import { ExecutionService } from '@/executions/execution.service'; import { CommunityPackagesService } from '@/modules/community-packages/community-packages.service'; import { Telemetry } from '@/telemetry'; @@ -93,6 +94,12 @@ const testWithAPIKey = expect(response.statusCode).toBe(401); }; +function createTraceparent() { + const traceId = randomBytes(16).toString('hex'); + const spanId = randomBytes(8).toString('hex'); + return `00-${traceId}-${spanId}-01`; +} + describe('GET /executions/:id', () => { test('should fail due to missing API Key', testWithAPIKey('get', '/executions/1', null)); @@ -110,8 +117,16 @@ describe('GET /executions/:id', () => { test('owner should be able to get an execution owned by him', async () => { const workflow = await createWorkflow({}, owner); + const traceparent = createTraceparent(); - const execution = await createSuccessfulExecution(workflow); + const execution = await createExecution( + { + finished: true, + status: 'success', + tracingContext: { traceparent }, + }, + workflow, + ); const response = await authOwnerAgent.get(`/executions/${execution.id}`); @@ -127,6 +142,7 @@ describe('GET /executions/:id', () => { stoppedAt, workflowId, waitTill, + tracingContext, } = response.body; expect(id).toBeDefined(); @@ -138,6 +154,53 @@ describe('GET /executions/:id', () => { expect(stoppedAt).not.toBeNull(); expect(workflowId).toBe(execution.workflowId); expect(waitTill).toBeNull(); + expect(tracingContext).toEqual({ traceparent }); + }); + + test('should return a webhook execution when the stored tracestate is null', async () => { + const traceparent = createTraceparent(); + const workflow = await createWorkflow({}, owner); + const execution = await createExecution( + { + finished: true, + status: 'success', + mode: 'webhook', + tracingContext: { + traceparent, + tracestate: null, + } as unknown as ExecutionEntity['tracingContext'], + }, + workflow, + ); + + const withoutData = await authOwnerAgent.get(`/executions/${execution.id}`); + const withData = await authOwnerAgent.get(`/executions/${execution.id}?includeData=true`); + + expect(withoutData.statusCode).toBe(200); + expect(withData.statusCode).toBe(200); + expect(withoutData.body.tracingContext).toEqual({ traceparent }); + expect(withData.body.tracingContext).toEqual({ traceparent }); + }); + + test('should return a webhook execution when the stored traceparent is missing', async () => { + const workflow = await createWorkflow({}, owner); + const execution = await createExecution( + { + finished: true, + status: 'success', + mode: 'webhook', + tracingContext: {} as unknown as ExecutionEntity['tracingContext'], + }, + workflow, + ); + + const withoutData = await authOwnerAgent.get(`/executions/${execution.id}`); + const withData = await authOwnerAgent.get(`/executions/${execution.id}?includeData=true`); + + expect(withoutData.statusCode).toBe(200); + expect(withData.statusCode).toBe(200); + expect(withoutData.body.tracingContext).toBeNull(); + expect(withData.body.tracingContext).toBeNull(); }); test('owner should be able to read executions of other users', async () => { diff --git a/packages/cli/test/integration/public-api/n8n-packages-export.test.ts b/packages/cli/test/integration/public-api/n8n-packages-export.test.ts index a09513c3575..4b57452b291 100644 --- a/packages/cli/test/integration/public-api/n8n-packages-export.test.ts +++ b/packages/cli/test/integration/public-api/n8n-packages-export.test.ts @@ -1,9 +1,9 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, createWorkflow, testDb } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; import { Container } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; -import { EventService } from '@/events/event.service'; import { createFolder } from '@test-integration/db/folders'; import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users'; diff --git a/packages/cli/test/integration/public-api/n8n-packages-import-selection.test.ts b/packages/cli/test/integration/public-api/n8n-packages-import-selection.test.ts index 05ee18e326f..b4f831bf72b 100644 --- a/packages/cli/test/integration/public-api/n8n-packages-import-selection.test.ts +++ b/packages/cli/test/integration/public-api/n8n-packages-import-selection.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, createWorkflow, @@ -9,7 +10,6 @@ import { WorkflowRepository, type User } from '@n8n/db'; import { Container } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; -import { EventService } from '@/events/event.service'; import { buildEntityPackageBuffer, serializedProject, diff --git a/packages/cli/test/integration/public-api/n8n-packages-import.test.ts b/packages/cli/test/integration/public-api/n8n-packages-import.test.ts index f20fe886245..ab2b78d1f8b 100644 --- a/packages/cli/test/integration/public-api/n8n-packages-import.test.ts +++ b/packages/cli/test/integration/public-api/n8n-packages-import.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, mockInstance, testDb } from '@n8n/backend-test-utils'; import type { Project, User } from '@n8n/db'; import { ProjectRepository } from '@n8n/db'; @@ -5,7 +6,6 @@ import { Container } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; import { CredentialTypes } from '@/credential-types'; -import { EventService } from '@/events/event.service'; import { buildImportPackageBuffer, serializedWorkflow, diff --git a/packages/cli/test/integration/public-api/node-type-policies.test.ts b/packages/cli/test/integration/public-api/node-type-policies.test.ts index 954ecdb2725..59c13d3730f 100644 --- a/packages/cli/test/integration/public-api/node-type-policies.test.ts +++ b/packages/cli/test/integration/public-api/node-type-policies.test.ts @@ -1,9 +1,9 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, linkUserToProject, testDb } from '@n8n/backend-test-utils'; import { LICENSE_FEATURES } from '@n8n/constants'; import type { Project, User } from '@n8n/db'; import { Container } from '@n8n/di'; -import { EventService } from '@/events/event.service'; import { TypeAvailabilityPolicyRepository } from '@/modules/type-availability-policies/database/repositories/type-availability-policy.repository'; import { createMemberWithApiKey, createOwnerWithApiKey } from '@test-integration/db/users'; import * as utils from '@test-integration/utils'; diff --git a/packages/cli/test/integration/public-api/otel-settings.test.ts b/packages/cli/test/integration/public-api/otel-settings.test.ts index 4082c9f6d54..7fdf841b8ec 100644 --- a/packages/cli/test/integration/public-api/otel-settings.test.ts +++ b/packages/cli/test/integration/public-api/otel-settings.test.ts @@ -74,6 +74,18 @@ describe('OpenTelemetry settings in Public API', () => { expect(typeof response.body.exporterEndpoint).toBe('string'); }); + it('returns saved settings with masked exporter headers', async () => { + await testServer.publicApiAgentFor(owner).put('/settings/otel').send(validSettings); + + const response = await testServer.publicApiAgentFor(owner).get('/settings/otel'); + + expect(response.status).toBe(200); + expect(response.body).toStrictEqual({ + ...validSettings, + exporterHeaders: `authorization=${CREDENTIAL_BLANKING_VALUE}`, + }); + }); + it('exposes exactly the fields the UI configures, and nothing more', async () => { const response = await testServer.publicApiAgentFor(owner).get('/settings/otel'); @@ -109,6 +121,17 @@ describe('OpenTelemetry settings in Public API', () => { const response = await testServer.publicApiAgentFor(scopedOwner).get('/settings/otel'); expect(response.status).toBe(403); + expect(response.body).toEqual({ message: 'Forbidden' }); + }); + + it('rejects an unknown query parameter with 400', async () => { + const response = await testServer + .publicApiAgentFor(owner) + .get('/settings/otel') + .query({ unexpected: 'value' }); + + expect(response.status).toBe(400); + expect(response.body.message).toContain('unexpected'); }); }); diff --git a/packages/cli/test/integration/public-api/projects.test.ts b/packages/cli/test/integration/public-api/projects.test.ts index f6a16f9c00f..b62a5b00e6b 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, getProjectByNameOrFail, @@ -18,7 +19,6 @@ import { Container } from '@n8n/di'; import type { MockInstance } from 'vitest'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; -import { EventService } from '@/events/event.service'; import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; import { Telemetry } from '@/telemetry'; import { diff --git a/packages/cli/test/integration/public-api/promotions.test.ts b/packages/cli/test/integration/public-api/promotions.test.ts index 1c0442e5027..9b3df9250be 100644 --- a/packages/cli/test/integration/public-api/promotions.test.ts +++ b/packages/cli/test/integration/public-api/promotions.test.ts @@ -6,7 +6,7 @@ import { GLOBAL_MEMBER_ROLE, ProjectRepository, UserRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { BadRequestError } from '@n8n/errors'; import { PromotionConfigRepository } from '@/modules/promotions.ee/database/repositories/promotion-config.repository'; import { PromotionConnectionProjectRepository } from '@/modules/promotions.ee/database/repositories/promotion-connection-project.repository'; import { PromotionConnectionRepository } from '@/modules/promotions.ee/database/repositories/promotion-connection.repository'; diff --git a/packages/cli/test/integration/public-api/security-policy.test.ts b/packages/cli/test/integration/public-api/security-policy.test.ts index 99abaea3303..2adc29824fc 100644 --- a/packages/cli/test/integration/public-api/security-policy.test.ts +++ b/packages/cli/test/integration/public-api/security-policy.test.ts @@ -10,7 +10,7 @@ import { import { In } from '@n8n/typeorm'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { createMemberWithApiKey, createOwnerWithApiKey } from '@test-integration/db/users'; import { setupTestServer } from '@test-integration/utils'; diff --git a/packages/cli/test/integration/public-api/source-control.test.ts b/packages/cli/test/integration/public-api/source-control.test.ts index 61cf9828f21..a184099a409 100644 --- a/packages/cli/test/integration/public-api/source-control.test.ts +++ b/packages/cli/test/integration/public-api/source-control.test.ts @@ -1,11 +1,10 @@ import type { SourceControlledFile } from '@n8n/api-types'; +import { EventService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; import { Container } from '@n8n/di'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { SourceControlPreferencesService } from '@/modules/source-control.ee/source-control-preferences.service.ee'; import { SourceControlService } from '@/modules/source-control.ee/source-control.service.ee'; import { Telemetry } from '@/telemetry'; diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index 45d85c7742c..ec7bc1eecb0 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, createWorkflow, @@ -28,7 +29,6 @@ import { v4 as uuid } from 'uuid'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { STARTING_NODES } from '@/constants'; -import { EventService } from '@/events/event.service'; import { ExecutionService } from '@/executions/execution.service'; import { InstanceRedactionEnforcementService } from '@/modules/redaction/instance-redaction-enforcement.service'; import { ProjectService } from '@/services/project.service.ee'; diff --git a/packages/cli/test/integration/saml/saml.api.test.ts b/packages/cli/test/integration/saml/saml.api.test.ts index 3e3f4155c32..137abc82e8c 100644 --- a/packages/cli/test/integration/saml/saml.api.test.ts +++ b/packages/cli/test/integration/saml/saml.api.test.ts @@ -26,8 +26,7 @@ import type express from 'express'; import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow'; import { TEMPLATES_DIR } from '@/constants'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; import { EC_TEST_CERTIFICATE, diff --git a/packages/cli/test/integration/scheduling/scheduled-job-lifecycle.test.ts b/packages/cli/test/integration/scheduling/scheduled-job-lifecycle.test.ts index f59e9bf454d..e05c378e362 100644 --- a/packages/cli/test/integration/scheduling/scheduled-job-lifecycle.test.ts +++ b/packages/cli/test/integration/scheduling/scheduled-job-lifecycle.test.ts @@ -14,6 +14,7 @@ import { } from '@n8n/db'; import { Container } from '@n8n/di'; import { ActiveWorkflowTriggers, ExternalSecretsProxy, InstanceSettings } from 'n8n-core'; +import { Cron } from 'n8n-nodes-base/nodes/Cron/Cron.node'; import { ScheduleTrigger } from 'n8n-nodes-base/nodes/Schedule/ScheduleTrigger.node'; import type { INode, INodeTypeData } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; @@ -75,6 +76,16 @@ const scheduleNode = (suffix: string, id = `node-${suffix}`): INode => ({ parameters: { rule: { interval: [{ field: 'minutes', minutesInterval: 5 }] } }, }); +/** A legacy Cron node whose generated seconds field must not change the job across republishes. */ +const cronNode = (suffix: string, id = `node-${suffix}`): INode => ({ + id, + name: `Cron ${suffix}`, + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [0, 0], + parameters: { triggerTimes: { item: [{ mode: 'everyHour', minute: 15 }] } }, +}); + /** Run the one pending publication record, as the leader's consumer would. */ const applyNextRecord = async () => { const record = await outboxRepository.claimNextPendingRecord(); @@ -125,6 +136,7 @@ beforeAll(async () => { await testDb.init(); const nodes: INodeTypeData = { + 'n8n-nodes-base.cron': { type: new Cron(), sourcePath: '' }, 'n8n-nodes-base.scheduleTrigger': { type: new ScheduleTrigger(), sourcePath: '' }, }; await utils.initNodeTypes(nodes); @@ -232,6 +244,23 @@ describe('scheduled job lifecycle across publish, unpublish and republish', () = expect(jobs.find((job) => job.ownerMemberId === trigger.id)?.id).toBe(first.id); }); + it('re-activating a Cron node converges on the same generated cron and job name', async () => { + const trigger = cronNode('a'); + const workflow = await createWorkflow([trigger]); + await publish(workflow); + const [first] = await jobsOf(workflow.id); + expect(first.cronExpression).toMatch(/^\d+ 15 \* \* \* \*$/); + await unpublish(workflow, workflow.versionId); + + // Runs the node's `trigger()` again, which the legacy engine lets pick a + // fresh random second on every activation. + await republish(workflow, [trigger]); + + const jobs = await jobsOf(workflow.id); + expect(jobs).toHaveLength(1); + expect(jobs[0]).toMatchObject({ name: first.name, cronExpression: first.cronExpression }); + }); + it('republishing without a trigger node deletes that node’s rows', async () => { const kept = scheduleNode('kept'); const dropped = scheduleNode('dropped'); diff --git a/packages/cli/test/integration/scheduling/system-task-provisioning.test.ts b/packages/cli/test/integration/scheduling/system-task-provisioning.test.ts index aa9192ca6fa..bf29401336d 100644 --- a/packages/cli/test/integration/scheduling/system-task-provisioning.test.ts +++ b/packages/cli/test/integration/scheduling/system-task-provisioning.test.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { testDb } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { ScheduledJobMisfirePolicy, ScheduledJobOwnerType } from '@n8n/constants'; @@ -10,7 +11,6 @@ import { ErrorReporter } from 'n8n-core'; import { inc } from 'semver'; import { N8N_VERSION } from '@/constants'; -import { EventService } from '@/events/event.service'; import { DurableJobProvisioner } from '@/scheduling/durable-job-provisioner'; import { SystemTaskJobRegistrar, diff --git a/packages/cli/test/integration/server-static-assets.test.ts b/packages/cli/test/integration/server-static-assets.test.ts index 549049aa126..13ba8cc1809 100644 --- a/packages/cli/test/integration/server-static-assets.test.ts +++ b/packages/cli/test/integration/server-static-assets.test.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { mockInstance, mockLogger, testDb } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { HTML_NONCE_PLACEHOLDER } from '@n8n/constants'; @@ -16,7 +17,6 @@ import { AUTH_COOKIE_NAME } from '@/constants'; import { ControllerRegistry } from '@/controller.registry'; import { CredentialsOverwrites } from '@/credentials-overwrites'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; -import type { EventService } from '@/events/event.service'; import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay'; import { ExternalHooks } from '@/external-hooks'; import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; diff --git a/packages/cli/test/integration/services/role.service.test.ts b/packages/cli/test/integration/services/role.service.test.ts index 876e9139e57..bdccfae7796 100644 --- a/packages/cli/test/integration/services/role.service.test.ts +++ b/packages/cli/test/integration/services/role.service.test.ts @@ -10,8 +10,7 @@ import { MANDATORY_INSTANCE_SCOPES, } from '@n8n/permissions'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError, NotFoundError } from '@n8n/errors'; import { License } from '@/license'; import { ProvisioningRoleDeletionChecker } from '@/modules/provisioning.ee/role-deletion-checker.ee'; import { ProjectService } from '@/services/project.service.ee'; diff --git a/packages/cli/test/integration/shared/db/executions.ts b/packages/cli/test/integration/shared/db/executions.ts index 7c1caa3aba6..7ff30280fe2 100644 --- a/packages/cli/test/integration/shared/db/executions.ts +++ b/packages/cli/test/integration/shared/db/executions.ts @@ -47,6 +47,7 @@ export async function createExecution( jsonSizeBytes, binaryDataSizeBytes, workflowVersionId, + tracingContext, } = attributes; const execution = await Container.get(ExecutionRepository).save({ @@ -62,6 +63,7 @@ export async function createExecution( ...(jsonSizeBytes !== undefined && { jsonSizeBytes }), ...(binaryDataSizeBytes !== undefined && { binaryDataSizeBytes }), ...(workflowVersionId !== undefined && { workflowVersionId }), + ...(tracingContext !== undefined && { tracingContext }), }); if (metadata?.length) { diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index 5588381f128..ce5df046245 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -246,7 +246,7 @@ export const setupTestServer = ({ // CacheService must be initialized before PrometheusMetricsService // because cache-metrics.service calls isRedis() during init, which // reads this.cache.kind — only set after CacheService.init() resolves. - const { CacheService } = await import('@/services/cache/cache.service.js'); + const { CacheService } = await import('@n8n/backend-services'); await Container.get(CacheService).init(); const { PrometheusMetricsService } = await import('@/metrics/prometheus/index.js'); Container.get(PrometheusMetricsService).init(app); diff --git a/packages/cli/test/integration/token-exchange/embed-auth.api.test.ts b/packages/cli/test/integration/token-exchange/embed-auth.api.test.ts index d799815b5e5..1034bdd539a 100644 --- a/packages/cli/test/integration/token-exchange/embed-auth.api.test.ts +++ b/packages/cli/test/integration/token-exchange/embed-auth.api.test.ts @@ -1,5 +1,6 @@ process.env.N8N_ENV_FEAT_TOKEN_EXCHANGE = 'true'; +import { EventService } from '@n8n/backend-services'; import { testDb } from '@n8n/backend-test-utils'; import { AuthIdentity, AuthIdentityRepository, UserRepository } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -7,7 +8,6 @@ import jwt from 'jsonwebtoken'; import { InstanceSettings } from 'n8n-core'; import { generateKeyPairSync, randomUUID } from 'node:crypto'; -import { EventService } from '@/events/event.service'; import { qualifiedProviderId } from '@/modules/token-exchange/services/identity-resolution.service'; import { TokenExchangeConfig } from '@/modules/token-exchange/token-exchange.config'; diff --git a/packages/cli/test/integration/token-exchange/identity-resolution.service.test.ts b/packages/cli/test/integration/token-exchange/identity-resolution.service.test.ts index 51eea6a0c0c..204e0ffb78d 100644 --- a/packages/cli/test/integration/token-exchange/identity-resolution.service.test.ts +++ b/packages/cli/test/integration/token-exchange/identity-resolution.service.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { testDb } from '@n8n/backend-test-utils'; import { AuthIdentity, @@ -8,7 +9,6 @@ import { } from '@n8n/db'; import { Container } from '@n8n/di'; -import { EventService } from '@/events/event.service'; import { IdentityResolutionService, qualifiedProviderId, diff --git a/packages/cli/test/integration/token-exchange/trusted-key.service.integration.test.ts b/packages/cli/test/integration/token-exchange/trusted-key.service.integration.test.ts index 68da7134bf2..d0adc3aa238 100644 --- a/packages/cli/test/integration/token-exchange/trusted-key.service.integration.test.ts +++ b/packages/cli/test/integration/token-exchange/trusted-key.service.integration.test.ts @@ -1,5 +1,6 @@ import { mockInstance, testDb, testModules } from '@n8n/backend-test-utils'; import { Container } from '@n8n/di'; +import { EntityManager } from '@n8n/typeorm'; import type { KeyObject } from 'node:crypto'; import type { TrustedKeySourceEntity } from '@/modules/token-exchange/database/entities/trusted-key-source.entity'; @@ -342,7 +343,7 @@ describe('TrustedKeyService (integration)', () => { // Corrupt config await sourceRepo.update('static', { config: 'invalid-json' }); - await service.refreshSource('static'); + await expect(service.refreshSource('static')).rejects.toThrow('malformed JSON'); const source = await sourceRepo.findOneBy({ id: 'static' }); expect(source!.status).toBe('error'); @@ -361,6 +362,69 @@ describe('TrustedKeyService (integration)', () => { }); }); + describe('refreshDueSources', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + /** Causes a constraint failure after the old keys of `sourceId` are deleted. */ + function failKeyWritesFor(sourceId: string) { + const originalDelete = EntityManager.prototype.delete; + vi.spyOn(EntityManager.prototype, 'delete').mockImplementation(async function ( + this: EntityManager, + target, + criteria, + ) { + const result = await originalDelete.call(this, target, criteria); + if ( + target === TrustedKeyEntity && + (criteria as { sourceId?: string }).sourceId === sourceId + ) { + await this.insert(TrustedKeyEntity, { + sourceId: 'missing-source', + kid: 'test-kid', + data: '{}', + createdAt: new Date(), + }); + } + return result; + }); + } + + it.each(['fetch', 'write'] as const)( + 'should stop at a source whose key %s failed and try it last on the next run', + async (failureType) => { + const signal = new AbortController().signal; + const lastRefreshedAt = new Date('2020-01-01T00:00:00.000Z'); + await insertSource({ + id: 'broken', + updatedAt: lastRefreshedAt, + lastRefreshedAt, + ...(failureType === 'fetch' ? { config: 'invalid-json' } : {}), + }); + const oldKey = await insertKey({ sourceId: 'broken', kid: 'old-key' }); + await insertSource({ id: 'static', updatedAt: new Date('2020-01-02T00:00:00.000Z') }); + if (failureType === 'write') { + failKeyWritesFor('broken'); + } + + await expect(service.refreshDueSources(signal)).rejects.toThrow(); + + const failedSource = (await sourceRepo.findOneBy({ id: 'broken' }))!; + expect(failedSource.status).toBe('error'); + expect(failedSource.lastError).toBeTruthy(); + expect(failedSource.lastRefreshedAt).toEqual(lastRefreshedAt); + expect(await keyRepo.findBy({ sourceId: 'broken' })).toEqual([oldKey]); + expect((await sourceRepo.findOneBy({ id: 'static' }))!.status).toBe('pending'); + + await expect(service.refreshDueSources(signal)).rejects.toThrow(); + + expect((await sourceRepo.findOneBy({ id: 'static' }))!.status).toBe('healthy'); + expect(await keyRepo.findBy({ sourceId: 'static' })).toHaveLength(1); + }, + ); + }); + describe('algorithm validation and key compatibility', () => { it.each([ { name: 'RSA key with RS256', kid: 'rsa-key', algorithms: ['RS256'], key: RSA_PUBLIC_KEY }, @@ -402,7 +466,7 @@ describe('TrustedKeyService (integration)', () => { ])('should reject $name', async ({ entries }) => { await insertSource({ config: JSON.stringify(entries) }); - await service.refreshSource('static'); + await expect(service.refreshSource('static')).rejects.toThrow(); const source = await sourceRepo.findOneBy({ id: 'static' }); expect(source!.status).toBe('error'); diff --git a/packages/cli/test/integration/variables.test.ts b/packages/cli/test/integration/variables.test.ts index bc1a3251b46..ca4cc17352e 100644 --- a/packages/cli/test/integration/variables.test.ts +++ b/packages/cli/test/integration/variables.test.ts @@ -2,7 +2,7 @@ import { createTeamProject, linkUserToProject, testDb } from '@n8n/backend-test- import type { Project, Variables } from '@n8n/db'; import { Container } from '@n8n/di'; -import { CacheService } from '@/services/cache/cache.service'; +import { CacheService } from '@n8n/backend-services'; import { createProjectVariable, createVariable, diff --git a/packages/cli/test/integration/workflow-history-compaction.service.test.ts b/packages/cli/test/integration/workflow-history-compaction.service.test.ts index 282e889794a..73a8c519aa7 100644 --- a/packages/cli/test/integration/workflow-history-compaction.service.test.ts +++ b/packages/cli/test/integration/workflow-history-compaction.service.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { mockLogger, createWorkflow, testDb, createWorkflowHistory } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; @@ -9,7 +10,6 @@ import { InstanceSettings } from 'n8n-core'; import type { INode } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; -import { EventService } from '@/events/event.service'; import { WorkflowHistoryCompactionService } from '@/services/pruning/workflow-history-compaction.service'; describe('compacting cycle', () => { diff --git a/packages/cli/test/integration/workflows/workflow-index.test.ts b/packages/cli/test/integration/workflows/workflow-index.test.ts index 068d7bb4a94..2317e134521 100644 --- a/packages/cli/test/integration/workflows/workflow-index.test.ts +++ b/packages/cli/test/integration/workflows/workflow-index.test.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import { testDb, createWorkflow, @@ -13,7 +14,6 @@ import { ErrorReporter, Tracing } from 'n8n-core'; import type { INode } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; -import { EventService } from '@/events/event.service'; import { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service'; import { retryUntil } from '@test-integration/retry-until'; diff --git a/packages/cli/test/integration/workflows/workflow-publication-outbox-cleanup.task.test.ts b/packages/cli/test/integration/workflows/workflow-publication-outbox-cleanup.task.test.ts index 3f3cf3c1bb2..b08e4a0bec5 100644 --- a/packages/cli/test/integration/workflows/workflow-publication-outbox-cleanup.task.test.ts +++ b/packages/cli/test/integration/workflows/workflow-publication-outbox-cleanup.task.test.ts @@ -1,4 +1,5 @@ import type { Logger } from '@n8n/backend-common'; +import type { EventService } from '@n8n/backend-services'; import { testDb } from '@n8n/backend-test-utils'; import { GlobalConfig, WorkflowsConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; @@ -15,7 +16,6 @@ import { sleep } from '@n8n/utils/sleep'; import type { Span, Tracing } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; -import type { EventService } from '@/events/event.service'; import { WorkflowPublicationOutboxCleanupService } from '@/workflows/publication/workflow-publication-outbox-cleanup.service'; import { WorkflowPublicationOutboxCleanupTask } from '@/workflows/publication/workflow-publication-outbox-cleanup.task'; diff --git a/packages/cli/test/integration/workflows/workflow-publication-reconciler.test.ts b/packages/cli/test/integration/workflows/workflow-publication-reconciler.test.ts index 6a46eba18d6..6137c9afb55 100644 --- a/packages/cli/test/integration/workflows/workflow-publication-reconciler.test.ts +++ b/packages/cli/test/integration/workflows/workflow-publication-reconciler.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { createWorkflowHistory, createWorkflowWithHistory, @@ -21,7 +22,6 @@ import type { INode, INodeTypeData } from 'n8n-workflow'; import { ActiveExecutions } from '@/active-executions'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; -import { EventService } from '@/events/event.service'; import { ExecutionService } from '@/executions/execution.service'; import { ExternalHooks } from '@/external-hooks'; import { Push } from '@/push'; diff --git a/packages/cli/test/integration/workflows/workflow-save-policy.test.ts b/packages/cli/test/integration/workflows/workflow-save-policy.test.ts index d07ba670f32..cb10d9469d1 100644 --- a/packages/cli/test/integration/workflows/workflow-save-policy.test.ts +++ b/packages/cli/test/integration/workflows/workflow-save-policy.test.ts @@ -6,6 +6,8 @@ import { PolicyCheck, PolicyCheckMetadata } from '@n8n/decorators'; import { Container } from '@n8n/di'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; +import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; +import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay'; import { cleanupRolesAndScopes } from '../shared/db/roles'; import { createOwnerWithApiKey } from '../shared/db/users'; @@ -68,6 +70,12 @@ let owner: User; let editorAgent: SuperAgentTest; let publicApiAgent: SuperAgentTest; let workflowRepository: WorkflowRepository; +const eventBus = mockInstance(MessageEventBus); + +const blockEvents = () => + eventBus.sendAuditEvent.mock.calls + .map(([options]) => options) + .filter((options) => options.eventName === 'n8n.audit.policy.decision.blocked'); const truncate = async () => await testDb.truncate([ @@ -86,6 +94,7 @@ const truncate = async () => beforeAll(async () => { await utils.initNodeTypes(); workflowRepository = Container.get(WorkflowRepository); + Container.get(LogStreamingEventRelay).init(); // The public API's OpenAPI validator compiles the whole spec on the first authenticated // request that reaches it (~3s). Spend it here, under the 30s hook timeout, so it can't @@ -99,6 +108,7 @@ beforeAll(async () => { beforeEach(async () => { mode = 'allow'; + eventBus.sendAuditEvent.mockClear(); await truncate(); await cleanupRolesAndScopes(); @@ -128,6 +138,31 @@ describe('with a check that denies every save', () => { await expect(workflowRepository.count()).resolves.toBe(0); }); + test('editor create sends one audit event naming the point, violation and user', async () => { + await editorAgent.post('/workflows').send(editorPayload); + + expect(blockEvents()).toHaveLength(1); + expect(blockEvents()[0].payload).toMatchObject({ + point: 'workflowSave', + outcome: 'violation', + checkIds: expect.arrayContaining([CHECK_ID]), + violations: [ + { + checkId: CHECK_ID, + kind: DENIAL.kind, + subject: DENIAL.subject, + subjectType: DENIAL.subjectType, + scope: null, + matchedRuleId: null, + }, + ], + policyVersions: [], + actorType: 'user', + userId: owner.id, + _email: owner.email, + }); + }); + test('editor update fails with 403 and the violations', async () => { const workflow = await createWorkflow({ name: 'Stored name' }, owner); @@ -219,4 +254,12 @@ test('a check that breaks blocks the save without leaking violations', async () const { meta } = response.body as { meta: Record }; expect(meta.violations).toBeUndefined(); await expect(workflowRepository.count()).resolves.toBe(0); + + expect(blockEvents()).toHaveLength(1); + expect(blockEvents()[0].payload).toMatchObject({ + outcome: 'checkFailure', + correlationIds: meta.correlationIds, + userId: owner.id, + }); + expect(JSON.stringify(blockEvents()[0].payload)).not.toContain('failed on purpose'); }); diff --git a/packages/cli/test/integration/workflows/workflow.service.test.ts b/packages/cli/test/integration/workflows/workflow.service.test.ts index d075be1e03d..b8c6dcf2096 100644 --- a/packages/cli/test/integration/workflows/workflow.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.test.ts @@ -372,14 +372,17 @@ describe('activateWorkflow()', () => { const updatedWorkflow = await workflowService.activateWorkflow(owner, workflow.id); - expect(enforceSpy).toHaveBeenCalledExactlyOnceWith({ - workflow: { - id: workflow.id, - name: workflow.name, - nodes: expect.any(Array), + expect(enforceSpy).toHaveBeenCalledExactlyOnceWith( + { + workflow: { + id: workflow.id, + name: workflow.name, + nodes: expect.any(Array), + }, + projectId: expect.any(String), }, - projectId: expect.any(String), - }); + { kind: 'user', user: expect.objectContaining({ id: owner.id }) }, + ); expect(updatedWorkflow.activeVersionId).toBe(workflow.versionId); }); diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index 9429e218caa..df4a46841b7 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -754,7 +754,7 @@ describe('GET /workflows/:workflowId', () => { { id: savedCredential.id, name: savedCredential.name, - currentUserHasAccess: true, + currentUserCanUse: true, }, ]); @@ -765,7 +765,7 @@ describe('GET /workflows/:workflowId', () => { ['owner', () => owner], ['admin', () => admin], ])( - 'should return workflow with credentials saying %s does have access even when not shared', + 'should return workflow with credentials saying %s can use them even when not shared', async (_description, getActor) => { const actor = getActor(); const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); @@ -786,7 +786,7 @@ describe('GET /workflows/:workflowId', () => { { id: savedCredential.id, name: savedCredential.name, - currentUserHasAccess: true, + currentUserCanUse: true, }, ]); @@ -794,7 +794,7 @@ describe('GET /workflows/:workflowId', () => { }, ); - test('should return workflow with credentials for all users with or without access', async () => { + test('should return workflow with credentials for all users whether or not they can use them', async () => { const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); const workflowPayload = makeWorkflow({ @@ -811,7 +811,7 @@ describe('GET /workflows/:workflowId', () => { { id: savedCredential.id, name: savedCredential.name, - currentUserHasAccess: true, // one user has access + currentUserCanUse: true, // one user can use it }, ]); expect(member1Workflow.sharedWithProjects).toHaveLength(1); @@ -825,15 +825,15 @@ describe('GET /workflows/:workflowId', () => { { id: savedCredential.id, name: savedCredential.name, - currentUserHasAccess: false, // the other one doesn't + currentUserCanUse: false, // the other one can't }, ]); expect(member2Workflow.sharedWithProjects).toHaveLength(1); }); - test('should return workflow with credentials for all users with access', async () => { + test('should return workflow with credentials for all users who can use them', async () => { const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); - // Both users have access to the credential (none is owner) + // Both users can use the credential (none is owner) await shareCredentialWithUsers(savedCredential, [anotherMember]); const workflowPayload = makeWorkflow({ @@ -850,7 +850,7 @@ describe('GET /workflows/:workflowId', () => { { id: savedCredential.id, name: savedCredential.name, - currentUserHasAccess: true, + currentUserCanUse: true, }, ]); expect(member1Workflow.sharedWithProjects).toHaveLength(1); @@ -865,7 +865,7 @@ describe('GET /workflows/:workflowId', () => { { id: savedCredential.id, name: savedCredential.name, - currentUserHasAccess: true, + currentUserCanUse: true, }, ]); expect(member2Workflow.sharedWithProjects).toHaveLength(1); @@ -873,7 +873,7 @@ describe('GET /workflows/:workflowId', () => { test('should return workflow credentials home project and shared with projects', async () => { const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); - // Both users have access to the credential (none is owner) + // Both users can use the credential (none is owner) await shareCredentialWithUsers(savedCredential, [anotherMember]); const workflowPayload = makeWorkflow({ @@ -890,7 +890,7 @@ describe('GET /workflows/:workflowId', () => { { id: savedCredential.id, name: savedCredential.name, - currentUserHasAccess: true, + currentUserCanUse: true, homeProject: { id: memberPersonalProject.id, }, @@ -899,6 +899,60 @@ describe('GET /workflows/:workflowId', () => { ]); expect(member1Workflow.sharedWithProjects).toHaveLength(1); }); + + describe('with N8N_ENV_FEAT_CRED_SHARING enabled', () => { + beforeEach(() => { + process.env.N8N_ENV_FEAT_CRED_SHARING = 'true'; + }); + + afterEach(() => { + delete process.env.N8N_ENV_FEAT_CRED_SHARING; + }); + + test('reports currentUserCanUse from the identity-based check', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + const workflowPayload = makeWorkflow({ + withPinData: false, + withCredential: { id: savedCredential.id, name: savedCredential.name }, + }); + const workflow = await createWorkflow(workflowPayload, owner); + + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200); + const responseWorkflow: WorkflowWithSharingsMetaDataAndCredentials = response.body.data; + + expect(responseWorkflow.usedCredentials).toMatchObject([ + { + id: savedCredential.id, + name: savedCredential.name, + currentUserCanUse: true, + }, + ]); + }); + + test('describes a referenced credential the user cannot use, unconditionally', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + + const workflowPayload = makeWorkflow({ + withPinData: false, + withCredential: { id: savedCredential.id, name: savedCredential.name }, + }); + const workflow = await createWorkflow(workflowPayload, member); + await shareWorkflowWithUsers(workflow, [anotherMember]); + + const response = await authAnotherMemberAgent.get(`/workflows/${workflow.id}`).expect(200); + const responseWorkflow: WorkflowWithSharingsMetaDataAndCredentials = response.body.data; + + // Visible regardless of access: name, type and owner are unconditional. + expect(responseWorkflow.usedCredentials).toMatchObject([ + { + id: savedCredential.id, + name: savedCredential.name, + currentUserCanUse: false, + }, + ]); + }); + }); }); describe('POST /workflows', () => { @@ -1805,7 +1859,9 @@ describe('PUT /:workflowId/transfer', () => { expect(response.body).toEqual({}); expect(activeWorkflowManager.remove).toHaveBeenCalledWith(workflow.id); - expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update'); + expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update', undefined, { + actor: { kind: 'user', user: { id: member.id } }, + }); }); test('should move workflow to project root if `destinationParentFolderId` is not provided', async () => { @@ -1935,7 +1991,9 @@ describe('PUT /:workflowId/transfer', () => { }); expect(activeWorkflowManager.remove).toHaveBeenCalledWith(workflow.id); - expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update'); + expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update', undefined, { + actor: { kind: 'user', user: { id: member.id } }, + }); const workflowFromDB = await workflowRepository.findOneByOrFail({ id: workflow.id }); expect(workflowFromDB.active).toBe(false); diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 3f7960e355a..0fa9c344035 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -1,3 +1,4 @@ +import { EventService } from '@n8n/backend-services'; import { createTeamProject, getPersonalProject, @@ -43,7 +44,6 @@ import { v4 as uuid } from 'uuid'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { CollaborationService } from '@/collaboration/collaboration.service'; -import { EventService } from '@/events/event.service'; import { EngineDataPlaneProxyService } from '@/services/engine-data-plane-proxy.service'; import { InstanceWriteAccessService } from '@/services/instance-write-access.service'; import { ProjectService } from '@/services/project.service.ee'; @@ -3914,7 +3914,9 @@ describe('PATCH /workflows/:workflowId', () => { expect(response.statusCode).toBe(200); expect(activeWorkflowManagerLike.remove).toHaveBeenCalledWith(workflow.id); - expect(activeWorkflowManagerLike.add).toHaveBeenCalledWith(workflow.id, 'update'); + expect(activeWorkflowManagerLike.add).toHaveBeenCalledWith(workflow.id, 'update', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); }); test('should not reactivate when settings unchanged', async () => { @@ -4287,7 +4289,9 @@ describe('POST /workflows/:workflowId/activate', () => { .send({ versionId: newVersionId }); expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); + expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); const { data } = response.body; expect(data.id).toBe(workflow.id); @@ -4422,7 +4426,9 @@ describe('POST /workflows/:workflowId/activate', () => { .send({ versionId: workflow.versionId, name: newVersionName }); expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); + expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); const { data } = response.body; expect(data.activeVersionId).toBe(workflow.versionId); @@ -4443,7 +4449,9 @@ describe('POST /workflows/:workflowId/activate', () => { .send({ versionId: workflow.versionId, description: newDescription }); expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); + expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); const { data } = response.body; expect(data.activeVersionId).toBe(workflow.versionId); @@ -4467,7 +4475,9 @@ describe('POST /workflows/:workflowId/activate', () => { }); expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); + expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); const { data } = response.body; expect(data.activeVersionId).toBe(workflow.versionId); @@ -4592,7 +4602,9 @@ describe('POST /workflows/:workflowId/activate', () => { // First remove active version expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id); - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'update'); + expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'update', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); }); test('should call active workflow manager with activate mode if workflow is not active', async () => { @@ -4604,7 +4616,9 @@ describe('POST /workflows/:workflowId/activate', () => { .send({ versionId: workflow.versionId }); expect(activeWorkflowManagerLike.remove).not.toBeCalledWith(workflow.id); - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); + expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); expect(addRecordSpy).toBeCalledWith({ event: 'activated', userId: owner.id, diff --git a/packages/cli/test/migration/1790605116208-add-agent-queue-message-references.test.ts b/packages/cli/test/migration/1790605116208-add-agent-queue-message-references.test.ts new file mode 100644 index 00000000000..862c2018692 --- /dev/null +++ b/packages/cli/test/migration/1790605116208-add-agent-queue-message-references.test.ts @@ -0,0 +1,413 @@ +import { + createTestMigrationContext, + initDbUpToMigration, + runSingleMigration, + type TestMigrationContext, +} from '@n8n/backend-test-utils'; +import { DbConnection } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; +import { jsonParse, UserError } from 'n8n-workflow'; +import { randomUUID } from 'node:crypto'; + +const MIGRATION_NAME = 'AddAgentQueueMessageReferences1790605116208'; +const createdAt = new Date('2026-05-12T10:00:00.123Z'); +const updatedAt = new Date('2026-05-12T10:01:00.456Z'); +const timestamps = { createdAt, updatedAt }; +const previewPayload = { + kind: 'preview', + message: 'Pending input', + resourceId: 'preview-resource', + userId: 'owner', +}; + +function readJson(value: unknown): unknown { + return typeof value === 'string' ? jsonParse(value) : value; +} + +describe('AddAgentQueueMessageReferences migration', () => { + let dataSource: DataSource; + let context: TestMigrationContext; + let projectId: string; + let agentId: string; + + beforeAll(async () => { + await Container.get(DbConnection).init(); + dataSource = Container.get(DataSource); + }); + + beforeEach(async () => { + const setup = createTestMigrationContext(dataSource); + await setup.queryRunner.clearDatabase(); + await setup.queryRunner.release(); + await initDbUpToMigration(MIGRATION_NAME); + context = createTestMigrationContext(dataSource); + projectId = randomUUID(); + agentId = randomUUID(); + await insert('project', { id: projectId, name: 'Test project', type: 'team', ...timestamps }); + await insert('agents', { + id: agentId, + name: 'Test agent', + projectId, + integrations: '[]', + tools: '{}', + skills: '{}', + ...timestamps, + }); + }); + + afterEach(async () => { + await context?.queryRunner.release(); + }); + + afterAll(async () => { + await Container.get(DbConnection).close(); + }); + + async function insert(table: string, values: Record) { + const keys = Object.keys(values); + await context.runQuery( + `INSERT INTO ${context.escape.tableName(table)} (${keys.map(context.escape.columnName).join(', ')}) VALUES (${keys.map((key) => ':' + key).join(', ')})`, + values, + ); + } + + async function rows(table: string) { + return await context.runQuery>>( + `SELECT * FROM ${context.escape.tableName(table)} ORDER BY ${context.escape.columnName('id')}`, + ); + } + + async function createThread() { + const threadId = randomUUID(); + await insert('agent_execution_threads', { + id: threadId, + agentId, + agentName: 'Test agent', + projectId, + accessScope: 'project', + ...timestamps, + }); + return threadId; + } + + async function migrate() { + await context.queryRunner.release(); + try { + await runSingleMigration(MIGRATION_NAME); + } finally { + context = createTestMigrationContext(dataSource); + } + } + + it('backfills queued input, preserves started input, and keeps constraints through rollback', async () => { + const previewThread = await createThread(); + const integrationThread = await createThread(); + const activeThread = await createThread(); + const executionId = randomUUID(); + const messageId = randomUUID(); + const attachment = { + id: 'attachment', + fileName: 'image.png', + mimeType: 'image/png', + sizeBytes: 3, + }; + const filePart = { + type: 'file', + mediaType: 'image/png', + fileRef: { id: 'attachment', fileName: 'image.png', sizeBytes: 3 }, + }; + const author = { userId: 'sender', displayName: 'Sender' }; + const messageContext = { + platform: 'slack', + integrationConnectionId: 'connection', + messageId: 'platform-message', + target: { type: 'thread', threadId: 'platform-thread' }, + replyMessageId: 'reply-message', + replyExpectation: 'optional', + updatedAt: createdAt.toISOString(), + }; + const integrationPayload = { + kind: 'integration', + message: 'Original input', + resourceId: 'integration-resource', + modelMessage: 'Enriched input', + attachments: [attachment], + author, + credentialId: 'credential', + platformThreadId: 'platform-thread', + sender: { userId: 'sender', isMe: false }, + messageContext, + contextConversation: { threadId: integrationThread }, + forceBuffered: false, + slackThreadContext: { channelId: 'channel', threadTs: '123.456' }, + }; + await insert('agent_message_queue', { + threadId: previewThread, + source: 'chat', + payload: JSON.stringify({ ...previewPayload, message: '', attachments: [attachment] }), + ...timestamps, + }); + await insert('agent_message_queue', { + threadId: integrationThread, + source: 'slack', + payload: JSON.stringify(integrationPayload), + ...timestamps, + }); + await insert('agents_resources', { id: 'active-resource', ...timestamps }); + await insert('agents_threads', { + id: activeThread, + resourceId: 'active-resource', + ...timestamps, + }); + await insert('agents_messages', { + id: messageId, + threadId: activeThread, + resourceId: 'active-resource', + role: 'user', + content: JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'Started input' }] }), + modelContent: JSON.stringify({ + role: 'user', + content: [{ type: 'text', text: 'Runtime enrichment' }], + }), + modelContextAt: updatedAt, + origin: '{"source":"chat"}', + ...timestamps, + }); + await insert('agent_execution', { + id: executionId, + threadId: activeThread, + status: 'success', + hitlStatus: 'suspended', + ...timestamps, + }); + await insert('agent_execution_message_links', { + executionId, + messageId, + direction: 'input', + position: 0, + createdAt, + }); + await insert('agent_checkpoints', { + runId: 'suspended-run', + agentId, + threadId: activeThread, + state: JSON.stringify({ + status: 'suspended', + persistence: { threadId: activeThread, hostMetadata: { n8nExecutionId: executionId } }, + messages: [{ id: messageId }], + }), + ...timestamps, + }); + await insert('agent_message_queue', { + threadId: activeThread, + executionId, + source: 'chat', + payload: JSON.stringify({ + ...previewPayload, + resourceId: 'active-resource', + message: 'Started input', + }), + ...timestamps, + }); + // Cross a batch boundary while earlier queue rows are updated in place. + for (let index = 0; index < 101; index++) { + await insert('agent_message_queue', { + threadId: previewThread, + source: 'chat', + payload: JSON.stringify({ ...previewPayload, message: `Pending ${index}` }), + ...timestamps, + }); + } + await insert('agent_message_queue', { + threadId: previewThread, + source: 'chat', + payload: JSON.stringify(previewPayload), + ...timestamps, + }); + const originalQueue = await rows('agent_message_queue'); + const deletedId = originalQueue.pop()!.id; + await context.runQuery( + `DELETE FROM ${context.escape.tableName('agent_message_queue')} WHERE ${context.escape.columnName('id')} = :id`, + { id: deletedId }, + ); + const [originalMessage] = await rows('agents_messages'); + const snapshots = new Map(); + for (const table of [ + 'agent_execution_threads', + 'agent_execution', + 'agent_checkpoints', + 'agent_execution_message_links', + ]) { + snapshots.set( + table, + await context.runQuery(`SELECT * FROM ${context.escape.tableName(table)}`), + ); + } + + await migrate(); + + const queue = await rows('agent_message_queue'); + const messages = await rows('agents_messages'); + expect(messages).toHaveLength(originalQueue.length); + expect(queue).toHaveLength(originalQueue.length); + for (const [index, item] of queue.entries()) { + const { source: _source, payload: _payload, ...original } = originalQueue[index]; + expect(item).toMatchObject(original); + expect(item).not.toHaveProperty('source'); + expect(item.messageId).toEqual(expect.any(String)); + } + const preview = messages.find(({ id }) => id === queue[0].messageId)!; + expect(preview).toMatchObject({ + threadId: previewThread, + resourceId: 'preview-resource', + role: 'user', + type: null, + author: null, + modelContent: null, + modelContextAt: null, + createdAt: queue[0].createdAt, + updatedAt: queue[0].updatedAt, + }); + expect(readJson(preview.content)).toEqual({ role: 'user', content: [filePart] }); + expect(readJson(preview.origin)).toEqual({ source: 'chat' }); + expect(readJson(queue[0].payload)).toEqual({ kind: 'preview' }); + const integration = messages.find(({ id }) => id === queue[1].messageId)!; + expect(integration).toMatchObject({ + threadId: integrationThread, + resourceId: 'integration-resource', + modelContextAt: null, + }); + expect(readJson(integration.content)).toEqual({ + role: 'user', + content: [{ type: 'text', text: 'Original input' }, filePart], + }); + expect(readJson(integration.modelContent)).toEqual({ + role: 'user', + content: [{ type: 'text', text: 'Enriched input' }, filePart], + }); + expect(readJson(integration.author)).toEqual(author); + expect(readJson(integration.origin)).toEqual({ + source: 'slack', + integrationConnectionId: 'connection', + platformMessageId: 'platform-message', + platformThreadId: 'platform-thread', + }); + expect(readJson(queue[1].payload)).toEqual({ + kind: 'integration', + credentialId: 'credential', + sender: integrationPayload.sender, + contextConversation: integrationPayload.contextConversation, + forceBuffered: false, + slackThreadContext: integrationPayload.slackThreadContext, + messageContext: { + target: messageContext.target, + replyMessageId: 'reply-message', + replyExpectation: 'optional', + updatedAt: messageContext.updatedAt, + }, + }); + expect(queue[2].messageId).toBe(messageId); + expect(messages.find(({ id }) => id === messageId)).toEqual(originalMessage); + const lastMessage = messages.find(({ id }) => id === queue.at(-1)!.messageId)!; + expect(readJson(lastMessage.content)).toEqual({ + role: 'user', + content: [{ type: 'text', text: 'Pending 100' }], + }); + expect(await rows('agents_resources')).toHaveLength(3); + expect(await rows('agents_threads')).toHaveLength(3); + for (const [table, snapshot] of snapshots) { + expect(await context.runQuery(`SELECT * FROM ${context.escape.tableName(table)}`)).toEqual( + snapshot, + ); + } + + const queueInput = { + threadId: previewThread, + messageId: preview.id as string, + payload: '{"kind":"preview"}', + }; + await expect( + insert('agent_message_queue', { ...queueInput, messageId: null }), + ).rejects.toThrow(); + await expect( + insert('agent_message_queue', { ...queueInput, messageId: randomUUID() }), + ).rejects.toThrow(); + await expect(insert('agent_message_queue', queueInput)).rejects.toThrow(); + await context.queryRunner.release(); + await expect(dataSource.undoLastMigration({ transaction: 'each' })).rejects.toThrow(UserError); + context = createTestMigrationContext(dataSource); + expect(await rows('agent_message_queue')).toEqual(queue); + await context.runQuery( + `DELETE FROM ${context.escape.tableName('agents_messages')} WHERE ${context.escape.columnName('id')} = :id`, + { id: preview.id }, + ); + expect(await rows('agent_message_queue')).toHaveLength(queue.length - 1); + await context.runQuery(`DELETE FROM ${context.escape.tableName('agent_message_queue')}`); + await insert('agent_message_queue', { ...queueInput, messageId: lastMessage.id as string }); + const [newItem] = await rows('agent_message_queue'); + expect(BigInt(String(newItem.id))).toBeGreaterThan(BigInt(String(deletedId))); + await context.runQuery(`DELETE FROM ${context.escape.tableName('agent_message_queue')}`); + const retainedMessages = await rows('agents_messages'); + await context.queryRunner.release(); + await dataSource.undoLastMigration({ transaction: 'each' }); + context = createTestMigrationContext(dataSource); + expect(await rows('agents_messages')).toEqual(retainedMessages); + const reverted = await context.queryRunner.getTable( + `${context.tablePrefix}agent_message_queue`, + ); + expect(reverted?.findColumnByName('source')).toMatchObject({ length: '32', isNullable: false }); + await insert('agent_message_queue', { + threadId: previewThread, + source: 'chat', + payload: JSON.stringify(previewPayload), + }); + const [revertedItem] = await rows('agent_message_queue'); + expect(BigInt(String(revertedItem.id))).toBeGreaterThan(BigInt(String(newItem.id))); + await context.runQuery(`DELETE FROM ${context.escape.tableName('agent_message_queue')}`); + await migrate(); + expect(await rows('agents_messages')).toEqual(retainedMessages); + await insert('agent_message_queue', { ...queueInput, messageId: lastMessage.id as string }); + const [reappliedItem] = await rows('agent_message_queue'); + expect(BigInt(String(reappliedItem.id))).toBeGreaterThan(BigInt(String(revertedItem.id))); + }); + + it.each(['invalid payload', 'missing input link'])( + 'preserves queue data when an item has %s', + async (problem) => { + const threadId = await createThread(); + const executionId = randomUUID(); + await insert('agent_execution', { + id: executionId, + threadId, + status: 'running', + ...timestamps, + }); + await insert('agent_message_queue', { + threadId, + source: 'chat', + payload: JSON.stringify(previewPayload), + ...timestamps, + }); + await insert('agent_message_queue', { + threadId, + source: 'chat', + executionId: problem === 'missing input link' ? executionId : null, + payload: problem === 'invalid payload' ? 'null' : JSON.stringify(previewPayload), + ...timestamps, + }); + const original = await rows('agent_message_queue'); + await expect(migrate()).rejects.toThrow(UserError); + expect(await rows('agent_message_queue')).toEqual(original); + expect(await rows('agents_messages')).toEqual([]); + expect(await rows('agents_threads')).toEqual([]); + expect(await rows('agents_resources')).toEqual([]); + expect( + await context.queryRunner.hasColumn( + `${context.tablePrefix}agent_message_queue`, + 'messageId', + ), + ).toBe(false); + }, + ); +}); diff --git a/packages/cli/test/migration/1790672501346-create-trusted-source-tables.test.ts b/packages/cli/test/migration/1790672501346-create-trusted-source-tables.test.ts new file mode 100644 index 00000000000..fe8eab9661c --- /dev/null +++ b/packages/cli/test/migration/1790672501346-create-trusted-source-tables.test.ts @@ -0,0 +1,267 @@ +import { + createTestMigrationContext, + initDbUpToMigration, + runSingleMigration, + undoLastSingleMigration, + type TestMigrationContext, +} from '@n8n/backend-test-utils'; +import { DbConnection } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; +import { randomUUID } from 'node:crypto'; + +const MIGRATION_NAME = 'CreateTrustedSourceTables1790672501346'; + +const SOURCE_TABLE = 'trusted_source'; +const IDENTITY_TABLE = 'trusted_source_identity'; +const USER_TABLE = 'user'; + +describe('CreateTrustedSourceTables migration', () => { + let dataSource: DataSource; + + async function withContext(fn: (context: TestMigrationContext) => Promise): Promise { + const context = createTestMigrationContext(dataSource); + try { + return await fn(context); + } finally { + await context.queryRunner.release(); + } + } + + async function insertUser({ escape, runQuery }: TestMigrationContext, id: string) { + await runQuery( + `INSERT INTO ${escape.tableName(USER_TABLE)} (${['id', 'email', 'roleSlug'].map(escape.columnName).join(', ')}) + VALUES (:id, :email, :roleSlug)`, + { id, email: `${id}@example.com`, roleSlug: 'global:owner' }, + ); + } + + async function insertSource( + { escape, runQuery }: TestMigrationContext, + id: string, + overrides: { name?: string; issuer?: string } = {}, + ) { + const now = new Date(); + await runQuery( + `INSERT INTO ${escape.tableName(SOURCE_TABLE)} + (${['id', 'name', 'type', 'issuer', 'managedBy', 'status', 'configVersion', 'config', 'createdAt', 'updatedAt'].map(escape.columnName).join(', ')}) + VALUES (:id, :name, :type, :issuer, :managedBy, :status, :configVersion, :config, :createdAt, :updatedAt)`, + { + id, + name: overrides.name ?? `source-${id}`, + type: 'oauth2', + issuer: overrides.issuer ?? `https://issuer.example.com/${id}`, + managedBy: 'admin', + status: 'unchecked', + configVersion: 1, + config: 'encrypted', + createdAt: now, + updatedAt: now, + }, + ); + } + + async function insertIdentity( + { escape, runQuery }: TestMigrationContext, + sourceId: string, + subject: string, + userId: string, + ) { + const now = new Date(); + await runQuery( + `INSERT INTO ${escape.tableName(IDENTITY_TABLE)} + (${['sourceId', 'subject', 'userId', 'provenance', 'status', 'createdAt', 'updatedAt'].map(escape.columnName).join(', ')}) + VALUES (:sourceId, :subject, :userId, :provenance, :status, :createdAt, :updatedAt)`, + { + sourceId, + subject, + userId, + provenance: 'admin', + status: 'active', + createdAt: now, + updatedAt: now, + }, + ); + } + + async function countRows( + { escape, runQuery }: TestMigrationContext, + table: string, + column: string, + value: string, + ) { + const rows = await runQuery>( + `SELECT COUNT(*) AS ${escape.columnName('c')} FROM ${escape.tableName(table)} WHERE ${escape.columnName(column)} = :value`, + { value }, + ); + return Number(rows[0].c); + } + + async function getTable(context: TestMigrationContext, table: string) { + return await context.queryRunner.getTable(`${context.tablePrefix}${table}`); + } + + beforeAll(async () => { + await Container.get(DbConnection).init(); + dataSource = Container.get(DataSource); + await withContext(async (context) => await context.queryRunner.clearDatabase()); + await initDbUpToMigration(MIGRATION_NAME); + await runSingleMigration(MIGRATION_NAME); + }); + + afterAll(async () => { + await Container.get(DbConnection).close(); + }); + + describe('Up migration', () => { + it('creates the trusted_source table with the expected columns and indexes', async () => { + await withContext(async (context) => { + const table = await getTable(context, SOURCE_TABLE); + expect(table).toBeDefined(); + + for (const column of [ + 'id', + 'name', + 'type', + 'issuer', + 'managedBy', + 'status', + 'configVersion', + 'config', + 'createdAt', + 'updatedAt', + ]) { + expect(table?.findColumnByName(column), column).toMatchObject({ isNullable: false }); + } + for (const column of ['lastError', 'lastCheckedAt']) { + expect(table?.findColumnByName(column), column).toMatchObject({ isNullable: true }); + } + + const uniqueIndexColumns = table?.indices + .filter((index) => index.isUnique) + .map((index) => index.columnNames) + .sort(); + expect(uniqueIndexColumns).toEqual([['issuer'], ['name']]); + }); + }); + + it('creates the trusted_source_identity table with the expected columns and a non-unique userId index', async () => { + await withContext(async (context) => { + const table = await getTable(context, IDENTITY_TABLE); + expect(table).toBeDefined(); + + for (const column of [ + 'sourceId', + 'subject', + 'userId', + 'provenance', + 'status', + 'createdAt', + 'updatedAt', + ]) { + expect(table?.findColumnByName(column), column).toMatchObject({ isNullable: false }); + } + expect(table?.findColumnByName('lastSeenAt')).toMatchObject({ isNullable: true }); + + const userIdIndex = table?.indices.find( + (index) => index.columnNames.length === 1 && index.columnNames[0] === 'userId', + ); + expect(userIdIndex).toMatchObject({ isUnique: false }); + }); + }); + + it('rejects a second source with the same issuer or the same name', async () => { + await withContext(async (context) => { + const issuer = `https://issuer.example.com/${randomUUID()}`; + const name = `name-${randomUUID()}`; + await insertSource(context, randomUUID(), { issuer, name }); + + await expect(insertSource(context, randomUUID(), { issuer })).rejects.toThrow(); + await expect(insertSource(context, randomUUID(), { name })).rejects.toThrow(); + + const otherId = randomUUID(); + await insertSource(context, otherId); + expect(await countRows(context, SOURCE_TABLE, 'id', otherId)).toBe(1); + }); + }); + + it('keys identities by source and subject', async () => { + await withContext(async (context) => { + const userId = randomUUID(); + const firstSourceId = randomUUID(); + const secondSourceId = randomUUID(); + await insertUser(context, userId); + await insertSource(context, firstSourceId); + await insertSource(context, secondSourceId); + await insertIdentity(context, firstSourceId, 'subject-1', userId); + + await expect(insertIdentity(context, firstSourceId, 'subject-1', userId)).rejects.toThrow(); + + await insertIdentity(context, secondSourceId, 'subject-1', userId); + expect(await countRows(context, IDENTITY_TABLE, 'userId', userId)).toBe(2); + }); + }); + + it('deletes the identities of a deleted source and keeps the user', async () => { + await withContext(async (context) => { + const userId = randomUUID(); + const sourceId = randomUUID(); + await insertUser(context, userId); + await insertSource(context, sourceId); + await insertIdentity(context, sourceId, 'subject-1', userId); + + await context.runQuery( + `DELETE FROM ${context.escape.tableName(SOURCE_TABLE)} WHERE ${context.escape.columnName('id')} = :id`, + { id: sourceId }, + ); + + expect(await countRows(context, IDENTITY_TABLE, 'sourceId', sourceId)).toBe(0); + expect(await countRows(context, USER_TABLE, 'id', userId)).toBe(1); + }); + }); + + it('deletes the identities of a deleted user and keeps the source', async () => { + await withContext(async (context) => { + const userId = randomUUID(); + const sourceId = randomUUID(); + await insertUser(context, userId); + await insertSource(context, sourceId); + await insertIdentity(context, sourceId, 'subject-1', userId); + + await context.runQuery( + `DELETE FROM ${context.escape.tableName(USER_TABLE)} WHERE ${context.escape.columnName('id')} = :id`, + { id: userId }, + ); + + expect(await countRows(context, IDENTITY_TABLE, 'userId', userId)).toBe(0); + expect(await countRows(context, SOURCE_TABLE, 'id', sourceId)).toBe(1); + }); + }); + }); + + describe('Down migration', () => { + it('drops both tables and can be applied again', async () => { + await undoLastSingleMigration(); + + await withContext(async (context) => { + expect(await context.queryRunner.hasTable(`${context.tablePrefix}${SOURCE_TABLE}`)).toBe( + false, + ); + expect(await context.queryRunner.hasTable(`${context.tablePrefix}${IDENTITY_TABLE}`)).toBe( + false, + ); + }); + + await runSingleMigration(MIGRATION_NAME); + + await withContext(async (context) => { + expect(await context.queryRunner.hasTable(`${context.tablePrefix}${SOURCE_TABLE}`)).toBe( + true, + ); + expect(await context.queryRunner.hasTable(`${context.tablePrefix}${IDENTITY_TABLE}`)).toBe( + true, + ); + }); + }); + }); +}); diff --git a/packages/cli/test/migration/1790683443934-add-agent-message-steering.test.ts b/packages/cli/test/migration/1790683443934-add-agent-message-steering.test.ts new file mode 100644 index 00000000000..e63ee5c71db --- /dev/null +++ b/packages/cli/test/migration/1790683443934-add-agent-message-steering.test.ts @@ -0,0 +1,166 @@ +import { + createTestMigrationContext, + initDbUpToMigration, + runSingleMigration, +} from '@n8n/backend-test-utils'; +import { DbConnection } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; +import { randomUUID } from 'node:crypto'; + +const MIGRATION = 'AddAgentMessageSteering1790683443934'; + +describe('AddAgentMessageSteering migration', () => { + let dataSource: DataSource; + const projectId = randomUUID(); + const agentId = randomUUID(); + const threadId = randomUUID(); + const executionId = randomUUID(); + const steeringExecutionId = randomUUID(); + const messageIds = [randomUUID(), randomUUID(), randomUUID()]; + + beforeAll(async () => { + await Container.get(DbConnection).init(); + dataSource = Container.get(DataSource); + const ctx = createTestMigrationContext(dataSource); + await ctx.queryRunner.clearDatabase(); + await ctx.queryRunner.release(); + await initDbUpToMigration(MIGRATION); + const seed = createTestMigrationContext(dataSource); + const values = { projectId, agentId, threadId, executionId, now: new Date() }; + try { + await seed.runQuery( + `INSERT INTO ${seed.escape.tableName('project')} ("id", "name", "type", "createdAt", "updatedAt") VALUES (:projectId, 'Project', 'team', :now, :now)`, + values, + ); + await seed.runQuery( + `INSERT INTO ${seed.escape.tableName('agents')} ("id", "name", "projectId", "integrations", "tools", "skills") VALUES (:agentId, 'Agent', :projectId, '[]', '{}', '{}')`, + values, + ); + await seed.runQuery( + `INSERT INTO ${seed.escape.tableName('agent_execution_threads')} ("id", "agentId", "agentName", "projectId") VALUES (:threadId, :agentId, 'Agent', :projectId)`, + values, + ); + await seed.runQuery( + `INSERT INTO ${seed.escape.tableName('agent_execution')} ("id", "threadId", "status") VALUES (:executionId, :threadId, 'running')`, + values, + ); + await seed.runQuery( + `INSERT INTO ${seed.escape.tableName('agent_execution')} ("id", "threadId", "status") VALUES (:steeringExecutionId, :threadId, 'running')`, + { steeringExecutionId, threadId }, + ); + await seed.runQuery( + `INSERT INTO ${seed.escape.tableName('agents_resources')} ("id") VALUES (:threadId)`, + values, + ); + await seed.runQuery( + `INSERT INTO ${seed.escape.tableName('agents_threads')} ("id", "resourceId") VALUES (:threadId, :threadId)`, + values, + ); + for (const id of messageIds) { + await seed.runQuery( + `INSERT INTO ${seed.escape.tableName('agents_messages')} ("id", "threadId", "resourceId", "role", "content") VALUES (:id, :threadId, :threadId, 'user', :content)`, + { + id, + threadId, + content: JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'Input' }] }), + }, + ); + } + await seed.runQuery( + `INSERT INTO ${seed.escape.tableName('agent_execution_message_links')} ("executionId", "messageId", "direction", "position") VALUES (:executionId, :messageId, 'input', 0)`, + { executionId, messageId: messageIds[0] }, + ); + await seed.runQuery( + `INSERT INTO ${seed.escape.tableName('agent_message_queue')} ("threadId", "messageId", "payload", "executionId") VALUES (:threadId, :messageId, '{"kind":"preview"}', :executionId)`, + { ...values, messageId: messageIds[0] }, + ); + await seed.runQuery( + `INSERT INTO ${seed.escape.tableName('agent_message_queue')} ("threadId", "messageId", "payload") VALUES (:threadId, :messageId, '{"kind":"preview"}')`, + { ...values, messageId: messageIds[1] }, + ); + // Retired IDs must stay retired when SQLite rebuilds the table. + await seed.runQuery( + `DELETE FROM ${seed.escape.tableName('agent_message_queue')} WHERE "executionId" IS NULL`, + ); + } finally { + await seed.queryRunner.release(); + } + }); + + afterAll(async () => { + await Container.get(DbConnection).close(); + }); + + it('keeps existing rows, foreign keys, and increasing queue IDs through up, down, and up', async () => { + await runSingleMigration(MIGRATION); + await dataSource.undoLastMigration({ transaction: 'none' }); + await runSingleMigration(MIGRATION); + const ctx = createTestMigrationContext(dataSource); + try { + const executions = await ctx.runQuery>( + `SELECT "acceptsSteering" FROM ${ctx.escape.tableName('agent_execution')} WHERE "id" = :executionId`, + { executionId }, + ); + expect(Boolean(executions[0].acceptsSteering)).toBe(false); + const rows = await ctx.runQuery< + Array<{ executionId: string; steeringExecutionId: string | null }> + >( + `SELECT "executionId", "steeringExecutionId" FROM ${ctx.escape.tableName('agent_message_queue')}`, + ); + expect(rows).toEqual([{ executionId, steeringExecutionId: null }]); + expect( + await ctx.runQuery( + `SELECT "messageId" FROM ${ctx.escape.tableName('agent_execution_message_links')}`, + ), + ).toEqual([{ messageId: messageIds[0] }]); + expect( + await ctx.runQuery(`SELECT "id" FROM ${ctx.escape.tableName('agents_messages')}`), + ).toHaveLength(3); + await expect( + ctx.runQuery( + `INSERT INTO ${ctx.escape.tableName('agent_message_queue')} ("threadId", "messageId", "payload") VALUES (:threadId, :messageId, '{"kind":"preview"}')`, + { threadId, messageId: messageIds[0] }, + ), + ).rejects.toThrow(); + await ctx.runQuery( + `INSERT INTO ${ctx.escape.tableName('agent_message_queue')} ("threadId", "messageId", "payload", "steeringExecutionId", "steeringOrder") VALUES (:threadId, :messageId, '{"kind":"preview"}', :steeringExecutionId, 1)`, + { threadId, steeringExecutionId, messageId: messageIds[1] }, + ); + const ids = await ctx.runQuery>( + `SELECT "id" FROM ${ctx.escape.tableName('agent_message_queue')} ORDER BY "id"`, + ); + expect(Number(ids[1].id)).toBeGreaterThan(2); + await expect( + ctx.runQuery( + `INSERT INTO ${ctx.escape.tableName('agent_message_queue')} ("threadId", "messageId", "payload", "steeringExecutionId", "steeringOrder") VALUES (:threadId, :messageId, '{"kind":"preview"}', :steeringExecutionId, 1)`, + { threadId, steeringExecutionId, messageId: messageIds[2] }, + ), + ).rejects.toThrow(); + await expect( + ctx.runQuery( + `DELETE FROM ${ctx.escape.tableName('agent_execution')} WHERE "id" = :steeringExecutionId`, + { steeringExecutionId }, + ), + ).rejects.toThrow(); + await ctx.runQuery( + `DELETE FROM ${ctx.escape.tableName('agents_messages')} WHERE "id" = :messageId`, + { messageId: messageIds[0] }, + ); + expect( + await ctx.runQuery( + `SELECT "messageId" FROM ${ctx.escape.tableName('agent_message_queue')}`, + ), + ).toEqual([{ messageId: messageIds[1] }]); + await ctx.runQuery( + `DELETE FROM ${ctx.escape.tableName('agent_execution_threads')} WHERE "id" = :threadId`, + { threadId }, + ); + expect( + await ctx.runQuery(`SELECT "id" FROM ${ctx.escape.tableName('agent_message_queue')}`), + ).toEqual([]); + } finally { + await ctx.queryRunner.release(); + } + }); +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index e662c44fb55..44c65334f5d 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -37,7 +37,9 @@ { "path": "../@n8n/decorators/tsconfig.build.json" }, { "path": "../@n8n/db/tsconfig.build.json" }, { "path": "../@n8n/errors/tsconfig.build.json" }, + { "path": "../@n8n/inbound-auth/tsconfig.build.json" }, { "path": "../@n8n/backend-common/tsconfig.build.json" }, + { "path": "../@n8n/backend-services/tsconfig.build.json" }, { "path": "../@n8n/backend-test-utils/tsconfig.build.json" }, { "path": "../@n8n/blob-storage/tsconfig.build.json" }, { "path": "../@n8n/di/tsconfig.build.json" }, diff --git a/packages/core/eslint.config.mjs b/packages/core/eslint.config.mjs index 6d7a5fdf2ad..418631e76a2 100644 --- a/packages/core/eslint.config.mjs +++ b/packages/core/eslint.config.mjs @@ -9,17 +9,42 @@ export default defineConfig( // TODO: Lower the complexity threshold complexity: ['error', 27], 'n8n-local-rules/no-dynamic-regexp': 'error', - - // TODO: Remove these - 'no-prototype-builtins': 'warn', - 'no-ex-assign': 'warn', - 'no-useless-escape': 'warn', - '@typescript-eslint/no-require-imports': 'warn', - '@typescript-eslint/no-base-to-string': 'warn', - '@typescript-eslint/prefer-optional-chain': 'warn', - '@typescript-eslint/no-array-delete': 'warn', }, }, + { + files: [ + 'src/execution-engine/node-execution-context/execute-context.ts', + 'src/execution-engine/node-execution-context/execute-single-context.ts', + 'src/execution-engine/node-execution-context/supply-data-context.ts', + 'src/execution-engine/node-execution-context/utils/get-input-connection-data.ts', + 'src/nodes-loader/directory-loader.ts', + ], + rules: { 'no-prototype-builtins': 'warn' }, + }, + { + files: ['src/execution-engine/node-execution-context/utils/get-input-connection-data.ts'], + rules: { 'no-ex-assign': 'warn' }, + }, + { + files: [ + 'src/execution-engine/node-execution-context/utils/request-helpers/pagination.ts', + 'src/execution-engine/node-execution-context/utils/webhook-helper-functions.ts', + 'src/execution-engine/routing-node.ts', + ], + rules: { '@typescript-eslint/no-base-to-string': 'warn' }, + }, + { + files: ['src/nodes-loader/load-class-in-isolation.ts', 'test/helpers/index.ts'], + rules: { '@typescript-eslint/no-require-imports': 'warn' }, + }, + { + files: ['src/execution-engine/node-execution-context/__tests__/execute-single-context.test.ts'], + rules: { '@typescript-eslint/no-array-delete': 'warn' }, + }, + { + files: ['src/execution-engine/__tests__/routing-node.test.ts'], + rules: { '@typescript-eslint/prefer-optional-chain': 'warn' }, + }, { files: ['**/*.test.ts', '**/test/**/*.ts', '**/__test__/**/*.ts', '**/__tests__/**/*.ts'], rules: { diff --git a/packages/core/oxlint.config.mts b/packages/core/oxlint.config.mts new file mode 100644 index 00000000000..e13c48c6df1 --- /dev/null +++ b/packages/core/oxlint.config.mts @@ -0,0 +1,62 @@ +import { backendConfig } from '@n8n/oxlint-config/backend'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [backendConfig], + options: { typeAware: true }, + ignorePatterns: ['bin/*.js', 'nodes-testing/*.ts', 'nodes-testing/*.cjs', 'coverage/*'], + rules: { + complexity: ['error', 27], + 'n8n-local-rules/no-dynamic-regexp': 'error', + }, + overrides: [ + { + files: [ + 'src/execution-engine/node-execution-context/execute-context.ts', + 'src/execution-engine/node-execution-context/execute-single-context.ts', + 'src/execution-engine/node-execution-context/supply-data-context.ts', + 'src/execution-engine/node-execution-context/utils/get-input-connection-data.ts', + 'src/nodes-loader/directory-loader.ts', + ], + rules: { 'no-prototype-builtins': 'warn' }, + }, + { + files: ['src/execution-engine/node-execution-context/utils/get-input-connection-data.ts'], + rules: { 'no-ex-assign': 'warn' }, + }, + { + files: [ + 'src/execution-engine/node-execution-context/utils/request-helpers/pagination.ts', + 'src/execution-engine/node-execution-context/utils/webhook-helper-functions.ts', + 'src/execution-engine/routing-node.ts', + ], + rules: { 'typescript/no-base-to-string': 'warn' }, + }, + { + files: ['src/nodes-loader/load-class-in-isolation.ts', 'test/helpers/index.ts'], + rules: { 'typescript/no-require-imports': 'warn' }, + }, + { + files: [ + 'src/execution-engine/node-execution-context/__tests__/execute-single-context.test.ts', + ], + rules: { 'typescript/no-array-delete': 'warn' }, + }, + { + files: ['**/*.test.ts', '**/test/**/*.ts', '**/__test__/**/*.ts', '**/__tests__/**/*.ts'], + jsPlugins: ['@n8n/eslint-config/plugin'], + rules: { + 'prefer-const': 'warn', + 'import/no-duplicates': 'warn', + 'typescript/no-unsafe-assignment': 'warn', + 'typescript/no-unsafe-argument': 'warn', + 'typescript/no-unsafe-call': 'warn', + 'typescript/no-unsafe-return': 'warn', + 'typescript/unbound-method': 'warn', + 'no-unused-expressions': 'warn', + 'id-denylist': 'warn', + 'n8n-local-rules/no-dynamic-regexp': 'off', + }, + }, + ], +}); diff --git a/packages/core/package.json b/packages/core/package.json index 693d1b5d296..e0531689a61 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "2.41.0", + "version": "2.42.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", @@ -18,8 +18,8 @@ "dev": "pnpm watch", "format": "biome format --write .", "format:check": "biome ci .", - "lint": "eslint . --quiet", - "lint:fix": "eslint . --fix", + "lint": "oxlint --quiet", + "lint:fix": "oxlint --fix", "watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\"", "test": "vitest run", "test:unit": "vitest run", @@ -31,6 +31,8 @@ "bin" ], "devDependencies": { + "@n8n/eslint-config": "workspace:*", + "@n8n/oxlint-config": "workspace:*", "@n8n/playwright-janitor": "workspace:*", "@n8n/typescript-config": "workspace:*", "@n8n/typeorm": "workspace:*", @@ -47,6 +49,8 @@ "@vitest/coverage-v8": "catalog:", "axios": "catalog:", "nock": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", "reflect-metadata": "catalog:", "typescript": "catalog:typescript", "vite": "catalog:", diff --git a/packages/core/src/execution-engine/__tests__/routing-node.test.ts b/packages/core/src/execution-engine/__tests__/routing-node.test.ts index 5f85aa094a6..9fd166f481a 100644 --- a/packages/core/src/execution-engine/__tests__/routing-node.test.ts +++ b/packages/core/src/execution-engine/__tests__/routing-node.test.ts @@ -846,7 +846,7 @@ describe('RoutingNode', () => { // Prototype untouched; the value lands as an own property on the request body. expect(Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call')).toBe(false); expect(Object.prototype.toString.call([])).toBe('[object Array]'); - expect((result?.options.body as { toString?: { call?: unknown } }).toString?.call).toBe( + expect((result!.options.body as { toString?: { call?: unknown } }).toString?.call).toBe( 'x', ); }); @@ -2749,6 +2749,8 @@ describe('RoutingNode', () => { const routingNode = new RoutingNode(executeFunctions, nodeType, mockCredentials); return await routingNode.runNode(); }; + const getRequestOptions = (result: Awaited>) => + (result![0]![0]!.json as { requestOptions: IHttpRequestOptions }).requestOptions; test("propagates credential allowedDomains when mode is 'domains'", async () => { const result = await runWithCredential({ @@ -2757,8 +2759,7 @@ describe('RoutingNode', () => { allowedDomains: 'api.example.com', }); - const requestOptions = (result?.[0]?.[0]?.json as { requestOptions: IHttpRequestOptions }) - .requestOptions; + const requestOptions = getRequestOptions(result); expect(requestOptions.allowedDomains).toBe('api.example.com'); }); @@ -2768,8 +2769,7 @@ describe('RoutingNode', () => { allowedHttpRequestDomains: 'all', }); - const requestOptions = (result?.[0]?.[0]?.json as { requestOptions: IHttpRequestOptions }) - .requestOptions; + const requestOptions = getRequestOptions(result); expect(requestOptions.allowedDomains).toBeUndefined(); }); @@ -2780,8 +2780,7 @@ describe('RoutingNode', () => { allowedDomains: 'other.example.com', }); - const requestOptions = (result?.[0]?.[0]?.json as { requestOptions: IHttpRequestOptions }) - .requestOptions; + const requestOptions = getRequestOptions(result); expect(requestOptions.allowedDomains).toBe('api.example.com, other.example.com'); }); @@ -2799,8 +2798,7 @@ describe('RoutingNode', () => { { baseURL, routedUrl: 'https://user-chosen.example.net/path' }, ); - const requestOptions = (result?.[0]?.[0]?.json as { requestOptions: IHttpRequestOptions }) - .requestOptions; + const requestOptions = getRequestOptions(result); expect(requestOptions.allowedDomains).toBe('other.example.com'); }); @@ -2810,8 +2808,7 @@ describe('RoutingNode', () => { allowedHttpRequestDomains: 'none', }); - const requestOptions = (result?.[0]?.[0]?.json as { requestOptions: IHttpRequestOptions }) - .requestOptions; + const requestOptions = getRequestOptions(result); expect(requestOptions.allowedDomains).toBeUndefined(); }); @@ -2822,8 +2819,7 @@ describe('RoutingNode', () => { allowedDomains: ' ', }); - const requestOptions = (result?.[0]?.[0]?.json as { requestOptions: IHttpRequestOptions }) - .requestOptions; + const requestOptions = getRequestOptions(result); expect(requestOptions.allowedDomains).toBe('api.example.com'); }); @@ -2833,16 +2829,14 @@ describe('RoutingNode', () => { allowedHttpRequestDomains: 'domains', }); - const requestOptions = (result?.[0]?.[0]?.json as { requestOptions: IHttpRequestOptions }) - .requestOptions; + const requestOptions = getRequestOptions(result); expect(requestOptions.allowedDomains).toBe('api.example.com'); }); test('does not set allowedDomains when restriction field is absent', async () => { const result = await runWithCredential({ apiKey: 'testApiKey' }); - const requestOptions = (result?.[0]?.[0]?.json as { requestOptions: IHttpRequestOptions }) - .requestOptions; + const requestOptions = getRequestOptions(result); expect(requestOptions.allowedDomains).toBeUndefined(); }); @@ -2856,8 +2850,7 @@ describe('RoutingNode', () => { }, ); - const requestOptions = (result?.[0]?.[0]?.json as { requestOptions: IHttpRequestOptions }) - .requestOptions; + const requestOptions = getRequestOptions(result); expect(requestOptions.url).toBe('/tests/project-123'); }); @@ -2880,8 +2873,7 @@ describe('RoutingNode', () => { }, ); - const requestOptions = (result?.[0]?.[0]?.json as { requestOptions: IHttpRequestOptions }) - .requestOptions; + const requestOptions = getRequestOptions(result); expect(requestOptions.allowedDomains).toBe('api.example.com'); }); @@ -2903,8 +2895,7 @@ describe('RoutingNode', () => { { baseURL: parameterizedBaseUrl, nodeParameters: {} }, ); - const requestOptions = (result?.[0]?.[0]?.json as { requestOptions: IHttpRequestOptions }) - .requestOptions; + const requestOptions = getRequestOptions(result); expect(requestOptions.allowedDomains).toBeUndefined(); }); @@ -2918,8 +2909,7 @@ describe('RoutingNode', () => { { baseURL: parameterizedBaseUrl, nodeParameters: {} }, ); - const requestOptions = (result?.[0]?.[0]?.json as { requestOptions: IHttpRequestOptions }) - .requestOptions; + const requestOptions = getRequestOptions(result); expect(requestOptions.allowedDomains).toBe('api.example.com, other.example.com'); }); }); @@ -2939,8 +2929,7 @@ describe('RoutingNode', () => { { propertyBaseURL: 'https://upload.example.com' }, ); - const requestOptions = (result?.[0]?.[0]?.json as { requestOptions: IHttpRequestOptions }) - .requestOptions; + const requestOptions = getRequestOptions(result); expect(requestOptions.allowedDomains).toBe('upload.example.com, api.example.com'); }); @@ -2950,8 +2939,7 @@ describe('RoutingNode', () => { { propertyBaseURL: 'https://upload.example.com' }, ); - const requestOptions = (result?.[0]?.[0]?.json as { requestOptions: IHttpRequestOptions }) - .requestOptions; + const requestOptions = getRequestOptions(result); expect(requestOptions.allowedDomains).toBeUndefined(); }); }); @@ -2975,8 +2963,7 @@ describe('RoutingNode', () => { }, ); - const requestOptions = (result?.[0]?.[0]?.json as { requestOptions: IHttpRequestOptions }) - .requestOptions; + const requestOptions = getRequestOptions(result); expect(requestOptions.allowedDomains).toBe('api.example.com'); }); @@ -2998,8 +2985,7 @@ describe('RoutingNode', () => { { propertyBaseURL: parameterizedPropertyBaseUrl, nodeParameters: {} }, ); - const requestOptions = (result?.[0]?.[0]?.json as { requestOptions: IHttpRequestOptions }) - .requestOptions; + const requestOptions = getRequestOptions(result); expect(requestOptions.allowedDomains).toBeUndefined(); }); }); diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-additional-keys.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-additional-keys.test.ts index b387ac4fc0b..b9ee6c0a4f8 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-additional-keys.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-additional-keys.test.ts @@ -77,7 +77,7 @@ describe('getAdditionalKeys', () => { const result = getAdditionalKeys(additionalData, 'manual', null, { isCredential: true }); expect(result.$secrets).toBeDefined(); - expect((result.$secrets?.provider1 as IDataObject).secret1).toEqual('secret-value'); + expect((result.$secrets!.provider1 as IDataObject).secret1).toEqual('secret-value'); }); it('should return undefined $secrets by default', () => { diff --git a/packages/frontend/@n8n/chat/package.json b/packages/frontend/@n8n/chat/package.json index a941304be57..304770cbebd 100644 --- a/packages/frontend/@n8n/chat/package.json +++ b/packages/frontend/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "1.40.0", + "version": "1.41.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 efade1f89b1..a30915a4a4b 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.31.0", + "version": "1.32.0", "files": [ "dist" ], diff --git a/packages/frontend/@n8n/design-system/package.json b/packages/frontend/@n8n/design-system/package.json index 9bf22552974..05eab7fe6f4 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.40.0", + "version": "2.41.0", "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/frontend/@n8n/design-system/src/components/N8nChatMessage/ChatMessage.vue b/packages/frontend/@n8n/design-system/src/components/N8nChatMessage/ChatMessage.vue index 0c7a11a63e0..06efe3a4255 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nChatMessage/ChatMessage.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nChatMessage/ChatMessage.vue @@ -33,6 +33,8 @@ useResizeObserver(message, shrinkWrapUserMessage); defineSlots<{ default(): unknown; actions?(): unknown; + /** Content below the actions row, outside the area whose hover shows the actions. */ + 'after-actions'?(): unknown; }>(); @@ -47,6 +49,7 @@ defineSlots<{
+ diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDropdownMenu/DropdownMenu.vue b/packages/frontend/@n8n/design-system/src/components/N8nDropdownMenu/DropdownMenu.vue index 8a029c76fd1..aba7f6a5924 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nDropdownMenu/DropdownMenu.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nDropdownMenu/DropdownMenu.vue @@ -606,7 +606,6 @@ defineExpose({ open, close, highlightFirstItem, handleExternalKeydown, focusTrig overflow-y: auto; border-radius: var(--radius--xs); background-color: var(--background--surface); - --shadow-color--outline: var(--border-color); box-shadow: var(--shadow--md), var(--shadow--outline); will-change: transform, opacity; transform-origin: var(--n8n--dropdown--offset--origin-x) var(--n8n--dropdown--offset--origin-y); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nPopover/Popover.vue b/packages/frontend/@n8n/design-system/src/components/N8nPopover/Popover.vue index 76e15e027ba..ec1a55defa4 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nPopover/Popover.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nPopover/Popover.vue @@ -201,9 +201,7 @@ watch( border-radius: var(--radius--xs); background-color: var(--background--surface); - box-shadow: - var(--shadow--md), - inset var(--shadow--outline); + box-shadow: var(--shadow--md), var(--shadow--outline); will-change: transform, opacity; transform-origin: var(--popover--offset--origin-x) var(--popover--offset--origin-y); @@ -264,7 +262,7 @@ watch( .popoverArrow { fill: var(--background--surface); - stroke: var(--border-color); + stroke: var(--shadow-color--outline); stroke-width: 1px; // Overlap the card by 1px so its outline shadow doesn't cut the arrow off. transform: translateY(-1px); diff --git a/packages/frontend/@n8n/design-system/src/css/_primitives.scss b/packages/frontend/@n8n/design-system/src/css/_primitives.scss index eb7d4b999cd..c27542d4a43 100644 --- a/packages/frontend/@n8n/design-system/src/css/_primitives.scss +++ b/packages/frontend/@n8n/design-system/src/css/_primitives.scss @@ -319,23 +319,30 @@ --shadow--xl: 0 20px 25px -5px var(--shadow-color), 0 8px 10px -6px var(--shadow-color); } -/** Redefine this primitive overrides here because Lightning CSS doesnt like light-dark in .scss files **/ +/** Redefine these primitives because Lightning CSS does not support light-dark() in SCSS. **/ @mixin dark-theme-overrides { --shadow-color: var(--color--black-alpha-250); --shadow-color--outline: var(--color--white-alpha-100); + + /** Recompute these values in the scope that defines the dark shadow colors. **/ + --shadow--outline: 0 0 0 1px var(--shadow-color--outline); + --shadow--2xs: 0 1px var(--shadow-color); + --shadow--xs: 0 1px 3px -1px var(--shadow-color); + --shadow--sm: 0 4px 6px -1px var(--shadow-color), 0 2px 4px -2px var(--shadow-color); + --shadow--md: 0 10px 15px -3px var(--shadow-color), 0 4px 6px -4px var(--shadow-color); + --shadow--xl: 0 20px 25px -5px var(--shadow-color), 0 8px 10px -6px var(--shadow-color); } :root { @include primitives; +} - /** Make sure that media query preceeds [data-theme] as MacOS theme can override attribute specificity **/ - @media (prefers-color-scheme: dark) { - body:not([data-theme]) { - @include dark-theme-overrides; - } - } +body[data-theme='dark'] { + @include dark-theme-overrides; +} - [data-theme='dark'] { +@media (prefers-color-scheme: dark) { + body:not([data-theme]) { @include dark-theme-overrides; } } diff --git a/packages/frontend/@n8n/design-system/src/css/_tokens.scss b/packages/frontend/@n8n/design-system/src/css/_tokens.scss index 18618cf9a1d..76026e73fea 100644 --- a/packages/frontend/@n8n/design-system/src/css/_tokens.scss +++ b/packages/frontend/@n8n/design-system/src/css/_tokens.scss @@ -668,9 +668,9 @@ --background--disabled: var(--color--neutral-800); /* stylelint-disable @n8n/css-var-naming */ - --border-color: var(--border-color-base, var(--color--white-alpha-100)); + --border-color: var(--border-color-base, var(--color--white-alpha-150)); /* stylelint-enable @n8n/css-var-naming */ - --border-color--subtle: var(--color--white-alpha-50); + --border-color--subtle: var(--color--white-alpha-100); --border-color--strong: var(--color--white-alpha-200); --border-color--stronger: var(--color--white-alpha-400); diff --git a/packages/frontend/@n8n/design-system/src/css/mixins/_popover.scss b/packages/frontend/@n8n/design-system/src/css/mixins/_popover.scss index 665920782aa..a26b7ad16a9 100644 --- a/packages/frontend/@n8n/design-system/src/css/mixins/_popover.scss +++ b/packages/frontend/@n8n/design-system/src/css/mixins/_popover.scss @@ -13,10 +13,7 @@ overflow: hidden; border-radius: var(--popover-content--radius, var(--radius--xs)); background-color: var(--background--surface); - --shadow-color--outline: var(--border-color); - box-shadow: - var(--shadow--md), - inset var(--shadow--outline); + box-shadow: var(--shadow--md), var(--shadow--outline); will-change: transform, opacity; transform-origin: var(--n8n--popover--offset--origin-x) var(--n8n--popover--offset--origin-y); diff --git a/packages/frontend/@n8n/frontend-constants/package.json b/packages/frontend/@n8n/frontend-constants/package.json index 8b886e5335a..24dfc967534 100644 --- a/packages/frontend/@n8n/frontend-constants/package.json +++ b/packages/frontend/@n8n/frontend-constants/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/frontend-constants", "type": "module", - "version": "0.8.0", + "version": "0.9.0", "files": [ "dist" ], diff --git a/packages/frontend/@n8n/frontend-module-sdk/package.json b/packages/frontend/@n8n/frontend-module-sdk/package.json index e603174e19d..a2737ec7f64 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.11.0", + "version": "0.12.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 bfa1ed6568d..25946ee24b8 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.41.0", + "version": "2.42.0", "files": [ "dist" ], diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index ded2f2f5948..a7bf0cb0623 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2532,10 +2532,12 @@ "nodeView.refresh": "Refresh", "nodeView.resetZoom": "Reset Zoom", "nodeView.tidyUp": "Tidy Up", + "nodeView.runButtonText.execute": "Execute", "nodeView.runButtonText.executeWorkflow": "Execute workflow", + "nodeView.runButtonText.executeWorkflowFrom": "Execute workflow from {nodeName}", "nodeView.runButtonText.executingWorkflow": "Executing workflow", "nodeView.runButtonText.waitingForTriggerEvent": "Waiting for trigger event", - "nodeView.runButtonText.from": "from {nodeName}", + "nodeView.runButtonText.from": "From {nodeName}", "nodeView.showError.workflowError": "Workflow execution had an error", "nodeView.showError.getWorkflowDataFromUrl.title": "Problem loading workflow", "nodeView.showError.importWorkflowData.title": "Problem importing workflow", @@ -3703,6 +3705,7 @@ "settings.mcp.allowedRedirectUris.validation.invalidUrl": "Invalid URL: {url}", "settings.mcp.allowedRedirectUris.validation.invalidProtocol": "Invalid protocol for URL: {url}. Only http and https are allowed.", "settings.mcp.allowedRedirectUris.validation.httpsRequired": "HTTPS required in production: {url}", + "experiments.surfaceAssistantOnWorkflowError.nudge.action": "Fix with n8n Assistant", "experiments.surfaceMcpToNewCloudUsers.onboarding.title": "Build from your assistant", "experiments.surfaceMcpToNewCloudUsers.onboarding.description": "Connect MCP clients like Claude Code and Cursor to build, run, and iterate on workflows in your instance", "experiments.surfaceMcpToNewCloudUsers.onboarding.client.claude": "Claude", @@ -4507,12 +4510,18 @@ "settings.migrationReport.refreshButton": "Refresh", "settings.migrationReport.tabs.workflowIssues": "Workflow issues", "settings.migrationReport.tabs.instanceIssues": "Instance issues", - "settings.migrationReport.workflowTooltip.critical": "Affected workflows will break after the update. You need to update or replace impacted nodes.", - "settings.migrationReport.workflowTooltip.medium": "Workflows may still run but could produce incorrect results. Review and test before updating.", - "settings.migrationReport.workflowTooltip.low": "Behavior might change slightly in specific cases. Most workflows will keep working as expected.", - "settings.migrationReport.instanceTooltip.critical": "This issue will likely prevent the instance from starting or working correctly after the update. Must be fixed before proceeding.", - "settings.migrationReport.instanceTooltip.medium": "This may affect performance, compatibility, or connected services. Review and fix if relevant to your setup.", - "settings.migrationReport.instanceTooltip.low": "Minor configuration change. Doesn't block the update but may cause subtle changes in behavior.", + "settings.migrationReport.impact.upgradeBlocked": "Upgrade blocked", + "settings.migrationReport.impact.executionsFail": "Executions fail", + "settings.migrationReport.impact.behaviorChanges": "Behavior changes", + "settings.migrationReport.impact.capabilityRemoved": "Capability removed", + "settings.migrationReport.workflowTooltip.upgradeBlocked": "The update cannot proceed until you fix the affected workflows.", + "settings.migrationReport.workflowTooltip.executionsFail": "Affected workflows will fail after the update. Update or replace the affected nodes.", + "settings.migrationReport.workflowTooltip.behaviorChanges": "Affected workflows keep running, but their behavior changes. Review and test them before you update.", + "settings.migrationReport.workflowTooltip.capabilityRemoved": "Executions are not affected. A capability these workflows use is no longer available after the update.", + "settings.migrationReport.instanceTooltip.upgradeBlocked": "The update may be blocked, or the instance may not start after the update. Fix this before you proceed.", + "settings.migrationReport.instanceTooltip.executionsFail": "Executions on this instance will fail after the update. Fix this before you proceed.", + "settings.migrationReport.instanceTooltip.behaviorChanges": "The instance keeps running, but its behavior changes after the update. Review the change and adjust your setup if needed.", + "settings.migrationReport.instanceTooltip.capabilityRemoved": "Executions are not affected. A capability of this instance is no longer available after the update.", "settings.migrationReport.emptyWorkflowIssues.title": "No workflow issues detected", "settings.migrationReport.emptyWorkflowIssues.description": "Your workflows are fully compatible with version {version}. You're good to go!", "settings.migrationReport.emptyInstanceIssues.title": "No instance issues detected", @@ -7263,6 +7272,9 @@ "instanceAi.setupPanel.helpFindApiKey": "Help me find my API key", "instanceAi.setupPanel.helpFindAccessToken": "Help me find my access token", "instanceAi.setupPanel.helpSetUp": "Help me set this up", + "instanceAi.setupPanel.helpUnavailableWhileBuilding": "The assistant is still working. Help will be available when it finishes.", + "instanceAi.setupPanel.earlySetupCoachmark": "You can start completing these steps now. The list will update as the assistant builds your workflow.", + "instanceAi.setupPanel.dismissCoachmark": "Got it", "instanceAi.setupPanel.helpVisibleFields": "The OAuth form asks for: {fields}.", "instanceAi.setupPanel.helpOAuthState": "Selected OAuth mode: {mode}. Connection state: {state}.", "instanceAi.setupPanel.helpManagedOAuthAvailable": "Managed OAuth is available on this instance.", @@ -7992,10 +8004,15 @@ "agents.chat.clearHistory.error": "Could not clear chat history", "agents.chat.stop.error": "Couldn't stop the agent. Try again.", "agents.chat.queue.title": "{count} message up next | {count} messages up next", + "agents.chat.queue.steer": "Steer", + "agents.chat.queue.steerTooltip": "Send to current execution", + "agents.chat.queue.steering": "Waiting for the next step", + "agents.chat.queue.steerError": "Couldn't send the message to the current execution. Check the queue and try again.", "agents.chat.queue.edit": "Edit queued message", "agents.chat.queue.save": "Save changes", "agents.chat.queue.cancelEdit": "Discard edits", "agents.chat.queue.editUnavailable": "This message has started or was removed. Your changes weren't applied.", + "agents.chat.queue.editSteeringUnavailable": "This message is waiting for the next step. Your changes weren't applied.", "agents.chat.queue.editError": "Couldn't save your changes. Try again.", "agents.chat.queue.remove": "Remove queued message", "agents.chat.queue.loadError": "Couldn't load queued messages. Reopen the chat to try again.", @@ -8218,7 +8235,7 @@ "agents.toolConfig.mcpApproval.refresh.hint": "Refresh the list of tools from this MCP server.", "agents.toolConfig.mcpApproval.tools.placeholder": "Select tools", "agents.toolConfig.mcpApproval.loadError": "Could not load MCP tools. Check the server settings and try again.", - "agents.new.defaultName": "New Agent", + "agents.new.defaultName": "Untitled", "agents.builder.sections.agent": "Agent", "agents.builder.sections.advanced": "Advanced", "agents.builder.sections.configJson": "Raw", @@ -8296,7 +8313,7 @@ "agents.builder.memory.episodicMemory.credentialDialog.title": "Select an OpenAI credential", "agents.builder.knowledge.advanced.title": "Advanced", "agents.builder.files.title": "Knowledge base", - "agents.builder.files.titleTooltip": "Files uploaded to this agent. n8n stores them so the agent can search their contents and read them when answering.", + "agents.builder.files.titleTooltip": "n8n stores files uploaded to this agent so it can search and read their contents when answering.", "agents.builder.files.description": "Add CSV, PDF, Markdown, or TXT files this agent can search and read. Upload up to {maxFiles} files at a time, {maxSizeMb} MB each. Knowledge base size is limited to {maxTotalGb} GB.", "agents.builder.files.count": "{count} file uploaded | {count} files uploaded", "agents.builder.files.upload": "Upload file", diff --git a/packages/frontend/@n8n/rest-api-client/package.json b/packages/frontend/@n8n/rest-api-client/package.json index 5f0c488a9d9..b8aa3c100aa 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.41.0", + "version": "2.42.0", "files": [ "dist" ], diff --git a/packages/frontend/@n8n/stores/package.json b/packages/frontend/@n8n/stores/package.json index afb59a67234..90a0470d054 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.41.0", + "version": "2.42.0", "files": [ "dist" ], diff --git a/packages/frontend/@n8n/stores/src/settings.store.test.ts b/packages/frontend/@n8n/stores/src/settings.store.test.ts index d9a77e045db..770becffadc 100644 --- a/packages/frontend/@n8n/stores/src/settings.store.test.ts +++ b/packages/frontend/@n8n/stores/src/settings.store.test.ts @@ -146,74 +146,6 @@ describe('settings.store', () => { }); }); - describe('isExecuteWorkflowNodeExcluded', () => { - it('should return true when executeWorkflow is in excludeNodes', async () => { - getSettings.mockResolvedValueOnce({ - ...mockSettings, - excludeNodes: ['n8n-nodes-base.executeWorkflow'], - }); - - const settingsStore = useSettingsStore(); - await settingsStore.getSettings(); - - expect(settingsStore.isExecuteWorkflowNodeExcluded).toBe(true); - }); - - it('should return false when executeWorkflow is not excluded', async () => { - getSettings.mockResolvedValueOnce({ - ...mockSettings, - excludeNodes: ['n8n-nodes-base.executeCommand'], - }); - - const settingsStore = useSettingsStore(); - await settingsStore.getSettings(); - - expect(settingsStore.isExecuteWorkflowNodeExcluded).toBe(false); - }); - - it('should return false when only executeWorkflowTrigger is excluded', async () => { - getSettings.mockResolvedValueOnce({ - ...mockSettings, - excludeNodes: ['n8n-nodes-base.executeWorkflowTrigger'], - }); - - const settingsStore = useSettingsStore(); - await settingsStore.getSettings(); - - expect(settingsStore.isExecuteWorkflowNodeExcluded).toBe(false); - }); - }); - - describe('isSubworkflowConversionDisabled', () => { - it.each([ - [['n8n-nodes-base.executeWorkflow']], - [['n8n-nodes-base.executeWorkflowTrigger']], - [['n8n-nodes-base.executeWorkflow', 'n8n-nodes-base.executeWorkflowTrigger']], - ])('should return true when %j is excluded', async (excludeNodes) => { - getSettings.mockResolvedValueOnce({ - ...mockSettings, - excludeNodes, - }); - - const settingsStore = useSettingsStore(); - await settingsStore.getSettings(); - - expect(settingsStore.isSubworkflowConversionDisabled).toBe(true); - }); - - it('should return false when both sub-workflow nodes are available', async () => { - getSettings.mockResolvedValueOnce({ - ...mockSettings, - excludeNodes: ['n8n-nodes-base.executeCommand'], - }); - - const settingsStore = useSettingsStore(); - await settingsStore.getSettings(); - - expect(settingsStore.isSubworkflowConversionDisabled).toBe(false); - }); - }); - describe('isCrdtCollaborationEnabled', () => { it('should return true when collaboration.crdt is local', async () => { getSettings.mockResolvedValueOnce({ diff --git a/packages/frontend/@n8n/stores/src/settings.store.ts b/packages/frontend/@n8n/stores/src/settings.store.ts index 44840ba8d4c..f2d2ae3b249 100644 --- a/packages/frontend/@n8n/stores/src/settings.store.ts +++ b/packages/frontend/@n8n/stores/src/settings.store.ts @@ -13,12 +13,7 @@ import * as moduleSettingsApi from '@n8n/rest-api-client/api/module-settings'; import * as settingsApi from '@n8n/rest-api-client/api/settings'; import { testHealthEndpoint } from '@n8n/rest-api-client/api/templates'; import Bowser from 'bowser'; -import { - EXECUTE_WORKFLOW_NODE_TYPE, - EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, - type IDataObject, - type WorkflowSettings, -} from 'n8n-workflow'; +import type { IDataObject, WorkflowSettings } from 'n8n-workflow'; import { defineStore } from 'pinia'; import { computed, ref } from 'vue'; @@ -269,20 +264,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { () => settings.value.workflowCallerPolicyDefaultOption, ); - const isNodeTypeExcluded = (nodeType: string) => { - const excludeNodes = settings.value.excludeNodes; - return Array.isArray(excludeNodes) && excludeNodes.includes(nodeType); - }; - - const isExecuteWorkflowNodeExcluded = computed(() => - isNodeTypeExcluded(EXECUTE_WORKFLOW_NODE_TYPE), - ); - - const isSubworkflowConversionDisabled = computed( - () => - isExecuteWorkflowNodeExcluded.value || isNodeTypeExcluded(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE), - ); - const permanentlyDismissedBanners = computed(() => settings.value.banners?.dismissed ?? []); const isCommunityPlan = computed(() => planName.value.toLowerCase() === 'community'); @@ -515,8 +496,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { isMultiMain, isWorkerViewAvailable, workflowCallerPolicyDefaultOption, - isExecuteWorkflowNodeExcluded, - isSubworkflowConversionDisabled, permanentlyDismissedBanners, saveDataErrorExecution, saveDataSuccessExecution, diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index 2ccd717bbad..c7497e69f26 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.41.0", + "version": "2.42.0", "description": "Workflow Editor UI for n8n", "main": "index.js", "type": "module", diff --git a/packages/frontend/editor-ui/src/__tests__/mocks.ts b/packages/frontend/editor-ui/src/__tests__/mocks.ts index 325992bf1e5..f482017d932 100644 --- a/packages/frontend/editor-ui/src/__tests__/mocks.ts +++ b/packages/frontend/editor-ui/src/__tests__/mocks.ts @@ -21,7 +21,6 @@ import { Workflow, } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; -import { vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { @@ -39,8 +38,7 @@ import { import type { INodeUi, IWorkflowDb } from '@/Interface'; import type { IExecutionResponse } from '@/features/execution/executions/executions.types'; import { CanvasNodeRenderType } from '@/features/workflows/canvas/canvas.types'; -import type { FrontendSettings, NodeTypeAvailabilityScope } from '@n8n/api-types'; -import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies'; +import type { FrontendSettings } from '@n8n/api-types'; import type { ExpressionLocalResolveContext } from '@/app/types/expressions'; export const mockNode = ({ @@ -341,18 +339,3 @@ export function createTestExpressionLocalResolveContext( ...data, }; } - -/** - * Makes the active pinia's policy store report these node types as restricted. Call after - * `setActivePinia` (or after rendering with the pinia the component uses). - */ -export function mockRestrictedNodeTypes( - restricted: Record = {}, -): void { - vi.spyOn(useTypeAvailabilityPoliciesStore(), 'getNodeTypeAvailability').mockImplementation( - (name) => { - const scope = restricted[name]; - return scope ? { name, available: false, scope } : { name, available: true }; - }, - ); -} diff --git a/packages/frontend/editor-ui/src/app/App.vue b/packages/frontend/editor-ui/src/app/App.vue index b89c3757a09..e1695b9b568 100644 --- a/packages/frontend/editor-ui/src/app/App.vue +++ b/packages/frontend/editor-ui/src/app/App.vue @@ -14,6 +14,9 @@ import { useTelemetryInitializer } from '@/app/composables/useTelemetryInitializ import { useWorkflowDiffRouting } from '@/app/composables/useWorkflowDiffRouting'; import { useModulePushDispatcher } from '@/app/composables/useModulePushDispatcher'; import { useTrialIntroModalAutoOpen } from '@/experiments/trialIntroModal/useTrialIntroModalAutoOpen'; +// Experiment cleanup (119_surface_assistant_on_workflow_error) +import WorkflowErrorNudge from '@/experiments/surfaceAssistantOnWorkflowError/components/WorkflowErrorNudge.vue'; +// EOF Experiment cleanup import { CODEMIRROR_TOOLTIP_CONTAINER_ELEMENT_ID, HIRING_BANNER, VIEWS } from '@/app/constants'; import { useNDVStore } from '@/features/ndv/shared/ndv.store'; import { useSettingsStore } from '@n8n/stores/settings.store'; @@ -143,6 +146,9 @@ useExposeCssVar('--ask-assistant--floating-button--margin-bottom', askAiFloating