fix(ai-builder): Pack @n8n/errors when linking the workflow SDK into the sandbox (no-changelog) (#40056)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jaakko Husso 2026-10-02 08:34:28 +00:00 committed by GitHub
parent 1b294221b5
commit 7d5730f688
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 165 additions and 21 deletions

View File

@ -113,7 +113,7 @@ without search results. `research(action="fetch-url")` still works.
| `N8N_INSTANCE_AI_SANDBOX_AUTO_DELETE_MINUTES` | number | `10080` (7 days) | Minutes a stopped Daytona sandbox waits before being deleted. Negative disables auto-delete; `0` deletes on stop. Ignored when `N8N_INSTANCE_AI_SANDBOX_EPHEMERAL` is true. |
| `N8N_INSTANCE_AI_BUILDER_SANDBOX_TTL_MS` | number | `900000` | Idle TTL for the in-process thread-workspace cache. Expiry removes the cache entry but does not delete the remote sandbox. `0` disables cache eviction. |
| `N8N_INSTANCE_AI_DAYTONA_TOKEN_REFRESH_SKEW_MS` | number | `300000` | How early a Daytona token is refreshed before expiry (5 minutes). |
| `N8N_INSTANCE_AI_SANDBOX_LINK_SDK` | boolean | `false` | Local-dev only. When `1` or `true`, pack `@n8n/utils`, `n8n-workflow`, and `@n8n/workflow-sdk` from the host monorepo into each sandbox after `npm install`. Build all three packages first. Start a new AI thread after changing this because existing sandboxes keep their initialized `node_modules`. |
| `N8N_INSTANCE_AI_SANDBOX_LINK_SDK` | boolean | `false` | Local-dev only. When `1` or `true`, pack `@n8n/utils`, `@n8n/errors`, `n8n-workflow`, and `@n8n/workflow-sdk` from the host monorepo into each sandbox after `npm install`. Build all four packages first. Start a new AI thread after changing this because existing sandboxes keep their initialized `node_modules`. |
When sandbox is enabled, Instance AI writes workflow source files in the runtime
workspace and `build-workflow` runs TypeScript sources through the sandbox

View File

@ -129,9 +129,9 @@ creation because it is instance-specific and too large for the Daytona image
request.
When `N8N_INSTANCE_AI_SANDBOX_LINK_SDK` is enabled for local development,
Instance AI packs and installs the local `@n8n/utils`, `n8n-workflow`, and
`@n8n/workflow-sdk` packages. Build those packages before starting a new
thread.
Instance AI packs and installs the local `@n8n/utils`, `@n8n/errors`,
`n8n-workflow`, and `@n8n/workflow-sdk` packages. Build those packages before
starting a new thread.
## Workflow Build Path

View File

@ -0,0 +1,72 @@
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { findLinkedWorkspacePackages, SANDBOX_LINK_ROOT_PACKAGE } from '../pack-workspace-sdk';
async function writePackage(dir: string, name: string, dependencies: Record<string, string> = {}) {
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, 'package.json'), JSON.stringify({ name, dependencies }));
}
describe('findLinkedWorkspacePackages', () => {
let root: string;
beforeEach(async () => {
// The real path, because module resolution returns real paths (/var is a link on macOS).
root = await realpath(await mkdtemp(path.join(tmpdir(), 'linked-packages-')));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
it('finds the linked packages of the monorepo, also those the host package does not depend on', async () => {
const sdkPath = path.dirname(require.resolve(`${SANDBOX_LINK_ROOT_PACKAGE}/package.json`));
const found = await findLinkedWorkspacePackages(SANDBOX_LINK_ROOT_PACKAGE, sdkPath);
expect(found.map((pkg) => pkg.name).sort()).toEqual([
'@n8n/errors',
'@n8n/utils',
'@n8n/workflow-sdk',
'n8n-workflow',
]);
const names = found.map((pkg) => pkg.name);
expect(names.indexOf('@n8n/errors')).toBeLessThan(names.indexOf('n8n-workflow'));
expect(names.at(-1)).toBe('@n8n/workflow-sdk');
});
it('resolves a dependency from the package that depends on it, and skips packages that are not linked', async () => {
const rootPath = path.join(root, 'sdk');
const workflowPath = path.join(rootPath, 'node_modules', 'workflow');
await writePackage(rootPath, 'sdk', {
workflow: 'workspace:*',
constants: 'workspace:*',
lodash: '^4.0.0',
});
await writePackage(workflowPath, 'workflow', { errors: 'workspace:*' });
await writePackage(path.join(workflowPath, 'node_modules', 'errors'), 'errors');
const found = await findLinkedWorkspacePackages(
'sdk',
rootPath,
new Set(['sdk', 'workflow', 'errors']),
);
expect(found).toEqual([
{ name: 'errors', path: path.join(workflowPath, 'node_modules', 'errors') },
{ name: 'workflow', path: workflowPath },
{ name: 'sdk', path: rootPath },
]);
});
it('throws when a linked dependency cannot be resolved', async () => {
const rootPath = path.join(root, 'sdk');
await writePackage(rootPath, 'sdk', { errors: 'workspace:*' });
await expect(
findLinkedWorkspacePackages('sdk', rootPath, new Set(['sdk', 'errors'])),
).rejects.toThrow('errors (a dependency of sdk) could not be resolved');
});
});

View File

@ -11,10 +11,11 @@
* packages into tarballs on the host so the sandbox can `npm install` them
* post-creation and override the registry copies.
*
* `@n8n/workflow-sdk`, `n8n-workflow`, and `@n8n/utils` are linked together:
* packing only the SDK still leaves npm's copies of those deps in place, which
* breaks when master is ahead of the registry (e.g. unreleased exports like
* `@n8n/utils/sleep`).
* `@n8n/workflow-sdk` is linked together with the workspace packages it needs
* from master: packing only the SDK leaves npm's copies of the others in
* place, which breaks when master is ahead of the registry under the same
* version (e.g. `UserError` moving from `n8n-workflow` to `@n8n/errors`).
* Other workspace dependencies keep their npm copies, to keep the link small.
*
* We use `pnpm pack` (not `npm pack`) because the workspace `package.json`
* uses pnpm protocols (`workspace:*`, `catalog:`) that npm can't resolve;
@ -35,12 +36,26 @@ const execFileAsync = promisify(execFile);
const ENV_FLAG = 'N8N_INSTANCE_AI_SANDBOX_LINK_SDK';
/** Packages installed into the sandbox when workspace linking is enabled. */
export const SANDBOX_LINKED_WORKSPACE_PACKAGES = [
/** The package whose workspace dependencies are linked into the sandbox with it. */
export const SANDBOX_LINK_ROOT_PACKAGE = '@n8n/workflow-sdk';
/**
* Workspace packages installed into the sandbox when workspace linking is
* enabled. Add a package here when the SDK breaks against its npm copy.
*/
export const SANDBOX_LINKED_WORKSPACE_PACKAGES: ReadonlySet<string> = new Set([
'@n8n/utils',
'@n8n/errors',
'n8n-workflow',
'@n8n/workflow-sdk',
] as const;
SANDBOX_LINK_ROOT_PACKAGE,
]);
/** A workspace package found on the host. */
export interface HostWorkspacePackage {
name: string;
/** Absolute path of the package directory. */
path: string;
}
export interface WorkspacePackageTarball {
/** Raw tarball bytes, ready to upload to the sandbox. */
@ -69,8 +84,8 @@ export function isLinkWorkspaceSdkEnabled(): boolean {
export async function packWorkspacePackage(
logger: Logger,
packageName: string,
packagePath: string | null = resolvePackagePath(packageName),
): Promise<WorkspacePackageTarball | null> {
const packagePath = resolvePackagePath(packageName);
if (!packagePath) {
logger.warn(
`${ENV_FLAG} is set but ${packageName} could not be resolved on the host — skipping sandbox link`,
@ -130,12 +145,19 @@ export async function packSandboxLinkedWorkspacePackages(
): Promise<WorkspacePackageTarball[] | null> {
if (!isLinkWorkspaceSdkEnabled()) return null;
const rootPath = resolvePackagePath(SANDBOX_LINK_ROOT_PACKAGE);
if (!rootPath) {
throw new Error(
`${ENV_FLAG} is enabled, but ${SANDBOX_LINK_ROOT_PACKAGE} could not be resolved on the host. Unset ${ENV_FLAG}.`,
);
}
const packed: WorkspacePackageTarball[] = [];
for (const packageName of SANDBOX_LINKED_WORKSPACE_PACKAGES) {
const tarball = await packWorkspacePackage(logger, packageName);
for (const pkg of await findLinkedWorkspacePackages(SANDBOX_LINK_ROOT_PACKAGE, rootPath)) {
const tarball = await packWorkspacePackage(logger, pkg.name, pkg.path);
if (!tarball) {
throw new Error(
`${ENV_FLAG} is enabled, but ${packageName} could not be packed. Run \`pnpm build\` for packages/@n8n/utils, packages/workflow, and packages/@n8n/workflow-sdk, or unset ${ENV_FLAG}.`,
`${ENV_FLAG} is enabled, but ${pkg.name} could not be packed. Run \`pnpm build\` in ${pkg.path}, or unset ${ENV_FLAG}.`,
);
}
packed.push(tarball);
@ -162,15 +184,65 @@ export async function packWorkspaceSdk(
return { ...packed, sdkPath: packed.packagePath };
}
function resolvePackagePath(name: string): string | null {
/**
* The root package and the packages in `linked` that it depends on at runtime,
* directly or through other linked packages. Each dependency resolves from the
* package that depends on it, because pnpm links only direct dependencies into
* a package's `node_modules`. Dependencies come before their dependents.
*/
export async function findLinkedWorkspacePackages(
rootName: string,
rootPath: string,
linked: ReadonlySet<string> = SANDBOX_LINKED_WORKSPACE_PACKAGES,
): Promise<HostWorkspacePackage[]> {
const found: HostWorkspacePackage[] = [];
const visited = new Set<string>();
async function visit(name: string, packagePath: string): Promise<void> {
if (visited.has(name)) return;
visited.add(name);
const manifest: unknown = JSON.parse(
await readFile(path.join(packagePath, 'package.json'), 'utf8'),
);
const dependencies =
typeof manifest === 'object' && manifest !== null && 'dependencies' in manifest
? manifest.dependencies
: undefined;
if (typeof dependencies === 'object' && dependencies !== null) {
const packageRequire = createRequire(path.join(packagePath, 'package.json'));
for (const [dependency, range] of Object.entries(dependencies)) {
if (typeof range !== 'string' || !range.startsWith('workspace:')) continue;
if (!linked.has(dependency)) continue;
const dependencyPath = resolvePackagePath(dependency, packageRequire);
if (!dependencyPath) {
throw new Error(
`${ENV_FLAG} is enabled, but ${dependency} (a dependency of ${name}) could not be resolved on the host. Run \`pnpm install\`, or unset ${ENV_FLAG}.`,
);
}
await visit(dependency, dependencyPath);
}
}
found.push({ name, path: packagePath });
}
await visit(rootName, rootPath);
return found;
}
function resolvePackagePath(
name: string,
fromRequire: NodeJS.Require = hostRequire,
): string | null {
try {
return path.dirname(hostRequire.resolve(`${name}/package.json`));
return path.dirname(fromRequire.resolve(`${name}/package.json`));
} catch {
// Packages that omit a `package.json` export still live under node_modules.
for (const base of hostRequire.resolve.paths(name) ?? []) {
for (const base of fromRequire.resolve.paths(name) ?? []) {
const candidate = path.join(base, name, 'package.json');
try {
hostRequire(candidate);
fromRequire(candidate);
return path.dirname(candidate);
} catch {
// keep looking

View File

@ -248,7 +248,7 @@ export async function linkWorkspaceSdkIfEnabled(
if (!packedPackages?.length) {
linkedPackagesPromise = null;
throw new Error(
'N8N_INSTANCE_AI_SANDBOX_LINK_SDK is enabled, but workspace packages could not be packed. Run `pnpm build` in packages/@n8n/utils, packages/workflow, and packages/@n8n/workflow-sdk, or unset N8N_INSTANCE_AI_SANDBOX_LINK_SDK.',
'N8N_INSTANCE_AI_SANDBOX_LINK_SDK is enabled, but workspace packages could not be packed. Run `pnpm build` in packages/@n8n/utils, packages/@n8n/errors, packages/workflow, and packages/@n8n/workflow-sdk, or unset N8N_INSTANCE_AI_SANDBOX_LINK_SDK.',
);
}