mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
1b294221b5
commit
7d5730f688
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user