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:
n8n-assistant[bot] 2026-05-27 14:20:16 +00:00 committed by GitHub
parent 29859104b3
commit 7a38f3a33e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 109 additions and 32 deletions

View File

@ -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([

View File

@ -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)))

View File

@ -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);

View File

@ -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,
};

View File

@ -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', () => {

View File

@ -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;
}
}