fix: preserve snapshot paths and reject malformed skill tracking rows

This commit is contained in:
haelyra
2026-10-11 06:32:21 -04:00
parent 2df7533ee4
commit a7e5c4d030
5 changed files with 97 additions and 8 deletions
@@ -10,7 +10,7 @@
"id": "hand-seeded",
"source": "manual-curation-pending-regeneration"
},
"registryDigest": "7144a76d4b920efacf5a8d678ec6757ea34410834c82f4e226b6a32af6f8c540",
"registryDigest": "cb89b3434da17c00988095077f61208ab81c2f0611f0edbd5c90d3672bce7ac1",
"schemaVersion": 1,
"triggers": {
"skill:agent-runtime-gateway-smoke-test": [
+4 -1
View File
@@ -103,7 +103,10 @@ function readJsonl(filePath) {
.reduce((rows, line) => {
try {
const record = JSON.parse(line);
if (record && typeof record === 'object' && !Array.isArray(record)) {
if (record && typeof record === 'object' && !Array.isArray(record)
&& typeof record.skill_id === 'string' && record.skill_id.trim().length > 0
&& VALID_OUTCOMES.has(record.outcome)
&& typeof record.recorded_at === 'string' && !Number.isNaN(Date.parse(record.recorded_at))) {
rows.push(record);
}
} catch {
+21 -5
View File
@@ -100,7 +100,14 @@ function listVersions(skillPath) {
}
const filePath = path.join(versionsDir, fileName);
const stats = fs.statSync(filePath);
let stats;
try {
stats = fs.statSync(filePath);
} catch (error) {
// A dangling link still occupies a path, but is not a readable snapshot.
if (error.code === 'ENOENT') return null;
throw error;
}
if (!stats.isFile()) {
return null;
}
@@ -146,7 +153,10 @@ function readJsonl(filePath) {
.filter(Boolean)
.reduce((rows, line) => {
try {
rows.push(JSON.parse(line));
const record = JSON.parse(line);
if (record && typeof record === 'object' && !Array.isArray(record)) {
rows.push(record);
}
} catch {
// Ignore malformed rows so the log remains append-only and resilient.
}
@@ -163,12 +173,18 @@ function createVersion(skillPath, options = {}) {
ensureSkillVersioning(skillPath);
const versions = listVersions(skillPath);
const nextVersion = versions.length === 0 ? 1 : versions[versions.length - 1].version + 1;
const snapshotPath = path.join(getVersionsDir(skillPath), `v${nextVersion}.md`);
const versionsDir = getVersionsDir(skillPath);
const occupiedPaths = new Set(fs.readdirSync(versionsDir));
let nextVersion = versions.length === 0 ? 1 : versions[versions.length - 1].version + 1;
// Listing excludes non-files, but their paths remain user-owned and occupied.
while (occupiedPaths.has(`v${nextVersion}.md`)) {
nextVersion += 1;
}
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, 'utf8');
fs.writeFileSync(snapshotPath, skillContent, { encoding: 'utf8', flag: 'wx' });
appendEvolutionRecord(skillPath, 'amendments', {
event: 'snapshot',
version: nextVersion,
+70
View File
@@ -0,0 +1,70 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const versioning = require('../../scripts/lib/skill-evolution/versioning');
const health = require('../../scripts/lib/skill-evolution/health');
let passed = 0;
let failed = 0;
function createFixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-evolution-storage-'));
const skillDir = path.join(root, 'skills', 'demo');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Original\n');
return { root, skillDir, versionsDir: versioning.getVersionsDir(skillDir) };
}
function test(name, fn) {
const fixture = createFixture();
try {
fn(fixture);
passed += 1;
console.log(` ✓ ${name}`);
} catch (error) {
failed += 1;
console.error(` ✗ ${name}: ${error.message}`);
} finally {
fs.rmSync(fixture.root, { recursive: true, force: true });
}
}
test('snapshot creation preserves occupied directories and links plus earlier snapshots', fixture => {
const { root, skillDir, versionsDir } = fixture;
versioning.createVersion(skillDir);
fs.mkdirSync(path.join(versionsDir, 'v2.md'));
fs.writeFileSync(path.join(versionsDir, 'v2.md', 'keep.txt'), 'preserve');
if (process.platform !== 'win32') {
fs.symlinkSync(path.join(root, 'missing'), path.join(versionsDir, 'v3.md'));
}
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Updated\n');
const snapshot = versioning.createVersion(skillDir);
assert.equal(snapshot.version, process.platform === 'win32' ? 3 : 4);
assert.equal(fs.readFileSync(snapshot.path, 'utf8'), '# Updated\n');
assert.equal(fs.readFileSync(path.join(versionsDir, 'v1.md'), 'utf8'), '# Original\n');
assert.equal(fs.readFileSync(path.join(versionsDir, 'v2.md', 'keep.txt'), 'utf8'), 'preserve');
assert.equal(versioning.getEvolutionLog(skillDir, 'amendments').at(-1).version, snapshot.version);
if (process.platform !== 'win32') {
assert.ok(fs.lstatSync(path.join(versionsDir, 'v3.md')).isSymbolicLink());
fs.symlinkSync(path.join(versionsDir, 'v1.md'), path.join(versionsDir, 'v5.md'));
assert.deepEqual(versioning.listVersions(skillDir).map(entry => entry.version), [1, 4, 5]);
assert.equal(versioning.getCurrentVersion(skillDir), 5);
}
});
test('health skips malformed amendment rows while retaining a valid pending proposal', fixture => {
const { root, skillDir } = fixture;
versioning.ensureSkillVersioning(skillDir);
fs.writeFileSync(versioning.getEvolutionLogPath(skillDir, 'amendments'),
'null\n[]\n42\n{bad}\n{"event":"proposal","status":"pending"}\n');
const report = health.collectSkillHealth({
skillsRoot: path.join(root, 'skills'), homeDir: root, now: '2026-03-15T12:00:00.000Z',
});
assert.equal(report.skills.find(skill => skill.skill_id === 'demo').pending_amendments, 1);
});
console.log(`Results: Passed: ${passed}, Failed: ${failed}`);
process.exitCode = failed > 0 ? 1 : 0;
+1 -1
View File
@@ -320,7 +320,7 @@ function runTests() {
duration_ms: 1,
user_feedback: 'accepted',
recorded_at: '2026-03-15T11:45:00.000Z',
})}\n{bad-json}\nnull\n42\nfalse\n"not a record"\n[]\n`,
})}\n{bad-json}\nnull\n42\nfalse\n"not a record"\n[]\n{}\n{"skill_id":42}\n{"skill_id":""}\n{"skill_id":"alpha","outcome":"invalid","recorded_at":"2026-03-15T11:45:00.000Z"}\n{"skill_id":"alpha","outcome":"success","recorded_at":"bad-date"}\n`,
'utf8'
);