diff --git a/packages/@n8n/instance-ai/docs/configuration.md b/packages/@n8n/instance-ai/docs/configuration.md index 3137f21d561..ead60e0d748 100644 --- a/packages/@n8n/instance-ai/docs/configuration.md +++ b/packages/@n8n/instance-ai/docs/configuration.md @@ -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 diff --git a/packages/@n8n/instance-ai/docs/sandboxing.md b/packages/@n8n/instance-ai/docs/sandboxing.md index 87bfb4268df..c804085c39c 100644 --- a/packages/@n8n/instance-ai/docs/sandboxing.md +++ b/packages/@n8n/instance-ai/docs/sandboxing.md @@ -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 diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/pack-workspace-sdk.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/pack-workspace-sdk.test.ts new file mode 100644 index 00000000000..3ec85d80be6 --- /dev/null +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/pack-workspace-sdk.test.ts @@ -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 = {}) { + 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'); + }); +}); diff --git a/packages/@n8n/instance-ai/src/workspace/pack-workspace-sdk.ts b/packages/@n8n/instance-ai/src/workspace/pack-workspace-sdk.ts index 453838726c0..ff2820835de 100644 --- a/packages/@n8n/instance-ai/src/workspace/pack-workspace-sdk.ts +++ b/packages/@n8n/instance-ai/src/workspace/pack-workspace-sdk.ts @@ -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 = 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 { - 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 { 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 = SANDBOX_LINKED_WORKSPACE_PACKAGES, +): Promise { + const found: HostWorkspacePackage[] = []; + const visited = new Set(); + + async function visit(name: string, packagePath: string): Promise { + 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 diff --git a/packages/@n8n/instance-ai/src/workspace/sandbox-setup.ts b/packages/@n8n/instance-ai/src/workspace/sandbox-setup.ts index cae329679c8..42968dab3a8 100644 --- a/packages/@n8n/instance-ai/src/workspace/sandbox-setup.ts +++ b/packages/@n8n/instance-ai/src/workspace/sandbox-setup.ts @@ -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.', ); }