fix: publish rollback target snapshot before replacing live skill

This commit is contained in:
haelyra
2026-10-11 11:14:44 -04:00
parent 4e337f0a43
commit a668d06f30
2 changed files with 71 additions and 7 deletions
+13 -7
View File
@@ -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,
+58
View File
@@ -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;