mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
Merge branch 'master' into devp-646-cross-module-import-lint
Some checks failed
CI: Python / Checks (push) Has been cancelled
Some checks failed
CI: Python / Checks (push) Has been cancelled
This commit is contained in:
commit
67e35eb088
|
|
@ -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
|
||||
|
|
|
|||
215
.devcontainer/codespaces/README.md
Normal file
215
.devcontainer/codespaces/README.md
Normal 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.
|
||||
154
.devcontainer/codespaces/agent-worker.mjs
Normal file
154
.devcontainer/codespaces/agent-worker.mjs
Normal 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);
|
||||
}
|
||||
9
.devcontainer/codespaces/codespaces-env.sh
Normal file
9
.devcontainer/codespaces/codespaces-env.sh
Normal 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
|
||||
26
.devcontainer/codespaces/codespaces-secrets.sh
Normal file
26
.devcontainer/codespaces/codespaces-secrets.sh
Normal 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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
7
.devcontainer/codespaces/gh-shim.sh
Normal file
7
.devcontainer/codespaces/gh-shim.sh
Normal 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 "$@"
|
||||
6
.devcontainer/codespaces/gitcredential-refresh.sh
Normal file
6
.devcontainer/codespaces/gitcredential-refresh.sh
Normal 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 "$@"
|
||||
34
.devcontainer/codespaces/post-start.mjs
Normal file
34
.devcontainer/codespaces/post-start.mjs
Normal 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"',
|
||||
]);
|
||||
4
.devcontainer/codespaces/tmux.conf
Normal file
4
.devcontainer/codespaces/tmux.conf
Normal 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"
|
||||
|
|
@ -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
1
.gitattributes
vendored
|
|
@ -1 +1,2 @@
|
|||
*.sh text eol=lf
|
||||
docs/generated/**/* linguist-generated
|
||||
|
|
|
|||
52
.github/DEVELOPING_V3.md
vendored
52
.github/DEVELOPING_V3.md
vendored
|
|
@ -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
45
.github/WORKFLOWS.md
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
.github/actionlint.yml
vendored
2
.github/actionlint.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
119
.github/claude-templates/e2e-test.md
vendored
119
.github/claude-templates/e2e-test.md
vendored
|
|
@ -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
|
||||
179
.github/claude-templates/security-fix.md
vendored
179
.github/claude-templates/security-fix.md
vendored
|
|
@ -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
|
||||
```
|
||||
6
.github/docker-compose.yml
vendored
6
.github/docker-compose.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
||||
59
.github/scripts/claude-task/resume-callback.mjs
vendored
59
.github/scripts/claude-task/resume-callback.mjs
vendored
|
|
@ -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
95
.github/scripts/db-test-matrix.mjs
vendored
Normal 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
128
.github/scripts/db-test-matrix.test.mjs
vendored
Normal 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/);
|
||||
});
|
||||
});
|
||||
44
.github/scripts/sync-conflict-owners.mjs
vendored
44
.github/scripts/sync-conflict-owners.mjs
vendored
|
|
@ -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:`,
|
||||
|
|
|
|||
45
.github/scripts/sync-conflict-owners.test.mjs
vendored
45
.github/scripts/sync-conflict-owners.test.mjs
vendored
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
526
.github/scripts/sync-master-to-3x.mjs
vendored
526
.github/scripts/sync-master-to-3x.mjs
vendored
|
|
@ -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.`);
|
||||
|
|
|
|||
853
.github/scripts/sync-master-to-3x.test.mjs
vendored
853
.github/scripts/sync-master-to-3x.test.mjs
vendored
|
|
@ -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>/);
|
||||
});
|
||||
|
|
|
|||
2
.github/test-metrics/e2e-impact-map.json
vendored
2
.github/test-metrics/e2e-impact-map.json
vendored
File diff suppressed because one or more lines are too long
135
.github/workflows/build-node-pc-image.yml
vendored
Normal file
135
.github/workflows/build-node-pc-image.yml
vendored
Normal 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
|
||||
155
.github/workflows/build-v3-nightly.yml
vendored
155
.github/workflows/build-v3-nightly.yml
vendored
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
3
.github/workflows/ci-master.yml
vendored
3
.github/workflows/ci-master.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
19
.github/workflows/ci-pull-requests.yml
vendored
19
.github/workflows/ci-pull-requests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
22
.github/workflows/docker-build-push.yml
vendored
22
.github/workflows/docker-build-push.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
26
.github/workflows/test-db-reusable.yml
vendored
26
.github/workflows/test-db-reusable.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
245
.github/workflows/test-evals-instance-ai.yml
vendored
245
.github/workflows/test-evals-instance-ai.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
4
.github/workflows/test-unit-reusable.yml
vendored
4
.github/workflows/test-unit-reusable.yml
vendored
|
|
@ -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() }}
|
||||
|
|
|
|||
219
.github/workflows/util-claude-task.yml
vendored
219
.github/workflows/util-claude-task.yml
vendored
|
|
@ -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
|
||||
6
.github/workflows/util-data-tooling.yml
vendored
6
.github/workflows/util-data-tooling.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
27
.github/workflows/util-sync-master-to-3x.yml
vendored
27
.github/workflows/util-sync-master-to-3x.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
120
CHANGELOG.md
120
CHANGELOG.md
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
|
|
|
|||
130
docker/images/node-pc/Dockerfile
Normal file
130
docker/images/node-pc/Dockerfile
Normal 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
|
||||
45
docs/generated/postgres-schema/README.md
generated
45
docs/generated/postgres-schema/README.md
generated
|
|
@ -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
|
||||
|
|
|
|||
76
docs/generated/postgres-schema/public.poller_state.md
generated
Normal file
76
docs/generated/postgres-schema/public.poller_state.md
generated
Normal 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)
|
||||
24
docs/generated/postgres-schema/public.user.md
generated
24
docs/generated/postgres-schema/public.user.md
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
125
docs/generated/postgres-schema/public.workflow_review_activity.md
generated
Normal file
125
docs/generated/postgres-schema/public.workflow_review_activity.md
generated
Normal 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)
|
||||
83
docs/generated/postgres-schema/public.workflow_review_activity_comment.md
generated
Normal file
83
docs/generated/postgres-schema/public.workflow_review_activity_comment.md
generated
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
49
docs/generated/sqlite-schema/README.md
generated
49
docs/generated/sqlite-schema/README.md
generated
|
|
@ -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
|
||||
|
|
|
|||
83
docs/generated/sqlite-schema/poller_state.md
generated
Normal file
83
docs/generated/sqlite-schema/poller_state.md
generated
Normal 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)
|
||||
24
docs/generated/sqlite-schema/user.md
generated
24
docs/generated/sqlite-schema/user.md
generated
|
|
@ -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
|
||||
|
|
|
|||
24
docs/generated/sqlite-schema/workflow_entity.md
generated
24
docs/generated/sqlite-schema/workflow_entity.md
generated
|
|
@ -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
|
||||
|
|
|
|||
2
docs/generated/sqlite-schema/workflow_history.md
generated
2
docs/generated/sqlite-schema/workflow_history.md
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
130
docs/generated/sqlite-schema/workflow_review_activity.md
generated
Normal file
130
docs/generated/sqlite-schema/workflow_review_activity.md
generated
Normal 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)
|
||||
90
docs/generated/sqlite-schema/workflow_review_activity_comment.md
generated
Normal file
90
docs/generated/sqlite-schema/workflow_review_activity_comment.md
generated
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
22
package.json
22
package.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"/);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
113
packages/@n8n/agents/src/runtime/streaming/stream-stall.ts
Normal file
113
packages/@n8n/agents/src/runtime/streaming/stream-stall.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <=
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue
Block a user