mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
refactor(ai-builder): Replace hand-rolled sandbox client with @n8n/sandbox-client SDK (no-changelog) (#29879)
This commit is contained in:
parent
6f4f0a0303
commit
7c57843cf6
|
|
@ -42,6 +42,7 @@
|
|||
"langsmith": "catalog:",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@n8n/api-types": "workspace:*",
|
||||
"@n8n/sandbox-client": "0.0.1",
|
||||
"@n8n/utils": "workspace:*",
|
||||
"@n8n/workflow-sdk": "workspace:*",
|
||||
"linkedom": "^0.18.9",
|
||||
|
|
|
|||
|
|
@ -109,14 +109,6 @@ 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),
|
||||
|
|
|
|||
|
|
@ -1,152 +0,0 @@
|
|||
import {
|
||||
DockerfileStepsBuilder,
|
||||
N8nSandboxClient,
|
||||
N8nSandboxServiceError,
|
||||
} from '../n8n-sandbox-client';
|
||||
|
||||
function createJsonResponse(body: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
function bodyToRecord(body: unknown): Record<string, unknown> {
|
||||
if (typeof body !== 'string') {
|
||||
throw new Error('Expected request body to be a JSON string');
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(body) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error('Expected request body to be valid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
describe('N8nSandboxClient', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should send dockerfile_steps when DockerfileStepsBuilder is provided', async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValueOnce(
|
||||
createJsonResponse({
|
||||
id: 'sandbox-1',
|
||||
status: 'running',
|
||||
provider: 'n8n-sandbox',
|
||||
image_id: 'img-123',
|
||||
created_at: 1,
|
||||
last_active_at: 2,
|
||||
}),
|
||||
) as jest.MockedFunction<typeof fetch>;
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const client = new N8nSandboxClient({
|
||||
baseUrl: 'https://sandbox.example.com',
|
||||
apiKey: 'sandbox-key',
|
||||
});
|
||||
|
||||
const dockerfile = new DockerfileStepsBuilder()
|
||||
.run('apt-get update')
|
||||
.run('apt-get install -y git');
|
||||
|
||||
await client.createSandbox({ dockerfile });
|
||||
|
||||
const body = bodyToRecord(fetchMock.mock.calls[0]?.[1]?.body);
|
||||
expect(body.dockerfile_steps).toEqual(['RUN apt-get update', 'RUN apt-get install -y git']);
|
||||
});
|
||||
|
||||
it('should send no body when no options are provided', async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValueOnce(
|
||||
createJsonResponse({
|
||||
id: 'sandbox-1',
|
||||
status: 'running',
|
||||
provider: 'n8n-sandbox',
|
||||
image_id: '',
|
||||
created_at: 1,
|
||||
last_active_at: 2,
|
||||
}),
|
||||
) as jest.MockedFunction<typeof fetch>;
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const client = new N8nSandboxClient({
|
||||
baseUrl: 'https://sandbox.example.com',
|
||||
apiKey: 'sandbox-key',
|
||||
});
|
||||
|
||||
await client.createSandbox();
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should parse streamed exec output', async () => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
'{"type":"stdout","data":"hello\\n"}\n' +
|
||||
'{"type":"stderr","data":"warn\\n"}\n' +
|
||||
'{"type":"exit","exit_code":0,"success":true,"execution_time_ms":42,"timed_out":false,"killed":false}\n',
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
global.fetch = jest.fn().mockResolvedValue(
|
||||
new Response(stream, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/x-ndjson' },
|
||||
}),
|
||||
) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
const client = new N8nSandboxClient({
|
||||
baseUrl: 'https://sandbox.example.com',
|
||||
apiKey: 'sandbox-key',
|
||||
});
|
||||
|
||||
const stdoutChunks: string[] = [];
|
||||
const stderrChunks: string[] = [];
|
||||
const result = await client.exec('sandbox-1', {
|
||||
command: 'echo hello',
|
||||
onStdout: (data) => stdoutChunks.push(data),
|
||||
onStderr: (data) => stderrChunks.push(data),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
exitCode: 0,
|
||||
stdout: 'hello\n',
|
||||
stderr: 'warn\n',
|
||||
executionTimeMs: 42,
|
||||
timedOut: false,
|
||||
killed: false,
|
||||
success: true,
|
||||
});
|
||||
expect(stdoutChunks).toEqual(['hello\n']);
|
||||
expect(stderrChunks).toEqual(['warn\n']);
|
||||
});
|
||||
|
||||
it('should convert JSON error responses into service errors', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue(
|
||||
createJsonResponse(
|
||||
{
|
||||
error: 'sandbox missing',
|
||||
code: 404,
|
||||
},
|
||||
{ status: 404 },
|
||||
),
|
||||
) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
const client = new N8nSandboxClient({
|
||||
baseUrl: 'https://sandbox.example.com',
|
||||
apiKey: 'sandbox-key',
|
||||
});
|
||||
|
||||
await expect(client.getSandbox('sandbox-1')).rejects.toMatchObject(
|
||||
new N8nSandboxServiceError('sandbox missing', 404, 404),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -17,7 +17,6 @@ import type { ErrorReporter, Logger } from '../logger';
|
|||
import type { SandboxConfig } from './create-workspace';
|
||||
import { DaytonaFilesystem } from './daytona-filesystem';
|
||||
import { N8nSandboxFilesystem } from './n8n-sandbox-filesystem';
|
||||
import { N8nSandboxImageManager } from './n8n-sandbox-image-manager';
|
||||
import { N8nSandboxServiceSandbox } from './n8n-sandbox-sandbox';
|
||||
import {
|
||||
isLinkWorkspaceSdkEnabled,
|
||||
|
|
@ -70,8 +69,6 @@ async function cleanupTrackedSandboxProcesses(workspace: Workspace): Promise<voi
|
|||
export class BuilderSandboxFactory {
|
||||
private daytona: Daytona | null = null;
|
||||
|
||||
private n8nSandboxImageManager: N8nSandboxImageManager | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly config: SandboxConfig,
|
||||
private readonly imageManager?: SnapshotManager,
|
||||
|
|
@ -151,11 +148,6 @@ export class BuilderSandboxFactory {
|
|||
return this.daytona;
|
||||
}
|
||||
|
||||
private getN8nSandboxImageManager(): N8nSandboxImageManager {
|
||||
this.n8nSandboxImageManager ??= new N8nSandboxImageManager();
|
||||
return this.n8nSandboxImageManager;
|
||||
}
|
||||
|
||||
/** Cached node-types catalog string — generated once, reused across builders. */
|
||||
private catalogCache: string | null = null;
|
||||
|
||||
|
|
@ -292,14 +284,12 @@ export class BuilderSandboxFactory {
|
|||
): Promise<BuilderWorkspace> {
|
||||
const config = this.assertIsN8nSandbox();
|
||||
|
||||
const dockerfile = this.getN8nSandboxImageManager().getDockerfile();
|
||||
const catalog = await this.getNodeCatalog(context);
|
||||
|
||||
const sandbox = new N8nSandboxServiceSandbox({
|
||||
apiKey: config.apiKey,
|
||||
serviceUrl: config.serviceUrl,
|
||||
timeout: config.timeout ?? 300_000,
|
||||
dockerfile,
|
||||
});
|
||||
|
||||
const destroySandbox = async (): Promise<void> => {
|
||||
|
|
|
|||
|
|
@ -1,539 +0,0 @@
|
|||
import type { FileContent } from '@mastra/core/workspace';
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Error payload returned by the sandbox service. */
|
||||
export interface N8nSandboxServiceErrorPayload {
|
||||
error: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
export class N8nSandboxServiceError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'N8nSandboxServiceError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Sandbox metadata exposed by the service API. */
|
||||
export interface N8nSandboxRecord {
|
||||
id: string;
|
||||
status: string;
|
||||
provider: string;
|
||||
imageId: string;
|
||||
createdAt: number;
|
||||
lastActiveAt: number;
|
||||
}
|
||||
|
||||
/** Directory entry returned by the service file listing API. */
|
||||
export interface N8nSandboxFileEntry {
|
||||
name: string;
|
||||
size: number;
|
||||
isDir: boolean;
|
||||
type: 'file' | 'directory';
|
||||
modTime: string;
|
||||
}
|
||||
|
||||
/** File stat payload mapped into Mastra-friendly shape. */
|
||||
export interface N8nSandboxFileStat {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
size: number;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
/** Aggregated result of an execute-to-completion shell command. */
|
||||
export interface N8nSandboxExecResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
executionTimeMs: number;
|
||||
timedOut: boolean;
|
||||
killed: boolean;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
// ── Exec event schemas (streamed NDJSON from `/exec`) ────────────────────────
|
||||
|
||||
const execEventStdoutSchema = z.object({ type: z.literal('stdout'), data: z.string() });
|
||||
const execEventStderrSchema = z.object({ type: z.literal('stderr'), data: z.string() });
|
||||
const execEventExitSchema = z.object({
|
||||
type: z.literal('exit'),
|
||||
exit_code: z.number(),
|
||||
success: z.boolean(),
|
||||
execution_time_ms: z.number(),
|
||||
timed_out: z.boolean(),
|
||||
killed: z.boolean(),
|
||||
});
|
||||
const execEventErrorSchema = z.object({ type: z.literal('error'), error: z.string() });
|
||||
|
||||
const execEventSchema = z.discriminatedUnion('type', [
|
||||
execEventStdoutSchema,
|
||||
execEventStderrSchema,
|
||||
execEventExitSchema,
|
||||
execEventErrorSchema,
|
||||
]);
|
||||
|
||||
type ExecEvent = z.infer<typeof execEventSchema>;
|
||||
|
||||
// ── Service response schemas ─────────────────────────────────────────────────
|
||||
|
||||
const createSandboxResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
status: z.string(),
|
||||
provider: z.string(),
|
||||
image_id: z.string().optional(),
|
||||
created_at: z.number(),
|
||||
last_active_at: z.number(),
|
||||
});
|
||||
|
||||
type CreateSandboxResponse = z.infer<typeof createSandboxResponseSchema>;
|
||||
|
||||
const fileEntryResponseSchema = z.object({
|
||||
name: z.string(),
|
||||
size: z.number(),
|
||||
is_dir: z.boolean(),
|
||||
type: z.enum(['file', 'directory']),
|
||||
mod_time: z.string(),
|
||||
});
|
||||
|
||||
type FileEntryResponse = z.infer<typeof fileEntryResponseSchema>;
|
||||
|
||||
const fileStatResponseSchema = z.object({
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
type: z.enum(['file', 'directory']),
|
||||
size: z.number(),
|
||||
created_at: z.string(),
|
||||
modified_at: z.string(),
|
||||
});
|
||||
|
||||
type FileStatResponse = z.infer<typeof fileStatResponseSchema>;
|
||||
|
||||
/** Client configuration for talking to the sandbox service. */
|
||||
export interface N8nSandboxClientOptions {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
/** Fluent builder for constructing Dockerfile instructions sent at sandbox creation. */
|
||||
export class DockerfileStepsBuilder {
|
||||
private readonly steps: string[] = [];
|
||||
|
||||
/** Append one or more RUN instructions. */
|
||||
run(command: string | string[]): this {
|
||||
const commands = Array.isArray(command) ? command : [command];
|
||||
for (const cmd of commands) {
|
||||
this.steps.push(`RUN ${cmd}`);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): string[] {
|
||||
return [...this.steps];
|
||||
}
|
||||
}
|
||||
|
||||
/** Options used when creating a sandbox instance. */
|
||||
interface CreateSandboxOptions {
|
||||
dockerfile?: DockerfileStepsBuilder;
|
||||
}
|
||||
|
||||
/** Command execution request sent to `/exec`. */
|
||||
interface N8nSandboxExecRequest {
|
||||
command: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
workdir?: string;
|
||||
timeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
onStdout?: (data: string) => void;
|
||||
onStderr?: (data: string) => void;
|
||||
}
|
||||
|
||||
/** Exit metadata captured from the final `/exec` event. */
|
||||
interface ExecExitMeta {
|
||||
exitCode: number;
|
||||
executionTimeMs: number;
|
||||
timedOut: boolean;
|
||||
killed: boolean;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl?: string): string {
|
||||
return (baseUrl ?? '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function mapSandboxRecord(payload: CreateSandboxResponse): N8nSandboxRecord {
|
||||
return {
|
||||
id: payload.id,
|
||||
status: payload.status,
|
||||
provider: payload.provider,
|
||||
imageId: payload.image_id ?? '',
|
||||
createdAt: payload.created_at,
|
||||
lastActiveAt: payload.last_active_at,
|
||||
};
|
||||
}
|
||||
|
||||
function asBuffer(content: FileContent): Buffer {
|
||||
return typeof content === 'string' ? Buffer.from(content, 'utf-8') : Buffer.from(content);
|
||||
}
|
||||
|
||||
/** Yields parsed objects from an NDJSON ReadableStream, one per line. */
|
||||
async function* readNdjsonStream<T>(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
parse: (line: string) => T,
|
||||
): AsyncGenerator<T> {
|
||||
const decoder = new TextDecoder();
|
||||
let pending = '';
|
||||
|
||||
for await (const chunk of stream) {
|
||||
pending += decoder.decode(chunk, { stream: true });
|
||||
let newlineIndex = pending.indexOf('\n');
|
||||
while (newlineIndex !== -1) {
|
||||
const line = pending.slice(0, newlineIndex).trim();
|
||||
pending = pending.slice(newlineIndex + 1);
|
||||
if (line.length > 0) {
|
||||
yield parse(line);
|
||||
}
|
||||
newlineIndex = pending.indexOf('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any remaining partial line
|
||||
pending += decoder.decode();
|
||||
const last = pending.trim();
|
||||
if (last.length > 0) {
|
||||
yield parse(last);
|
||||
}
|
||||
}
|
||||
|
||||
function parseExecEvent(line: string): ExecEvent {
|
||||
try {
|
||||
const json: unknown = JSON.parse(line);
|
||||
return execEventSchema.parse(json);
|
||||
} catch {
|
||||
return { type: 'error', error: 'Invalid exec event payload' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin HTTP client for the n8n sandbox service.
|
||||
*
|
||||
* It handles sandbox lifecycle, file operations, streamed command execution,
|
||||
* and lazy image instantiation for builder prewarming.
|
||||
*/
|
||||
export class N8nSandboxClient {
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly options: N8nSandboxClientOptions) {
|
||||
this.baseUrl = normalizeBaseUrl(options.baseUrl);
|
||||
}
|
||||
|
||||
async createSandbox(options: CreateSandboxOptions = {}): Promise<N8nSandboxRecord> {
|
||||
const body: Record<string, unknown> = {};
|
||||
|
||||
const steps = options.dockerfile?.build();
|
||||
if (steps?.length) {
|
||||
body.dockerfile_steps = steps;
|
||||
}
|
||||
|
||||
return mapSandboxRecord(
|
||||
await this.requestJson<CreateSandboxResponse>('POST', '/sandboxes', {
|
||||
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getSandbox(id: string): Promise<N8nSandboxRecord> {
|
||||
return mapSandboxRecord(
|
||||
await this.requestJson<CreateSandboxResponse>('GET', `/sandboxes/${id}`),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSandbox(id: string): Promise<void> {
|
||||
await this.expectSuccess(this.request('DELETE', `/sandboxes/${id}`));
|
||||
}
|
||||
|
||||
async deleteImage(id: string): Promise<void> {
|
||||
await this.expectSuccess(this.request('DELETE', `/images/${id}`));
|
||||
}
|
||||
|
||||
async exec(id: string, request: N8nSandboxExecRequest): Promise<N8nSandboxExecResult> {
|
||||
const response = await this.request('POST', `/sandboxes/${id}/exec`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
command: request.command,
|
||||
env: request.env,
|
||||
workdir: request.workdir,
|
||||
timeout_ms: request.timeoutMs,
|
||||
}),
|
||||
signal: request.abortSignal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await this.toError(response);
|
||||
}
|
||||
|
||||
return await this.readExecResult(response, request);
|
||||
}
|
||||
|
||||
async readFile(id: string, path: string): Promise<Buffer> {
|
||||
const response = await this.request('GET', `/sandboxes/${id}/files/content`, {
|
||||
query: { path },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await this.toError(response);
|
||||
}
|
||||
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
async writeFile(id: string, path: string, content: FileContent, overwrite = true): Promise<void> {
|
||||
await this.expectSuccess(
|
||||
this.request('PUT', `/sandboxes/${id}/files`, {
|
||||
query: { path, overwrite: String(overwrite) },
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: asBuffer(content),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async appendFile(id: string, path: string, content: FileContent): Promise<void> {
|
||||
await this.expectSuccess(
|
||||
this.request('POST', `/sandboxes/${id}/files`, {
|
||||
query: { path },
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: asBuffer(content),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteFile(
|
||||
id: string,
|
||||
path: string,
|
||||
options?: { recursive?: boolean; force?: boolean },
|
||||
): Promise<void> {
|
||||
await this.expectSuccess(
|
||||
this.request('DELETE', `/sandboxes/${id}/files`, {
|
||||
query: {
|
||||
path,
|
||||
recursive: String(options?.recursive ?? false),
|
||||
force: String(options?.force ?? false),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async copyFile(
|
||||
id: string,
|
||||
request: { src: string; dest: string; recursive?: boolean; overwrite?: boolean },
|
||||
): Promise<void> {
|
||||
await this.expectSuccess(
|
||||
this.request('POST', `/sandboxes/${id}/files/copy`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
src: request.src,
|
||||
dest: request.dest,
|
||||
recursive: request.recursive ?? false,
|
||||
overwrite: request.overwrite ?? false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async moveFile(
|
||||
id: string,
|
||||
request: { src: string; dest: string; overwrite?: boolean },
|
||||
): Promise<void> {
|
||||
await this.expectSuccess(
|
||||
this.request('POST', `/sandboxes/${id}/files/move`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
src: request.src,
|
||||
dest: request.dest,
|
||||
overwrite: request.overwrite ?? false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async mkdir(id: string, path: string, recursive = false): Promise<void> {
|
||||
await this.expectSuccess(
|
||||
this.request('POST', `/sandboxes/${id}/mkdir`, {
|
||||
query: { path, recursive: String(recursive) },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async listFiles(
|
||||
id: string,
|
||||
request: { path?: string; recursive?: boolean; extension?: string } = {},
|
||||
): Promise<N8nSandboxFileEntry[]> {
|
||||
const payload = await this.requestJson<FileEntryResponse[]>('GET', `/sandboxes/${id}/files`, {
|
||||
query: {
|
||||
...(request.path ? { path: request.path } : {}),
|
||||
...(request.recursive !== undefined ? { recursive: String(request.recursive) } : {}),
|
||||
...(request.extension ? { extension: request.extension } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return payload.map((entry) => ({
|
||||
name: entry.name,
|
||||
size: entry.size,
|
||||
isDir: entry.is_dir,
|
||||
type: entry.type,
|
||||
modTime: entry.mod_time,
|
||||
}));
|
||||
}
|
||||
|
||||
async stat(id: string, path: string): Promise<N8nSandboxFileStat> {
|
||||
const payload = await this.requestJson<FileStatResponse>('GET', `/sandboxes/${id}/stat`, {
|
||||
query: { path },
|
||||
});
|
||||
|
||||
return {
|
||||
name: payload.name,
|
||||
path: payload.path,
|
||||
type: payload.type,
|
||||
size: payload.size,
|
||||
createdAt: payload.created_at,
|
||||
modifiedAt: payload.modified_at,
|
||||
};
|
||||
}
|
||||
|
||||
private async readExecResult(
|
||||
response: Response,
|
||||
request: Pick<N8nSandboxExecRequest, 'onStdout' | 'onStderr'>,
|
||||
): Promise<N8nSandboxExecResult> {
|
||||
if (!response.body) {
|
||||
throw new Error('Sandbox exec response body is not readable');
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let exitMeta: ExecExitMeta | null = null;
|
||||
|
||||
for await (const event of readNdjsonStream(response.body, parseExecEvent)) {
|
||||
switch (event.type) {
|
||||
case 'stdout':
|
||||
stdout += event.data;
|
||||
request.onStdout?.(event.data);
|
||||
break;
|
||||
case 'stderr':
|
||||
stderr += event.data;
|
||||
request.onStderr?.(event.data);
|
||||
break;
|
||||
case 'error':
|
||||
throw new Error(event.error);
|
||||
case 'exit':
|
||||
exitMeta = {
|
||||
exitCode: event.exit_code,
|
||||
executionTimeMs: event.execution_time_ms,
|
||||
timedOut: event.timed_out,
|
||||
killed: event.killed,
|
||||
success: event.success,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const finalExitMeta = this.requireExecExitMeta(exitMeta);
|
||||
return {
|
||||
exitCode: finalExitMeta.exitCode,
|
||||
stdout,
|
||||
stderr,
|
||||
executionTimeMs: finalExitMeta.executionTimeMs,
|
||||
timedOut: finalExitMeta.timedOut,
|
||||
killed: finalExitMeta.killed,
|
||||
success: finalExitMeta.success,
|
||||
};
|
||||
}
|
||||
|
||||
private requireExecExitMeta(exitMeta: ExecExitMeta | null): ExecExitMeta {
|
||||
if (!exitMeta) {
|
||||
throw new Error('Sandbox exec stream ended without an exit event');
|
||||
}
|
||||
|
||||
return exitMeta;
|
||||
}
|
||||
|
||||
private async expectSuccess(responsePromise: Promise<Response>): Promise<void> {
|
||||
const response = await responsePromise;
|
||||
if (!response.ok) {
|
||||
throw await this.toError(response);
|
||||
}
|
||||
}
|
||||
|
||||
private async requestJson<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
options: {
|
||||
body?: string | Buffer;
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const response = await this.request(method, path, options);
|
||||
if (!response.ok) {
|
||||
throw await this.toError(response);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
private async request(
|
||||
method: string,
|
||||
path: string,
|
||||
options: {
|
||||
body?: string | Buffer;
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<Response> {
|
||||
if (!this.baseUrl) {
|
||||
throw new Error('n8n sandbox service URL is not configured');
|
||||
}
|
||||
|
||||
const url = new URL(`${this.baseUrl}${path}`);
|
||||
for (const [key, value] of Object.entries(options.query ?? {})) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
const headers = new Headers(options.headers);
|
||||
if (this.options.apiKey) {
|
||||
headers.set('X-Api-Key', this.options.apiKey);
|
||||
}
|
||||
|
||||
return await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: options.body,
|
||||
signal: options.signal,
|
||||
});
|
||||
}
|
||||
|
||||
private async toError(response: Response): Promise<Error> {
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (contentType.includes('application/json')) {
|
||||
const payload = (await response.json()) as Partial<N8nSandboxServiceErrorPayload>;
|
||||
return new N8nSandboxServiceError(
|
||||
payload.error ?? `Sandbox service request failed with status ${response.status}`,
|
||||
response.status,
|
||||
payload.code,
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
return new N8nSandboxServiceError(
|
||||
text || `Sandbox service request failed with status ${response.status}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,9 +10,9 @@ import type {
|
|||
WriteOptions,
|
||||
} from '@mastra/core/workspace';
|
||||
import { MastraFilesystem } from '@mastra/core/workspace';
|
||||
import { SandboxServiceError } from '@n8n/sandbox-client';
|
||||
import { dirname } from 'node:path/posix';
|
||||
|
||||
import { N8nSandboxServiceError } from './n8n-sandbox-client';
|
||||
import type { N8nSandboxServiceSandbox } from './n8n-sandbox-sandbox';
|
||||
|
||||
function getParentDirectory(path: string): string | null {
|
||||
|
|
@ -129,7 +129,7 @@ export class N8nSandboxFilesystem extends MastraFilesystem {
|
|||
await client.stat(sandboxId, path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof N8nSandboxServiceError && error.status === 404) {
|
||||
if (error instanceof SandboxServiceError && error.status === 404) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
import { DockerfileStepsBuilder } from './n8n-sandbox-client';
|
||||
import {
|
||||
BUILD_MJS,
|
||||
N8N_SANDBOX_WORKSPACE_ROOT,
|
||||
PACKAGE_JSON,
|
||||
TSCONFIG_JSON,
|
||||
} from './sandbox-setup';
|
||||
|
||||
function b64(content: string): string {
|
||||
return Buffer.from(content, 'utf-8').toString('base64');
|
||||
}
|
||||
|
||||
const ROOT = N8N_SANDBOX_WORKSPACE_ROOT;
|
||||
|
||||
export class N8nSandboxImageManager {
|
||||
private cachedDockerfile: DockerfileStepsBuilder | null = null;
|
||||
|
||||
getDockerfile(): DockerfileStepsBuilder {
|
||||
if (this.cachedDockerfile) return this.cachedDockerfile;
|
||||
|
||||
this.cachedDockerfile = new DockerfileStepsBuilder()
|
||||
.run(`mkdir -p ${ROOT}/src ${ROOT}/chunks ${ROOT}/node-types`)
|
||||
.run(`echo '${b64(PACKAGE_JSON)}' | base64 -d > ${ROOT}/package.json`)
|
||||
.run(`echo '${b64(TSCONFIG_JSON)}' | base64 -d > ${ROOT}/tsconfig.json`)
|
||||
.run(`echo '${b64(BUILD_MJS)}' | base64 -d > ${ROOT}/build.mjs`)
|
||||
.run(`cd ${ROOT} && npm install --ignore-scripts`);
|
||||
|
||||
return this.cachedDockerfile;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,17 +5,15 @@ import type {
|
|||
ProviderStatus,
|
||||
SandboxInfo,
|
||||
} from '@mastra/core/workspace';
|
||||
import { SandboxClient } from '@n8n/sandbox-client';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { N8nSandboxClient, type DockerfileStepsBuilder } from './n8n-sandbox-client';
|
||||
|
||||
export interface N8nSandboxServiceSandboxOptions {
|
||||
id?: string;
|
||||
apiKey?: string;
|
||||
serviceUrl?: string;
|
||||
timeout?: number;
|
||||
dockerfile?: DockerfileStepsBuilder;
|
||||
}
|
||||
|
||||
function shellEscape(value: string): string {
|
||||
|
|
@ -37,13 +35,13 @@ export class N8nSandboxServiceSandbox extends MastraSandbox {
|
|||
|
||||
private readonly instanceId = `n8n-sandbox-${randomUUID()}`;
|
||||
|
||||
private readonly client: N8nSandboxClient;
|
||||
private readonly client: SandboxClient;
|
||||
|
||||
private sandboxId?: string;
|
||||
|
||||
constructor(private readonly options: N8nSandboxServiceSandboxOptions) {
|
||||
super({ name: 'N8nSandboxServiceSandbox' });
|
||||
this.client = new N8nSandboxClient({
|
||||
this.client = new SandboxClient({
|
||||
apiKey: options.apiKey,
|
||||
baseUrl: options.serviceUrl,
|
||||
});
|
||||
|
|
@ -60,9 +58,7 @@ export class N8nSandboxServiceSandbox extends MastraSandbox {
|
|||
return;
|
||||
}
|
||||
|
||||
const sandbox = await this.client.createSandbox({
|
||||
dockerfile: this.options.dockerfile,
|
||||
});
|
||||
const sandbox = await this.client.createSandbox();
|
||||
this.sandboxId = sandbox.id;
|
||||
}
|
||||
|
||||
|
|
@ -83,8 +79,6 @@ export class N8nSandboxServiceSandbox extends MastraSandbox {
|
|||
lastUsedAt: new Date(sandbox.lastActiveAt * 1000),
|
||||
metadata: {
|
||||
remoteStatus: sandbox.status,
|
||||
imageId: sandbox.imageId,
|
||||
remoteProvider: sandbox.provider,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -118,7 +112,7 @@ export class N8nSandboxServiceSandbox extends MastraSandbox {
|
|||
};
|
||||
}
|
||||
|
||||
getClient(): N8nSandboxClient {
|
||||
getClient(): SandboxClient {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1703,6 +1703,9 @@ importers:
|
|||
'@n8n/api-types':
|
||||
specifier: workspace:*
|
||||
version: link:../api-types
|
||||
'@n8n/sandbox-client':
|
||||
specifier: 0.0.1
|
||||
version: 0.0.1
|
||||
'@n8n/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../utils
|
||||
|
|
@ -8402,6 +8405,10 @@ packages:
|
|||
resolution: {integrity: sha512-qmDvJIjcNsZ6tXWy2G9yuCgMPTTn35GMA3dPpSLm7QJVpbQzYdw0ALy1bKoivXnEM3U93/OrK+/M719b+fg84Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@n8n/sandbox-client@0.0.1':
|
||||
resolution: {integrity: sha512-qvfc8/qv+rPz0nsM/r0pL/tWm5Rcry3X/d80/uxobBkWDtjZs9cFj10NBzU8+yrSs8F3dsk08FIWyVCzSt9jKA==}
|
||||
engines: {node: '>=22.16', pnpm: '>=10.22.0'}
|
||||
|
||||
'@n8n/typeorm@0.3.20-16':
|
||||
resolution: {integrity: sha512-XEfVKqbkDkLhU0tn3/zUvolDA/8u6/khDxP4pPvGKv68kPxC2h25eesszmXtfIKBWosE/8sAOOTtowA1b4jFYQ==}
|
||||
engines: {node: '>=16.13.0'}
|
||||
|
|
@ -28301,6 +28308,12 @@ snapshots:
|
|||
outvariant: 1.4.3
|
||||
strict-event-emitter: 0.5.1
|
||||
|
||||
'@n8n/sandbox-client@0.0.1':
|
||||
dependencies:
|
||||
axios: 1.16.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
'@n8n/typeorm@0.3.20-16(@sentry/node@10.36.0)(mysql2@3.17.0)(pg@8.17.0)(sqlite3@5.1.7)':
|
||||
dependencies:
|
||||
app-root-path: 3.1.0
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user