mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
perf(ai-builder): Trust a matching prebaked bundle manifest in the sandbox (no-changelog) (#40820)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f6861fe494
commit
2bc3265e63
|
|
@ -128,6 +128,13 @@ and installed dependencies. The node-type catalog is written after sandbox
|
|||
creation because it is instance-specific and too large for the Daytona image
|
||||
request.
|
||||
|
||||
The knowledge base and runtime skills each have a manifest with a content
|
||||
hash. Setup compares the manifest hash with the expected hash. When the
|
||||
hashes do not match, setup writes the bundle again and writes the manifest
|
||||
last. A matching manifest is trusted, and setup reads no other bundle file.
|
||||
Each cached sandbox entry keeps the result, so later runs do not read the
|
||||
manifest again.
|
||||
|
||||
When `N8N_INSTANCE_AI_SANDBOX_LINK_SDK` is enabled for local development,
|
||||
Instance AI packs and installs the local `@n8n/utils`, `@n8n/errors`,
|
||||
`n8n-workflow`, and `@n8n/workflow-sdk` packages. Build those packages before
|
||||
|
|
|
|||
|
|
@ -534,6 +534,7 @@ export const createSandbox: typeof CreateWorkspaceMod.createSandbox = lazyFuncti
|
|||
export const createWorkspace: typeof CreateWorkspaceMod.createWorkspace = lazyFunction(
|
||||
() => loadCreateWorkspace().createWorkspace,
|
||||
);
|
||||
export type { VerifiedWorkspaceBundles } from './workspace/prebaked-workspace-bundle';
|
||||
export type SnapshotManager = SnapshotManagerMod.SnapshotManager;
|
||||
export const SnapshotManager: typeof SnapshotManagerMod.SnapshotManager = lazyClass(
|
||||
() => loadSnapshotManager().SnapshotManager,
|
||||
|
|
|
|||
|
|
@ -299,7 +299,7 @@ describe('materializeKnowledgeBaseIntoWorkspace', () => {
|
|||
expect(writes.size).toBe(0);
|
||||
});
|
||||
|
||||
it('rematerializes when prebaked manifest exists but payload is incomplete', async () => {
|
||||
it('trusts a matching prebaked manifest without checking each payload file', async () => {
|
||||
const bundle = await buildKnowledgeBaseWorkspaceBundle({ root: ROOT, logger: mockLogger });
|
||||
const files = new Map<string, string>([
|
||||
[bundle.manifestPath, bundle.files.get(bundle.manifestPath) ?? ''],
|
||||
|
|
@ -313,7 +313,6 @@ describe('materializeKnowledgeBaseIntoWorkspace', () => {
|
|||
});
|
||||
|
||||
expect(materialized.contentHash).toBe(bundle.contentHash);
|
||||
expect(writes.has(bundle.indexPath)).toBe(true);
|
||||
expect(writes.size).toBeGreaterThan(1);
|
||||
expect(writes.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { computeWorkspaceContentHash } from '../workspace/compute-workspace-cont
|
|||
import {
|
||||
loadPrebakedWorkspaceBundle,
|
||||
materializeWorkspaceBundle,
|
||||
type VerifiedWorkspaceBundles,
|
||||
} from '../workspace/prebaked-workspace-bundle';
|
||||
import type { SandboxWorkspace } from '../workspace/sandbox-fs';
|
||||
import { stringifyWorkspaceJson, withTrailingNewline } from '../workspace/workspace-file-content';
|
||||
|
|
@ -100,6 +101,7 @@ export interface BuildKnowledgeBaseWorkspaceBundleOptions {
|
|||
|
||||
interface MaterializeKnowledgeBaseOptions extends BuildKnowledgeBaseWorkspaceBundleOptions {
|
||||
workspace: SandboxWorkspace;
|
||||
verifiedBundles?: VerifiedWorkspaceBundles;
|
||||
}
|
||||
|
||||
function addTemplatesToKnowledgeBaseFiles(
|
||||
|
|
@ -307,6 +309,7 @@ export async function loadPrebakedKnowledgeBaseBundle(
|
|||
|
||||
return await loadPrebakedWorkspaceBundle({
|
||||
workspace: options.workspace,
|
||||
verifiedBundles: options.verifiedBundles,
|
||||
manifestPath: bundle.manifestPath,
|
||||
expectedHash: bundle.contentHash,
|
||||
hashField: 'contentHash',
|
||||
|
|
@ -343,10 +346,12 @@ export async function materializeKnowledgeBaseIntoWorkspace(
|
|||
async () => {
|
||||
return await materializeWorkspaceBundle({
|
||||
workspace: options.workspace,
|
||||
verifiedBundles: options.verifiedBundles,
|
||||
resourceLabel: KNOWLEDGE_BASE_FILE_LABEL,
|
||||
logger: options.logger,
|
||||
loadPrebaked: async () => await loadPrebakedKnowledgeBaseBundle(options),
|
||||
buildBundle: async () => await buildKnowledgeBaseWorkspaceBundle(options),
|
||||
bundleHash: (bundle) => bundle.contentHash,
|
||||
materializedLogMessage: 'Materialized knowledge base into workspace',
|
||||
materializedLogContext: (bundle) => ({
|
||||
root: options.root,
|
||||
|
|
|
|||
|
|
@ -319,6 +319,42 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => {
|
|||
expect(text).toContain(skillPath);
|
||||
});
|
||||
|
||||
it('checks a prebaked bundle once per sandbox and reads only the manifest', async () => {
|
||||
const source = loadInstanceAiRuntimeSkillSource();
|
||||
const { workspace, writes, readFile, writeFile } = createMockWorkspace();
|
||||
const root = '/home/daytona/workspace';
|
||||
const bundle = await buildRuntimeSkillWorkspaceBundle({ source, root, logger: mockLogger });
|
||||
if (!bundle) throw new Error('Expected runtime skill bundle');
|
||||
for (const [path, content] of bundle.files) {
|
||||
writes.set(path, content);
|
||||
}
|
||||
const verifiedBundles = new Map<string, string>();
|
||||
const runSkillLoad = async () => {
|
||||
const runtimeSource = createLazyWorkspaceRuntimeSkillSource({
|
||||
logger: mockLogger,
|
||||
source,
|
||||
workspace,
|
||||
resolveVerifiedBundles: async () => await Promise.resolve(verifiedBundles),
|
||||
});
|
||||
return await createSkillLoadTool(runtimeSource).handler?.(
|
||||
{ skillId: 'data-table-manager' },
|
||||
{},
|
||||
);
|
||||
};
|
||||
|
||||
await runSkillLoad();
|
||||
|
||||
expect(readFile).toHaveBeenCalledTimes(1);
|
||||
expect(readFile).toHaveBeenCalledWith(bundle.manifestPath, expect.anything());
|
||||
|
||||
readFile.mockClear();
|
||||
const result = await runSkillLoad();
|
||||
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
expect(skillLoadText(result)).toContain('[Skill: "data-table-manager"]');
|
||||
});
|
||||
|
||||
it('falls back to live materialization when the prebaked manifest is stale', async () => {
|
||||
const source = loadInstanceAiRuntimeSkillSource();
|
||||
const { workspace, writes, writeFile } = createMockWorkspace();
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { traceSandboxOperation } from '../tracing/sandbox-tracing';
|
|||
import {
|
||||
loadPrebakedWorkspaceBundle,
|
||||
materializeWorkspaceBundle,
|
||||
type VerifiedWorkspaceBundles,
|
||||
} from '../workspace/prebaked-workspace-bundle';
|
||||
import { stringifyWorkspaceJson, withTrailingNewline } from '../workspace/workspace-file-content';
|
||||
import { WORKSPACE_MANIFEST_FILE } from '../workspace/workspace-manifest';
|
||||
|
|
@ -70,6 +71,7 @@ interface BuildRuntimeSkillWorkspaceBundleOptions {
|
|||
interface MaterializeRuntimeSkillsOptions {
|
||||
source: RuntimeSkillSource;
|
||||
workspace: Workspace;
|
||||
verifiedBundles?: VerifiedWorkspaceBundles;
|
||||
root: string;
|
||||
workspaceRoot?: string;
|
||||
logger: Logger;
|
||||
|
|
@ -78,6 +80,8 @@ interface MaterializeRuntimeSkillsOptions {
|
|||
interface LazyWorkspaceRuntimeSkillSourceOptions {
|
||||
source: RuntimeSkillSource;
|
||||
workspace: Workspace | undefined;
|
||||
/** Resolves the verified bundles of the sandbox behind `workspace`. */
|
||||
resolveVerifiedBundles?: () => Promise<VerifiedWorkspaceBundles | undefined>;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
|
|
@ -387,6 +391,7 @@ const RUNTIME_SKILL_FILE_LABEL = 'Runtime skill file';
|
|||
export async function loadPrebakedRuntimeSkillsBundle({
|
||||
source,
|
||||
workspace,
|
||||
verifiedBundles,
|
||||
root,
|
||||
workspaceRoot = root,
|
||||
logger,
|
||||
|
|
@ -398,6 +403,7 @@ export async function loadPrebakedRuntimeSkillsBundle({
|
|||
|
||||
return await loadPrebakedWorkspaceBundle({
|
||||
workspace,
|
||||
verifiedBundles,
|
||||
manifestPath,
|
||||
expectedHash: source.registry.skillsHash,
|
||||
hashField: 'skillsHash',
|
||||
|
|
@ -592,6 +598,7 @@ export async function buildRuntimeSkillWorkspaceBundle({
|
|||
export async function materializeRuntimeSkillsIntoWorkspace({
|
||||
source,
|
||||
workspace,
|
||||
verifiedBundles,
|
||||
root,
|
||||
logger,
|
||||
}: MaterializeRuntimeSkillsOptions): Promise<MaterializedRuntimeSkills | undefined> {
|
||||
|
|
@ -608,10 +615,17 @@ export async function materializeRuntimeSkillsIntoWorkspace({
|
|||
|
||||
return await materializeWorkspaceBundle({
|
||||
workspace,
|
||||
verifiedBundles,
|
||||
resourceLabel: RUNTIME_SKILL_FILE_LABEL,
|
||||
logger,
|
||||
loadPrebaked: async () =>
|
||||
await loadPrebakedRuntimeSkillsBundle({ source, workspace, root, logger }),
|
||||
await loadPrebakedRuntimeSkillsBundle({
|
||||
source,
|
||||
workspace,
|
||||
verifiedBundles,
|
||||
root,
|
||||
logger,
|
||||
}),
|
||||
buildBundle: async () => {
|
||||
const bundle = await buildRuntimeSkillWorkspaceBundle({ source, root, logger });
|
||||
if (!bundle) {
|
||||
|
|
@ -619,6 +633,7 @@ export async function materializeRuntimeSkillsIntoWorkspace({
|
|||
}
|
||||
return bundle;
|
||||
},
|
||||
bundleHash: (bundle) => bundle.skillsHash,
|
||||
materializedLogMessage: 'Materialized runtime skills into workspace',
|
||||
materializedLogContext: (bundle) => ({
|
||||
root,
|
||||
|
|
@ -635,6 +650,7 @@ export async function materializeRuntimeSkillsIntoWorkspace({
|
|||
export function createLazyWorkspaceRuntimeSkillSource({
|
||||
source,
|
||||
workspace,
|
||||
resolveVerifiedBundles,
|
||||
logger,
|
||||
}: LazyWorkspaceRuntimeSkillSourceOptions): RuntimeSkillSource {
|
||||
if (!workspace || source.registry.skills.length === 0) return source;
|
||||
|
|
@ -664,7 +680,8 @@ export function createLazyWorkspaceRuntimeSkillSource({
|
|||
|
||||
materializePromise ??= (async () => {
|
||||
const root = await getWorkspaceRoot(runtimeWorkspace);
|
||||
const options = { source, workspace: runtimeWorkspace, root, logger };
|
||||
const verifiedBundles = await resolveVerifiedBundles?.();
|
||||
const options = { source, workspace: runtimeWorkspace, verifiedBundles, root, logger };
|
||||
const result = await materializeRuntimeSkillsIntoWorkspace(options);
|
||||
if (result) {
|
||||
materialized = result;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
loadPrebakedWorkspaceBundle,
|
||||
materializeWorkspaceBundle,
|
||||
type VerifiedWorkspaceBundles,
|
||||
} from '../prebaked-workspace-bundle';
|
||||
import type { SandboxWorkspace } from '../sandbox-fs';
|
||||
import { stringifyWorkspaceJson } from '../workspace-file-content';
|
||||
|
|
@ -9,38 +10,35 @@ const ROOT = '/home/daytona/workspace';
|
|||
|
||||
const mockLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never;
|
||||
|
||||
function createSandboxWorkspace(files: Map<string, string>): {
|
||||
workspace: SandboxWorkspace;
|
||||
writes: Map<string, string>;
|
||||
} {
|
||||
function createSandboxWorkspace(files: Map<string, string>) {
|
||||
const writes = new Map<string, string>();
|
||||
const writeFile = vi.fn(async (path: string, content: string | Buffer) => {
|
||||
writes.set(path, Buffer.isBuffer(content) ? content.toString('utf-8') : content);
|
||||
await Promise.resolve();
|
||||
});
|
||||
const executeCommand = vi.fn(async (command: string) => {
|
||||
const readMatch = /^cat '([^']+)' 2>\/dev\/null$/.exec(command);
|
||||
if (readMatch) {
|
||||
const content = files.get(readMatch[1]);
|
||||
return await Promise.resolve(
|
||||
content === undefined
|
||||
? { exitCode: 1, stdout: '', stderr: 'missing' }
|
||||
: { exitCode: 0, stdout: content, stderr: '' },
|
||||
);
|
||||
}
|
||||
|
||||
return await Promise.resolve({ exitCode: 0, stdout: '', stderr: '' });
|
||||
});
|
||||
const workspace: SandboxWorkspace = {
|
||||
filesystem: {
|
||||
provider: 'local',
|
||||
writeFile: vi.fn(async (path: string, content: string | Buffer) => {
|
||||
writes.set(path, Buffer.isBuffer(content) ? content.toString('utf-8') : content);
|
||||
await Promise.resolve();
|
||||
}),
|
||||
writeFile,
|
||||
mkdir: vi.fn(async () => await Promise.resolve()),
|
||||
},
|
||||
sandbox: {
|
||||
executeCommand: vi.fn(async (command: string) => {
|
||||
const readMatch = /^cat '([^']+)' 2>\/dev\/null$/.exec(command);
|
||||
if (readMatch) {
|
||||
const content = files.get(readMatch[1]);
|
||||
return await Promise.resolve(
|
||||
content === undefined
|
||||
? { exitCode: 1, stdout: '', stderr: 'missing' }
|
||||
: { exitCode: 0, stdout: content, stderr: '' },
|
||||
);
|
||||
}
|
||||
|
||||
return await Promise.resolve({ exitCode: 0, stdout: '', stderr: '' });
|
||||
}),
|
||||
},
|
||||
sandbox: { executeCommand },
|
||||
};
|
||||
|
||||
return { workspace, writes };
|
||||
return { workspace, writes, writeFile, executeCommand };
|
||||
}
|
||||
|
||||
describe('loadPrebakedWorkspaceBundle', () => {
|
||||
|
|
@ -106,8 +104,8 @@ describe('loadPrebakedWorkspaceBundle', () => {
|
|||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the manifest hash matches but a payload file is missing', async () => {
|
||||
const { workspace } = createSandboxWorkspace(
|
||||
it('trusts a matching manifest without a read of each payload file', async () => {
|
||||
const { workspace, executeCommand } = createSandboxWorkspace(
|
||||
new Map([
|
||||
[
|
||||
manifestPath,
|
||||
|
|
@ -132,7 +130,8 @@ describe('loadPrebakedWorkspaceBundle', () => {
|
|||
buildBundle: () => bundle,
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(result).toBe(bundle);
|
||||
expect(executeCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -158,6 +157,7 @@ describe('materializeWorkspaceBundle', () => {
|
|||
resourceLabel: 'Test bundle file',
|
||||
loadPrebaked: async () => await Promise.resolve(undefined),
|
||||
buildBundle: () => bundle,
|
||||
bundleHash: (built) => built.contentHash,
|
||||
materializedLogMessage: 'materialized',
|
||||
materializedLogContext: () => ({ root: ROOT }),
|
||||
});
|
||||
|
|
@ -187,6 +187,7 @@ describe('materializeWorkspaceBundle', () => {
|
|||
resourceLabel: 'Test bundle file',
|
||||
loadPrebaked: async () => await Promise.resolve(undefined),
|
||||
buildBundle: () => bundle,
|
||||
bundleHash: (built) => built.contentHash,
|
||||
materializedLogMessage: 'materialized',
|
||||
materializedLogContext: () => ({ root: ROOT }),
|
||||
});
|
||||
|
|
@ -223,6 +224,7 @@ describe('materializeWorkspaceBundle', () => {
|
|||
buildBundle: () => bundle,
|
||||
}),
|
||||
buildBundle: () => bundle,
|
||||
bundleHash: (built) => built.contentHash,
|
||||
materializedLogMessage: 'materialized',
|
||||
materializedLogContext: () => ({ root: ROOT }),
|
||||
});
|
||||
|
|
@ -231,3 +233,131 @@ describe('materializeWorkspaceBundle', () => {
|
|||
expect(writes.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prebaked bundle checks with verified bundles', () => {
|
||||
const manifestPath = `${ROOT}/bundle/.manifest.json`;
|
||||
const filePaths = [`${ROOT}/bundle/a.txt`, `${ROOT}/bundle/b.txt`];
|
||||
const manifest = stringifyWorkspaceJson({ schemaVersion: 1, contentHash: 'abc123' });
|
||||
const bundle = {
|
||||
rootDir: `${ROOT}/bundle`,
|
||||
manifestPath,
|
||||
files: new Map([
|
||||
...filePaths.map((path): [string, string] => [path, 'content\n']),
|
||||
[manifestPath, manifest],
|
||||
]),
|
||||
contentHash: 'abc123',
|
||||
};
|
||||
|
||||
function readPaths(executeCommand: ReturnType<typeof createSandboxWorkspace>['executeCommand']) {
|
||||
return executeCommand.mock.calls.flatMap(([command]) => {
|
||||
const match = /^cat '([^']+)' 2>\/dev\/null$/.exec(command);
|
||||
return match ? [match[1]] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async function materialize(
|
||||
workspace: SandboxWorkspace,
|
||||
verifiedBundles: VerifiedWorkspaceBundles,
|
||||
) {
|
||||
return await materializeWorkspaceBundle({
|
||||
logger: mockLogger,
|
||||
workspace,
|
||||
verifiedBundles,
|
||||
resourceLabel: 'Test bundle file',
|
||||
loadPrebaked: async () =>
|
||||
await loadPrebakedWorkspaceBundle({
|
||||
logger: mockLogger,
|
||||
workspace,
|
||||
verifiedBundles,
|
||||
manifestPath,
|
||||
expectedHash: bundle.contentHash,
|
||||
hashField: 'contentHash',
|
||||
schemaVersion: 1,
|
||||
resourceLabel: 'Test bundle file',
|
||||
invalidManifestLogMessage: 'invalid',
|
||||
staleManifestLogMessage: 'stale',
|
||||
staleManifestLogKeys: { expected: 'expectedHash', actual: 'actualHash' },
|
||||
successLogMessage: 'success',
|
||||
successLogContext: () => ({ root: ROOT }),
|
||||
buildBundle: () => bundle,
|
||||
}),
|
||||
buildBundle: () => bundle,
|
||||
bundleHash: (built) => built.contentHash,
|
||||
materializedLogMessage: 'materialized',
|
||||
materializedLogContext: () => ({ root: ROOT }),
|
||||
});
|
||||
}
|
||||
|
||||
it('reads only the manifest when the manifest hash matches', async () => {
|
||||
const { workspace, writes, executeCommand } = createSandboxWorkspace(new Map(bundle.files));
|
||||
const verifiedBundles: VerifiedWorkspaceBundles = new Map();
|
||||
|
||||
await expect(materialize(workspace, verifiedBundles)).resolves.toBe(bundle);
|
||||
|
||||
expect(readPaths(executeCommand)).toEqual([manifestPath]);
|
||||
expect(writes.size).toBe(0);
|
||||
expect(verifiedBundles.get(manifestPath)).toBe(bundle.contentHash);
|
||||
});
|
||||
|
||||
it('makes no sandbox reads after the bundle is verified for the sandbox', async () => {
|
||||
const { workspace, executeCommand } = createSandboxWorkspace(new Map(bundle.files));
|
||||
const verifiedBundles: VerifiedWorkspaceBundles = new Map();
|
||||
await materialize(workspace, verifiedBundles);
|
||||
executeCommand.mockClear();
|
||||
|
||||
await expect(materialize(workspace, verifiedBundles)).resolves.toBe(bundle);
|
||||
|
||||
expect(readPaths(executeCommand)).toEqual([]);
|
||||
});
|
||||
|
||||
it('checks the sandbox again when the expected hash changes', async () => {
|
||||
const { workspace, writes, executeCommand } = createSandboxWorkspace(new Map(bundle.files));
|
||||
const verifiedBundles: VerifiedWorkspaceBundles = new Map([[manifestPath, 'older-hash']]);
|
||||
|
||||
await materialize(workspace, verifiedBundles);
|
||||
|
||||
expect(readPaths(executeCommand)).toEqual([manifestPath]);
|
||||
expect(writes.size).toBe(0);
|
||||
expect(verifiedBundles.get(manifestPath)).toBe(bundle.contentHash);
|
||||
});
|
||||
|
||||
it('writes the bundle again when the manifest is stale', async () => {
|
||||
const { workspace, writes } = createSandboxWorkspace(
|
||||
new Map([
|
||||
...filePaths.map((path): [string, string] => [path, 'old\n']),
|
||||
[manifestPath, stringifyWorkspaceJson({ schemaVersion: 1, contentHash: 'stale' })],
|
||||
]),
|
||||
);
|
||||
const verifiedBundles: VerifiedWorkspaceBundles = new Map();
|
||||
|
||||
await materialize(workspace, verifiedBundles);
|
||||
|
||||
expect([...writes.keys()].sort()).toEqual([manifestPath, ...filePaths].sort());
|
||||
expect(verifiedBundles.get(manifestPath)).toBe(bundle.contentHash);
|
||||
});
|
||||
|
||||
it('writes the bundle again when the manifest is missing', async () => {
|
||||
const { workspace, writes } = createSandboxWorkspace(new Map());
|
||||
const verifiedBundles: VerifiedWorkspaceBundles = new Map();
|
||||
|
||||
await materialize(workspace, verifiedBundles);
|
||||
|
||||
expect([...writes.keys()].sort()).toEqual([manifestPath, ...filePaths].sort());
|
||||
expect(verifiedBundles.get(manifestPath)).toBe(bundle.contentHash);
|
||||
});
|
||||
|
||||
it('does not record the bundle when a write fails', async () => {
|
||||
const { workspace, writeFile, executeCommand } = createSandboxWorkspace(new Map());
|
||||
writeFile.mockRejectedValue(new Error('disk full'));
|
||||
executeCommand.mockResolvedValue({
|
||||
exitCode: 1,
|
||||
stdout: '',
|
||||
stderr: 'disk full',
|
||||
});
|
||||
const verifiedBundles: VerifiedWorkspaceBundles = new Map();
|
||||
|
||||
await expect(materialize(workspace, verifiedBundles)).rejects.toThrow();
|
||||
|
||||
expect(verifiedBundles.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import { jsonParse } from 'n8n-workflow';
|
|||
import type { Mock } from 'vitest';
|
||||
|
||||
import { makeBuilderTemplatesTarGz } from '../../knowledge-base/__tests__/builder-templates-archive.fixtures';
|
||||
import { buildKnowledgeBaseWorkspaceBundle } from '../../knowledge-base/materialize-knowledge-base';
|
||||
import type { InstanceAiContext, SearchableNodeDescription } from '../../types';
|
||||
import type { BuilderTemplatesBundle } from '../builder-templates-service';
|
||||
import type { SandboxWorkspace } from '../sandbox-fs';
|
||||
|
|
@ -402,6 +403,53 @@ describe('setupSandboxWorkspace', () => {
|
|||
expect(writtenPaths.some((p) => p.includes('/knowledge-base/templates/'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reads only the knowledge base manifest when the prebaked bundle matches', async () => {
|
||||
const runInSandbox: RunInSandboxMock =
|
||||
vi.fn<
|
||||
(
|
||||
...args: [SandboxWorkspace, string, string?]
|
||||
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
|
||||
>();
|
||||
runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
|
||||
const readFileViaSandbox: ReadFileViaSandboxMock =
|
||||
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
|
||||
readFileViaSandbox.mockResolvedValue(null);
|
||||
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
|
||||
runInSandbox,
|
||||
readFileViaSandbox,
|
||||
);
|
||||
const context = createSetupContext();
|
||||
const knowledgeBase = await buildKnowledgeBaseWorkspaceBundle({
|
||||
root: '/sandbox',
|
||||
logger: context.logger,
|
||||
});
|
||||
const files = new Map(knowledgeBase.files);
|
||||
files.set('/sandbox/.sandbox-initialized', '2024-01-01T00:00:00.000Z');
|
||||
const writeFile = vi.fn<
|
||||
(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>
|
||||
>(async () => await Promise.resolve());
|
||||
const readFile = vi.fn(async (path: string) => {
|
||||
const content = files.get(path);
|
||||
if (content === undefined) return await Promise.reject(new Error(`ENOENT: ${path}`));
|
||||
return await Promise.resolve(content);
|
||||
});
|
||||
const verifiedBundles = new Map<string, string>();
|
||||
|
||||
const initialized = await setupSandboxWorkspace(
|
||||
createLocalWorkspace(writeFile, undefined, readFile),
|
||||
context,
|
||||
{ verifiedBundles },
|
||||
);
|
||||
|
||||
expect(initialized).toBe(false);
|
||||
expect(readFile.mock.calls.map(([path]) => path)).toEqual([
|
||||
'/sandbox/.sandbox-initialized',
|
||||
knowledgeBase.manifestPath,
|
||||
]);
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
expect(verifiedBundles.get(knowledgeBase.manifestPath)).toBe(knowledgeBase.contentHash);
|
||||
});
|
||||
|
||||
it('materializes knowledge-base templates on the local provider when a bundle is available', async () => {
|
||||
const runInSandbox: RunInSandboxMock =
|
||||
vi.fn<
|
||||
|
|
|
|||
|
|
@ -8,8 +8,16 @@ import {
|
|||
import { parseVersionedWorkspaceManifest } from './workspace-manifest';
|
||||
import { traceSandboxOperation, sandboxFileBytes } from '../tracing/sandbox-tracing';
|
||||
|
||||
/**
|
||||
* Bundle hash by manifest path, for bundles that one sandbox already has.
|
||||
* Keep it as long as the sandbox, so that repeated checks in that sandbox do
|
||||
* not read the manifest again.
|
||||
*/
|
||||
export type VerifiedWorkspaceBundles = Map<string, string>;
|
||||
|
||||
export interface LoadPrebakedWorkspaceBundleOptions<TBundle> {
|
||||
workspace: WorkspaceFileTarget;
|
||||
verifiedBundles?: VerifiedWorkspaceBundles;
|
||||
manifestPath: string;
|
||||
expectedHash: string;
|
||||
hashField: string;
|
||||
|
|
@ -27,12 +35,18 @@ export interface LoadPrebakedWorkspaceBundleOptions<TBundle> {
|
|||
buildBundle: () => Promise<TBundle | undefined> | TBundle | undefined;
|
||||
}
|
||||
|
||||
/** Reuse a workspace bundle when its manifest hash matches the expected value. */
|
||||
/**
|
||||
* Reuse a workspace bundle when its manifest hash matches the expected value.
|
||||
* A matching manifest is trusted without a read of each bundle file: images
|
||||
* and snapshots bake the files with the manifest, and a live write puts the
|
||||
* manifest last.
|
||||
*/
|
||||
export async function loadPrebakedWorkspaceBundle<TBundle extends { files: Map<string, string> }>(
|
||||
options: LoadPrebakedWorkspaceBundleOptions<TBundle>,
|
||||
): Promise<TBundle | undefined> {
|
||||
let decision = 'reused';
|
||||
let actualHash: string | undefined;
|
||||
const { verifiedBundles } = options;
|
||||
return await traceSandboxOperation(
|
||||
'check-bundle',
|
||||
{
|
||||
|
|
@ -43,6 +57,14 @@ export async function loadPrebakedWorkspaceBundle<TBundle extends { files: Map<s
|
|||
}),
|
||||
},
|
||||
async () => {
|
||||
if (verifiedBundles?.get(options.manifestPath) === options.expectedHash) {
|
||||
decision = 'cached';
|
||||
actualHash = options.expectedHash;
|
||||
const bundle = await options.buildBundle();
|
||||
if (!bundle) decision = 'empty';
|
||||
return bundle;
|
||||
}
|
||||
|
||||
const manifestRaw = await readWorkspaceFile(options.workspace, options.manifestPath, {
|
||||
logger: options.logger,
|
||||
resourceLabel: options.resourceLabel,
|
||||
|
|
@ -81,29 +103,7 @@ export async function loadPrebakedWorkspaceBundle<TBundle extends { files: Map<s
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPaths = Array.from(bundle.files.keys()).filter(
|
||||
(path) => path !== options.manifestPath,
|
||||
);
|
||||
const existenceChecks = await Promise.all(
|
||||
payloadPaths.map(async (path) => ({
|
||||
path,
|
||||
exists:
|
||||
(await readWorkspaceFile(options.workspace, path, {
|
||||
logger: options.logger,
|
||||
resourceLabel: options.resourceLabel,
|
||||
})) !== null,
|
||||
})),
|
||||
);
|
||||
const missingPath = existenceChecks.find((check) => !check.exists)?.path;
|
||||
if (missingPath) {
|
||||
decision = 'incomplete';
|
||||
options.logger.debug('Ignoring incomplete prebaked workspace bundle', {
|
||||
manifestPath: options.manifestPath,
|
||||
missingPath,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
verifiedBundles?.set(options.manifestPath, options.expectedHash);
|
||||
options.logger.debug(options.successLogMessage, options.successLogContext(bundle));
|
||||
return bundle;
|
||||
},
|
||||
|
|
@ -114,10 +114,13 @@ export interface MaterializeWorkspaceBundleOptions<
|
|||
TBundle extends { files: Map<string, string>; manifestPath: string },
|
||||
> {
|
||||
workspace: WorkspaceFileTarget;
|
||||
verifiedBundles?: VerifiedWorkspaceBundles;
|
||||
resourceLabel: string;
|
||||
logger: Logger;
|
||||
loadPrebaked: () => Promise<TBundle | undefined>;
|
||||
buildBundle: () => Promise<TBundle> | TBundle;
|
||||
/** Hash that the bundle manifest records. */
|
||||
bundleHash: (bundle: TBundle) => string;
|
||||
materializedLogMessage: string;
|
||||
materializedLogContext: (bundle: TBundle) => Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -168,6 +171,7 @@ export async function materializeWorkspaceBundle<
|
|||
});
|
||||
}
|
||||
|
||||
options.verifiedBundles?.set(bundle.manifestPath, options.bundleHash(bundle));
|
||||
options.logger.debug(options.materializedLogMessage, options.materializedLogContext(bundle));
|
||||
return bundle;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
packSandboxLinkedWorkspacePackages,
|
||||
type WorkspacePackageTarball,
|
||||
} from './pack-workspace-sdk';
|
||||
import type { VerifiedWorkspaceBundles } from './prebaked-workspace-bundle';
|
||||
import {
|
||||
runInSandbox,
|
||||
readFileViaSandbox,
|
||||
|
|
@ -471,11 +472,13 @@ async function materializeKnowledgeBaseStep(
|
|||
workspace: SandboxWorkspace,
|
||||
root: string,
|
||||
context: InstanceAiContext,
|
||||
verifiedBundles: VerifiedWorkspaceBundles | undefined,
|
||||
): Promise<void> {
|
||||
await setupStep('materialize-knowledge-base', async () => {
|
||||
const templatesBundle = (await context.templatesService?.getBundle()) ?? null;
|
||||
await materializeKnowledgeBaseIntoWorkspace({
|
||||
workspace,
|
||||
verifiedBundles,
|
||||
root,
|
||||
logger: context.logger,
|
||||
templatesArchive: templatesBundle?.archive ?? null,
|
||||
|
|
@ -494,6 +497,7 @@ async function materializeKnowledgeBaseStep(
|
|||
export async function setupSandboxWorkspace(
|
||||
workspace: SandboxWorkspace,
|
||||
context: InstanceAiContext,
|
||||
options: { verifiedBundles?: VerifiedWorkspaceBundles } = {},
|
||||
): Promise<boolean> {
|
||||
return await traceSandboxOperation(
|
||||
'initialize-workspace',
|
||||
|
|
@ -511,7 +515,7 @@ export async function setupSandboxWorkspace(
|
|||
async () => await readWorkspaceFile(workspace, markerFile),
|
||||
);
|
||||
if (marker !== null) {
|
||||
await materializeKnowledgeBaseStep(workspace, root, context);
|
||||
await materializeKnowledgeBaseStep(workspace, root, context, options.verifiedBundles);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -538,7 +542,7 @@ export async function setupSandboxWorkspace(
|
|||
'write-workspace-files',
|
||||
async () => await writeWorkspaceFiles(workspace, root, files),
|
||||
);
|
||||
await materializeKnowledgeBaseStep(workspace, root, context);
|
||||
await materializeKnowledgeBaseStep(workspace, root, context, options.verifiedBundles);
|
||||
|
||||
// npm install (must run after package.json is in place)
|
||||
await setupStep('install-dependencies', async () => {
|
||||
|
|
|
|||
|
|
@ -529,6 +529,29 @@ describe('InstanceAiSandboxService', () => {
|
|||
expect(setupSandboxWorkspace).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes the verified bundles of the sandbox to workspace setup', async () => {
|
||||
const { service } = createSandboxService({
|
||||
config: { sandboxEnabled: true, sandboxProvider: 'daytona' },
|
||||
});
|
||||
const workspace = { init: vi.fn(async () => {}), destroy: vi.fn(async () => {}) };
|
||||
(createSandbox as Mock).mockResolvedValue({ id: 'sandbox-1' });
|
||||
(createWorkspace as Mock).mockReturnValue(workspace);
|
||||
(setupSandboxWorkspace as Mock).mockResolvedValue(undefined);
|
||||
|
||||
const entry = await service.getOrCreateWorkspace(
|
||||
'thread-1',
|
||||
fakeUser,
|
||||
{} as InstanceAiContext,
|
||||
);
|
||||
|
||||
expect(entry?.verifiedBundles).toEqual(new Map());
|
||||
const setupOptions = (setupSandboxWorkspace as Mock).mock.calls[0]?.[2] as {
|
||||
verifiedBundles: unknown;
|
||||
};
|
||||
expect((setupSandboxWorkspace as Mock).mock.calls[0]?.[0]).toBe(workspace);
|
||||
expect(setupOptions.verifiedBundles).toBe(entry?.verifiedBundles);
|
||||
});
|
||||
|
||||
it('assigns a deterministic thread-scoped UUID for the n8n-sandbox provider', async () => {
|
||||
const n8nSandboxConfig: Overrides['config'] = {
|
||||
sandboxEnabled: true,
|
||||
|
|
|
|||
|
|
@ -1175,6 +1175,17 @@ describe('InstanceAiService — runtime workspace setup', () => {
|
|||
expect(workspace.init).toHaveBeenCalledTimes(1);
|
||||
expect(setupSandboxWorkspace).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Runtime skills and workspace setup share the verified bundles of the sandbox.
|
||||
const { resolveVerifiedBundles } = (createLazyWorkspaceRuntimeSkillSource as Mock).mock
|
||||
.calls[0]?.[0] as { resolveVerifiedBundles: () => Promise<unknown> };
|
||||
const verifiedBundles = await resolveVerifiedBundles();
|
||||
expect(verifiedBundles).toEqual(new Map());
|
||||
const setupOptions = (setupSandboxWorkspace as Mock).mock.calls[0]?.[2] as {
|
||||
verifiedBundles: unknown;
|
||||
};
|
||||
expect((setupSandboxWorkspace as Mock).mock.calls[0]?.[0]).toBe(workspace);
|
||||
expect(setupOptions.verifiedBundles).toBe(verifiedBundles);
|
||||
|
||||
(createLazyRuntimeWorkspace as Mock).mockClear();
|
||||
(createLazyWorkspaceRuntimeSkillSource as Mock).mockClear();
|
||||
(createSandbox as Mock).mockClear();
|
||||
|
|
|
|||
|
|
@ -2919,6 +2919,7 @@ export class InstanceAiService {
|
|||
runtimeSkills = createLazyWorkspaceRuntimeSkillSource({
|
||||
source: allRuntimeSkills,
|
||||
workspace: runtimeSkillWorkspace,
|
||||
resolveVerifiedBundles: async () => (await getSandboxEntry())?.verifiedBundles,
|
||||
logger: this.logger,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
type Logger,
|
||||
type ManagedBackgroundTask,
|
||||
type SandboxConfig,
|
||||
type VerifiedWorkspaceBundles,
|
||||
} from '@n8n/instance-ai';
|
||||
import type { ErrorReporter } from 'n8n-core';
|
||||
import { OperationalError, UnexpectedError } from 'n8n-workflow';
|
||||
|
|
@ -36,6 +37,8 @@ export type RuntimeSandboxEntry = {
|
|||
sandbox: NonNullable<Awaited<ReturnType<typeof createSandbox>>>;
|
||||
workspace: NonNullable<ReturnType<typeof createWorkspace>>;
|
||||
configFingerprint: string;
|
||||
/** Prebaked bundles already verified in this sandbox, shared across runs. */
|
||||
verifiedBundles: VerifiedWorkspaceBundles;
|
||||
setupComplete: boolean;
|
||||
setupPromise: Promise<void> | undefined;
|
||||
expiresAt: number;
|
||||
|
|
@ -419,7 +422,9 @@ export class InstanceAiSandboxService {
|
|||
): Promise<void> {
|
||||
if (entry.setupComplete) return;
|
||||
|
||||
entry.setupPromise ??= setupSandboxWorkspace(entry.workspace, context)
|
||||
entry.setupPromise ??= setupSandboxWorkspace(entry.workspace, context, {
|
||||
verifiedBundles: entry.verifiedBundles,
|
||||
})
|
||||
.then(() => {
|
||||
entry.setupComplete = true;
|
||||
})
|
||||
|
|
@ -481,6 +486,7 @@ export class InstanceAiSandboxService {
|
|||
sandbox,
|
||||
workspace,
|
||||
configFingerprint: cacheState.fingerprint,
|
||||
verifiedBundles: new Map(),
|
||||
setupComplete: false,
|
||||
setupPromise: undefined,
|
||||
expiresAt: this.nextSandboxExpiry(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user