diff --git a/scripts/lib/skill-evolution/versioning.js b/scripts/lib/skill-evolution/versioning.js index 9adcce14b..1e1e6c7e5 100644 --- a/scripts/lib/skill-evolution/versioning.js +++ b/scripts/lib/skill-evolution/versioning.js @@ -184,13 +184,12 @@ function allocateSnapshotVersion(skillPath) { return nextVersion; } -function createVersion(skillPath, options = {}) { - const skillFilePath = ensureSkillExists(skillPath); +function publishVersion(skillPath, skillContent, options) { + ensureSkillExists(skillPath); const nextVersion = allocateSnapshotVersion(skillPath); const versionsDir = getVersionsDir(skillPath); ensureSkillVersioning(skillPath); const snapshotPath = path.join(versionsDir, `v${nextVersion}.md`); - const skillContent = fs.readFileSync(skillFilePath, 'utf8'); const createdAt = options.timestamp || new Date().toISOString(); fs.writeFileSync(snapshotPath, skillContent, { encoding: 'utf8', flag: 'wx' }); @@ -210,6 +209,12 @@ function createVersion(skillPath, options = {}) { }; } +function createVersion(skillPath, options = {}) { + const skillFilePath = ensureSkillExists(skillPath); + const skillContent = fs.readFileSync(skillFilePath, 'utf8'); + return publishVersion(skillPath, skillContent, options); +} + function rollbackTo(skillPath, targetVersion, options = {}) { const normalizedTargetVersion = Number(targetVersion); if (!Number.isSafeInteger(normalizedTargetVersion) || normalizedTargetVersion <= 0) { @@ -225,15 +230,16 @@ function rollbackTo(skillPath, targetVersion, options = {}) { const currentVersion = getCurrentVersion(skillPath); const targetContent = fs.readFileSync(targetPath, 'utf8'); - allocateSnapshotVersion(skillPath); - fs.writeFileSync(getSkillFilePath(skillPath), targetContent, 'utf8'); - - const createdVersion = createVersion(skillPath, { + // Publish the restored-content snapshot before replacing unsnapshotted live work. + // Initialization and exclusive publication failures therefore leave SKILL.md intact. + const createdVersion = publishVersion(skillPath, targetContent, { timestamp: options.timestamp, reason: options.reason || `rollback to v${normalizedTargetVersion}`, author: options.author || null, }); + fs.writeFileSync(getSkillFilePath(skillPath), targetContent, 'utf8'); + appendEvolutionRecord(skillPath, 'amendments', { event: 'rollback', version: createdVersion.version, diff --git a/tests/lib/skill-evolution-storage.test.js b/tests/lib/skill-evolution-storage.test.js index 50283f2d1..99e028530 100644 --- a/tests/lib/skill-evolution-storage.test.js +++ b/tests/lib/skill-evolution-storage.test.js @@ -140,5 +140,63 @@ test('unsafe rollback targets preserve the live skill and excluded snapshots', f } }); +test('rollback initialization failure preserves unsnapshotted live bytes and the target', fixture => { + const { skillDir, versionsDir } = fixture; + fs.mkdirSync(versionsDir); + const target = path.join(versionsDir, 'v1.md'); + fs.writeFileSync(target, '# Saved target\n'); + fs.writeFileSync(versioning.getEvolutionDir(skillDir), 'owned blocker'); + assert.throws(() => versioning.rollbackTo(skillDir, 1), /ENOTDIR/); + assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Original\n'); + assert.equal(fs.readFileSync(target, 'utf8'), '# Saved target\n'); + assert.deepEqual(fs.readdirSync(versionsDir), ['v1.md']); + assert.equal(fs.readFileSync(versioning.getEvolutionDir(skillDir), 'utf8'), 'owned blocker'); +}); + +test('rollback exclusive publication failure preserves live bytes and permits a later retry', fixture => { + const { skillDir, versionsDir } = fixture; + versioning.createVersion(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Unsnapshotted work\n'); + const amendmentPath = versioning.getEvolutionLogPath(skillDir, 'amendments'); + const priorLog = fs.readFileSync(amendmentPath, 'utf8'); + const writeFileSync = fs.writeFileSync; + const injectedError = Object.assign(new Error('Injected snapshot publication failure'), { code: 'EIO' }); + fs.writeFileSync = (file, ...args) => { + if (file === path.join(versionsDir, 'v2.md') && args[1]?.flag === 'wx') throw injectedError; + return writeFileSync(file, ...args); + }; + try { + assert.throws(() => versioning.rollbackTo(skillDir, 1), error => error === injectedError); + } finally { + fs.writeFileSync = writeFileSync; + } + assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Unsnapshotted work\n'); + assert.equal(fs.readFileSync(path.join(versionsDir, 'v1.md'), 'utf8'), '# Original\n'); + assert.deepEqual(fs.readdirSync(versionsDir), ['v1.md']); + assert.equal(fs.readFileSync(amendmentPath, 'utf8'), priorLog); + const restored = versioning.rollbackTo(skillDir, 1); + assert.equal(restored.version, 2); + assert.equal(fs.readFileSync(restored.path, 'utf8'), '# Original\n'); + assert.equal(fs.readFileSync(restored.path, 'utf8'), fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8')); + assert.equal(versioning.getEvolutionLog(skillDir, 'amendments').at(-1).event, 'rollback'); +}); + +test('successful rollback snapshots restored content and retains earlier targets for another rollback', fixture => { + const { skillDir } = fixture; + versioning.createVersion(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Second version\n'); + versioning.createVersion(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Unsnapshotted work\n'); + const restored = versioning.rollbackTo(skillDir, 1); + assert.equal(restored.version, 3); + assert.equal(fs.readFileSync(restored.path, 'utf8'), '# Original\n'); + assert.equal(fs.readFileSync(restored.path, 'utf8'), fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8')); + const second = versioning.rollbackTo(skillDir, 2); + assert.equal(second.version, 4); + assert.equal(fs.readFileSync(second.path, 'utf8'), '# Second version\n'); + assert.equal(fs.readFileSync(second.path, 'utf8'), fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8')); + assert.equal(versioning.getCurrentVersion(skillDir), 4); +}); + console.log(`Results: Passed: ${passed}, Failed: ${failed}`); process.exitCode = failed > 0 ? 1 : 0;