feat(core): Use versioned prebuilt Daytona snapshots for Instance AI sandboxes (#29359)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mutasem Aldmour 2026-04-29 13:10:16 +02:00 committed by GitHub
parent ecd0ba8eba
commit 308d0b42b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 880 additions and 74 deletions

View File

@ -0,0 +1,43 @@
name: 'Release: Build Daytona snapshot'
on:
workflow_call:
inputs:
n8n_version:
description: 'n8n version to build the Daytona snapshot for'
required: true
type: string
secrets:
DAYTONA_API_KEY:
required: true
DAYTONA_API_URL:
required: false
workflow_dispatch:
inputs:
n8n_version:
description: 'n8n version to build the Daytona snapshot for (e.g. 1.123.0)'
required: true
type: string
permissions:
contents: read
jobs:
build-snapshot:
name: Build versioned Daytona snapshot
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js and build
uses: ./.github/actions/setup-nodejs
- name: Build versioned Daytona snapshot
env:
N8N_VERSION: ${{ inputs.n8n_version }}
DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
DAYTONA_API_URL: ${{ secrets.DAYTONA_API_URL }}
run: node packages/@n8n/instance-ai/scripts/build-snapshot.cjs --version "$N8N_VERSION"

View File

@ -105,6 +105,15 @@ jobs:
release_type: ${{ needs.determine-version-info.outputs.release_type }}
secrets: inherit
build-daytona-snapshot:
name: Build Daytona snapshot
needs: [determine-version-info]
if: github.event.pull_request.merged == true
uses: ./.github/workflows/release-build-daytona-snapshot.yml
with:
n8n_version: ${{ needs.determine-version-info.outputs.version }}
secrets: inherit
create-github-release:
name: Create GitHub Release
needs: [determine-version-info, publish-to-npm, publish-to-docker-hub]
@ -183,11 +192,13 @@ jobs:
create-github-release,
move-track-tag,
promote-stable-tag,
build-daytona-snapshot,
]
if: |
always() &&
needs.publish-to-npm.result == 'success' &&
needs.create-github-release.result == 'success' &&
needs.build-daytona-snapshot.result == 'success' &&
(needs.move-track-tag.result == 'success' || needs.move-track-tag.result == 'skipped') &&
(needs.promote-stable-tag.result == 'success' || needs.promote-stable-tag.result == 'skipped')
uses: ./.github/workflows/release-publish-post-release.yml

View File

@ -2,6 +2,8 @@ import { defineConfig } from 'eslint/config';
import { baseConfig } from '@n8n/eslint-config/base';
export default defineConfig(baseConfig, {
ignores: ['scripts/**/*.cjs'],
}, {
rules: {
// Mastra tool names are kebab-case identifiers (e.g. 'list-workflows')
// which require quotes in object literals — skip naming checks for those

View File

@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* Build a versioned Daytona snapshot for the running n8n version.
*
* Run from the n8n release pipeline (see
* `.github/workflows/release-build-daytona-snapshot.yml`). Authenticates
* with a static Daytona admin API key supplied via env vars and creates
* the snapshot named `n8n-instance-ai-<version>` from the same image
* descriptor used by the runtime fallback path. Re-runs against the same
* version are idempotent "already exists" is treated as success.
*
* The runtime never calls `snapshot.create` through the sandbox proxy in
* cloud mode; CI is the only producer of cloud snapshots.
*
* The actual create-with-already-exists logic lives in
* `SnapshotManager.createSnapshot` so the runtime (direct mode) and CI
* share a single implementation.
*
* CommonJS so Node resolves the package via `main: dist/index.js` instead
* of the bundler-only `module: src/index.ts` entry.
*
* Required env vars:
* DAYTONA_API_KEY admin key with snapshot.create permissions
* DAYTONA_API_URL Daytona API base URL (optional SDK default used if absent)
*
* Usage:
* node packages/@n8n/instance-ai/scripts/build-snapshot.cjs --version 1.123.0
*/
const { Daytona } = require('@daytonaio/sdk');
const { SnapshotManager } = require('@n8n/instance-ai');
function parseVersion(argv) {
const flagIdx = argv.indexOf('--version');
if (flagIdx !== -1 && argv[flagIdx + 1]) return argv[flagIdx + 1];
for (const arg of argv) {
if (arg.startsWith('--version=')) return arg.slice('--version='.length);
}
return process.env.N8N_VERSION;
}
const consoleLogger = {
info: (msg, meta) => console.log(JSON.stringify({ level: 'info', msg, ...meta })),
warn: (msg, meta) => console.warn(JSON.stringify({ level: 'warn', msg, ...meta })),
error: (msg, meta) => console.error(JSON.stringify({ level: 'error', msg, ...meta })),
debug: () => {},
};
async function main() {
const version = parseVersion(process.argv.slice(2));
if (!version) {
console.error('Missing --version (or N8N_VERSION env)');
process.exit(1);
}
const apiKey = process.env.DAYTONA_API_KEY;
if (!apiKey) {
console.error('Missing DAYTONA_API_KEY');
process.exit(1);
}
const apiUrl = process.env.DAYTONA_API_URL || undefined;
const daytona = new Daytona({ apiKey, apiUrl });
const baseImage = process.env.SANDBOX_IMAGE || undefined;
const manager = new SnapshotManager(baseImage, consoleLogger, version);
const name = await manager.createSnapshot(daytona, {
timeout: 1800,
onLogs: (chunk) => process.stdout.write(`${chunk}\n`),
});
consoleLogger.info('Snapshot ready', { name });
}
main().catch((error) => {
consoleLogger.error('Snapshot creation failed', {
error: error instanceof Error ? error.message : String(error),
});
process.exit(1);
});

View File

@ -5,3 +5,18 @@ export interface Logger {
error(message: string, metadata?: Record<string, unknown>): void;
debug(message: string, metadata?: Record<string, unknown>): void;
}
/**
* Minimal error-reporter contract structurally compatible with the n8n-core
* `ErrorReporter`. Wired to Sentry by the cli layer; defaults to a no-op when
* the package is used standalone (e.g. in CI scripts and tests).
*/
export interface ErrorReporter {
error(
error: unknown,
options?: {
tags?: Record<string, string>;
extra?: Record<string, unknown>;
},
): void;
}

View File

@ -1,47 +1,99 @@
// Tight mocks so we can drive `createN8nSandbox` end-to-end in Jest without
// touching real sandboxes, filesystems, or the Mastra runtime.
jest.mock('@mastra/core/workspace', () => {
class Workspace {
sandbox: unknown;
filesystem: unknown;
constructor(opts: { sandbox: unknown; filesystem: unknown }) {
this.sandbox = opts.sandbox;
this.filesystem = opts.filesystem;
}
async init(): Promise<void> {
await Promise.resolve();
// Mock external SDKs and other workspace modules so we can drive the factory
// end-to-end in Jest without touching real sandboxes, filesystems, or the
// Mastra runtime.
interface DaytonaCreateParams {
snapshot?: string;
image?: { dockerfile: string };
language?: string;
ephemeral?: boolean;
labels?: Record<string, string>;
}
interface DaytonaCreateOptions {
timeout?: number;
}
const daytonaCreateMock = jest.fn<
Promise<{ id: string }>,
[DaytonaCreateParams, DaytonaCreateOptions?]
>();
const daytonaDeleteMock = jest.fn<Promise<void>, [unknown]>().mockResolvedValue(undefined);
jest.mock('@daytonaio/sdk', () => {
class Daytona {
create = daytonaCreateMock;
delete = daytonaDeleteMock;
}
class DaytonaError extends Error {
statusCode?: number;
constructor(message: string, statusCode?: number) {
super(message);
this.statusCode = statusCode;
}
}
class LocalSandbox {}
class LocalFilesystem {}
return { Workspace, LocalSandbox, LocalFilesystem };
class Image {
dockerfile = 'FROM node:20';
static base() {
return new Image();
}
runCommands() {
return this;
}
}
return { Daytona, DaytonaError, Image };
});
jest.mock('@mastra/daytona', () => ({ DaytonaSandbox: class {} }));
jest.mock('@daytonaio/sdk', () => ({ Daytona: class {} }));
jest.mock('../daytona-filesystem', () => ({
DaytonaFilesystem: class {
constructor(public sandbox: unknown) {}
},
}));
jest.mock('../n8n-sandbox-filesystem', () => ({
N8nSandboxFilesystem: class {
constructor(public sandbox: unknown) {}
writeFile = jest.fn(async () => {
await Promise.resolve();
});
},
}));
jest.mock('../n8n-sandbox-image-manager', () => ({
N8nSandboxImageManager: class {
getDockerfile() {
return 'FROM node:20';
jest.mock('@mastra/core/workspace', () => {
class LocalSandbox {
constructor(public opts: unknown) {}
}
class LocalFilesystem {
constructor(public opts: unknown) {}
}
class Workspace {
sandbox: { processes?: { list: jest.Mock; kill: jest.Mock; get: jest.Mock } } & {
[k: string]: unknown;
};
filesystem: { writeFile: jest.Mock };
constructor(public opts: { sandbox: unknown; filesystem: unknown }) {
this.sandbox = {
...(opts.sandbox as Record<string, unknown>),
processes: {
list: jest.fn().mockResolvedValue([]),
kill: jest.fn().mockResolvedValue(undefined),
get: jest.fn().mockResolvedValue(undefined),
},
};
this.filesystem = { writeFile: jest.fn().mockResolvedValue(undefined) };
}
},
}));
init = jest.fn().mockResolvedValue(undefined);
}
return { LocalSandbox, LocalFilesystem, Workspace };
});
jest.mock('@mastra/daytona', () => {
class DaytonaSandbox {
constructor(public opts: unknown) {}
}
return { DaytonaSandbox };
});
jest.mock('../daytona-filesystem', () => {
class DaytonaFilesystem {
writeFile = jest.fn().mockResolvedValue(undefined);
constructor(public sandbox: unknown) {}
}
return { DaytonaFilesystem };
});
jest.mock('../n8n-sandbox-filesystem', () => {
class N8nSandboxFilesystem {
writeFile = jest.fn().mockResolvedValue(undefined);
constructor(public sandbox: unknown) {}
}
return { N8nSandboxFilesystem };
});
type MockN8nSandbox = { destroy: jest.Mock };
const capturedSandboxes: MockN8nSandbox[] = [];
@ -57,6 +109,28 @@ jest.mock('../n8n-sandbox-sandbox', () => ({
},
}));
jest.mock('../n8n-sandbox-image-manager', () => ({
N8nSandboxImageManager: class {
getDockerfile() {
return 'FROM node:20';
}
},
}));
jest.mock('../pack-workspace-sdk', () => ({
packWorkspaceSdk: jest.fn().mockResolvedValue(null),
isLinkWorkspaceSdkEnabled: jest.fn().mockReturnValue(false),
}));
jest.mock('../sandbox-setup', () => ({
formatNodeCatalogLine: jest.fn((x: { name?: string }) => x.name ?? ''),
getWorkspaceRoot: jest.fn(async () => await Promise.resolve('/home/daytona/workspace')),
setupSandboxWorkspace: jest.fn(async () => await Promise.resolve()),
PACKAGE_JSON: '{}',
TSCONFIG_JSON: '{}',
BUILD_MJS: '',
}));
jest.mock('../sandbox-fs', () => ({
runInSandbox: jest.fn(async () => await Promise.resolve({ exitCode: 0, stdout: '', stderr: '' })),
writeFileViaSandbox: jest.fn(async () => {
@ -64,28 +138,39 @@ jest.mock('../sandbox-fs', () => ({
}),
}));
jest.mock('../sandbox-setup', () => ({
getWorkspaceRoot: jest.fn(async () => await Promise.resolve('/workspace')),
formatNodeCatalogLine: jest.fn((x: { name?: string }) => x.name ?? ''),
setupSandboxWorkspace: jest.fn(async () => await Promise.resolve()),
}));
import type { Logger } from '../../logger';
import type { InstanceAiContext } from '../../types';
import { BuilderSandboxFactory } from '../builder-sandbox-factory';
import type { SandboxConfig } from '../create-workspace';
import { SnapshotManager } from '../snapshot-manager';
const { BuilderSandboxFactory } =
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
require('../builder-sandbox-factory') as typeof import('../builder-sandbox-factory');
const NOOP_LOGGER: Logger = {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
};
function makeContext(): InstanceAiContext {
return {
nodeService: {
listSearchable: jest.fn(async () => await Promise.resolve([{ name: 'node-a' }])),
listSearchable: jest.fn().mockResolvedValue([{ name: 'node-a' }]),
},
} as unknown as InstanceAiContext;
} as never;
}
function makeConfig(): SandboxConfig {
function makeDaytonaConfig(overrides: Partial<SandboxConfig> = {}): SandboxConfig {
return {
enabled: true,
provider: 'daytona',
daytonaApiKey: 'test-key',
daytonaApiUrl: 'https://api.daytona.io',
n8nVersion: '1.123.0',
...overrides,
} as SandboxConfig;
}
function makeN8nSandboxConfig(): SandboxConfig {
return {
enabled: true,
provider: 'n8n-sandbox',
@ -94,6 +179,177 @@ function makeConfig(): SandboxConfig {
} as SandboxConfig;
}
describe('BuilderSandboxFactory createDaytona snapshot branching', () => {
beforeEach(() => {
daytonaCreateMock.mockReset();
daytonaCreateMock.mockResolvedValue({ id: 'sandbox-id' });
daytonaDeleteMock.mockClear();
});
it('passes { snapshot } when ensureSnapshot returns a name', async () => {
const config = makeDaytonaConfig();
const snapshotManager = new SnapshotManager('node:20', NOOP_LOGGER, '1.123.0');
jest.spyOn(snapshotManager, 'ensureSnapshot').mockResolvedValue('n8n/instance-ai:1.123.0');
const factory = new BuilderSandboxFactory(config, snapshotManager, NOOP_LOGGER);
await factory.create('builder-1', makeContext());
expect(daytonaCreateMock).toHaveBeenCalledTimes(1);
const [params] = daytonaCreateMock.mock.calls[0];
expect(params.snapshot).toBe('n8n/instance-ai:1.123.0');
expect(params.image).toBeUndefined();
});
it('passes { image } when ensureSnapshot returns null', async () => {
const config = makeDaytonaConfig();
const snapshotManager = new SnapshotManager('node:20', NOOP_LOGGER, '1.123.0');
jest.spyOn(snapshotManager, 'ensureSnapshot').mockResolvedValue(null);
const ensureImageSpy = jest.spyOn(snapshotManager, 'ensureImage');
const factory = new BuilderSandboxFactory(config, snapshotManager, NOOP_LOGGER);
await factory.create('builder-1', makeContext());
expect(daytonaCreateMock).toHaveBeenCalledTimes(1);
const [params] = daytonaCreateMock.mock.calls[0];
expect(params.image).toBeDefined();
expect(params.snapshot).toBeUndefined();
expect(ensureImageSpy).toHaveBeenCalled();
});
it('passes mode "direct" to ensureSnapshot when getAuthToken is absent', async () => {
const config = makeDaytonaConfig();
const snapshotManager = new SnapshotManager('node:20', NOOP_LOGGER, '1.123.0');
const ensureSnapshotSpy = jest.spyOn(snapshotManager, 'ensureSnapshot').mockResolvedValue(null);
const factory = new BuilderSandboxFactory(config, snapshotManager, NOOP_LOGGER);
await factory.create('builder-1', makeContext());
expect(ensureSnapshotSpy).toHaveBeenCalledWith(expect.anything(), 'direct');
});
it('passes mode "proxy" to ensureSnapshot when getAuthToken is present', async () => {
const config = makeDaytonaConfig({
getAuthToken: jest.fn().mockResolvedValue('jwt-token'),
} as Partial<SandboxConfig>);
const snapshotManager = new SnapshotManager('node:20', NOOP_LOGGER, '1.123.0');
const ensureSnapshotSpy = jest.spyOn(snapshotManager, 'ensureSnapshot').mockResolvedValue(null);
const factory = new BuilderSandboxFactory(config, snapshotManager, NOOP_LOGGER);
await factory.create('builder-1', makeContext());
expect(ensureSnapshotSpy).toHaveBeenCalledWith(expect.anything(), 'proxy');
});
});
describe('BuilderSandboxFactory createDaytona error reporting', () => {
beforeEach(() => {
daytonaCreateMock.mockReset();
daytonaDeleteMock.mockClear();
});
function makeManager(): SnapshotManager {
const manager = new SnapshotManager('node:20', NOOP_LOGGER, '1.123.0');
jest.spyOn(manager, 'ensureSnapshot').mockResolvedValue('n8n/instance-ai:1.123.0');
jest.spyOn(manager, 'ensureImage').mockReturnValue({ dockerfile: 'FROM node:20' } as never);
return manager;
}
it('falls back to declarative image when create with snapshot fails', async () => {
const config = makeDaytonaConfig();
const snapshotManager = makeManager();
const errorReporter = { error: jest.fn() };
daytonaCreateMock
.mockRejectedValueOnce(
Object.assign(new Error('Snapshot n8n/instance-ai:1.123.0 not found'), {
statusCode: 400,
}),
)
.mockResolvedValueOnce({ id: 'sandbox-id' });
const factory = new BuilderSandboxFactory(config, snapshotManager, NOOP_LOGGER, errorReporter);
await factory.create('builder-1', makeContext());
expect(daytonaCreateMock).toHaveBeenCalledTimes(2);
expect(daytonaCreateMock.mock.calls[0][0].snapshot).toBe('n8n/instance-ai:1.123.0');
expect(daytonaCreateMock.mock.calls[1][0].image).toBeDefined();
expect(daytonaCreateMock.mock.calls[1][0].snapshot).toBeUndefined();
});
it('reports snapshot-strategy create failures to the error reporter', async () => {
const config = makeDaytonaConfig();
const snapshotManager = makeManager();
const errorReporter = { error: jest.fn() };
const error = Object.assign(new Error('Snapshot not found'), { statusCode: 400 });
daytonaCreateMock.mockRejectedValueOnce(error).mockResolvedValueOnce({ id: 'sandbox-id' });
const factory = new BuilderSandboxFactory(config, snapshotManager, NOOP_LOGGER, errorReporter);
await factory.create('builder-1', makeContext());
expect(errorReporter.error).toHaveBeenCalledWith(
error,
expect.objectContaining({
tags: expect.objectContaining({
component: 'builder-sandbox-factory',
strategy: 'snapshot',
}) as unknown,
}),
);
});
it('reports image-strategy create failures and rethrows', async () => {
const config = makeDaytonaConfig();
const snapshotManager = new SnapshotManager('node:20', NOOP_LOGGER, '1.123.0');
jest.spyOn(snapshotManager, 'ensureSnapshot').mockResolvedValue(null);
jest
.spyOn(snapshotManager, 'ensureImage')
.mockReturnValue({ dockerfile: 'FROM node:20' } as never);
const errorReporter = { error: jest.fn() };
const error = new Error('Daytona is on fire');
daytonaCreateMock.mockRejectedValue(error);
const factory = new BuilderSandboxFactory(config, snapshotManager, NOOP_LOGGER, errorReporter);
await expect(factory.create('builder-1', makeContext())).rejects.toThrow('Daytona is on fire');
expect(errorReporter.error).toHaveBeenCalledWith(
error,
expect.objectContaining({
tags: expect.objectContaining({
component: 'builder-sandbox-factory',
strategy: 'image',
}) as unknown,
}),
);
});
it('reports both strategies and rethrows when both fail', async () => {
const config = makeDaytonaConfig();
const snapshotManager = makeManager();
const errorReporter = { error: jest.fn() };
const snapshotError = Object.assign(new Error('Snapshot not found'), { statusCode: 400 });
const imageError = new Error('Image build failed');
daytonaCreateMock.mockRejectedValueOnce(snapshotError).mockRejectedValueOnce(imageError);
const factory = new BuilderSandboxFactory(config, snapshotManager, NOOP_LOGGER, errorReporter);
await expect(factory.create('builder-1', makeContext())).rejects.toThrow('Image build failed');
expect(errorReporter.error).toHaveBeenCalledTimes(2);
expect(errorReporter.error).toHaveBeenNthCalledWith(
1,
snapshotError,
expect.objectContaining({
tags: expect.objectContaining({ strategy: 'snapshot' }) as unknown,
}),
);
expect(errorReporter.error).toHaveBeenNthCalledWith(
2,
imageError,
expect.objectContaining({
tags: expect.objectContaining({ strategy: 'image' }) as unknown,
}),
);
});
});
describe('BuilderSandboxFactory.createN8nSandbox cleanup on failure', () => {
beforeEach(() => {
capturedSandboxes.length = 0;
@ -108,7 +364,7 @@ describe('BuilderSandboxFactory.createN8nSandbox cleanup on failure', () => {
const sandboxSetup = require('../sandbox-setup') as typeof import('../sandbox-setup');
(sandboxSetup.getWorkspaceRoot as jest.Mock).mockRejectedValueOnce(new Error('setup boom'));
const factory = new BuilderSandboxFactory(makeConfig(), undefined);
const factory = new BuilderSandboxFactory(makeN8nSandboxConfig(), undefined);
await expect(factory.create('b-1', makeContext())).rejects.toThrow('setup boom');
@ -121,7 +377,7 @@ describe('BuilderSandboxFactory.createN8nSandbox cleanup on failure', () => {
const sandboxSetup = require('../sandbox-setup') as typeof import('../sandbox-setup');
(sandboxSetup.getWorkspaceRoot as jest.Mock).mockRejectedValueOnce(new Error('setup boom'));
const factory = new BuilderSandboxFactory(makeConfig(), undefined);
const factory = new BuilderSandboxFactory(makeN8nSandboxConfig(), undefined);
const createPromise = factory.create('b-2', makeContext());
// Arrange: when createN8nSandbox tries to destroy after the error, that
@ -134,7 +390,7 @@ describe('BuilderSandboxFactory.createN8nSandbox cleanup on failure', () => {
});
it('returns a cleanup handle that destroys the sandbox when create succeeds', async () => {
const factory = new BuilderSandboxFactory(makeConfig(), undefined);
const factory = new BuilderSandboxFactory(makeN8nSandboxConfig(), undefined);
const bw = await factory.create('b-3', makeContext());
expect(capturedSandboxes).toHaveLength(1);

View File

@ -0,0 +1,259 @@
// Mock the Daytona SDK before importing — its source has require() paths that
// jest can't resolve in this monorepo, and we don't need the real types here.
jest.mock('@daytonaio/sdk', () => {
class DaytonaError extends Error {
statusCode?: number;
constructor(message: string, statusCode?: number) {
super(message);
this.name = 'DaytonaError';
this.statusCode = statusCode;
}
}
class DaytonaNotFoundError extends DaytonaError {
constructor(message: string, statusCode = 404) {
super(message, statusCode);
this.name = 'DaytonaNotFoundError';
}
}
class Image {
dockerfile = 'FROM node:20\nRUN echo mock';
static base() {
return new Image();
}
runCommands() {
return this;
}
}
return { DaytonaError, DaytonaNotFoundError, Image };
});
import { DaytonaError } from '@daytonaio/sdk';
import type { Logger } from '../../logger';
import { SnapshotManager } from '../snapshot-manager';
const NOOP_LOGGER: Logger = {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
};
interface CreateSnapshotParams {
name: string;
image: { dockerfile: string };
}
interface FakeSnapshotApi {
get: jest.Mock<Promise<{ name: string }>, [string]>;
create: jest.Mock<Promise<{ name: string }>, [CreateSnapshotParams, unknown?]>;
}
interface FakeDaytona {
snapshot: FakeSnapshotApi;
}
function makeFakeDaytona(): FakeDaytona {
return {
snapshot: {
get: jest.fn<Promise<{ name: string }>, [string]>(),
create: jest.fn<Promise<{ name: string }>, [CreateSnapshotParams, unknown?]>(),
},
};
}
describe('SnapshotManager.createSnapshot', () => {
it('returns the snapshot name on successful create', async () => {
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
const daytona = makeFakeDaytona();
daytona.snapshot.create.mockResolvedValue({ name: 'n8n/instance-ai:1.123.0' });
const result = await manager.createSnapshot(daytona as never);
expect(result).toBe('n8n/instance-ai:1.123.0');
expect(daytona.snapshot.create).toHaveBeenCalledTimes(1);
const callArgs = daytona.snapshot.create.mock.calls[0][0];
expect(callArgs).toEqual(expect.objectContaining({ name: 'n8n/instance-ai:1.123.0' }));
expect(callArgs.image).toBeDefined();
});
it('treats 409 conflict as success', async () => {
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
const daytona = makeFakeDaytona();
daytona.snapshot.create.mockRejectedValue(new DaytonaError('already exists', 409));
const result = await manager.createSnapshot(daytona as never);
expect(result).toBe('n8n/instance-ai:1.123.0');
});
it('treats messages mentioning "already exists" as success', async () => {
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
const daytona = makeFakeDaytona();
daytona.snapshot.create.mockRejectedValue(
new DaytonaError('Snapshot with this name already exists', 400),
);
const result = await manager.createSnapshot(daytona as never);
expect(result).toBe('n8n/instance-ai:1.123.0');
});
it('throws on transient errors', async () => {
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
const daytona = makeFakeDaytona();
daytona.snapshot.create.mockRejectedValue(new DaytonaError('upstream 500', 500));
await expect(manager.createSnapshot(daytona as never)).rejects.toThrow('upstream 500');
});
it('throws when no version is configured', async () => {
const manager = new SnapshotManager(undefined, NOOP_LOGGER, undefined);
const daytona = makeFakeDaytona();
await expect(manager.createSnapshot(daytona as never)).rejects.toThrow();
expect(daytona.snapshot.create).not.toHaveBeenCalled();
});
it('forwards options to daytona.snapshot.create', async () => {
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
const daytona = makeFakeDaytona();
daytona.snapshot.create.mockResolvedValue({ name: 'n8n/instance-ai:1.123.0' });
const onLogs = jest.fn();
await manager.createSnapshot(daytona as never, { timeout: 1800, onLogs });
expect(daytona.snapshot.create).toHaveBeenCalledWith(
expect.objectContaining({ name: 'n8n/instance-ai:1.123.0' }),
expect.objectContaining({ timeout: 1800, onLogs }),
);
});
});
describe('SnapshotManager.ensureSnapshot', () => {
describe('when no version is provided', () => {
it('returns null without calling daytona', async () => {
const manager = new SnapshotManager(undefined, NOOP_LOGGER, undefined);
const daytona = makeFakeDaytona();
const result = await manager.ensureSnapshot(daytona as never, 'direct');
expect(result).toBeNull();
expect(daytona.snapshot.get).not.toHaveBeenCalled();
expect(daytona.snapshot.create).not.toHaveBeenCalled();
});
it('returns null in proxy mode without calling daytona', async () => {
const manager = new SnapshotManager(undefined, NOOP_LOGGER, undefined);
const daytona = makeFakeDaytona();
const result = await manager.ensureSnapshot(daytona as never, 'proxy');
expect(result).toBeNull();
expect(daytona.snapshot.get).not.toHaveBeenCalled();
});
});
describe('proxy mode', () => {
it('returns the snapshot name without calling daytona', async () => {
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
const daytona = makeFakeDaytona();
const result = await manager.ensureSnapshot(daytona as never, 'proxy');
expect(result).toBe('n8n/instance-ai:1.123.0');
expect(daytona.snapshot.get).not.toHaveBeenCalled();
expect(daytona.snapshot.create).not.toHaveBeenCalled();
});
});
describe('direct mode', () => {
it('optimistically creates and returns the snapshot name', async () => {
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
const daytona = makeFakeDaytona();
daytona.snapshot.create.mockResolvedValue({ name: 'n8n/instance-ai:1.123.0' });
const result = await manager.ensureSnapshot(daytona as never, 'direct');
expect(result).toBe('n8n/instance-ai:1.123.0');
expect(daytona.snapshot.create).toHaveBeenCalledTimes(1);
expect(daytona.snapshot.get).not.toHaveBeenCalled();
});
it('treats 409 conflict as success', async () => {
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
const daytona = makeFakeDaytona();
daytona.snapshot.create.mockRejectedValue(new DaytonaError('already exists', 409));
const result = await manager.ensureSnapshot(daytona as never, 'direct');
expect(result).toBe('n8n/instance-ai:1.123.0');
});
it('returns null and clears memoization on transient errors', async () => {
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
const daytona = makeFakeDaytona();
daytona.snapshot.create
.mockRejectedValueOnce(new DaytonaError('upstream 500', 500))
.mockResolvedValueOnce({ name: 'n8n/instance-ai:1.123.0' });
const first = await manager.ensureSnapshot(daytona as never, 'direct');
const second = await manager.ensureSnapshot(daytona as never, 'direct');
expect(first).toBeNull();
expect(second).toBe('n8n/instance-ai:1.123.0');
expect(daytona.snapshot.create).toHaveBeenCalledTimes(2);
});
it('memoizes a successful create — does not call create twice', async () => {
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
const daytona = makeFakeDaytona();
daytona.snapshot.create.mockResolvedValue({ name: 'n8n/instance-ai:1.123.0' });
await manager.ensureSnapshot(daytona as never, 'direct');
const second = await manager.ensureSnapshot(daytona as never, 'direct');
expect(second).toBe('n8n/instance-ai:1.123.0');
expect(daytona.snapshot.create).toHaveBeenCalledTimes(1);
});
it('reports transient failures via the error reporter', async () => {
const errorReporter = { error: jest.fn() };
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0', errorReporter);
const daytona = makeFakeDaytona();
const error = new DaytonaError('upstream 500', 500);
daytona.snapshot.create.mockRejectedValue(error);
await manager.ensureSnapshot(daytona as never, 'direct');
expect(errorReporter.error).toHaveBeenCalledWith(
error,
expect.objectContaining({
tags: expect.objectContaining({ component: 'snapshot-manager' }) as unknown,
}),
);
});
it('does not report when create succeeds', async () => {
const errorReporter = { error: jest.fn() };
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0', errorReporter);
const daytona = makeFakeDaytona();
daytona.snapshot.create.mockResolvedValue({ name: 'n8n/instance-ai:1.123.0' });
await manager.ensureSnapshot(daytona as never, 'direct');
expect(errorReporter.error).not.toHaveBeenCalled();
});
it('does not report 409/already-exists as an error', async () => {
const errorReporter = { error: jest.fn() };
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0', errorReporter);
const daytona = makeFakeDaytona();
daytona.snapshot.create.mockRejectedValue(new DaytonaError('already exists', 409));
await manager.ensureSnapshot(daytona as never, 'direct');
expect(errorReporter.error).not.toHaveBeenCalled();
});
});
});

View File

@ -13,7 +13,7 @@ import { DaytonaSandbox } from '@mastra/daytona';
import assert from 'node:assert/strict';
import { join as posixJoin } from 'node:path/posix';
import type { Logger } from '../logger';
import type { ErrorReporter, Logger } from '../logger';
import type { SandboxConfig } from './create-workspace';
import { DaytonaFilesystem } from './daytona-filesystem';
import { N8nSandboxFilesystem } from './n8n-sandbox-filesystem';
@ -76,6 +76,7 @@ export class BuilderSandboxFactory {
private readonly config: SandboxConfig,
private readonly imageManager?: SnapshotManager,
private readonly logger: Logger = NOOP_LOGGER,
private readonly errorReporter?: ErrorReporter,
) {}
/** Cached workspace-SDK tarball promise (one pack per process). */
@ -171,22 +172,60 @@ export class BuilderSandboxFactory {
): Promise<BuilderWorkspace> {
const config = this.assertIsDaytona();
assert(this.imageManager, 'Daytona snapshot manager required');
const snapshotManager = this.imageManager;
// Get pre-warmed image (config + deps, no catalog — catalog is too large for API body)
const image = this.imageManager.ensureImage();
const mode: 'direct' | 'proxy' = config.getAuthToken ? 'proxy' : 'direct';
// Start sandbox creation AND catalog generation in parallel
// Resolve sandbox source — versioned named snapshot when available,
// fallback to declarative image otherwise. Every Daytona create
// failure is reported with a `strategy` tag so missing-snapshot bugs
// are loud and trackable in Sentry, regardless of which path
// ultimately succeeds.
const createSandboxFn = async () => {
const daytona = await this.getDaytona();
return await daytona.create(
{
image,
language: 'typescript',
ephemeral: true,
labels: { 'n8n-builder': builderId },
},
{ timeout: 300 },
);
const snapshotName = await snapshotManager.ensureSnapshot(daytona, mode);
const baseParams = {
language: 'typescript',
ephemeral: true,
labels: { 'n8n-builder': builderId },
} as const;
if (snapshotName) {
try {
return await daytona.create({ ...baseParams, snapshot: snapshotName }, { timeout: 300 });
} catch (error) {
this.errorReporter?.error(error, {
tags: {
component: 'builder-sandbox-factory',
strategy: 'snapshot',
mode,
},
extra: { snapshotName, builderId },
});
this.logger.warn('Sandbox create from snapshot failed; falling back to image', {
snapshotName,
mode,
error: error instanceof Error ? error.message : String(error),
});
}
}
try {
return await daytona.create(
{ ...baseParams, image: snapshotManager.ensureImage() },
{ timeout: 300 },
);
} catch (error) {
this.errorReporter?.error(error, {
tags: {
component: 'builder-sandbox-factory',
strategy: 'image',
mode,
},
extra: { builderId },
});
throw error;
}
};
const [sandbox, catalog] = await Promise.all([createSandboxFn(), this.getNodeCatalog(context)]);

View File

@ -22,6 +22,8 @@ interface DaytonaSandboxConfig extends SandboxConfigBase {
daytonaApiUrl?: string;
daytonaApiKey?: string;
image?: string;
/** Running n8n version, used to resolve a versioned prebuilt snapshot (`n8n-instance-ai-<version>`). */
n8nVersion?: string;
/** When provided, called before each Daytona interaction to get a fresh auth token (e.g. a short-lived JWT for proxy mode). */
getAuthToken?: () => Promise<string>;
}

View File

@ -1,30 +1,56 @@
/**
* Prepares and caches a Daytona Image descriptor with config files and
* node_modules pre-installed. The Image is declarative actual building
* happens when a sandbox is created from it.
* node_modules pre-installed, and resolves a versioned named snapshot
* (`n8n/instance-ai:<n8nVersion>`) for sandbox creation.
*
* Two strategies for `ensureSnapshot`:
* - 'direct' mode (self-hosted): optimistic create via `snapshot.create`.
* Treats a 409 / "already exists" response as success. Any other failure
* is reported (when an `errorReporter` is wired) and the manager falls
* back to declarative image so the next request retries the create.
* - 'proxy' mode (cloud): trusts CI to have published the snapshot for
* this version and returns the name without an upstream call. The
* sandbox-create request validates existence; missing-snapshot failures
* surface there with a discriminable Daytona error.
*
* The node-types catalog is NOT baked into the image (too large for API body limit).
* It's written to each sandbox after creation via the filesystem API.
*
* Exported as SnapshotManager for backward compatibility (name in index.ts/service).
*/
import { Image } from '@daytonaio/sdk';
import type { Daytona } from '@daytonaio/sdk';
import { DaytonaError, Image } from '@daytonaio/sdk';
import type { Logger } from '../logger';
import type { ErrorReporter, Logger } from '../logger';
import { PACKAGE_JSON, TSCONFIG_JSON, BUILD_MJS } from './sandbox-setup';
export type SnapshotMode = 'direct' | 'proxy';
export interface CreateSnapshotOptions {
timeout?: number;
onLogs?: (chunk: string) => void;
}
/** Base64-encode content for safe embedding in RUN commands (avoids newline/quote issues). */
function b64(s: string): string {
return Buffer.from(s, 'utf-8').toString('base64');
}
function isAlreadyExistsError(error: unknown): boolean {
if (!(error instanceof DaytonaError)) return false;
if (error.statusCode === 409) return true;
return /already exists/i.test(error.message);
}
export class SnapshotManager {
private cachedImage: Image | null = null;
private snapshotPromise: Promise<string | null> | null = null;
constructor(
private readonly baseImage: string | undefined,
private readonly logger: Logger,
private readonly n8nVersion: string | undefined,
private readonly errorReporter?: ErrorReporter,
) {}
/** Get or prepare the image descriptor. Synchronous after first call. */
@ -52,8 +78,77 @@ export class SnapshotManager {
return this.cachedImage;
}
/**
* Create the versioned Daytona snapshot for the configured n8n version.
* Treats 409 / "already exists" as success re-runs against the same
* version are idempotent. Throws on transient or unexpected errors so
* callers can decide whether to retry, fall back, or fail loudly.
*
* Single source of truth for snapshot creation across:
* - Runtime direct mode (lazy create on first builder invocation)
* - CI release pipeline (`scripts/build-snapshot.mjs`)
*/
async createSnapshot(daytona: Daytona, options?: CreateSnapshotOptions): Promise<string> {
const name = this.snapshotName();
try {
await daytona.snapshot.create({ name, image: this.ensureImage() }, options);
this.logger.info('Created versioned Daytona snapshot', { name });
return name;
} catch (error) {
if (isAlreadyExistsError(error)) {
this.logger.info('Versioned Daytona snapshot already exists', { name });
return name;
}
throw error;
}
}
/**
* Resolve the named snapshot for the running n8n version, returning the
* snapshot name if it can be used, or null if the caller should fall back
* to the declarative image.
*
* - 'proxy': returns the name without an upstream call. CI is the only
* producer of cloud snapshots; trusting the name keeps the proxy
* surface minimal (no `snapshot.get` allow-list needed). Existence is
* validated implicitly by the subsequent `daytona.create({ snapshot })`.
* - 'direct': lazy-creates the snapshot via `createSnapshot`, memoized
* per process. On transient failure, clears the memo so the next
* request retries, and reports the error.
*/
async ensureSnapshot(daytona: Daytona, mode: SnapshotMode): Promise<string | null> {
if (!this.n8nVersion) return null;
const name = this.snapshotName();
if (mode === 'proxy') return name;
this.snapshotPromise ??= this.createSnapshot(daytona).catch((error) => {
this.errorReporter?.error(error, {
tags: { component: 'snapshot-manager', operation: 'create-snapshot' },
extra: { snapshotName: name },
});
this.logger.warn('Failed to create versioned snapshot; using declarative image', {
name,
error: error instanceof Error ? error.message : String(error),
});
return null;
});
const result = await this.snapshotPromise;
if (result === null) this.snapshotPromise = null;
return result;
}
private snapshotName(): string {
if (!this.n8nVersion) {
throw new Error('SnapshotManager: n8nVersion is required to derive a snapshot name');
}
return `n8n/instance-ai:${this.n8nVersion}`;
}
/** Invalidate cached image (e.g., when base image changes). */
invalidate(): void {
this.cachedImage = null;
this.snapshotPromise = null;
}
}

View File

@ -12,6 +12,7 @@ import {
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { ErrorReporter } from 'n8n-core';
import { Time } from '@n8n/constants';
import type { InstanceAiConfig } from '@n8n/config';
import { AiBuilderTemporaryWorkflowRepository, UserRepository, type User } from '@n8n/db';
@ -229,6 +230,7 @@ export class InstanceAiService {
private readonly telemetry: Telemetry,
private readonly userRepository: UserRepository,
private readonly aiBuilderTemporaryWorkflowRepository: AiBuilderTemporaryWorkflowRepository,
private readonly errorReporter: ErrorReporter,
) {
this.logger = logger.scoped('instance-ai');
this.instanceAiConfig = globalConfig.instanceAi;
@ -293,6 +295,7 @@ export class InstanceAiService {
daytonaApiUrl: daytonaApiUrl || undefined,
daytonaApiKey: daytonaApiKey || undefined,
image: sandboxImage || undefined,
n8nVersion: N8N_VERSION || undefined,
timeout: sandboxTimeout,
};
}
@ -363,8 +366,9 @@ export class InstanceAiService {
if (config.provider === 'daytona') {
return new BuilderSandboxFactory(
config,
new SnapshotManager(config.image, this.logger),
new SnapshotManager(config.image, this.logger, config.n8nVersion, this.errorReporter),
this.logger,
this.errorReporter,
);
}