mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-30 16:26:59 +02:00
fix(core): Stabilize runtime skill snapshot hashes (no-changelog) (backport to release-candidate/2.23.x) (#31213)
Co-authored-by: Albert Alises <albert.alises@gmail.com>
This commit is contained in:
parent
29859104b3
commit
7a38f3a33e
|
|
@ -195,6 +195,53 @@ description: Has no instructions.
|
|||
}
|
||||
});
|
||||
|
||||
it('hashes skill content independently of load locations', () => {
|
||||
const linkedFiles = {
|
||||
references: [{ path: 'references/guide.md', bytes: 5, sha256: 'abc123' }],
|
||||
templates: [],
|
||||
scripts: [],
|
||||
assets: [],
|
||||
examples: [],
|
||||
other: [],
|
||||
};
|
||||
const baseSkill = {
|
||||
id: 'same-skill',
|
||||
name: 'same-skill',
|
||||
description: 'Same skill',
|
||||
instructions: 'Use the same instructions.',
|
||||
sourceName: 'same-skill',
|
||||
path: '/ci/workspace/skills/same-skill/SKILL.md',
|
||||
sourcePath: '/ci/workspace/skills/same-skill/SKILL.md',
|
||||
directory: '/ci/workspace/skills/same-skill',
|
||||
sourceDirectory: 'ci-category/same-skill',
|
||||
category: 'ci-category',
|
||||
linkedFiles,
|
||||
};
|
||||
const movedSkill = {
|
||||
...baseSkill,
|
||||
sourceName: 'renamed-folder',
|
||||
path: '/usr/local/lib/node_modules/n8n/skills/renamed-folder/SKILL.md',
|
||||
sourcePath: '/usr/local/lib/node_modules/n8n/skills/renamed-folder/SKILL.md',
|
||||
directory: '/usr/local/lib/node_modules/n8n/skills/renamed-folder',
|
||||
sourceDirectory: 'prod-category/renamed-folder',
|
||||
category: 'prod-category',
|
||||
};
|
||||
|
||||
const baseRegistry = createRuntimeSkillRegistry([baseSkill]);
|
||||
const movedRegistry = createRuntimeSkillRegistry([movedSkill]);
|
||||
const changedRegistry = createRuntimeSkillRegistry([
|
||||
{ ...movedSkill, instructions: 'Use different instructions.' },
|
||||
]);
|
||||
|
||||
expect(baseRegistry.skills[0].path).not.toBe(movedRegistry.skills[0].path);
|
||||
expect(baseRegistry.skills[0].sourceDirectory).not.toBe(
|
||||
movedRegistry.skills[0].sourceDirectory,
|
||||
);
|
||||
expect(baseRegistry.skills[0].hash).toBe(movedRegistry.skills[0].hash);
|
||||
expect(baseRegistry.skillsHash).toBe(movedRegistry.skillsHash);
|
||||
expect(baseRegistry.skillsHash).not.toBe(changedRegistry.skillsHash);
|
||||
});
|
||||
|
||||
it('rejects duplicate skill ids and names', () => {
|
||||
expect(() =>
|
||||
createRuntimeSkillRegistry([
|
||||
|
|
|
|||
|
|
@ -189,18 +189,13 @@ function toRegistryEntry(skill: RuntimeSkill): RuntimeSkillRegistryEntry {
|
|||
}
|
||||
|
||||
function hashSkill(skill: RuntimeSkill): string {
|
||||
// Keep hashes tied to skill content, not where or how that content was loaded.
|
||||
return hashJson({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
instructions: skill.instructions,
|
||||
recommendedTools: skill.recommendedTools,
|
||||
sourceName: skill.sourceName,
|
||||
path: skill.path,
|
||||
sourcePath: skill.sourcePath,
|
||||
directory: skill.directory,
|
||||
sourceDirectory: skill.sourceDirectory,
|
||||
category: skill.category,
|
||||
allowedTools: skill.allowedTools,
|
||||
interface: skill.interface,
|
||||
policy: skill.policy,
|
||||
|
|
@ -401,10 +396,17 @@ function normalizeLinkedFilePath(filePath: string): string | null {
|
|||
function hashRegistry(skills: RuntimeSkillRegistryEntry[]): string {
|
||||
return hashJson({
|
||||
schemaVersion: RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION,
|
||||
skills,
|
||||
skills: skills.map(toRegistryHashEntry),
|
||||
});
|
||||
}
|
||||
|
||||
function toRegistryHashEntry(skill: RuntimeSkillRegistryEntry) {
|
||||
return {
|
||||
id: skill.id,
|
||||
hash: skill.hash,
|
||||
};
|
||||
}
|
||||
|
||||
function hashJson(value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify(stableClone(value)))
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => {
|
|||
path: skillPath,
|
||||
directory: skillDir,
|
||||
});
|
||||
expect(registry.skills[0]).not.toHaveProperty('sourcePath');
|
||||
|
||||
const manifest = jsonParse<{ schemaVersion: number; skillsHash: string }>(
|
||||
bundle.files.get(manifestPath) ?? '{}',
|
||||
|
|
@ -179,6 +180,7 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => {
|
|||
path: skillPath,
|
||||
directory: skillDir,
|
||||
});
|
||||
expect(registry.skills[0]).not.toHaveProperty('sourcePath');
|
||||
const manifestContent = writes.get(manifestPath);
|
||||
if (!manifestContent) throw new Error('Expected runtime skill manifest to be written');
|
||||
const manifest = jsonParse<{ schemaVersion: number; skillsHash: string }>(manifestContent);
|
||||
|
|
|
|||
|
|
@ -307,8 +307,11 @@ function materializedRegistry(
|
|||
const skill = materializedById.get(entry.id);
|
||||
if (!skill) return entry;
|
||||
|
||||
const materializedEntry = { ...entry };
|
||||
delete materializedEntry.sourcePath;
|
||||
|
||||
return {
|
||||
...entry,
|
||||
...materializedEntry,
|
||||
path: skill.path,
|
||||
directory: skill.directory,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ import type { Logger } from '../../logger';
|
|||
import { SnapshotManager } from '../snapshot-manager';
|
||||
|
||||
const SNAPSHOT_NAME_PATTERN = /^n8n\/instance-ai:1\.123\.0-[a-f0-9]{12}$/;
|
||||
const SKILLS_HASH_A = 'aaaaaaaaaaaa';
|
||||
const SKILLS_HASH_B = 'bbbbbbbbbbbb';
|
||||
|
||||
const NOOP_LOGGER: Logger = {
|
||||
info: () => {},
|
||||
|
|
@ -125,7 +127,7 @@ describe('SnapshotManager.ensureImage', () => {
|
|||
expect(image.dockerfile).toContain('/home/daytona/workspace/skills/.manifest.json');
|
||||
});
|
||||
|
||||
it('changes the snapshot setup hash when the runtime skills hash changes', async () => {
|
||||
it('changes the snapshot suffix when the runtime skills hash changes', async () => {
|
||||
const daytonaA = makeFakeDaytona();
|
||||
const daytonaB = makeFakeDaytona();
|
||||
daytonaA.snapshot.create.mockResolvedValue({ name: 'ignored-a' });
|
||||
|
|
@ -135,14 +137,14 @@ describe('SnapshotManager.ensureImage', () => {
|
|||
NOOP_LOGGER,
|
||||
'1.123.0',
|
||||
undefined,
|
||||
createRuntimeSkillSource('hash-a'),
|
||||
createRuntimeSkillSource(SKILLS_HASH_A),
|
||||
);
|
||||
const managerB = new SnapshotManager(
|
||||
undefined,
|
||||
NOOP_LOGGER,
|
||||
'1.123.0',
|
||||
undefined,
|
||||
createRuntimeSkillSource('hash-b'),
|
||||
createRuntimeSkillSource(SKILLS_HASH_B),
|
||||
);
|
||||
|
||||
const snapshotA = await managerA.createSnapshot(daytonaA as never);
|
||||
|
|
@ -150,8 +152,41 @@ describe('SnapshotManager.ensureImage', () => {
|
|||
|
||||
expect(snapshotA).toMatch(SNAPSHOT_NAME_PATTERN);
|
||||
expect(snapshotB).toMatch(SNAPSHOT_NAME_PATTERN);
|
||||
expect(snapshotA).toBe(`n8n/instance-ai:1.123.0-${SKILLS_HASH_A}`);
|
||||
expect(snapshotB).toBe(`n8n/instance-ai:1.123.0-${SKILLS_HASH_B}`);
|
||||
expect(snapshotA).not.toBe(snapshotB);
|
||||
});
|
||||
|
||||
it('keeps the snapshot suffix stable when the base image changes', async () => {
|
||||
const daytonaA = makeFakeDaytona();
|
||||
const daytonaB = makeFakeDaytona();
|
||||
daytonaA.snapshot.create.mockResolvedValue({ name: 'ignored-a' });
|
||||
daytonaB.snapshot.create.mockResolvedValue({ name: 'ignored-b' });
|
||||
const managerA = new SnapshotManager(
|
||||
'daytonaio/sandbox:0.5.0',
|
||||
NOOP_LOGGER,
|
||||
'1.123.0',
|
||||
undefined,
|
||||
createRuntimeSkillSource(SKILLS_HASH_A),
|
||||
);
|
||||
const managerB = new SnapshotManager(
|
||||
'node:24',
|
||||
NOOP_LOGGER,
|
||||
'1.123.0',
|
||||
undefined,
|
||||
createRuntimeSkillSource(SKILLS_HASH_A),
|
||||
);
|
||||
|
||||
const snapshotA = await managerA.createSnapshot(daytonaA as never);
|
||||
const snapshotB = await managerB.createSnapshot(daytonaB as never);
|
||||
|
||||
expect(snapshotA).toBe(`n8n/instance-ai:1.123.0-${SKILLS_HASH_A}`);
|
||||
expect(snapshotB).toBe(snapshotA);
|
||||
expect(daytonaA.snapshot.create.mock.calls[0][0].image.dockerfile).toContain(
|
||||
'FROM daytonaio/sandbox:0.5.0',
|
||||
);
|
||||
expect(daytonaB.snapshot.create.mock.calls[0][0].image.dockerfile).toContain('FROM node:24');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SnapshotManager.createSnapshot', () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Prepares and caches a Daytona Image descriptor with config files,
|
||||
* node_modules, and runtime skills pre-installed, and resolves a versioned
|
||||
* named snapshot (`n8n/instance-ai:<n8nVersion>-<setupHash>`) for sandbox
|
||||
* named snapshot (`n8n/instance-ai:<n8nVersion>-<runtimeSkillsHash>`) for sandbox
|
||||
* creation.
|
||||
*
|
||||
* Two strategies for `ensureSnapshot`:
|
||||
|
|
@ -20,7 +20,6 @@
|
|||
|
||||
import type { Daytona, DaytonaError as TDaytonaError, Image } from '@daytonaio/sdk';
|
||||
import type { RuntimeSkillSource } from '@n8n/agents';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { dirname as posixDirname } from 'node:path/posix';
|
||||
|
||||
import { loadDaytona } from './lazy-daytona';
|
||||
|
|
@ -37,7 +36,7 @@ export interface CreateSnapshotOptions {
|
|||
}
|
||||
|
||||
const DAYTONA_WORKSPACE_ROOT = '/home/daytona/workspace';
|
||||
const SETUP_HASH_LENGTH = 12;
|
||||
const EMPTY_RUNTIME_SKILLS_HASH = '000000000000';
|
||||
|
||||
/** Base64-encode content for safe embedding in RUN commands (avoids newline/quote issues). */
|
||||
function b64(s: string): string {
|
||||
|
|
@ -60,7 +59,7 @@ export class SnapshotManager {
|
|||
|
||||
private snapshotPromise: Promise<string | null> | null = null;
|
||||
|
||||
private setupHashPromise: Promise<string> | null = null;
|
||||
private snapshotSuffixPromise: Promise<string> | null = null;
|
||||
|
||||
private runtimeSkillBundlePromise: ReturnType<typeof buildRuntimeSkillWorkspaceBundle> | null =
|
||||
null;
|
||||
|
|
@ -180,24 +179,13 @@ export class SnapshotManager {
|
|||
return result;
|
||||
}
|
||||
|
||||
private async setupHash(): Promise<string> {
|
||||
this.setupHashPromise ??= (async () => {
|
||||
private async snapshotSuffix(): Promise<string> {
|
||||
this.snapshotSuffixPromise ??= (async () => {
|
||||
const runtimeSkillBundle = await this.runtimeSkillBundle();
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
baseImage: this.baseImage ?? 'daytonaio/sandbox:0.5.0',
|
||||
packageJson: PACKAGE_JSON,
|
||||
tsconfigJson: TSCONFIG_JSON,
|
||||
buildMjs: BUILD_MJS,
|
||||
skillsHash: runtimeSkillBundle?.skillsHash ?? '',
|
||||
}),
|
||||
)
|
||||
.digest('hex')
|
||||
.slice(0, SETUP_HASH_LENGTH);
|
||||
return runtimeSkillBundle?.skillsHash ?? EMPTY_RUNTIME_SKILLS_HASH;
|
||||
})();
|
||||
|
||||
return await this.setupHashPromise;
|
||||
return await this.snapshotSuffixPromise;
|
||||
}
|
||||
|
||||
private async runtimeSkillBundle(): ReturnType<typeof buildRuntimeSkillWorkspaceBundle> {
|
||||
|
|
@ -214,14 +202,14 @@ export class SnapshotManager {
|
|||
if (!this.n8nVersion) {
|
||||
throw new Error('SnapshotManager: n8nVersion is required to derive a snapshot name');
|
||||
}
|
||||
return `n8n/instance-ai:${this.n8nVersion}-${await this.setupHash()}`;
|
||||
return `n8n/instance-ai:${this.n8nVersion}-${await this.snapshotSuffix()}`;
|
||||
}
|
||||
|
||||
/** Invalidate cached image (e.g., when base image changes). */
|
||||
invalidate(): void {
|
||||
this.cachedImage = null;
|
||||
this.snapshotPromise = null;
|
||||
this.setupHashPromise = null;
|
||||
this.snapshotSuffixPromise = null;
|
||||
this.runtimeSkillBundlePromise = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user