Merge branch 'master' into devp-646-cross-module-import-lint
Some checks failed
CI: Python / Checks (push) Has been cancelled

This commit is contained in:
Charlie Kolb 2026-08-13 13:43:09 +02:00 committed by GitHub
commit 67e35eb088
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1306 changed files with 69568 additions and 20336 deletions

View File

@ -16,6 +16,19 @@ RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
ENV DISABLE_AUTOUPDATER=1
RUN npm install -g @anthropic-ai/claude-code opencode-ai
COPY codespaces-env.sh /usr/local/lib/codespaces-env.sh
COPY codespaces-secrets.sh /etc/profile.d/codespaces-secrets.sh
# git and gh must survive token rotation: these read the token on each call
COPY gitcredential-refresh.sh /usr/local/bin/gitcredential-refresh.sh
COPY gh-shim.sh /usr/local/bin/gh
RUN chmod +x /usr/local/bin/gitcredential-refresh.sh /usr/local/bin/gh
# VS Code terminals are non-login shells: they read bashrc, not profile.d
RUN echo '. /usr/local/lib/codespaces-env.sh' >> /etc/bash.bashrc
COPY tmux.conf /etc/tmux.conf
RUN echo node ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/node && chmod 0440 /etc/sudoers.d/node
RUN mkdir -p /workspaces && chown node:node /workspaces
RUN corepack enable

View File

@ -0,0 +1,215 @@
# Cloud agent sessions (Codespaces)
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.
This is a separate devcontainer config from the laptop one in
`.devcontainer/` — it ships both agent CLIs, `tmux` for session persistence,
Playwright system deps, and Docker-in-Docker (for testcontainers and
`pnpm --filter n8n-containers services`), on the same Postgres sidecar setup.
## One-time setup (~5 min)
1. Add your key at [github.com/settings/codespaces](https://github.com/settings/codespaces)
→ **New secret** → name `ANTHROPIC_API_KEY`, repository access `n8n-io/n8n`.
(Alternative for Max subscriptions: `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`.)
2. Give the GitHub CLI the codespace scope:
```bash
gh auth refresh -h github.com -s codespace
```
## Daily flow
```bash
pnpm session # attach the default agent session (creates everything on first run)
pnpm session fix-flaky # a second, parallel agent in its own git worktree
pnpm session ls # what's running
pnpm session tunnel # forward n8n ports (default 5678, 8080) to localhost; Ctrl-C to stop
pnpm session stop # end of day: billing stops, disk survives
pnpm session rm # delete the codespace
```
- **Detach** with `Ctrl-b d` — the agent keeps working without you.
- **Scroll** with the mouse wheel (tmux mouse mode is on). To use the
terminal's own text selection, hold **Shift** and drag.
- **Reattach** by running the same `pnpm session <name>` from any machine.
- Each named session gets its own worktree (`/workspaces/wt-<name>`, branch
`session/<name>`), so parallel agents never touch each other's tree. Builds
in fresh worktrees are cache-hits via a shared turbo cache.
- First codespace creation takes ~20 min uncached (image + full build). After
that, sessions attach instantly; new worktrees cost a `pnpm install` (~1–2 min).
## Agent worker (drive a session from n8n)
`agent-worker.mjs` lets an n8n workflow drive a Claude Code session on the
codespace. This is how Slack (the Flaky bot) steers a session that runs here.
Each turn is one headless `claude -p`. The session state is on disk. So
`--resume` continues a conversation across turns, and across a stop/start.
**The worker polls outward. Nothing inbound is exposed.** GitHub sets every
forwarded port to private on start. It gives no API to make a port public. So
you cannot reach a codespace from outside reliably. The worker calls out
instead. It asks n8n for a turn addressed to this box's owner (`$GITHUB_USER`).
It runs the turn. It sends the result to the turn's resume URL. It uses no
tunnel, no open port, and no domain.
The worker starts on each container start (`postStartCommand`). It needs two
secrets. Add them at
[github.com/settings/codespaces](https://github.com/settings/codespaces), the
same way as `ANTHROPIC_API_KEY`:
- `AGENT_WORKER_TOKEN` — the shared bearer token. The worker sends it on each poll.
- `N8N_DEQUEUE_URL` — the n8n webhook that returns a pending turn.
The log is at `/tmp/agent-worker.log`. The tmux session is `agent-worker`. To
watch it, run `tmux attach -t agent-worker`. The worker does not start if a
secret or `$GITHUB_USER` is missing.
A turn stops after about 25 minutes (`TURN_TIMEOUT_MS`). This limit is below the
n8n Wait limit. So the worker reports a clear message before n8n reports a
generic timeout. Keep the worker limit below the n8n limit if you change either.
### Build and run the app in a session
The prebuild already installed the dependencies and warmed the build. So a
session rarely needs a cold `pnpm install` or a full `pnpm build`. Both are slow
(often 10–20 minutes cold). Both can outlast a turn's limit.
- **Bring the app up with one command: `pnpm dev:up`.** It installs missing
dependencies, starts the backend, waits for health, and prints the URL. Add
`--build` only when a frontend change must appear (see below).
- **Open the app** at `https://<codespace-name>-5678.app.github.dev`. The port
is private. It opens for you in a browser that is signed in to GitHub. You do
not need a tunnel. An anonymous or server caller gets a 302. That is why the
worker polls outward instead.
- **`pnpm dev` no longer exists.** Use `pnpm dev:be` for the backend (on 5678).
Use `pnpm dev:fe:editor` for the editor UI with hot reload (on 8080).
- **`dev:be` serves the editor from the `dist` build.** So a frontend edit does
not hot-reload there. Run `pnpm dev:up --build` (or `pnpm build`) and restart
to show it. For live frontend hot reload, use `pnpm dev:fe:editor`. That path
needs `pnpm session tunnel 5678 8080` from your laptop. Its API base is set to
`localhost:5678`, so the `-8080.app.github.dev` URL does not work on its own.
- Run `pnpm install` only when the dependencies change. `pnpm build` reuses the
turbo cache and is fast when warm.
- To clear stale build outputs after a branch switch, run `pnpm reset`. Add
`--full` if that does not clear it.
- Give a long build its own turn. Do not chain an install and a full build
behind other work in one turn.
## Flaky tools (MCP)
Claude sessions get the `flaky` MCP server automatically: Currents
flaky/quarantine data, the `qa_*` BigQuery dataset, Sentry RCA, live Linear,
and repo investigation. Login registers it from the repo-level
`FLAKY_MCP_TOKEN` / `FLAKY_MCP_URL` secrets. Forks have no secrets and skip
it. Tell the agent to call `get_flaky_context` first — it returns the rules
the tools assume.
## Quality skills (Claude plugin)
Claude sessions can also load the private quality skills from the
`n8n-io/n8n-claude-skills` plugin marketplace (bug insights, defect attribution,
mutation testing, and more). `post-start.mjs` installs the `quality` plugin on
each container start, so every session gets the skills with no per-session step.
The private marketplace uses the codespace's own GitHub auth — no extra token.
`devcontainer.json` grants the codespace read access to
`n8n-io/n8n-claude-skills` via `customizations.codespaces.repositories`, and
**each user authorizes that access once when they create the codespace** (GitHub
prompts for it, then remembers). Both repos are in the same org, which is what
lets this work.
The codespace authenticates git over HTTPS and has no SSH key, so `post-start.mjs`
sets `CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1` — Claude Code's plugin loader otherwise
clones `owner/repo` shorthand over SSH and the private clone fails.
If a user does not authorize the grant, the clone fails and the skills step is
skipped (the worker still starts). Existing codespaces created before this change
need a recreate to get the prompt. The log is at `/tmp/post-start.log`: a failed
`skills repo reachable` line means the grant was not authorized; a failed
`marketplace add` after a reachable repo means a loader-auth problem.
## Viewing the dev UI locally
Two terminal windows:
```bash
pnpm session # window 1: attach the agent session
pnpm session tunnel # window 2: forward 5678 + 8080 to localhost
```
Attaching lands you in the agent (Claude), not a shell — open a shell in a new
tmux window with `Ctrl-b c` (or ask the agent), then pick a dev server and open
the matching port:
- `pnpm dev:up` — backend on **5678** (editor served from the `dist` build).
Open http://localhost:5678.
- `pnpm dev:fe:editor` — editor UI with hot reload on **8080**. Open
http://localhost:8080. It needs the backend on 5678 too, so run `pnpm dev:up`
alongside it.
The tunnel prints nothing while forwarding — that's normal. `Connection
refused` means nothing is listening on that port in the codespace yet; it
starts serving as soon as the dev server is up, no restart needed. Pass ports
to override the defaults (`pnpm session tunnel 5678 8080 5679`), but always
forward the pair together with matching numbers: the Vite dev UI points its
API base at `localhost:5678` (the `N8N_PORT` default), so an asymmetric or
partial mapping breaks it.
## What survives what
| Event | Running processes | Disk (checkout, worktrees, chat history) |
|---|---|---|
| Detach / close laptop / network drop | ✅ keep running | ✅ |
| Stop, or idle timeout (default 30 min, max 4 h) | ❌ killed | ✅ |
| Delete (`pnpm session rm`) | ❌ | ❌ (push your branches first) |
After a stop, `pnpm session <name>` restarts the codespace (~30–60 s); run
`claude --resume` (or `--continue`) inside to restore the conversation from disk.
## Gotchas (learned the hard way)
- **Codespaces clones the repo to `/workspaces/n8n`**, not `/workspaces` —
`workspaceFolder` here differs from the laptop config on purpose.
- **A failing `onCreateCommand` is fatal**: Codespaces discards the container
and drops you into a minimal recovery container (no node, no CLIs). If your
codespace has nothing installed, check
`/workspaces/.codespaces/.persistedshare/creation.log`.
- **`gh codespace ssh` needs sshd inside the container** — provided by the
`sshd` devcontainer feature, don't remove it.
- **User secrets aren't visible in ssh shells by default**: the codespace
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.
- **You cannot paste images into a remote 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
path. The file stays on disk and survives detach and `--resume`.
- **`git push` / `gh` return 401 in tmux and long sessions** — same root
cause as the secrets gotcha, plus rotation: Codespaces refreshes the
on-disk `GITHUB_TOKEN` every few minutes, so a login-time snapshot goes
stale. The image fixes both: a credential helper reads the current token
on each `git push` (`gitcredential-refresh.sh`), and a shim at
`/usr/local/bin/gh` does the same for `gh`. The token is scoped to
`n8n-io/n8n`: fork-based flows do not work, push branches directly. Do
not add SSH keys as a workaround — they have no per-repo granularity.
- Codespaces created by org members on this repo are **org-owned and
org-billed** (organization ownership + a monthly Codespaces budget are
enabled for n8n-io). Codespaces created before that change, or by
non-members, bill to the personal account (free tier: 120 core-hours/month).
If creation unexpectedly falls back to personal billing, the org budget is
exhausted for the period.
## Costs
Compute bills only while the codespace runs: ~$0.72/hr for the 8-core box,
~nothing stopped. Usage draws from a shared monthly org budget, so
`pnpm session stop` when you leave; the idle timeout is the backstop, not the
plan.

View File

@ -0,0 +1,154 @@
#!/usr/bin/env node
// Poll worker for per-turn Claude Code conversations on a codespace.
// You cannot reach a codespace from outside. GitHub keeps forwarded ports
// private. So this worker calls out. It polls n8n for a turn addressed to this
// box's owner. It runs one `claude -p`. It sends the result to the turn's resume
// URL. Every call is outbound HTTPS to n8n. It opens no inbound port.
//
// AGENT_WORKER_TOKEN=… N8N_DEQUEUE_URL=… node agent-worker.mjs
//
// Env:
// N8N_DEQUEUE_URL n8n webhook that hands back one pending turn (required)
// AGENT_WORKER_TOKEN shared bearer sent on every dequeue (required)
// GITHUB_USER box owner's login; the bootstrap route for a new thread (codespaces set this)
// CODESPACE_NAME stable box id; routes a thread back to the box holding its session (codespaces set this)
// TURN_TIMEOUT_MS per-turn limit; keep below the n8n Wait limit (default 25 min)
import { execFile } from 'node:child_process';
import { resolve as resolvePath, sep } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
const DEQUEUE_URL = process.env.N8N_DEQUEUE_URL;
const TOKEN = process.env.AGENT_WORKER_TOKEN;
const GITHUB_USER = process.env.GITHUB_USER;
const BOX_ID = process.env.CODESPACE_NAME;
const ROOT = '/workspaces';
const POLL_INTERVAL_MS = 3000;
// Warn and use the default on a bad value, so a config mistake cannot silently
// disable the turn limit.
function posNum(name, fallback) {
const raw = process.env[name];
if (raw === undefined) return fallback;
const n = Number(raw);
if (Number.isFinite(n) && n > 0) return n;
console.error(`${name} is not a positive number ("${raw}"); using ${fallback}.`);
return fallback;
}
// Keep this below the n8n Wait-node limit. Then the worker reports a slow turn
// before n8n's Wait ends with a generic message.
const TURN_TIMEOUT_MS = posNum('TURN_TIMEOUT_MS', 25 * 60_000);
for (const [k, v] of Object.entries({
N8N_DEQUEUE_URL: DEQUEUE_URL,
AGENT_WORKER_TOKEN: TOKEN,
GITHUB_USER,
})) {
if (!v) {
console.error(`Refusing to start: ${k} is not set.`);
process.exit(1);
}
}
function runClaude({ message, sessionId, cwd }) {
const safeCwd = resolvePath(typeof cwd === 'string' && cwd ? cwd : `${ROOT}/n8n`);
if (safeCwd !== ROOT && !safeCwd.startsWith(ROOT + sep))
throw new Error(`cwd must be under ${ROOT}`);
const args = ['-p', '--output-format', 'json', '--dangerously-skip-permissions'];
if (sessionId) args.push('--resume', sessionId);
args.push(message);
return new Promise((res, rej) => {
execFile(
'claude',
args,
{ cwd: safeCwd, timeout: TURN_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 },
(err, stdout, stderr) => {
try {
res(JSON.parse(stdout));
} catch {
if (err?.killed)
rej(
new Error(
`The turn passed the ${Math.round(TURN_TIMEOUT_MS / 60_000)}-minute limit and stopped. It may have been in a build. Do a smaller step, or run a long build in its own turn.`,
),
);
else rej(new Error(stderr?.trim() || err?.message || 'claude produced no output'));
}
},
);
});
}
async function post(url, body) {
return fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
}
async function dequeue() {
const res = await post(DEQUEUE_URL, { githubUser: GITHUB_USER, boxId: BOX_ID, token: TOKEN });
if (!res.ok) throw new Error(`dequeue HTTP ${res.status}`);
const text = await res.text();
if (!text.trim()) return null; // no pending turn
const turn = JSON.parse(text);
return turn?.turnId ? turn : null;
}
async function handle(turn) {
let result;
try {
const r = await runClaude(turn);
result = {
turnId: turn.turnId,
status: 'done',
output: r.result ?? '',
sessionId: r.session_id ?? turn.sessionId ?? '',
boxId: BOX_ID,
};
} catch (error) {
result = {
turnId: turn.turnId,
status: 'error',
output: error.message,
sessionId: turn.sessionId ?? '',
boxId: BOX_ID,
};
}
// Send the result to the turn's resume URL. This continues the waiting n8n
// execution. Retry on a failed status or a network error, so a transient
// failure does not drop the result and leave the turn to time out.
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await post(turn.resumeUrl, result);
if (res.ok) return;
console.error(`turn ${turn.turnId}: result POST got HTTP ${res.status} (attempt ${attempt})`);
} catch (error) {
console.error(
`turn ${turn.turnId}: result POST failed (attempt ${attempt}): ${error.message}`,
);
}
await sleep(2000 * attempt);
}
console.error(`turn ${turn.turnId}: result not delivered after 3 attempts`);
}
console.log(`agent-worker polling as ${GITHUB_USER} every ${POLL_INTERVAL_MS}ms`);
for (;;) {
try {
const turn = await dequeue();
if (turn) {
console.log(
`${new Date().toISOString()} turn ${turn.turnId} by ${turn.author ?? 'unknown'}: ${turn.sessionId ? 'resume' : 'new'}`,
);
await handle(turn);
continue; // get the next turn now, with no delay
}
} catch (error) {
console.error(`poll error: ${error.message}`);
}
await sleep(POLL_INTERVAL_MS);
}

View File

@ -0,0 +1,9 @@
# Codespaces delivers secrets base64-encoded in a shared env file. It injects
# them into VS Code sessions only. Other shells must source this file to get
# them. Tokens rotate every few minutes: read at time of use, not at login.
if [ -f /workspaces/.codespaces/shared/.env-secrets ]; then
while IFS='=' read -r key val; do
case "$key" in '' | *[!A-Za-z0-9_]*) continue ;; esac
dec=$(printf %s "$val" | base64 -d 2>/dev/null) && export "$key=$dec"
done </workspaces/.codespaces/shared/.env-secrets
fi

View File

@ -0,0 +1,26 @@
# Login-shell setup for ssh/tmux sessions: export secrets, then do the
# one-time registrations.
. /usr/local/lib/codespaces-env.sh
# Claude Code keeps its first-run state in ~/.claude.json. Write the defaults
# so a new codespace does not show the theme and trust prompts. The env
# secrets supply the login.
[ -f "$HOME/.claude.json" ] || printf '%s\n' \
'{"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.
if command -v git >/dev/null 2>&1 &&
! git config --global --get-all credential.helper 2>/dev/null | grep -qs gitcredential-refresh; then
git config --global --add credential.helper /usr/local/bin/gitcredential-refresh.sh
fi

View File

@ -2,24 +2,57 @@
"name": "n8n (Codespaces agent sessions)",
"dockerComposeFile": "docker-compose.yml",
"service": "n8n",
"workspaceFolder": "/workspaces",
// Codespaces clones the repo into /workspaces/<repo>, unlike the laptop config
"workspaceFolder": "/workspaces/n8n",
"hostRequirements": {
"cpus": 8,
"memory": "32gb",
"storage": "64gb"
},
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {}
"ghcr.io/devcontainers/features/github-cli:1": {},
// gh codespace ssh needs an SSH server inside the container
"ghcr.io/devcontainers/features/sshd:1": {},
// Docker for testcontainers and `pnpm --filter n8n-containers services`;
// needs `privileged: true` on the n8n service (compose-based config)
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"forwardPorts": [8080, 5678],
// Grant the codespace's own GitHub token read access to the private skills
// marketplace (same org), so Claude can install it with the codespace's normal
// git auth — no extra PAT. Each user authorizes this once at codespace creation.
"customizations": {
"codespaces": {
"repositories": {
"n8n-io/n8n-claude-skills": { "permissions": { "contents": "read" } }
}
}
},
// Runs in prebuilds, so a fresh codespace starts with deps installed and the build warm
"onCreateCommand": "corepack install && pnpm install && (pnpm build > /tmp/build.log 2>&1 || tail -n 50 /tmp/build.log) && pnpm --filter=n8n-playwright exec playwright install chromium",
// On each container start: install the private skills marketplace into Claude
// (best-effort) and start the poll worker. `bash -lc` loads the secrets shim,
// so the worker gets its token. Logs: /tmp/post-start.log, /tmp/agent-worker.log.
"postStartCommand": "bash -lc 'node /workspaces/n8n/.devcontainer/codespaces/post-start.mjs >> /tmp/post-start.log 2>&1' || true",
"secrets": {
"ANTHROPIC_API_KEY": {
"description": "Anthropic API key for Claude Code / OpenCode sessions"
},
"AGENT_WORKER_TOKEN": {
"description": "The shared bearer token. The poll worker sends it to n8n on each dequeue. See .devcontainer/codespaces/agent-worker.mjs."
},
"N8N_DEQUEUE_URL": {
"description": "n8n webhook the poll worker calls to claim a pending turn"
},
"CLAUDE_CODE_OAUTH_TOKEN": {
"description": "Alternative to an API key: token from `claude setup-token` (Max subscription)"
},
// Both set at the repository level — team members get them automatically
"FLAKY_MCP_TOKEN": {
"description": "Bearer token for the Flaky (QA assistant) MCP endpoint"
},
"FLAKY_MCP_URL": {
"description": "URL of the Flaky MCP endpoint on the internal n8n instance"
}
}
}

View File

@ -3,10 +3,10 @@ volumes:
services:
postgres:
image: postgres:16-alpine
image: postgres:18-alpine
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
- postgres-data:/var/lib/postgresql
environment:
- POSTGRES_DB=n8n
- POSTGRES_PASSWORD=password
@ -15,6 +15,8 @@ services:
build:
context: .
dockerfile: Dockerfile
# Required by the docker-in-docker devcontainer feature
privileged: true
volumes:
- ../..:/workspaces:cached
command: sleep infinity
@ -24,3 +26,5 @@ services:
DB_POSTGRESDB_HOST: postgres
DB_TYPE: postgresdb
DB_POSTGRESDB_PASSWORD: password
# Prompt-cache TTL in seconds. 1 hour keeps the cache warm between turns.
ANTHROPIC_PROMPT_CACHE_TTL: "3600"

View File

@ -0,0 +1,7 @@
#!/bin/sh
# gh reads its token from the env only. Long sessions hold a stale snapshot of
# the rotated token. Read the current token on each call, then run the real gh
# (the github-cli feature installs it at /usr/bin/gh).
. /usr/local/lib/codespaces-env.sh
[ -n "$GITHUB_TOKEN" ] && export GH_TOKEN="$GITHUB_TOKEN"
exec /usr/bin/gh "$@"

View File

@ -0,0 +1,6 @@
#!/bin/sh
# The stock helper reads $GITHUB_TOKEN from the env. Non-login shells do not
# have it, and the token rotates. Read the current token, then delegate.
[ "$1" = get ] || exit 0
. /usr/local/lib/codespaces-env.sh
exec /.codespaces/bin/gitcredential_github.sh "$@"

View File

@ -0,0 +1,34 @@
#!/usr/bin/env node
// Runs on each codespace start. Installs the skills marketplace, starts the worker.
import { execFileSync } from 'node:child_process';
const MARKETPLACE = 'n8n-io/n8n-claude-skills';
const PLUGIN = 'quality@n8n-claude-skills';
// The codespace clones over HTTPS and has no SSH key. The loader defaults to SSH
// for "owner/repo", so it must prefer HTTPS or the private clone fails.
process.env.CLAUDE_CODE_PLUGIN_PREFER_HTTPS = '1';
process.env.CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE = '1';
function tryRun(label, cmd, args) {
try {
execFileSync(cmd, args, { stdio: 'inherit' });
return true;
} catch (error) {
console.error(`${label}: ${error.message}`);
return false;
}
}
tryRun('skills repo reachable', 'git', ['ls-remote', `https://github.com/${MARKETPLACE}`, 'HEAD']);
if (tryRun('marketplace add', 'claude', ['plugin', 'marketplace', 'add', MARKETPLACE]))
tryRun('plugin install', 'claude', ['plugin', 'install', PLUGIN]);
tryRun('worker start', 'tmux', [
'new-session',
'-d',
'-s',
'agent-worker',
'bash -lc "export CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE=1; node /workspaces/n8n/.devcontainer/codespaces/agent-worker.mjs >> /tmp/agent-worker.log 2>&1"',
]);

View File

@ -0,0 +1,4 @@
# Mouse scroll, a deep scrollback, and correct colors for agent sessions.
set -g mouse on
set -g history-limit 50000
set -g default-terminal "tmux-256color"

View File

@ -3,10 +3,10 @@ volumes:
services:
postgres:
image: postgres:16-alpine
image: postgres:18-alpine
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
- postgres-data:/var/lib/postgresql
environment:
- POSTGRES_DB=n8n
- POSTGRES_PASSWORD=password

1
.gitattributes vendored
View File

@ -1 +1,2 @@
*.sh text eol=lf
docs/generated/**/* linguist-generated

View File

@ -133,14 +133,26 @@ of the breaking removal on `3.x`.
original message and author. Nothing is ever squashed. Merge commits in the range (breaking
PRs merged into `3.x`) are flattened away, and a breaking commit that also landed on
`master` is dropped as empty.
3. On a **conflict**, `3.x` is left **untouched** and a **draft conflict PR** (labeled
`automation:v3-sync`) carrying the conflict markers is opened on `sync/master-to-3x`, plus a
post to the **`#alerts-v3-sync`** Slack channel. **Syncs pause until that PR is merged** —
so conflicts never pile up silently.
3. Conflicts confined to **mechanical files** — tool-generated content with a deterministic
resolution (`pnpm-lock.yaml`, `packages/frontend/editor-ui/data/node-popularity.json`,
`.github/test-metrics/e2e-impact-map.json`) — are **auto-resolved during the replay**,
exactly as a human resolver would: the lockfile is regenerated with
`pnpm install --lockfile-only` (pnpm merges its own conflict markers), bot-maintained data
files take `master`'s side. The resolution is folded into the stalled commit, so this
still adds **no commit and no PR**. The list lives in `MECHANICAL_PATHS` in
[`sync-master-to-3x.mjs`](./scripts/sync-master-to-3x.mjs).
4. On a **real code conflict**, `3.x` is left **untouched** and a **draft conflict PR**
(labeled `automation:v3-sync`) carrying the conflict markers is opened on
`sync/master-to-3x` — with the mechanical files already pre-resolved — plus a post to the
**`#alerts-v3-sync`** Slack channel. **Syncs pause until that PR is merged** — so
conflicts never pile up silently.
Whatever route it takes, the sync verifies that the content it is about to push is **exactly
the tree a merge of `3.x` and `master` produces** (`git merge-tree`), and that no conflict
markers are present. Either check failing fails the run instead of rewriting `3.x`.
markers are present. When mechanical files had to be regenerated, the exactness check applies
to every path **except** those files, and a regenerated lockfile must additionally be
consistent with the manifests in the pushed tree. Any check failing fails the run instead of
rewriting `3.x`.
> **`3.x` is force-pushed daily.** Branch breaking-change PRs off **`master`** and target
> `3.x` — then your merge-base is a `master` commit that survives every rewrite and your
@ -152,7 +164,8 @@ markers are present. Either check failing fails the run instead of rewriting `3.
The conflict branch is `master` merged into `3.x` with the **conflict markers committed**, so
you see exactly what clashed — and the required checks stay red until they're gone, so the PR
can't be merged half-resolved:
can't be merged half-resolved. Mechanical files arrive **pre-resolved** (listed in the PR
under "Auto-resolved for you"), so only the real code conflicts need you:
```bash
git fetch origin sync/master-to-3x && git switch sync/master-to-3x
@ -160,8 +173,13 @@ git fetch origin sync/master-to-3x && git switch sync/master-to-3x
git push origin sync/master-to-3x
```
If the PR says the lockfile was **deferred** (a `package.json` / `pnpm-workspace.yaml` is
conflicted too), resolve the manifests first, then regenerate it with
`pnpm install --lockfile-only` and include the result in your fix commit.
Then **merge the PR with the normal merge button.** `master`'s commits arrive as-is and your
fix stays its own commit.
fix stays its own commit. **Never close a conflict PR unmerged** — closing resolves nothing,
the same conflict reopens on the next sync, and the new PR will call out the abandoned one.
`3.x` never holds markers at its tip, so nightly images keep building; the merge commit that
carries them drops out of `3.x`'s history at the next sync (the replay takes the queue's
@ -170,7 +188,9 @@ commits only).
The next sync then makes `3.x` linear again. The plain replay stalls at that point (a fix
recorded around a merge commit leaves no patch to replay), so it replays a second time with
`3.x`'s side favoured, and your fix commit — which is in the queue — does the real work.
Nothing is squashed, and the tree guard proves the result is exactly the merge of `3.x` and
Stalls that favouring cannot settle on its own (e.g. modify/delete, when `master` touched a
file a breaking commit deletes) are resolved toward `3.x`'s side during the replay. Nothing
is squashed, and the tree guard proves the result is exactly the merge of `3.x` and
`master`.
**Who gets pinged.** The conflict is attributed to the authors of the `3.x` commits behind the
@ -186,8 +206,24 @@ Those authors are **requested as reviewers** on the conflict PR and listed in th
```bash
docker pull n8nio/n8n:v3-nightly # latest v3 nightly
docker pull n8nio/n8n:v3-nightly-20260625 # a specific build date
docker pull n8nio/n8n:v3-rc # latest release candidate
docker pull n8nio/n8n:v3-rc-20260625 # latest RC of that day
docker pull n8nio/n8n:v3-rc-20260625.2 # one exact RC, never overwritten
```
Every Monday's nightly is also retagged as a release candidate, and a maintainer can
publish extra RCs any day (`force_rc` on a manual run). Each publish claims the next
rolling number for the day — `v3-rc-<date>.1`, `.2`, … — and moves `v3-rc` and
`v3-rc-<date>` onto it, so:
- **`v3-rc-<date>.N`** — pin this to hold a build still. Immutable.
- **`v3-rc` / `v3-rc-<date>`** — track the newest RC overall / of that day. These move.
The retag covers the whole set — `n8nio/n8n`, `n8nio/runners` and
`n8nio/runners:v3-rc[-<date>.N]-distroless` — so pinning one RC across a stack gives
images built from one `3.x` commit, unlike `v3-nightly`, which moves daily and can be
mid-build when you pull. The same tags exist on GHCR (`ghcr.io/n8n-io/…`).
Use these to trial v3 in docker/kubernetes before release. Do **not** use them in
production.

45
.github/WORKFLOWS.md vendored
View File

@ -187,7 +187,6 @@ These only run if specific files changed:
| Event | Workflow | Condition |
|----------------------------|-----------------------------|------------------------------------------------------|
| Review approved | `release-chromatic.yml` | + design files changed |
| Comment with `@claude` | `util-claude.yml` | mention in any comment |
| Any review | `util-notify-pr-status.yml` | not community-labeled |
**Why Instance AI evals fire once per PR state-change, not per push:** the
@ -210,7 +209,7 @@ the lab bench.** The gate deliberately exposes only PR re-runs. Anything that
isn't PR gating — baselines, model experiments, arbitrary branch runs — goes
through `test-evals-instance-ai.yml`'s own dispatch form ("Instance AI
Evals: Experiments"): full knob set (branch, filter, tier, suite,
iterations, experiment-name, model), no per-PR cancellation (dispatches run in parallel, e.g. concurrent
iterations, experiment-name, model, model-url, model-key, reasoning-effort, supports-structured-outputs), no per-PR cancellation (dispatches run in parallel, e.g. concurrent
model-comparison arms), and SHA-keyed docker cache hits on master. Evals never
run on fork PRs: the event trigger gates on `head.repo.fork`, and the `pr`
re-run path refuses fork PRs in `resolve` (dispatched runs carry secrets).
@ -248,26 +247,8 @@ parallelism). See the `--build-via-mcp` section in
| Workflow | Purpose |
|---------------------------|---------------------------------------------------------|
| `util-claude-task.yml` | Run Claude Code to complete a task and create a PR |
| `util-data-tooling.yml` | SQLite/PostgreSQL export/import validation (manual) |
#### Claude Task Runner (`util-claude-task.yml`)
Runs Claude Code to complete a task, then creates a PR with the changes. Use for well-specced tasks or simple fixes. Can be triggered via GitHub UI or API.
Claude reads templates from `.github/claude-templates/` for task-specific guidance. Add new templates as needed for recurring task types.
**Inputs:**
- `task` - Description of what Claude should do
- `user_token` - GitHub PAT (PR will be authored by the token owner)
**Token requirements** (fine-grained PAT):
- Repository: `n8n-io/n8n`
- Contents: `Read and write`
- Pull requests: `Read and write`
**Governance:** If you provide your personal PAT, you cannot approve the resulting PR. For automated/bot use cases (e.g., dependabot-style updates via n8n workflows), an app token can be used instead.
---
## Workflow Call Graph
@ -425,13 +406,22 @@ During the v3 release window, `master` carries normal feature work (behind opt-i
flags) and the long-lived `3.x` branch carries breaking changes. `util-sync-master-to-3x.yml`
syncs daily by **replaying the `3.x`-only commits onto `master` and force-pushing `3.x`**, so a
clean sync adds no commit and nothing is squashed. What it pushes is always verified to be
exactly the tree a merge of `3.x` and `master` produces, and marker-free. On a real conflict
`3.x` is left untouched and a draft PR carrying the conflict markers (labeled
`automation:v3-sync`) is opened on `sync/master-to-3x`, requesting the breaking-commit authors
as reviewers via `sync-conflict-owners.mjs`, posting to `#alerts-v3-sync` and pausing further
syncs until it is resolved and merged normally.
exactly the tree a merge of `3.x` and `master` produces, and marker-free. Conflicts confined
to mechanical, tool-generated files (the pnpm lockfile, bot-maintained data files — see
`MECHANICAL_PATHS` in `sync-master-to-3x.mjs`) are auto-resolved during the replay; the tree
check then applies to every path except those files. On a real code conflict `3.x` is left
untouched and a draft PR carrying the conflict markers (labeled `automation:v3-sync`, with
mechanical files pre-resolved) is opened on `sync/master-to-3x`, requesting the
breaking-commit authors as reviewers via `sync-conflict-owners.mjs`, posting to
`#alerts-v3-sync` and pausing further syncs until it is resolved and merged normally.
`build-v3-nightly.yml` publishes `n8nio/n8n:v3-nightly[-<date>]` images from `3.x`
by calling `docker-build-push.yml` with `ref: 3.x` + `date_tag`.
by calling `docker-build-push.yml` with `ref: 3.x` + `date_tag`. On Mondays it also
retags that run's n8n + runners manifests as a release candidate (by digest on GHCR, so
the RC is exactly what was built), giving a self-consistent set to trial. Any manual run
can promote too via the `force_rc` dispatch input, several times a day: each publish
claims the next free `v3-rc-<date>.N` as its immutable tag and moves the floating `v3-rc`
and `v3-rc-<date>` onto it. The counter is derived by probing the registry, and the job
is serialized on a `v3-rc-tagging` concurrency group so two runs can't claim one number.
See **[`DEVELOPING_V3.md`](./DEVELOPING_V3.md)** for the full model.
@ -511,6 +501,7 @@ Scripts in `.github/scripts/`:
|-------------------------|-------------------|---------------------------|
| `validate-docs-links.js`| Check doc URLs | `util-check-docs-urls.yml`|
| `send-build-stats.mjs` | Build telemetry | `setup-nodejs` action |
| `db-test-matrix.mjs` | DB test matrix from `postgres-versions.json` | `ci-pull-requests.yml` |
### Slack Scripts
@ -772,7 +763,7 @@ Adding a new channel requires inviting the bot first; the first run otherwise fa
| Cloud/CDN | `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` |
| GitHub Automation | `N8N_ASSISTANT_APP_ID`, `N8N_ASSISTANT_PRIVATE_KEY` |
| Benchmarking | `BENCHMARK_ARM_*`, `N8N_BENCHMARK_LICENSE_CERT` |
| AI/Evals | `ANTHROPIC_API_KEY`, `EVALS_LANGSMITH_*` |
| AI/Evals | `EVALS_ANTHROPIC_KEY`, `EVALS_OPENAI_KEY`, `EVALS_OPENROUTER_KEY`, `EVALS_XAI_KEY`, `EVALS_BASETEN_KEY`, `EVALS_FIREWORKS_KEY`, `EVALS_TOGETHER_KEY`, `EVALS_DATABRICKS_KEY`, `EVALS_MODAL_KEY`, `EVALS_LYCEUM_KEY`, `EVALS_AZURE_FOUNDRY_KEY`, `EVALS_VERTEX_KEY`, `EVALS_VERTEX_PROJECT_ID`, `EVALS_VERTEX_LOCATION`, `EVALS_LANGSMITH_*` |
### Scoping

View File

@ -5,4 +5,6 @@ self-hosted-runner:
- blacksmith-2vcpu-ubuntu-2204-arm
- blacksmith-8vcpu-ubuntu-2204
- blacksmith-8vcpu-ubuntu-2204-arm
- blacksmith-32vcpu-ubuntu-2204
- blacksmith-32vcpu-ubuntu-2204-arm
- ubuntu-slim

View File

@ -1,119 +0,0 @@
# E2E Test Task Guide
## Required Reading
**Before writing any code**, read these files:
```
packages/testing/playwright/AGENTS.md # Patterns, anti-patterns, entry points
packages/testing/playwright/CONTRIBUTING.md # Detailed architecture (first 200 lines)
```
## Spec Validation
Before starting, verify the spec includes:
| Required | Example |
|----------|---------|
| **File(s) to modify** | `tests/e2e/credentials/crud.spec.ts` |
| **Specific behavior** | "Verify credential renaming updates the list" |
| **Pattern reference** | "Follow existing tests in same file" or "See AGENTS.md" |
**If missing, ask for clarification.** Don't guess at requirements.
## Commands
```bash
# Run single test
pnpm --filter=n8n-playwright test:local tests/e2e/your-test.spec.ts --reporter=list 2>&1 | tail -50
# Run with pattern match
pnpm --filter=n8n-playwright test:local --grep "should do something" --reporter=list 2>&1 | tail -50
# Container tests (requires pnpm build:docker first)
pnpm --filter=n8n-playwright test:container:sqlite --grep @capability:email --reporter=list 2>&1 | tail -50
```
## Test Structure
```typescript
import { test, expect } from '../fixtures/base';
import { nanoid } from 'nanoid';
test('should do something @mode:sqlite', async ({ n8n, api }) => {
// Setup via API (faster, more reliable)
const workflow = await api.workflowApi.createWorkflow(workflowJson);
// UI interaction via entry points
await n8n.start.fromBlankCanvas();
// Assertions
await expect(n8n.workflows.getWorkflowByName(workflow.name)).toBeVisible();
});
```
## Entry Points
Use `n8n.start.*` methods - see `composables/TestEntryComposer.ts`:
- `fromBlankCanvas()` - New workflow
- `fromImportedWorkflow(file)` - Pre-built workflow
- `fromNewProjectBlankCanvas()` - Project-scoped
- `withUser(user)` - Isolated browser context
## Multi-User Tests
```typescript
const member = await api.publicApi.createUser({ role: 'global:member' });
const memberPage = await n8n.start.withUser(member);
await memberPage.navigate.toWorkflows();
```
## Development Process
1. **Validate spec** - Has file, behavior, pattern reference?
2. **Read existing code** - Understand current patterns in the file
3. **Identify helpers needed** - Check `pages/`, `services/`, `composables/`
4. **Add helpers first** if missing
5. **Write test** following 4-layer architecture
6. **Verify iteratively** - Small changes, test frequently
## Mandatory Verification
**Always run before marking complete:**
```bash
# 1. Tests pass (check output for failures - piping loses exit code)
pnpm --filter=n8n-playwright test:local <your-test> --reporter=list 2>&1 | tail -50
# 2. Not flaky (required)
pnpm --filter=n8n-playwright test:local <your-test> --repeat-each 3 --reporter=list 2>&1 | tail -50
# 3. Lint passes
pnpm --filter=n8n-playwright lint 2>&1 | tail -30
# 4. Typecheck passes
pnpm --filter=n8n-playwright typecheck 2>&1 | tail -30
```
**Important:** Piping through `tail` loses the exit code. Always check the output for "failed" or error messages rather than relying on exit codes.
**If any fail, fix before completing.**
## Refactoring Existing Tests
**Always verify tests pass BEFORE making changes:**
```bash
pnpm --filter=n8n-playwright test:local tests/e2e/target-file.spec.ts --reporter=list 2>&1 | tail -50
```
Then make small incremental changes, re-running after each.
## Done Checklist
- [ ] Spec had clear file, behavior, and pattern reference
- [ ] Read `AGENTS.md` and relevant existing code
- [ ] Used `n8n.start.*` entry points
- [ ] Used `nanoid()` for unique IDs (not `Date.now()`)
- [ ] No serial mode, `@db:reset`, or `n8n.api.signin()`
- [ ] Multi-user tests use `n8n.start.withUser()`
- [ ] Tests pass with `--repeat-each 3`
- [ ] Lint and typecheck pass

View File

@ -1,179 +0,0 @@
# Security Vulnerability Fix Guidelines
## Overview
This guide covers how to fix security vulnerabilities in the n8n codebase. Follow a systematic approach to identify, fix, and verify vulnerabilities in dependencies or base images.
## Decision Tree
```
Is it a direct dependency?
→ Yes: Update in catalog or package.json
→ No: Is it transitive?
→ Yes: Add pnpm override
→ No: Is it base image?
→ Yes: Update Dockerfile, trigger base image workflow
```
## Process Flow
```
Scan → Investigate → Fix → Verify
↓ ↓ ↓ ↓
pnpm pnpm why Update pnpm
build: (trace) deps build:
docker: or docker:
scan override scan
```
## Step-by-Step Process
### 1. Initial Setup
Start with a clean install:
```bash
pnpm install --frozen-lockfile
```
### 2. Scan for Vulnerabilities
Run the Docker scan to verify if the vulnerability exists:
```bash
pnpm build:docker:scan
```
### 3. Investigate the Source
Use `pnpm why` to trace where the vulnerable package is coming from:
```bash
pnpm why <package-name> -r
```
### 4. Determine Fix Strategy
#### Case A: Direct Dependency
If the vulnerable package is a **direct dependency**:
**Update via Catalog** (preferred for shared dependencies):
```yaml
# pnpm-workspace.yaml
catalog:
'@azure/identity': 4.13.0 # Updated version
```
```json
// packages/cli/package.json
{
"dependencies": {
"@azure/identity": "catalog:"
}
}
```
**Or update directly in package.json:**
```json
{
"dependencies": {
"vulnerable-package": "^1.2.3"
}
}
```
Then: `pnpm install`
#### Case B: Transitive Dependency
If the vulnerable package is a **transitive dependency**:
**Add an override** in the root `package.json`:
```json
{
"pnpm": {
"overrides": {
"vulnerable-package": "^1.2.3"
}
}
}
```
**For multiple versions:**
```json
{
"pnpm": {
"overrides": {
"vulnerable-package@3": "^3.2.1",
"vulnerable-package@4": "^4.0.1"
}
}
}
```
Then: `pnpm install`
#### Case C: Base Image / NPM Issue
If the vulnerability comes from the **base Docker image**:
1. Check `docker/images/n8n-base/Dockerfile`
2. Update Node version or Alpine packages if needed
3. Note: Base image rebuild requires manual workflow trigger
### 5. Verify the Fix
```bash
pnpm install
pnpm why <package-name> # Check version updated
pnpm build:docker:scan # Confirm vulnerability resolved
```
## Commit & PR Standards
### Commit Format
```
{type}({scope}): {neutral description}
{Brief neutral context}
Addresses: CVE-XXXX-XXXXX
Refs: {LINEAR-ID}
```
### Type Selection
| Scenario | Type |
|----------|------|
| Dependency update | `fix(deps)` |
| Code vulnerability fix | `fix` |
| License/compliance | `chore` |
| Docker/build hardening | `build` |
### Title Language - USE NEUTRAL LANGUAGE
Commit/PR titles appear in changelogs. Use neutral language:
| ❌ Avoid | ✅ Use Instead |
|----------|----------------|
| CVE-XXXX-XXXXX | (footer only) |
| vulnerability, exploit | issue, concern |
| critical, security fix | improvement, update |
| patch vulnerability | validate, harden, ensure |
### Example Commit
**Good:**
```
fix(deps): update jws to 4.0.1
Updates jws package to latest stable version.
Addresses: CVE-2025-65945
Refs: SEC-412
```
**Bad:**
```
fix(security): patch critical CVE-2025-65945 in jws
```
## Done Checklist
- [ ] `pnpm build:docker:scan` shows no vulnerability for the CVE
- [ ] `pnpm why <package>` shows updated version
- [ ] Commit follows neutral language format (no CVE in title)
- [ ] PR references Linear ticket if provided
## Common Commands
```bash
pnpm install --frozen-lockfile # Initial setup
pnpm build:docker:scan # Scan for vulnerabilities
pnpm why <package-name> -r # Investigate dependency
pnpm install # Update lockfile after changes
pnpm list <package-name> # Check specific package versions
```

View File

@ -1,6 +1,6 @@
services:
postgres:
image: postgres:16
image: ${TEST_IMAGE_POSTGRES:?set it from packages/testing/containers/postgres-versions.json}
restart: always
environment:
- POSTGRES_DB=n8n
@ -8,5 +8,7 @@ services:
- POSTGRES_PASSWORD=password
ports:
- 5432:5432
# Not PGDATA: Postgres 18+ moved it under /var/lib/postgresql/<major>/ and
# refuses to start when /var/lib/postgresql/data is mounted.
tmpfs:
- /var/lib/postgresql/data
- /var/lib/postgresql

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
/**
* Builds the Claude task prompt and writes it to GITHUB_ENV.
* Uses a random delimiter to prevent heredoc collision with user input.
*
* Usage: node prepare-claude-prompt.mjs
*
* Environment variables:
* INPUT_TASK - The task description (required)
* USE_RAW_PROMPT - "true" to pass task directly without wrapping
* GITHUB_ENV - Path to GitHub env file (set by Actions)
*/
import { randomUUID } from 'node:crypto';
import { appendFileSync, readdirSync } from 'node:fs';
const task = process.env.INPUT_TASK;
const useRaw = process.env.USE_RAW_PROMPT === 'true';
const envFile = process.env.GITHUB_ENV;
if (!task) {
console.error('INPUT_TASK environment variable is required');
process.exit(1);
}
if (!envFile) {
console.error('GITHUB_ENV environment variable is required');
process.exit(1);
}
let prompt;
if (useRaw) {
prompt = task;
} else if (task.startsWith('/')) {
// Task is a skill invocation (e.g. "/n8n:linear-issue CAT-2820").
// Wrap it so the model invokes the Skill tool instead of implementing code.
prompt = `# Skill Invocation
Invoke the following skill using the Skill tool and follow its instructions.
${task}`;
} else {
// List available templates so Claude knows what exists (reads them if needed)
const templateDir = '.github/claude-templates';
let templateSection = '';
try {
const files = readdirSync(templateDir).filter((f) => f.endsWith('.md'));
if (files.length > 0) {
const listing = files.map((f) => ` - ${templateDir}/${f}`).join('\n');
templateSection = `\n# Templates\nThese guides are available if relevant to your task. Read any that match before starting:\n${listing}`;
}
} catch {
// No templates directory, skip
}
prompt = `# Task
${task}
${templateSection}
# Instructions
1. Read any relevant templates listed above before starting
2. Complete the task described above
3. Make commits as you work - the last commit message will be used as the PR title
4. IMPORTANT: End every commit message with: Co-authored-by: Claude <noreply@anthropic.com>
5. Ensure code passes linting and type checks before finishing
# Token Optimization
When running lint/typecheck, suppress verbose output:
pnpm lint 2>&1 | tail -30
pnpm typecheck 2>&1 | tail -30`;
}
// Random delimiter guarantees no collision with user content
const delimiter = `CLAUDE_PROMPT_DELIM_${randomUUID().replace(/-/g, '')}`;
appendFileSync(envFile, `CLAUDE_PROMPT<<${delimiter}\n${prompt}\n${delimiter}\n`);

View File

@ -1,59 +0,0 @@
#!/usr/bin/env node
/**
* Sends a callback to the resume URL with the Claude task result.
* Uses fetch() directly to avoid E2BIG errors from shell argument limits.
*
* Usage: node resume-callback.mjs
*
* Environment variables:
* RESUME_URL - Callback URL to POST to (required)
* EXECUTION_FILE - Path to Claude's execution output JSON (optional)
* CLAUDE_OUTCOME - "success" or "failure" (required)
* CLAUDE_SESSION_ID - Session ID for resuming conversations (optional)
* BRANCH_NAME - Git branch name (optional)
*/
import { existsSync, readFileSync } from 'node:fs';
const resumeUrl = process.env.RESUME_URL;
const executionFile = process.env.EXECUTION_FILE;
const claudeOutcome = process.env.CLAUDE_OUTCOME;
const sessionId = process.env.CLAUDE_SESSION_ID ?? '';
const branchName = process.env.BRANCH_NAME ?? '';
if (!resumeUrl) {
console.error('RESUME_URL environment variable is required');
process.exit(1);
}
const success = claudeOutcome === 'success';
let result = null;
if (executionFile && existsSync(executionFile)) {
try {
const execution = JSON.parse(readFileSync(executionFile, 'utf-8'));
// Extract the last element (Claude's final result message)
result = Array.isArray(execution) ? execution.at(-1) : execution;
} catch (err) {
console.warn(`Failed to parse execution file: ${err.message}`);
}
}
const payload = JSON.stringify({ success, branch: branchName, sessionId, result });
try {
const response = await fetch(resumeUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
});
if (!response.ok) {
const body = await response.text();
console.error(`Callback failed: ${body}`);
process.exit(1);
}
} catch (err) {
console.error(`Callback error: ${err.message}`);
process.exit(1);
}

95
.github/scripts/db-test-matrix.mjs vendored Normal file
View File

@ -0,0 +1,95 @@
// Builds the job matrix for test-db-reusable.yml. Node builtins only: it runs in a
// job that installs the monorepo's dependencies, not this folder's.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
export const POSTGRES_VERSIONS_PATH = 'packages/testing/containers/postgres-versions.json';
/** The matrix wall is bounded by its slowest leg, so a larger runner buys nothing. */
const RUNNER = 'blacksmith-4vcpu-ubuntu-2204';
/**
* @typedef {Object} PostgresVersions
* @property {string} primary
* @property {Array<{ major: number, image: string, support?: string }>} matrix
*/
/** @returns {PostgresVersions} */
export function readPostgresVersions(repoRoot = REPO_ROOT) {
const file = path.join(repoRoot, POSTGRES_VERSIONS_PATH);
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
/**
* Coverage and the schema-docs check run on the primary Postgres leg only, since
* the committed docs come from that one version.
*
* @param {PostgresVersions} versions
*/
export function buildMatrix(versions) {
const { primary, matrix } = versions;
if (!Array.isArray(matrix) || matrix.length === 0) {
throw new Error(`${POSTGRES_VERSIONS_PATH}: "matrix" must be a non-empty array`);
}
// Checked first: everything below treats the last entry as the newest.
const majors = matrix.map((entry) => entry.major);
for (let i = 1; i < majors.length; i++) {
if (majors[i] <= majors[i - 1]) {
throw new Error(
`${POSTGRES_VERSIONS_PATH}: "matrix" must be sorted by ascending major, got ${majors.join(', ')}`,
);
}
}
const newest = matrix[matrix.length - 1];
if (newest.image !== primary) {
throw new Error(
`${POSTGRES_VERSIONS_PATH}: "primary" (${primary}) must be the newest "matrix" entry's image (${newest.image})`,
);
}
// An exact minor keeps CI reproducible across Postgres point releases.
for (const { major, image } of matrix) {
const pinned = /^postgres:(\d+)\.\d+-alpine$/.exec(image);
if (!pinned) {
throw new Error(
`${POSTGRES_VERSIONS_PATH}: "${image}" must pin an exact minor, e.g. postgres:${major}.4-alpine`,
);
}
if (Number(pinned[1]) !== major) {
throw new Error(`${POSTGRES_VERSIONS_PATH}: "${image}" does not match major ${major}`);
}
}
return [
{
name: 'SQLite Pooled',
runner: RUNNER,
'test-cmd': 'pnpm test:sqlite',
'migration-cmd': 'pnpm test:sqlite:migrations',
'schema-check-cmd': 'pnpm --filter=@n8n/db schema:check:sqlite',
TEST_IMAGE_POSTGRES: undefined,
collectCoverage: 'false',
},
...matrix.map(({ major, image }) => ({
name: `Postgres ${major}`,
runner: RUNNER,
'test-cmd': 'pnpm test:postgres:integration:tc',
'migration-cmd': 'pnpm test:postgres:migrations:tc',
'schema-check-cmd': image === primary ? 'pnpm --filter=@n8n/db schema:check:postgres' : '',
TEST_IMAGE_POSTGRES: image,
collectCoverage: image === primary ? 'true' : 'false',
})),
];
}
// Skipped when imported by the tests.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
console.log(JSON.stringify(buildMatrix(readPostgresVersions())));
}

128
.github/scripts/db-test-matrix.test.mjs vendored Normal file
View File

@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { buildMatrix, readPostgresVersions, POSTGRES_VERSIONS_PATH } from './db-test-matrix.mjs';
// node --test ./.github/scripts/db-test-matrix.test.mjs
const versions = () => ({
primary: 'postgres:18.4-alpine',
matrix: [
{ major: 16, support: 'compatibility', image: 'postgres:16.14-alpine' },
{ major: 17, support: 'supported', image: 'postgres:17.10-alpine' },
{ major: 18, support: 'supported', image: 'postgres:18.4-alpine' },
],
});
describe('postgres-versions.json', () => {
it('is valid, so the DB workflow can build a matrix from it', () => {
assert.doesNotThrow(() => buildMatrix(readPostgresVersions()));
});
it('covers the supported range plus one compatibility major', () => {
const { matrix } = readPostgresVersions();
const supported = matrix.filter((entry) => entry.support === 'supported');
const compatibility = matrix.filter((entry) => entry.support === 'compatibility');
assert.equal(supported.length, 2, `${POSTGRES_VERSIONS_PATH}: expected two supported majors`);
assert.equal(
compatibility.length,
1,
`${POSTGRES_VERSIONS_PATH}: expected one compatibility major`,
);
assert.ok(compatibility[0].major < Math.min(...supported.map((entry) => entry.major)));
});
});
describe('buildMatrix', () => {
it('emits one leg per database', () => {
const legs = buildMatrix(versions());
assert.deepEqual(
legs.map((leg) => leg.name),
['SQLite Pooled', 'Postgres 16', 'Postgres 17', 'Postgres 18'],
);
});
it('passes each Postgres leg its own pinned image', () => {
const legs = buildMatrix(versions());
assert.deepEqual(
legs.filter((leg) => leg.TEST_IMAGE_POSTGRES).map((leg) => leg.TEST_IMAGE_POSTGRES),
['postgres:16.14-alpine', 'postgres:17.10-alpine', 'postgres:18.4-alpine'],
);
assert.equal(legs[0].TEST_IMAGE_POSTGRES, undefined, 'SQLite leg needs no Postgres image');
});
it('runs every leg against both the integration and the migration suite', () => {
for (const leg of buildMatrix(versions())) {
assert.ok(leg['test-cmd'], `${leg.name} has no test command`);
assert.ok(leg['migration-cmd'], `${leg.name} has no migration command`);
}
});
it('collects coverage and checks schema docs on the primary Postgres leg only', () => {
const legs = buildMatrix(versions());
const collecting = legs.filter((leg) => leg.collectCoverage === 'true');
assert.deepEqual(
collecting.map((leg) => leg.name),
['Postgres 18'],
);
const checkingPostgresSchema = legs.filter(
(leg) => leg['schema-check-cmd'] === 'pnpm --filter=@n8n/db schema:check:postgres',
);
assert.deepEqual(
checkingPostgresSchema.map((leg) => leg.name),
['Postgres 18'],
);
const skipping = legs.filter((leg) => leg['schema-check-cmd'] === '');
assert.deepEqual(
skipping.map((leg) => leg.name),
['Postgres 16', 'Postgres 17'],
);
});
it('still checks the SQLite schema docs', () => {
const legs = buildMatrix(versions());
assert.equal(legs[0]['schema-check-cmd'], 'pnpm --filter=@n8n/db schema:check:sqlite');
});
it('rejects a primary that is not the newest major', () => {
const stale = { ...versions(), primary: 'postgres:17.10-alpine' };
assert.throws(() => buildMatrix(stale), /"primary".*must be the newest/);
});
it('rejects majors that are not in ascending order', () => {
const unsorted = versions();
[unsorted.matrix[0], unsorted.matrix[1]] = [unsorted.matrix[1], unsorted.matrix[0]];
assert.throws(() => buildMatrix(unsorted), /ascending major, got 17, 16, 18/);
});
it('rejects a floating image tag', () => {
const floating = {
primary: 'postgres:18-alpine',
matrix: [{ major: 18, image: 'postgres:18-alpine' }],
};
assert.throws(() => buildMatrix(floating), /must pin an exact minor/);
});
it('rejects an image whose major disagrees with its entry', () => {
const mismatched = {
primary: 'postgres:17.10-alpine',
matrix: [{ major: 18, image: 'postgres:17.10-alpine' }],
};
assert.throws(() => buildMatrix(mismatched), /does not match major 18/);
});
it('rejects an empty matrix', () => {
assert.throws(() => buildMatrix({ primary: 'postgres:18.4-alpine', matrix: [] }), /non-empty/);
});
});

View File

@ -80,16 +80,35 @@ export async function resolveLogins(repo, shas, token, fetchFn = fetch) {
}
// Build the conflict-PR body, reviewer CSV, and Slack owner line.
export function buildOutputs({ syncBranch, targetBranch = '3.x', files, owners }) {
export function buildOutputs({
syncBranch,
targetBranch = '3.x',
files,
owners,
preResolved = [],
lockfileDeferred = false,
abandoned = [],
}) {
const filesMd = files.map((f) => `- \`${f}\``).join('\n') || '_none detected_';
const ownersMd = owners.length
? owners.map((o) => `- @${o}`).join('\n')
: '_Could not auto-attribute — review the conflicted files manually._';
const slack = owners.length
? `Likely owners (GitHub): ${owners.map((o) => `@${o}`).join(' ')}`
: 'Could not auto-attribute owners.';
const abandonedWarning = abandoned.length
? `A previous PR for this recurring conflict (${abandoned.map((pr) => `#${pr.number}`).join(', ')}) was closed without being merged. Closing resolves nothing — the conflict comes back on the next sync. **Merge, don't close.**`
: '';
const slack = [
owners.length
? `Likely owners (GitHub): ${owners.map((o) => `@${o}`).join(' ')}`
: 'Could not auto-attribute owners.',
abandoned.length
? `⚠️ ${abandoned.map((pr) => `<${pr.url}|#${pr.number}>`).join(', ')} was closed without merging and the conflict is back — merge this one, don't close it.`
: '',
]
.filter(Boolean)
.join(' ');
const body = [
`Automated \`master\`→\`${targetBranch}\` sync hit a conflict.`,
...(abandonedWarning ? ['', '> [!WARNING]', `> ${abandonedWarning}`] : []),
'',
`**\`${targetBranch}\` was not touched.** This branch is \`master\` merged into \`${targetBranch}\` with the **conflict markers committed**, so you can see exactly what clashed. The required checks stay red until they are resolved, so this PR cannot be merged half-done.`,
'',
@ -97,12 +116,27 @@ export function buildOutputs({ syncBranch, targetBranch = '3.x', files, owners }
`1. \`git fetch origin ${syncBranch} && git switch ${syncBranch}\``,
'2. Fix the conflict markers and commit them **in one commit of your own** (rather than amending the merge).',
`3. \`git push origin ${syncBranch}\``,
`4. **Merge this PR with the normal merge button.** \`master\`'s commits come in as-is and your fix stays its own commit — nothing is squashed.`,
`4. **Merge this PR with the normal merge button.** \`master\`'s commits come in as-is and your fix stays its own commit — nothing is squashed. Never close this PR unmerged: closing resolves nothing and the same conflict reopens on the next sync.`,
'',
`**Daily syncs are paused until this PR is merged.** The next sync then replays \`${targetBranch}\` onto master linearly again — which also drops this merge commit and its markers out of \`${targetBranch}\`'s history — and verifies the result is exactly what a merge would produce.`,
'',
'### Conflicted files',
filesMd,
...(preResolved.length
? [
'',
'### Auto-resolved for you',
'These tool-generated files also conflicted and were resolved mechanically — no action needed:',
preResolved.map((f) => `- \`${f}\``).join('\n'),
]
: []),
...(lockfileDeferred
? [
'',
'> [!NOTE]',
'> `pnpm-lock.yaml` still carries its conflict markers because a manifest (`package.json` / `pnpm-workspace.yaml`) is conflicted too, or its regeneration failed. After resolving the manifests, regenerate it with `pnpm install --lockfile-only` and commit the result.',
]
: []),
'',
'### Likely owners',
`Authors of the ${targetBranch} commits behind the conflicted files, requested as reviewers:`,

View File

@ -93,8 +93,51 @@ test('buildOutputs formats reviewers, slack line, and PR body with owners', () =
});
test('buildOutputs degrades gracefully when nothing could be attributed', () => {
const out = buildOutputs({ syncBranch: 'sync/master-to-3x', syncBase: 'abc1234', files: ['x.ts'], owners: [] });
const out = buildOutputs({
syncBranch: 'sync/master-to-3x',
syncBase: 'abc1234',
files: ['x.ts'],
owners: [],
});
assert.equal(out.ownersCsv, '');
assert.equal(out.slack, 'Could not auto-attribute owners.');
assert.match(out.body, /Could not auto-attribute/);
});
test('buildOutputs lists mechanically pre-resolved files apart from the code conflicts', () => {
const out = buildOutputs({
syncBranch: 'sync/master-to-3x',
files: ['packages/cli/x.ts'],
owners: ['alice'],
preResolved: ['pnpm-lock.yaml'],
});
assert.match(out.body, /### Conflicted files\n- `packages\/cli\/x\.ts`/);
assert.match(out.body, /### Auto-resolved for you/);
assert.match(out.body, /resolved mechanically — no action needed/);
assert.ok(out.body.indexOf('pnpm-lock.yaml') > out.body.indexOf('Auto-resolved'));
});
test('buildOutputs carries the regen instruction when the lockfile was deferred', () => {
const out = buildOutputs({
syncBranch: 'sync/master-to-3x',
files: ['packages/cli/package.json'],
owners: [],
lockfileDeferred: true,
});
assert.match(out.body, /still carries its conflict markers/);
assert.match(out.body, /pnpm install --lockfile-only/);
});
test('buildOutputs warns about conflict PRs that were closed without merging', () => {
const abandoned = [{ number: 42, url: 'https://github.com/n8n-io/n8n/pull/42' }];
const out = buildOutputs({
syncBranch: 'sync/master-to-3x',
files: ['x.ts'],
owners: ['alice'],
abandoned,
});
assert.match(out.body, /#42\) was closed without being merged/);
assert.match(out.body, /Merge, don't close/);
assert.match(out.slack, /<https:\/\/github\.com\/n8n-io\/n8n\/pull\/42\|#42>/);
assert.match(out.slack, /merge this one, don't close it/);
});

View File

@ -11,24 +11,39 @@
*
* Whatever route it takes, the content 3.x receives is verified to be exactly the tree that
* merging 3.x with master produces (`git merge-tree`); a mismatch fails the run instead of
* pushing. That invariant is what makes the rewrite safe.
* pushing. That invariant is what makes the rewrite safe. When MECHANICAL files (see
* `MECHANICAL_PATHS`) had to be regenerated, the verification demands exactness everywhere
* BUT those files, which get their own checks instead (no markers, lockfile consistent
* with the pushed tree's manifests).
*
* Three cases:
* Four cases:
* 1. The replay is clean → force-push. No commit.
* 2. The replay stalls, but 3.x's content still reconciles with master → an earlier
* conflict was resolved in a merge, which leaves no patch to replay. Replay again
* favouring the 3.x side (`-X theirs`), mirroring the side the resolved merge kept;
* the human's fix commit is in the queue and does the real work. Tree is then proven.
* 3. The content does NOT reconcile → a genuinely new conflict. 3.x is left UNTOUCHED and a
* draft PR is opened on the sync branch carrying the conflict markers, attributed to the
* authors of the breaking commits behind the conflicted files. Syncs pause until it is
* merged.
* the human's fix commit is in the queue and does the real work. Stalls the strategy
* option cannot settle (modify/delete — `-X` never resolves those) are resolved in
* place toward the queue commit's side. Tree is then proven.
* 3. master conflicts with 3.x, but ONLY on mechanical files — tool-generated content
* with a deterministic resolution (the pnpm lockfile, bot-maintained data files).
* These are resolved in place while the replay is stopped, exactly as a human
* resolver would (regenerate the lockfile, take master's blob), and folded into the
* stalled commit — still no commit of its own and no PR. When the `-X theirs` route
* resolves lockfile hunks without stalling, the lockfile is reconciled at the tip
* instead (folded into the tip commit by amending — never a commit of its own).
* 4. The content does NOT reconcile on a real code path → a genuinely new conflict. 3.x
* is left UNTOUCHED and a draft PR is opened on the sync branch carrying the conflict
* markers — with the mechanical files pre-resolved, so the resolver only deals with
* real code — attributed to the authors of the breaking commits behind the conflicted
* files. Syncs pause until it is merged.
*
* The conflict branch carries the conflict markers, so the resolver sees exactly what clashed
* and the required checks stay red until they fix it in a commit of their own. That PR is
* merged with the normal GitHub merge button — master's commits arrive as-is and the fix stays
* its own commit. 3.x itself never has markers at its tip (nightly images build from it), and
* the merge commit holding them is dropped from its history by the next replay.
* its own commit. NEVER close a conflict PR unmerged: closing resolves nothing and the same
* conflict reopens on the next sync. 3.x itself never has markers at its tip (nightly images
* build from it), and the merge commit holding them is dropped from its history by the next
* replay.
*
* Runs from a checkout of the target branch (fetch-depth 0). Assumes credentials are NOT
* persisted by checkout — pushes go through an explicit token URL.
@ -39,7 +54,8 @@
* Env: GH_TOKEN (installation token with contents/pull-requests/issues write),
* GITHUB_REPOSITORY (owner/repo, auto-provided by Actions),
* SYNC_TARGET_BRANCH (optional — override 3.x to rehearse against a scratch branch).
* Requires Node 18+ (global fetch), git 2.38+ (`merge-tree --write-tree`) and `gh` on PATH.
* Requires Node 18+ (global fetch), git 2.38+ (`merge-tree --write-tree`), `gh` on PATH,
* and `pnpm` on PATH (lockfile conflict resolution).
*
* See .github/DEVELOPING_V3.md for the full v3 development model.
*/
@ -47,12 +63,33 @@
import { execFileSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { conflictedFiles, breakingShas, resolveLogins, buildOutputs } from './sync-conflict-owners.mjs';
import {
conflictedFiles,
breakingShas,
resolveLogins,
buildOutputs,
} from './sync-conflict-owners.mjs';
export const TARGET_BRANCH = '3.x';
export const SYNC_BRANCH = 'sync/master-to-3x';
export const CONFLICT_LABEL = 'automation:v3-sync';
export const LOCKFILE = 'pnpm-lock.yaml';
/**
* Paths whose conflicts are MECHANICAL: tool-generated files with a deterministic
* resolution, so no human judgement is lost by resolving them automatically.
* Keys are exact repo-relative paths; values pick the resolution strategy:
* - 'pnpm-regen': pnpm natively merges a conflicted lockfile when regenerating
* (`pnpm install --lockfile-only`).
* - 'take-master': bots regenerate the file on master, so master's side wins.
*/
export const MECHANICAL_PATHS = {
[LOCKFILE]: 'pnpm-regen',
'packages/frontend/editor-ui/data/node-popularity.json': 'take-master',
'.github/test-metrics/e2e-impact-map.json': 'take-master',
};
const BOT_NAME = 'n8n-assistant[bot]';
const BOT_EMAIL = 'n8n-assistant[bot]@users.noreply.github.com';
@ -60,6 +97,8 @@ const BOT_EMAIL = 'n8n-assistant[bot]@users.noreply.github.com';
// throwing on a non-zero exit (mirrors `set -e`). Injectable for tests.
const runGit = (args, opts = {}) => execFileSync('git', args, { encoding: 'utf8', ...opts }).trim();
const runGh = (args, opts = {}) => execFileSync('gh', args, { encoding: 'utf8', ...opts }).trim();
const runPnpm = (args, opts = {}) =>
execFileSync('pnpm', args, { encoding: 'utf8', ...opts }).trim();
// The branch to sync into. Overridable so the whole flow can be rehearsed end-to-end
// against a throwaway branch before it is pointed at the real 3.x.
@ -93,29 +132,164 @@ export function isAncestor(git, maybeAncestor, descendant) {
* The tree a merge of the two commits would produce — the definition of the content 3.x
* should end up with, computed without touching the working tree.
*
* `ok: false` means the two sides genuinely conflict: there is a new conflict that no
* existing commit on 3.x resolves.
* `ok: false` means the two sides genuinely conflict; `conflictedPaths` then names the
* clashing files, and `tree` is still written (conflicted blobs carry their markers) —
* it is the comparison baseline for the relaxed tree guard.
*/
export function mergeTree(git, a, b) {
const res = attempt(git, ['merge-tree', '--write-tree', a, b]);
return { ok: res.ok, tree: res.ok ? res.out.split('\n')[0].trim() : '', out: res.out };
const res = attempt(git, ['merge-tree', '--write-tree', '--name-only', a, b]);
// Line 0 is the toplevel tree OID (written even on conflict); the lines up to the
// first blank line are the conflicted filenames (`--name-only`). Informational
// messages follow the blank line, but `attempt` folds stderr in too — filter both.
const lines = res.out.split('\n');
const tree = lines[0]?.trim() ?? '';
let conflictedPaths = [];
if (!res.ok) {
const blank = lines.indexOf('', 1);
const section = lines.slice(1, blank === -1 ? undefined : blank);
conflictedPaths = [
...new Set(section.filter((l) => l && !/^(CONFLICT|Auto-merging|warning)/.test(l))),
];
}
return {
ok: res.ok,
tree: res.ok || conflictedPaths.length ? tree : '',
conflictedPaths,
out: res.out,
};
}
// Replay the 3.x-only commits onto master. Merge commits in the range (breaking PR merges,
// past sync merges) are flattened; commits already applied to master are dropped as empty.
// Individual commits, messages and authors are preserved — nothing is squashed.
export function tryRebase(git, masterSha, log = console.log, extraArgs = []) {
const { ok, out } = attempt(git, ['rebase', ...extraArgs, masterSha]);
if (!ok && out) log(out);
return ok;
// Split conflicted paths into mechanically-resolvable ones and real code conflicts.
export function classifyPaths(paths) {
const mechanical = [];
const code = [];
for (const p of paths) (MECHANICAL_PATHS[p] ? mechanical : code).push(p);
return { mechanical, code };
}
// The content guard for every push: whichever route produced HEAD, its tree must be exactly
// what merging 3.x with master yields.
export function assertTree(git, expectedTree) {
// A conflicted manifest makes lockfile regeneration meaningless until it is resolved
// (catalogs live in pnpm-workspace.yaml, so it counts as a manifest too).
export function blocksLockfileRegen(codePaths) {
return codePaths.some(
(p) => p === 'package.json' || p.endsWith('/package.json') || p === 'pnpm-workspace.yaml',
);
}
/**
* Resolve one mechanical path in the working tree (valid mid-merge and mid-rebase alike)
* and stage the result.
*/
export function resolveMechanicalPath({ git, pnpm, path, masterSha, log = console.log }) {
const strategy = MECHANICAL_PATHS[path];
if (strategy === 'pnpm-regen') {
// pnpm merges the conflict markers itself while regenerating. `--no-frozen-lockfile`
// is required: pnpm flips `--frozen-lockfile` on by default when CI=true.
log(`Regenerating ${path} with pnpm...`);
pnpm(['install', '--lockfile-only', '--no-frozen-lockfile']);
git(['add', '--', path]);
return;
}
log(`Resolving ${path} with master's version...`);
if (attempt(git, ['cat-file', '-e', `${masterSha}:${path}`]).ok) {
git(['checkout', masterSha, '--', path]);
} else {
git(['rm', '--force', '--', path]); // master deleted it
}
}
/**
* Resolve one stalled path by taking the side of the queue commit being replayed
* ("theirs" while a rebase is stopped). Exists for the stalls `-X theirs` cannot settle
* itself — modify/delete, which strategy options never resolve: when the queue commit
* deleted the file there is no stage 3, so the deletion wins. Only meaningful on the
* favoured replay passes, where the callers' tree guard proves the final content — a
* wrong pick here fails the run instead of pushing.
*/
export function resolveQueueSidePath({ git, path, log = console.log }) {
// `ls-files -u` lines are `<mode> <oid> <stage>\t<path>`; stage 3 is the queue side.
const stages = git(['ls-files', '-u', '--', path]);
const queueSideExists = stages.split('\n').some((line) => /^\S+ \S+ 3\t/.test(line));
if (queueSideExists) {
log(`Resolving ${path} with the replayed commit's version...`);
git(['checkout', '--theirs', '--', path]);
git(['add', '--', path]);
} else {
log(`Resolving ${path} with the replayed commit's deletion...`);
git(['rm', '--force', '--', path]);
}
}
/**
* Replay the 3.x-only commits onto master, resolving stalls that involve ONLY mechanical
* paths in place — folded into the stalled commit exactly as a human's `rebase --continue`
* would, so no commit is added. Bails (leaving the rebase stopped, for the caller to
* abort) as soon as a stall touches a real code path — unless `favourQueue` is set, in
* which case code-path stalls are resolved with the queue commit's side too. That flag
* belongs only on the favoured (`-X theirs`) passes, whose tree guard proves the outcome.
*
* Merge commits in the range (breaking PR merges, past conflict-PR merges) are flattened;
* commits already applied to master — or fully absorbed by a mechanical resolution — are
* dropped as empty. Individual commits, messages and authors are preserved.
*/
export function rebaseResolvingMechanical({
git,
pnpm,
masterSha,
log = console.log,
extraArgs = [],
favourQueue = false,
maxStalls = 50,
}) {
const resolved = new Set();
let res = attempt(git, ['rebase', ...extraArgs, masterSha]);
let stalls = 0;
while (!res.ok) {
if (++stalls > maxStalls) {
log(`Giving up after ${maxStalls} rebase stalls.`);
return { ok: false, resolved: [...resolved] };
}
const { mechanical, code } = classifyPaths(conflictedFiles(git));
// No conflicted files means the rebase failed for some other reason — don't guess.
if (mechanical.length + code.length === 0 || (code.length > 0 && !favourQueue)) {
if (res.out) log(res.out);
return { ok: false, resolved: [...resolved], conflictedCode: code };
}
for (const path of mechanical) {
resolveMechanicalPath({ git, pnpm, path, masterSha, log });
resolved.add(path);
}
for (const path of code) {
resolveQueueSidePath({ git, path, log });
}
// The resolution may have absorbed the whole commit — skip it rather than
// letting `--continue` refuse an empty commit.
const empty = attempt(git, ['diff-index', '--cached', '--quiet', 'HEAD']).ok;
res = attempt(git, empty ? ['rebase', '--skip'] : ['rebase', '--continue']);
}
return { ok: true, resolved: [...resolved] };
}
/**
* The content guard for every push: whichever route produced HEAD, its tree must be exactly
* what merging 3.x with master yields — except on `allowedPaths`, the mechanical files the
* merge itself could not resolve (always derived from the merge-tree conflict list, never
* from what the run happened to touch). With no `allowedPaths` this is exact equality.
*/
export function assertTreeMatches(git, expectedTree, allowedPaths = []) {
const actual = git(['rev-parse', 'HEAD^{tree}']);
if (actual !== expectedTree) {
throw new Error(`Replayed tree ${actual} does not match the merge tree ${expectedTree}; refusing to push.`);
if (actual === expectedTree) return;
if (allowedPaths.length === 0) {
throw new Error(
`Replayed tree ${actual} does not match the merge tree ${expectedTree}; refusing to push.`,
);
}
const out = git(['diff-tree', '-r', '--name-only', '--no-renames', expectedTree, actual]);
const allowed = new Set(allowedPaths);
const violations = (out ? out.split('\n') : []).filter((p) => p && !allowed.has(p));
if (violations.length > 0) {
throw new Error(
`Replayed tree deviates from the merge tree on non-mechanical paths:\n${violations.join('\n')}\nrefusing to push.`,
);
}
}
@ -123,8 +297,63 @@ export function assertTree(git, expectedTree) {
// with matches, 1 with none and >1 on error, so an error must not be read as "clean".
export function assertNoMarkers(git, rev = 'HEAD') {
const found = attempt(git, ['grep', '-I', '-l', '-e', '^<<<<<<< ', '-e', '^>>>>>>> ', rev]);
if (found.ok) throw new Error(`Refusing to continue: conflict markers present in ${rev}:\n${found.out}`);
if (found.out.includes('fatal:')) throw new Error(`Could not scan ${rev} for conflict markers:\n${found.out}`);
if (found.ok)
throw new Error(`Refusing to continue: conflict markers present in ${rev}:\n${found.out}`);
if (found.out.includes('fatal:'))
throw new Error(`Could not scan ${rev} for conflict markers:\n${found.out}`);
}
/**
* Fold any residual deviation between the replayed tip and the proven merge tree into the
* tip commit. `-X theirs` resolves overlapping hunks toward the queue side without
* stalling — silently dropping master-side changes (lockfile hunks are the usual case)
* that no fix commit in the queue reasserts. The merge tree is the definition of the
* correct content, so its blobs are taken verbatim. `excludePaths` names files the merge
* tree itself could not resolve (marker-carrying mechanical blobs), which get their own
* reconciliation. Never a commit of its own, and never a rewrite of a master commit.
*/
export function reconcileWithMergeTreeAtTip({
git,
mergedTree,
masterSha,
excludePaths = [],
log = console.log,
}) {
if (git(['rev-parse', 'HEAD^{tree}']) === mergedTree) return [];
const out = git(['diff-tree', '-r', '--name-only', '--no-renames', mergedTree, 'HEAD']);
const excluded = new Set(excludePaths);
const paths = (out ? out.split('\n') : []).filter((p) => p && !excluded.has(p));
if (paths.length === 0) return [];
if (git(['rev-parse', 'HEAD']) === masterSha) {
throw new Error('Merge-tree reconciliation would amend a master commit; refusing.');
}
log(`Folding the merge tree's content for ${paths.join(', ')} into the tip commit.`);
for (const path of paths) {
if (attempt(git, ['cat-file', '-e', `${mergedTree}:${path}`]).ok) {
git(['checkout', mergedTree, '--', path]);
} else {
git(['rm', '--force', '--', path]); // the merge deleted it
}
}
git(['commit', '--amend', '--no-edit', '--no-verify']);
return paths;
}
/**
* Make the lockfile at the replayed tip consistent with the tip's own manifests. A regen
* at a stall usually already did this, but the `-X theirs` route resolves lockfile hunks
* without stalling — so the reconciliation happens here, folded into the tip commit by
* amending. Never a commit of its own, and never a rewrite of a master commit.
*/
export function reconcileLockfileAtTip({ git, pnpm, masterSha, log = console.log }) {
pnpm(['install', '--lockfile-only', '--no-frozen-lockfile']);
if (attempt(git, ['diff', '--quiet', '--', LOCKFILE]).ok) return;
if (git(['rev-parse', 'HEAD']) === masterSha) {
throw new Error('Lockfile reconciliation would amend a master commit; refusing.');
}
log('Folding a lockfile reconciliation into the tip commit.');
git(['add', '--', LOCKFILE]);
git(['commit', '--amend', '--no-edit', '--no-verify']);
}
// Append key=value lines to $GITHUB_OUTPUT (no-op when running outside Actions).
@ -139,32 +368,86 @@ export function writeGithubOutput(obj, env = process.env) {
/**
* Build the conflict branch: master merged into 3.x with the conflict markers committed as
* they are. The markers are the review surface — the resolver sees exactly what clashed, and
* the required checks stay red until they fix it, so the PR cannot be merged half-resolved
* (an auto-resolved branch would be green with master's change silently dropped).
* they are — except mechanical files, which are pre-resolved so the resolver only deals
* with real code conflicts. The remaining markers are the review surface: the resolver sees
* exactly what clashed, and the required checks stay red until they fix it, so the PR
* cannot be merged half-resolved (an auto-resolved branch would be green with master's
* change silently dropped).
*
* 3.x never carries them at its tip, and not for long in its history either: this merge
* commit is dropped by the next replay, which takes the queue's commits only.
* The lockfile is left with its markers when a manifest is among the code conflicts
* (regenerating is meaningless until the manifests are resolved) or when the regen fails
* transiently — flagged via `lockfileDeferred` so the PR body carries the instruction.
*
* @returns {string[]} the conflicted files
* 3.x never carries the markers at its tip, and not for long in its history either: this
* merge commit is dropped by the next replay, which takes the queue's commits only.
*
* @returns {{ files: string[], preResolved: string[], lockfileDeferred: boolean }}
* `files` is the list the PR reports and attributes owners for: the code conflicts,
* or every conflict when none are code (a fallback after a failed auto-resolution).
*/
export function buildConflictBranch({ git, masterSha, log = console.log }) {
export function buildConflictBranch({ git, pnpm, masterSha, log = console.log }) {
const merge = attempt(git, ['merge', '--no-edit', masterSha]);
if (merge.ok) {
// merge-tree said these conflict; if a real merge disagrees, don't guess.
throw new Error('Expected a merge conflict but the merge succeeded; aborting to avoid guessing.');
throw new Error(
'Expected a merge conflict but the merge succeeded; aborting to avoid guessing.',
);
}
if (merge.out) log(merge.out);
const files = conflictedFiles(git);
const all = conflictedFiles(git);
const { mechanical, code } = classifyPaths(all);
const preResolved = [];
let lockfileDeferred = false;
for (const path of mechanical) {
const needsManifests = MECHANICAL_PATHS[path] === 'pnpm-regen' && blocksLockfileRegen(code);
if (needsManifests) {
lockfileDeferred = true;
continue;
}
try {
resolveMechanicalPath({ git, pnpm, path, masterSha, log });
preResolved.push(path);
} catch (error) {
// Degrade gracefully (e.g. a transient registry failure): leave the markers
// for the resolver rather than failing the PR-opening path.
log(`warning: could not pre-resolve ${path}: ${error.message}`);
if (MECHANICAL_PATHS[path] === 'pnpm-regen') lockfileDeferred = true;
}
}
git(['add', '-A']);
git(['commit', '--no-edit', '--no-verify']);
return files;
return { files: code.length > 0 ? code : all, preResolved, lockfileDeferred };
}
// Conflict PRs that were recently closed WITHOUT being merged — closing resolves nothing,
// so the same conflict is about to come back; the new PR and Slack message call it out.
export function recentAbandonedConflictPrs(
gh,
{ label = CONFLICT_LABEL, sinceDays = 14, now = Date.now() } = {},
) {
const out = gh([
'pr',
'list',
'--state',
'closed',
'--label',
label,
'--json',
'number,url,mergedAt,closedAt',
'--limit',
'10',
]);
return JSON.parse(out || '[]').filter(
(pr) => !pr.mergedAt && pr.closedAt && now - Date.parse(pr.closedAt) < sinceDays * 86_400_000,
);
}
/**
* Push the marker-free conflict branch and open a draft PR, attributing it to the authors of
* the breaking commits behind the conflicted files. 3.x is left untouched.
* Push the marker-carrying conflict branch and open a draft PR, attributing it to the
* authors of the breaking commits behind the conflicted files. 3.x is left untouched.
*
* @returns {Promise<{ prUrl: string, ownersSlack: string }>}
*/
@ -178,6 +461,8 @@ export async function openConflictPr({
pushUrl,
target = TARGET_BRANCH,
files = [],
preResolved = [],
lockfileDeferred = false,
fetchFn = fetch,
log = console.log,
}) {
@ -193,24 +478,50 @@ export async function openConflictPr({
log(`warning: could not resolve owners: ${error.message}`);
}
let abandoned = [];
try {
abandoned = recentAbandonedConflictPrs(gh);
} catch (error) {
log(`warning: could not check for abandoned conflict PRs: ${error.message}`);
}
const { ownersCsv, slack, body } = buildOutputs({
syncBranch: SYNC_BRANCH,
targetBranch: target,
files,
owners,
preResolved,
lockfileDeferred,
abandoned,
});
git(['push', '--force', pushUrl, `HEAD:refs/heads/${SYNC_BRANCH}`]);
// Ensure the label exists (idempotent), then open the draft conflict PR.
gh(['label', 'create', CONFLICT_LABEL, '--color', 'B60205', '--description', 'master→3.x sync conflict', '--force']);
gh([
'label',
'create',
CONFLICT_LABEL,
'--color',
'B60205',
'--description',
'master→3.x sync conflict',
'--force',
]);
const prUrl = gh([
'pr', 'create', '--draft',
'--base', target,
'--head', SYNC_BRANCH,
'--label', CONFLICT_LABEL,
'--title', 'chore: Resolve master→3.x sync conflict',
'--body', body,
'pr',
'create',
'--draft',
'--base',
target,
'--head',
SYNC_BRANCH,
'--label',
CONFLICT_LABEL,
'--title',
'chore: Resolve master→3.x sync conflict',
'--body',
body,
]);
// Request owners as reviewers (best-effort: the API rejects the PR author
@ -229,6 +540,7 @@ export async function openConflictPr({
export async function sync({
git = runGit,
gh = runGh,
pnpm = runPnpm,
env = process.env,
fetchFn = fetch,
log = console.log,
@ -244,7 +556,12 @@ export async function sync({
const pushUrl = `https://x-access-token:${token}@github.com/${repo}.git`;
// The lease pins the tip we replayed from, so a concurrent push is never overwritten.
const pushReplay = (from) =>
git(['push', `--force-with-lease=refs/heads/${target}:${from}`, pushUrl, `HEAD:refs/heads/${target}`]);
git([
'push',
`--force-with-lease=refs/heads/${target}:${from}`,
pushUrl,
`HEAD:refs/heads/${target}`,
]);
// Halt gate: if a previous conflict PR is still open, do nothing until it is merged.
if (hasOpenConflictPr(gh)) {
@ -254,6 +571,8 @@ export async function sync({
git(['config', 'user.name', BOT_NAME]);
git(['config', 'user.email', BOT_EMAIL]);
// `rebase --continue` insists on an editor for the commit message; keep it headless.
git(['config', 'core.editor', 'true']);
git(['fetch', 'origin', 'master']);
// Pin to the fetched SHA — a command-line refspec doesn't reliably update the
@ -266,43 +585,130 @@ export async function sync({
return;
}
const queue = git(['log', '--no-merges', '--reverse', '--format=%h %s', `${masterSha}..${preHead}`]);
const queue = git([
'log',
'--no-merges',
'--reverse',
'--format=%h %s',
`${masterSha}..${preHead}`,
]);
log(`Replaying onto master ${masterSha}:\n${queue || '(none)'}`);
// What the content must end up as, and whether a new conflict exists at all.
const merged = mergeTree(git, preHead, masterSha);
if (!merged.ok) {
log(`master conflicts with ${target} — leaving ${target} untouched and opening a conflict PR.`);
const files = buildConflictBranch({ git, masterSha, log });
const { mechanical, code } = classifyPaths(merged.conflictedPaths);
if (code.length === 0 && mechanical.length > 0) {
log(
`master conflicts with ${target} only on mechanical files (${mechanical.join(', ')}); auto-resolving.`,
);
let replay = rebaseResolvingMechanical({ git, pnpm, masterSha, log });
if (!replay.ok) {
// An intermediate stall on a code file whose endpoints still reconcile —
// same "resolved in a merge" situation as the clean-tree second pass.
attempt(git, ['rebase', '--abort']);
replay = rebaseResolvingMechanical({
git,
pnpm,
masterSha,
log,
extraArgs: ['-X', 'theirs'],
favourQueue: true,
});
}
if (replay.ok) {
// The favoured retry may have drifted from the merge tree on cleanly-merging
// paths; fold those back before the mechanical files get their own treatment.
reconcileWithMergeTreeAtTip({
git,
mergedTree: merged.tree,
masterSha,
excludePaths: mechanical,
log,
});
if (mechanical.includes(LOCKFILE)) reconcileLockfileAtTip({ git, pnpm, masterSha, log });
assertTreeMatches(git, merged.tree, mechanical);
assertNoMarkers(git);
pushReplay(preHead);
log(
`Replayed ${target} onto master, auto-resolving ${mechanical.join(', ')} — no PR, no sync commit.`,
);
return;
}
attempt(git, ['rebase', '--abort']);
log('Mechanical auto-resolution did not complete; falling back to a conflict PR.');
} else {
log(
`master conflicts with ${target} — leaving ${target} untouched and opening a conflict PR.`,
);
}
const { files, preResolved, lockfileDeferred } = buildConflictBranch({
git,
pnpm,
masterSha,
log,
});
const { prUrl, ownersSlack } = await openConflictPr({
git, gh, repo, token, masterSha, preHead, pushUrl, target, files, fetchFn, log,
git,
gh,
repo,
token,
masterSha,
preHead,
pushUrl,
target,
files,
preResolved,
lockfileDeferred,
fetchFn,
log,
});
writeGithubOutput({ conflict_pr: prUrl, conflict_owners: ownersSlack }, env);
return;
}
if (tryRebase(git, masterSha, log)) {
assertTree(git, merged.tree);
const plainReplay = attempt(git, ['rebase', masterSha]);
if (plainReplay.ok) {
assertTreeMatches(git, merged.tree);
assertNoMarkers(git);
pushReplay(preHead);
log(`Replayed ${target} onto master — no sync commit created.`);
return;
}
if (plainReplay.out) log(plainReplay.out);
// The content reconciles but the patches no longer apply on their own: an earlier
// conflict was resolved in a merge, and a merge resolution leaves no patch to replay.
// Replay favouring the 3.x side — the same side that resolved merge kept — and let the
// resolver's own fix commit, which is in the queue, do the real work. Nothing is
// squashed; the tree guard below proves the outcome.
// resolver's own fix commit, which is in the queue, do the real work. Stalls the
// strategy option cannot settle (modify/delete — `-X` never resolves those) are
// resolved in place toward the queue commit's side. Nothing is squashed; the tree
// guard below proves the outcome exactly, since the merge was clean.
git(['rebase', '--abort']);
log(`Patches no longer apply individually; replaying with ${target}'s side favoured.`);
if (!tryRebase(git, masterSha, log, ['-X', 'theirs'])) {
const favoured = rebaseResolvingMechanical({
git,
pnpm,
masterSha,
log,
extraArgs: ['-X', 'theirs'],
favourQueue: true,
});
if (!favoured.ok) {
git(['rebase', '--abort']);
throw new Error(`Could not replay ${target} onto master even with its own side favoured; needs a human.`);
throw new Error(
`Could not replay ${target} onto master even with its own side favoured; needs a human.`,
);
}
assertTree(git, merged.tree);
// Favouring resolves overlapping hunks toward 3.x without stalling, which can drop
// master-side changes no fix commit reasserts; the merge was clean, so the merge tree
// is the correct content — fold any drift back into the tip.
reconcileWithMergeTreeAtTip({ git, mergedTree: merged.tree, masterSha, log });
assertTreeMatches(git, merged.tree);
assertNoMarkers(git);
pushReplay(preHead);
log(`Replayed ${target} onto master — no sync commit created.`);

View File

@ -4,8 +4,15 @@ import { test } from 'node:test';
import {
hasOpenConflictPr,
mergeTree,
tryRebase,
assertTree,
classifyPaths,
blocksLockfileRegen,
resolveMechanicalPath,
resolveQueueSidePath,
rebaseResolvingMechanical,
reconcileWithMergeTreeAtTip,
reconcileLockfileAtTip,
recentAbandonedConflictPrs,
assertTreeMatches,
assertNoMarkers,
buildConflictBranch,
openConflictPr,
@ -14,9 +21,10 @@ import {
CONFLICT_LABEL,
SYNC_BRANCH,
TARGET_BRANCH,
LOCKFILE,
} from './sync-master-to-3x.mjs';
// A git/gh stub: routes calls by a matcher, records every invocation.
// A git/gh/pnpm stub: routes calls by a matcher, records every invocation.
function makeStub(routes = []) {
const calls = [];
const fn = (args) => {
@ -30,24 +38,39 @@ function makeStub(routes = []) {
return fn;
}
const fail = (stdout = '') => () => {
const err = new Error('command failed');
err.status = 1;
err.stdout = stdout;
throw err;
};
const fail =
(stdout = '') =>
() => {
const err = new Error('command failed');
err.status = 1;
err.stdout = stdout;
throw err;
};
const okFetch = (logins) => async () => ({
ok: true,
json: async () => ({ data: { repository: Object.fromEntries(logins.map((l, i) => [`c${i}`, { author: { user: { login: l } } }])) } }),
json: async () => ({
data: {
repository: Object.fromEntries(
logins.map((l, i) => [`c${i}`, { author: { user: { login: l } } }]),
),
},
}),
});
const PRE_HEAD = 'PREHEAD';
const MASTER = 'MASTERSHA';
const MERGE_TREE = 'MERGETREEOID';
const POPULARITY = 'packages/frontend/editor-ui/data/node-popularity.json';
const isRebase = (a) => a[0] === 'rebase' && a[1] !== '--abort';
const favouringOwnSide = (a) => a[0] === 'rebase' && a.includes('-X') && a.includes('theirs');
const isConflictedFiles = (a) => a[0] === 'diff' && a.includes('--diff-filter=U');
// What `git merge-tree --write-tree --name-only` emits on a conflict: the (marker-carrying)
// tree OID, the conflicted paths, a blank line, then informational messages.
const conflictedMergeTree = (...paths) =>
fail(`${MERGE_TREE}\n${paths.join('\n')}\n\nCONFLICT (content): Merge conflict in ${paths[0]}`);
// Routes shared by every sync path: master fetched, 3.x hasn't absorbed it yet, and the
// merge tree is computable (no new conflict). git grep exits non-zero => no markers.
@ -71,45 +94,373 @@ test('targetBranch defaults to 3.x and honours the rehearsal override', () => {
test('hasOpenConflictPr reflects the open-PR count from gh', () => {
const empty = makeStub([[() => true, '[]']]);
assert.equal(hasOpenConflictPr(empty), false);
assert.deepEqual(empty.calls[0], ['pr', 'list', '--state', 'open', '--label', CONFLICT_LABEL, '--json', 'number']);
assert.deepEqual(empty.calls[0], [
'pr',
'list',
'--state',
'open',
'--label',
CONFLICT_LABEL,
'--json',
'number',
]);
const one = makeStub([[() => true, JSON.stringify([{ number: 42 }])]]);
assert.equal(hasOpenConflictPr(one), true);
});
test('mergeTree reports the tree when clean and not-ok when the sides conflict', () => {
test('mergeTree reports the tree when clean, and the conflicted paths when the sides conflict', () => {
const clean = makeStub([[() => true, `${MERGE_TREE}\nsome extra output`]]);
assert.deepEqual(mergeTree(clean, 'A', 'B'), { ok: true, tree: MERGE_TREE, out: `${MERGE_TREE}\nsome extra output` });
assert.deepEqual(clean.calls[0], ['merge-tree', '--write-tree', 'A', 'B']);
assert.deepEqual(mergeTree(clean, 'A', 'B'), {
ok: true,
tree: MERGE_TREE,
conflictedPaths: [],
out: `${MERGE_TREE}\nsome extra output`,
});
assert.deepEqual(clean.calls[0], ['merge-tree', '--write-tree', '--name-only', 'A', 'B']);
const conflicting = makeStub([[() => true, fail('CONFLICT (content): x.ts')]]);
assert.equal(mergeTree(conflicting, 'A', 'B').ok, false);
const conflicting = makeStub([
[
() => true,
fail(
`${MERGE_TREE}\n${LOCKFILE}\npackages/cli/x.ts\n\nAuto-merging ${LOCKFILE}\nCONFLICT (content): Merge conflict in ${LOCKFILE}`,
),
],
]);
const res = mergeTree(conflicting, 'A', 'B');
assert.equal(res.ok, false);
// The conflicted tree is still written — it is the baseline for the relaxed guard.
assert.equal(res.tree, MERGE_TREE);
assert.deepEqual(res.conflictedPaths, [LOCKFILE, 'packages/cli/x.ts']);
});
test('tryRebase replays onto master and can favour a side', () => {
const git = makeStub([[(a) => a[0] === 'rebase', '']]);
assert.equal(tryRebase(git, MASTER, () => {}), true);
assert.deepEqual(git.calls[0], ['rebase', MASTER]);
test('classifyPaths and blocksLockfileRegen split mechanical from code conflicts', () => {
const { mechanical, code } = classifyPaths([
LOCKFILE,
'packages/cli/x.ts',
'.github/test-metrics/e2e-impact-map.json',
]);
assert.deepEqual(mechanical, [LOCKFILE, '.github/test-metrics/e2e-impact-map.json']);
assert.deepEqual(code, ['packages/cli/x.ts']);
assert.equal(tryRebase(git, MASTER, () => {}, ['-X', 'theirs']), true);
assert.deepEqual(git.calls[1], ['rebase', '-X', 'theirs', MASTER]);
assert.equal(blocksLockfileRegen(['packages/cli/package.json']), true);
assert.equal(blocksLockfileRegen(['package.json']), true);
assert.equal(blocksLockfileRegen(['pnpm-workspace.yaml']), true);
assert.equal(blocksLockfileRegen(['packages/cli/x.ts']), false);
});
test('assertTree and assertNoMarkers are the push guards', () => {
assert.doesNotThrow(() => assertTree(makeStub([[() => true, MERGE_TREE]]), MERGE_TREE));
assert.throws(() => assertTree(makeStub([[() => true, 'OTHER']]), MERGE_TREE), /does not match the merge tree/);
test('resolveMechanicalPath takes the blob from master, or the deletion when master removed the file', () => {
const present = makeStub([[(a) => a[0] === 'cat-file', '']]);
resolveMechanicalPath({
git: present,
pnpm: makeStub(),
path: POPULARITY,
masterSha: MASTER,
log: () => {},
});
assert.ok(
present.calls.some((a) => a[0] === 'checkout' && a[1] === MASTER && a.includes(POPULARITY)),
);
const deleted = makeStub([[(a) => a[0] === 'cat-file', fail()]]);
resolveMechanicalPath({
git: deleted,
pnpm: makeStub(),
path: POPULARITY,
masterSha: MASTER,
log: () => {},
});
assert.ok(
deleted.calls.some((a) => a[0] === 'rm' && a.includes('--force') && a.includes(POPULARITY)),
);
});
test('resolveMechanicalPath regenerates the lockfile with pnpm and stages it', () => {
const git = makeStub();
const pnpm = makeStub();
resolveMechanicalPath({ git, pnpm, path: LOCKFILE, masterSha: MASTER, log: () => {} });
// `--no-frozen-lockfile` is load-bearing: pnpm defaults to frozen when CI=true.
assert.deepEqual(pnpm.calls[0], ['install', '--lockfile-only', '--no-frozen-lockfile']);
assert.ok(git.calls.some((a) => a[0] === 'add' && a.includes(LOCKFILE)));
});
test('rebaseResolvingMechanical resolves mechanical stalls in place and skips emptied commits', () => {
const git = makeStub([
[(a) => a[0] === 'rebase' && a[1] === '--skip', ''],
[isRebase, fail(`CONFLICT (content): Merge conflict in ${POPULARITY}`)],
[isConflictedFiles, POPULARITY],
[(a) => a[0] === 'cat-file', ''],
[(a) => a[0] === 'diff-index', ''], // resolution emptied the commit
]);
const res = rebaseResolvingMechanical({
git,
pnpm: makeStub(),
masterSha: MASTER,
log: () => {},
});
assert.equal(res.ok, true);
assert.deepEqual(res.resolved, [POPULARITY]);
assert.ok(git.calls.some((a) => a[0] === 'checkout' && a[1] === MASTER));
assert.ok(git.calls.some((a) => a[0] === 'rebase' && a[1] === '--skip'));
});
test('rebaseResolvingMechanical bails as soon as a stall touches a code path', () => {
const git = makeStub([
[isRebase, fail('CONFLICT (content): Merge conflict in packages/cli/x.ts')],
[isConflictedFiles, `packages/cli/x.ts\n${LOCKFILE}`],
]);
const pnpm = makeStub();
const res = rebaseResolvingMechanical({ git, pnpm, masterSha: MASTER, log: () => {} });
assert.equal(res.ok, false);
assert.deepEqual(res.conflictedCode, ['packages/cli/x.ts']);
// Nothing may be auto-resolved when human judgement is needed for the same stall.
assert.equal(pnpm.calls.length, 0);
assert.equal(
git.calls.some((a) => a[0] === 'checkout'),
false,
);
});
test('resolveQueueSidePath takes the queue commit side, or its deletion when there is no stage 3', () => {
const FILE = 'packages/cli/x.ts';
const bothSides = makeStub([
[(a) => a[0] === 'ls-files', `100644 aaa 2\t${FILE}\n100644 bbb 3\t${FILE}`],
]);
resolveQueueSidePath({ git: bothSides, path: FILE, log: () => {} });
assert.ok(bothSides.calls.some((a) => a[0] === 'checkout' && a[1] === '--theirs'));
assert.ok(bothSides.calls.some((a) => a[0] === 'add' && a.includes(FILE)));
// modify/delete with the queue commit deleting: stages 1 and 2 only.
const queueDeleted = makeStub([
[(a) => a[0] === 'ls-files', `100644 aaa 1\t${FILE}\n100644 bbb 2\t${FILE}`],
]);
resolveQueueSidePath({ git: queueDeleted, path: FILE, log: () => {} });
assert.ok(queueDeleted.calls.some((a) => a[0] === 'rm' && a.includes(FILE)));
assert.equal(
queueDeleted.calls.some((a) => a[0] === 'checkout'),
false,
);
});
test('rebaseResolvingMechanical with favourQueue resolves a modify/delete code stall and continues', () => {
const FILE = 'packages/nodes-base/nodes/Function/Function.node.ts';
const git = makeStub([
[(a) => a[0] === 'rebase' && a[1] === '--continue', ''],
[isRebase, fail(`CONFLICT (modify/delete): ${FILE} deleted in 0ff923a066`)],
[isConflictedFiles, FILE],
[(a) => a[0] === 'ls-files', `100644 aaa 1\t${FILE}\n100644 bbb 2\t${FILE}`],
[(a) => a[0] === 'diff-index', fail()], // staged deletion -> continue, not skip
]);
const pnpm = makeStub();
const res = rebaseResolvingMechanical({
git,
pnpm,
masterSha: MASTER,
favourQueue: true,
log: () => {},
});
assert.equal(res.ok, true);
assert.ok(git.calls.some((a) => a[0] === 'rm' && a.includes(FILE)));
assert.ok(git.calls.some((a) => a[0] === 'rebase' && a[1] === '--continue'));
// The stall carried no mechanical file, so nothing mechanical may be touched.
assert.equal(pnpm.calls.length, 0);
});
test('assertTreeMatches is exact by default and scoped to the allowed paths otherwise', () => {
assert.doesNotThrow(() => assertTreeMatches(makeStub([[() => true, MERGE_TREE]]), MERGE_TREE));
assert.throws(
() => assertTreeMatches(makeStub([[() => true, 'OTHER']]), MERGE_TREE),
/does not match the merge tree/,
);
const onlyLockfile = makeStub([
[(a) => a[0] === 'rev-parse', 'OTHER'],
[(a) => a[0] === 'diff-tree', LOCKFILE],
]);
assert.doesNotThrow(() => assertTreeMatches(onlyLockfile, MERGE_TREE, [LOCKFILE]));
const alsoCode = makeStub([
[(a) => a[0] === 'rev-parse', 'OTHER'],
[(a) => a[0] === 'diff-tree', `${LOCKFILE}\npackages/cli/x.ts`],
]);
assert.throws(() => assertTreeMatches(alsoCode, MERGE_TREE, [LOCKFILE]), /non-mechanical paths/);
});
test('assertNoMarkers is a push guard', () => {
assert.doesNotThrow(() => assertNoMarkers(makeStub([[() => true, fail()]])));
assert.throws(() => assertNoMarkers(makeStub([[() => true, 'packages/cli/x.ts']])), /conflict markers present/);
assert.throws(
() => assertNoMarkers(makeStub([[() => true, 'packages/cli/x.ts']])),
/conflict markers present/,
);
// git grep failing for any other reason must not read as "clean".
assert.throws(() => assertNoMarkers(makeStub([[() => true, fail('fatal: bad object')]])), /Could not scan/);
assert.throws(
() => assertNoMarkers(makeStub([[() => true, fail('fatal: bad object')]])),
/Could not scan/,
);
});
test('reconcileWithMergeTreeAtTip folds favoured-replay drift into the tip commit, never a master commit', () => {
// No drift: nothing to do.
const clean = makeStub([[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', MERGE_TREE]]);
assert.deepEqual(
reconcileWithMergeTreeAtTip({
git: clean,
mergedTree: MERGE_TREE,
masterSha: MASTER,
log: () => {},
}),
[],
);
assert.equal(
clean.calls.some((a) => a[0] === 'commit'),
false,
);
// Lockfile drift: take the merge tree's blob and amend the tip.
const drifted = makeStub([
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'OTHER'],
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', PRE_HEAD],
[(a) => a[0] === 'diff-tree', LOCKFILE],
[(a) => a[0] === 'cat-file', ''],
]);
assert.deepEqual(
reconcileWithMergeTreeAtTip({
git: drifted,
mergedTree: MERGE_TREE,
masterSha: MASTER,
log: () => {},
}),
[LOCKFILE],
);
assert.ok(
drifted.calls.some((a) => a[0] === 'checkout' && a[1] === MERGE_TREE && a.includes(LOCKFILE)),
);
assert.ok(drifted.calls.some((a) => a[0] === 'commit' && a.includes('--amend')));
// Excluded (mechanical) paths are left to their own reconciliation.
const excluded = makeStub([
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'OTHER'],
[(a) => a[0] === 'diff-tree', LOCKFILE],
]);
assert.deepEqual(
reconcileWithMergeTreeAtTip({
git: excluded,
mergedTree: MERGE_TREE,
masterSha: MASTER,
excludePaths: [LOCKFILE],
log: () => {},
}),
[],
);
assert.equal(
excluded.calls.some((a) => a[0] === 'commit'),
false,
);
// A path the merge deleted is removed, not checked out.
const deleted = makeStub([
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'OTHER'],
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', PRE_HEAD],
[(a) => a[0] === 'diff-tree', 'packages/cli/gone.ts'],
[(a) => a[0] === 'cat-file', fail()],
]);
reconcileWithMergeTreeAtTip({
git: deleted,
mergedTree: MERGE_TREE,
masterSha: MASTER,
log: () => {},
});
assert.ok(deleted.calls.some((a) => a[0] === 'rm' && a.includes('packages/cli/gone.ts')));
// Never rewrite a master commit.
const atMasterTip = makeStub([
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'OTHER'],
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', MASTER],
[(a) => a[0] === 'diff-tree', LOCKFILE],
]);
assert.throws(
() =>
reconcileWithMergeTreeAtTip({
git: atMasterTip,
mergedTree: MERGE_TREE,
masterSha: MASTER,
log: () => {},
}),
/amend a master commit/,
);
});
test('reconcileLockfileAtTip folds an inconsistent lockfile into the tip commit, never a master commit', () => {
const consistent = makeStub([[(a) => a[0] === 'diff', '']]);
reconcileLockfileAtTip({ git: consistent, pnpm: makeStub(), masterSha: MASTER, log: () => {} });
assert.equal(
consistent.calls.some((a) => a[0] === 'commit'),
false,
);
const inconsistent = makeStub([
[(a) => a[0] === 'diff', fail()],
[(a) => a[0] === 'rev-parse', PRE_HEAD],
]);
const pnpm = makeStub();
reconcileLockfileAtTip({ git: inconsistent, pnpm, masterSha: MASTER, log: () => {} });
assert.deepEqual(pnpm.calls[0], ['install', '--lockfile-only', '--no-frozen-lockfile']);
assert.ok(inconsistent.calls.some((a) => a[0] === 'commit' && a.includes('--amend')));
const atMasterTip = makeStub([
[(a) => a[0] === 'diff', fail()],
[(a) => a[0] === 'rev-parse', MASTER],
]);
assert.throws(
() =>
reconcileLockfileAtTip({
git: atMasterTip,
pnpm: makeStub(),
masterSha: MASTER,
log: () => {},
}),
/amend a master commit/,
);
});
test('recentAbandonedConflictPrs keeps only recently closed-unmerged conflict PRs', () => {
const now = Date.parse('2026-08-10T00:00:00Z');
const gh = makeStub([
[
() => true,
JSON.stringify([
{
number: 1,
url: 'u1',
mergedAt: '2026-08-01T00:00:00Z',
closedAt: '2026-08-01T00:00:00Z',
},
{ number: 2, url: 'u2', mergedAt: null, closedAt: '2026-07-01T00:00:00Z' }, // too old
{ number: 3, url: 'u3', mergedAt: null, closedAt: '2026-08-08T00:00:00Z' },
]),
],
]);
const abandoned = recentAbandonedConflictPrs(gh, { now });
assert.deepEqual(
abandoned.map((pr) => pr.number),
[3],
);
assert.equal(gh.calls[0][gh.calls[0].indexOf('--state') + 1], 'closed');
});
test('sync replays and force-pushes with a lease, creating no commit', async () => {
const git = makeStub([...baseGitRoutes, [isRebase, '']]);
const gh = makeStub(noOpenPr);
await sync({ git, gh, env, log: () => {} });
await sync({ git, gh, pnpm: makeStub(), env, log: () => {} });
const push = git.calls.find((a) => a[0] === 'push');
assert.deepEqual(push, [
@ -119,16 +470,31 @@ test('sync replays and force-pushes with a lease, creating no commit', async ()
`HEAD:refs/heads/${TARGET_BRANCH}`,
]);
// The point of the change: no merge, no squash, no PR on the clean path.
assert.equal(git.calls.some((a) => a[0] === 'merge'), false);
assert.equal(git.calls.some((a) => a[0] === 'commit'), false);
assert.equal(gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'), false);
assert.equal(
git.calls.some((a) => a[0] === 'merge'),
false,
);
assert.equal(
git.calls.some((a) => a[0] === 'commit'),
false,
);
assert.equal(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'),
false,
);
});
test('sync targets the rehearsal branch when SYNC_TARGET_BRANCH is set', async () => {
const git = makeStub([...baseGitRoutes, [isRebase, '']]);
const gh = makeStub(noOpenPr);
await sync({ git, gh, env: { ...env, SYNC_TARGET_BRANCH: '3x-sync-test' }, log: () => {} });
await sync({
git,
gh,
pnpm: makeStub(),
env: { ...env, SYNC_TARGET_BRANCH: '3x-sync-test' },
log: () => {},
});
assert.equal(git.calls.find((a) => a[0] === 'push').at(-1), 'HEAD:refs/heads/3x-sync-test');
});
@ -141,10 +507,18 @@ test('sync does nothing when 3.x already contains master', async () => {
]);
const gh = makeStub(noOpenPr);
await sync({ git, gh, env, log: () => {} });
await sync({ git, gh, pnpm: makeStub(), env, log: () => {} });
assert.equal(git.calls.some((a) => a[0] === 'rebase'), false, 'must not rebase');
assert.equal(git.calls.some((a) => a[0] === 'push'), false, 'must not push');
assert.equal(
git.calls.some((a) => a[0] === 'rebase'),
false,
'must not rebase',
);
assert.equal(
git.calls.some((a) => a[0] === 'push'),
false,
'must not push',
);
});
test('sync refuses to push when the replayed tree is not the merge tree', async () => {
@ -155,15 +529,22 @@ test('sync refuses to push when the replayed tree is not the merge tree', async
]);
const gh = makeStub(noOpenPr);
await assert.rejects(() => sync({ git, gh, env, log: () => {} }), /does not match the merge tree/);
assert.equal(git.calls.some((a) => a[0] === 'push'), false, 'must not push a suspect rewrite');
await assert.rejects(
() => sync({ git, gh, pnpm: makeStub(), env, log: () => {} }),
/does not match the merge tree/,
);
assert.equal(
git.calls.some((a) => a[0] === 'push'),
false,
'must not push a suspect rewrite',
);
});
test('sync halts (no fetch/rebase) when a conflict PR is already open', async () => {
const git = makeStub();
const gh = makeStub([[(a) => a[0] === 'pr' && a[1] === 'list', JSON.stringify([{ number: 7 }])]]);
await sync({ git, gh, env, log: () => {} });
await sync({ git, gh, pnpm: makeStub(), env, log: () => {} });
assert.equal(git.calls.length, 0, 'must not touch git while halted');
});
@ -178,55 +559,160 @@ test('sync replays favouring 3.x when the patches no longer apply on their own',
]);
const gh = makeStub(noOpenPr);
await sync({ git, gh, env, log: () => {} });
await sync({ git, gh, pnpm: makeStub(), env, log: () => {} });
assert.ok(git.calls.some((a) => a[0] === 'rebase' && a[1] === '--abort'), 'stalled rebase must be aborted');
assert.ok(
git.calls.some((a) => a[0] === 'rebase' && a[1] === '--abort'),
'stalled rebase must be aborted',
);
assert.ok(git.calls.some(favouringOwnSide), 'expected the second replay to favour 3.x');
assert.ok(git.calls.some((a) => a[0] === 'push'), 'expected the replay to be pushed');
assert.ok(
git.calls.some((a) => a[0] === 'push'),
'expected the replay to be pushed',
);
// Still no new commit and no PR: the resolver's own fix commit is already in the queue.
assert.equal(git.calls.some((a) => a[0] === 'commit'), false);
assert.equal(gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'), false);
assert.equal(
git.calls.some((a) => a[0] === 'commit'),
false,
);
assert.equal(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'),
false,
);
});
test('sync recovers when the favoured replay stalls on a modify/delete conflict', async () => {
// The endpoints reconcile (merge-tree is clean) because a fix commit in the queue
// re-deletes the file — but replaying the deleting commit itself stalls on
// modify/delete, which `-X theirs` never settles on its own.
const FILE = 'packages/nodes-base/nodes/Function/Function.node.ts';
const git = makeStub([
...baseGitRoutes,
[(a) => a[0] === 'rebase' && a[1] === '--continue', ''],
[isRebase, fail(`CONFLICT (modify/delete): ${FILE} deleted in 0ff923a066`)],
[isConflictedFiles, FILE],
[(a) => a[0] === 'ls-files', `100644 aaa 1\t${FILE}\n100644 bbb 2\t${FILE}`],
[(a) => a[0] === 'diff-index', fail()],
]);
const gh = makeStub(noOpenPr);
await sync({ git, gh, pnpm: makeStub(), env, log: () => {} });
assert.ok(
git.calls.some((a) => a[0] === 'rm' && a.includes(FILE)),
'expected the queue-side deletion to be taken',
);
assert.ok(
git.calls.some((a) => a[0] === 'push'),
'expected the replay to be pushed',
);
// Still no new commit and no PR — the fix commit in the queue does the real work.
assert.equal(
git.calls.some((a) => a[0] === 'commit'),
false,
);
assert.equal(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'),
false,
);
});
test('sync folds favoured-replay drift back to the merge tree before pushing', async () => {
// The favoured replay finishes but its tip drifts from the merge tree (the lockfile
// hunks `-X theirs` resolved toward 3.x): the drift is folded back, then pushed.
let treeReads = 0;
const git = makeStub([
...baseGitRoutes.filter((r) => !r[0](['rev-parse', 'HEAD^{tree}'])),
[
(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}',
() => (treeReads++ === 0 ? 'DRIFTED' : MERGE_TREE),
],
[(a) => a[0] === 'diff-tree', LOCKFILE],
[(a) => a[0] === 'cat-file', ''],
[favouringOwnSide, ''],
[isRebase, fail('CONFLICT (content): packages/cli/x.ts')],
]);
const gh = makeStub(noOpenPr);
await sync({ git, gh, pnpm: makeStub(), env, log: () => {} });
assert.ok(git.calls.some((a) => a[0] === 'checkout' && a[1] === MERGE_TREE));
assert.ok(git.calls.some((a) => a[0] === 'commit' && a.includes('--amend')));
assert.ok(
git.calls.some((a) => a[0] === 'push'),
'expected the reconciled replay to be pushed',
);
assert.equal(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'),
false,
);
});
test('sync fails without pushing when even the favoured replay cannot finish', async () => {
const git = makeStub([...baseGitRoutes, [isRebase, fail('CONFLICT')]]);
const git = makeStub([
...baseGitRoutes,
[isRebase, fail('rebase failed for a non-conflict reason')],
// No conflicted files: nothing the driver may resolve — don't guess.
[isConflictedFiles, ''],
]);
const gh = makeStub(noOpenPr);
await assert.rejects(() => sync({ git, gh, env, log: () => {} }), /needs a human/);
assert.equal(git.calls.some((a) => a[0] === 'push'), false);
await assert.rejects(
() => sync({ git, gh, pnpm: makeStub(), env, log: () => {} }),
/needs a human/,
);
assert.equal(
git.calls.some((a) => a[0] === 'push'),
false,
);
assert.equal(git.calls.filter((a) => a[0] === 'rebase' && a[1] === '--abort').length, 2);
});
test('buildConflictBranch commits the conflicted state, markers and all', () => {
const git = makeStub([
[(a) => a[0] === 'merge', fail('CONFLICT (content): packages/cli/x.ts')],
[(a) => a[0] === 'diff', 'packages/cli/x.ts'],
]);
const files = buildConflictBranch({ git, masterSha: MASTER, log: () => {} });
assert.deepEqual(files, ['packages/cli/x.ts']);
assert.deepEqual(git.calls[0], ['merge', '--no-edit', MASTER]);
assert.ok(git.calls.some((a) => a[0] === 'add' && a[1] === '-A'));
assert.ok(git.calls.some((a) => a[0] === 'commit' && a.includes('--no-edit')));
// The markers ARE the review surface here, so nothing may auto-resolve them.
assert.equal(git.calls.some((a) => a.includes('-X')), false);
assert.equal(git.calls.some((a) => a[0] === 'merge' && a[1] === '--abort'), false);
});
test('buildConflictBranch refuses to guess when the merge unexpectedly succeeds', () => {
const git = makeStub([[(a) => a[0] === 'merge', '']]);
assert.throws(() => buildConflictBranch({ git, masterSha: MASTER, log: () => {} }), /Expected a merge conflict/);
assert.equal(git.calls.some((a) => a[0] === 'commit'), false);
});
test('sync opens a draft conflict PR and leaves 3.x untouched on a real conflict', async () => {
test('sync auto-resolves a lockfile-only conflict during the replay — no PR, no commit', async () => {
const git = makeStub([
...baseGitRoutes.filter((r) => !r[0](['merge-tree'])),
[(a) => a[0] === 'merge-tree', fail('CONFLICT (content): packages/cli/x.ts')],
[(a) => a[0] === 'merge', fail('CONFLICT (content): packages/cli/x.ts')],
[(a) => a[0] === 'diff', 'packages/cli/x.ts'],
[(a) => a[0] === 'merge-tree', conflictedMergeTree(LOCKFILE)],
[(a) => a[0] === 'rebase' && a[1] === '--continue', ''],
[isRebase, fail(`CONFLICT (content): Merge conflict in ${LOCKFILE}`)],
[isConflictedFiles, LOCKFILE],
[(a) => a[0] === 'diff' && a.includes('--quiet'), ''], // tip lockfile already consistent
[(a) => a[0] === 'diff-index', fail()], // staged resolution -> continue, not skip
]);
const gh = makeStub(noOpenPr);
const pnpm = makeStub();
await sync({ git, gh, pnpm, env, log: () => {} });
// The stall regen plus the tip reconciliation check.
assert.deepEqual(pnpm.calls[0], ['install', '--lockfile-only', '--no-frozen-lockfile']);
assert.equal(pnpm.calls.length, 2);
assert.ok(git.calls.some((a) => a[0] === 'add' && a.includes(LOCKFILE)));
assert.ok(git.calls.some((a) => a[0] === 'rebase' && a[1] === '--continue'));
const push = git.calls.find((a) => a[0] === 'push');
assert.equal(push[1], `--force-with-lease=refs/heads/${TARGET_BRANCH}:${PRE_HEAD}`);
// No human surface: no merge commit, no amend, no conflict PR.
assert.equal(
git.calls.some((a) => a[0] === 'merge'),
false,
);
assert.equal(
git.calls.some((a) => a[0] === 'commit'),
false,
);
assert.equal(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'),
false,
);
});
test('sync falls back to a conflict PR when mechanical auto-resolution cannot complete', async () => {
const git = makeStub([
...baseGitRoutes.filter((r) => !r[0](['merge-tree'])),
[(a) => a[0] === 'merge-tree', conflictedMergeTree(LOCKFILE)],
[isRebase, fail('CONFLICT')],
// Both replay attempts stall on a code file the endpoints do not reconcile.
[isConflictedFiles, 'packages/cli/x.ts'],
[(a) => a[0] === 'merge', fail('CONFLICT (content): Merge conflict in packages/cli/x.ts')],
[(a) => a[0] === 'log', 'breaking-sha'],
]);
const gh = makeStub([
@ -234,7 +720,142 @@ test('sync opens a draft conflict PR and leaves 3.x untouched on a real conflict
[(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/99'],
]);
await sync({ git, gh, env, fetchFn: okFetch(['alice']), log: () => {} });
await sync({ git, gh, pnpm: makeStub(), env, fetchFn: okFetch(['alice']), log: () => {} });
assert.ok(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'),
'expected the fallback conflict PR',
);
// Only the sync branch is pushed — 3.x must not move after a failed auto-resolution.
const pushes = git.calls.filter((a) => a[0] === 'push');
assert.equal(pushes.length, 1);
assert.equal(pushes[0].at(-1), `HEAD:refs/heads/${SYNC_BRANCH}`);
});
test('buildConflictBranch commits the conflicted state, markers and all', () => {
const git = makeStub([
[(a) => a[0] === 'merge', fail('CONFLICT (content): packages/cli/x.ts')],
[isConflictedFiles, 'packages/cli/x.ts'],
]);
const { files, preResolved, lockfileDeferred } = buildConflictBranch({
git,
pnpm: makeStub(),
masterSha: MASTER,
log: () => {},
});
assert.deepEqual(files, ['packages/cli/x.ts']);
assert.deepEqual(preResolved, []);
assert.equal(lockfileDeferred, false);
assert.deepEqual(git.calls[0], ['merge', '--no-edit', MASTER]);
assert.ok(git.calls.some((a) => a[0] === 'add' && a[1] === '-A'));
assert.ok(git.calls.some((a) => a[0] === 'commit' && a.includes('--no-edit')));
// The markers ARE the review surface here, so nothing may auto-resolve them.
assert.equal(
git.calls.some((a) => a.includes('-X')),
false,
);
assert.equal(
git.calls.some((a) => a[0] === 'merge' && a[1] === '--abort'),
false,
);
});
test('buildConflictBranch pre-resolves mechanical files so only code conflicts remain', () => {
const git = makeStub([
[(a) => a[0] === 'merge', fail('CONFLICT')],
[isConflictedFiles, `packages/cli/x.ts\n${LOCKFILE}\n${POPULARITY}`],
[(a) => a[0] === 'cat-file', ''],
]);
const pnpm = makeStub();
const { files, preResolved, lockfileDeferred } = buildConflictBranch({
git,
pnpm,
masterSha: MASTER,
log: () => {},
});
assert.deepEqual(files, ['packages/cli/x.ts']);
assert.deepEqual(preResolved, [LOCKFILE, POPULARITY]);
assert.equal(lockfileDeferred, false);
assert.deepEqual(pnpm.calls[0], ['install', '--lockfile-only', '--no-frozen-lockfile']);
assert.ok(
git.calls.some((a) => a[0] === 'checkout' && a[1] === MASTER && a.includes(POPULARITY)),
);
});
test('buildConflictBranch defers the lockfile when a manifest is conflicted too', () => {
const git = makeStub([
[(a) => a[0] === 'merge', fail('CONFLICT')],
[isConflictedFiles, `packages/cli/package.json\n${LOCKFILE}`],
]);
const pnpm = makeStub();
const { files, preResolved, lockfileDeferred } = buildConflictBranch({
git,
pnpm,
masterSha: MASTER,
log: () => {},
});
assert.deepEqual(files, ['packages/cli/package.json']);
assert.deepEqual(preResolved, []);
assert.equal(lockfileDeferred, true);
assert.equal(pnpm.calls.length, 0, 'regen is meaningless until the manifests are resolved');
});
test('buildConflictBranch degrades to a deferred lockfile when the regen fails', () => {
const git = makeStub([
[(a) => a[0] === 'merge', fail('CONFLICT')],
[isConflictedFiles, `packages/cli/x.ts\n${LOCKFILE}`],
]);
const pnpm = makeStub([[() => true, fail('ERR_PNPM_REGISTRY unreachable')]]);
const { files, preResolved, lockfileDeferred } = buildConflictBranch({
git,
pnpm,
masterSha: MASTER,
log: () => {},
});
assert.deepEqual(files, ['packages/cli/x.ts']);
assert.deepEqual(preResolved, []);
assert.equal(lockfileDeferred, true);
assert.ok(
git.calls.some((a) => a[0] === 'commit'),
'the conflict branch must still be committed',
);
});
test('buildConflictBranch refuses to guess when the merge unexpectedly succeeds', () => {
const git = makeStub([[(a) => a[0] === 'merge', '']]);
assert.throws(
() => buildConflictBranch({ git, pnpm: makeStub(), masterSha: MASTER, log: () => {} }),
/Expected a merge conflict/,
);
assert.equal(
git.calls.some((a) => a[0] === 'commit'),
false,
);
});
test('sync opens a draft conflict PR and leaves 3.x untouched on a real conflict', async () => {
const git = makeStub([
...baseGitRoutes.filter((r) => !r[0](['merge-tree'])),
[(a) => a[0] === 'merge-tree', conflictedMergeTree('packages/cli/x.ts')],
[(a) => a[0] === 'merge', fail('CONFLICT (content): packages/cli/x.ts')],
[isConflictedFiles, 'packages/cli/x.ts'],
[(a) => a[0] === 'log', 'breaking-sha'],
]);
const gh = makeStub([
...noOpenPr,
[(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/99'],
]);
await sync({ git, gh, pnpm: makeStub(), env, fetchFn: okFetch(['alice']), log: () => {} });
const create = gh.calls.find((a) => a[0] === 'pr' && a[1] === 'create');
assert.ok(create.includes('--draft'));
@ -253,12 +874,52 @@ test('sync opens a draft conflict PR and leaves 3.x untouched on a real conflict
const pushes = git.calls.filter((a) => a[0] === 'push');
assert.equal(pushes.length, 1);
assert.equal(pushes[0].at(-1), `HEAD:refs/heads/${SYNC_BRANCH}`);
assert.equal(git.calls.some((a) => a[0] === 'rebase'), false, 'no replay attempt while a conflict is unresolved');
assert.equal(
git.calls.some((a) => a[0] === 'rebase'),
false,
'no replay attempt while a code conflict is unresolved',
);
});
test('sync reports only the code conflicts on a mixed conflict, with mechanical files pre-resolved', async () => {
const git = makeStub([
...baseGitRoutes.filter((r) => !r[0](['merge-tree'])),
[(a) => a[0] === 'merge-tree', conflictedMergeTree(LOCKFILE, 'packages/cli/x.ts')],
[(a) => a[0] === 'merge', fail('CONFLICT')],
[isConflictedFiles, `packages/cli/x.ts\n${LOCKFILE}`],
[(a) => a[0] === 'log' && a.includes('--format=%H'), 'breaking-sha'],
]);
const gh = makeStub([
...noOpenPr,
[(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/99'],
]);
const pnpm = makeStub();
await sync({ git, gh, pnpm, env, fetchFn: okFetch(['alice']), log: () => {} });
assert.equal(pnpm.calls.length, 1, 'the lockfile is regenerated for the conflict branch');
const create = gh.calls.find((a) => a[0] === 'pr' && a[1] === 'create');
const body = create[create.indexOf('--body') + 1];
assert.match(body, /### Conflicted files\n- `packages\/cli\/x\.ts`/);
assert.match(body, /### Auto-resolved for you/);
assert.ok(
body.indexOf(LOCKFILE) > body.indexOf('Auto-resolved'),
'the lockfile belongs to the auto-resolved section',
);
// Owner attribution is scoped to the real code conflicts only.
const attributions = git.calls.filter((a) => a[0] === 'log' && a.includes('--format=%H'));
assert.equal(attributions.length, 1);
assert.equal(attributions[0].at(-1), 'packages/cli/x.ts');
});
test('openConflictPr degrades gracefully when owner resolution fails', async () => {
const git = makeStub([[(a) => a[0] === 'log', 'sha1']]);
const gh = makeStub([[(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/1']]);
const gh = makeStub([
[(a) => a[0] === 'pr' && a[1] === 'list', '[]'],
[(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/1'],
]);
const failingFetch = async () => ({ ok: false, status: 500, json: async () => ({}) });
const { prUrl, ownersSlack } = await openConflictPr({
@ -277,5 +938,41 @@ test('openConflictPr degrades gracefully when owner resolution fails', async ()
assert.equal(prUrl, 'https://github.com/n8n-io/n8n/pull/1');
assert.equal(ownersSlack, 'Could not auto-attribute owners.');
// No reviewer request when there are no owners.
assert.equal(gh.calls.some((a) => a[0] === 'pr' && a[1] === 'edit'), false);
assert.equal(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'edit'),
false,
);
});
test('openConflictPr calls out a recently abandoned conflict PR', async () => {
const git = makeStub([[(a) => a[0] === 'log', 'sha1']]);
const closedAt = new Date(Date.now() - 2 * 86_400_000).toISOString();
const gh = makeStub([
[
(a) => a[0] === 'pr' && a[1] === 'list' && a.includes('closed'),
JSON.stringify([
{ number: 42, url: 'https://github.com/n8n-io/n8n/pull/42', mergedAt: null, closedAt },
]),
],
[(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/43'],
]);
const { ownersSlack } = await openConflictPr({
git,
gh,
repo: 'n8n-io/n8n',
token: 't',
masterSha: MASTER,
preHead: PRE_HEAD,
pushUrl: 'https://push',
files: ['x.ts'],
fetchFn: okFetch(['alice']),
log: () => {},
});
const create = gh.calls.find((a) => a[0] === 'pr' && a[1] === 'create');
const body = create[create.indexOf('--body') + 1];
assert.match(body, /#42\) was closed without being merged/);
assert.match(body, /Merge, don't close/);
assert.match(ownersSlack, /<https:\/\/github\.com\/n8n-io\/n8n\/pull\/42\|#42>/);
});

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,135 @@
name: 'Build: Node PC Image'
# Publishes n8nio/node-pc, the pointer-compressed Node runtime consumed by the
# n8n `-pc` image variant. Compiling Node from source is too slow under QEMU,
# so each architecture builds on its native runner and a manifest job stitches
# the per-arch images, following docker-build-push.yml.
on:
push:
branches:
- master
paths:
- 'docker/images/node-pc/Dockerfile'
- '.github/workflows/build-node-pc-image.yml'
pull_request:
paths:
- 'docker/images/node-pc/Dockerfile'
- '.github/workflows/build-node-pc-image.yml'
workflow_dispatch:
inputs:
push:
description: 'Push to registries'
required: false
default: false
type: boolean
env:
NODE_VERSION: '26.5.1'
jobs:
build:
runs-on: ${{ matrix.runner }}
timeout-minutes: 150
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: blacksmith-32vcpu-ubuntu-2204
arch: amd64
- platform: linux/arm64
runner: blacksmith-32vcpu-ubuntu-2204-arm
arch: arm64
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to DHI Registry (for pulling the runtime base)
uses: ./.github/actions/docker-registry-login
with:
login-ghcr: 'false'
login-dhi: 'true'
dockerhub-username: ${{ secrets.DOCKER_USERNAME }}
dockerhub-password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to Docker registries (for pushing)
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true)
uses: ./.github/actions/docker-registry-login
with:
login-ghcr: 'true'
login-dockerhub: 'true'
dockerhub-username: ${{ secrets.DOCKER_USERNAME }}
dockerhub-password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push runtime target
uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2
with:
context: .
file: ./docker/images/node-pc/Dockerfile
target: runtime
platforms: ${{ matrix.platform }}
build-args: |
NODE_VERSION=${{ env.NODE_VERSION }}
provenance: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true) }}
sbom: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true) }}
push: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true) }}
tags: |
${{ secrets.DOCKER_USERNAME }}/node-pc:${{ env.NODE_VERSION }}-${{ matrix.arch }}
${{ secrets.DOCKER_USERNAME }}/node-pc:${{ env.NODE_VERSION }}-${{ github.sha }}-${{ matrix.arch }}
ghcr.io/${{ github.repository_owner }}/node-pc:${{ env.NODE_VERSION }}-${{ matrix.arch }}
ghcr.io/${{ github.repository_owner }}/node-pc:${{ env.NODE_VERSION }}-${{ github.sha }}-${{ matrix.arch }}
no-cache: true
# No no-cache here: reuses the compile layers from the runtime build
# above, so Node compiles once per architecture
- name: Build and push dev target
uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2
with:
context: .
file: ./docker/images/node-pc/Dockerfile
target: dev
platforms: ${{ matrix.platform }}
build-args: |
NODE_VERSION=${{ env.NODE_VERSION }}
provenance: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true) }}
sbom: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true) }}
push: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true) }}
tags: |
${{ secrets.DOCKER_USERNAME }}/node-pc:${{ env.NODE_VERSION }}-dev-${{ matrix.arch }}
${{ secrets.DOCKER_USERNAME }}/node-pc:${{ env.NODE_VERSION }}-${{ github.sha }}-dev-${{ matrix.arch }}
ghcr.io/${{ github.repository_owner }}/node-pc:${{ env.NODE_VERSION }}-dev-${{ matrix.arch }}
ghcr.io/${{ github.repository_owner }}/node-pc:${{ env.NODE_VERSION }}-${{ github.sha }}-dev-${{ matrix.arch }}
create-multi-arch-manifest:
needs: build
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker registries
uses: ./.github/actions/docker-registry-login
with:
login-ghcr: 'true'
login-dockerhub: 'true'
dockerhub-username: ${{ secrets.DOCKER_USERNAME }}
dockerhub-password: ${{ secrets.DOCKER_PASSWORD }}
- name: Create multi-arch manifests
run: |
for REPO in \
"${{ secrets.DOCKER_USERNAME }}/node-pc" \
"ghcr.io/${{ github.repository_owner }}/node-pc" \
; do
for DEV in "" "-dev"; do
AMD64="$REPO:${{ env.NODE_VERSION }}-${{ github.sha }}$DEV-amd64"
ARM64="$REPO:${{ env.NODE_VERSION }}-${{ github.sha }}$DEV-arm64"
for TAG in \
"$REPO:${{ env.NODE_VERSION }}$DEV" \
"$REPO:${{ env.NODE_VERSION }}-${{ github.sha }}$DEV" \
; do
echo "Creating manifest $TAG"
docker buildx imagetools create --tag "$TAG" "$AMD64" "$ARM64"
done
done
done

View File

@ -5,6 +5,9 @@
# before release. Reuses the shared docker-build-push.yml pipeline, pointing it
# at the 3.x branch via the `ref` input.
#
# On Mondays the same manifests are additionally retagged v3-rc / v3-rc-<date>, so
# there is a weekly, self-consistent set to run a stack against.
#
# Lives on master so the schedule fires (scheduled runs only trigger on the
# default branch), but builds the 3.x branch's code.
@ -15,6 +18,12 @@ on:
# 08:00 UTC — after the master→3.x sync (06:00) so the image reflects the latest sync
- cron: '0 8 * * *'
workflow_dispatch:
inputs:
force_rc:
description: 'Also tag this build as a release candidate (normally Mondays only)'
required: false
type: boolean
default: false
jobs:
prepare:
@ -24,10 +33,14 @@ jobs:
permissions: {}
outputs:
date_tag: ${{ steps.date.outputs.date }}
day_of_week: ${{ steps.date.outputs.day_of_week }}
steps:
- name: Compute date tag
id: date
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
# Both values read the same UTC clock, so they can't disagree across a day boundary.
run: |
echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
echo "day_of_week=$(date -u +%u)" >> "$GITHUB_OUTPUT"
build:
name: Build and push v3-nightly images
@ -44,3 +57,143 @@ jobs:
date_tag: ${{ needs.prepare.outputs.date_tag }}
push_enabled: true
secrets: inherit
# On Mondays (or on demand via force_rc), promote the whole set (n8n + both runners
# images) to v3-rc so a stack pulling `v3-rc` gets images that were built from the same
# 3.x commit. Pure retag of manifests the build already pushed — nothing is rebuilt.
#
# RCs can be published several times a day: each publish claims the next free rolling
# number for today (v3-rc-<date>.1, .2, …) as its immutable reference, and also moves
# the floating v3-rc and v3-rc-<date> tags onto it.
tag_release_candidate:
name: Tag release candidate
needs: [prepare, build]
if: ${{ needs.prepare.outputs.day_of_week == '1' || inputs.force_rc }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# Serialize promotion only (not the build) so two runs can't claim the same number.
concurrency:
group: v3-rc-tagging
cancel-in-progress: false
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to Docker registries
uses: ./.github/actions/docker-registry-login
with:
login-ghcr: true
login-dockerhub: true
dockerhub-username: ${{ secrets.DOCKER_USERNAME }}
dockerhub-password: ${{ secrets.DOCKER_PASSWORD }}
- name: Claim rolling RC number for today
id: rc
env:
RC_VERSION: v3-rc
DATE_TAG: ${{ needs.prepare.outputs.date_tag }}
N8N_IMAGE: ${{ needs.build.outputs.n8n_image }}
MAX_PER_DAY: '50'
run: |
set -euo pipefail
# The n8n GHCR repo is the single source of truth for the counter; all images
# then share the number, so one RC is always one consistent set.
tag_exists() {
local ref=$1 out
if out=$(docker buildx imagetools inspect "$ref" --raw 2>&1); then
return 0
fi
# Only a genuine absence frees the number. Auth/network/rate-limit failures
# must not be read as "free" — that would overwrite an immutable tag.
if grep -qiE 'not found|manifest unknown|no such manifest|MANIFEST_UNKNOWN|404' <<<"$out"; then
return 1
fi
echo "::error::Cannot determine whether ${ref} exists: ${out}"
exit 1
}
RC_NUMBER=''
for n in $(seq 1 "$MAX_PER_DAY"); do
if ! tag_exists "${N8N_IMAGE}:${RC_VERSION}-${DATE_TAG}.${n}"; then
RC_NUMBER=$n
break
fi
echo "${RC_VERSION}-${DATE_TAG}.${n} already published"
done
if [[ -z "$RC_NUMBER" ]]; then
echo "::error::Already published ${MAX_PER_DAY} release candidates for ${DATE_TAG}"
exit 1
fi
echo "Claiming ${RC_VERSION}-${DATE_TAG}.${RC_NUMBER}"
echo "number=${RC_NUMBER}" >> "$GITHUB_OUTPUT"
- name: Promote nightly manifests to release candidate
env:
NIGHTLY_VERSION: v3-nightly
RC_VERSION: v3-rc
DATE_TAG: ${{ needs.prepare.outputs.date_tag }}
RC_NUMBER: ${{ steps.rc.outputs.number }}
DOCKER_BASE: ${{ secrets.DOCKER_USERNAME }}
N8N_IMAGE: ${{ needs.build.outputs.n8n_image }}
N8N_DIGEST: ${{ needs.build.outputs.n8n_digest }}
RUNNERS_IMAGE: ${{ needs.build.outputs.runners_image }}
RUNNERS_DIGEST: ${{ needs.build.outputs.runners_digest }}
DISTROLESS_IMAGE: ${{ needs.build.outputs.runners_distroless_image }}
DISTROLESS_DIGEST: ${{ needs.build.outputs.runners_distroless_digest }}
run: |
set -euo pipefail
# Each image gets three tags: the numbered one is the immutable reference for this
# publish; v3-rc and v3-rc-<date> float onto the newest RC overall / of the day.
RC_ROLLING="${RC_VERSION}-${DATE_TAG}.${RC_NUMBER}"
# Retag one image as RC on both registries. GHCR is sourced by digest (immutable,
# can't drift); Docker Hub is sourced by its dated tag because `imagetools create`
# only retags within a repository — it can't pull GHCR blobs into Docker Hub.
promote() {
local ghcr_repo=$1 digest=$2 docker_repo=$3 suffix=$4
if [[ -z "$ghcr_repo" || -z "$digest" ]]; then
echo "::error::Missing image/digest for ${docker_repo}${suffix} - nightly manifest not published"
return 1
fi
echo "Promoting ${ghcr_repo}@${digest} -> ${RC_ROLLING}${suffix}"
docker buildx imagetools create \
--tag "${ghcr_repo}:${RC_ROLLING}${suffix}" \
--tag "${ghcr_repo}:${RC_VERSION}${suffix}" \
--tag "${ghcr_repo}:${RC_VERSION}-${DATE_TAG}${suffix}" \
"${ghcr_repo}@${digest}"
docker buildx imagetools create \
--tag "${docker_repo}:${RC_ROLLING}${suffix}" \
--tag "${docker_repo}:${RC_VERSION}${suffix}" \
--tag "${docker_repo}:${RC_VERSION}-${DATE_TAG}${suffix}" \
"${docker_repo}:${NIGHTLY_VERSION}-${DATE_TAG}${suffix}"
{
echo "- \`${ghcr_repo}:${RC_ROLLING}${suffix}\`"
echo "- \`${docker_repo}:${RC_ROLLING}${suffix}\`"
} >> "$GITHUB_STEP_SUMMARY"
}
{
echo "## v3 release candidate \`${RC_ROLLING}\`"
echo ""
echo "Immutable references (\`${RC_VERSION}\` and \`${RC_VERSION}-${DATE_TAG}\` now point here too):"
} >> "$GITHUB_STEP_SUMMARY"
promote "$N8N_IMAGE" "$N8N_DIGEST" "${DOCKER_BASE}/n8n" ""
promote "$RUNNERS_IMAGE" "$RUNNERS_DIGEST" "${DOCKER_BASE}/runners" ""
promote "$DISTROLESS_IMAGE" "$DISTROLESS_DIGEST" "${DOCKER_BASE}/runners" "-distroless"

View File

@ -24,6 +24,9 @@ jobs:
# The default is build without typechecking, we want both
build-command: pnpm build
- name: Report build critical path
run: node scripts/turbo-critical-path.mjs >> "$GITHUB_STEP_SUMMARY"
unit-test:
name: Unit tests
uses: ./.github/workflows/test-unit-reusable.yml

View File

@ -33,6 +33,7 @@ jobs:
commit_sha: ${{ steps.commit-sha.outputs.sha }}
merge_base: ${{ steps.ci-filter.outputs.merge-base }}
matrix: ${{ steps.generate-matrix.outputs.matrix }}
db_test_matrix: ${{ steps.generate-db-test-matrix.outputs.db_test_matrix }}
skip_tests: ${{ steps.generate-matrix.outputs.skip-tests }}
affected_packages: ${{ steps.affected-packages.outputs.list }}
changed_files: ${{ steps.ci-filter.outputs.changed-files }}
@ -70,8 +71,10 @@ jobs:
!**/*.spec.ts
!packages/testing/playwright/**
!packages/frontend/@n8n/storybook/**
!.devcontainer/**
!scripts/agent-setup.mjs
!scripts/backend-module/**
!scripts/cloud-session.mjs
!scripts/licenses/**
!scripts/mutation-health/**
!scripts/sync-agent-skill-links.mjs
@ -83,8 +86,10 @@ jobs:
!**/*.md
!**/LICENSE
!**/CHANGELOG.md
!.devcontainer/**
!scripts/agent-setup.mjs
!scripts/backend-module/**
!scripts/cloud-session.mjs
!scripts/licenses/**
!scripts/mutation-health/**
!scripts/sync-agent-skill-links.mjs
@ -140,7 +145,10 @@ jobs:
packages/@n8n/scheduler/src/**
packages/cli/**/__tests__/**
packages/testing/containers/services/postgres.ts
packages/testing/containers/postgres-versions.json
packages/testing/containers/test-containers.ts
.github/workflows/test-db-reusable.yml
.github/scripts/db-test-matrix.mjs
docs/generated/**
.tbls.sqlite.yml
.tbls.postgres.yml
@ -151,6 +159,10 @@ jobs:
with:
build-command: ${{ fromJSON(steps.ci-filter.outputs.results).ci && 'pnpm build' || 'pnpm turbo run build --filter=@n8n/playwright-janitor' }}
- name: Report build critical path
if: fromJSON(steps.ci-filter.outputs.results).ci
run: node scripts/turbo-critical-path.mjs >> "$GITHUB_STEP_SUMMARY"
- name: Run format check
if: fromJSON(steps.ci-filter.outputs.results).ci
run: pnpm format:check
@ -167,6 +179,12 @@ jobs:
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
echo "skip-tests=$(node -e "process.stdout.write(JSON.parse(process.argv[1])[0]?.skip === true ? 'true' : 'false')" "$MATRIX")" >> "$GITHUB_OUTPUT"
# Generated here because a reusable workflow cannot resolve its own matrix.
- name: Generate DB test matrix
id: generate-db-test-matrix
if: fromJSON(steps.ci-filter.outputs.results).db
run: echo "db_test_matrix=$(node .github/scripts/db-test-matrix.mjs)" >> "$GITHUB_OUTPUT"
- name: Compute affected packages
id: affected-packages
if: fromJSON(steps.ci-filter.outputs.results).unit
@ -314,6 +332,7 @@ jobs:
uses: ./.github/workflows/test-db-reusable.yml
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
matrix: ${{ needs.install-and-build.outputs.db_test_matrix }}
performance:
name: Performance

View File

@ -40,6 +40,28 @@ on:
required: false
type: string
default: ''
outputs:
# Repo name + multi-arch manifest digest per image, so callers can promote what
# was actually pushed (retag by digest) instead of re-deriving tag strings.
# Empty when push_enabled is false — the manifest job doesn't run.
n8n_image:
description: 'GHCR repo for the n8n image (no tag)'
value: ${{ jobs.create_multi_arch_manifest.outputs.n8n_image }}
n8n_digest:
description: 'Multi-arch manifest digest of the n8n image'
value: ${{ jobs.create_multi_arch_manifest.outputs.n8n_digest }}
runners_image:
description: 'GHCR repo for the runners image (no tag)'
value: ${{ jobs.create_multi_arch_manifest.outputs.runners_image }}
runners_digest:
description: 'Multi-arch manifest digest of the runners image'
value: ${{ jobs.create_multi_arch_manifest.outputs.runners_digest }}
runners_distroless_image:
description: 'GHCR repo for the distroless runners image (no tag; same repo as runners)'
value: ${{ jobs.create_multi_arch_manifest.outputs.runners_distroless_image }}
runners_distroless_digest:
description: 'Multi-arch manifest digest of the distroless runners image'
value: ${{ jobs.create_multi_arch_manifest.outputs.runners_distroless_digest }}
workflow_dispatch:
inputs:

View File

@ -7,6 +7,10 @@ on:
required: false
type: string
default: ''
matrix:
description: 'Job matrix JSON, from .github/scripts/db-test-matrix.mjs.'
required: true
type: string
env:
NODE_OPTIONS: '--max-old-space-size=6144'
@ -19,24 +23,7 @@ jobs:
strategy:
fail-fast: false
matrix:
include:
- name: SQLite Pooled
runner: blacksmith-4vcpu-ubuntu-2204
test-cmd: pnpm test:sqlite
migration-cmd: pnpm test:sqlite:migrations
schema-check-cmd: pnpm --filter=@n8n/db schema:check:sqlite
collectCoverage: 'false'
- name: Postgres 16
# 4vcpu is enough since persistent-worker + template-DB make PG16
# complete in ~480s, which is now close to the SQLite leg (~360s)
# — the matrix wall is bounded by the slower leg, so the larger
# 8vcpu runner buys headroom we can't cash in anywhere.
runner: blacksmith-4vcpu-ubuntu-2204
test-cmd: pnpm test:postgres:integration:tc
migration-cmd: pnpm test:postgres:migrations:tc
schema-check-cmd: pnpm --filter=@n8n/db schema:check:postgres
TEST_IMAGE_POSTGRES: 'postgres:16'
collectCoverage: 'true'
include: ${{ fromJSON(inputs.matrix) }}
env:
TEST_IMAGE_POSTGRES: ${{ matrix.TEST_IMAGE_POSTGRES }}
COVERAGE_ENABLED: ${{ matrix.collectCoverage }}
@ -49,7 +36,7 @@ jobs:
uses: ./.github/actions/setup-nodejs
- name: Pre-pull Test Container Images
run: pnpm tsx packages/testing/containers/pull-test-images.ts || true
run: pnpm --filter n8n-containers exec tsx pull-test-images.ts || true
- name: Run Tests
working-directory: packages/cli
@ -64,6 +51,7 @@ jobs:
# postgres testcontainer) and fails if they differ from what's committed.
# tbls runs as a Docker image in CI.
- name: Verify schema docs are up to date
if: matrix.schema-check-cmd != ''
run: ${{ matrix.schema-check-cmd }}
- name: Upload coverage to Codecov

View File

@ -114,7 +114,7 @@ jobs:
- name: Pre-pull Test Container Images
if: matrix.pre-pull-containers
run: pnpm tsx packages/testing/containers/pull-test-images.ts || true
run: pnpm --filter n8n-containers exec tsx pull-test-images.ts || true
- name: Run tests
working-directory: ${{ matrix.working-directory || github.workspace }}

View File

@ -60,7 +60,37 @@ on:
type: string
default: ''
model:
description: 'Model override for all Instance AI agents in the lane backends, provider/model format. Empty = backend default.'
description: 'Model override (provider/model). Empty = anthropic/claude-opus-4-8. Vertex Claude: google-vertex-anthropic/<model>. OpenAI-compat experiments use custom/<model> + model-url + model-key. See evaluations/README.md.'
required: false
type: string
default: ''
model-url:
description: 'Optional N8N_INSTANCE_AI_MODEL_URL (required for custom/* OpenAI-compatible /v1; also anthropic/* Azure Foundry). See evaluations/README.md.'
required: false
type: string
default: ''
model-key:
description: 'Which EVALS_* secret to use for custom/* (or anthropic/* + model-url). One of: baseten, fireworks, together, modal, databricks, azure, lyceum. Empty = keyless custom, or native openai/openrouter/xai/anthropic prefix routing.'
required: false
type: string
default: ''
reasoning-effort:
description: 'Optional custom/* reasoning effort override (none|minimal|low|medium|high|xhigh|max). Empty = runtime known-model map; still empty = omit.'
required: false
type: string
default: ''
supports-structured-outputs:
description: 'Optional custom/* supportsStructuredOutputs override (true|false). Empty = runtime known-model map; still empty = omit.'
required: false
type: string
default: ''
lanes:
description: 'Parallel n8n lane containers (1-11). Empty = 10, or 1 when model-key=baseten (fits Baseten Basic verified ~500k TPM).'
required: false
type: string
default: ''
eval-concurrency:
description: 'Concurrent scenario executions. Empty = 32, or 2 when model-key=baseten (~0.5M TPM / ~12 RPM — fits Baseten Basic verified).'
required: false
type: string
default: ''
@ -95,7 +125,31 @@ on:
required: false
default: ''
model:
description: 'Model for all Instance AI agents (provider/model). Empty = backend default (anthropic/claude-opus-4-8). For experiments: anthropic/claude-sonnet-4-6, anthropic/claude-sonnet-5.'
description: 'Model for all Instance AI agents (provider/model). Empty = backend default (anthropic/claude-opus-4-8). Vertex Claude: google-vertex-anthropic/<model>. OpenAI-compat experiments: custom/<model> + model-url + model-key.'
required: false
default: ''
model-url:
description: 'Base URL (N8N_INSTANCE_AI_MODEL_URL). Required for custom/*; optional for anthropic/* Azure Foundry.'
required: false
default: ''
model-key:
description: 'EVALS_* secret alias for custom/*: baseten | fireworks | together | modal | databricks | azure | lyceum. Empty = keyless custom or native provider prefix.'
required: false
default: ''
reasoning-effort:
description: 'Optional custom/* reasoning effort override (none|minimal|low|medium|high|xhigh|max). Empty = runtime known-model map; still empty = omit.'
required: false
default: ''
supports-structured-outputs:
description: 'Optional custom/* supportsStructuredOutputs override (true|false). Empty = runtime known-model map; still empty = omit.'
required: false
default: ''
lanes:
description: 'Parallel n8n lane containers (1-11). Empty = 10, or 1 when model-key=baseten (fits Baseten Basic verified ~500k TPM).'
required: false
default: ''
eval-concurrency:
description: 'Concurrent scenarios. Empty = 32, or 2 when model-key=baseten (~0.5M TPM / ~12 RPM — fits Baseten Basic verified).'
required: false
default: ''
@ -105,16 +159,19 @@ jobs:
# 8vcpu/32GB: full-suite N=10 baselines need ~150 min of eval time and
# did not fit the 4vcpu tier at any timeout.
runs-on: blacksmith-8vcpu-ubuntu-2204
# Long ceiling only for high-N baseline captures; regular runs keep a
# tight guard so a wedged run fails fast. Plain comparison (not fromJSON):
# it coerces the string input to a number, and a malformed dispatch value
# (NaN) falls back to the 90-minute guard instead of erroring the job.
timeout-minutes: ${{ (inputs.iterations || '3') >= 5 && 240 || 90 }}
# Long ceiling for high-N baselines (240) and for Baseten (600: auto
# 1 lane / c=2 is ~16× slower than the Anthropic default, so a ~18 min
# full suite becomes ~5 h; +2 h buffer for provider backoff). Plain
# comparison (not fromJSON): coerces the string input to a number;
# malformed NaN falls back to the 90-minute guard instead of erroring.
timeout-minutes: ${{ (inputs['model-key'] || '') == 'baseten' && 600 || ((inputs.iterations || '3') >= 5 && 240 || 90) }}
env:
# Each port hosts an independent n8n container. The eval CLI's
# work-stealing allocator dispatches builds across them, capped per-lane.
# 10 lanes x 2.5 GB caps = 25 GB, leaving ~7 GB for sandbox + CLI + OS.
LANE_PORTS: '5678,5679,5680,5681,5682,5683,5684,5685,5686,5687'
# Contiguous ports from 5678; 5678..5688 avoids Node fetch()'s blocked-
# port list (max 11 lanes). Defaults: 10 lanes / c=32 for Anthropic;
# model-key=baseten auto-throttles to 1 / 2 to stay under Baseten Basic
# verified (~500k TPM).
LANES: ${{ inputs.lanes != '' && inputs.lanes || ((inputs['model-key'] || '') == 'baseten' && '1' || '10') }}
EVAL_CONCURRENCY: ${{ inputs.eval-concurrency != '' && inputs.eval-concurrency || ((inputs['model-key'] || '') == 'baseten' && '2' || '32') }}
permissions:
contents: read
pull-requests: write
@ -174,7 +231,20 @@ jobs:
- name: Start n8n containers
env:
EVALS_ANTHROPIC_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
EVALS_OPENAI_KEY: ${{ secrets.EVALS_OPENAI_KEY }}
INSTANCE_AI_BRAVE_SEARCH_API_KEY: ${{ secrets.INSTANCE_AI_BRAVE_SEARCH_API_KEY }}
EVALS_OPENROUTER_KEY: ${{ secrets.EVALS_OPENROUTER_KEY }}
EVALS_XAI_KEY: ${{ secrets.EVALS_XAI_KEY }}
EVALS_BASETEN_KEY: ${{ secrets.EVALS_BASETEN_KEY }}
EVALS_FIREWORKS_KEY: ${{ secrets.EVALS_FIREWORKS_KEY }}
EVALS_TOGETHER_KEY: ${{ secrets.EVALS_TOGETHER_KEY }}
EVALS_DATABRICKS_KEY: ${{ secrets.EVALS_DATABRICKS_KEY }}
EVALS_MODAL_KEY: ${{ secrets.EVALS_MODAL_KEY }}
EVALS_LYCEUM_KEY: ${{ secrets.EVALS_LYCEUM_KEY }}
EVALS_AZURE_FOUNDRY_KEY: ${{ secrets.EVALS_AZURE_FOUNDRY_KEY }}
EVALS_VERTEX_KEY: ${{ secrets.EVALS_VERTEX_KEY }}
EVALS_VERTEX_PROJECT_ID: ${{ secrets.EVALS_VERTEX_PROJECT_ID }}
EVALS_VERTEX_LOCATION: ${{ secrets.EVALS_VERTEX_LOCATION }}
N8N_LICENSE_ACTIVATION_KEY: ${{ secrets.N8N_LICENSE_ACTIVATION_KEY }}
N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }}
N8N_ENCRYPTION_KEY: ${{ secrets.N8N_ENCRYPTION_KEY }}
@ -187,6 +257,10 @@ jobs:
SANDBOX_NAME_PREFIX: evals-ci-${{ github.head_ref || inputs.branch || github.ref_name }}
SANDBOX_PROVIDER: ${{ inputs.sandbox-provider || 'n8n-sandbox' }}
INSTANCE_AI_MODEL: ${{ inputs.model }}
INSTANCE_AI_MODEL_URL: ${{ inputs.model-url }}
INSTANCE_AI_MODEL_KEY: ${{ inputs.model-key }}
INSTANCE_AI_REASONING_EFFORT: ${{ inputs.reasoning-effort }}
INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS: ${{ inputs.supports-structured-outputs }}
run: |
# Build provider-specific env args
SANDBOX_ARGS=()
@ -216,8 +290,112 @@ jobs:
if [ -n "$INSTANCE_AI_MODEL" ]; then
MODEL_ARGS+=(-e N8N_INSTANCE_AI_MODEL="$INSTANCE_AI_MODEL")
fi
# Anthropic-compatible custom base (Azure Foundry Claude). Drop /v1/messages
# if pasted from the Foundry Details "Endpoint" field — the Anthropic SDK
# appends /v1/messages itself.
if [ -n "$INSTANCE_AI_MODEL_URL" ]; then
MODEL_ARGS+=(-e N8N_INSTANCE_AI_MODEL_URL="$INSTANCE_AI_MODEL_URL")
fi
IFS=',' read -ra PORTS <<< "$LANE_PORTS"
# custom/* experiment knobs: pass through workflow inputs only.
# When unset, the n8n runtime looks up custom-model-defaults.ts and
# omits the field if still unresolved — do not re-implement that map here.
if [ -n "$INSTANCE_AI_REASONING_EFFORT" ]; then
MODEL_ARGS+=(-e N8N_INSTANCE_AI_REASONING_EFFORT="$INSTANCE_AI_REASONING_EFFORT")
echo "custom reasoning effort override: $INSTANCE_AI_REASONING_EFFORT"
fi
if [ -n "$INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS" ]; then
MODEL_ARGS+=(-e N8N_INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS="$INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS")
echo "custom supportsStructuredOutputs override: $INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS"
fi
# EvalMock / pin-data / in-product eval LLMs always use Anthropic Sonnet,
# independent of the builder experiment model (custom/Kimi, openai/*, …).
# ANTHROPIC_API_KEY is the provider-native key resolveEvalModelConfig prefers
# when N8N_INSTANCE_AI_EVAL_MODEL differs from N8N_INSTANCE_AI_MODEL.
MODEL_ARGS+=(
-e N8N_INSTANCE_AI_EVAL_MODEL=anthropic/claude-sonnet-4-6
-e ANTHROPIC_API_KEY="$EVALS_ANTHROPIC_KEY"
)
# Lane builds use the provider matching N8N_INSTANCE_AI_MODEL.
# The eval CLI step keeps EVALS_ANTHROPIC_KEY for Sonnet verifier/mocks.
MODEL_API_KEY="$EVALS_ANTHROPIC_KEY"
MODEL_PROVIDER="${INSTANCE_AI_MODEL%%/*}"
require_secret() {
local name="$1" value="$2"
if [ -z "$value" ]; then
echo "::error::${name} secret is empty (model=${INSTANCE_AI_MODEL:-default} model-key=${INSTANCE_AI_MODEL_KEY:-})"
exit 1
fi
}
resolve_model_key() {
case "$INSTANCE_AI_MODEL_KEY" in
baseten) require_secret EVALS_BASETEN_KEY "$EVALS_BASETEN_KEY"; MODEL_API_KEY="$EVALS_BASETEN_KEY" ;;
fireworks) require_secret EVALS_FIREWORKS_KEY "$EVALS_FIREWORKS_KEY"; MODEL_API_KEY="$EVALS_FIREWORKS_KEY" ;;
together) require_secret EVALS_TOGETHER_KEY "$EVALS_TOGETHER_KEY"; MODEL_API_KEY="$EVALS_TOGETHER_KEY" ;;
modal) require_secret EVALS_MODAL_KEY "$EVALS_MODAL_KEY"; MODEL_API_KEY="$EVALS_MODAL_KEY" ;;
databricks) require_secret EVALS_DATABRICKS_KEY "$EVALS_DATABRICKS_KEY"; MODEL_API_KEY="$EVALS_DATABRICKS_KEY" ;;
azure) require_secret EVALS_AZURE_FOUNDRY_KEY "$EVALS_AZURE_FOUNDRY_KEY"; MODEL_API_KEY="$EVALS_AZURE_FOUNDRY_KEY" ;;
lyceum) require_secret EVALS_LYCEUM_KEY "$EVALS_LYCEUM_KEY"; MODEL_API_KEY="$EVALS_LYCEUM_KEY" ;;
'') ;;
*)
echo "::error::unknown model-key '${INSTANCE_AI_MODEL_KEY}' (expected baseten|fireworks|together|modal|databricks|azure|lyceum)"
exit 1
;;
esac
}
if [[ "$INSTANCE_AI_MODEL" == custom/* ]]; then
if [ -z "$INSTANCE_AI_MODEL_URL" ]; then
echo "::error::model custom/* requires model-url (OpenAI-compatible base URL ending in /v1)"
exit 1
fi
# model-key selects EVALS_*; empty model-key = keyless custom router.
if [ -n "$INSTANCE_AI_MODEL_KEY" ]; then
resolve_model_key
else
MODEL_API_KEY=""
fi
elif [ "$MODEL_PROVIDER" = "google-vertex-anthropic" ]; then
if [ -n "$INSTANCE_AI_MODEL_URL" ]; then
echo "::error::google-vertex-anthropic/* does not use model-url (leave it empty)"
exit 1
fi
require_secret EVALS_VERTEX_KEY "$EVALS_VERTEX_KEY"
require_secret EVALS_VERTEX_PROJECT_ID "$EVALS_VERTEX_PROJECT_ID"
MODEL_API_KEY=""
MODEL_ARGS+=(
-e N8N_INSTANCE_AI_VERTEX_PROJECT_ID="$EVALS_VERTEX_PROJECT_ID"
-e N8N_INSTANCE_AI_VERTEX_LOCATION="${EVALS_VERTEX_LOCATION:-global}"
-e N8N_INSTANCE_AI_VERTEX_SERVICE_ACCOUNT_JSON="$EVALS_VERTEX_KEY"
)
elif [ -n "$INSTANCE_AI_MODEL_URL" ]; then
# anthropic/* + model-url = Azure Foundry Claude (or model-key=azure).
if [ -n "$INSTANCE_AI_MODEL_KEY" ]; then
resolve_model_key
else
require_secret EVALS_AZURE_FOUNDRY_KEY "$EVALS_AZURE_FOUNDRY_KEY"
MODEL_API_KEY="$EVALS_AZURE_FOUNDRY_KEY"
fi
else
case "$MODEL_PROVIDER" in
openai) require_secret EVALS_OPENAI_KEY "$EVALS_OPENAI_KEY"; MODEL_API_KEY="$EVALS_OPENAI_KEY" ;;
openrouter) require_secret EVALS_OPENROUTER_KEY "$EVALS_OPENROUTER_KEY"; MODEL_API_KEY="$EVALS_OPENROUTER_KEY" ;;
xai) require_secret EVALS_XAI_KEY "$EVALS_XAI_KEY"; MODEL_API_KEY="$EVALS_XAI_KEY" ;;
esac
fi
if [ "$LANES" -lt 1 ] || [ "$LANES" -gt 11 ]; then
echo "::error::lanes must be 1-11 (got $LANES)"
exit 1
fi
PORTS=()
for i in $(seq 0 $((LANES - 1))); do
PORTS+=($((5678 + i)))
done
# Reuse across later steps (Create test users / Assert / Run Evals).
echo "LANE_PORTS=${PORTS[*]}" >> "$GITHUB_ENV"
echo "Starting $LANES lane(s) on ports ${PORTS[*]} (eval concurrency $EVAL_CONCURRENCY)"
for i in "${!PORTS[@]}"; do
port="${PORTS[$i]}"
# Bounded and self-healing: a lane that exhausts its capped heap is
@ -242,8 +420,8 @@ jobs:
-e E2E_TESTS=true \
-e N8N_ENABLED_MODULES=instance-ai \
-e N8N_AI_ENABLED=true \
-e N8N_INSTANCE_AI_MODEL_API_KEY="$EVALS_ANTHROPIC_KEY" \
-e INSTANCE_AI_BRAVE_SEARCH_API_KEY="$INSTANCE_AI_BRAVE_SEARCH_API_KEY" \
-e N8N_INSTANCE_AI_MODEL_API_KEY="$MODEL_API_KEY" \
-e N8N_AI_ASSISTANT_BASE_URL="" \
-e N8N_INSTANCE_AI_SANDBOX_ENABLED=true \
"${SANDBOX_ARGS[@]}" \
@ -283,7 +461,7 @@ jobs:
- name: Create test users
run: |
IFS=',' read -ra PORTS <<< "$LANE_PORTS"
read -ra PORTS <<< "$LANE_PORTS"
for port in "${PORTS[@]}"; do
curl -sf -X POST "http://localhost:$port/rest/e2e/reset" \
-H "Content-Type: application/json" \
@ -304,7 +482,7 @@ jobs:
SANDBOX_PROVIDER: ${{ inputs.sandbox-provider || 'n8n-sandbox' }}
INSTANCE_AI_MODEL: ${{ inputs.model }}
run: |
IFS=',' read -ra PORTS <<< "$LANE_PORTS"
read -ra PORTS <<< "$LANE_PORTS"
bad=0
for i in "${!PORTS[@]}"; do
port="${PORTS[$i]}"
@ -323,13 +501,15 @@ jobs:
echo " lane $lane: sandboxEnabled=true sandboxProvider=$SANDBOX_PROVIDER ok"
fi
# /preferences returns the effective model name (user pref || config),
# i.e. the name part of provider/model.
# i.e. everything after the first provider/ segment (openrouter/moonshotai/kimi-k3
# → moonshotai/kimi-k3). Use #*/ not ##*/ so nested OpenRouter ids match.
if [ -n "$INSTANCE_AI_MODEL" ]; then
expected_model="${INSTANCE_AI_MODEL#*/}"
effective=$(curl -sf -b "/tmp/cookies-$port.txt" \
"http://localhost:$port/rest/instance-ai/preferences" \
| jq -r '.data.modelName')
if [ "$effective" != "${INSTANCE_AI_MODEL##*/}" ]; then
echo "::error::lane $lane (port $port): expected model '${INSTANCE_AI_MODEL##*/}', got '$effective'"
if [ "$effective" != "$expected_model" ]; then
echo "::error::lane $lane (port $port): expected model '$expected_model', got '$effective'"
bad=$((bad+1))
else
echo " lane $lane: model=$effective ok"
@ -364,7 +544,10 @@ jobs:
continue-on-error: true
working-directory: packages/@n8n/instance-ai
env:
# Host-side verifier/judges always use Anthropic — never the builder experiment model.
N8N_INSTANCE_AI_EVAL_MODEL: anthropic/claude-sonnet-4-6
N8N_INSTANCE_AI_MODEL_API_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
LANGSMITH_TRACING: 'true'
LANGSMITH_ENDPOINT: ${{ secrets.EVALS_LANGSMITH_ENDPOINT }}
LANGSMITH_API_KEY: ${{ secrets.EVALS_LANGSMITH_API_KEY }}
@ -383,13 +566,14 @@ jobs:
EXPERIMENT_NAME: ${{ inputs.experiment-name }}
EVAL_PR_NUMBER: ${{ inputs.pr-number || github.event.pull_request.number }}
run: |
IFS=',' read -ra PORTS <<< "$LANE_PORTS"
read -ra PORTS <<< "$LANE_PORTS"
URLS=()
for port in "${PORTS[@]}"; do
URLS+=("http://localhost:$port")
done
BASE_URLS=$(IFS=,; printf '%s' "${URLS[*]}")
ARGS=(--base-url "$BASE_URLS" --concurrency 32 --verbose --iterations "${ITERATIONS:-3}")
echo "Lanes: $LANES | eval concurrency: $EVAL_CONCURRENCY"
ARGS=(--base-url "$BASE_URLS" --concurrency "$EVAL_CONCURRENCY" --verbose --iterations "${ITERATIONS:-3}")
# LangTracer is the only CI case source (no disk fallback by design).
ARGS+=(--source langtracer --suite "${SUITE:-baseline}")
# Pin the LangSmith cohort: langtracer mode otherwise derives a
@ -420,6 +604,17 @@ jobs:
if: ${{ always() }}
env:
EVALS_ANTHROPIC_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
EVALS_OPENAI_KEY: ${{ secrets.EVALS_OPENAI_KEY }}
EVALS_OPENROUTER_KEY: ${{ secrets.EVALS_OPENROUTER_KEY }}
EVALS_XAI_KEY: ${{ secrets.EVALS_XAI_KEY }}
EVALS_BASETEN_KEY: ${{ secrets.EVALS_BASETEN_KEY }}
EVALS_FIREWORKS_KEY: ${{ secrets.EVALS_FIREWORKS_KEY }}
EVALS_TOGETHER_KEY: ${{ secrets.EVALS_TOGETHER_KEY }}
EVALS_DATABRICKS_KEY: ${{ secrets.EVALS_DATABRICKS_KEY }}
EVALS_MODAL_KEY: ${{ secrets.EVALS_MODAL_KEY }}
EVALS_LYCEUM_KEY: ${{ secrets.EVALS_LYCEUM_KEY }}
EVALS_AZURE_FOUNDRY_KEY: ${{ secrets.EVALS_AZURE_FOUNDRY_KEY }}
EVALS_VERTEX_KEY: ${{ secrets.EVALS_VERTEX_KEY }}
DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
N8N_LICENSE_ACTIVATION_KEY: ${{ secrets.N8N_LICENSE_ACTIVATION_KEY }}
N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }}
@ -430,9 +625,13 @@ jobs:
# ::add-mask:: is a single-line workflow command. Multi-line secrets
# (e.g. N8N_LICENSE_CERT is PEM-encoded) must be masked one line at
# a time, otherwise only the first line is registered.
for v in "$EVALS_ANTHROPIC_KEY" "$DAYTONA_API_KEY" \
"$N8N_LICENSE_ACTIVATION_KEY" "$N8N_LICENSE_CERT" \
"$N8N_ENCRYPTION_KEY" "$EVALS_LANGSMITH_API_KEY"; do
for v in "$EVALS_ANTHROPIC_KEY" "$EVALS_OPENAI_KEY" "$EVALS_OPENROUTER_KEY" \
"$EVALS_XAI_KEY" "$EVALS_BASETEN_KEY" "$EVALS_FIREWORKS_KEY" \
"$EVALS_TOGETHER_KEY" "$EVALS_DATABRICKS_KEY" "$EVALS_MODAL_KEY" \
"$EVALS_LYCEUM_KEY" "$EVALS_AZURE_FOUNDRY_KEY" "$EVALS_VERTEX_KEY" \
"$DAYTONA_API_KEY" \
"$N8N_LICENSE_ACTIVATION_KEY" "$N8N_LICENSE_CERT" "$N8N_ENCRYPTION_KEY" \
"$EVALS_LANGSMITH_API_KEY"; do
[ -z "$v" ] && continue
while IFS= read -r line; do
[ -n "$line" ] && echo "::add-mask::$line"

View File

@ -66,7 +66,7 @@ jobs:
# @n8n/vitest-config/node), peak forks ≈ 2 × 50% ≈ the core count, instead
# of ~10 concurrent suites each spawning a core-sized pool and pegging CPU.
- name: Test Unit (backend, except cli and nodes-base)
run: pnpm turbo test:unit --continue --concurrency=2 --filter='!./packages/frontend/**' --filter='!n8n' --filter='!n8n-nodes-base' --summarize
run: pnpm turbo test:unit --continue --concurrency=2 --filter='!./packages/frontend/**' --filter='!./packages/modules/**' --filter='!n8n' --filter='!n8n-nodes-base' --summarize
- name: Test Unit (cli, scoped)
if: ${{ !cancelled() }}
@ -126,7 +126,7 @@ jobs:
# run serially to avoid cross-package interference. Unit jobs above
# don't need this because they isolate per-process.
- name: Test Integration (backend, except cli)
run: pnpm turbo test:integration --continue --concurrency=1 --filter='!./packages/frontend/**' --filter='!n8n' --summarize
run: pnpm turbo test:integration --continue --concurrency=1 --filter='!./packages/frontend/**' --filter='!./packages/modules/**' --filter='!n8n' --summarize
- name: Test Integration (cli, scoped)
if: ${{ !cancelled() }}

View File

@ -1,219 +0,0 @@
name: 'Util: Claude Task Runner'
# The `task` input is deliberately not in the run name: dispatchers pass large
# multiline payloads (e.g. a full Linear issue as JSON) that must not appear in
# the publicly visible run list.
run-name: 'Util: Claude Task Runner (model=${{ inputs.model }}, ref=${{ inputs.ref }})'
on:
workflow_dispatch:
inputs:
task:
description: 'Task description - what should Claude do?'
required: true
type: string
resumeUrl:
description: 'Optional callback URL to call with the result when the workflow completes'
required: false
type: string
model:
description: 'Claude model to use (e.g., claude-opus-4-6, claude-sonnet-4-5, claude-haiku-4-5)'
required: false
type: string
default: 'claude-sonnet-4-5-20250929'
use_raw_prompt:
description: 'Pass the task input directly as the prompt without wrapping it with guidelines and instructions'
required: false
type: boolean
default: false
max_turns:
description: 'Maximum conversation turns before Claude stops gracefully'
required: false
type: number
default: 50
suppress_output:
description: 'Suppress console output and job summary'
required: false
type: boolean
default: true
ref:
description: 'Git ref (branch/tag/SHA) to check out and work on'
required: false
type: string
default: 'master'
jobs:
run-claude-task:
runs-on: blacksmith-4vcpu-ubuntu-2204
timeout-minutes: 60
permissions:
contents: write
pull-requests: write
issues: write
steps:
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref }}
fetch-depth: 1
token: ${{ steps.generate_token.outputs.token }}
- name: Configure git remote with token
run: git remote set-url origin "https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/${{ github.repository }}.git"
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
- name: Create working branch
run: |
BRANCH_NAME="claude/task-${{ github.run_id }}-${{ github.run_attempt }}"
echo "BRANCH_NAME=$BRANCH_NAME" >> "$GITHUB_ENV"
git checkout -b "$BRANCH_NAME"
- name: Configure git author
env:
GH_ACTOR: ${{ github.actor }}
GH_ACTOR_ID: ${{ github.actor_id }}
run: |
git config user.name "$GH_ACTOR"
git config user.email "$GH_ACTOR_ID+$GH_ACTOR@users.noreply.github.com"
echo "Git author configured as: $GH_ACTOR"
- name: Prepare Claude prompt
env:
INPUT_TASK: ${{ inputs.task }}
USE_RAW_PROMPT: ${{ inputs.use_raw_prompt }}
run: node .github/scripts/claude-task/prepare-claude-prompt.mjs
- name: Create MCP config
env:
MCP_LINEAR_API_KEY: ${{ secrets.MCP_LINEAR_API_KEY }}
MCP_NOTION_TOKEN: ${{ secrets.MCP_NOTION_TOKEN }}
run: |
cat > /tmp/mcp-config.json << 'MCPEOF'
{
"mcpServers": {
"linear": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://mcp.linear.app/sse",
"--header",
"Authorization:${AUTH_HEADER}"
],
"env": {
"AUTH_HEADER": "Bearer MCP_LINEAR_API_KEY_PLACEHOLDER"
}
},
"notion": {
"command": "npx",
"args": ["-y", "@notionhq/notion-mcp-server"],
"env": {
"OPENAPI_MCP_HEADERS": "{\"Authorization\":\"Bearer MCP_NOTION_TOKEN_PLACEHOLDER\",\"Notion-Version\":\"2025-02-19\"}"
}
}
}
}
MCPEOF
sed -i "s|MCP_LINEAR_API_KEY_PLACEHOLDER|${MCP_LINEAR_API_KEY}|g" /tmp/mcp-config.json
sed -i "s|MCP_NOTION_TOKEN_PLACEHOLDER|${MCP_NOTION_TOKEN}|g" /tmp/mcp-config.json
- name: Run Claude
id: claude
continue-on-error: true
uses: anthropics/claude-code-action@d668cc452deb931732f24c6b1bbf24d97bc0c586 # v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ steps.generate_token.outputs.token }}
prompt: ${{ env.CLAUDE_PROMPT }}
show_full_output: ${{ inputs.suppress_output != true }}
display_report: false
plugin_marketplaces: './.claude/plugins/n8n'
plugins: 'n8n@n8n'
settings: |
{
"permissions": {
"allow": [
"Bash",
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
"TodoWrite",
"Skill",
"Task",
"mcp__linear__*",
"mcp__notion__*"
]
},
"extraKnownMarketplaces": {
"n8n": {
"source": {
"source": "directory",
"path": "./.claude/plugins/n8n"
}
}
},
"enabledPlugins": {
"n8n@n8n": true
}
}
claude_args: |
--model ${{ inputs.model }} --max-turns ${{ inputs.max_turns }} --mcp-config /tmp/mcp-config.json
- name: Push branch
if: always()
env:
REF: ${{ inputs.ref }}
run: |
if ! git diff --quiet "$REF" 2>/dev/null; then
git push -u origin "$BRANCH_NAME"
echo "::notice::Changes pushed to branch $BRANCH_NAME"
else
echo "::notice::No changes to push."
fi
- name: Summary
if: always()
env:
CLAUDE_OUTCOME: ${{ steps.claude.outcome }}
CLAUDE_SESSION_ID: ${{ steps.claude.outputs.session_id }}
run: |
{
echo "## Claude Task Runner"
echo "**Branch:** ${BRANCH_NAME:-N/A}"
echo "**Claude outcome:** $CLAUDE_OUTCOME"
if [ -n "$CLAUDE_SESSION_ID" ]; then
echo "**Session ID:** \`$CLAUDE_SESSION_ID\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Call resume url
if: always() && inputs.resumeUrl != ''
env:
RESUME_URL: ${{ inputs.resumeUrl }}
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
CLAUDE_OUTCOME: ${{ steps.claude.outcome }}
CLAUDE_SESSION_ID: ${{ steps.claude.outputs.session_id }}
run: node .github/scripts/claude-task/resume-callback.mjs
- name: Check final status
if: always()
run: |
if [ "${{ steps.claude.outcome }}" = "failure" ]; then
echo "::error::Claude task failed. Check the 'Run Claude' step logs for details."
exit 1
fi

View File

@ -68,6 +68,12 @@ jobs:
if: fromJSON(steps.paths-filter.outputs.results).db
uses: ./.github/actions/setup-nodejs
- name: Resolve Postgres image
if: fromJSON(steps.paths-filter.outputs.results).db
run: |
IMAGE=$(node -p "require('./packages/testing/containers/postgres-versions.json').primary")
echo "TEST_IMAGE_POSTGRES=$IMAGE" >> "$GITHUB_ENV"
- name: Start Postgres
if: fromJSON(steps.paths-filter.outputs.results).db
uses: isbang/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0

View File

@ -5,14 +5,19 @@
# true by REPLAYING the 3.x-only commits onto master and force-pushing 3.x — so a clean
# sync adds no commit at all, and every replayed commit is kept as-is (nothing squashed,
# authors preserved). The content pushed is always verified to be exactly what a merge of
# 3.x and master would produce.
# 3.x and master would produce. Per-commit stalls the replay cannot settle with a strategy
# option alone (e.g. modify/delete) are resolved toward 3.x's side, under that same
# verification.
#
# When master genuinely conflicts with 3.x, 3.x is left untouched: a draft conflict PR
# carrying the conflict markers is opened on the sync branch and #alerts-v3-sync is notified.
# The resolver fixes the markers in a commit of their own and merges that PR with the normal
# merge button. No further syncs run until it is merged. 3.x itself never has markers at its
# tip (nightly images build from it) and the merge commit holding them drops out of its
# history at the next replay.
# Conflicts confined to mechanical, tool-generated files (the pnpm lockfile, bot-maintained
# data files) are resolved in place during the replay — no PR, no commit, no human. When
# master genuinely conflicts with 3.x on real code, 3.x is left untouched: a draft conflict
# PR carrying the conflict markers (mechanical files pre-resolved) is opened on the sync
# branch and #alerts-v3-sync is notified. The resolver fixes the markers in a commit of their
# own and merges that PR with the normal merge button — never closes it, since closing
# resolves nothing and the conflict returns. No further syncs run until it is merged. 3.x
# itself never has markers at its tip (nightly images build from it) and the merge commit
# holding them drops out of its history at the next replay.
#
# See .github/DEVELOPING_V3.md for the full v3 development model.
@ -61,6 +66,14 @@ jobs:
fetch-depth: 0
persist-credentials: false # we push with an explicit token URL instead
# Node + pnpm toolchain for the mechanical lockfile resolution
# (`pnpm install --lockfile-only`). No dependency install, no build.
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
install-command: ''
build-command: ''
- name: Sync master into 3.x
id: sync
env:

View File

@ -1,3 +1,123 @@
# [2.35.0](https://github.com/n8n-io/n8n/compare/n8n@2.34.0...n8n@2.35.0) (2026-08-11)
### Bug Fixes
* **AI Agent Node:** Stop pre-tool-call text leaking into agent responses ([#35212](https://github.com/n8n-io/n8n/issues/35212)) ([d5845df](https://github.com/n8n-io/n8n/commit/d5845df4fcbb52f1e90696aa8f4ed1520f9dad1e))
* **core:** Apply redaction policy to Code node console output ([#35121](https://github.com/n8n-io/n8n/issues/35121)) ([242da02](https://github.com/n8n-io/n8n/commit/242da024ac04d26f58208400e97e94a95a4b2538))
* **core:** Bound oversized agent tool results ([#35759](https://github.com/n8n-io/n8n/issues/35759)) ([8fbcd7e](https://github.com/n8n-io/n8n/commit/8fbcd7efe95c32a48e571d9551b45af56d212ab4))
* **core:** Close review requests when a source control pull archives or cascade-deletes a workflow ([#35798](https://github.com/n8n-io/n8n/issues/35798)) ([fd366ce](https://github.com/n8n-io/n8n/commit/fd366ceba13468c41490cb359beaa092f5a5ed1d))
* **core:** Compile MCP tool schemas on their declared JSON Schema dialect ([#35610](https://github.com/n8n-io/n8n/issues/35610)) ([eeaa425](https://github.com/n8n-io/n8n/commit/eeaa425056bd350cb7a91fb8202b84630811aa86))
* **core:** Deduplicate OAuth2 authorization URL query parameters ([#35949](https://github.com/n8n-io/n8n/issues/35949)) ([0b4b044](https://github.com/n8n-io/n8n/commit/0b4b044fb201672b5456174f5af7fe8d6b7d9c1a))
* **core:** Detect node parameter values the connected credential cannot use ([#35633](https://github.com/n8n-io/n8n/issues/35633)) ([5925f5b](https://github.com/n8n-io/n8n/commit/5925f5bb65ef5c2b030159320a53603be5ff3928))
* **core:** Fail stalled Instance AI model streams fast instead of hanging ([#35943](https://github.com/n8n-io/n8n/issues/35943)) ([c04825d](https://github.com/n8n-io/n8n/commit/c04825d4c4762da827318d7df795728b2ef1133e))
* **core:** Fix task runner health check failing ([#35808](https://github.com/n8n-io/n8n/issues/35808)) ([9c4a613](https://github.com/n8n-io/n8n/commit/9c4a613b448acd059eac7ee2bbf86a9351f2ae01))
* **core:** Gate error workflows behind primary readiness ([#35634](https://github.com/n8n-io/n8n/issues/35634)) ([c79bd88](https://github.com/n8n-io/n8n/commit/c79bd8871e5e67da25f5bd4fd07f763b31e3b607))
* **core:** Handle errors from triggered and polled workflow executions ([#35689](https://github.com/n8n-io/n8n/issues/35689)) ([1deee72](https://github.com/n8n-io/n8n/commit/1deee723a333aaf9619ec5e0e4d940d9ad29f236))
* **core:** Handle out-of-scope display option dependencies ([#35845](https://github.com/n8n-io/n8n/issues/35845)) ([9241a72](https://github.com/n8n-io/n8n/commit/9241a72b9aa021d34790245ca6bd6a921f544638))
* **core:** Honor dry run on data table row delete ([#35713](https://github.com/n8n-io/n8n/issues/35713)) ([76fb3bc](https://github.com/n8n-io/n8n/commit/76fb3bcfacce38b1c5900124aa237b6980b1dea1))
* **core:** Ignore undeclared fields in agent resume payloads ([#35607](https://github.com/n8n-io/n8n/issues/35607)) ([42754a8](https://github.com/n8n-io/n8n/commit/42754a82b3013bb9ca67ba2ed609695a6f84144b))
* **core:** Keep agent workflow tools linked after renames ([#35690](https://github.com/n8n-io/n8n/issues/35690)) ([b4736b7](https://github.com/n8n-io/n8n/commit/b4736b738cf4385fabecf5e5e6b3ab54a3093da2))
* **core:** Keep database ping loop alive across connection recovery ([#35752](https://github.com/n8n-io/n8n/issues/35752)) ([3f2d479](https://github.com/n8n-io/n8n/commit/3f2d479d0abe22fedc857d8682229c8c2fd6181b))
* **core:** Keep n8n-docs always loaded so n8n questions do not route to web search ([#35537](https://github.com/n8n-io/n8n/issues/35537)) ([e73f0a4](https://github.com/n8n-io/n8n/commit/e73f0a46543d16f9a76ef9ca3ffdbb07dced608a))
* **core:** Keep valid resume payloads intact when ignoring undeclared fields ([#35751](https://github.com/n8n-io/n8n/issues/35751)) ([a2c9578](https://github.com/n8n-io/n8n/commit/a2c9578294bfb8e7969dc6e51ce296ba8b07c4d6))
* **core:** Nest agent traces under the calling workflow node ([#35457](https://github.com/n8n-io/n8n/issues/35457)) ([5d0a424](https://github.com/n8n-io/n8n/commit/5d0a424947abef88cb894b9b39cedf10c52c071b))
* **core:** Normalize the $fromAI type argument before coercing defaults ([#35641](https://github.com/n8n-io/n8n/issues/35641)) ([c0a7674](https://github.com/n8n-io/n8n/commit/c0a767434c0c415c45c5f4d3a7d096ac9cecb0c5))
* **core:** Probe azure blob with list instead of exists() ([#35719](https://github.com/n8n-io/n8n/issues/35719)) ([8bbc988](https://github.com/n8n-io/n8n/commit/8bbc988a9a3cf9b639dd5b17338d77efcc19add8))
* **core:** Reattach n8n-sandbox thread sandboxes after restart, idle eviction, and across mains ([#35953](https://github.com/n8n-io/n8n/issues/35953)) ([0e7e49b](https://github.com/n8n-io/n8n/commit/0e7e49bdc955f5b55ba77e46206705ca410ce99e))
* **core:** Recover unresponsive task runners ([#35456](https://github.com/n8n-io/n8n/issues/35456)) ([7a79486](https://github.com/n8n-io/n8n/commit/7a7948690b237845ad034f378038902373e11f2e))
* **core:** Reject stale Instance AI confirmations instead of hijacking the live run ([#35733](https://github.com/n8n-io/n8n/issues/35733)) ([4555145](https://github.com/n8n-io/n8n/commit/45551451acf6c4fafb6e6f94bdb5aabd345f0322))
* **core:** Report failed publishing when imported workflows share a webhook path ([#35525](https://github.com/n8n-io/n8n/issues/35525)) ([32f38a3](https://github.com/n8n-io/n8n/commit/32f38a3541235552508c647d8774baae7ce3b6b7))
* **core:** Require row read scope when data table writes return rows ([#35651](https://github.com/n8n-io/n8n/issues/35651)) ([ae6b256](https://github.com/n8n-io/n8n/commit/ae6b25624154bc111b766c445826ee6b824896d7))
* **core:** Resolve expressions in ephemeral agent tools ([#35876](https://github.com/n8n-io/n8n/issues/35876)) ([65083ab](https://github.com/n8n-io/n8n/commit/65083ab1b7768d6ef140954608f0e6bb5af9726c))
* **core:** Return a clear error for a node type with an unknown version ([#35196](https://github.com/n8n-io/n8n/issues/35196)) ([a9cde3f](https://github.com/n8n-io/n8n/commit/a9cde3f9f998c88e70e40fe67ad4a2ee68bd8391))
* **core:** Run published agent workflow tools as production ([#35784](https://github.com/n8n-io/n8n/issues/35784)) ([6cc9b28](https://github.com/n8n-io/n8n/commit/6cc9b287ede63d6e2b1327c8e0eb6fde65467c7e))
* **core:** Show Agent references in credential usage ([#35409](https://github.com/n8n-io/n8n/issues/35409)) ([a74c6fd](https://github.com/n8n-io/n8n/commit/a74c6fd11f8a1db2d3fef26bc5b651dcfc4d5601))
* **core:** Show Instance AI model overload errors clearly ([#35498](https://github.com/n8n-io/n8n/issues/35498)) ([40ba0dd](https://github.com/n8n-io/n8n/commit/40ba0ddb20e8b6f25deb81a0e6d99f1402d90d98))
* **core:** Strip leading BOM before parsing webhook request bodies ([#35933](https://github.com/n8n-io/n8n/issues/35933)) ([aa03666](https://github.com/n8n-io/n8n/commit/aa03666bc69e5d642865ba221ecb33d0655c4559))
* **editor:** Derive workflow home project from shared relation when absent ([#35810](https://github.com/n8n-io/n8n/issues/35810)) ([62e6862](https://github.com/n8n-io/n8n/commit/62e6862f5c428242d0a052ef91bab138cdd4d170))
* **editor:** Disable no-op workflow review updates ([#35890](https://github.com/n8n-io/n8n/issues/35890)) ([bf860f3](https://github.com/n8n-io/n8n/commit/bf860f349c310f4a72ad784978b0b08adf541859))
* **editor:** Fix janky spacing between agent capability chips with long labels ([#35493](https://github.com/n8n-io/n8n/issues/35493)) ([007400d](https://github.com/n8n-io/n8n/commit/007400dfc1feb0abc4c262f74956ad2592ebde6a))
* **editor:** Focus Markdown editor input before toolbar ([#35529](https://github.com/n8n-io/n8n/issues/35529)) ([698d137](https://github.com/n8n-io/n8n/commit/698d137c183f2459361d20f0e00136ab0af9d771))
* **editor:** Keep NDV panels spanning the full container width ([#35818](https://github.com/n8n-io/n8n/issues/35818)) ([561af4c](https://github.com/n8n-io/n8n/commit/561af4c567f62acd9fc15ec0a3c28a987cfa953d))
* **editor:** Keep Publish button visible in narrow workflow headers ([#35502](https://github.com/n8n-io/n8n/issues/35502)) ([1de80ac](https://github.com/n8n-io/n8n/commit/1de80acc0236cba8447c099bf997b3226a008a44))
* **editor:** Lock the AI Assistant canvas while the agent is working ([#35339](https://github.com/n8n-io/n8n/issues/35339)) ([1206780](https://github.com/n8n-io/n8n/commit/120678011d6e60656c442b3a97f52802a8a93304))
* **editor:** Make DropdownMenus more obviously scrollable ([#35726](https://github.com/n8n-io/n8n/issues/35726)) ([d3e49b2](https://github.com/n8n-io/n8n/commit/d3e49b21d980ba71bb7cc95916e9b040ad172008))
* **editor:** Preserve new agent artifact lifecycle ([#35703](https://github.com/n8n-io/n8n/issues/35703)) ([caeb40e](https://github.com/n8n-io/n8n/commit/caeb40ecf358845739d06cee917af65546e6e6b1))
* **editor:** Remove unsupported button type attributes ([#35578](https://github.com/n8n-io/n8n/issues/35578)) ([980c9bd](https://github.com/n8n-io/n8n/commit/980c9bd6ea8229386fe0daf5a3b1ea7ec889a868))
* **editor:** Require an explicit answer or Skip on Instance AI questions ([#35700](https://github.com/n8n-io/n8n/issues/35700)) ([6f765e9](https://github.com/n8n-io/n8n/commit/6f765e993456699219ee1c05c61580dfefbad2d4))
* **editor:** Show agent tool credentials above configuration ([#35590](https://github.com/n8n-io/n8n/issues/35590)) ([1ca0708](https://github.com/n8n-io/n8n/commit/1ca0708ba8965cfc0b981400a9ae0da2d56fdad7))
* **editor:** Show dependency pills on views listing more than 100 resources ([#35825](https://github.com/n8n-io/n8n/issues/35825)) ([926aa1a](https://github.com/n8n-io/n8n/commit/926aa1a73ba14a58a767ff532238c79e9a92080f))
* **Execute Workflow Node:** Prevent invalid input mappings ([#35767](https://github.com/n8n-io/n8n/issues/35767)) ([34ca40b](https://github.com/n8n-io/n8n/commit/34ca40bde06734fcf3450f47ec45b994ca66be4a))
* Fix token data updates and refresh for DCR oauth credentials ([#35535](https://github.com/n8n-io/n8n/issues/35535)) ([abe2959](https://github.com/n8n-io/n8n/commit/abe2959de808548e65953e9468f077d0b5eb623b))
* **Kafka Trigger Node:** Stop consumers reliably when a workflow is deactivated or updated ([#35069](https://github.com/n8n-io/n8n/issues/35069)) ([03213e7](https://github.com/n8n-io/n8n/commit/03213e746641732a01556d7202691021d3aece64))
* Preserve Agent build intent and refresh artifact names ([#35823](https://github.com/n8n-io/n8n/issues/35823)) ([daba594](https://github.com/n8n-io/n8n/commit/daba5946549120da2eea4680961bcf9a1202258e))
* **Schedule Node:** Apply declared interval defaults to stored rules ([#35271](https://github.com/n8n-io/n8n/issues/35271)) ([28d929d](https://github.com/n8n-io/n8n/commit/28d929dc2eae050cc85a7887049ea5080e048165))
* Send n8n version on backend posthog flag retrieval ([#35621](https://github.com/n8n-io/n8n/issues/35621)) ([1ade7a2](https://github.com/n8n-io/n8n/commit/1ade7a26c09859f800b7586d820fd6052dcf7822))
* **SharePoint Node:** Point delegated Sites.Selected users at URL or ID mode ([#35369](https://github.com/n8n-io/n8n/issues/35369)) ([305ed29](https://github.com/n8n-io/n8n/commit/305ed296f45504d7751fcf6c98aff0dcb0fd1755))
* Skip setup cards for credentials the workflow already settled ([#35701](https://github.com/n8n-io/n8n/issues/35701)) ([4493e5a](https://github.com/n8n-io/n8n/commit/4493e5a8b82e16e7a62a8dd264a083304290b1f1))
* Use caller's timezone for Insights date range boundaries ([#33989](https://github.com/n8n-io/n8n/issues/33989)) ([279bde3](https://github.com/n8n-io/n8n/commit/279bde3be6b42067e7d24306f2e026d2b514ee5e))
* **X (Formerly Twitter) Node:** Update OAuth2 and API endpoints to `x.com` ([#32699](https://github.com/n8n-io/n8n/issues/32699)) ([8ac5a60](https://github.com/n8n-io/n8n/commit/8ac5a606163bd8ab0748d7093cbaddfa7c99b54b))
### Features
* Add self-hosted AI Assistant onboarding ([#35579](https://github.com/n8n-io/n8n/issues/35579)) ([4ae4cc3](https://github.com/n8n-io/n8n/commit/4ae4cc3602d2ea6637c95bb452dbcb146d154d07))
* Add Simplified Custom Auth credential with recipe-guided setup in the AI Assistant ([#35056](https://github.com/n8n-io/n8n/issues/35056)) ([53841f1](https://github.com/n8n-io/n8n/commit/53841f1b0e6bf5517526415d87860a633fff7d9e))
* Add task-runners sidecar to the get.n8n.io stack ([#35426](https://github.com/n8n-io/n8n/issues/35426)) ([9493631](https://github.com/n8n-io/n8n/commit/94936315840d08981a461a9bb6307ded87c23b99))
* **API:** Add @Deprecated decorator to the decorated controller platform ([#35725](https://github.com/n8n-io/n8n/issues/35725)) ([897ce6a](https://github.com/n8n-io/n8n/commit/897ce6ab9a0cfad4d4bbb2f2e029f21b29912da5))
* **API:** Add endpoint to create custom roles ([#35522](https://github.com/n8n-io/n8n/issues/35522)) ([d225d03](https://github.com/n8n-io/n8n/commit/d225d0317b97f5574690b461664031ac2bb07512))
* **API:** Add publishIfActive option to workflow update endpoint ([#35813](https://github.com/n8n-io/n8n/issues/35813)) ([0e22fdf](https://github.com/n8n-io/n8n/commit/0e22fdfbc121efdbaad0f713880ed26ca4d81d1b))
* **core:** Add Agent Builder test runs ([#35716](https://github.com/n8n-io/n8n/issues/35716)) ([b660ebe](https://github.com/n8n-io/n8n/commit/b660ebef3894e80dade73c87db486e23d2b64cb1))
* **core:** Add backend form-trigger submission gate for end-user credentials (no changelog) ([#35539](https://github.com/n8n-io/n8n/issues/35539)) ([ddcc94f](https://github.com/n8n-io/n8n/commit/ddcc94f896b052530e4ca63f0ccf4ca9a2c69a1e))
* **core:** Add detailLevel option to MCP get_workflow_details ([#35497](https://github.com/n8n-io/n8n/issues/35497)) ([3773c31](https://github.com/n8n-io/n8n/commit/3773c31e47217dd5ac61fc85ad748f448dfb642a))
* **core:** Add Discord as an agent chat channel ([#35331](https://github.com/n8n-io/n8n/issues/35331)) ([4e6fc65](https://github.com/n8n-io/n8n/commit/4e6fc65f55ed2501f590c280359c423a0f4da6ad))
* **core:** Add HITL to agent builder test runs ([#35731](https://github.com/n8n-io/n8n/issues/35731)) ([0df0041](https://github.com/n8n-io/n8n/commit/0df0041019fcd035f5c610ff5f08ddea7af56e70))
* **core:** Add local agent token counting ([#35836](https://github.com/n8n-io/n8n/issues/35836)) ([ae1252a](https://github.com/n8n-io/n8n/commit/ae1252a7ade832b3e6887b712a4e4c010c702e96))
* **core:** Add projectConflictPolicy to package import ([#35488](https://github.com/n8n-io/n8n/issues/35488)) ([881a14f](https://github.com/n8n-io/n8n/commit/881a14fa4d29c45f3071f9ec2d8d1782937d7b26))
* **core:** Add telemetry for Simplified Custom Auth templated credentials ([#35832](https://github.com/n8n-io/n8n/issues/35832)) ([c485cea](https://github.com/n8n-io/n8n/commit/c485cea340a7167081cc63f8f3e4a1437fa278c1))
* **core:** Auto-close workflow review requests when a workflow is archived or moved ([#35772](https://github.com/n8n-io/n8n/issues/35772)) ([a044c0d](https://github.com/n8n-io/n8n/commit/a044c0d1d8e40db29c89bb4e46e1a674b1ca96e8))
* **core:** Enable 'instance-ai' module by default and remove startup warning ([#35670](https://github.com/n8n-io/n8n/issues/35670)) ([fcc4ef5](https://github.com/n8n-io/n8n/commit/fcc4ef59d6af9cc250fadf455e167835c488a91e))
* **core:** Expose agent test execution through MCP ([#35744](https://github.com/n8n-io/n8n/issues/35744)) ([6f761ad](https://github.com/n8n-io/n8n/commit/6f761ad91734e9f7f06c276ca7dcf0540e897d8e))
* **core:** Expose MCP registry search to the assistant as a tool ([#35318](https://github.com/n8n-io/n8n/issues/35318)) ([59278a7](https://github.com/n8n-io/n8n/commit/59278a791f769f64f2e873a146a6c669aa9a0645))
* **core:** Extend n8n Connect to Agent Builder node tools ([#35402](https://github.com/n8n-io/n8n/issues/35402)) ([104bc5c](https://github.com/n8n-io/n8n/commit/104bc5c8d92ab5ba05aac1eeaf3dfe17b7c2d520))
* **core:** Gate n8n Connect with instance config ([#35007](https://github.com/n8n-io/n8n/issues/35007)) ([935cfb9](https://github.com/n8n-io/n8n/commit/935cfb912e418ce312d6ebc77517883435465c0e))
* **core:** Include SDK language rules in the MCP workflow SDK reference ([#35183](https://github.com/n8n-io/n8n/issues/35183)) ([1c6e992](https://github.com/n8n-io/n8n/commit/1c6e99272a0698f86cf500e2b1f57ae4c8bf3a44))
* **core:** Make Azure Key Vault provider endpoints configurable ([#35214](https://github.com/n8n-io/n8n/issues/35214)) ([82e22c9](https://github.com/n8n-io/n8n/commit/82e22c954c768ae195909a168e589bdee01404b2))
* **core:** Make the vm expression engine the default ([#35738](https://github.com/n8n-io/n8n/issues/35738)) ([98e0952](https://github.com/n8n-io/n8n/commit/98e0952c367e848cd6318a7683729b3941508771))
* **core:** Migrate the instance MCP server to the MCP TypeScript SDK v2 ([#35467](https://github.com/n8n-io/n8n/issues/35467)) ([74631f7](https://github.com/n8n-io/n8n/commit/74631f72c056cbf090e892f8c85c7bb86869425f))
* **core:** Persist agent session timelines during execution ([#35490](https://github.com/n8n-io/n8n/issues/35490)) ([1546c95](https://github.com/n8n-io/n8n/commit/1546c954856cbdd8f9e6f30f9b58a60bce46bf11))
* **core:** Reconcile tag ids on name collision when importing packages ([#35224](https://github.com/n8n-io/n8n/issues/35224)) ([c9eeb2e](https://github.com/n8n-io/n8n/commit/c9eeb2e180c4c5c91affba90265ae06a364dbbd6))
* **core:** Support the MCP 2026-07-28 discovery handshake on the instance server ([#35470](https://github.com/n8n-io/n8n/issues/35470)) ([145863c](https://github.com/n8n-io/n8n/commit/145863c58e56bd886feee110e202091649e643c9))
* **core:** Surface decision eligibility on the review detail ([#35391](https://github.com/n8n-io/n8n/issues/35391)) ([5e4a20d](https://github.com/n8n-io/n8n/commit/5e4a20d031196b78365881a5d2c7e87372a0663f))
* **core:** Teach the AI assistant to organise workflows into node groups ([#35646](https://github.com/n8n-io/n8n/issues/35646)) ([5bf5f38](https://github.com/n8n-io/n8n/commit/5bf5f385fd675e719702187b6de0a5421a0c0388))
* **core:** Warn on startup when Postgres is below the supported range ([#35495](https://github.com/n8n-io/n8n/issues/35495)) ([c72b14f](https://github.com/n8n-io/n8n/commit/c72b14f69cea5b249ec28528b3a0f98408cd5e6c))
* **editor:** Add Changes diff tab to workflow review detail pane ([#35431](https://github.com/n8n-io/n8n/issues/35431)) ([6c8197e](https://github.com/n8n-io/n8n/commit/6c8197e379eebe8096773fbdeb730ec1a9b1323b))
* **editor:** Add ChatMessage to @n8n/design-system ([#35461](https://github.com/n8n-io/n8n/issues/35461)) ([38f0691](https://github.com/n8n-io/n8n/commit/38f069172c7815fdcb8f2e8dde688e4999c9303a))
* **editor:** Add more Markdown editor toolbar options ([#35630](https://github.com/n8n-io/n8n/issues/35630)) ([32db83c](https://github.com/n8n-io/n8n/commit/32db83c6b6cab046180adefadf7bcd0e8c884722))
* **editor:** Add review detail metadata ([#35638](https://github.com/n8n-io/n8n/issues/35638)) ([cf13bc0](https://github.com/n8n-io/n8n/commit/cf13bc02d24b79dd8b1d5823e21cd595fa30db03))
* **editor:** Allow adding a workflow version description when submitting for review ([#35640](https://github.com/n8n-io/n8n/issues/35640)) ([5f0e464](https://github.com/n8n-io/n8n/commit/5f0e46463097a7c0cf8455f55713fa953f688eda))
* **editor:** Disable command bar in canvas-only mode ([#35542](https://github.com/n8n-io/n8n/issues/35542)) ([fda3301](https://github.com/n8n-io/n8n/commit/fda3301317a4813424a07a28d0468babb780ccf1))
* **editor:** Enable enhanced HITL Slack/Telegram ([#35411](https://github.com/n8n-io/n8n/issues/35411)) ([5d501e3](https://github.com/n8n-io/n8n/commit/5d501e370a15619f50f0ab67728d8b1e5416de72))
* **editor:** Improve agent file upload visibility ([#35513](https://github.com/n8n-io/n8n/issues/35513)) ([75a0cdf](https://github.com/n8n-io/n8n/commit/75a0cdfd2016b3ca0ab11460e1052dcc4719532e))
* **editor:** Name the pinned workflow version when submitting for review ([#35566](https://github.com/n8n-io/n8n/issues/35566)) ([fe37fe3](https://github.com/n8n-io/n8n/commit/fe37fe3b97b0751136f4e3c2b29acaf2aee4ac20))
* **editor:** Restore one-click connector for ChatGPT MCP client ([#35396](https://github.com/n8n-io/n8n/issues/35396)) ([2058a16](https://github.com/n8n-io/n8n/commit/2058a165203edb378c0e9893a2a3bd144b01818b))
* Extract the get-n8n stack definition into a versioned standalone file ([#35620](https://github.com/n8n-io/n8n/issues/35620)) ([8a1645a](https://github.com/n8n-io/n8n/commit/8a1645aab664bdf454ec8124ba51ce06639d78dc))
* **Form Trigger Node:** Add credential connect UI for end-user credentials ([#35347](https://github.com/n8n-io/n8n/issues/35347)) ([a89dc5d](https://github.com/n8n-io/n8n/commit/a89dc5dc353d0d7bb72086a029059fa0e3ace9b6))
* **GitHub Node:** Add timeout option to Dispatch and Wait for Completion ([#35837](https://github.com/n8n-io/n8n/issues/35837)) ([a6f07db](https://github.com/n8n-io/n8n/commit/a6f07dbba00c6a31758cc3ac8ba2a5e29039f475))
* **Kafka Node:** Add compression options to version 2 ([#35727](https://github.com/n8n-io/n8n/issues/35727)) ([21b52fb](https://github.com/n8n-io/n8n/commit/21b52fbddf6acc639b872fd1a9109e4275dcc3b9))
* **Kafka Node:** Add version 2 to send messages via the new Kafka library ([#35450](https://github.com/n8n-io/n8n/issues/35450)) ([d2901bd](https://github.com/n8n-io/n8n/commit/d2901bd02d6163afc49ed35dc07351fdaf155c2f))
* Load Instance AI models dynamically ([#35927](https://github.com/n8n-io/n8n/issues/35927)) ([269a45d](https://github.com/n8n-io/n8n/commit/269a45d7dd1b9c0a50ab1335bfb455b09475f87d))
* **Merge Node:** Add "Append" and "Combine" search aliases ([#35709](https://github.com/n8n-io/n8n/issues/35709)) ([cc98292](https://github.com/n8n-io/n8n/commit/cc98292be0826e67b508d8b26c6e37e20a7eae00))
* **Salesforce Trigger Node:** Add OAuth2 JWT authentication support ([#32257](https://github.com/n8n-io/n8n/issues/32257)) ([eda2496](https://github.com/n8n-io/n8n/commit/eda249642c574d2f6b4ed4c20a98de969b12831e))
* **Slack Node:** Use Real-time Search API for message search ([#35291](https://github.com/n8n-io/n8n/issues/35291)) ([204f346](https://github.com/n8n-io/n8n/commit/204f3467206ecd1efb5ac854a2607051dcd3391a))
### Performance Improvements
* **core:** Resolve webhook description fields via declared native resolvers ([#35404](https://github.com/n8n-io/n8n/issues/35404)) ([18c2d71](https://github.com/n8n-io/n8n/commit/18c2d715e36f26cc8dcae0c409b3d075bb07b433))
# [2.34.0](https://github.com/n8n-io/n8n/compare/n8n@2.33.0...n8n@2.34.0) (2026-08-04)

View File

@ -42,6 +42,14 @@
]
},
"organizeImports": { "enabled": false },
// Every `tsconfig*.json` is JSONC. Biome only infers that from the exact name `tsconfig.json`,
// so variants like `tsconfig.frontend-module.json` need it spelled out.
"overrides": [
{
"include": ["**/tsconfig*.json"],
"json": { "parser": { "allowComments": true } }
}
],
"linter": {
"enabled": false
},

View File

@ -94,9 +94,11 @@ parse_args() {
check_deps() {
command -v docker >/dev/null 2>&1 || fail "Docker is not installed.
Install it first:
Linux: https://docs.docker.com/engine/install/
Linux / WSL: curl -fsSL https://get.docker.com | sh
then: sudo usermod -aG docker \$USER && re-open your shell
macOS: https://docs.docker.com/desktop/setup/install/mac-install/
Windows (WSL): https://docs.docker.com/desktop/setup/install/windows-install/
Windows: https://docs.docker.com/desktop/setup/install/windows-install/
(enable your distro under Settings > Resources > WSL integration)
Podman, Colima and other Docker-compatible engines work too: install the
'docker' CLI with the compose plugin and point DOCKER_HOST at their socket."
@ -141,19 +143,22 @@ http_get() {
}
port_in_use() {
# In use only when something actually answers. Refusals AND timeouts count
# as free: Windows firewalls/WSL drop instead of refusing, which made the
# old "anything but refused" check a false positive. --noproxy keeps a
# corporate proxy from answering on 127.0.0.1's behalf. Ambiguous cases
# fall through to the container healthcheck, which surfaces real conflicts.
if command -v curl >/dev/null 2>&1; then
# Any HTTP response (even an error status) means something is listening;
# only "connection refused" (rc 7) means the port is free.
rc=0
curl -s -o /dev/null --max-time 2 "http://127.0.0.1:${N8N_PORT}/" 2>/dev/null || rc=$?
[ "$rc" -ne 7 ]
curl -s -o /dev/null --noproxy '*' --max-time 2 "http://127.0.0.1:${N8N_PORT}/" 2>/dev/null || rc=$?
# rc 0 = HTTP response; 52/56 = connection accepted but no/broken reply.
[ "$rc" -eq 0 ] || [ "$rc" -eq 52 ] || [ "$rc" -eq 56 ]
elif command -v wget >/dev/null 2>&1; then
# wget rc 4 = network failure (connection refused) = port free.
rc=0
wget -q -O /dev/null -T 2 "http://127.0.0.1:${N8N_PORT}/" 2>/dev/null || rc=$?
[ "$rc" -ne 4 ]
wget -q -O /dev/null --no-proxy -T 2 "http://127.0.0.1:${N8N_PORT}/" 2>/dev/null || rc=$?
# rc 0 = OK response; 8 = server issued an error response.
[ "$rc" -eq 0 ] || [ "$rc" -eq 8 ]
else
# No way to check; let the healthcheck surface conflicts later.
return 1
fi
}
@ -218,7 +223,6 @@ INSTANCE_AI_BRAVE_SEARCH_API_KEY=
N8N_INSTANCE_AI_SEARXNG_URL=http://searxng:8080
SEARXNG_SECRET=$(gen_secret)
N8N_INSTANCE_AI_MODEL=anthropic/claude-opus-4-8
N8N_INSTANCE_AI_SANDBOX_ENABLED=true
N8N_INSTANCE_AI_SANDBOX_PROVIDER=n8n-sandbox
N8N_INSTANCE_AI_SANDBOX_IMAGE=ghcr.io/n8n-io/n8n-sandbox-service-sandbox:latest

View File

@ -1,34 +1,35 @@
ARG NODE_VERSION=24.18.1
ARG N8N_VERSION=snapshot
# Builder stage exists because the runtime base image has no toolchain.
# Pinned to multi-arch index digest (linux/amd64 + linux/arm64) for reproducible builds.
# Bump the digest together with the tag when updating the base image.
# Digest pins to node:24.18.1-alpine3.24 (Node 24.18.1, Alpine 3.24).
FROM node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS builder
COPY ./compiled /usr/local/lib/node_modules/n8n
RUN apk add --no-cache python3 make g++ && \
cd /usr/local/lib/node_modules/n8n && \
npm rebuild sqlite3 && \
# Rebuild isolated-vm from source. node-gyp-build's musl detection relies
# on /etc/alpine-release which DHI Alpine omits, so the bundled prebuilt
# loader picks the glibc binary and segfaults in musl pthread paths.
# Removing prebuilds and invoking node-gyp directly avoids npm rebuild's
# double-build behavior (built-in node-gyp + install-script node-gyp)
# which races on dep-file state.
# Use npm's bundled node-gyp rather than `npx node-gyp` so the toolchain
# version is fixed by the pinned image digest instead of being fetched
# from the registry at build time.
rm -rf node_modules/isolated-vm/prebuilds && \
cd node_modules/isolated-vm && \
# The runtime base has no compiler, so this stage supplies one.
# The digest pins node:24.18.1-alpine3.24 for amd64 and arm64. Change the tag and the digest together.
FROM node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS toolchain
RUN apk add --no-cache python3 make g++
# Build isolated-vm in a separate stage. The only cache input is the module source.
# Below `COPY ./compiled`, each application change starts a new compile.
FROM toolchain AS native-builder
# `node_modules/isolated-vm` is a pnpm symlink, so copy the target directory.
# The wildcard tracks the version in the catalog.
COPY ./compiled/node_modules/.pnpm/isolated-vm@*/node_modules/isolated-vm /build/isolated-vm
WORKDIR /build/isolated-vm
# node-gyp-build reads /etc/alpine-release to detect musl. DHI Alpine has no such file,
# so the loader takes the glibc prebuild and crashes. Build from source instead.
# Call node-gyp directly: `npm rebuild` starts two builds that conflict, and
# `npx node-gyp` downloads a different version at build time.
RUN rm -rf prebuilds && \
node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild --release -j max
# Pinned to multi-arch index digest (linux/amd64 + linux/arm64) for reproducible builds.
# n8nio/base is republished by a separate workflow (docker/images/n8n-base) on every
# base change, so a base rebuild does not reach this image until the digest is
# manually re-pinned here. Bump the digest together with the tag whenever the base
# image is intentionally updated.
# Digest pins to n8nio/base:24.18.1 (Node 24.18.1, Alpine 3.24).
FROM toolchain AS builder
COPY ./compiled /usr/local/lib/node_modules/n8n
RUN cd /usr/local/lib/node_modules/n8n && npm rebuild sqlite3
COPY --from=native-builder /build/isolated-vm/build/Release/isolated_vm.node \
/usr/local/lib/node_modules/n8n/node_modules/isolated-vm/build/Release/isolated_vm.node
# node-gyp-build finds build/Release first. Delete the prebuilds to remove the glibc fallback.
RUN rm -rf /usr/local/lib/node_modules/n8n/node_modules/isolated-vm/prebuilds
# The digest pins n8nio/base:24.18.1 for amd64 and arm64. A separate workflow builds that
# image, so a new base reaches this image only when you change the digest here.
FROM n8nio/base:24.18.1@sha256:f7a23e449f093b8b203467919e969ca35d12ba959442aadc1f69bb174b160ad2
ARG N8N_VERSION
@ -42,7 +43,7 @@ WORKDIR /home/node
COPY --from=builder /usr/local/lib/node_modules/n8n /usr/local/lib/node_modules/n8n
COPY docker/images/n8n/docker-entrypoint.sh /
# The Alpine 3.24 base has no /usr/local/bin (node lives in /usr/bin); create it for the symlink.
# The base keeps node in /usr/bin and has no /usr/local/bin. Make that directory for the symlink.
RUN mkdir -p /usr/local/bin && \
ln -s /usr/local/lib/node_modules/n8n/bin/n8n /usr/local/bin/n8n && \
mkdir -p /home/node/.n8n && \

View File

@ -0,0 +1,130 @@
# Node.js compiled with V8 pointer compression, for the n8n `-pc` image variant.
#
# No official pointer-compressed Node binaries exist (nodejs.org publishes none,
# and Docker Hub node images are stock), so this image compiles Node from the
# GPG-verified source tarball, following the source-build path of the official
# docker-node Alpine recipe with one added configure flag.
#
# Two published targets:
# runtime (default) - drop-in sibling of n8nio/base, layered on the same DHI
# base with our compiled Node swapped in
# dev - plain Alpine with apk usable, for the n8n builder
# stage, which must compile native addons against this
# Node's headers
ARG NODE_VERSION=26.5.1
# Compiling V8 needs 1.5-2 GB of memory per parallel job. Lower this on
# builders whose memory-to-core ratio cannot cover all cores.
ARG MAKE_JOBS=
# Pinned to a multi-arch index digest (linux/amd64 + linux/arm64) for reproducible builds.
# Bump the tag and digest together when updating.
FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS builder
ARG NODE_VERSION
ARG MAKE_JOBS
RUN apk add --no-cache --virtual .build-deps \
binutils-gold \
curl \
g++ \
gcc \
gnupg \
libgcc \
linux-headers \
make \
python3 \
py-setuptools \
rust \
cargo \
&& OPENSSL_ARCH= && alpineArch="$(apk --print-arch)" \
&& case "${alpineArch##*-}" in \
x86_64) OPENSSL_ARCH=linux-x86_64;; \
aarch64) OPENSSL_ARCH=linux-aarch64;; \
*) echo "unsupported architecture: $alpineArch" && exit 1;; \
esac \
&& export GNUPGHOME="$(mktemp -d)" \
# gpg keys listed at https://github.com/nodejs/node#release-keys
&& for key in \
5BE8A3F6C8A5C01D106C0AD820B1A390B168D356 \
DD792F5973C6DE52C432CBDAC77ABFA00DDBF2B7 \
CC68F5A3106FF448322E48ED27F5E38D5B0A215F \
8FCCA13FEF1D0C2E91008E09770F7A9A5AE15600 \
890C08DB8579162FEE0DF9DB8BEAB4DFCF555EF4 \
C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C \
108F52B48DB57BB0CC439B2997B01419BD92F80A \
A363A499291CBBC940DD62E41F10027AF002F8B0 \
655F3B5C1FB3FA8D1A0CA6BDE4A7D232B936D2FD \
; do \
{ gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$key" && gpg --batch --fingerprint "$key"; } || \
{ gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key" && gpg --batch --fingerprint "$key"; } ; \
done \
&& curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION.tar.xz" \
&& curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \
&& gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \
&& gpgconf --kill all \
&& rm -rf "$GNUPGHOME" \
&& grep " node-v$NODE_VERSION.tar.xz\$" SHASUMS256.txt | sha256sum -c - \
&& tar -xf "node-v$NODE_VERSION.tar.xz" \
&& cd "node-v$NODE_VERSION" \
&& ./configure --experimental-enable-pointer-compression \
&& make -j"${MAKE_JOBS:-$(getconf _NPROCESSORS_ONLN)}" V= \
&& make install DESTDIR=/node-install \
# Remove unused OpenSSL headers to save ~34MB. See https://github.com/nodejs/node/issues/46451
&& find /node-install/usr/local/include/node/openssl/archs -mindepth 1 -maxdepth 1 ! -name "$OPENSSL_ARCH" -exec rm -rf {} \;
FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS base
RUN addgroup -g 1000 node \
&& adduser -u 1000 -G node -s /bin/sh -D node \
&& apk add --no-cache libstdc++
COPY --from=builder /node-install/usr/local /usr/local
RUN node --version && npm --version \
&& node -e "if (!process.config.variables.v8_enable_pointer_compression) { throw new Error('pointer compression is not enabled') }"
FROM base AS dev
# node-gyp must compile native addons against this build's headers so they pick
# up the pointer compression defines, never against downloaded stock headers.
ENV npm_config_nodedir=/usr/local
# ---------------------------------------------------------------------------
# Runtime layers on the same DHI base as n8n-base, so both siblings share
# Docker's OS-layer patching and attestations. Only the Node binary is ours:
# DHI's stock Node (at /usr/bin on Alpine) is removed and replaced by the
# pointer-compressed build.
# Pinned to a multi-arch index digest (linux/amd64 + linux/arm64) for reproducible builds.
# Bump the tag and digest together when updating.
FROM dhi.io/node:26.5.1-alpine3.24-dev@sha256:c4062f85acd1ca91ffb7d15048dcc5f15a922d630e65eb3c3c0dcdcef6ea36d8 AS runtime
RUN rm -f /usr/bin/node /usr/bin/nodejs /usr/bin/npm /usr/bin/npx /usr/bin/corepack
COPY --from=builder /node-install/usr/local /usr/local
RUN node --version \
&& node -e "if (!process.config.variables.v8_enable_pointer_compression) { throw new Error('pointer compression is not enabled') }"
# Mirrors docker/images/n8n-base/Dockerfile so the n8n runtime stage can swap
# between the two.
RUN apk add --no-cache busybox-binsh && \
apk --no-cache add --virtual .build-deps-fonts msttcorefonts-installer fontconfig && \
update-ms-fonts && \
fc-cache -f && \
apk del .build-deps-fonts && \
find /usr/share/fonts/truetype/msttcorefonts/ -type l -exec unlink {} \; && \
# No blanket `apk upgrade` — patched bytes come from bumping the pinned
# DHI digest instead, same policy as n8n-base.
apk add --no-cache \
openssh \
graphicsmagick \
tini \
tzdata \
ca-certificates \
libc6-compat && \
rm -rf /tmp/* /root/.npm /root/.cache/node /opt/yarn* && \
apk del apk-tools
# The compiled build lives at /usr/local/bin, which is what the cloud launch
# and AppArmor profile expect. Keep the stock /usr/bin path working for
# anything that references it.
RUN ln -sf /usr/local/bin/node /usr/bin/node
WORKDIR /home/node
ENV NODE_PATH=/usr/local/lib/node_modules
EXPOSE 5678/tcp

View File

@ -92,6 +92,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.oauth_clients](public.oauth_clients.md) | 10 | | BASE TABLE |
| [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) | 7 | | BASE TABLE |
| [public.oauth_user_consents](public.oauth_user_consents.md) | 5 | | BASE TABLE |
| [public.poller_state](public.poller_state.md) | 7 | | BASE TABLE |
| [public.processed_data](public.processed_data.md) | 5 | | BASE TABLE |
| [public.project](public.project.md) | 9 | | BASE TABLE |
| [public.project_relation](public.project_relation.md) | 5 | | BASE TABLE |
@ -122,14 +123,16 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.workflow_dependency](public.workflow_dependency.md) | 9 | | BASE TABLE |
| [public.workflow_entity](public.workflow_entity.md) | 20 | | BASE TABLE |
| [public.workflow_history](public.workflow_history.md) | 11 | | BASE TABLE |
| [public.workflow_publication_outbox](public.workflow_publication_outbox.md) | 7 | | BASE TABLE |
| [public.workflow_publication_outbox](public.workflow_publication_outbox.md) | 8 | | BASE TABLE |
| [public.workflow_publication_trigger_status](public.workflow_publication_trigger_status.md) | 8 | | BASE TABLE |
| [public.workflow_publish_history](public.workflow_publish_history.md) | 6 | | BASE TABLE |
| [public.workflow_published_version](public.workflow_published_version.md) | 4 | | BASE TABLE |
| [public.workflow_review_activity](public.workflow_review_activity.md) | 8 | | BASE TABLE |
| [public.workflow_review_activity_comment](public.workflow_review_activity_comment.md) | 8 | | BASE TABLE |
| [public.workflow_review_request](public.workflow_review_request.md) | 12 | | BASE TABLE |
| [public.workflow_review_request_authors](public.workflow_review_request_authors.md) | 2 | | BASE TABLE |
| [public.workflow_review_request_reviewers](public.workflow_review_request_reviewers.md) | 2 | | BASE TABLE |
| [public.workflow_review_request_workflow](public.workflow_review_request_workflow.md) | 4 | | BASE TABLE |
| [public.workflow_review_request_workflow](public.workflow_review_request_workflow.md) | 5 | | BASE TABLE |
| [public.workflow_statistics](public.workflow_statistics.md) | 7 | | BASE TABLE |
| [public.workflow_statistics_delta](public.workflow_statistics_delta.md) | 6 | | BASE TABLE |
| [public.workflows_tags](public.workflows_tags.md) | 2 | | BASE TABLE |
@ -278,6 +281,7 @@ erDiagram
"public.oauth_refresh_tokens" }o--|| "public.oauth_clients" : "FOREIGN KEY (#quot;clientId#quot;) REFERENCES oauth_clients(id) ON DELETE CASCADE"
"public.oauth_user_consents" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.oauth_user_consents" }o--|| "public.oauth_clients" : "FOREIGN KEY (#quot;clientId#quot;) REFERENCES oauth_clients(id) ON DELETE CASCADE"
"public.poller_state" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.processed_data" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(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"
@ -320,6 +324,11 @@ erDiagram
"public.workflow_publish_history" }o--o| "public.workflow_history" : "FOREIGN KEY (#quot;versionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE SET NULL"
"public.workflow_published_version" |o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE RESTRICT"
"public.workflow_published_version" }o--|| "public.workflow_history" : "FOREIGN KEY (#quot;publishedVersionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE RESTRICT"
"public.workflow_review_activity" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_activity" }o--o| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.workflow_review_activity" }o--|| "public.workflow_review_request" : "FOREIGN KEY (#quot;workflowReviewRequestId#quot;) REFERENCES workflow_review_request(id) ON DELETE CASCADE"
"public.workflow_review_activity_comment" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_activity_comment" }o--|| "public.workflow_review_activity" : "FOREIGN KEY (#quot;activityId#quot;) REFERENCES workflow_review_activity(id) ON DELETE CASCADE"
"public.workflow_review_request" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_request" }o--o| "public.user" : "FOREIGN KEY (#quot;updatedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_request" }o--o| "public.user" : "FOREIGN KEY (#quot;closedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
@ -330,6 +339,7 @@ erDiagram
"public.workflow_review_request_reviewers" }o--|| "public.workflow_review_request" : "FOREIGN KEY (#quot;workflowReviewRequestId#quot;) REFERENCES workflow_review_request(id) ON DELETE CASCADE"
"public.workflow_review_request_workflow" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.workflow_review_request_workflow" }o--o| "public.workflow_history" : "FOREIGN KEY (#quot;workflowVersionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE SET NULL"
"public.workflow_review_request_workflow" }o--o| "public.workflow_history" : "FOREIGN KEY (#quot;baselineVersionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE SET NULL"
"public.workflow_review_request_workflow" }o--|| "public.workflow_review_request" : "FOREIGN KEY (#quot;workflowReviewRequestId#quot;) REFERENCES workflow_review_request(id) ON DELETE CASCADE"
"public.workflows_tags" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.workflows_tags" }o--|| "public.tag_entity" : "FOREIGN KEY (#quot;tagId#quot;) REFERENCES tag_entity(id) ON DELETE CASCADE"
@ -1172,6 +1182,15 @@ erDiagram
json scope
uuid userId FK
}
"public.poller_state" {
timestamp_3__with_time_zone backoffUntil
integer consecutiveErrors
timestamp_3__with_time_zone createdAt
json cursor
varchar_36_ nodeId
timestamp_3__with_time_zone updatedAt
varchar_36_ workflowId FK
}
"public.processed_data" {
varchar_255_ context
timestamp_3__with_time_zone createdAt
@ -1478,6 +1497,7 @@ erDiagram
text errorMessage
integer id
varchar_36_ publishedVersionId
varchar_20_ reason
varchar_20_ status
timestamp_3__with_time_zone updatedAt
varchar_36_ workflowId
@ -1506,6 +1526,26 @@ erDiagram
timestamp_3__with_time_zone updatedAt
varchar_36_ workflowId FK
}
"public.workflow_review_activity" {
timestamp_3__with_time_zone createdAt
uuid createdById FK
json data
integer id
varchar_64_ type
integer typeVersion
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK
}
"public.workflow_review_activity_comment" {
integer activityId FK
text body
timestamp_3__with_time_zone createdAt
uuid createdById FK
timestamp_3__with_time_zone deletedAt
json history
integer id
timestamp_3__with_time_zone updatedAt
}
"public.workflow_review_request" {
timestamp_3__with_time_zone approvedAt
uuid closedById FK
@ -1529,6 +1569,7 @@ erDiagram
varchar_36_ workflowReviewRequestId FK
}
"public.workflow_review_request_workflow" {
varchar_36_ baselineVersionId FK
varchar_36_ id
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK

View File

@ -0,0 +1,76 @@
# public.poller_state
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| backoffUntil | timestamp(3) with time zone | | true | | | Time before which no poll is attempted; NULL when not backing off. |
| consecutiveErrors | integer | 0 | false | | | Polls that have failed since the last successful one. |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| cursor | json | '{}'::json | false | | | How far the poll node has consumed its source, in whatever shape that node uses. |
| nodeId | varchar(36) | | false | | | |
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| workflowId | varchar(36) | | false | | [public.workflow_entity](public.workflow_entity.md) | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| FK_poller_state_workflowId | FOREIGN KEY | FOREIGN KEY ("workflowId") REFERENCES workflow_entity(id) ON DELETE CASCADE |
| PK_eda59336a5dd1e6a12f4d4b7287 | PRIMARY KEY | PRIMARY KEY ("workflowId", "nodeId") |
| poller_state_consecutiveErrors_not_null | n | NOT NULL "consecutiveErrors" |
| poller_state_createdAt_not_null | n | NOT NULL "createdAt" |
| poller_state_cursor_not_null | n | NOT NULL cursor |
| poller_state_nodeId_not_null | n | NOT NULL "nodeId" |
| poller_state_updatedAt_not_null | n | NOT NULL "updatedAt" |
| poller_state_workflowId_not_null | n | NOT NULL "workflowId" |
## Indexes
| Name | Definition |
| ---- | ---------- |
| PK_eda59336a5dd1e6a12f4d4b7287 | CREATE UNIQUE INDEX "PK_eda59336a5dd1e6a12f4d4b7287" ON public.poller_state USING btree ("workflowId", "nodeId") |
## Relations
```mermaid
erDiagram
"public.poller_state" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.poller_state" {
timestamp_3__with_time_zone backoffUntil
integer consecutiveErrors
timestamp_3__with_time_zone createdAt
json cursor
varchar_36_ nodeId
timestamp_3__with_time_zone updatedAt
varchar_36_ workflowId FK
}
"public.workflow_entity" {
boolean active
varchar_36_ activeVersionId FK
json connections
timestamp_3__with_time_zone createdAt
text description
varchar_36_ id
boolean isArchived
json meta
varchar_128_ name
json nodeGroups
json nodes
varchar_36_ parentFolderId FK
json pinData
json settings
varchar sourceWorkflowId
json staticData
integer triggerCount
timestamp_3__with_time_zone updatedAt
integer versionCounter
character_36_ versionId
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)

View File

@ -8,7 +8,7 @@
| disabled | boolean | false | false | | | |
| email | varchar(255) | | true | | | |
| firstName | varchar(32) | | true | | | |
| id | uuid | gen_random_uuid() | false | [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_history](public.agent_history.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.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_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.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_history](public.agent_history.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.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) | | |
| lastActiveAt | date | | true | | | |
| lastName | varchar(32) | | true | | | |
| mfaEnabled | boolean | false | false | | | |
@ -70,6 +70,8 @@ erDiagram
"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"
"public.workflow_publish_history" }o--o| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_activity" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_activity_comment" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_request" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_request" }o--o| "public.user" : "FOREIGN KEY (#quot;updatedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_request" }o--o| "public.user" : "FOREIGN KEY (#quot;closedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
@ -330,6 +332,26 @@ erDiagram
varchar_36_ versionId FK
varchar_36_ workflowId FK
}
"public.workflow_review_activity" {
timestamp_3__with_time_zone createdAt
uuid createdById FK
json data
integer id
varchar_64_ type
integer typeVersion
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK
}
"public.workflow_review_activity_comment" {
integer activityId FK
text body
timestamp_3__with_time_zone createdAt
uuid createdById FK
timestamp_3__with_time_zone deletedAt
json history
integer id
timestamp_3__with_time_zone updatedAt
}
"public.workflow_review_request" {
timestamp_3__with_time_zone approvedAt
uuid closedById FK

View File

@ -9,7 +9,7 @@
| connections | json | | false | | | |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| description | text | | true | | | |
| id | varchar(36) | | false | [public.ai_builder_temporary_workflow](public.ai_builder_temporary_workflow.md) [public.chat_hub_messages](public.chat_hub_messages.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.evaluation_collection](public.evaluation_collection.md) [public.evaluation_config](public.evaluation_config.md) [public.execution_entity](public.execution_entity.md) [public.insights_metadata](public.insights_metadata.md) [public.processed_data](public.processed_data.md) [public.shared_workflow](public.shared_workflow.md) [public.test_run](public.test_run.md) [public.webhook_entity](public.webhook_entity.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_dependency](public.workflow_dependency.md) [public.workflow_history](public.workflow_history.md) [public.workflow_publication_trigger_status](public.workflow_publication_trigger_status.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_published_version](public.workflow_published_version.md) [public.workflow_review_request_workflow](public.workflow_review_request_workflow.md) [public.workflows_tags](public.workflows_tags.md) | | |
| id | varchar(36) | | false | [public.ai_builder_temporary_workflow](public.ai_builder_temporary_workflow.md) [public.chat_hub_messages](public.chat_hub_messages.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.evaluation_collection](public.evaluation_collection.md) [public.evaluation_config](public.evaluation_config.md) [public.execution_entity](public.execution_entity.md) [public.insights_metadata](public.insights_metadata.md) [public.poller_state](public.poller_state.md) [public.processed_data](public.processed_data.md) [public.shared_workflow](public.shared_workflow.md) [public.test_run](public.test_run.md) [public.webhook_entity](public.webhook_entity.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_dependency](public.workflow_dependency.md) [public.workflow_history](public.workflow_history.md) [public.workflow_publication_trigger_status](public.workflow_publication_trigger_status.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_published_version](public.workflow_published_version.md) [public.workflow_review_activity](public.workflow_review_activity.md) [public.workflow_review_request_workflow](public.workflow_review_request_workflow.md) [public.workflows_tags](public.workflows_tags.md) | | |
| isArchived | boolean | false | false | | | |
| meta | json | | true | | | |
| name | varchar(128) | | false | | | |
@ -73,6 +73,7 @@ erDiagram
"public.evaluation_config" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.execution_entity" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.insights_metadata" }o--o| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE SET NULL"
"public.poller_state" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.processed_data" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.shared_workflow" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.test_run" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
@ -83,6 +84,7 @@ erDiagram
"public.workflow_publication_trigger_status" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.workflow_publish_history" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.workflow_published_version" |o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE RESTRICT"
"public.workflow_review_activity" }o--o| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.workflow_review_request_workflow" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.workflows_tags" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.workflow_entity" }o--o| "public.folder" : "FOREIGN KEY (#quot;parentFolderId#quot;) REFERENCES folder(id) ON DELETE CASCADE"
@ -215,6 +217,15 @@ erDiagram
varchar_36_ workflowId FK
varchar_128_ workflowName
}
"public.poller_state" {
timestamp_3__with_time_zone backoffUntil
integer consecutiveErrors
timestamp_3__with_time_zone createdAt
json cursor
varchar_36_ nodeId
timestamp_3__with_time_zone updatedAt
varchar_36_ workflowId FK
}
"public.processed_data" {
varchar_255_ context
timestamp_3__with_time_zone createdAt
@ -301,7 +312,18 @@ erDiagram
timestamp_3__with_time_zone updatedAt
varchar_36_ workflowId FK
}
"public.workflow_review_activity" {
timestamp_3__with_time_zone createdAt
uuid createdById FK
json data
integer id
varchar_64_ type
integer typeVersion
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK
}
"public.workflow_review_request_workflow" {
varchar_36_ baselineVersionId FK
varchar_36_ id
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK

View File

@ -49,6 +49,7 @@ erDiagram
"public.workflow_publish_history" }o--o| "public.workflow_history" : "FOREIGN KEY (#quot;versionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE SET NULL"
"public.workflow_published_version" }o--|| "public.workflow_history" : "FOREIGN KEY (#quot;publishedVersionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE RESTRICT"
"public.workflow_review_request_workflow" }o--o| "public.workflow_history" : "FOREIGN KEY (#quot;workflowVersionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE SET NULL"
"public.workflow_review_request_workflow" }o--o| "public.workflow_history" : "FOREIGN KEY (#quot;baselineVersionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE SET NULL"
"public.workflow_history" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.workflow_history" {
@ -111,6 +112,7 @@ erDiagram
varchar_36_ workflowId FK
}
"public.workflow_review_request_workflow" {
varchar_36_ baselineVersionId FK
varchar_36_ id
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK

View File

@ -8,6 +8,7 @@
| errorMessage | text | | true | | | Error details for surfacing failed publications to the user. |
| id | integer | | false | | | |
| publishedVersionId | varchar(36) | | false | | | References workflow_history.versionId. |
| reason | varchar(20) | 'publish'::character varying | false | | | Why this publication was enqueued: user publish/unpublish, leader startup pass, leadership takeover, or reconciler drift-heal |
| status | varchar(20) | | false | | | |
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| workflowId | varchar(36) | | false | | | References workflow_entity.id. |
@ -16,11 +17,13 @@
| Name | Type | Definition |
| ---- | ---- | ---------- |
| CHK_workflow_publication_outbox_reason | CHECK | CHECK (((reason)::text = ANY ((ARRAY['publish'::character varying, 'startup'::character varying, 'leadership-takeover'::character varying, 'reconcile'::character varying])::text[]))) |
| CHK_workflow_publication_outbox_status | CHECK | CHECK (((status)::text = ANY ((ARRAY['pending'::character varying, 'in_progress'::character varying, 'completed'::character varying, 'partial_success'::character varying, 'failed'::character varying])::text[]))) |
| PK_b3e2eeee36a4bd044d56468d311 | PRIMARY KEY | PRIMARY KEY (id) |
| workflow_publication_outbox_createdAt_not_null | n | NOT NULL "createdAt" |
| workflow_publication_outbox_id_not_null | n | NOT NULL id |
| workflow_publication_outbox_publishedVersionId_not_null | n | NOT NULL "publishedVersionId" |
| workflow_publication_outbox_reason_not_null | n | NOT NULL reason |
| workflow_publication_outbox_status_not_null | n | NOT NULL status |
| workflow_publication_outbox_updatedAt_not_null | n | NOT NULL "updatedAt" |
| workflow_publication_outbox_workflowId_not_null | n | NOT NULL "workflowId" |
@ -43,6 +46,7 @@ erDiagram
text errorMessage
integer id
varchar_36_ publishedVersionId
varchar_20_ reason
varchar_20_ status
timestamp_3__with_time_zone updatedAt
varchar_36_ workflowId

View File

@ -0,0 +1,125 @@
# public.workflow_review_activity
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| createdById | uuid | | true | | [public.user](public.user.md) | |
| data | json | | true | | | Detail per activity type |
| id | integer | | false | [public.workflow_review_activity_comment](public.workflow_review_activity_comment.md) | | |
| type | varchar(64) | | false | | | Feed entry kind; see WorkflowReviewActivityType in @n8n/api-types |
| typeVersion | integer | 1 | false | | | Schema version of the `data` payload for this `type` |
| workflowId | varchar(36) | | true | | [public.workflow_entity](public.workflow_entity.md) | Scopes the entry to one workflow; NULL for review-level entries like comments |
| workflowReviewRequestId | varchar(36) | | false | | [public.workflow_review_request](public.workflow_review_request.md) | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| CHK_workflow_review_activity_type | CHECK | CHECK (((type)::text = ANY ((ARRAY['review.opened'::character varying, 'comment.created'::character varying, 'review.changes_requested'::character varying, 'review.version_updated'::character varying, 'review.approved'::character varying, 'workflow.published'::character varying, 'review.closed'::character varying])::text[]))) |
| FK_61048bf6220dd354c955a9d9379 | FOREIGN KEY | FOREIGN KEY ("workflowReviewRequestId") REFERENCES workflow_review_request(id) ON DELETE CASCADE |
| FK_d0162c063ffb699b0f1d1172508 | FOREIGN KEY | FOREIGN KEY ("workflowId") REFERENCES workflow_entity(id) ON DELETE CASCADE |
| FK_fcf78b037a72fc7aa01ab237e08 | FOREIGN KEY | FOREIGN KEY ("createdById") REFERENCES "user"(id) ON DELETE SET NULL |
| PK_f1e66ba0b645dec566057ad21e6 | PRIMARY KEY | PRIMARY KEY (id) |
| workflow_review_activity_createdAt_not_null | n | NOT NULL "createdAt" |
| workflow_review_activity_id_not_null | n | NOT NULL id |
| workflow_review_activity_typeVersion_not_null | n | NOT NULL "typeVersion" |
| workflow_review_activity_type_not_null | n | NOT NULL type |
| workflow_review_activity_workflowReviewRequestId_not_null | n | NOT NULL "workflowReviewRequestId" |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_workflow_review_activity_request | CREATE INDEX "IDX_workflow_review_activity_request" ON public.workflow_review_activity USING btree ("workflowReviewRequestId", id) |
| PK_f1e66ba0b645dec566057ad21e6 | CREATE UNIQUE INDEX "PK_f1e66ba0b645dec566057ad21e6" ON public.workflow_review_activity USING btree (id) |
## Relations
```mermaid
erDiagram
"public.workflow_review_activity" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_activity_comment" }o--|| "public.workflow_review_activity" : "FOREIGN KEY (#quot;activityId#quot;) REFERENCES workflow_review_activity(id) ON DELETE CASCADE"
"public.workflow_review_activity" }o--o| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.workflow_review_activity" }o--|| "public.workflow_review_request" : "FOREIGN KEY (#quot;workflowReviewRequestId#quot;) REFERENCES workflow_review_request(id) ON DELETE CASCADE"
"public.workflow_review_activity" {
timestamp_3__with_time_zone createdAt
uuid createdById FK
json data
integer id
varchar_64_ type
integer typeVersion
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK
}
"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
}
"public.workflow_review_activity_comment" {
integer activityId FK
text body
timestamp_3__with_time_zone createdAt
uuid createdById FK
timestamp_3__with_time_zone deletedAt
json history
integer id
timestamp_3__with_time_zone updatedAt
}
"public.workflow_entity" {
boolean active
varchar_36_ activeVersionId FK
json connections
timestamp_3__with_time_zone createdAt
text description
varchar_36_ id
boolean isArchived
json meta
varchar_128_ name
json nodeGroups
json nodes
varchar_36_ parentFolderId FK
json pinData
json settings
varchar sourceWorkflowId
json staticData
integer triggerCount
timestamp_3__with_time_zone updatedAt
integer versionCounter
character_36_ versionId
}
"public.workflow_review_request" {
timestamp_3__with_time_zone approvedAt
uuid closedById FK
timestamp_3__with_time_zone createdAt
uuid createdById FK
varchar_50_ decision
text description
varchar_36_ id
varchar_36_ projectId FK
varchar_16_ state
varchar_255_ title
timestamp_3__with_time_zone updatedAt
uuid updatedById FK
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)

View File

@ -0,0 +1,83 @@
# public.workflow_review_activity_comment
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| activityId | integer | | false | | [public.workflow_review_activity](public.workflow_review_activity.md) | Thread this message belongs to; the activity row is its header |
| body | text | | true | | | Only user-editable text in the feed; nulled on delete |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| createdById | uuid | | true | | [public.user](public.user.md) | |
| deletedAt | timestamp(3) with time zone | | true | | | Set when the comment is deleted |
| history | json | | true | | | Comment revision history. |
| id | integer | | false | | | |
| updatedAt | timestamp(3) with time zone | | true | | | Set when the body is edited |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| FK_3cd755e8ce44ee49bdf7e9222ec | FOREIGN KEY | FOREIGN KEY ("activityId") REFERENCES workflow_review_activity(id) ON DELETE CASCADE |
| FK_9cfac4b106553d0930a1962bea0 | FOREIGN KEY | FOREIGN KEY ("createdById") REFERENCES "user"(id) ON DELETE SET NULL |
| PK_5a2a7dea8b7e6afa1001129da27 | PRIMARY KEY | PRIMARY KEY (id) |
| workflow_review_activity_comment_activityId_not_null | n | NOT NULL "activityId" |
| workflow_review_activity_comment_createdAt_not_null | n | NOT NULL "createdAt" |
| workflow_review_activity_comment_id_not_null | n | NOT NULL id |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_workflow_review_activity_comment_activity | CREATE INDEX "IDX_workflow_review_activity_comment_activity" ON public.workflow_review_activity_comment USING btree ("activityId", id) |
| PK_5a2a7dea8b7e6afa1001129da27 | CREATE UNIQUE INDEX "PK_5a2a7dea8b7e6afa1001129da27" ON public.workflow_review_activity_comment USING btree (id) |
## Relations
```mermaid
erDiagram
"public.workflow_review_activity_comment" }o--|| "public.workflow_review_activity" : "FOREIGN KEY (#quot;activityId#quot;) REFERENCES workflow_review_activity(id) ON DELETE CASCADE"
"public.workflow_review_activity_comment" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_activity_comment" {
integer activityId FK
text body
timestamp_3__with_time_zone createdAt
uuid createdById FK
timestamp_3__with_time_zone deletedAt
json history
integer id
timestamp_3__with_time_zone updatedAt
}
"public.workflow_review_activity" {
timestamp_3__with_time_zone createdAt
uuid createdById FK
json data
integer id
varchar_64_ type
integer typeVersion
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK
}
"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)

View File

@ -10,7 +10,7 @@
| createdById | uuid | | true | | [public.user](public.user.md) | |
| decision | varchar(50) | 'pending'::character varying | false | | | Latest review outcome while the request is open |
| description | text | | true | | | |
| id | varchar(36) | | false | [public.workflow_review_request_authors](public.workflow_review_request_authors.md) [public.workflow_review_request_reviewers](public.workflow_review_request_reviewers.md) [public.workflow_review_request_workflow](public.workflow_review_request_workflow.md) | | |
| id | varchar(36) | | false | [public.workflow_review_activity](public.workflow_review_activity.md) [public.workflow_review_request_authors](public.workflow_review_request_authors.md) [public.workflow_review_request_reviewers](public.workflow_review_request_reviewers.md) [public.workflow_review_request_workflow](public.workflow_review_request_workflow.md) | | |
| projectId | varchar(36) | | false | | [public.project](public.project.md) | |
| state | varchar(16) | 'open'::character varying | false | | | Review lifecycle: open reviews accept actions; closed reviews are done |
| title | varchar(255) | | false | | | |
@ -50,6 +50,7 @@ erDiagram
"public.workflow_review_request" }o--o| "public.user" : "FOREIGN KEY (#quot;closedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_request" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_activity" }o--|| "public.workflow_review_request" : "FOREIGN KEY (#quot;workflowReviewRequestId#quot;) REFERENCES workflow_review_request(id) ON DELETE CASCADE"
"public.workflow_review_request_authors" }o--|| "public.workflow_review_request" : "FOREIGN KEY (#quot;workflowReviewRequestId#quot;) REFERENCES workflow_review_request(id) ON DELETE CASCADE"
"public.workflow_review_request_reviewers" }o--|| "public.workflow_review_request" : "FOREIGN KEY (#quot;workflowReviewRequestId#quot;) REFERENCES workflow_review_request(id) ON DELETE CASCADE"
"public.workflow_review_request_workflow" }o--|| "public.workflow_review_request" : "FOREIGN KEY (#quot;workflowReviewRequestId#quot;) REFERENCES workflow_review_request(id) ON DELETE CASCADE"
@ -87,6 +88,16 @@ erDiagram
json settings
timestamp_3__with_time_zone updatedAt
}
"public.workflow_review_activity" {
timestamp_3__with_time_zone createdAt
uuid createdById FK
json data
integer id
varchar_64_ type
integer typeVersion
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK
}
"public.workflow_review_request_authors" {
uuid userId FK
varchar_36_ workflowReviewRequestId FK
@ -96,6 +107,7 @@ erDiagram
varchar_36_ workflowReviewRequestId FK
}
"public.workflow_review_request_workflow" {
varchar_36_ baselineVersionId FK
varchar_36_ id
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK

View File

@ -21,6 +21,7 @@
| Name | Definition |
| ---- | ---------- |
| IDX_workflow_review_request_authors_user | CREATE INDEX "IDX_workflow_review_request_authors_user" ON public.workflow_review_request_authors USING btree ("userId", "workflowReviewRequestId") |
| PK_ae197bb5135d422f6392a879e0c | CREATE UNIQUE INDEX "PK_ae197bb5135d422f6392a879e0c" ON public.workflow_review_request_authors USING btree ("workflowReviewRequestId", "userId") |
## Relations

View File

@ -4,6 +4,7 @@
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| baselineVersionId | varchar(36) | | true | | [public.workflow_history](public.workflow_history.md) | Published workflow_history version captured when the review was approved |
| id | varchar(36) | | false | | | |
| workflowId | varchar(36) | | false | | [public.workflow_entity](public.workflow_entity.md) | |
| workflowReviewRequestId | varchar(36) | | false | | [public.workflow_review_request](public.workflow_review_request.md) | |
@ -16,6 +17,7 @@
| FK_0f6f0f2c6d46b806fee02962ac2 | FOREIGN KEY | FOREIGN KEY ("workflowVersionId") REFERENCES workflow_history("versionId") ON DELETE SET NULL |
| FK_619f5b0544bcec60c3387e82f2f | FOREIGN KEY | FOREIGN KEY ("workflowId") REFERENCES workflow_entity(id) ON DELETE CASCADE |
| FK_e44b652e6dc99ef1364a2d85504 | FOREIGN KEY | FOREIGN KEY ("workflowReviewRequestId") REFERENCES workflow_review_request(id) ON DELETE CASCADE |
| FK_workflow_review_request_workflow_baselineVersionId | FOREIGN KEY | FOREIGN KEY ("baselineVersionId") REFERENCES workflow_history("versionId") ON DELETE SET NULL |
| PK_be3bf4facb054cf2b2b116b3b9c | PRIMARY KEY | PRIMARY KEY (id) |
| workflow_review_request_workfl_workflowReviewRequestId_not_null | n | NOT NULL "workflowReviewRequestId" |
| workflow_review_request_workflow_id_not_null | n | NOT NULL id |
@ -34,16 +36,31 @@
```mermaid
erDiagram
"public.workflow_review_request_workflow" }o--o| "public.workflow_history" : "FOREIGN KEY (#quot;baselineVersionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE SET NULL"
"public.workflow_review_request_workflow" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.workflow_review_request_workflow" }o--|| "public.workflow_review_request" : "FOREIGN KEY (#quot;workflowReviewRequestId#quot;) REFERENCES workflow_review_request(id) ON DELETE CASCADE"
"public.workflow_review_request_workflow" }o--o| "public.workflow_history" : "FOREIGN KEY (#quot;workflowVersionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE SET NULL"
"public.workflow_review_request_workflow" {
varchar_36_ baselineVersionId FK
varchar_36_ id
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK
varchar_36_ workflowVersionId FK
}
"public.workflow_history" {
varchar_255_ authors
boolean autosaved
json connections
timestamp_3__with_time_zone createdAt
text description
varchar_128_ name
json nodeGroups
json nodes
timestamp_3__with_time_zone updatedAt
varchar_36_ versionId
varchar_36_ workflowId FK
}
"public.workflow_entity" {
boolean active
varchar_36_ activeVersionId FK
@ -80,19 +97,6 @@ erDiagram
timestamp_3__with_time_zone updatedAt
uuid updatedById FK
}
"public.workflow_history" {
varchar_255_ authors
boolean autosaved
json connections
timestamp_3__with_time_zone createdAt
text description
varchar_128_ name
json nodeGroups
json nodes
timestamp_3__with_time_zone updatedAt
varchar_36_ versionId
varchar_36_ workflowId FK
}
```
---

View File

@ -92,6 +92,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [oauth_clients](oauth_clients.md) | 10 | | table |
| [oauth_refresh_tokens](oauth_refresh_tokens.md) | 7 | | table |
| [oauth_user_consents](oauth_user_consents.md) | 5 | | table |
| [poller_state](poller_state.md) | 7 | | table |
| [processed_data](processed_data.md) | 5 | | table |
| [project](project.md) | 9 | | table |
| [project_relation](project_relation.md) | 5 | | table |
@ -122,14 +123,16 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [workflow_dependency](workflow_dependency.md) | 9 | | table |
| [workflow_entity](workflow_entity.md) | 20 | | table |
| [workflow_history](workflow_history.md) | 11 | | table |
| [workflow_publication_outbox](workflow_publication_outbox.md) | 7 | | table |
| [workflow_publication_outbox](workflow_publication_outbox.md) | 8 | | table |
| [workflow_publication_trigger_status](workflow_publication_trigger_status.md) | 8 | | table |
| [workflow_publish_history](workflow_publish_history.md) | 6 | | table |
| [workflow_published_version](workflow_published_version.md) | 4 | | table |
| [workflow_review_activity](workflow_review_activity.md) | 8 | | table |
| [workflow_review_activity_comment](workflow_review_activity_comment.md) | 8 | | table |
| [workflow_review_request](workflow_review_request.md) | 12 | | table |
| [workflow_review_request_authors](workflow_review_request_authors.md) | 2 | | table |
| [workflow_review_request_reviewers](workflow_review_request_reviewers.md) | 2 | | table |
| [workflow_review_request_workflow](workflow_review_request_workflow.md) | 4 | | table |
| [workflow_review_request_workflow](workflow_review_request_workflow.md) | 5 | | table |
| [workflow_statistics](workflow_statistics.md) | 7 | | table |
| [workflows_tags](workflows_tags.md) | 2 | | table |
@ -261,6 +264,7 @@ erDiagram
"oauth_refresh_tokens" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"oauth_user_consents" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"oauth_user_consents" }o--|| "oauth_clients" : "FOREIGN KEY (clientId) REFERENCES oauth_clients (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"poller_state" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"processed_data" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (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--|| "role" : "FOREIGN KEY (role) REFERENCES role (slug) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE"
@ -307,6 +311,11 @@ erDiagram
"workflow_published_version" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE"
"workflow_published_version" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_published_version" }o--|| "workflow_history" : "FOREIGN KEY (publishedVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_activity" }o--o| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_activity" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_activity" }o--|| "workflow_review_request" : "FOREIGN KEY (workflowReviewRequestId) REFERENCES workflow_review_request (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_activity_comment" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_activity_comment" }o--|| "workflow_review_activity" : "FOREIGN KEY (activityId) REFERENCES workflow_review_activity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_request" }o--o| "user" : "FOREIGN KEY (closedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_request" }o--o| "user" : "FOREIGN KEY (updatedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_request" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
@ -315,9 +324,10 @@ erDiagram
"workflow_review_request_authors" |o--|| "workflow_review_request" : "FOREIGN KEY (workflowReviewRequestId) REFERENCES workflow_review_request (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_request_reviewers" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_request_reviewers" |o--|| "workflow_review_request" : "FOREIGN KEY (workflowReviewRequestId) REFERENCES workflow_review_request (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_request_workflow" }o--o| "workflow_history" : "FOREIGN KEY (workflowVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_request_workflow" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_request_workflow" }o--o| "workflow_history" : "FOREIGN KEY (baselineVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_request_workflow" }o--|| "workflow_review_request" : "FOREIGN KEY (workflowReviewRequestId) REFERENCES workflow_review_request (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_request_workflow" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_request_workflow" }o--o| "workflow_history" : "FOREIGN KEY (workflowVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflows_tags" |o--|| "tag_entity" : "FOREIGN KEY (tagId) REFERENCES tag_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflows_tags" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@ -1159,6 +1169,15 @@ erDiagram
TEXT scope
varchar userId FK
}
"poller_state" {
datetime_3_ backoffUntil
INTEGER consecutiveErrors
datetime_3_ createdAt
TEXT cursor
varchar_36_ nodeId PK
datetime_3_ updatedAt
varchar_36_ workflowId PK
}
"processed_data" {
varchar_255_ context PK
datetime_3_ createdAt
@ -1467,6 +1486,7 @@ erDiagram
TEXT errorMessage
INTEGER id
varchar_36_ publishedVersionId
varchar_20_ reason
varchar_20_ status
datetime_3_ updatedAt
varchar_36_ workflowId
@ -1495,6 +1515,26 @@ erDiagram
datetime_3_ updatedAt
varchar_36_ workflowId PK
}
"workflow_review_activity" {
datetime_3_ createdAt
varchar createdById FK
TEXT data
INTEGER id
varchar_64_ type
INTEGER typeVersion
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK
}
"workflow_review_activity_comment" {
INTEGER activityId FK
TEXT body
datetime_3_ createdAt
varchar createdById FK
datetime_3_ deletedAt
TEXT history
INTEGER id
datetime_3_ updatedAt
}
"workflow_review_request" {
datetime_3_ approvedAt
varchar closedById FK
@ -1518,6 +1558,7 @@ erDiagram
varchar_36_ workflowReviewRequestId PK
}
"workflow_review_request_workflow" {
varchar_36_ baselineVersionId FK
varchar_36_ id PK
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK

View File

@ -0,0 +1,83 @@
# poller_state
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "poller_state" ("workflowId" varchar(36) NOT NULL, "nodeId" varchar(36) NOT NULL, "cursor" text NOT NULL DEFAULT ('{}'), "consecutiveErrors" integer NOT NULL DEFAULT (0), "backoffUntil" 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 "FK_poller_state_workflowId" FOREIGN KEY ("workflowId") REFERENCES "workflow_entity" ("id") ON DELETE CASCADE, PRIMARY KEY ("workflowId", "nodeId"))
```
</details>
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| backoffUntil | datetime(3) | | true | | | |
| consecutiveErrors | INTEGER | 0 | false | | | |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| cursor | TEXT | '{}' | false | | | |
| nodeId | varchar(36) | | false | | | |
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| workflowId | varchar(36) | | false | | [workflow_entity](workflow_entity.md) | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| nodeId | PRIMARY KEY | PRIMARY KEY (nodeId) |
| sqlite_autoindex_poller_state_1 | PRIMARY KEY | PRIMARY KEY (workflowId, nodeId) |
| workflowId | PRIMARY KEY | PRIMARY KEY (workflowId) |
## Indexes
| Name | Definition |
| ---- | ---------- |
| sqlite_autoindex_poller_state_1 | PRIMARY KEY (workflowId, nodeId) |
## Relations
```mermaid
erDiagram
"poller_state" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"poller_state" {
datetime_3_ backoffUntil
INTEGER consecutiveErrors
datetime_3_ createdAt
TEXT cursor
varchar_36_ nodeId PK
datetime_3_ updatedAt
varchar_36_ workflowId PK
}
"workflow_entity" {
boolean active
varchar_36_ activeVersionId FK
TEXT connections
datetime_3_ createdAt
TEXT description
varchar_36_ id PK
boolean isArchived
TEXT meta
varchar_128_ name
TEXT nodeGroups
TEXT nodes
varchar_36_ parentFolderId FK
TEXT pinData
TEXT settings
varchar sourceWorkflowId
TEXT staticData
INTEGER triggerCount
datetime_3_ updatedAt
INTEGER versionCounter
varchar_36_ versionId
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)

View File

@ -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 | [agent_eval_dataset](agent_eval_dataset.md) [agent_eval_rating](agent_eval_rating.md) [agent_eval_run](agent_eval_run.md) [agent_history](agent_history.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) [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_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 | [agent_eval_dataset](agent_eval_dataset.md) [agent_eval_rating](agent_eval_rating.md) [agent_eval_run](agent_eval_run.md) [agent_history](agent_history.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) [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) | | |
| lastActiveAt | date | | true | | | |
| lastName | varchar(32) | | true | | | |
| mfaEnabled | boolean | FALSE | false | | | |
@ -76,6 +76,8 @@ erDiagram
"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"
"workflow_publish_history" }o--o| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_activity" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_activity_comment" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_request" }o--o| "user" : "FOREIGN KEY (closedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_request" }o--o| "user" : "FOREIGN KEY (updatedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_request" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
@ -336,6 +338,26 @@ erDiagram
varchar_36_ versionId FK
varchar_36_ workflowId FK
}
"workflow_review_activity" {
datetime_3_ createdAt
varchar createdById FK
TEXT data
INTEGER id
varchar_64_ type
INTEGER typeVersion
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK
}
"workflow_review_activity_comment" {
INTEGER activityId FK
TEXT body
datetime_3_ createdAt
varchar createdById FK
datetime_3_ deletedAt
TEXT history
INTEGER id
datetime_3_ updatedAt
}
"workflow_review_request" {
datetime_3_ approvedAt
varchar closedById FK

View File

@ -20,7 +20,7 @@ CREATE TABLE "workflow_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" va
| connections | TEXT | | true | | | |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| description | TEXT | | true | | | |
| id | varchar(36) | | false | [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) [chat_hub_messages](chat_hub_messages.md) [chat_hub_sessions](chat_hub_sessions.md) [evaluation_collection](evaluation_collection.md) [evaluation_config](evaluation_config.md) [execution_entity](execution_entity.md) [insights_metadata](insights_metadata.md) [processed_data](processed_data.md) [shared_workflow](shared_workflow.md) [test_run](test_run.md) [workflow_builder_session](workflow_builder_session.md) [workflow_dependency](workflow_dependency.md) [workflow_history](workflow_history.md) [workflow_publication_trigger_status](workflow_publication_trigger_status.md) [workflow_publish_history](workflow_publish_history.md) [workflow_published_version](workflow_published_version.md) [workflow_review_request_workflow](workflow_review_request_workflow.md) [workflows_tags](workflows_tags.md) | | |
| id | varchar(36) | | false | [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) [chat_hub_messages](chat_hub_messages.md) [chat_hub_sessions](chat_hub_sessions.md) [evaluation_collection](evaluation_collection.md) [evaluation_config](evaluation_config.md) [execution_entity](execution_entity.md) [insights_metadata](insights_metadata.md) [poller_state](poller_state.md) [processed_data](processed_data.md) [shared_workflow](shared_workflow.md) [test_run](test_run.md) [workflow_builder_session](workflow_builder_session.md) [workflow_dependency](workflow_dependency.md) [workflow_history](workflow_history.md) [workflow_publication_trigger_status](workflow_publication_trigger_status.md) [workflow_publish_history](workflow_publish_history.md) [workflow_published_version](workflow_published_version.md) [workflow_review_activity](workflow_review_activity.md) [workflow_review_request_workflow](workflow_review_request_workflow.md) [workflows_tags](workflows_tags.md) | | |
| isArchived | boolean | FALSE | false | | | |
| meta | TEXT | | true | | | |
| name | varchar(128) | | false | | | |
@ -72,6 +72,7 @@ erDiagram
"evaluation_config" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"execution_entity" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"insights_metadata" }o--o| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"poller_state" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"processed_data" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"shared_workflow" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"test_run" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@ -82,6 +83,7 @@ erDiagram
"workflow_publish_history" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_published_version" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE"
"workflow_published_version" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_activity" }o--o| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_request_workflow" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflows_tags" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_entity" }o--o| "folder" : "FOREIGN KEY (parentFolderId) REFERENCES folder (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@ -214,6 +216,15 @@ erDiagram
varchar_16_ workflowId FK
varchar_128_ workflowName
}
"poller_state" {
datetime_3_ backoffUntil
INTEGER consecutiveErrors
datetime_3_ createdAt
TEXT cursor
varchar_36_ nodeId PK
datetime_3_ updatedAt
varchar_36_ workflowId PK
}
"processed_data" {
varchar_255_ context PK
datetime_3_ createdAt
@ -292,7 +303,18 @@ erDiagram
datetime_3_ updatedAt
varchar_36_ workflowId PK
}
"workflow_review_activity" {
datetime_3_ createdAt
varchar createdById FK
TEXT data
INTEGER id
varchar_64_ type
INTEGER typeVersion
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK
}
"workflow_review_request_workflow" {
varchar_36_ baselineVersionId FK
varchar_36_ id PK
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK

View File

@ -53,6 +53,7 @@ erDiagram
"workflow_publish_history" }o--o| "workflow_history" : "FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_published_version" }o--|| "workflow_history" : "FOREIGN KEY (publishedVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE"
"workflow_published_version" }o--|| "workflow_history" : "FOREIGN KEY (publishedVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_request_workflow" }o--o| "workflow_history" : "FOREIGN KEY (baselineVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_request_workflow" }o--o| "workflow_history" : "FOREIGN KEY (workflowVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_history" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@ -116,6 +117,7 @@ erDiagram
varchar_36_ workflowId PK
}
"workflow_review_request_workflow" {
varchar_36_ baselineVersionId FK
varchar_36_ id PK
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK

View File

@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "workflow_publication_outbox" ("id" integer PRIMARY KEY NOT NULL, "workflowId" varchar(36) NOT NULL, "publishedVersionId" varchar(36) NOT NULL, "status" varchar(20) NOT NULL, "errorMessage" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_workflow_publication_outbox_status" CHECK ("status" IN ('pending', 'in_progress', 'completed', 'partial_success', 'failed')))
CREATE TABLE "workflow_publication_outbox" ("id" integer PRIMARY KEY NOT NULL, "workflowId" varchar(36) NOT NULL, "publishedVersionId" varchar(36) NOT NULL, "status" varchar(20) NOT NULL, "errorMessage" text, "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')), "reason" varchar(20) NOT NULL DEFAULT ('publish'), CONSTRAINT "CHK_workflow_publication_outbox_status" CHECK (("status" IN ('pending', 'in_progress', 'completed', 'partial_success', 'failed'))), CONSTRAINT "CHK_workflow_publication_outbox_reason" CHECK ("reason" IN ('publish', 'startup', 'leadership-takeover', 'reconcile')))
```
</details>
@ -19,6 +19,7 @@ CREATE TABLE "workflow_publication_outbox" ("id" integer PRIMARY KEY NOT NULL, "
| errorMessage | TEXT | | true | | | |
| id | INTEGER | | false | | | |
| publishedVersionId | varchar(36) | | false | | | |
| reason | varchar(20) | 'publish' | false | | | |
| status | varchar(20) | | false | | | |
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| workflowId | varchar(36) | | false | | | |
@ -27,7 +28,8 @@ CREATE TABLE "workflow_publication_outbox" ("id" integer PRIMARY KEY NOT NULL, "
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - | CHECK | CHECK ("status" IN ('pending', 'in_progress', 'completed', 'partial_success', 'failed')) |
| - | CHECK | CHECK (("status" IN ('pending', 'in_progress', 'completed', 'partial_success', 'failed'))) |
| - | CHECK | CHECK ("reason" IN ('publish', 'startup', 'leadership-takeover', 'reconcile')) |
| id | PRIMARY KEY | PRIMARY KEY (id) |
## Indexes
@ -47,6 +49,7 @@ erDiagram
TEXT errorMessage
INTEGER id
varchar_36_ publishedVersionId
varchar_20_ reason
varchar_20_ status
datetime_3_ updatedAt
varchar_36_ workflowId

View File

@ -0,0 +1,130 @@
# workflow_review_activity
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "workflow_review_activity" ("id" integer PRIMARY KEY NOT NULL, "workflowReviewRequestId" varchar(36) NOT NULL, "type" varchar(64) NOT NULL, "typeVersion" integer NOT NULL DEFAULT (1), "data" text, "createdById" varchar, "workflowId" varchar(36), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_workflow_review_activity_type" CHECK ("type" IN ('review.opened', 'comment.created', 'review.changes_requested', 'review.version_updated', 'review.approved', 'workflow.published', 'review.closed')), CONSTRAINT "FK_61048bf6220dd354c955a9d9379" FOREIGN KEY ("workflowReviewRequestId") REFERENCES "workflow_review_request" ("id") ON DELETE CASCADE, CONSTRAINT "FK_fcf78b037a72fc7aa01ab237e08" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE SET NULL, CONSTRAINT "FK_d0162c063ffb699b0f1d1172508" FOREIGN KEY ("workflowId") REFERENCES "workflow_entity" ("id") ON DELETE CASCADE)
```
</details>
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| createdById | varchar | | true | | [user](user.md) | |
| data | TEXT | | true | | | |
| id | INTEGER | | false | [workflow_review_activity_comment](workflow_review_activity_comment.md) | | |
| type | varchar(64) | | false | | | |
| typeVersion | INTEGER | 1 | false | | | |
| workflowId | varchar(36) | | true | | [workflow_entity](workflow_entity.md) | |
| workflowReviewRequestId | varchar(36) | | false | | [workflow_review_request](workflow_review_request.md) | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - | CHECK | CHECK ("type" IN ('review.opened', 'comment.created', 'review.changes_requested', 'review.version_updated', 'review.approved', 'workflow.published', 'review.closed')) |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE |
| - (Foreign key ID: 2) | FOREIGN KEY | FOREIGN KEY (workflowReviewRequestId) REFERENCES workflow_review_request (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| id | PRIMARY KEY | PRIMARY KEY (id) |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_workflow_review_activity_request | CREATE INDEX "IDX_workflow_review_activity_request" ON "workflow_review_activity" ("workflowReviewRequestId", "id") |
## Relations
```mermaid
erDiagram
"workflow_review_activity" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_activity_comment" }o--|| "workflow_review_activity" : "FOREIGN KEY (activityId) REFERENCES workflow_review_activity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_activity" }o--o| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_activity" }o--|| "workflow_review_request" : "FOREIGN KEY (workflowReviewRequestId) REFERENCES workflow_review_request (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_activity" {
datetime_3_ createdAt
varchar createdById FK
TEXT data
INTEGER id
varchar_64_ type
INTEGER typeVersion
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK
}
"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
}
"workflow_review_activity_comment" {
INTEGER activityId FK
TEXT body
datetime_3_ createdAt
varchar createdById FK
datetime_3_ deletedAt
TEXT history
INTEGER id
datetime_3_ updatedAt
}
"workflow_entity" {
boolean active
varchar_36_ activeVersionId FK
TEXT connections
datetime_3_ createdAt
TEXT description
varchar_36_ id PK
boolean isArchived
TEXT meta
varchar_128_ name
TEXT nodeGroups
TEXT nodes
varchar_36_ parentFolderId FK
TEXT pinData
TEXT settings
varchar sourceWorkflowId
TEXT staticData
INTEGER triggerCount
datetime_3_ updatedAt
INTEGER versionCounter
varchar_36_ versionId
}
"workflow_review_request" {
datetime_3_ approvedAt
varchar closedById FK
datetime_3_ createdAt
varchar createdById FK
varchar_50_ decision
TEXT description
varchar_36_ id PK
varchar_36_ projectId FK
varchar_16_ state
varchar_255_ title
datetime_3_ updatedAt
varchar updatedById FK
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)

View File

@ -0,0 +1,90 @@
# workflow_review_activity_comment
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "workflow_review_activity_comment" ("id" integer PRIMARY KEY NOT NULL, "activityId" integer NOT NULL, "createdById" varchar, "body" text, "history" text, "updatedAt" datetime(3), "deletedAt" datetime(3), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "FK_3cd755e8ce44ee49bdf7e9222ec" FOREIGN KEY ("activityId") REFERENCES "workflow_review_activity" ("id") ON DELETE CASCADE, CONSTRAINT "FK_9cfac4b106553d0930a1962bea0" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE SET NULL)
```
</details>
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| activityId | INTEGER | | false | | [workflow_review_activity](workflow_review_activity.md) | |
| body | TEXT | | true | | | |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| createdById | varchar | | true | | [user](user.md) | |
| deletedAt | datetime(3) | | true | | | |
| history | TEXT | | true | | | |
| id | INTEGER | | false | | | |
| updatedAt | datetime(3) | | true | | | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE |
| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (activityId) REFERENCES workflow_review_activity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| id | PRIMARY KEY | PRIMARY KEY (id) |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_workflow_review_activity_comment_activity | CREATE INDEX "IDX_workflow_review_activity_comment_activity" ON "workflow_review_activity_comment" ("activityId", "id") |
## Relations
```mermaid
erDiagram
"workflow_review_activity_comment" }o--|| "workflow_review_activity" : "FOREIGN KEY (activityId) REFERENCES workflow_review_activity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_activity_comment" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_activity_comment" {
INTEGER activityId FK
TEXT body
datetime_3_ createdAt
varchar createdById FK
datetime_3_ deletedAt
TEXT history
INTEGER id
datetime_3_ updatedAt
}
"workflow_review_activity" {
datetime_3_ createdAt
varchar createdById FK
TEXT data
INTEGER id
varchar_64_ type
INTEGER typeVersion
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK
}
"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)

View File

@ -21,7 +21,7 @@ CREATE TABLE "workflow_review_request" ("id" varchar(36) PRIMARY KEY NOT NULL, "
| createdById | varchar | | true | | [user](user.md) | |
| decision | varchar(50) | 'pending' | false | | | |
| description | TEXT | | true | | | |
| id | varchar(36) | | false | [workflow_review_request_authors](workflow_review_request_authors.md) [workflow_review_request_reviewers](workflow_review_request_reviewers.md) [workflow_review_request_workflow](workflow_review_request_workflow.md) | | |
| id | varchar(36) | | false | [workflow_review_activity](workflow_review_activity.md) [workflow_review_request_authors](workflow_review_request_authors.md) [workflow_review_request_reviewers](workflow_review_request_reviewers.md) [workflow_review_request_workflow](workflow_review_request_workflow.md) | | |
| projectId | varchar(36) | | false | | [project](project.md) | |
| state | varchar(16) | 'open' | false | | | |
| title | varchar(255) | | false | | | |
@ -55,6 +55,7 @@ erDiagram
"workflow_review_request" }o--o| "user" : "FOREIGN KEY (closedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_request" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_activity" }o--|| "workflow_review_request" : "FOREIGN KEY (workflowReviewRequestId) REFERENCES workflow_review_request (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_request_authors" |o--|| "workflow_review_request" : "FOREIGN KEY (workflowReviewRequestId) REFERENCES workflow_review_request (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_request_reviewers" |o--|| "workflow_review_request" : "FOREIGN KEY (workflowReviewRequestId) REFERENCES workflow_review_request (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_request_workflow" }o--|| "workflow_review_request" : "FOREIGN KEY (workflowReviewRequestId) REFERENCES workflow_review_request (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@ -92,6 +93,16 @@ erDiagram
TEXT settings
datetime_3_ updatedAt
}
"workflow_review_activity" {
datetime_3_ createdAt
varchar createdById FK
TEXT data
INTEGER id
varchar_64_ type
INTEGER typeVersion
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK
}
"workflow_review_request_authors" {
varchar userId PK
varchar_36_ workflowReviewRequestId PK
@ -101,6 +112,7 @@ erDiagram
varchar_36_ workflowReviewRequestId PK
}
"workflow_review_request_workflow" {
varchar_36_ baselineVersionId FK
varchar_36_ id PK
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK

View File

@ -32,6 +32,7 @@ CREATE TABLE "workflow_review_request_authors" ("workflowReviewRequestId" varcha
| Name | Definition |
| ---- | ---------- |
| IDX_workflow_review_request_authors_user | CREATE INDEX "IDX_workflow_review_request_authors_user" ON "workflow_review_request_authors" ("userId", "workflowReviewRequestId") |
| sqlite_autoindex_workflow_review_request_authors_1 | PRIMARY KEY (workflowReviewRequestId, userId) |
## Relations

View File

@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "workflow_review_request_workflow" ("id" varchar(36) PRIMARY KEY NOT NULL, "workflowReviewRequestId" varchar(36) NOT NULL, "workflowId" varchar(36) NOT NULL, "workflowVersionId" varchar(36), CONSTRAINT "FK_e44b652e6dc99ef1364a2d85504" FOREIGN KEY ("workflowReviewRequestId") REFERENCES "workflow_review_request" ("id") ON DELETE CASCADE, CONSTRAINT "FK_619f5b0544bcec60c3387e82f2f" FOREIGN KEY ("workflowId") REFERENCES "workflow_entity" ("id") ON DELETE CASCADE, CONSTRAINT "FK_0f6f0f2c6d46b806fee02962ac2" FOREIGN KEY ("workflowVersionId") REFERENCES "workflow_history" ("versionId") ON DELETE SET NULL)
CREATE TABLE "workflow_review_request_workflow" ("id" varchar(36) PRIMARY KEY NOT NULL, "workflowReviewRequestId" varchar(36) NOT NULL, "workflowId" varchar(36) NOT NULL, "workflowVersionId" varchar(36), "baselineVersionId" varchar(36), CONSTRAINT "FK_0f6f0f2c6d46b806fee02962ac2" FOREIGN KEY ("workflowVersionId") REFERENCES "workflow_history" ("versionId") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_619f5b0544bcec60c3387e82f2f" FOREIGN KEY ("workflowId") REFERENCES "workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_e44b652e6dc99ef1364a2d85504" FOREIGN KEY ("workflowReviewRequestId") REFERENCES "workflow_review_request" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_workflow_review_request_workflow_baselineVersionId" FOREIGN KEY ("baselineVersionId") REFERENCES "workflow_history" ("versionId") ON DELETE SET NULL)
```
</details>
@ -15,6 +15,7 @@ CREATE TABLE "workflow_review_request_workflow" ("id" varchar(36) PRIMARY KEY NO
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| baselineVersionId | varchar(36) | | true | | [workflow_history](workflow_history.md) | |
| id | varchar(36) | | false | | | |
| workflowId | varchar(36) | | false | | [workflow_entity](workflow_entity.md) | |
| workflowReviewRequestId | varchar(36) | | false | | [workflow_review_request](workflow_review_request.md) | |
@ -24,9 +25,10 @@ CREATE TABLE "workflow_review_request_workflow" ("id" varchar(36) PRIMARY KEY NO
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (workflowVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE |
| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| - (Foreign key ID: 2) | FOREIGN KEY | FOREIGN KEY (workflowReviewRequestId) REFERENCES workflow_review_request (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (baselineVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE |
| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (workflowReviewRequestId) REFERENCES workflow_review_request (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| - (Foreign key ID: 2) | FOREIGN KEY | FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| - (Foreign key ID: 3) | FOREIGN KEY | FOREIGN KEY (workflowVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE |
| id | PRIMARY KEY | PRIMARY KEY (id) |
| sqlite_autoindex_workflow_review_request_workflow_1 | PRIMARY KEY | PRIMARY KEY (id) |
@ -34,8 +36,8 @@ CREATE TABLE "workflow_review_request_workflow" ("id" varchar(36) PRIMARY KEY NO
| Name | Definition |
| ---- | ---------- |
| IDX_workflow_review_request_workflow_workflow_request | CREATE INDEX "IDX_workflow_review_request_workflow_workflow_request"<br /> ON "workflow_review_request_workflow"("workflowId", "workflowReviewRequestId") |
| UQ_workflow_review_request_workflow_request_workflow | CREATE UNIQUE INDEX "UQ_workflow_review_request_workflow_request_workflow"<br /> ON "workflow_review_request_workflow"("workflowReviewRequestId", "workflowId") |
| IDX_workflow_review_request_workflow_workflow_request | CREATE INDEX "IDX_workflow_review_request_workflow_workflow_request" ON "workflow_review_request_workflow" ("workflowId", "workflowReviewRequestId") |
| UQ_workflow_review_request_workflow_request_workflow | CREATE UNIQUE INDEX "UQ_workflow_review_request_workflow_request_workflow" ON "workflow_review_request_workflow" ("workflowReviewRequestId", "workflowId") |
| sqlite_autoindex_workflow_review_request_workflow_1 | PRIMARY KEY (id) |
## Relations
@ -43,16 +45,31 @@ CREATE TABLE "workflow_review_request_workflow" ("id" varchar(36) PRIMARY KEY NO
```mermaid
erDiagram
"workflow_review_request_workflow" }o--o| "workflow_history" : "FOREIGN KEY (baselineVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_request_workflow" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_request_workflow" }o--|| "workflow_review_request" : "FOREIGN KEY (workflowReviewRequestId) REFERENCES workflow_review_request (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflow_review_request_workflow" }o--o| "workflow_history" : "FOREIGN KEY (workflowVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"workflow_review_request_workflow" {
varchar_36_ baselineVersionId FK
varchar_36_ id PK
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK
varchar_36_ workflowVersionId FK
}
"workflow_history" {
varchar_255_ authors
boolean autosaved
TEXT connections
datetime_3_ createdAt
TEXT description
varchar_128_ name
TEXT nodeGroups
TEXT nodes
datetime_3_ updatedAt
varchar_36_ versionId PK
varchar_36_ workflowId FK
}
"workflow_entity" {
boolean active
varchar_36_ activeVersionId FK
@ -89,19 +106,6 @@ erDiagram
datetime_3_ updatedAt
varchar updatedById FK
}
"workflow_history" {
varchar_255_ authors
boolean autosaved
TEXT connections
datetime_3_ createdAt
TEXT description
varchar_128_ name
TEXT nodeGroups
TEXT nodes
datetime_3_ updatedAt
varchar_36_ versionId PK
varchar_36_ workflowId FK
}
```
---

View File

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "2.34.0",
"version": "2.35.0",
"private": true,
"engines": {
"node": ">=22.22",
@ -22,6 +22,7 @@
"build:docker:test": "node scripts/build-n8n.mjs && node scripts/dockerize-n8n.mjs && turbo run test:container:standard --filter=n8n-playwright",
"typecheck": "turbo typecheck",
"dev": "node scripts/dev-command-removed-notice.mjs",
"dev:up": "node scripts/dev-up.mjs",
"dev:be": "turbo run dev --env-mode=loose --log-prefix=none --filter=n8n",
"dev:ai": "turbo run dev --concurrency=150 --env-mode=loose --filter=@n8n/n8n-nodes-langchain --filter=n8n --filter=n8n-core",
"dev:fe": "run-p start dev:fe:editor",
@ -32,6 +33,7 @@
"dev-metrics:status": "node scripts/dev-metrics/setup.mjs --status",
"dev-metrics:reset": "node scripts/dev-metrics/setup.mjs --reset",
"clean": "turbo run clean",
"session": "node scripts/cloud-session.mjs",
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
"format": "turbo run format && node scripts/format.mjs",
"grind": "node scripts/grind.mjs",
@ -57,12 +59,12 @@
"mutate": "node scripts/mutation-health/mutate.mjs",
"test": "turbo run test",
"test:ci": "turbo run test --continue --concurrency=1",
"test:ci:frontend": "turbo run test --continue --filter='./packages/frontend/**'",
"test:ci:frontend:changed": "turbo run test:changed --continue --filter='./packages/frontend/**'",
"test:ci:backend": "turbo run test --continue --concurrency=1 --filter='!./packages/frontend/**'",
"test:ci:backend:unit": "turbo run test:unit --continue --filter='!./packages/frontend/**'",
"test:ci:backend:integration": "turbo run test:integration --continue --concurrency=1 --filter='!./packages/frontend/**'",
"test:ci:backend:integration:changed": "turbo run test:integration:changed --continue --concurrency=1 --filter='!./packages/frontend/**'",
"test:ci:frontend": "turbo run test --continue --filter='./packages/frontend/**' --filter='./packages/modules/**'",
"test:ci:frontend:changed": "turbo run test:changed --continue --filter='./packages/frontend/**' --filter='./packages/modules/**'",
"test:ci:backend": "turbo run test --continue --concurrency=1 --filter='!./packages/frontend/**' --filter='!./packages/modules/**'",
"test:ci:backend:unit": "turbo run test:unit --continue --filter='!./packages/frontend/**' --filter='!./packages/modules/**'",
"test:ci:backend:integration": "turbo run test:integration --continue --concurrency=1 --filter='!./packages/frontend/**' --filter='!./packages/modules/**'",
"test:ci:backend:integration:changed": "turbo run test:integration:changed --continue --concurrency=1 --filter='!./packages/frontend/**' --filter='!./packages/modules/**'",
"test:affected": "turbo run test --affected --concurrency=1",
"db:schema:docs": "turbo run build --filter=@n8n/db && pnpm --filter=@n8n/db schema:docs",
"db:schema:check": "turbo run build --filter=@n8n/db && pnpm --filter=@n8n/db schema:check",
@ -145,7 +147,7 @@
"nodemailer": "8.0.10",
"validator": "13.15.26",
"zod": "3.25.76",
"js-yaml": "4.3.0",
"js-yaml": "4.3.1",
"body-parser": "2.3.0",
"glob@10": "10.5.0",
"glob@7": "7.2.3",
@ -179,6 +181,7 @@
"@xmldom/xmldom": "0.8.13",
"yaml@<=2.8.3": "2.8.3",
"axios": "catalog:",
"fast-xml-builder": "1.2.1",
"fast-xml-parser": "5.7.2",
"postcss@<=8.5.9": "8.5.10",
"@anthropic-ai/sdk@<=0.91.1": "0.91.1",
@ -210,7 +213,8 @@
"node-gyp>undici": "catalog:undici-v7",
"@babel/traverse": "^7.23.2",
"@vitest/browser@<4.1.10": "4.1.10",
"immutable": "5.1.8"
"immutable": "5.1.8",
"nanoid@<3.3.17": "catalog:"
},
"patchedDependencies": {
"axios": "patches/axios.patch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/agents",
"version": "0.19.0",
"version": "0.20.0",
"description": "AI agent SDK for n8n's code-first execution engine",
"engines": {
"node": ">=22.22"
@ -98,6 +98,7 @@
"@ai-sdk/deepseek": "catalog:",
"@ai-sdk/gateway": "catalog:",
"@ai-sdk/google": "catalog:",
"@ai-sdk/google-vertex": "catalog:",
"@ai-sdk/groq": "catalog:",
"@ai-sdk/mistral": "catalog:",
"@ai-sdk/openai": "catalog:",
@ -108,7 +109,7 @@
"@daytona/sdk": "catalog:",
"@modelcontextprotocol/sdk": "catalog:",
"@n8n/ai-utilities": "workspace:*",
"@n8n/sandbox-client": "0.0.4",
"@n8n/sandbox-client": "0.1.0",
"@n8n/utils": "workspace:*",
"@openrouter/ai-sdk-provider": "catalog:",
"@opentelemetry/exporter-trace-otlp-http": ">=0.50.0",

View File

@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "You are a file editor. Use workspace_str_replace_file for single exact replacements. Use workspace_batch_str_replace_file for batch exact replacements. Do not rewrite the whole file.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file editor. Use workspace_str_replace_file for single exact replacements. Use workspace_batch_str_replace_file for batch exact replacements. Do not rewrite the whole file.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -45,6 +45,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -356,30 +398,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_016dfmhE8qzLins851cV3H8y\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01SfNPaEFaLMtWjQ5VwTscF3\",\"name\":\"workspace_str_replace_file\",\"input\":{\"path\":\"/project/config.ts\",\"old_str\":\"OLD_REGION\",\"new_str\":\"EU_CENTRAL\"},\"caller\":{\"type\":\"direct\"}},{\"type\":\"tool_use\",\"id\":\"toolu_015nqvmWf68HbECwWegWz8sc\",\"name\":\"workspace_batch_str_replace_file\",\"input\":{\"path\":\"/project/config.ts\",\"replacements\":[{\"old_str\":\"OLD_OWNER\",\"new_str\":\"API_TEAM\"},{\"old_str\":\"OLD_STATUS\",\"new_str\":\"READY\"}]},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2695,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":235,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011Cdvps9x1PZJTaRXwscm4i\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll perform these exact workspace edits as requested.\"},{\"type\":\"tool_use\",\"id\":\"toolu_01VvQRTAXwEHkyi3p8ED8FKa\",\"name\":\"workspace_str_replace_file\",\"input\":{\"path\":\"/project/config.ts\",\"old_str\":\"OLD_REGION\",\"new_str\":\"EU_CENTRAL\"},\"caller\":{\"type\":\"direct\"}},{\"type\":\"tool_use\",\"id\":\"toolu_01Q7r7gq1UMemkioXZb3K4uu\",\"name\":\"workspace_batch_str_replace_file\",\"input\":{\"path\":\"/project/config.ts\",\"replacements\":[{\"old_str\":\"OLD_OWNER\",\"new_str\":\"API_TEAM\"},{\"old_str\":\"OLD_STATUS\",\"new_str\":\"READY\"}]},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3098,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":246,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:49:02Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:40Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:49:02Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:41Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:49:01Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:40Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:49:02Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:40Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa52091b9cca-VIE",
"cf-ray": "a296a6eeebf025c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:49:02 GMT",
"request-id": "req_011CceuzsFwTUirDKjA1WcAd",
"date": "Tue, 11 Aug 2026 10:45:41 GMT",
"request-id": "req_011Cdvps9agnsjmw9EVXdHsA",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-e40b64c96d7fb552737d103ae83cada7-5a55d8fabfc4d941-01",
"traceresponse": "00-bed2fea9fc1bc58cca3b89eb8b71fed6-ab0980a9e1c845ec-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -395,7 +437,7 @@
"system": [
{
"type": "text",
"text": "You are a file editor. Use workspace_str_replace_file for single exact replacements. Use workspace_batch_str_replace_file for batch exact replacements. Do not rewrite the whole file.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file editor. Use workspace_str_replace_file for single exact replacements. Use workspace_batch_str_replace_file for batch exact replacements. Do not rewrite the whole file.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -411,9 +453,13 @@
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "I'll perform these exact workspace edits as requested."
},
{
"type": "tool_use",
"id": "toolu_01SfNPaEFaLMtWjQ5VwTscF3",
"id": "toolu_01VvQRTAXwEHkyi3p8ED8FKa",
"name": "workspace_str_replace_file",
"input": {
"path": "/project/config.ts",
@ -426,7 +472,7 @@
},
{
"type": "tool_use",
"id": "toolu_015nqvmWf68HbECwWegWz8sc",
"id": "toolu_01Q7r7gq1UMemkioXZb3K4uu",
"name": "workspace_batch_str_replace_file",
"input": {
"path": "/project/config.ts",
@ -452,12 +498,12 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01SfNPaEFaLMtWjQ5VwTscF3",
"tool_use_id": "toolu_01VvQRTAXwEHkyi3p8ED8FKa",
"content": "{\"success\":true,\"result\":\"Edit applied successfully.\"}"
},
{
"type": "tool_result",
"tool_use_id": "toolu_015nqvmWf68HbECwWegWz8sc",
"tool_use_id": "toolu_01Q7r7gq1UMemkioXZb3K4uu",
"content": "{\"success\":true,\"result\":\"All 2 replacements applied successfully.\"}"
}
]
@ -485,6 +531,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -796,30 +884,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01T4otvEXmVvxBiiMoLzNgeq\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"done.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3010,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":5,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpsH39vxMjMiW9skyNg\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"done.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3424,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":5,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:49:03Z",
"anthropic-ratelimit-input-tokens-remaining": "9998000",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:42Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:49:03Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:42Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:49:02Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:42Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:49:03Z",
"anthropic-ratelimit-tokens-remaining": "11998000",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:42Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa5ccad49cca-VIE",
"cf-ray": "a296a6f92c2525c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:49:03 GMT",
"request-id": "req_011CceuzzcTFdAF5b7BKHiNL",
"date": "Tue, 11 Aug 2026 10:45:42 GMT",
"request-id": "req_011CdvpsGcMfay6mqpRFwN7K",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-55b4e50bd84506d145e265639d6be8a7-6aca1a5a81b91f13-01",
"traceresponse": "00-27597d9f81748b032913a1498e1fe1ee-10723a592821a25d-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -45,6 +45,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -356,30 +398,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01EKdY3sCJpJ34NFfzbFxszT\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01MEW257FPx9fz1MW3C7GPKU\",\"name\":\"workspace_append_file\",\"input\":{\"path\":\"/project/source.txt\",\"content\":\"-beta\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2717,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":79,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvprZfFZSt3spHTvny9k\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_014ZKsAn1jZg6LSG2MdNRwh4\",\"name\":\"workspace_append_file\",\"input\":{\"path\":\"/project/source.txt\",\"content\":\"-beta\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3120,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":79,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:54Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:33Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:54Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:33Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:54Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:32Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:54Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:33Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa262f169cca-VIE",
"cf-ray": "a296a6bc8ec625c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:55 GMT",
"request-id": "req_011CceuzMFQJ91oP93iiVNsZ",
"date": "Tue, 11 Aug 2026 10:45:33 GMT",
"request-id": "req_011CdvprZBEULGuPqvU8KbgA",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-6ddd3c82dc3a4340fb8cd8b54da73a9d-509c284e82389845-01",
"traceresponse": "00-136d85c3f51da67a3786c288cafc89ed-d0f8d85336bd22bd-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -395,7 +437,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -413,7 +455,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"name": "workspace_append_file",
"input": {
"path": "/project/source.txt",
@ -430,7 +472,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"tool_use_id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"content": "{\"success\":true}"
}
]
@ -458,6 +500,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -769,30 +853,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_013W8BnSB4sccwn7TkTJFX4v\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Then call 2:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01AAGAAT3ZyuToHCpTvw1UY4\",\"name\":\"workspace_copy_file\",\"input\":{\"src\":\"/project/source.txt\",\"dest\":\"/project/copied.txt\",\"overwrite\":true},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2812,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":106,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvprgAfpsbZ2N5TiZ88g\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now calling step 2:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01H1FQ8SRbigEQtxc8fwQpBm\",\"name\":\"workspace_copy_file\",\"input\":{\"src\":\"/project/source.txt\",\"dest\":\"/project/copied.txt\",\"overwrite\":true},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3215,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":107,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:55Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:34Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:56Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:34Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:55Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:33Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:55Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:34Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa2ccfb29cca-VIE",
"cf-ray": "a296a6c3d88525c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:56 GMT",
"request-id": "req_011CceuzRqUqf6kW8XcixnnJ",
"date": "Tue, 11 Aug 2026 10:45:35 GMT",
"request-id": "req_011CdvprevFw1U1jm1thzx9r",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-4608436a24e376fdf5d5503f1da1299d-2742fcc997418ac3-01",
"traceresponse": "00-4b7fab311d2b4039d16df4b27528c21d-800ed256314c13f8-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -808,7 +892,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -826,7 +910,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"name": "workspace_append_file",
"input": {
"path": "/project/source.txt",
@ -843,7 +927,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"tool_use_id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"content": "{\"success\":true}"
}
]
@ -853,11 +937,11 @@
"content": [
{
"type": "text",
"text": "Then call 2:"
"text": "Now calling step 2:"
},
{
"type": "tool_use",
"id": "toolu_01AAGAAT3ZyuToHCpTvw1UY4",
"id": "toolu_01H1FQ8SRbigEQtxc8fwQpBm",
"name": "workspace_copy_file",
"input": {
"src": "/project/source.txt",
@ -875,7 +959,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01AAGAAT3ZyuToHCpTvw1UY4",
"tool_use_id": "toolu_01H1FQ8SRbigEQtxc8fwQpBm",
"content": "{\"success\":true}"
}
]
@ -903,6 +987,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -1214,30 +1340,31 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_019YqkNADyLzcpp7qVRppJaJ\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Then call 3:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01L6PpMLB7XoKWWzDvtgpDZw\",\"name\":\"workspace_move_file\",\"input\":{\"src\":\"/project/copied.txt\",\"dest\":\"/project/moved.txt\",\"overwrite\":true},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2934,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":106,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvprmgK3DiBfXzNuQYMn\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now calling step 3:\"},{\"type\":\"tool_use\",\"id\":\"toolu_019tbVwDcpgXv5Zbti1PwRuv\",\"name\":\"workspace_move_file\",\"input\":{\"src\":\"/project/copied.txt\",\"dest\":\"/project/moved.txt\",\"overwrite\":true},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3338,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":107,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:56Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:35Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:57Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:36Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:56Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:35Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:56Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:35Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa34ac929cca-VIE",
"cf-ray": "a296a6ce9caf25c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:57 GMT",
"request-id": "req_011CceuzXTZjDSZG3o9ygFy5",
"date": "Tue, 11 Aug 2026 10:45:36 GMT",
"request-id": "req_011CdvprmMDAJzCZsQ5M8Kyb",
"server": "cloudflare",
"server-timing": "x-originResponse;dur=1439",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-e029a29f91fdc77fc51f6da7dbf0e249-a0ae69375f60dda5-01",
"traceresponse": "00-197eacff06bc1829a0d46b9e69ea2e14-e3bd7495e1169f5e-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -1253,7 +1380,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -1271,7 +1398,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"name": "workspace_append_file",
"input": {
"path": "/project/source.txt",
@ -1288,7 +1415,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"tool_use_id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"content": "{\"success\":true}"
}
]
@ -1298,11 +1425,11 @@
"content": [
{
"type": "text",
"text": "Then call 2:"
"text": "Now calling step 2:"
},
{
"type": "tool_use",
"id": "toolu_01AAGAAT3ZyuToHCpTvw1UY4",
"id": "toolu_01H1FQ8SRbigEQtxc8fwQpBm",
"name": "workspace_copy_file",
"input": {
"src": "/project/source.txt",
@ -1320,7 +1447,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01AAGAAT3ZyuToHCpTvw1UY4",
"tool_use_id": "toolu_01H1FQ8SRbigEQtxc8fwQpBm",
"content": "{\"success\":true}"
}
]
@ -1330,11 +1457,11 @@
"content": [
{
"type": "text",
"text": "Then call 3:"
"text": "Now calling step 3:"
},
{
"type": "tool_use",
"id": "toolu_01L6PpMLB7XoKWWzDvtgpDZw",
"id": "toolu_019tbVwDcpgXv5Zbti1PwRuv",
"name": "workspace_move_file",
"input": {
"src": "/project/copied.txt",
@ -1352,7 +1479,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01L6PpMLB7XoKWWzDvtgpDZw",
"tool_use_id": "toolu_019tbVwDcpgXv5Zbti1PwRuv",
"content": "{\"success\":true}"
}
]
@ -1380,6 +1507,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -1691,30 +1860,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01QPZuSmpo52mP7eSuwVBYs2\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Then call 4:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01CSgw7v1W3Pk5TGwDgo227J\",\"name\":\"workspace_delete_file\",\"input\":{\"path\":\"/project/source.txt\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3056,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":66,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011Cdvpru82f34NT1senXnve\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now calling step 4:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01F118HcAu54KDWaxwU7ey1g\",\"name\":\"workspace_delete_file\",\"input\":{\"path\":\"/project/source.txt\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3461,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":67,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:58Z",
"anthropic-ratelimit-input-tokens-remaining": "9998000",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:37Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:58Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:37Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:57Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:36Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:58Z",
"anthropic-ratelimit-tokens-remaining": "11998000",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:37Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa3c98859cca-VIE",
"cf-ray": "a296a6d8fe9c25c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:58 GMT",
"request-id": "req_011Cceuzcja5wK24kE1VzCa9",
"date": "Tue, 11 Aug 2026 10:45:37 GMT",
"request-id": "req_011CdvprtZ3s4XurCaY3tGmG",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-67429a2bbcf8573821fecee3ac204783-4f1598ea01cf69b5-01",
"traceresponse": "00-f2544e4e958d13bc28089d62fa90b971-25fd6f2b5753ac5d-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -1730,7 +1899,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -1748,7 +1917,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"name": "workspace_append_file",
"input": {
"path": "/project/source.txt",
@ -1765,7 +1934,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"tool_use_id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"content": "{\"success\":true}"
}
]
@ -1775,11 +1944,11 @@
"content": [
{
"type": "text",
"text": "Then call 2:"
"text": "Now calling step 2:"
},
{
"type": "tool_use",
"id": "toolu_01AAGAAT3ZyuToHCpTvw1UY4",
"id": "toolu_01H1FQ8SRbigEQtxc8fwQpBm",
"name": "workspace_copy_file",
"input": {
"src": "/project/source.txt",
@ -1797,7 +1966,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01AAGAAT3ZyuToHCpTvw1UY4",
"tool_use_id": "toolu_01H1FQ8SRbigEQtxc8fwQpBm",
"content": "{\"success\":true}"
}
]
@ -1807,11 +1976,11 @@
"content": [
{
"type": "text",
"text": "Then call 3:"
"text": "Now calling step 3:"
},
{
"type": "tool_use",
"id": "toolu_01L6PpMLB7XoKWWzDvtgpDZw",
"id": "toolu_019tbVwDcpgXv5Zbti1PwRuv",
"name": "workspace_move_file",
"input": {
"src": "/project/copied.txt",
@ -1829,7 +1998,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01L6PpMLB7XoKWWzDvtgpDZw",
"tool_use_id": "toolu_019tbVwDcpgXv5Zbti1PwRuv",
"content": "{\"success\":true}"
}
]
@ -1839,11 +2008,11 @@
"content": [
{
"type": "text",
"text": "Then call 4:"
"text": "Now calling step 4:"
},
{
"type": "tool_use",
"id": "toolu_01CSgw7v1W3Pk5TGwDgo227J",
"id": "toolu_01F118HcAu54KDWaxwU7ey1g",
"name": "workspace_delete_file",
"input": {
"path": "/project/source.txt"
@ -1859,7 +2028,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01CSgw7v1W3Pk5TGwDgo227J",
"tool_use_id": "toolu_01F118HcAu54KDWaxwU7ey1g",
"content": "{\"success\":true}"
}
]
@ -1887,6 +2056,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -2198,30 +2409,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01BKvd9PLDS1vtttJ6YWL5Br\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Then call 5:\"},{\"type\":\"tool_use\",\"id\":\"toolu_019j3t7qZMGdn3MysWxpmGAT\",\"name\":\"workspace_rmdir\",\"input\":{\"path\":\"/project/remove-me\",\"recursive\":true,\"force\":false},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3138,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":99,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011Cdvpryic3Grraepf53xbo\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now calling step 5:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01XEifn4vNpCAjuMTJi6Cqy7\",\"name\":\"workspace_rmdir\",\"input\":{\"path\":\"/project/remove-me\",\"recursive\":true,\"force\":false},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3544,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":100,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:59Z",
"anthropic-ratelimit-input-tokens-remaining": "9998000",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:38Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:59Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:38Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:59Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:38Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:59Z",
"anthropic-ratelimit-tokens-remaining": "11998000",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:38Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa43d8699cca-VIE",
"cf-ray": "a296a6dee9c425c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:59 GMT",
"request-id": "req_011CceuzhY3ehpVEwNd5qCJt",
"date": "Tue, 11 Aug 2026 10:45:38 GMT",
"request-id": "req_011CdvprxdNRpWtgeiNgkt1p",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-591183da0fbe2f5ae52974f8a4befdde-773aca87367d033e-01",
"traceresponse": "00-232cb0cb9bc94bb5aafa3f715d9ad7b7-ad93f6789eb6e1b4-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -2237,7 +2448,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -2255,7 +2466,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"name": "workspace_append_file",
"input": {
"path": "/project/source.txt",
@ -2272,7 +2483,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"tool_use_id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"content": "{\"success\":true}"
}
]
@ -2282,11 +2493,11 @@
"content": [
{
"type": "text",
"text": "Then call 2:"
"text": "Now calling step 2:"
},
{
"type": "tool_use",
"id": "toolu_01AAGAAT3ZyuToHCpTvw1UY4",
"id": "toolu_01H1FQ8SRbigEQtxc8fwQpBm",
"name": "workspace_copy_file",
"input": {
"src": "/project/source.txt",
@ -2304,7 +2515,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01AAGAAT3ZyuToHCpTvw1UY4",
"tool_use_id": "toolu_01H1FQ8SRbigEQtxc8fwQpBm",
"content": "{\"success\":true}"
}
]
@ -2314,11 +2525,11 @@
"content": [
{
"type": "text",
"text": "Then call 3:"
"text": "Now calling step 3:"
},
{
"type": "tool_use",
"id": "toolu_01L6PpMLB7XoKWWzDvtgpDZw",
"id": "toolu_019tbVwDcpgXv5Zbti1PwRuv",
"name": "workspace_move_file",
"input": {
"src": "/project/copied.txt",
@ -2336,7 +2547,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01L6PpMLB7XoKWWzDvtgpDZw",
"tool_use_id": "toolu_019tbVwDcpgXv5Zbti1PwRuv",
"content": "{\"success\":true}"
}
]
@ -2346,11 +2557,11 @@
"content": [
{
"type": "text",
"text": "Then call 4:"
"text": "Now calling step 4:"
},
{
"type": "tool_use",
"id": "toolu_01CSgw7v1W3Pk5TGwDgo227J",
"id": "toolu_01F118HcAu54KDWaxwU7ey1g",
"name": "workspace_delete_file",
"input": {
"path": "/project/source.txt"
@ -2366,7 +2577,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01CSgw7v1W3Pk5TGwDgo227J",
"tool_use_id": "toolu_01F118HcAu54KDWaxwU7ey1g",
"content": "{\"success\":true}"
}
]
@ -2376,11 +2587,11 @@
"content": [
{
"type": "text",
"text": "Then call 5:"
"text": "Now calling step 5:"
},
{
"type": "tool_use",
"id": "toolu_019j3t7qZMGdn3MysWxpmGAT",
"id": "toolu_01XEifn4vNpCAjuMTJi6Cqy7",
"name": "workspace_rmdir",
"input": {
"path": "/project/remove-me",
@ -2398,7 +2609,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_019j3t7qZMGdn3MysWxpmGAT",
"tool_use_id": "toolu_01XEifn4vNpCAjuMTJi6Cqy7",
"content": "{\"success\":true}"
}
]
@ -2426,6 +2637,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -2737,30 +2990,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01Y4brQawzXvWux5QWcgu9JU\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"done.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3253,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":5,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011Cdvps5AH9YtHeFbtoE98E\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Done.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3660,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":5,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:49:00Z",
"anthropic-ratelimit-input-tokens-remaining": "9998000",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:39Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:49:01Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:39Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:49:00Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:39Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:49:00Z",
"anthropic-ratelimit-tokens-remaining": "11998000",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:39Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa4a0f449cca-VIE",
"cf-ray": "a296a6e7b90a25c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:49:01 GMT",
"request-id": "req_011Cceuzmnn1Zw4Vj9JYxguv",
"date": "Tue, 11 Aug 2026 10:45:39 GMT",
"request-id": "req_011Cdvps4hVmVjpscyMHhGfD",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-2256b07748acafdb04818e29c981f466-220e5914e62a5fef-01",
"traceresponse": "00-b6189b5d9d07cb78db54eae7f3370d90-d356c8a0edfea2fb-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Follow the user's instructions step by step using workspace tools. Available: workspace_mkdir, workspace_write_file, workspace_list_files, workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Follow the user's instructions step by step using workspace tools. Available: workspace_mkdir, workspace_write_file, workspace_list_files, workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -45,6 +45,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -356,30 +398,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01V3z9YdWdjUannYcTqKh9fG\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll help you with these tasks. Let me execute them step by step.\"},{\"type\":\"tool_use\",\"id\":\"toolu_01Bu1fxpsZxRB4HPSAEyyMh4\",\"name\":\"workspace_mkdir\",\"input\":{\"path\":\"/app\"},\"caller\":{\"type\":\"direct\"}},{\"type\":\"tool_use\",\"id\":\"toolu_016muGHEPHrgqWnNcrQRnJ7z\",\"name\":\"workspace_write_file\",\"input\":{\"path\":\"/app/config.ts\",\"content\":\"export default {}\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2628,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":133,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpsLbiNCvjQ5t8uuoT8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll help you with these tasks. Let me execute them step by step.\"},{\"type\":\"tool_use\",\"id\":\"toolu_01PssxbtZdNs3YBHtifmF5hN\",\"name\":\"workspace_mkdir\",\"input\":{\"path\":\"/app\"},\"caller\":{\"type\":\"direct\"}},{\"type\":\"tool_use\",\"id\":\"toolu_01WPf5Fdv3L8D738QFTgZgnb\",\"name\":\"workspace_write_file\",\"input\":{\"path\":\"/app/config.ts\",\"content\":\"export default {}\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3031,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":133,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:49:04Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:43Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:49:04Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:43Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:49:03Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:42Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:49:04Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:43Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa62887d9cca-VIE",
"cf-ray": "a296a6fe6e7725c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:49:05 GMT",
"request-id": "req_011Ccev14YMCHAwNXGdLawou",
"date": "Tue, 11 Aug 2026 10:45:43 GMT",
"request-id": "req_011CdvpsLAAyvpGkp3LhU5JW",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-5fbd3b07fe1d8a3e7bf502ec9093abcb-9deb31c7ba75cb08-01",
"traceresponse": "00-ea9a7c39ba842d38b3030987afc44193-44b95e84dd813e87-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -395,7 +437,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Follow the user's instructions step by step using workspace tools. Available: workspace_mkdir, workspace_write_file, workspace_list_files, workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Follow the user's instructions step by step using workspace tools. Available: workspace_mkdir, workspace_write_file, workspace_list_files, workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -417,7 +459,7 @@
},
{
"type": "tool_use",
"id": "toolu_01Bu1fxpsZxRB4HPSAEyyMh4",
"id": "toolu_01PssxbtZdNs3YBHtifmF5hN",
"name": "workspace_mkdir",
"input": {
"path": "/app"
@ -428,7 +470,7 @@
},
{
"type": "tool_use",
"id": "toolu_016muGHEPHrgqWnNcrQRnJ7z",
"id": "toolu_01WPf5Fdv3L8D738QFTgZgnb",
"name": "workspace_write_file",
"input": {
"path": "/app/config.ts",
@ -445,12 +487,12 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01Bu1fxpsZxRB4HPSAEyyMh4",
"tool_use_id": "toolu_01PssxbtZdNs3YBHtifmF5hN",
"content": "{\"success\":true}"
},
{
"type": "tool_result",
"tool_use_id": "toolu_016muGHEPHrgqWnNcrQRnJ7z",
"tool_use_id": "toolu_01WPf5Fdv3L8D738QFTgZgnb",
"content": "{\"success\":true}"
}
]
@ -478,6 +520,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -789,30 +873,31 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01XgerQjXzKnpycGPo4TSxR6\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now let me list the files and read the config file:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01VaD1z3Nj56KZkfnoCQYEFr\",\"name\":\"workspace_list_files\",\"input\":{\"path\":\"/app\"},\"caller\":{\"type\":\"direct\"}},{\"type\":\"tool_use\",\"id\":\"toolu_017ddge4okwcWsUKeHmUWh5J\",\"name\":\"workspace_read_file\",\"input\":{\"path\":\"/app/config.ts\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2834,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":112,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpsRTuotAB1J7wmHouH\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now let me list the files and read the contents:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01PDqhojRM2sUwN2BA12fD6k\",\"name\":\"workspace_list_files\",\"input\":{\"path\":\"/app\"},\"caller\":{\"type\":\"direct\"}},{\"type\":\"tool_use\",\"id\":\"toolu_01VsSW68Un1y5Z4WSKgC98TW\",\"name\":\"workspace_read_file\",\"input\":{\"path\":\"/app/config.ts\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3237,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":111,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:49:05Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:44Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:49:06Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:44Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:49:05Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:44Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:49:05Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:44Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa6adc519cca-VIE",
"cf-ray": "a296a70608e025c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:49:06 GMT",
"request-id": "req_011Ccev1AEtxSx7jNJn5dpat",
"date": "Tue, 11 Aug 2026 10:45:45 GMT",
"request-id": "req_011CdvpsRAoY9wE2wJUA47KM",
"server": "cloudflare",
"server-timing": "x-originResponse;dur=890",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-94b954f1851f06119a96cf4643ea2e3e-6bd3a9e17e35680a-01",
"traceresponse": "00-a65659e5bffea15b715f4da3117c061a-40ee649bcd94476f-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -828,7 +913,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Follow the user's instructions step by step using workspace tools. Available: workspace_mkdir, workspace_write_file, workspace_list_files, workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Follow the user's instructions step by step using workspace tools. Available: workspace_mkdir, workspace_write_file, workspace_list_files, workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -850,7 +935,7 @@
},
{
"type": "tool_use",
"id": "toolu_01Bu1fxpsZxRB4HPSAEyyMh4",
"id": "toolu_01PssxbtZdNs3YBHtifmF5hN",
"name": "workspace_mkdir",
"input": {
"path": "/app"
@ -861,7 +946,7 @@
},
{
"type": "tool_use",
"id": "toolu_016muGHEPHrgqWnNcrQRnJ7z",
"id": "toolu_01WPf5Fdv3L8D738QFTgZgnb",
"name": "workspace_write_file",
"input": {
"path": "/app/config.ts",
@ -878,12 +963,12 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01Bu1fxpsZxRB4HPSAEyyMh4",
"tool_use_id": "toolu_01PssxbtZdNs3YBHtifmF5hN",
"content": "{\"success\":true}"
},
{
"type": "tool_result",
"tool_use_id": "toolu_016muGHEPHrgqWnNcrQRnJ7z",
"tool_use_id": "toolu_01WPf5Fdv3L8D738QFTgZgnb",
"content": "{\"success\":true}"
}
]
@ -893,11 +978,11 @@
"content": [
{
"type": "text",
"text": "Now let me list the files and read the config file:"
"text": "Now let me list the files and read the contents:"
},
{
"type": "tool_use",
"id": "toolu_01VaD1z3Nj56KZkfnoCQYEFr",
"id": "toolu_01PDqhojRM2sUwN2BA12fD6k",
"name": "workspace_list_files",
"input": {
"path": "/app"
@ -908,7 +993,7 @@
},
{
"type": "tool_use",
"id": "toolu_017ddge4okwcWsUKeHmUWh5J",
"id": "toolu_01VsSW68Un1y5Z4WSKgC98TW",
"name": "workspace_read_file",
"input": {
"path": "/app/config.ts"
@ -924,12 +1009,12 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01VaD1z3Nj56KZkfnoCQYEFr",
"tool_use_id": "toolu_01PDqhojRM2sUwN2BA12fD6k",
"content": "{\"entries\":[{\"name\":\"config.ts\",\"type\":\"file\"}]}"
},
{
"type": "tool_result",
"tool_use_id": "toolu_017ddge4okwcWsUKeHmUWh5J",
"tool_use_id": "toolu_01VsSW68Un1y5Z4WSKgC98TW",
"content": "{\"content\":\"export default {}\"}"
}
]
@ -957,6 +1042,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -1268,30 +1395,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01Ai4Pb2PC3dfF9kwaUnrPid\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Perfect! Here's a summary:\\n\\n1. ✅ Created directory `/app`\\n2. ✅ Wrote to `/app/config.ts`\\n3. ✅ Files in `/app`: `config.ts` (file)\\n4. ✅ Contents of `/app/config.ts`: `export default {}`\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3036,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":80,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpsWL7BkYFfE8w2cjBn\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Perfect! Here's a summary of what was completed:\\n\\n1. ✅ Created directory `/app`\\n2. ✅ Wrote \\\"export default {}\\\" to `/app/config.ts`\\n3. ✅ Files in `/app`: `config.ts`\\n4. ✅ Contents of `/app/config.ts`: `export default {}`\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3438,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":86,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:49:07Z",
"anthropic-ratelimit-input-tokens-remaining": "9998000",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:45Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:49:08Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:46Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:49:06Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:45Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:49:07Z",
"anthropic-ratelimit-tokens-remaining": "11998000",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:45Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa712cb19cca-VIE",
"cf-ray": "a296a70c7edc25c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:49:08 GMT",
"request-id": "req_011Ccev1EYrTiraV6AfkNn9h",
"date": "Tue, 11 Aug 2026 10:45:46 GMT",
"request-id": "req_011CdvpsVnNHyhKrGgWZbCtt",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-9e5e0057531b057eed928bdb4ce3bbcb-b966db309639d9a1-01",
"traceresponse": "00-629d998844f7d0fbb6f65d4d907a0f5a-50e23e19f926749d-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "You are a shell assistant. When asked to run a command, use workspace_execute_command. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a shell assistant. When asked to run a command, use workspace_execute_command. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -45,6 +45,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -356,30 +398,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01NrCrHWQDPXXHHswb8LF95M\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_0183ghVMwEjV19cYYorGp1ys\",\"name\":\"workspace_execute_command\",\"input\":{\"command\":\"echo \\\"n8n workspace test\\\"\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2571,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":63,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpqvMfnU5aJ3yCRjaLJ\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01KesV1Uan4Xp39aa2E3TDYv\",\"name\":\"workspace_execute_command\",\"input\":{\"command\":\"echo \\\"n8n workspace test\\\"\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2974,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":63,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:45Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:24Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:45Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:24Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:44Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:23Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:45Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:24Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f9e7ddef9cca-VIE",
"cf-ray": "a296a6863d9025c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:45 GMT",
"request-id": "req_011Cceuyd2u9H7XtcJH2sCyc",
"date": "Tue, 11 Aug 2026 10:45:24 GMT",
"request-id": "req_011CdvpquwcXhiQKiWLvMrhj",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-5d0b495348935d7e83760dd23dd74ba4-e5f89c7f27d4b843-01",
"traceresponse": "00-3204edbed9c00a397be309fbe2303d89-fa32de201d974130-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -395,7 +437,7 @@
"system": [
{
"type": "text",
"text": "You are a shell assistant. When asked to run a command, use workspace_execute_command. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a shell assistant. When asked to run a command, use workspace_execute_command. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -413,7 +455,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_0183ghVMwEjV19cYYorGp1ys",
"id": "toolu_01KesV1Uan4Xp39aa2E3TDYv",
"name": "workspace_execute_command",
"input": {
"command": "echo \"n8n workspace test\""
@ -429,7 +471,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_0183ghVMwEjV19cYYorGp1ys",
"tool_use_id": "toolu_01KesV1Uan4Xp39aa2E3TDYv",
"content": "{\"success\":true,\"exitCode\":0,\"stdout\":\"n8n workspace test\\n\",\"stderr\":\"\",\"executionTimeMs\":1}"
}
]
@ -457,6 +499,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -768,30 +852,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01SxmnPVnQL86ZDoRmHYhwAk\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"The command executed successfully! Output:\\n```\\nn8n workspace test\\n```\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2675,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":20,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpqzYSS7MhPobDpcqnV\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"The command executed successfully! Output:\\n```\\nn8n workspace test\\n```\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3078,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":20,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:45Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:25Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:45Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:25Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:45Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:24Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:45Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:25Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f9edec9b9cca-VIE",
"cf-ray": "a296a68bb85a25c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:45 GMT",
"request-id": "req_011Cceuygp7MnpR9rmqJXNgv",
"date": "Tue, 11 Aug 2026 10:45:25 GMT",
"request-id": "req_011CdvpqywUKAn9aFUMLeMHP",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-f2caa093f45be43784dbbfd396bca714-f378d4930a294336-01",
"traceresponse": "00-1cd6e5a346e3490ec9abb5a203aba136-3432a2f3b1ab137e-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Use workspace_file_stat to get file info. Report the file size and type. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Use workspace_file_stat to get file info. Report the file size and type. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -45,6 +45,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -356,30 +398,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01L688PfiC1tEQq6tJ7o1nrG\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01LZe3MA7uNmpYCAR2eYNBJZ\",\"name\":\"workspace_file_stat\",\"input\":{\"path\":\"/data.json\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2575,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":59,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvprQiEyfNFpgrX1ry9w\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_017yP2GghaPPLJqn9fioyzt7\",\"name\":\"workspace_file_stat\",\"input\":{\"path\":\"/data.json\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2978,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":59,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:52Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:31Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:52Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:31Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:52Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:30Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:52Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:31Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa184a909cca-VIE",
"cf-ray": "a296a6af6cf325c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:52 GMT",
"request-id": "req_011CceuzBuLAUxWFojAeUTZo",
"date": "Tue, 11 Aug 2026 10:45:31 GMT",
"request-id": "req_011CdvprQDyPsxpSSNXXnKkM",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-5a3de610c4eb046e905b0d7972552f19-61b28ef29abf29e1-01",
"traceresponse": "00-8948e7d79706a0c7d896fc42a60fbe74-254d451470eaf110-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -395,7 +437,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Use workspace_file_stat to get file info. Report the file size and type. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Use workspace_file_stat to get file info. Report the file size and type. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -413,7 +455,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01LZe3MA7uNmpYCAR2eYNBJZ",
"id": "toolu_017yP2GghaPPLJqn9fioyzt7",
"name": "workspace_file_stat",
"input": {
"path": "/data.json"
@ -429,7 +471,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01LZe3MA7uNmpYCAR2eYNBJZ",
"tool_use_id": "toolu_017yP2GghaPPLJqn9fioyzt7",
"content": "{\"name\":\"data.json\",\"path\":\"/data.json\",\"type\":\"file\",\"size\":29,\"createdAt\":\"2026-01-01T00:00:00.000Z\",\"modifiedAt\":\"2026-01-01T00:00:00.000Z\"}"
}
]
@ -457,6 +499,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -768,30 +852,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01KV6ecsLbrFJK3Zpa5jjrH5\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"The file `/data.json` is a **file** with a size of **29 bytes**.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2708,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":24,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvprVicbbrnqRreCzJNb\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"The file `/data.json` is a **JSON file** with a size of **29 bytes**.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3111,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":25,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:53Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:31Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:53Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:32Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:53Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:31Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:53Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:31Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa1ffe169cca-VIE",
"cf-ray": "a296a6b63a2925c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:54 GMT",
"request-id": "req_011CceuzH39f3khocSTMQyZ5",
"date": "Tue, 11 Aug 2026 10:45:32 GMT",
"request-id": "req_011CdvprVFqTXKM1R9qYWTmt",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-33d5d91d179b8dd6dd6da787cb31e0e5-09b5c7a7810bd736-01",
"traceresponse": "00-da957029974f7fe2c88f56fdccabd5b7-2fc012bbeb67589a-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. For directory listing requests, call workspace_list_files with the exact path the user provides. Be concise and list the filenames you find.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. For directory listing requests, call workspace_list_files with the exact path the user provides. Be concise and list the filenames you find.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -45,6 +45,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -356,30 +398,31 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01EFqZ1oSL6ZccHPPmzvqUAv\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01MTqZWjgtM6PbqCmcQByBhq\",\"name\":\"workspace_list_files\",\"input\":{\"path\":\"/project\",\"recursive\":false},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2598,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":74,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011Cdvpr5xNfJCdri8TnwgaW\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01EqvFgp1qoNJaJ8i6LS9r8Q\",\"name\":\"workspace_list_files\",\"input\":{\"path\":\"/project\",\"recursive\":false},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3001,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":74,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:46Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:26Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:46Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:26Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:46Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:25Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:46Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:26Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f9f25dec9cca-VIE",
"cf-ray": "a296a6946bee25c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:46 GMT",
"request-id": "req_011Cceuyjx817Sxxa64ZCyQN",
"date": "Tue, 11 Aug 2026 10:45:26 GMT",
"request-id": "req_011Cdvpr5SNDL6qin8xEbvZz",
"server": "cloudflare",
"server-timing": "x-originResponse;dur=785",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-1b899442916f7b18b25284aea082803d-128a81c83d6b7193-01",
"traceresponse": "00-2cab80b88020ad5d644db648376f4b01-1cb7da94faf3f85e-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -395,7 +438,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. For directory listing requests, call workspace_list_files with the exact path the user provides. Be concise and list the filenames you find.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. For directory listing requests, call workspace_list_files with the exact path the user provides. Be concise and list the filenames you find.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -413,7 +456,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01MTqZWjgtM6PbqCmcQByBhq",
"id": "toolu_01EqvFgp1qoNJaJ8i6LS9r8Q",
"name": "workspace_list_files",
"input": {
"path": "/project",
@ -430,7 +473,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01MTqZWjgtM6PbqCmcQByBhq",
"tool_use_id": "toolu_01EqvFgp1qoNJaJ8i6LS9r8Q",
"content": "{\"entries\":[{\"name\":\"index.ts\",\"type\":\"file\"},{\"name\":\"README.md\",\"type\":\"file\"}]}"
}
]
@ -458,6 +501,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -769,30 +854,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01AVUiagNUFo5J4iX6gUAJ49\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"index.ts\\nREADME.md\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2711,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":10,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvprATF9r7MvJWQVHLkH\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"index.ts\\nREADME.md\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3114,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":10,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:47Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:27Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:47Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:27Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:47Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:27Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:47Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:27Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f9f98f269cca-VIE",
"cf-ray": "a296a69ada9525c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:47 GMT",
"request-id": "req_011CceuypgszzZEYxjkCPto8",
"date": "Tue, 11 Aug 2026 10:45:27 GMT",
"request-id": "req_011CdvprA2CM8CZGBaTei2Dj",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-ba0fb474f7ca4ddc086107751464c890-694ed1936623aa7d-01",
"traceresponse": "00-2c1a778bceedf9c4b2bbaed5eb212fba-8fb9396ab5538a3d-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. When asked to create a file, use workspace_write_file. When asked to read a file, use workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. When asked to create a file, use workspace_write_file. When asked to read a file, use workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -45,6 +45,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -356,30 +398,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01Du5tVa5DhioiiEvWGUBcU7\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll write the file first, then read it back.\"},{\"type\":\"tool_use\",\"id\":\"toolu_01TrVd5Gx19YUnAnjbfoN3Kp\",\"name\":\"workspace_write_file\",\"input\":{\"path\":\"/greeting.txt\",\"content\":\"Hello from n8n!\"},\"caller\":{\"type\":\"direct\"}},{\"type\":\"tool_use\",\"id\":\"toolu_01622M3pBYgZBPSbA8pzfp2U\",\"name\":\"workspace_read_file\",\"input\":{\"path\":\"/greeting.txt\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2603,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":133,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpqarARajhQkNQPhfo8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll write the file and then read it back for you.\"},{\"type\":\"tool_use\",\"id\":\"toolu_012z9je1zy6boKjnifomrQFg\",\"name\":\"workspace_write_file\",\"input\":{\"path\":\"/greeting.txt\",\"content\":\"Hello from n8n!\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3006,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":93,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:41Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:19Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:42Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:19Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:41Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:19Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:41Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:19Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f9d75b0d9cca-VIE",
"cf-ray": "a296a6697e5e25c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:42 GMT",
"request-id": "req_011CceuyRLgycRNJ6vqy2Wn4",
"date": "Tue, 11 Aug 2026 10:45:20 GMT",
"request-id": "req_011CdvpqaFwXapQca6aRhvPo",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-6efc27f89c7d4ec073ce03995671eb74-4c73623b0aaf1a22-01",
"traceresponse": "00-00713cc5710e0bbf851448f131ae18a7-1af996c79299dad1-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -395,7 +437,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. When asked to create a file, use workspace_write_file. When asked to read a file, use workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. When asked to create a file, use workspace_write_file. When asked to read a file, use workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -413,11 +455,11 @@
"content": [
{
"type": "text",
"text": "I'll write the file first, then read it back."
"text": "I'll write the file and then read it back for you."
},
{
"type": "tool_use",
"id": "toolu_01TrVd5Gx19YUnAnjbfoN3Kp",
"id": "toolu_012z9je1zy6boKjnifomrQFg",
"name": "workspace_write_file",
"input": {
"path": "/greeting.txt",
@ -426,10 +468,488 @@
"caller": {
"type": "direct"
}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_012z9je1zy6boKjnifomrQFg",
"content": "{\"success\":true}"
}
]
}
],
"tools": [
{
"name": "workspace_read_file",
"description": "Read the contents of a file from the workspace",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to read"
},
"encoding": {
"type": "string",
"enum": ["utf-8", "base64"],
"description": "File encoding (default: utf-8)"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to edit"
},
"old_str": {
"type": "string",
"description": "Exact text to replace. Must match exactly and be unique."
},
"new_str": {
"type": "string",
"description": "Replacement text to write in place of old_str."
}
},
"required": ["path", "old_str", "new_str"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_batch_str_replace_file",
"description": "Apply multiple exact text replacements to a workspace file atomically. If any replacement fails, no changes are written.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to edit"
},
"replacements": {
"type": "array",
"items": {
"type": "object",
"properties": {
"old_str": {
"type": "string",
"description": "Exact text to replace. Must match exactly and be unique."
},
"new_str": {
"type": "string",
"description": "Replacement text to write in place of old_str."
}
},
"required": ["old_str", "new_str"],
"additionalProperties": false
},
"description": "Ordered exact string replacements applied atomically."
}
},
"required": ["path", "replacements"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_write_file",
"description": "Write content to a file in the workspace",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to write"
},
"content": {
"type": "string",
"description": "Content to write to the file"
},
"recursive": {
"type": "boolean",
"description": "Create parent directories if they do not exist"
}
},
"required": ["path", "content"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_list_files",
"description": "List files and directories in the workspace",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory path to list"
},
"recursive": {
"type": "boolean",
"description": "List files recursively"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_file_stat",
"description": "Get metadata about a file or directory",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file or directory"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_mkdir",
"description": "Create a directory in the workspace",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path of the directory to create"
},
"recursive": {
"type": "boolean",
"description": "Create parent directories if needed"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_delete_file",
"description": "Delete a file or directory from the workspace",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file or directory to delete"
},
"recursive": {
"type": "boolean",
"description": "Delete directories and their contents recursively"
},
"force": {
"type": "boolean",
"description": "Ignore errors if the file does not exist"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_append_file",
"description": "Append content to a file in the workspace",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to append to"
},
"content": {
"type": "string",
"description": "Content to append to the file"
}
},
"required": ["path", "content"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_copy_file",
"description": "Copy a file or directory in the workspace",
"input_schema": {
"type": "object",
"properties": {
"src": {
"type": "string",
"description": "Source path of the file or directory to copy"
},
"dest": {
"type": "string",
"description": "Destination path for the copy"
},
"overwrite": {
"type": "boolean",
"description": "Overwrite the destination if it already exists"
}
},
"required": ["src", "dest"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_move_file",
"description": "Move or rename a file or directory in the workspace",
"input_schema": {
"type": "object",
"properties": {
"src": {
"type": "string",
"description": "Source path of the file or directory to move"
},
"dest": {
"type": "string",
"description": "Destination path"
},
"overwrite": {
"type": "boolean",
"description": "Overwrite the destination if it already exists"
}
},
"required": ["src", "dest"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_rmdir",
"description": "Remove a directory from the workspace",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path of the directory to remove"
},
"recursive": {
"type": "boolean",
"description": "Remove the directory and its contents recursively"
},
"force": {
"type": "boolean",
"description": "Ignore errors if the directory does not exist"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_execute_command",
"description": "Execute a shell command in the sandbox",
"input_schema": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute"
},
"cwd": {
"type": "string",
"description": "Working directory for the command"
},
"timeout": {
"type": "number",
"description": "Timeout in milliseconds"
}
},
"required": ["command"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_list_processes",
"description": "List all background processes spawned in the sandbox",
"input_schema": {
"type": "object",
"properties": {},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_kill_process",
"description": "Kill a background process in the sandbox by PID",
"input_schema": {
"type": "object",
"properties": {
"pid": {
"type": "number",
"description": "PID of the process to kill"
}
},
"required": ["pid"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
],
"tool_choice": {
"type": "auto"
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpqfdQ23sZ4RbpyKY3R\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now let me read the file back:\"},{\"type\":\"tool_use\",\"id\":\"toolu_0192ztBzne1P5eg2SYMroS1F\",\"name\":\"workspace_read_file\",\"input\":{\"path\":\"/greeting.txt\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3115,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":67,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:20Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:21Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:20Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:20Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a296a670ad3d25c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Tue, 11 Aug 2026 10:45:21 GMT",
"request-id": "req_011Cdvpqf9sgYdsuZaU6vTvJ",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-3620259cf7d15aa7a1ed9e440835a25e-75c9a5ca2afeb9b2-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
"responseIsBinary": false
},
{
"scope": "https://api.anthropic.com:443",
"method": "POST",
"path": "/v1/messages",
"body": {
"model": "claude-haiku-4-5",
"max_tokens": 64000,
"system": [
{
"type": "text",
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. When asked to create a file, use workspace_write_file. When asked to read a file, use workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "Write \"Hello from n8n!\" to /greeting.txt, then read it back and tell me the contents. You MUST call both tools"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "I'll write the file and then read it back for you."
},
{
"type": "tool_use",
"id": "toolu_01622M3pBYgZBPSbA8pzfp2U",
"id": "toolu_012z9je1zy6boKjnifomrQFg",
"name": "workspace_write_file",
"input": {
"path": "/greeting.txt",
"content": "Hello from n8n!"
},
"caller": {
"type": "direct"
}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_012z9je1zy6boKjnifomrQFg",
"content": "{\"success\":true}"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "Now let me read the file back:"
},
{
"type": "tool_use",
"id": "toolu_0192ztBzne1P5eg2SYMroS1F",
"name": "workspace_read_file",
"input": {
"path": "/greeting.txt"
@ -445,12 +965,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01TrVd5Gx19YUnAnjbfoN3Kp",
"content": "{\"success\":true}"
},
{
"type": "tool_result",
"tool_use_id": "toolu_01622M3pBYgZBPSbA8pzfp2U",
"tool_use_id": "toolu_0192ztBzne1P5eg2SYMroS1F",
"content": "{\"content\":\"Hello from n8n!\"}"
}
]
@ -478,6 +993,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -789,30 +1346,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01GVqiS8p7pay78EJf1QQQ7E\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Perfect! I've successfully written to and read from the file. The contents of `/greeting.txt` are:\\n\\n**Hello from n8n!**\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2816,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":35,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpqntCyYynSvQhsVWvi\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Perfect! The file has been created and read successfully. The contents of `/greeting.txt` are:\\n\\n**Hello from n8n!**\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3203,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":33,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:43Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:23Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:44Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:23Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:43Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:21Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:43Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:23Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f9e06a5d9cca-VIE",
"cf-ray": "a296a6771aa625c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:44 GMT",
"request-id": "req_011CceuyXXWCdG9Qd1JvxpkB",
"date": "Tue, 11 Aug 2026 10:45:23 GMT",
"request-id": "req_011CdvpqjhDXR596EH2BRxUe",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-752e0492478df0f43c55a51cfaa0e47a-0b4cc00ad19937e8-01",
"traceresponse": "00-b76abc360b302febf5486c73741e38e3-e07b415fd4cc2435-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. When asked to create a file, use workspace_write_file. Be very concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. When asked to create a file, use workspace_write_file. Be very concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -45,6 +45,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -358,33 +400,33 @@
},
"status": 200,
"response": [
"1f8b0800000000000013cd564d8fda3010bdf32ba2398736b0855df9b6dddbaa877ea8ab6a9bca9a4d067071ec604f602b94ff5e190810c26e7ba9686e9e37f6bcf726f1849664584405798f53929ed1712f474611ad817f9504025a20c4cd1ac41a0a9b93060199c62aa7fe0cd5bceabfeb8ffac364381a24c90062507938c34f653278b85dcdedc7c76fe8eec6f4a158dc2f6eef8b1b884f2a410cceea1040ef956734a16c660d936110df7fc4e0d996d2117a6b40984aeb5dc8d3a22293512b9813a3d2be89550d7b65ca8a25db39190f6238ba1ec59061362399394256d6c8764ad2e08e307f096bf6860a54cea820875a8e8a6efe011dcc4ed13a065bf171689cc4e0c92d55469215391010acc9d1e5c16533211794cb295910602c4b5ca2d2f8a409ea3ada3c75af47db8eefdc944fda66f3f37d3f93b22994d3f356ee311ed4eef6b1b55a569e9ade87752593c1a7d5f8cbc3d54df17837f1ef73e4abaf93cf8b39c460b008fb56d6cd7d8919c995534c72a2f4e68c600c88751d0cd63a08df97ca95a38cf7f29ae720b354667aa22b82108497acc84933be6ec526a565c53672e0b56de64f6fcd3eb944c70af5260802a0ee70bd088f750a25f22c0dbed497a793c2db19696ddf407d6126fccce9a14bdd37eb22a4e2286d4e4d41b4d95d985a94826747582873f1de4da3d57fe54db8d852a85ffdc0c2a4fad30d6ccb23226d5dcd903eaba805c29182d6183dbeb8ff7298d6ff709a9e8cbfebebce0c3bfc9874ad3bc6a0fb11ff0635769ee1f8080000"
"1f8b0800000000000013cd564d6fdb300cbde757183c3b9b932ec8a6db3e8eeb50acd807360f0263b3b11a457225daed10f8bf174ae2244ed26e97c1f34d2445bef7284ba49a0c8b6849dee39ca467743cc89151442be0df2581808e13e2760d62054b9b93060199c62aa761816a510d5f0d27c371329e8c92640431a83ce4f073998c46eff3ba741fcaabfa6efcf5cbfcddc7eb6b55cf203eaa043138ab8301bd579ed184b299354c8641fcfc1583675b4a47e8ad01612aadb7264f7715998c3ac69c1895f6adad6ad12b53562cd92ec87810e337d3d731649815243347c8ca1ad90d495abf23cc9ff2b57b43052a0b5a92432d27cbd3f8bd77541c7b9b186cc51d7c93183cb95a652459910301419a1c5d1e543637e4027339270b028c6589352a8d334dd03451f89ac180360ddf8a2967da668bf36d3f13b2ae93d3c386eda13f90ddee636bb5ac3cb5ad0feb4a26a3cbdbe9ccbc9de2a71ff5b7ab8b8b3bfacce3cbef1083c165d8776fddc2979891bc778a49de28bdce117401b16a82be5a07debb52b9729471cbee905fa9ccfc885004c1084f699093667c5e83754847838d650f68d3c45b6fcd2eb844c70af5da0802e0146b2f38562994c845ba03d4331e11a5f0129a9e5114a4b57dc10f0c4ddf82b49de919461ca5216bef9d09e9d2f529d9fd417d4be3d9112ed777ca7f81270a3768ef8df22934fb163dfbe8d8f24f6f8e2d0fd09ccdd90e2767d9759c70c0a6333e1cbe587f394434ff708a387af6a7d333e77d3f929d6a78e883a8fb3583c12342bc62eaf2090000"
],
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:48Z",
"anthropic-ratelimit-input-tokens-remaining": "9998000",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:27Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:48Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:27Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:48Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:27Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:48Z",
"anthropic-ratelimit-tokens-remaining": "11998000",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:27Z",
"cache-control": "no-cache",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa000f789cca-VIE",
"cf-ray": "a296a69f797b25c0-VIE",
"connection": "keep-alive",
"content-encoding": "gzip",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "text/event-stream; charset=utf-8",
"date": "Fri, 03 Jul 2026 09:48:49 GMT",
"request-id": "req_011CceuyuA1a3DJbhtARzSLq",
"date": "Tue, 11 Aug 2026 10:45:28 GMT",
"request-id": "req_011CdvprDERqVk87V64E9c8F",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-35661f42dfd6c284dfdf72f8af70d659-754025b36fb2493a-01",
"traceresponse": "00-3b35b498fba615a01c32fc04801e263f-ff74b075c4b93ceb-01",
"transfer-encoding": "chunked",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
@ -401,7 +443,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. When asked to create a file, use workspace_write_file. Be very concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. When asked to create a file, use workspace_write_file. Be very concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -419,7 +461,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01Qw6SV38mZCfsBdat3UfRqk",
"id": "toolu_01Mj7bnA7aNZvWP33qeRt2MX",
"name": "workspace_write_file",
"input": {
"path": "/hello.txt",
@ -436,7 +478,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01Qw6SV38mZCfsBdat3UfRqk",
"tool_use_id": "toolu_01Mj7bnA7aNZvWP33qeRt2MX",
"content": "{\"success\":true}"
}
]
@ -464,6 +506,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -777,33 +861,33 @@
},
"status": 200,
"response": [
"1f8b0800000000000013b554c1729b3010bdfb2bd4bdf48253a0b1ebea9ce94c3b39f4d643d35164b401d542a2d262bb93e1df3b6068c1c5492ee1c2f0dea2f7f62d0beed11267258620731481a4a7859224397b04fa5d21709890100dcfc01fa1740a0d70c88cac152e0ba977f5f27ab95aa671ba4ae2388108b46acf08b988934f9bcde6c30dbe5f5f7ffcb2bffd7afb53e3b7c316a2332588c03bd30232041d48da56367396d012f0ef3f2208e42ae151066781dbda981e0af8ab469be1045448529b3060f5e05edbaa26416e8736004fd7eb4d0499cc0a14994749da59312d8907dea35497b8e1dd5601ab024bf4d28855f97ffd3f3629ced9260257d3184a2208e8f73a43411a3d70689351d2ab3664fb80be6d5ce4e880837524e45e6a23b706a169d8e86a160b3c8dbd8f546c8dcb76f3c39f29e9e4141e4f3d8ff9b6e5fe3dc2635bd8dd38c0050395b6f999228316844b26151a924f9bec4a26264fc8d4dcdfb2dee28db308cdc5705e4df70dfbfc768facfb6a50312a903d6883ecfe5d81c6b82b3ad23d3b682a3aaa57647710c8a32cb5cdd9c1f95db883ab69c84f0eda55cfcdd955a34ec6470d7f83d94026248c0298ec2ba055826a6fe1c55bdbbce2da9eed599a3633edce6536e66014fa1f0a855c8555050000"
"1f8b0800000000000013bd544d8fd33010bdf7579839a74bb250217c437c5c800b0784c422ef341e12ab8e9db5276dd12aff1db94d76936e77e102be447e6fecf766c613da9263291a8a112b529131f04223a314b7c0bf5a02093312b2710ff2161aafc98284d262a76959a3d974cb97cbd5f232bf5c15795e400646a73b62a5f2a278abb76df8a4bf7c2cbf7d7ebfbbf9ba7ea37105d989126410bc4d00c66822a34bb2a5774c8e417eff914164dfaa4018bd03e93a6b0728d24d47aea419a889d1d83862dde8deb8b663c57e432e827c91bf2a3228b1ac499581908d776a1e928f7c20d48f71e3d9a4406d4d0d05b46ad53c8cbf678bfa94ed33f01d4fa1228348616b4a526c28808454198d41a722bb9f1452e2aa220f129c67855b3416d796a0ef8510a25f2ce8d8eea1946a6d7db939dff4332107194dfb63ae533ea53a9c63daa7c0c34702f433ddd6b8ea44484002e1316f9a2ce3d3de0e21336f4764eee92e6c70f6ce3b3ab81bd67f37f04c7c3096c4e1b59016c8e2fa794dd6fa0bdef3b5d819ae472be20a2207c2c6b84aec7cd8c42bb8989a7fb2b9befd536f7d3b713fbb6b1cfdb355989130c97a369c404e2bee8283bf1ed1fe1fcee8e950bdee4feb77ffbb7b58b92997deec6fb20bcd5a3f050000"
],
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9998000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:49Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:29Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:49Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:29Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:49Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:29Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11998000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:49Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:29Z",
"cache-control": "no-cache",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa08db3e9cca-VIE",
"cf-ray": "a296a6a9aafe25c0-VIE",
"connection": "keep-alive",
"content-encoding": "gzip",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "text/event-stream; charset=utf-8",
"date": "Fri, 03 Jul 2026 09:48:51 GMT",
"request-id": "req_011Cceuz1CQRsHjaRFJjWeNX",
"date": "Tue, 11 Aug 2026 10:45:29 GMT",
"request-id": "req_011CdvprLBPKMksnVdhVe21B",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-75c3a9e72f9fe676c85e3e65bc2b13e4-81812382f8587bcd-01",
"traceresponse": "00-f65c2bd509f39372a31913ac2591efd2-16dc82c2807ba48f-01",
"transfer-encoding": "chunked",
"vary": "Accept-Encoding",
"x-robots-tag": "none"

View File

@ -135,6 +135,21 @@ describe('createSandbox', () => {
expect(result?.provider).toBe('n8n-sandbox');
});
it('passes the configured id through to the N8nSandboxServiceSandbox', async () => {
const config: SandboxConfig = {
enabled: true,
provider: 'n8n-sandbox',
serviceUrl: 'https://sandbox.example.com',
id: '11111111-1111-4111-8111-111111111111',
timeout: 45_000,
};
const result = await createSandbox(config);
expect(result).toBeInstanceOf(N8nSandboxServiceSandbox);
expect(result?.id).toBe('11111111-1111-4111-8111-111111111111');
});
it('does not include sandbox secrets in unsupported provider errors', async () => {
const config = {
enabled: true,

View File

@ -113,46 +113,86 @@ describe('destroy()', () => {
describe('start()', () => {
describe('fresh creation (no existing ID)', () => {
it('creates a new sandbox', async () => {
it('creates a new sandbox with a service-generated id', async () => {
const sandbox = new N8nSandboxServiceSandbox(makeDefaultOptions());
await sandbox.start();
expect(mockCreateSandbox).toHaveBeenCalledTimes(1);
expect(mockCreateSandbox.mock.calls[0]).toEqual([]);
expect(sandbox.id).toBe('sb-123');
});
it('times out stalled creation and removes a sandbox created after the timeout', async () => {
let resolveCreation!: (record: ReturnType<typeof makeSandboxRecord>) => void;
mockCreateSandbox.mockReturnValue(
new Promise((resolve) => {
resolveCreation = resolve;
}),
);
const sandbox = new N8nSandboxServiceSandbox({
...makeDefaultOptions(),
timeout: 10,
});
await expect(sandbox.start()).rejects.toThrow(/abort|timeout/i);
resolveCreation(makeSandboxRecord({ id: 'late-sandbox' }));
await vi.waitFor(() => expect(mockDeleteSandbox).toHaveBeenCalledWith('late-sandbox'));
});
});
describe('reconnect to existing ID', () => {
it('reconnects when sandbox exists', async () => {
const sandbox = new N8nSandboxServiceSandbox({
...makeDefaultOptions(),
id: 'existing-sb',
});
mockGetSandbox.mockResolvedValue(makeSandboxRecord({ id: 'existing-sb' }));
await sandbox.start();
expect(mockGetSandbox).toHaveBeenCalledWith('existing-sb');
expect(mockCreateSandbox).not.toHaveBeenCalled();
expect(sandbox.id).toBe('existing-sb');
});
it('creates new when getSandbox returns 404', async () => {
mockGetSandbox.mockRejectedValue(new SandboxServiceError('not found', 404));
mockCreateSandbox.mockResolvedValue(makeSandboxRecord({ id: 'new-sb' }));
describe('create-or-reconnect with a configured id', () => {
it('creates or reconnects through the idempotent create endpoint', async () => {
const id = '11111111-1111-4111-8111-111111111111';
mockCreateSandbox.mockResolvedValue(makeSandboxRecord({ id }));
const sandbox = new N8nSandboxServiceSandbox({
...makeDefaultOptions(),
id: 'gone-sb',
id,
});
await sandbox.start();
expect(mockCreateSandbox).toHaveBeenCalledTimes(1);
expect(sandbox.id).toBe('new-sb');
expect(mockCreateSandbox).toHaveBeenCalledWith({ id });
expect(mockGetSandbox).not.toHaveBeenCalled();
expect(sandbox.id).toBe(id);
});
it('re-throws non-404 errors from getSandbox', async () => {
mockGetSandbox.mockRejectedValue(new SandboxServiceError('forbidden', 403));
it('does not delete a deterministic sandbox when creation times out', async () => {
let resolveCreation!: (record: ReturnType<typeof makeSandboxRecord>) => void;
mockCreateSandbox.mockReturnValue(
new Promise((resolve) => {
resolveCreation = resolve;
}),
);
const id = '22222222-2222-4222-8222-222222222222';
const sandbox = new N8nSandboxServiceSandbox({
...makeDefaultOptions(),
id,
timeout: 10,
});
await expect(sandbox.start()).rejects.toThrow(/abort|timeout/i);
resolveCreation(makeSandboxRecord({ id }));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mockDeleteSandbox).not.toHaveBeenCalled();
});
it('reuses the last known id when restarted after a stop', async () => {
mockCreateSandbox.mockResolvedValue(makeSandboxRecord({ id: 'sb-123' }));
const sandbox = new N8nSandboxServiceSandbox(makeDefaultOptions());
await sandbox.start();
await sandbox.stop();
await sandbox.start();
expect(mockCreateSandbox).toHaveBeenLastCalledWith({ id: 'sb-123' });
expect(sandbox.id).toBe('sb-123');
});
it('re-throws createSandbox errors', async () => {
mockCreateSandbox.mockRejectedValue(new SandboxServiceError('forbidden', 403));
const sandbox = new N8nSandboxServiceSandbox({
...makeDefaultOptions(),

View File

@ -63,6 +63,7 @@ describe('createWorkspaceTools', () => {
expect(names).toEqual([
'workspace_read_file',
'workspace_read_tool_result',
'workspace_str_replace_file',
'workspace_batch_str_replace_file',
'workspace_write_file',
@ -100,10 +101,11 @@ describe('createWorkspaceTools', () => {
const names = tools.map((t) => t.name);
expect(names).toContain('workspace_read_file');
expect(names).toContain('workspace_read_tool_result');
expect(names).toContain('workspace_str_replace_file');
expect(names).toContain('workspace_batch_str_replace_file');
expect(names).toContain('workspace_execute_command');
expect(names).toHaveLength(13);
expect(names).toHaveLength(14);
});
describe('tool handlers', () => {
@ -121,6 +123,131 @@ describe('createWorkspaceTools', () => {
expect(result).toEqual({ content: 'file content' });
});
it('read_tool_result describes and pages a nested result through escaped pointers', async () => {
const hash = 'a'.repeat(43);
const path = `tool-results/threads/${hash}/${hash}/${hash}.result.json`;
const escapedKey = 'folder/name~version';
const largeString = '"\\\n'.repeat(12_000);
const storedResult = {
[escapedKey]: {
entries: [{ large: largeString }, { value: 42 }],
enabled: true,
},
};
const fs = makeFakeFilesystem({
readFile: vi.fn().mockResolvedValue(JSON.stringify(storedResult)),
});
const tools = createWorkspaceTools({ filesystem: fs });
const readToolResult = tools.find((tool) => tool.name === 'workspace_read_tool_result')!;
await expect(
readToolResult.handler!({ path, view: 'describe' }, {} as never),
).resolves.toEqual({
view: 'describe',
pointer: '',
type: 'object',
childCount: 1,
});
const objectPage = (await readToolResult.handler!(
{ path, view: 'json', limit: 1 },
{} as never,
)) as {
children: Array<{ pointer: string; type: string }>;
};
expect(objectPage.children).toEqual([
{ pointer: '/folder~1name~0version', type: 'object', childCount: 2, key: escapedKey },
]);
const arrayPage = (await readToolResult.handler!(
{
path,
view: 'json',
pointer: '/folder~1name~0version/entries',
limit: 1,
},
{} as never,
)) as {
children: Array<{ pointer: string; type: string }>;
nextOffset: number | null;
hasMore: boolean;
};
expect(arrayPage.children).toEqual([
{
pointer: '/folder~1name~0version/entries/0',
type: 'object',
childCount: 1,
index: 0,
},
]);
expect(arrayPage).toMatchObject({ nextOffset: 1, hasMore: true });
let reconstructed = '';
let offset = 0;
do {
const page = (await readToolResult.handler!(
{
path,
view: 'json',
pointer: '/folder~1name~0version/entries/0/large',
offset,
maxChars: 20_000,
},
{} as never,
)) as {
content: string;
nextOffset: number | null;
hasMore: boolean;
};
expect(Buffer.byteLength(JSON.stringify(page), 'utf-8')).toBeLessThanOrEqual(40_000);
reconstructed += page.content;
if (page.nextOffset === null) break;
expect(page.nextOffset).toBeGreaterThan(offset);
offset = page.nextOffset;
} while (offset < largeString.length);
expect(reconstructed).toBe(largeString);
});
it.each([
{
name: 'an arbitrary workspace path',
path: '/tmp/result.json',
content: '{}',
pointer: undefined,
error: 'Path is not a stored tool result',
},
{
name: 'corrupt JSON',
path: `tool-results/runs/${'a'.repeat(43)}/${'b'.repeat(43)}.result.json`,
content: 'not-json',
pointer: undefined,
error: 'Stored tool result is not valid JSON',
},
{
name: 'a malformed pointer',
path: `tool-results/runs/${'a'.repeat(43)}/${'b'.repeat(43)}.result.json`,
content: '{}',
pointer: '/bad~2pointer',
error: 'Invalid JSON Pointer',
},
{
name: 'a missing pointer segment',
path: `tool-results/runs/${'a'.repeat(43)}/${'b'.repeat(43)}.result.json`,
content: '{}',
pointer: '/missing',
error: 'JSON Pointer does not reference a stored value',
},
])('read_tool_result rejects $name', async ({ path, content, pointer, error }) => {
const fs = makeFakeFilesystem({ readFile: vi.fn().mockResolvedValue(content) });
const tools = createWorkspaceTools({ filesystem: fs });
const readToolResult = tools.find((tool) => tool.name === 'workspace_read_tool_result')!;
await expect(
readToolResult.handler!({ path, view: 'json', pointer }, {} as never),
).rejects.toThrow(error);
});
it('targeted edit input schemas serialize with a top-level object type', () => {
const tools = createWorkspaceTools({ filesystem: makeFakeFilesystem() });
const strReplaceTool = tools.find((t) => t.name === 'workspace_str_replace_file')!;
@ -281,36 +408,6 @@ describe('createWorkspaceTools', () => {
expect(result).toEqual({ success: true });
});
it('execute_command handler includes sandbox default command environment', async () => {
const executeCommand = vi.fn().mockResolvedValue({
success: true,
exitCode: 0,
stdout: 'ok',
stderr: '',
executionTimeMs: 5,
});
const sandbox = makeFakeSandbox({
executeCommand,
getDefaultCommandEnv: () => ({ CUSTOM_ENV: 'enabled' }),
});
const tools = createWorkspaceTools({ sandbox });
const commandTool = tools.find((t) => t.name === 'workspace_execute_command')!;
const abortController = new AbortController();
const result = await commandTool.handler!(
{ command: 'node script.mjs', cwd: '/home/daytona/workspace' },
{ abortSignal: abortController.signal } as never,
);
expect(executeCommand).toHaveBeenCalledWith('node script.mjs', undefined, {
cwd: '/home/daytona/workspace',
env: { CUSTOM_ENV: 'enabled' },
timeout: undefined,
abortSignal: abortController.signal,
});
expect(result).toMatchObject({ success: true, stdout: 'ok' });
});
it('list_files handler calls filesystem.readdir', async () => {
const fs = makeFakeFilesystem();
const tools = createWorkspaceTools({ filesystem: fs });

View File

@ -25,7 +25,9 @@ export type {
Provider,
ThinkingConfig,
ThinkingConfigFor,
AnthropicThinkingEffort,
AnthropicThinkingConfig,
OpenAIReasoningEffort,
OpenAIThinkingConfig,
GoogleThinkingConfig,
XaiThinkingConfig,
@ -119,6 +121,11 @@ export {
raceWithAbort,
throwIfAborted,
} from './sdk/abort';
export {
DEFAULT_MODEL_STREAM_FIRST_OUTPUT_TIMEOUT_MS,
DEFAULT_MODEL_STREAM_IDLE_TIMEOUT_MS,
ModelStreamStallError,
} from './runtime/streaming/stream-stall';
export {
APPROVAL_RESUME_SCHEMA,
APPROVAL_SUSPEND_SCHEMA,
@ -248,6 +255,7 @@ export type {
ModelInfo,
ModelCost,
ModelLimits,
ModelModalities,
} from './sdk/catalog';
export { BaseMemory } from './storage/base-memory';
export { BaseVectorStore } from './storage/base-vector-store';
@ -412,7 +420,12 @@ export type {
export { Workspace } from './workspace';
export { BaseFilesystem } from './workspace';
export { BaseSandbox } from './workspace';
export { createWorkspaceTools } from './workspace';
export {
CORE_WORKSPACE_TOOL_NAMES,
createScopedWorkspace,
createWorkspaceTools,
getToolResultThreadDirectory,
} from './workspace';
export { SandboxProcessManager, ProcessHandle } from './workspace';
export type {

View File

@ -3,6 +3,8 @@ import type { JSONSchema7 } from 'json-schema';
import type { Mock, MockedFunction } from 'vitest';
import { z } from 'zod';
import { InMemoryFilesystem } from '../../__tests__/workspace/test-utils';
import { Agent } from '../../sdk/agent';
import { createCancellation } from '../../sdk/cancellation';
import { isLlmMessage } from '../../sdk/message';
import { Tool, Tool as ToolBuilder } from '../../sdk/tool';
@ -13,6 +15,7 @@ import type { 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';
import { Workspace, createWorkspaceTools } from '../../workspace';
import { AgentRuntime } from '../loop/agent-runtime';
import { InMemoryMemory } from '../memory/memory-store';
import { AgentEventBus } from '../state/event-bus';
@ -4130,6 +4133,67 @@ describe('AgentRuntime — runtime JSON Schema input validation', () => {
await runtime.generate('go');
expect(handlerFn).not.toHaveBeenCalled();
});
it('rejects unknown keys against a closed JSON Schema', async () => {
const handlerFn = vi.fn().mockResolvedValue({ ok: true });
const tool: BuiltTool = {
name: 'json_tool',
description: 'json tool',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: { query: { type: 'string' } },
},
handler: handlerFn,
};
generateText
.mockResolvedValueOnce(
makeGenerateWithToolCall('tc-1', 'json_tool', { query: 'cats', extra: true }),
)
.mockResolvedValueOnce(makeGenerateSuccess('done'));
const runtime = new AgentRuntime({
name: 'test',
model: 'openai/gpt-4o-mini',
instructions: 'test',
tools: [tool],
});
await runtime.generate('go');
expect(handlerFn).not.toHaveBeenCalled();
});
it('names the tool schema as the defect when it cannot be compiled', async () => {
const tool: BuiltTool = {
name: 'json_tool',
description: 'json tool',
inputSchema: { type: 'objct' } as unknown as JSONSchema7,
handler: async () => await Promise.resolve({ ok: true }),
};
generateText
.mockResolvedValueOnce(makeGenerateWithToolCall('tc-1', 'json_tool', { query: 'cats' }))
.mockResolvedValueOnce(makeGenerateSuccess('done'));
const runtime = new AgentRuntime({
name: 'test',
model: 'openai/gpt-4o-mini',
instructions: 'test',
tools: [tool],
});
const result = await runtime.generate('go');
const assistantMsg = result.messages.find(
(m) =>
isLlmMessage(m) && m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'),
) as Message;
const call = assistantMsg.content.find((c) => c.type === 'tool-call') as ContentToolCall;
expect(call.state === 'rejected' && call.error).toContain(
'input schema that could not be compiled',
);
});
});
// ---------------------------------------------------------------------------
@ -7725,4 +7789,328 @@ describe('AgentRuntime — oversized tool results', () => {
expect(envelope.tail).toContain('MESSAGE_TAIL');
expect(fileBlock).toMatchObject({ type: 'file', mediaType: 'text/plain', data: fileData });
});
describe('Workspace-backed oversized tool results', () => {
type OffloadedEnvelope = {
_offloaded: true;
path: string;
originalCharCount: number;
estimatedTokenCount: number;
requiredAction: {
toolName: 'workspace_read_tool_result';
input: { path: string; view: 'describe' };
};
};
function parseOffloadedEnvelope(value: string | undefined): OffloadedEnvelope {
try {
return JSON.parse(value ?? '') as OffloadedEnvelope;
} catch {
throw new Error('Expected a valid offloaded result envelope');
}
}
function modelToolResults(): unknown[] {
const call = generateText.mock.calls[1][0] as {
messages: Array<{
role: string;
content: Array<{ type: string; output?: { type: string; value: unknown } }>;
}>;
};
return call.messages
.filter((message) => message.role === 'tool')
.flatMap((message) => message.content)
.filter((part) => part.type === 'tool-result')
.map((part) => part.output?.value);
}
function createWorkspaceAgent(filesystem: InMemoryFilesystem, tools: BuiltTool[]) {
return new Agent('workspace-result-test')
.model('openai/gpt-4o-mini')
.instructions('Test')
.tool(tools)
.toolCallConcurrency(Infinity)
.workspace(new Workspace({ filesystem }));
}
it('stores complete concurrent transformed results without changing raw outputs', async () => {
const filesystem = new InMemoryFilesystem();
const transformedOutputs = {
first: { value: `FIRST${'a '.repeat(MAX_MODEL_TOOL_RESULT_TOKENS + 10_000)}` },
second: { value: `SECOND${'b '.repeat(MAX_MODEL_TOOL_RESULT_TOKENS + 10_000)}` },
};
const tools = (Object.keys(transformedOutputs) as Array<keyof typeof transformedOutputs>).map(
(name): BuiltTool => ({
name,
description: name,
inputSchema: z.object({}),
handler: async () => await Promise.resolve({ raw: name }),
toModelOutput: () => transformedOutputs[name],
}),
);
const agent = createWorkspaceAgent(filesystem, tools);
generateText
.mockResolvedValueOnce(
makeGenerateWithToolCalls([
{ toolCallId: 'tc/first', toolName: 'first', args: {} },
{ toolCallId: 'tc/second', toolName: 'second', args: {} },
]),
)
.mockResolvedValueOnce(makeGenerateSuccess());
const result = await agent.generate('run', {
persistence: { threadId: 'slack:C123:123.456', resourceId: 'user-1' },
});
const envelopes = modelToolResults() as OffloadedEnvelope[];
expect(envelopes).toHaveLength(2);
expect(new Set(envelopes.map(({ path }) => path)).size).toBe(2);
expect(envelopes.every(({ _offloaded }) => _offloaded)).toBe(true);
expect(envelopes.map(({ requiredAction }) => requiredAction)).toEqual(
envelopes.map(({ path }) => ({
toolName: 'workspace_read_tool_result',
input: { path, view: 'describe' },
})),
);
await expect(filesystem.readFile(envelopes[0].path, { encoding: 'utf8' })).resolves.toBe(
JSON.stringify(transformedOutputs.first),
);
await expect(filesystem.readFile(envelopes[1].path, { encoding: 'utf8' })).resolves.toBe(
JSON.stringify(transformedOutputs.second),
);
expect(result.toolCalls?.map(({ output }) => output)).toEqual([
{ raw: 'first' },
{ raw: 'second' },
]);
const reader = createWorkspaceTools({ filesystem }).find(
(tool) => tool.name === 'workspace_read_tool_result',
);
if (!reader?.handler) throw new Error('Expected workspace_read_tool_result');
await expect(reader.handler(envelopes[0].requiredAction.input, {} as never)).resolves.toEqual(
{
view: 'describe',
pointer: '',
type: 'object',
childCount: 1,
},
);
});
it('stores oversized errors without changing the rejected tool-call state', async () => {
const filesystem = new InMemoryFilesystem();
const tool: BuiltTool = {
name: 'large_error',
description: 'large error',
inputSchema: z.object({}),
handler: () => {
throw new Error(`ERROR_HEAD${'e '.repeat(MAX_MODEL_TOOL_RESULT_TOKENS + 10_000)}`);
},
};
const agent = createWorkspaceAgent(filesystem, [tool]);
generateText
.mockResolvedValueOnce(
makeGenerateWithToolCalls([{ toolCallId: 'tc-error', toolName: tool.name, args: {} }]),
)
.mockResolvedValueOnce(makeGenerateSuccess());
const result = await agent.generate('run', {
persistence: { threadId: 'thread-1', resourceId: 'user-1' },
});
const toolCall = result.messages
.flatMap((message) => ('content' in message ? message.content : []))
.find(
(content): content is ContentToolCall =>
content.type === 'tool-call' && content.toolCallId === 'tc-error',
);
const envelope = parseOffloadedEnvelope(
toolCall?.state === 'rejected' ? toolCall.error : undefined,
);
expect(toolCall?.state).toBe('rejected');
expect(envelope._offloaded).toBe(true);
expect(envelope.requiredAction).toEqual({
toolName: 'workspace_read_tool_result',
input: { path: envelope.path, view: 'describe' },
});
await expect(filesystem.readFile(envelope.path, { encoding: 'utf8' })).resolves.toContain(
'ERROR_HEAD',
);
});
it('stores oversized custom-message text while preserving file content', async () => {
const filesystem = new InMemoryFilesystem();
const fileData = Buffer.from('file').toString('base64');
const messageText = `MESSAGE${'m '.repeat(MAX_MODEL_TOOL_RESULT_TOKENS + 10_000)}`;
const tool: BuiltTool = {
name: 'large_message',
description: 'large message',
inputSchema: z.object({}),
handler: async () => await Promise.resolve({ ok: true }),
toMessage: async () =>
await Promise.resolve({
role: 'assistant',
content: [
{ type: 'text', text: messageText },
{ type: 'file', mediaType: 'text/plain', data: fileData },
],
}),
};
const agent = createWorkspaceAgent(filesystem, [tool]);
generateText
.mockResolvedValueOnce(
makeGenerateWithToolCalls([{ toolCallId: 'tc-message', toolName: tool.name, args: {} }]),
)
.mockResolvedValueOnce(makeGenerateSuccess());
const result = await agent.generate('run', {
persistence: { threadId: 'thread-1', resourceId: 'user-1' },
});
const message = result.messages.find(
(candidate) =>
'content' in candidate &&
candidate.content.some((content) => content.type === 'file' && content.data === fileData),
);
const text =
message && 'content' in message
? message.content.find((content) => content.type === 'text')?.text
: undefined;
const envelope = parseOffloadedEnvelope(text);
expect(envelope._offloaded).toBe(true);
expect(envelope.requiredAction).toEqual({
toolName: 'workspace_read_tool_result',
input: { path: envelope.path, view: 'describe' },
});
expect(message && 'content' in message ? message.content : []).toContainEqual({
type: 'file',
mediaType: 'text/plain',
data: fileData,
});
await expect(filesystem.readFile(envelope.path, { encoding: 'utf8' })).resolves.toBe(
JSON.stringify([{ type: 'text', text: messageText }]),
);
});
it('falls back to bounded truncation when storing the result fails', async () => {
const filesystem = new InMemoryFilesystem();
vi.spyOn(filesystem, 'writeFile').mockRejectedValue(new Error('storage unavailable'));
const tool: BuiltTool = {
name: 'large_result',
description: 'large result',
inputSchema: z.object({}),
handler: async () =>
await Promise.resolve({
value: `HEAD${'x '.repeat(MAX_MODEL_TOOL_RESULT_TOKENS + 10_000)}TAIL`,
}),
};
const agent = createWorkspaceAgent(filesystem, [tool]);
generateText
.mockResolvedValueOnce(
makeGenerateWithToolCalls([{ toolCallId: 'tc-large', toolName: tool.name, args: {} }]),
)
.mockResolvedValueOnce(makeGenerateSuccess());
await agent.generate('run');
expect(modelToolResults()[0]).toMatchObject({ _truncated: true });
});
});
});
// ---------------------------------------------------------------------------
// model stream stall handling
// ---------------------------------------------------------------------------
describe('AgentRuntime — model stream stall handling', () => {
beforeEach(() => {
streamText.mockReset();
});
/**
* streamText response that emits `chunks` then goes silent — a dead
* connection. Always leads with the SDK's synthetic `start` lifecycle chunk,
* which arrives before any provider byte and must not count as content.
*/
function makeStalledStream(chunks: Array<Record<string, unknown>> = []) {
chunks = [{ type: 'start' }, ...chunks];
return {
stream: (async function* () {
for (const c of chunks) yield await Promise.resolve(c);
await new Promise(() => {});
})(),
finishReason: new Promise(() => {}),
usage: new Promise(() => {}),
response: new Promise(() => {}),
toolCalls: new Promise(() => {}),
};
}
it('silently retries a turn that stalls before any content and completes', async () => {
streamText
.mockReturnValueOnce(makeStalledStream())
.mockReturnValueOnce(makeStreamSuccess('Recovered'));
const { runtime } = createRuntime();
const result = await runtime.stream('hi', {
modelStreamIdleTimeoutMs: 50,
modelStreamFirstOutputTimeoutMs: 50,
});
const chunks = await collectChunks(result.stream);
expect(streamText).toHaveBeenCalledTimes(2);
const finish = chunks.filter((c) => c.type === 'finish').at(-1) as
| (StreamChunk & { type: 'finish'; finishReason: string })
| undefined;
expect(finish?.finishReason).toBe('stop');
const text = chunks
.filter(
(c): c is StreamChunk & { type: 'text-delta'; delta: string } => c.type === 'text-delta',
)
.map((c) => c.delta)
.join('');
expect(text).toBe('Recovered');
expect(runtime.getState().status).toBe('success');
});
it('fails without retry when the stalled turn already streamed content', async () => {
streamText
.mockReturnValueOnce(
makeStalledStream([{ type: 'text-delta', id: 'text-1', text: 'partial' }]),
)
// A wrongly-taken retry completes loudly instead of hanging the test.
.mockReturnValue(makeStreamSuccess('should-not-run'));
const { runtime } = createRuntime();
const result = await runtime.stream('hi', {
modelStreamIdleTimeoutMs: 50,
modelStreamFirstOutputTimeoutMs: 50,
});
const chunks = await collectChunks(result.stream);
expect(streamText).toHaveBeenCalledTimes(1);
const errorChunk = chunks.find((c) => c.type === 'error') as
| (StreamChunk & { type: 'error'; error: unknown })
| undefined;
expect(String(errorChunk?.error)).toContain('stalled');
expect(runtime.getState().status).toBe('failed');
});
it('surfaces the stall error when the retry stalls too', async () => {
streamText.mockReturnValueOnce(makeStalledStream()).mockReturnValueOnce(makeStalledStream());
const { runtime } = createRuntime();
const result = await runtime.stream('hi', {
modelStreamIdleTimeoutMs: 50,
modelStreamFirstOutputTimeoutMs: 50,
});
const chunks = await collectChunks(result.stream);
expect(streamText).toHaveBeenCalledTimes(2);
const errorChunk = chunks.find((c) => c.type === 'error') as
| (StreamChunk & { type: 'error'; error: unknown })
| undefined;
expect(String(errorChunk?.error)).toContain('stalled');
expect(runtime.getState().status).toBe('failed');
});
});

View File

@ -7,6 +7,8 @@ type ProviderOpts = {
baseURL?: string;
fetch?: typeof globalThis.fetch;
headers?: Record<string, string>;
includeUsage?: boolean;
supportsStructuredOutputs?: boolean;
};
// All providers are mocked via vi.mock so require() inside the registry entries
@ -162,6 +164,8 @@ vi.mock('@ai-sdk/openai-compatible', () => ({
baseURL: opts.baseURL,
headers: opts.headers,
fetch: opts.fetch,
includeUsage: opts.includeUsage,
supportsStructuredOutputs: opts.supportsStructuredOutputs,
specificationVersion: 'v3',
}),
}));
@ -184,6 +188,25 @@ vi.mock('@ai-sdk/amazon-bedrock', () => ({
}),
}));
vi.mock('@ai-sdk/google-vertex/anthropic', () => ({
createVertexAnthropic:
(opts?: {
project?: string;
location?: string;
googleAuthOptions?: { credentials?: Record<string, unknown> };
fetch?: typeof globalThis.fetch;
}) =>
(model: string) => ({
provider: 'google-vertex-anthropic',
modelId: model,
project: opts?.project,
location: opts?.location,
googleAuthOptions: opts?.googleAuthOptions,
fetch: opts?.fetch,
specificationVersion: 'v3',
}),
}));
const { mockProxyAgent } = vi.hoisted(() => ({ mockProxyAgent: vi.fn() }));
vi.mock('undici', () => ({
ProxyAgent: mockProxyAgent,
@ -204,9 +227,9 @@ describe('createModel', () => {
});
it('should accept a string config', () => {
const model = createModel('anthropic/claude-sonnet-4-5') as unknown as Record<string, unknown>;
const model = createModel('anthropic/claude-opus-5') as unknown as Record<string, unknown>;
expect(model.provider).toBe('anthropic');
expect(model.modelId).toBe('claude-sonnet-4-5');
expect(model.modelId).toBe('claude-opus-5');
});
it('should accept an object config with baseURL', () => {
@ -360,7 +383,7 @@ describe('createModel', () => {
expect(model.apiKey).toBe('or-test');
});
it('should create model for nvidia', () => {
it('should create model for nvidia with SDK defaults for usage and structured outputs', () => {
const model = createModel({
id: 'nvidia/nvidia/llama-3.3-nemotron-super-49b-v1',
apiKey: 'nv-test',
@ -370,6 +393,28 @@ describe('createModel', () => {
expect(model.modelId).toBe('nvidia/llama-3.3-nemotron-super-49b-v1');
expect(model.apiKey).toBe('nv-test');
expect(model.baseURL).toBe('https://integrate.api.nvidia.com/v1');
expect(model.includeUsage).toBeUndefined();
expect(model.supportsStructuredOutputs).toBeUndefined();
});
it('should have undefined supportsStructuredOutputs for custom when unset', () => {
const model = createModel({
id: 'custom/Kimi-K3',
apiKey: 'key',
baseURL: 'https://example.com/v1',
}) as unknown as Record<string, unknown>;
expect(model.provider).toBe('custom');
expect(model.supportsStructuredOutputs).toBe(undefined);
});
it('should forward supportsStructuredOutputs for custom when set', () => {
const model = createModel({
id: 'custom/Kimi-K3',
apiKey: 'key',
baseURL: 'https://example.com/v1',
supportsStructuredOutputs: true,
}) as unknown as Record<string, unknown>;
expect(model.supportsStructuredOutputs).toBe(true);
});
});
@ -467,6 +512,58 @@ describe('createModel', () => {
});
});
describe('google-vertex-anthropic', () => {
it('should create model with project, location, and service-account JSON', () => {
const model = createModel({
id: 'google-vertex-anthropic/claude-opus-4-8',
project: 'my-project',
location: 'global',
googleCredentials: JSON.stringify({
client_email: 'svc@my-project.iam.gserviceaccount.com',
private_key: '-----BEGIN PRIVATE KEY-----\\nABC\\n-----END PRIVATE KEY-----\\n',
}),
}) as unknown as Record<string, unknown>;
expect(model.provider).toBe('google-vertex-anthropic');
expect(model.modelId).toBe('claude-opus-4-8');
expect(model.project).toBe('my-project');
expect(model.location).toBe('global');
expect(model.googleAuthOptions).toEqual({
credentials: {
client_email: 'svc@my-project.iam.gserviceaccount.com',
private_key: '-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----\n',
},
});
});
it('should default location to global when omitted', () => {
const model = createModel({
id: 'google-vertex-anthropic/claude-opus-4-8',
project: 'my-project',
}) as unknown as Record<string, unknown>;
expect(model.location).toBe('global');
expect(model.googleAuthOptions).toBeUndefined();
});
it('should throw if project is missing', () => {
expect(() =>
createModel({
id: 'google-vertex-anthropic/claude-opus-4-8',
location: 'global',
}),
).toThrow(/Invalid credentials for provider "google-vertex-anthropic"/);
});
it('should throw if googleCredentials is not valid JSON', () => {
expect(() =>
createModel({
id: 'google-vertex-anthropic/claude-opus-4-8',
project: 'my-project',
googleCredentials: 'not-json',
}),
).toThrow(/googleCredentials must be valid JSON/);
});
});
describe('unsupported provider', () => {
it('should throw for ollama', () => {
expect(() => createModel('ollama/llama3')).toThrow(/Unsupported provider: "ollama"/);

View File

@ -1,6 +1,11 @@
import { describe, it, expect } from 'vitest';
import { applyToolProviderOptionDefaults, getProviderQuirks } from '../model/provider-quirks';
import {
HIGH_REASONING_DEFAULT_MAX_OUTPUT_TOKENS,
applyToolProviderOptionDefaults,
getProviderQuirks,
resolveDefaultMaxOutputTokens,
} from '../model/provider-quirks';
describe('getProviderQuirks', () => {
it('returns an empty object for an unknown provider', () => {
@ -93,12 +98,80 @@ describe('thinkingToProviderOptions', () => {
});
});
it('anthropic: enabled mode with effort keeps default budgetTokens', () => {
expect(
getProviderQuirks('anthropic').thinkingToProviderOptions?.(
{ mode: 'enabled', effort: 'medium' },
'anthropic/claude-sonnet-4-5',
),
).toEqual({
anthropic: {
thinking: { type: 'enabled', budgetTokens: 10000 },
effort: 'medium',
},
});
});
it('anthropic: enabled mode with effort forwards explicit budgetTokens', () => {
expect(
getProviderQuirks('anthropic').thinkingToProviderOptions?.(
{ mode: 'enabled', effort: 'medium', budgetTokens: 5000 },
'anthropic/claude-sonnet-4-5',
),
).toEqual({
anthropic: {
thinking: { type: 'enabled', budgetTokens: 5000 },
effort: 'medium',
},
});
});
it('google-vertex-anthropic: emits Anthropic adaptive thinking under the anthropic namespace', () => {
expect(
getProviderQuirks('google-vertex-anthropic').thinkingToProviderOptions?.(
{ mode: 'adaptive', effort: 'medium' },
'google-vertex-anthropic/claude-opus-4-8',
),
).toEqual({
anthropic: {
thinking: { type: 'adaptive', display: 'summarized' },
effort: 'medium',
},
});
});
it('google-vertex-anthropic: tool defaults land under the anthropic namespace', () => {
expect(getProviderQuirks('google-vertex-anthropic').providerOptionsNamespace).toBe('anthropic');
expect(applyToolProviderOptionDefaults(undefined)).toEqual({
anthropic: { eagerInputStreaming: false },
});
});
it('openai: defaults reasoningEffort to medium', () => {
expect(getProviderQuirks('openai').thinkingToProviderOptions?.({}, 'openai/gpt-5')).toEqual({
openai: { reasoningEffort: 'medium', reasoningSummary: null },
});
});
it('openai: forwards GPT-5.6 Sol compatible reasoningEffort values', () => {
expect(
getProviderQuirks('openai').thinkingToProviderOptions?.(
{ reasoningEffort: 'medium' },
'openai/gpt-5.6-sol',
),
).toEqual({
openai: { reasoningEffort: 'medium', reasoningSummary: null },
});
expect(
getProviderQuirks('openai').thinkingToProviderOptions?.(
{ reasoningEffort: 'xhigh' },
'openai/gpt-5.6-sol',
),
).toEqual({
openai: { reasoningEffort: 'xhigh', reasoningSummary: null },
});
});
it('google: forwards thinkingBudget and thinkingLevel when set', () => {
expect(
getProviderQuirks('google').thinkingToProviderOptions?.(
@ -115,4 +188,38 @@ describe('thinkingToProviderOptions', () => {
xai: { reasoningEffort: 'high' },
});
});
it('custom: maps reasoningEffort to providerOptions.custom', () => {
expect(
getProviderQuirks('custom').thinkingToProviderOptions?.(
{
reasoningEffort: 'high',
},
'custom/Kimi-K3',
),
).toEqual({
custom: { reasoningEffort: 'high' },
});
});
it('custom: omits reasoning effort when unset', () => {
expect(getProviderQuirks('custom').thinkingToProviderOptions?.({}, 'custom/Kimi-K3')).toEqual(
{},
);
});
});
describe('resolveDefaultMaxOutputTokens', () => {
it.each([
'custom/accounts/fireworks/models/kimi-k3',
'openrouter/moonshotai/kimi-k3',
'custom/Kimi-K3',
] as const)('raises the output cap to the Kimi K3 default for %s', (modelId) => {
expect(resolveDefaultMaxOutputTokens(modelId)).toBe(HIGH_REASONING_DEFAULT_MAX_OUTPUT_TOKENS);
});
it('leaves unrelated models unset', () => {
expect(resolveDefaultMaxOutputTokens('custom/deepseek-ai/DeepSeek-V4-Pro')).toBeUndefined();
expect(resolveDefaultMaxOutputTokens('anthropic/claude-sonnet-4-5')).toBeUndefined();
});
});

View File

@ -54,6 +54,7 @@ import type { AgentMessage, ContentToolCall } from '../../types/sdk/message';
import type { JSONValue } from '../../types/utils/json';
import { getModelIdString } from '../../utils/model';
import { parseWithSchema } from '../../utils/parse';
import type { WorkspaceFilesystem } from '../../workspace';
import { MemoryOrchestrator } from '../memory/memory-orchestrator';
import type { ScopedMemoryTaskEvent } from '../memory/scoped-memory-task-runner';
import { generateThreadTitle } from '../memory/title-generation';
@ -93,6 +94,7 @@ export interface AgentRuntimeConfig {
instructionProviderOptions?: ProviderOptions;
tools?: BuiltTool[];
deferredTools?: BuiltTool[];
workspaceFilesystem?: WorkspaceFilesystem;
toolSearch?: {
topK?: number;
};
@ -221,6 +223,7 @@ export class AgentRuntime {
concurrency: config.toolCallConcurrency ?? 1,
onCancelled: () => this.updateState({ status: 'cancelled' }),
tokenCounter,
...(config.workspaceFilesystem ? { workspaceFilesystem: config.workspaceFilesystem } : {}),
});
this.modelCost = config.modelCost;
this.currentState = {
@ -880,6 +883,7 @@ export class AgentRuntime {
reasoning: staticLoopContext.reasoning,
providerOptions: staticLoopContext.providerOptions,
outputSpec: staticLoopContext.outputSpec,
maxOutputTokens: staticLoopContext.maxOutputTokens,
aiSdkOptions: this.buildAiSdkOptions(toolMap, options),
};
let turn = await sink.callModel(modelCallContext);

View File

@ -39,6 +39,7 @@ export class GenerateSink implements RunOutputSink<GenerateResult> {
...(ctx.hasTools ? { tools: ctx.aiTools } : {}),
...(ctx.providerOptions ? { providerOptions: ctx.providerOptions } : {}),
...(ctx.outputSpec ? { output: ctx.outputSpec } : {}),
...(ctx.maxOutputTokens !== undefined ? { maxOutputTokens: ctx.maxOutputTokens } : {}),
...ctx.aiSdkOptions,
});

View File

@ -69,6 +69,7 @@ export interface ModelCallContext {
reasoning?: LanguageModelCallOptions['reasoning'];
providerOptions?: Record<string, JSONObject>;
outputSpec?: ReturnType<typeof Output.object>;
maxOutputTokens?: number;
aiSdkOptions: {
telemetry?: TelemetryOptions;
repairToolCall?: ToolCallRepairFunction<NoInfer<ToolSet>>;

View File

@ -22,6 +22,7 @@ import {
getProviderQuirks,
PROVIDER_QUIRKS,
providerIdFromModelId,
resolveDefaultMaxOutputTokens,
} from '../model/provider-quirks';
import type { DeferredToolManager } from '../tools/deferred-tool-manager';
import { buildToolMap, toAiSdkProviderTools, toAiSdkTools } from '../tools/tool-adapter';
@ -38,6 +39,7 @@ export interface StaticLoopContext {
reasoning: AgentRuntimeConfig['reasoning'];
providerOptions?: Record<string, JSONObject>;
outputSpec?: ReturnType<typeof Output.object>;
maxOutputTokens?: number;
}
/**
@ -87,6 +89,7 @@ export class RuntimeContextBuilder {
reasoning: this.config.reasoning,
providerOptions: providerOptions as Record<string, JSONObject> | undefined,
outputSpec,
maxOutputTokens: execOptions?.maxOutputTokens ?? resolveDefaultMaxOutputTokens(this.modelId),
};
}

View File

@ -14,9 +14,33 @@ import { fromAiFinishReason, fromAiMessages } from '../model/messages';
import { createRawErrorReader, type RawErrorReader } from '../model/raw-error';
import { createRawUsageReader, type RawUsageReader } from '../model/raw-usage';
import { convertChunk, toTokenUsage } from '../streaming/stream';
import {
DEFAULT_MODEL_STREAM_FIRST_OUTPUT_TIMEOUT_MS,
DEFAULT_MODEL_STREAM_IDLE_TIMEOUT_MS,
MAX_MODEL_STREAM_STALL_RETRIES,
ModelStreamStallError,
raceWithStallDeadline,
withChunkIdleTimeout,
} from '../streaming/stream-stall';
import type { StreamWriterGuard } from '../streaming/stream-writer-guard';
import type { ToolCallBatchResult } from '../tools/tool-call-executor';
/**
* Chunk types that are pure transport bookkeeping: an attempt that stalled
* having emitted only these produced nothing user-visible or persisted, so it
* can be silently re-issued. Everything else (text, reasoning, tool activity)
* marks the attempt as streamed — unknown future types err on the safe side.
*/
const STALL_RETRY_SAFE_CHUNK_TYPES = new Set<string>([
'raw',
'start',
'stream-start',
'start-step',
'finish-step',
'finish',
'abort',
]);
/**
* Streaming output sink: drives the loop with `streamText`, forwards text /
* reasoning / tool chunks (and provider-executed tool timing) through the
@ -38,7 +62,6 @@ export class StreamSink implements RunOutputSink<void> {
// implementations live behind `RawErrorReader`; undefined when the run's
// provider has no reader.
private rawErrorReader: RawErrorReader | undefined;
constructor(
private readonly guard: StreamWriterGuard,
private readonly services: RunServices,
@ -101,23 +124,67 @@ export class StreamSink implements RunOutputSink<void> {
}
async callModel(ctx: ModelCallContext): Promise<ModelTurnResult> {
// Opt-in: only build the reader (and request raw chunks) when the host bills
// stopped runs. Also requires a reader for the provider, so an unsupported
// provider never pays the cost even with the option on.
this.rawUsageReader = this.options?.recoverUsageOnAbort
? createRawUsageReader(this.services.modelId)
: undefined;
// Some providers report failures (e.g. prompt safety blocks) only on the
// raw stream — no error, no content — so raw chunks are required to
// explain an otherwise silent empty response.
this.rawErrorReader = createRawErrorReader(this.services.modelId);
const idleMs = this.options?.modelStreamIdleTimeoutMs ?? DEFAULT_MODEL_STREAM_IDLE_TIMEOUT_MS;
// Pre-first-output silence tolerates prompt processing (large cache-miss
// prompts send nothing for minutes); never let it undercut the idle limit.
const firstOutputMs = Math.max(
idleMs,
this.options?.modelStreamFirstOutputTimeoutMs ?? DEFAULT_MODEL_STREAM_FIRST_OUTPUT_TIMEOUT_MS,
);
for (let attempt = 0; ; attempt++) {
// Opt-in: only build the reader (and request raw chunks) when the host bills
// stopped runs. Also requires a reader for the provider, so an unsupported
// provider never pays the cost even with the option on. Re-created per
// attempt so a retried turn starts from a clean raw capture.
this.rawUsageReader = this.options?.recoverUsageOnAbort
? createRawUsageReader(this.services.modelId)
: undefined;
// Some providers report failures (e.g. prompt safety blocks) only on the
// raw stream — no error, no content — so raw chunks are required to
// explain an otherwise silent empty response.
this.rawErrorReader = createRawErrorReader(this.services.modelId);
// Per-attempt controller: a stall must be able to cancel this attempt's
// fetch (releasing the socket the 1h network timeout would otherwise
// hold) without touching the run-level signal.
const turnAbort = new AbortController();
const attemptState = { streamedContent: false };
try {
return await this.streamModelTurn(ctx, turnAbort, { idleMs, firstOutputMs }, attemptState);
} catch (error) {
// A stall before any content is invisible to the user (and to the
// host's persistence) — re-issue the request instead of failing the
// run for what is usually a dead connection at request time. Once
// content has streamed, a retry would duplicate segments and orphan
// already-persisted tool-call facts, so the error surfaces instead.
// The abandoned attempt's prompt processing goes unbilled — accepted:
// it only has usage at all when the stream died between message_start
// and the first content chunk.
const retryable =
error instanceof ModelStreamStallError &&
attempt < MAX_MODEL_STREAM_STALL_RETRIES &&
!attemptState.streamedContent &&
!ctx.abortSignal.aborted;
if (!retryable) throw error;
}
}
}
private async streamModelTurn(
ctx: ModelCallContext,
turnAbort: AbortController,
deadlines: { idleMs: number; firstOutputMs: number },
attemptState: { streamedContent: boolean },
): Promise<ModelTurnResult> {
const { idleMs, firstOutputMs } = deadlines;
const { streamText } = loadAi();
const result = streamText({
model: ctx.model,
instructions: ctx.system,
messages: ctx.messages,
allowSystemInMessages: true,
abortSignal: ctx.abortSignal,
// Run abort (user stop) and turn abort (stall watchdog) both cancel
// this attempt's fetch.
abortSignal: AbortSignal.any([ctx.abortSignal, turnAbort.signal]),
...(ctx.reasoning ? { reasoning: ctx.reasoning } : {}),
// Surface the provider's raw message_start/message_delta events so an
// aborted run can recover its usage — the SDK reports none on abort.
@ -127,14 +194,33 @@ export class StreamSink implements RunOutputSink<void> {
...(ctx.hasTools ? { tools: ctx.aiTools } : {}),
...(ctx.providerOptions ? { providerOptions: ctx.providerOptions } : {}),
...(ctx.outputSpec ? { output: ctx.outputSpec } : {}),
...(ctx.maxOutputTokens !== undefined ? { maxOutputTokens: ctx.maxOutputTokens } : {}),
...ctx.aiSdkOptions,
...this.buildSmoothStreamTransformOptions(),
});
// A healthy streaming response emits chunks continuously (raw provider
// events included), so prolonged silence means the connection or provider
// is wedged — fail the turn instead of hanging until the network timeout.
// Pre-first-output silence gets the longer deadline (prompt processing on
// large cache-miss prompts sends nothing for minutes); once content has
// streamed, the tighter idle limit applies.
const chunkStream =
idleMs > 0
? withChunkIdleTimeout(
result.stream,
() => (attemptState.streamedContent ? idleMs : firstOutputMs),
() => turnAbort.abort(),
)
: result.stream;
// Consume the stream. When the AbortSignal fires mid-stream the AI SDK
// cancels the underlying fetch and the async iterator throws; the error
// propagates to the StreamSession which closes the consumer stream.
for await (const chunk of result.stream) {
for await (const chunk of chunkStream) {
// Anything beyond transport bookkeeping counts as content: once seen,
// a stalled attempt is no longer silently retryable (see callModel).
if (!STALL_RETRY_SAFE_CHUNK_TYPES.has(chunk.type)) attemptState.streamedContent = true;
// Track usage from raw provider events so an aborted turn (which never
// reaches the post-loop awaits) can still be billed via getTerminalFinish.
if (chunk.type === 'raw') {
@ -179,10 +265,18 @@ export class StreamSink implements RunOutputSink<void> {
}
}
const aiFinishReason = await result.finishReason;
const usage = await result.usage;
const providerMetadata = await result.providerMetadata;
const response = await result.response;
// The result promises settle as part of stream close on a healthy turn;
// guard them with the same stall deadline so an SDK-internal promise that
// never settles cannot hang the turn after the chunk loop ended.
const settle = async <T>(promise: PromiseLike<T>): Promise<T> =>
idleMs > 0
? await raceWithStallDeadline(promise, idleMs, () => turnAbort.abort())
: await promise;
const aiFinishReason = await settle(result.finishReason);
const usage = await settle(result.usage);
const providerMetadata = await settle(result.providerMetadata);
const response = await settle(result.response);
const newMessages = fromAiMessages(response.messages);
const errorReason = classifyModelTurnError({
aiFinishReason,
@ -195,9 +289,9 @@ export class StreamSink implements RunOutputSink<void> {
finishReason: fromAiFinishReason(aiFinishReason),
usage: toTokenUsage(usage, providerMetadata),
newMessages,
toolCalls: await result.toolCalls,
toolCalls: await settle(result.toolCalls),
structuredOutput:
ctx.outputSpec && aiFinishReason !== 'tool-calls' ? await result.output : undefined,
ctx.outputSpec && aiFinishReason !== 'tool-calls' ? await settle(result.output) : undefined,
...(errorReason && { errorReason }),
};
}

View File

@ -65,6 +65,84 @@ type ProviderRegistry = {
[P in ProviderId]: RegistryEntry<P>;
};
type OpenAiCompatibleCreds = {
apiKey?: string;
baseURL?: string;
headers?: Record<string, string>;
};
/**
* Parse a Vertex service-account JSON string into `googleAuthOptions`.
* Accepts either a full SA JSON blob or the subset `{ client_email, private_key }`.
* Returns undefined when unset so ADC can take over.
*/
function parseGoogleVertexAuthOptions(
googleCredentials: string | undefined,
): { credentials: Record<string, unknown> } | undefined {
if (!googleCredentials?.trim()) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(googleCredentials);
} catch {
throw new Error(
'Invalid credentials for provider "google-vertex-anthropic": googleCredentials must be valid JSON',
);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(
'Invalid credentials for provider "google-vertex-anthropic": googleCredentials must be a JSON object',
);
}
const credentials = { ...(parsed as Record<string, unknown>) };
// SA keys often arrive with literal `\n` escapes when pasted into env files.
if (typeof credentials.private_key === 'string') {
credentials.private_key = credentials.private_key.replace(/\\n/g, '\n');
}
return { credentials };
}
/**
* Shared builder for OpenAI-compatible HTTP providers. Prefer this over
* `@ai-sdk/<provider>` packages that pull optional NAPI binaries or v4-only types.
*/
function buildOpenAiCompatible(
name: string,
defaultBaseURL: string | undefined,
creds: OpenAiCompatibleCreds,
model: string,
fetch: FetchFn | undefined,
options?: { includeUsage?: boolean; supportsStructuredOutputs?: boolean },
): LanguageModel {
const { createOpenAICompatible } =
require('@ai-sdk/openai-compatible') as typeof import('@ai-sdk/openai-compatible');
const baseURL = creds.baseURL ?? defaultBaseURL;
if (!baseURL) {
throw new Error(`baseURL is required for OpenAI-compatible provider "${name}"`);
}
return createOpenAICompatible({
name,
baseURL,
apiKey: creds.apiKey,
headers: creds.headers,
fetch,
includeUsage: options?.includeUsage,
supportsStructuredOutputs: options?.supportsStructuredOutputs,
})(model);
}
type OpenAiCompatibleProviderId = 'nvidia';
function openAiCompatibleEntry<P extends OpenAiCompatibleProviderId>(
name: P,
defaultBaseURL: string,
options?: { includeUsage?: boolean; supportsStructuredOutputs?: boolean },
): RegistryEntry<P> {
return {
build: (creds, model, fetch) =>
buildOpenAiCompatible(name, defaultBaseURL, creds, model, fetch, options),
};
}
/**
* Registry of language model providers.
* Each entry maps a provider id to a builder that loads its @ai-sdk/* package
@ -86,17 +164,10 @@ const LANGUAGE_PROVIDERS: ProviderRegistry = {
},
},
custom: {
build: (creds, model, fetch) => {
const { createOpenAICompatible } =
require('@ai-sdk/openai-compatible') as typeof import('@ai-sdk/openai-compatible');
return createOpenAICompatible({
name: 'custom',
baseURL: creds.baseURL,
apiKey: creds.apiKey,
headers: creds.headers,
fetch,
})(model);
},
build: (creds, model, fetch) =>
buildOpenAiCompatible('custom', undefined, creds, model, fetch, {
supportsStructuredOutputs: creds.supportsStructuredOutputs,
}),
},
anthropic: {
build: (creds, model, fetch) => {
@ -116,6 +187,21 @@ const LANGUAGE_PROVIDERS: ProviderRegistry = {
return createAnthropic({ ...creds, baseURL: normalizedBaseURL, fetch })(model);
},
},
'google-vertex-anthropic': {
build: (creds, model, fetch) => {
const { createVertexAnthropic } =
require('@ai-sdk/google-vertex/anthropic') as typeof import('@ai-sdk/google-vertex/anthropic');
const googleAuthOptions = parseGoogleVertexAuthOptions(creds.googleCredentials);
return createVertexAnthropic({
project: creds.project,
location: creds.location,
baseURL: creds.baseURL,
headers: creds.headers,
...(googleAuthOptions ? { googleAuthOptions } : {}),
fetch,
})(model);
},
},
google: {
build: (creds, model, fetch) => {
const { createGoogle } = require('@ai-sdk/google') as typeof import('@ai-sdk/google');
@ -165,19 +251,7 @@ const LANGUAGE_PROVIDERS: ProviderRegistry = {
return createOpenRouter({ apiKey: creds.apiKey, baseURL: creds.baseURL, fetch })(model);
},
},
nvidia: {
build: (creds, model, fetch) => {
const { createOpenAICompatible } =
require('@ai-sdk/openai-compatible') as typeof import('@ai-sdk/openai-compatible');
return createOpenAICompatible({
name: 'nvidia',
baseURL: creds.baseURL ?? 'https://integrate.api.nvidia.com/v1',
apiKey: creds.apiKey,
headers: creds.headers,
fetch,
})(model);
},
},
nvidia: openAiCompatibleEntry('nvidia', 'https://integrate.api.nvidia.com/v1', {}),
'azure-openai': {
build: (creds, model, fetch) => {
const { createAzure } = require('@ai-sdk/azure') as typeof import('@ai-sdk/azure');

View File

@ -40,7 +40,7 @@ export function getEffectiveAnthropicCacheTtl(
config: PromptCachingConfig | undefined,
modelId: string,
): '5m' | '1h' | undefined {
if (getModelProvider(modelId) !== 'anthropic' || !isEnabledForProvider(config, 'anthropic')) {
if (!isAnthropicMessagesProvider(modelId) || !isEnabledForProvider(config, 'anthropic')) {
return undefined;
}
return getAnthropicCacheTtl(config);
@ -59,6 +59,12 @@ export function getModelProvider(modelId: string): string {
return modelId.split('/')[0];
}
/** Providers that speak the Anthropic Messages API (including Vertex Claude). */
export function isAnthropicMessagesProvider(modelId: string): boolean {
const provider = getModelProvider(modelId);
return provider === 'anthropic' || provider === 'google-vertex-anthropic';
}
/**
* Deterministic, non-reversible per-agent-version OpenAI `promptCacheKey`.
* Hashes the agent name, model id, and base instructions together so the key
@ -144,7 +150,7 @@ export function buildInstructionPromptCacheOptions(
config: PromptCachingConfig | undefined,
modelId: string,
): ProviderOptions | undefined {
if (getModelProvider(modelId) !== 'anthropic' || !isEnabledForProvider(config, 'anthropic')) {
if (!isAnthropicMessagesProvider(modelId) || !isEnabledForProvider(config, 'anthropic')) {
return undefined;
}
@ -201,10 +207,7 @@ export function applyRuntimeCacheBreakpoints(params: {
staticToolCacheName: string | undefined;
}): { messages: ModelMessage[]; aiTools: ToolSet } {
const { system, messages, aiTools, promptCaching, modelId, staticToolCacheName } = params;
if (
getModelProvider(modelId) !== 'anthropic' ||
!isEnabledForProvider(promptCaching, 'anthropic')
) {
if (!isAnthropicMessagesProvider(modelId) || !isEnabledForProvider(promptCaching, 'anthropic')) {
return { messages, aiTools };
}

View File

@ -19,8 +19,21 @@ export const PROVIDER_CREDENTIAL_SCHEMAS = {
}),
custom: apiKeyCreds.extend({
baseURL: z.string().min(1, 'baseURL is required'),
supportsStructuredOutputs: z.boolean().optional(),
}),
anthropic: apiKeyCreds,
/**
* Claude on Google Vertex (Anthropic Messages via `:rawPredict`).
* `googleCredentials` is a service-account JSON string; omit it to use ADC
* (`gcloud auth application-default login`).
*/
'google-vertex-anthropic': z.object({
project: z.string().min(1, 'project is required'),
location: z.string().min(1, 'location is required').default('global'),
googleCredentials: z.string().optional(),
baseURL: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
}),
google: apiKeyCreds,
xai: apiKeyCreds,
groq: apiKeyCreds,

View File

@ -3,12 +3,19 @@ import type {
AnthropicThinkingConfig,
GoogleThinkingConfig,
JSONObject,
OpenAIReasoningEffort,
OpenAIThinkingConfig,
ThinkingConfig,
XaiThinkingConfig,
} from '../../types';
export interface ProviderQuirks {
/**
* Namespace used in AI SDK `providerOptions` / `providerMetadata`.
* Defaults to the registry key. Vertex Anthropic still speaks the Anthropic
* Messages wire format, so its quirks emit under `anthropic`.
*/
providerOptionsNamespace?: string;
/** providerMetadata keys on reasoning parts that must be copied to providerOptions and survive replay. */
reasoningReplayKeys?: string[];
/** Defaults merged under this provider's namespace into every tool's providerOptions (explicit tool values win). */
@ -22,6 +29,31 @@ export interface ProviderQuirks {
) => Record<string, Record<string, unknown>>;
}
/** Shared Anthropic Messages thinking mapping (also used by Vertex Anthropic). */
function anthropicThinkingToProviderOptions(
thinking: ThinkingConfig,
modelId: string,
): Record<string, Record<string, unknown>> {
const cfg = thinking as AnthropicThinkingConfig;
const adaptive = cfg.mode === 'adaptive' ? cfg : undefined;
if (anthropicUsesAdaptiveThinking(modelId)) {
return {
anthropic: {
thinking: { type: 'adaptive', display: adaptive?.display ?? 'summarized' },
effort: adaptive?.effort ?? 'medium',
},
};
}
const budgetTokens = cfg.mode === 'adaptive' ? undefined : cfg.budgetTokens;
const effort = cfg.mode === 'adaptive' ? undefined : cfg.effort;
return {
anthropic: {
thinking: { type: 'enabled', budgetTokens: budgetTokens ?? 10000 },
...(effort !== undefined ? { effort } : {}),
},
};
}
/** Anthropic model families that take the adaptive thinking API. */
const ANTHROPIC_ADAPTIVE_THINKING = /claude-(?:opus-(?:5|4-6|4-7|4-8)|sonnet-(?:5|4-6)|fable-5)/;
/** Anthropic model families that predate it and still take a fixed token budget. */
@ -39,6 +71,21 @@ function anthropicUsesAdaptiveThinking(modelId: string): boolean {
return modelId.includes('claude-');
}
/** Map effort to top-level `reasoningEffort` (OpenAI-compatible chat). */
function reasoningEffortQuirk(
provider: ProviderId,
defaultEffort?: OpenAIReasoningEffort,
): ProviderQuirks {
return {
thinkingToProviderOptions: (thinking) => {
const cfg = thinking as OpenAIThinkingConfig;
const reasoningEffort = cfg.reasoningEffort ?? defaultEffort;
if (reasoningEffort === undefined) return {};
return { [provider]: { reasoningEffort } };
},
};
}
/**
* Declarative registry of provider-specific behavior the AI SDK doesn't
* normalize away. Each entry documents why the quirk exists and its upstream
@ -61,24 +108,14 @@ export const PROVIDER_QUIRKS: Partial<Record<ProviderId, ProviderQuirks>> = {
// QUIRK(anthropic): the two thinking APIs are mutually exclusive — an
// adaptive model rejects `type: 'enabled'` and vice versa — so the model
// decides the shape and the config only fills in its details.
thinkingToProviderOptions: (thinking, modelId) => {
const cfg = thinking as AnthropicThinkingConfig;
const adaptive = cfg.mode === 'adaptive' ? cfg : undefined;
if (anthropicUsesAdaptiveThinking(modelId)) {
return {
anthropic: {
thinking: { type: 'adaptive', display: adaptive?.display ?? 'summarized' },
effort: adaptive?.effort ?? 'medium',
},
};
}
const budgetTokens = cfg.mode === 'adaptive' ? undefined : cfg.budgetTokens;
return {
anthropic: {
thinking: { type: 'enabled', budgetTokens: budgetTokens ?? 10000 },
},
};
},
thinkingToProviderOptions: anthropicThinkingToProviderOptions,
},
// Vertex Claude uses AnthropicLanguageModel under the hood — providerOptions
// stay under the `anthropic` namespace even though the model id prefix differs.
'google-vertex-anthropic': {
providerOptionsNamespace: 'anthropic',
toolProviderOptionDefaults: { eagerInputStreaming: false },
thinkingToProviderOptions: anthropicThinkingToProviderOptions,
},
openai: {
// QUIRK(openai): the Responses API pairs each function_call item with a
@ -129,6 +166,8 @@ export const PROVIDER_QUIRKS: Partial<Record<ProviderId, ProviderQuirks>> = {
return { xai: { reasoningEffort: cfg.reasoningEffort ?? 'high' } };
},
},
// custom/*: only forward an explicit effort — no provider-level default.
custom: reasoningEffortQuirk('custom'),
};
export function getProviderQuirks(providerId: string): ProviderQuirks {
@ -139,6 +178,25 @@ export function providerIdFromModelId(modelId: string): string {
return modelId.split('/')[0];
}
/**
* Default completion-token cap for reasoning-heavy Kimi K3 models.
* Context windows are often 131072 shared input+output; requesting the full
* window as max_tokens overflows once any prompt tokens are present.
*/
export const HIGH_REASONING_DEFAULT_MAX_OUTPUT_TOKENS = 65_536;
/**
* Provider/model-specific default for AI SDK `maxOutputTokens`. Unknown models
* otherwise fall back to a 4096 cap that reasoning-heavy agent turns can exhaust
* before emitting text or tool calls.
*/
export function resolveDefaultMaxOutputTokens(modelId: string): number | undefined {
if (modelId.toLowerCase().includes('kimi-k3')) {
return HIGH_REASONING_DEFAULT_MAX_OUTPUT_TOKENS;
}
return undefined;
}
/** Merge every registered tool providerOptions default under its provider namespace; explicit tool values win. */
export function applyToolProviderOptionDefaults(
toolProviderOptions: Record<string, JSONObject> | undefined,
@ -146,7 +204,8 @@ export function applyToolProviderOptionDefaults(
const result = { ...toolProviderOptions };
for (const [provider, quirks] of Object.entries(PROVIDER_QUIRKS)) {
if (!quirks.toolProviderOptionDefaults) continue;
result[provider] = { ...quirks.toolProviderOptionDefaults, ...result[provider] };
const namespace = quirks.providerOptionsNamespace ?? provider;
result[namespace] = { ...quirks.toolProviderOptionDefaults, ...result[namespace] };
}
return result;
}

View File

@ -74,6 +74,7 @@ export class AnthropicRawUsageReader implements RawUsageReader {
*/
const RAW_USAGE_READERS: Partial<Record<ProviderId, () => RawUsageReader>> = {
anthropic: () => new AnthropicRawUsageReader(),
'google-vertex-anthropic': () => new AnthropicRawUsageReader(),
};
/**

View File

@ -0,0 +1,150 @@
import {
ModelStreamStallError,
raceWithStallDeadline,
withChunkIdleTimeout,
} from '../stream-stall';
describe('withChunkIdleTimeout', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('passes chunks through and completes with the source', async () => {
async function* source() {
yield await Promise.resolve(1);
yield 2;
yield 3;
}
const seen: number[] = [];
for await (const chunk of withChunkIdleTimeout(
source(),
() => 60_000,
() => {},
)) {
seen.push(chunk);
}
expect(seen).toEqual([1, 2, 3]);
});
it('accepts sync iterables, mirroring for-await semantics', async () => {
function* source() {
yield 'a';
yield 'b';
}
const seen: string[] = [];
for await (const chunk of withChunkIdleTimeout(
source(),
() => 60_000,
() => {},
)) {
seen.push(chunk);
}
expect(seen).toEqual(['a', 'b']);
});
it('fails with a stall error and aborts the source when a chunk never arrives', async () => {
const onStall = vi.fn();
const gate = new Promise<number>(() => {});
async function* source() {
yield 1;
yield await gate;
}
const iterator = withChunkIdleTimeout(source(), () => 60_000, onStall);
await expect(iterator.next()).resolves.toEqual({ done: false, value: 1 });
// Capture the outcome with a synchronously attached handler so the
// rejection is never unhandled while the fake timers advance.
const stalled = iterator.next().then(
() => undefined,
(error: unknown) => error,
);
await vi.advanceTimersByTimeAsync(59_999);
expect(onStall).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await expect(stalled).resolves.toBeInstanceOf(ModelStreamStallError);
expect(onStall).toHaveBeenCalledTimes(1);
});
it('resets the idle window on every chunk', async () => {
let release!: (value: number) => void;
async function* source() {
yield 1;
yield await new Promise<number>((resolve) => {
release = resolve;
});
yield 3;
}
const iterator = withChunkIdleTimeout(
source(),
() => 60_000,
() => {},
);
await expect(iterator.next()).resolves.toEqual({ done: false, value: 1 });
const pending = iterator.next();
// Just under the deadline the read is still allowed to settle…
await vi.advanceTimersByTimeAsync(59_000);
release(2);
await expect(pending).resolves.toEqual({ done: false, value: 2 });
// …and the next read gets a fresh window rather than the stale timer.
const after = iterator.next();
await vi.advanceTimersByTimeAsync(59_000);
await expect(after).resolves.toEqual({ done: false, value: 3 });
});
it('propagates source errors unchanged', async () => {
async function* source(): AsyncGenerator<number> {
yield await Promise.resolve(1);
throw new Error('provider exploded');
}
const iterator = withChunkIdleTimeout(
source(),
() => 60_000,
() => {},
);
await expect(iterator.next()).resolves.toEqual({ done: false, value: 1 });
await expect(iterator.next()).rejects.toThrow('provider exploded');
});
});
describe('raceWithStallDeadline', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('resolves with the promise when it settles in time', async () => {
await expect(raceWithStallDeadline(Promise.resolve('ok'), 60_000)).resolves.toBe('ok');
});
it('rejects with a stall error and notifies when the promise never settles', async () => {
const onStall = vi.fn();
// Capture the outcome with a synchronously attached handler so the
// rejection is never unhandled while the fake timers advance.
const settled = raceWithStallDeadline(new Promise<never>(() => {}), 60_000, onStall).then(
() => undefined,
(error: unknown) => error,
);
await vi.advanceTimersByTimeAsync(60_000);
await expect(settled).resolves.toBeInstanceOf(ModelStreamStallError);
expect(onStall).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,113 @@
/**
* Chunk-idle stall detection for model streams.
*
* The AI transport raises undici's network idle timeouts to one hour so long
* completions and slow non-streaming calls are not cut off — which means a
* dead connection (half-open socket, a proxy that keeps the wire warm without
* forwarding data) can hang a streaming turn for that long. Streaming
* responses emit deltas continuously, so extended silence *between chunks* is
* the reliable liveness signal the network layer cannot see. These helpers
* fail the turn fast with a typed, user-explainable error instead.
*/
/**
* Max silence between chunks once the turn has streamed content. Bytes were
* flowing and stopped — beyond this the connection is dead for all practical
* purposes, and the user is staring at a frozen response.
*/
export const DEFAULT_MODEL_STREAM_IDLE_TIMEOUT_MS = 90_000;
/**
* Max silence before the turn's first content chunk. Deliberately longer than
* the post-content limit: a large cache-miss prompt legitimately spends 1-3
* minutes in prompt processing before the provider sends anything, and a trip
* here is recovered by a silent retry rather than a user-facing error.
*/
export const DEFAULT_MODEL_STREAM_FIRST_OUTPUT_TIMEOUT_MS = 3 * 60_000;
/**
* How many times a stalled turn is silently re-issued before the stall error
* surfaces. Only applies while the stalled attempt has streamed no content
* (see `StreamSink.callModel`) — retrying an attempt the user already saw
* would duplicate segments and orphan persisted tool-call facts.
*/
export const MAX_MODEL_STREAM_STALL_RETRIES = 1;
export class ModelStreamStallError extends Error {
constructor(idleMs: number) {
super(
`The model stream stalled: no data received for ${Math.round(idleMs / 1000)} seconds. ` +
'This is usually a transient connection issue — please try again.',
);
this.name = 'ModelStreamStallError';
}
}
/** Resolve the iterator the way `for await` would: async first, sync fallback. */
function getChunkIterator<T>(
source: AsyncIterable<T> | Iterable<T>,
): AsyncIterator<T> | Iterator<T> {
if (Symbol.asyncIterator in source) return source[Symbol.asyncIterator]();
return source[Symbol.iterator]();
}
/**
* Yield chunks from `source`, throwing {@link ModelStreamStallError} when no
* chunk arrives within `idleMs`. `onStall` fires first so the caller can abort
* the underlying request (releasing the socket and settling the abandoned
* read). The abandoned read's eventual rejection is swallowed — the stall
* error is the one the caller reports.
*/
export async function* withChunkIdleTimeout<T>(
source: AsyncIterable<T> | Iterable<T>,
getIdleMs: () => number,
onStall: () => void,
): AsyncGenerator<T> {
const iterator = getChunkIterator(source);
try {
while (true) {
const next = Promise.resolve(iterator.next());
// If the deadline wins, the losing read settles later — without a
// handler its rejection would surface as an unhandled rejection.
next.catch(() => undefined);
const result = await raceWithStallDeadline(next, getIdleMs(), onStall);
if (result.done) return;
yield result.value;
}
} finally {
// Fire-and-forget: a wedged source's teardown may itself never settle,
// and awaiting it here would block the stall error on the very hang this
// wrapper exists to break. `onStall` already aborted the underlying
// request, which is what actually reclaims the socket.
void Promise.resolve()
.then(async () => await iterator.return?.())
.catch(() => undefined);
}
}
/**
* Await `promise` with the same stall deadline. Used for the SDK's post-stream
* result promises (`finishReason`, `usage`, `response`, …), which settle
* instantly on a healthy stream but would otherwise be an unguarded await.
*/
export async function raceWithStallDeadline<T>(
promise: PromiseLike<T>,
idleMs: number,
onStall?: () => void,
): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
onStall?.();
reject(new ModelStreamStallError(idleMs));
}, idleMs);
timer.unref?.();
}),
]);
} finally {
clearTimeout(timer);
}
}

View File

@ -10,6 +10,7 @@ import {
guardToolErrorForModel,
guardToolMessageForModel,
guardToolResultForModel,
type ToolResultGuardStorage,
} from './tool-result-guard';
import { isAbortError, raceWithAbort } from '../../sdk/abort';
import { isCancellation } from '../../sdk/cancellation';
@ -27,6 +28,7 @@ import type { AgentMessage, ContentToolCall, Message } from '../../types/sdk/mes
import type { JSONObject, JSONValue } from '../../types/utils/json';
import { parseWithSchema } from '../../utils/parse';
import { isZodSchema } from '../../utils/zod';
import type { WorkspaceFilesystem } from '../../workspace/types';
import { incrementToolCallCount } from '../loop/execution-counter';
import { stringifyError } from '../loop/runtime-helpers';
import type { AgentMessageList } from '../model/message-list';
@ -183,6 +185,7 @@ export interface ToolCallExecutorDeps {
/** Invoked when a run is aborted mid-batch so the runtime can set cancelled state. */
onCancelled: () => void;
tokenCounter: TokenCounter;
workspaceFilesystem?: WorkspaceFilesystem;
}
/**
@ -859,7 +862,11 @@ export class ToolCallExecutor {
});
params.list.setToolCallError(
params.toolCallId,
await guardToolErrorForModel(stringifyError(error), this.deps.tokenCounter),
await guardToolErrorForModel(
stringifyError(error),
this.deps.tokenCounter,
this.getResultStorage(params),
),
);
return { outcome: 'error', error };
}
@ -927,10 +934,10 @@ export class ToolCallExecutor {
if (!builtTool.inputSchema) return { ok: true, input: params.input };
const result = await parseWithSchema(builtTool.inputSchema, params.input);
if (!result.success) {
return {
ok: false,
outcome: await this.toolError(params, new Error(`Invalid tool input: ${result.error}`)),
};
const reason = result.schemaInvalid
? `Tool ${params.toolName} has an input schema that could not be compiled: ${result.error}`
: `Invalid tool input: ${result.error}`;
return { ok: false, outcome: await this.toolError(params, new Error(reason)) };
}
return { ok: true, input: result.data as JSONValue };
}
@ -1028,7 +1035,12 @@ export class ToolCallExecutor {
} catch (error) {
return await this.toolError(params, error);
}
const guardedResult = await guardToolResultForModel(modelResult, this.deps.tokenCounter);
const storage = this.getResultStorage(params);
const guardedResult = await guardToolResultForModel(
modelResult,
this.deps.tokenCounter,
storage,
);
this.eventBus.emit({
type: AgentEvent.ToolExecutionEnd,
@ -1040,9 +1052,9 @@ export class ToolCallExecutor {
list.setToolCallResult(toolCallId, guardedResult.historyOutput);
const customMessage = builtTool.toMessage?.(toolResult);
const customMessage = await builtTool.toMessage?.(toolResult);
const guardedCustomMessage = customMessage
? await guardToolMessageForModel(customMessage, this.deps.tokenCounter)
? await guardToolMessageForModel(customMessage, this.deps.tokenCounter, storage)
: undefined;
if (guardedCustomMessage) {
list.addResponse([guardedCustomMessage]);
@ -1060,4 +1072,17 @@ export class ToolCallExecutor {
customMessage: guardedCustomMessage,
};
}
private getResultStorage(params: ProcessToolCallParams): ToolResultGuardStorage | undefined {
const filesystem = this.deps.workspaceFilesystem;
if (!filesystem) return undefined;
return {
filesystem,
runId: params.runId,
toolCallId: params.toolCallId,
...(params.persistence?.threadId ? { threadId: params.persistence.threadId } : {}),
...(params.abortSignal ? { abortSignal: params.abortSignal } : {}),
};
}
}

View File

@ -2,6 +2,12 @@ import { toJsonValue } from '@n8n/utils/json/to-json-value';
import type { AgentMessage, MessageContent } from '../../types/sdk/message';
import type { JSONObject, JSONValue } from '../../types/utils/json';
import {
storeToolResult,
type ToolResultKind,
type ToolResultStorageScope,
} from '../../workspace/tool-result-storage';
import type { WorkspaceFilesystem } from '../../workspace/types';
import { estimateObservationTokens, type TokenCounter } from '../model/model-token-counter';
export const MAX_MODEL_TOOL_RESULT_TOKENS = 50_000;
@ -16,58 +22,92 @@ interface TruncatedToolResult extends JSONObject {
tail: string;
}
interface OffloadedToolResult extends JSONObject {
_offloaded: true;
path: string;
originalCharCount: number;
estimatedTokenCount: number;
requiredAction: {
toolName: 'workspace_read_tool_result';
input: {
path: string;
view: 'describe';
};
};
message: string;
}
export interface ToolResultGuardStorage extends ToolResultStorageScope {
filesystem: WorkspaceFilesystem;
}
export interface GuardedToolResult {
historyOutput: JSONValue;
wireOutput: unknown;
truncated: boolean;
offloaded: boolean;
}
export async function guardToolResultForModel(
output: unknown,
tokenCounter: TokenCounter = estimateObservationTokens,
storage?: ToolResultGuardStorage,
kind: ToolResultKind = 'result',
): Promise<GuardedToolResult> {
const historyOutput = toJsonValue(output);
const serialized = JSON.stringify(historyOutput);
if (isClearlyWithinTokenLimit(serialized)) {
return { historyOutput, wireOutput: output, truncated: false };
return { historyOutput, wireOutput: output, truncated: false, offloaded: false };
}
const tokenCount = await tokenCounter(serialized);
if (tokenCount <= MAX_MODEL_TOOL_RESULT_TOKENS) {
return { historyOutput, wireOutput: output, truncated: false };
return { historyOutput, wireOutput: output, truncated: false, offloaded: false };
}
const offloaded = await tryOffloadResult(serialized, tokenCount, kind, storage);
if (offloaded) {
return {
historyOutput: offloaded,
wireOutput: offloaded,
truncated: false,
offloaded: true,
};
}
const truncated = await buildTruncationEnvelope(serialized, tokenCount, tokenCounter);
return { historyOutput: truncated, wireOutput: truncated, truncated: true };
return {
historyOutput: truncated,
wireOutput: truncated,
truncated: true,
offloaded: false,
};
}
export async function guardToolErrorForModel(
errorText: string,
tokenCounter: TokenCounter = estimateObservationTokens,
storage?: ToolResultGuardStorage,
): Promise<string> {
const guarded = await guardToolResultForModel(errorText, tokenCounter);
return guarded.truncated ? JSON.stringify(guarded.historyOutput) : errorText;
const guarded = await guardToolResultForModel(errorText, tokenCounter, storage, 'error');
return guarded.truncated || guarded.offloaded ? JSON.stringify(guarded.historyOutput) : errorText;
}
export async function guardToolMessageForModel(
message: AgentMessage,
tokenCounter: TokenCounter = estimateObservationTokens,
storage?: ToolResultGuardStorage,
): Promise<AgentMessage> {
if (!('content' in message)) return message;
const textBlocks = message.content.filter((block) => block.type === 'text');
if (textBlocks.length === 0) return message;
const serialized = JSON.stringify(textBlocks.map(({ text }) => text));
if (isClearlyWithinTokenLimit(serialized)) return message;
const guarded = await guardToolResultForModel(textBlocks, tokenCounter, storage, 'message');
if (!guarded.truncated && !guarded.offloaded) return message;
const tokenCount = await tokenCounter(serialized);
if (tokenCount <= MAX_MODEL_TOOL_RESULT_TOKENS) return message;
const replacement = JSON.stringify(
await buildTruncationEnvelope(serialized, tokenCount, tokenCounter),
);
const replacement = JSON.stringify(guarded.historyOutput);
let replacedText = false;
const content = message.content.flatMap((block): MessageContent[] => {
if (block.type !== 'text') return [block];
@ -80,6 +120,33 @@ export async function guardToolMessageForModel(
return { ...message, content };
}
async function tryOffloadResult(
serialized: string,
tokenCount: number,
kind: ToolResultKind,
storage?: ToolResultGuardStorage,
): Promise<OffloadedToolResult | undefined> {
if (!storage || storage.filesystem.readOnly === true) return undefined;
try {
const path = await storeToolResult(storage.filesystem, storage, kind, serialized);
return {
_offloaded: true,
path,
originalCharCount: serialized.length,
estimatedTokenCount: tokenCount,
requiredAction: {
toolName: 'workspace_read_tool_result',
input: { path, view: 'describe' },
},
message:
'The complete tool result was stored in the workspace. Follow requiredAction before answering, then keep using workspace_read_tool_result with relevant JSON Pointers and nextOffset pages until you have enough evidence. Do not assume the first page is complete when hasMore is true.',
};
} catch {
return undefined;
}
}
function isClearlyWithinTokenLimit(serialized: string): boolean {
return (
Buffer.byteLength(serialized, 'utf8') + SMALL_PAYLOAD_OVERHEAD_BYTES <=

View File

@ -9,6 +9,7 @@ describe('fetchProviderCatalog', () => {
});
it('returns provider ids that match the agents runtime', async () => {
const abortController = new AbortController();
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () =>
@ -21,6 +22,7 @@ describe('fetchProviderCatalog', () => {
id: 'gpt-5',
name: 'GPT-5',
tool_call: true,
modalities: { input: ['text', 'image'], output: ['text'] },
},
'gpt-4.1-mini': {
id: 'gpt-4.1-mini',
@ -63,9 +65,13 @@ describe('fetchProviderCatalog', () => {
});
global.fetch = fetchMock as typeof fetch;
const catalog = await fetchProviderCatalog();
const catalog = await fetchProviderCatalog({ signal: abortController.signal });
expect(catalog.openai.models['gpt-5'].toolCall).toBe(true);
expect(catalog.openai.models['gpt-5'].modalities).toEqual({
input: ['text', 'image'],
output: ['text'],
});
expect(catalog.openai.models['gpt-5']).not.toHaveProperty('reasoning');
expect(catalog.openai.models['gpt-4.1-mini'].reasoning).toBe(false);
expect(catalog['aws-bedrock'].models['anthropic.claude-sonnet-4-5-v1:0'].name).toBe(
@ -76,6 +82,63 @@ describe('fetchProviderCatalog', () => {
expect(catalog['amazon-bedrock']).toBeUndefined();
expect(catalog.azure).toBeUndefined();
expect(catalog['azure-cognitive-services']).toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('https://models.dev/api.json', {
signal: abortController.signal,
});
});
it('rejects a malformed catalog response', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => await Promise.resolve([]),
});
global.fetch = fetchMock as typeof fetch;
await expect(fetchProviderCatalog()).rejects.toThrow();
});
it('omits malformed providers and models while retaining valid entries', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () =>
await Promise.resolve({
invalidProvider: {
id: 'invalid-provider',
models: {},
},
openai: {
id: 'openai',
name: 'OpenAI',
models: {
'gpt-valid': {
id: 'gpt-valid',
name: 'GPT Valid',
tool_call: true,
},
'gpt-missing-name': {
id: 'gpt-missing-name',
},
'gpt-invalid-tool-call': {
id: 'gpt-invalid-tool-call',
name: 'GPT Invalid Tool Call',
tool_call: 'yes',
},
},
},
}),
});
global.fetch = fetchMock as typeof fetch;
const catalog = await fetchProviderCatalog();
expect(catalog.invalidProvider).toBeUndefined();
expect(catalog.openai.models).toEqual({
'gpt-valid': {
id: 'gpt-valid',
name: 'GPT Valid',
toolCall: true,
},
});
});
it('drops models marked deprecated on models.dev', async () => {

View File

@ -1104,6 +1104,9 @@ export class Agent implements BuiltAgent, AgentBuilder {
instructions,
tools: allTools.length > 0 ? allTools : undefined,
deferredTools: finalDeferredTools.length > 0 ? finalDeferredTools : undefined,
...(this.workspaceInstance?.filesystem && this.workspaceInstance.filesystem.readOnly !== true
? { workspaceFilesystem: this.workspaceInstance.filesystem }
: {}),
toolSearch,
instructionProviderOptions: this.instructionProviderOpts,
providerTools: this.providerTools.length > 0 ? this.providerTools : undefined,

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