From 7a38f3a33e3f99062419daec3ca00fcffa9638c0 Mon Sep 17 00:00:00 2001 From: "n8n-assistant[bot]" <100856346+n8n-assistant[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 14:20:16 +0000 Subject: [PATCH] fix(core): Stabilize runtime skill snapshot hashes (no-changelog) (backport to release-candidate/2.23.x) (#31213) Co-authored-by: Albert Alises --- .../skills/__tests__/runtime-skills.test.ts | 47 +++++++++++++++++++ packages/@n8n/agents/src/skills/registry.ts | 16 ++++--- .../materialize-runtime-skills.test.ts | 2 + .../src/skills/materialize-runtime-skills.ts | 5 +- .../__tests__/snapshot-manager.test.ts | 41 ++++++++++++++-- .../src/workspace/snapshot-manager.ts | 30 ++++-------- 6 files changed, 109 insertions(+), 32 deletions(-) diff --git a/packages/@n8n/agents/src/skills/__tests__/runtime-skills.test.ts b/packages/@n8n/agents/src/skills/__tests__/runtime-skills.test.ts index 52c7bc30c68..02e3d5e0c67 100644 --- a/packages/@n8n/agents/src/skills/__tests__/runtime-skills.test.ts +++ b/packages/@n8n/agents/src/skills/__tests__/runtime-skills.test.ts @@ -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([ diff --git a/packages/@n8n/agents/src/skills/registry.ts b/packages/@n8n/agents/src/skills/registry.ts index 79900b234d7..e930002fd93 100644 --- a/packages/@n8n/agents/src/skills/registry.ts +++ b/packages/@n8n/agents/src/skills/registry.ts @@ -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))) diff --git a/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts b/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts index f77a677eea9..fab277dcb75 100644 --- a/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts +++ b/packages/@n8n/instance-ai/src/skills/__tests__/materialize-runtime-skills.test.ts @@ -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); diff --git a/packages/@n8n/instance-ai/src/skills/materialize-runtime-skills.ts b/packages/@n8n/instance-ai/src/skills/materialize-runtime-skills.ts index 58b473018e4..9c876ff1db0 100644 --- a/packages/@n8n/instance-ai/src/skills/materialize-runtime-skills.ts +++ b/packages/@n8n/instance-ai/src/skills/materialize-runtime-skills.ts @@ -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, }; diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/snapshot-manager.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/snapshot-manager.test.ts index 2ee905f9e2b..7d157ef9c88 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/snapshot-manager.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/snapshot-manager.test.ts @@ -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', () => { diff --git a/packages/@n8n/instance-ai/src/workspace/snapshot-manager.ts b/packages/@n8n/instance-ai/src/workspace/snapshot-manager.ts index afe1fe21ff5..f4b16d483c6 100644 --- a/packages/@n8n/instance-ai/src/workspace/snapshot-manager.ts +++ b/packages/@n8n/instance-ai/src/workspace/snapshot-manager.ts @@ -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:-`) for sandbox + * named snapshot (`n8n/instance-ai:-`) 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 | null = null; - private setupHashPromise: Promise | null = null; + private snapshotSuffixPromise: Promise | null = null; private runtimeSkillBundlePromise: ReturnType | null = null; @@ -180,24 +179,13 @@ export class SnapshotManager { return result; } - private async setupHash(): Promise { - this.setupHashPromise ??= (async () => { + private async snapshotSuffix(): Promise { + 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 { @@ -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; } }