mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-19 07:36:52 +00:00
Add configurable Daytona sandbox lifecycle behavior, deterministic sandbox names, and optional snapshot-first creation with image fallback for agent knowledge sandboxes.
This commit is contained in:
@@ -57,6 +57,10 @@ export class AgentsConfig {
|
||||
@Env('N8N_AGENTS_AI_SANDBOX_IMAGE')
|
||||
sandboxImage: string = 'daytonaio/sandbox:0.5.0';
|
||||
|
||||
/** Daytona snapshot name for agent knowledge sandboxes. Falls back to image when unavailable. */
|
||||
@Env('N8N_AGENTS_AI_SANDBOX_SNAPSHOT')
|
||||
sandboxSnapshot: string = '';
|
||||
|
||||
/** Default command timeout in the sandbox (milliseconds). */
|
||||
@Env('N8N_AGENTS_AI_SANDBOX_TIMEOUT')
|
||||
sandboxTimeout: number = 5 * Time.minutes.toMilliseconds;
|
||||
|
||||
@@ -591,6 +591,7 @@ describe('GlobalConfig', () => {
|
||||
sandboxEnabled: false,
|
||||
sandboxProvider: '',
|
||||
sandboxImage: 'daytonaio/sandbox:0.5.0',
|
||||
sandboxSnapshot: '',
|
||||
sandboxTimeout: 300000,
|
||||
sandboxEphemeral: false,
|
||||
daytonaVolumeId: '',
|
||||
@@ -618,6 +619,15 @@ describe('GlobalConfig', () => {
|
||||
expect(config.agents.sandboxEphemeral).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse N8N_AGENTS_AI_SANDBOX_SNAPSHOT from env variables', () => {
|
||||
process.env = {
|
||||
N8N_AGENTS_AI_SANDBOX_SNAPSHOT: 'n8n/agent-knowledge:1.2.3',
|
||||
};
|
||||
const config = Container.get(GlobalConfig);
|
||||
|
||||
expect(config.agents.sandboxSnapshot).toBe('n8n/agent-knowledge:1.2.3');
|
||||
});
|
||||
|
||||
it('should use values from env variables when defined', () => {
|
||||
process.env = {
|
||||
DB_POSTGRESDB_HOST: 'some-host',
|
||||
|
||||
@@ -121,6 +121,7 @@ function makeService(
|
||||
sandboxEnabled: true,
|
||||
sandboxProvider: 'daytona',
|
||||
sandboxImage: 'daytonaio/sandbox:0.5.0',
|
||||
sandboxSnapshot: '',
|
||||
sandboxTimeout: 300_000,
|
||||
sandboxEphemeral: false,
|
||||
daytonaApiUrl: 'https://daytona.example',
|
||||
@@ -209,6 +210,8 @@ describe('AgentKnowledgeSandboxService', () => {
|
||||
});
|
||||
expect(params.volumes).toEqual([expectedVolumeMount]);
|
||||
expect(params.ephemeral).toBe(false);
|
||||
expect(params.image).toBe('daytonaio/sandbox:0.5.0');
|
||||
expect(params.snapshot).toBeUndefined();
|
||||
expect(options).toEqual({ timeout: 300 });
|
||||
});
|
||||
|
||||
@@ -271,4 +274,56 @@ describe('AgentKnowledgeSandboxService', () => {
|
||||
expect(listMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a sandbox from configured snapshot', async () => {
|
||||
const service = makeService({ sandboxSnapshot: 'n8n/agent-knowledge:1.2.3' });
|
||||
const expectedName = buildExpectedSandboxName();
|
||||
|
||||
await service.withKnowledgeFilesystem(projectId, agentId, userId, async () => {});
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
const [params] = createMock.mock.calls[0];
|
||||
expect(params.snapshot).toBe('n8n/agent-knowledge:1.2.3');
|
||||
expect(params.image).toBeUndefined();
|
||||
expect(params.name).toBe(expectedName);
|
||||
expect(params.ephemeral).toBe(false);
|
||||
expect(params.autoStopInterval).toBe(5);
|
||||
expect(params.volumes).toEqual([expectedVolumeMount]);
|
||||
});
|
||||
|
||||
it('falls back to image when configured snapshot create fails', async () => {
|
||||
const logger = mock<Logger>();
|
||||
createMock
|
||||
.mockRejectedValueOnce(new Error('snapshot missing'))
|
||||
.mockResolvedValueOnce(makeSandbox('started'));
|
||||
const service = makeService({ sandboxSnapshot: 'n8n/agent-knowledge:missing' }, logger);
|
||||
|
||||
await service.withKnowledgeFilesystem(projectId, agentId, userId, async () => {});
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(2);
|
||||
const [snapshotParams] = createMock.mock.calls[0];
|
||||
const [imageParams] = createMock.mock.calls[1];
|
||||
expect(snapshotParams.snapshot).toBe('n8n/agent-knowledge:missing');
|
||||
expect(snapshotParams.image).toBeUndefined();
|
||||
expect(imageParams.image).toBe('daytonaio/sandbox:0.5.0');
|
||||
expect(imageParams.snapshot).toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Agent knowledge sandbox create from snapshot failed; falling back to image',
|
||||
expect.objectContaining({
|
||||
projectId,
|
||||
agentId,
|
||||
snapshotName: 'n8n/agent-knowledge:missing',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores whitespace-only sandboxSnapshot', async () => {
|
||||
const service = makeService({ sandboxSnapshot: ' ' });
|
||||
|
||||
await service.withKnowledgeFilesystem(projectId, agentId, userId, async () => {});
|
||||
|
||||
const [params] = createMock.mock.calls[0];
|
||||
expect(params.image).toBe('daytonaio/sandbox:0.5.0');
|
||||
expect(params.snapshot).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ interface AgentKnowledgeDaytonaConnection {
|
||||
apiUrl?: string;
|
||||
apiKey?: string;
|
||||
image: string;
|
||||
snapshot?: string;
|
||||
mode: 'direct' | 'proxy';
|
||||
}
|
||||
|
||||
@@ -224,21 +225,41 @@ export class AgentKnowledgeSandboxService {
|
||||
}
|
||||
|
||||
const image = connection.image;
|
||||
const baseCreateParams = {
|
||||
name,
|
||||
labels,
|
||||
language: 'typescript' as const,
|
||||
ephemeral: this.agentsConfig.sandboxEphemeral,
|
||||
autoStopInterval: AUTO_STOP_INTERVAL_MINUTES,
|
||||
volumes: [volumeMount],
|
||||
};
|
||||
|
||||
let sandbox: Sandbox;
|
||||
try {
|
||||
sandbox = await daytona.create(
|
||||
{
|
||||
name,
|
||||
labels,
|
||||
language: 'typescript',
|
||||
image,
|
||||
ephemeral: this.agentsConfig.sandboxEphemeral,
|
||||
autoStopInterval: AUTO_STOP_INTERVAL_MINUTES,
|
||||
volumes: [volumeMount],
|
||||
},
|
||||
{ timeout: timeoutSeconds },
|
||||
);
|
||||
if (connection.snapshot) {
|
||||
try {
|
||||
sandbox = await daytona.create(
|
||||
{ ...baseCreateParams, snapshot: connection.snapshot },
|
||||
{ timeout: timeoutSeconds },
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
'Agent knowledge sandbox create from snapshot failed; falling back to image',
|
||||
{
|
||||
projectId,
|
||||
agentId,
|
||||
snapshotName: connection.snapshot,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
);
|
||||
sandbox = await daytona.create(
|
||||
{ ...baseCreateParams, image },
|
||||
{ timeout: timeoutSeconds },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
sandbox = await daytona.create({ ...baseCreateParams, image }, { timeout: timeoutSeconds });
|
||||
}
|
||||
} catch (error) {
|
||||
if (connection.mode === 'proxy' && isVolumeMountFailure(error)) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -256,6 +277,7 @@ export class AgentKnowledgeSandboxService {
|
||||
|
||||
private async resolveDaytonaConnection(userId: string): Promise<AgentKnowledgeDaytonaConnection> {
|
||||
const directImage = this.agentsConfig.sandboxImage || DEFAULT_SANDBOX_IMAGE;
|
||||
const snapshot = this.agentsConfig.sandboxSnapshot.trim() || undefined;
|
||||
|
||||
if (!this.aiService.isProxyEnabled()) {
|
||||
return {
|
||||
@@ -263,6 +285,7 @@ export class AgentKnowledgeSandboxService {
|
||||
apiUrl: this.agentsConfig.daytonaApiUrl || undefined,
|
||||
apiKey: this.agentsConfig.daytonaApiKey || undefined,
|
||||
image: directImage,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,6 +298,7 @@ export class AgentKnowledgeSandboxService {
|
||||
apiUrl: client.getSandboxProxyBaseUrl(),
|
||||
apiKey: token.accessToken,
|
||||
image: proxyConfig.image || directImage,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user