diff --git a/plugins/ms-ai-architect/scripts/kb-update/lib/backup.mjs b/plugins/ms-ai-architect/scripts/kb-update/lib/backup.mjs new file mode 100644 index 0000000..9c26534 --- /dev/null +++ b/plugins/ms-ai-architect/scripts/kb-update/lib/backup.mjs @@ -0,0 +1,202 @@ +// backup.mjs — Backup + sentinel-guarded rollback for skills/-tree. +// Zero dependencies. Uses fs.cpSync (recursive + preserveTimestamps) without +// dereference (Node 22.17.x regression) and without filter (Windows symlink- +// type bug). Rollback writes a .rollback-in-progress sentinel at backupRoot +// BEFORE destructive operations and removes it on success — a crash mid- +// restore leaves the sentinel behind so detectStaleRollback() can flag it. + +import { + cpSync, + rmSync, + statSync, + readdirSync, + readFileSync, + existsSync, + unlinkSync, + mkdirSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { atomicWriteJson } from './atomic-write.mjs'; + +const META_FILENAME = '.backup-meta.json'; +const SENTINEL_FILENAME = '.rollback-in-progress'; +const DEFAULT_RETENTION_DAYS = 7; + +/** + * Produce a filesystem-safe ISO-ish timestamp: YYYY-MM-DDTHH-MM-SS. + * No colons, no fractional seconds, no Z. + * @returns {string} + */ +export function backupTimestamp(now = new Date()) { + return now.toISOString().slice(0, 19).replace(/:/g, '-'); +} + +function readMetaCreatedAt(dir) { + try { + const text = readFileSync(join(dir, META_FILENAME), 'utf8'); + const obj = JSON.parse(text); + if (obj && typeof obj.created_at === 'string') { + const t = Date.parse(obj.created_at); + return Number.isFinite(t) ? t : null; + } + return null; + } catch { + return null; + } +} + +/** + * Back up srcDir into backupRoot//. Writes a meta sentinel inside + * the new backup dir as the first post-copy action. + * + * @param {string} srcDir — directory to back up (must exist) + * @param {string} backupRoot — parent dir for backup-id subdirs + * @param {object} [opts] + * @param {number} [opts.retentionDays] — default 7 + * @param {Date} [opts.now] — override clock for testing + * @returns {{backupPath: string, retentionDays: number, restore: () => void}} + */ +export function backupDir(srcDir, backupRoot, opts = {}) { + if (!srcDir || typeof srcDir !== 'string') { + throw new Error('backupDir: srcDir is required'); + } + if (!backupRoot || typeof backupRoot !== 'string') { + throw new Error('backupDir: backupRoot is required'); + } + if (!existsSync(srcDir)) { + throw new Error(`backupDir: srcDir does not exist: ${srcDir}`); + } + const retentionDays = opts.retentionDays ?? DEFAULT_RETENTION_DAYS; + const now = opts.now ?? new Date(); + mkdirSync(backupRoot, { recursive: true }); + const backupPath = join(backupRoot, backupTimestamp(now)); + + cpSync(srcDir, backupPath, { + recursive: true, + force: true, + preserveTimestamps: true, + }); + + // First action inside backupPath after cpSync — write meta sentinel. + atomicWriteJson(join(backupPath, META_FILENAME), { + created_at: now.toISOString(), + src_dir: srcDir, + schema_version: 1, + }); + + const restore = () => { + const sentinelPath = join(backupRoot, SENTINEL_FILENAME); + atomicWriteJson(sentinelPath, { + backup_path: backupPath, + src_dir: srcDir, + started_at: new Date().toISOString(), + schema_version: 1, + }); + try { + rmSync(srcDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 200, + }); + cpSync(backupPath, srcDir, { + recursive: true, + force: true, + preserveTimestamps: true, + }); + // Remove the meta file we copied back into srcDir so srcDir is clean. + const restoredMeta = join(srcDir, META_FILENAME); + if (existsSync(restoredMeta)) { + try { + unlinkSync(restoredMeta); + } catch { + // best-effort + } + } + } finally { + // Sentinel removed only on success path — leave it on throw so the + // post-mortem detector can see the orphan. + } + try { + unlinkSync(sentinelPath); + } catch { + // best-effort + } + }; + + return { backupPath, retentionDays, restore }; +} + +/** + * True if a stale rollback sentinel exists at backupRoot. + * @param {string} backupRoot + * @returns {boolean} + */ +export function detectStaleRollback(backupRoot) { + if (!backupRoot || typeof backupRoot !== 'string') return false; + return existsSync(join(backupRoot, SENTINEL_FILENAME)); +} + +/** + * Resolve the effective creation time of a backup dir. + * Order: meta.created_at → dir mtime → null (skip with warning upstream). + */ +function resolveBackupAge(dir) { + const fromMeta = readMetaCreatedAt(dir); + if (fromMeta != null) return fromMeta; + try { + return statSync(dir).mtimeMs; + } catch { + return null; + } +} + +/** + * Delete backup directories under backupRoot older than retentionDays. + * Skips dirs with unresolvable age (logs a warning) rather than deleting them. + * + * @param {string} backupRoot + * @param {number} [retentionDays] — default 7 + * @param {object} [opts] + * @param {(msg: string) => void} [opts.warn] — default console.warn + * @param {Date} [opts.now] — override clock for testing + * @returns {{kept: string[], deleted: string[], skipped: string[]}} + */ +export function cleanupOldBackups(backupRoot, retentionDays = DEFAULT_RETENTION_DAYS, opts = {}) { + const result = { kept: [], deleted: [], skipped: [] }; + if (!backupRoot || !existsSync(backupRoot)) return result; + const warn = opts.warn ?? ((m) => console.warn(m)); + const now = opts.now ?? new Date(); + const cutoffMs = now.getTime() - retentionDays * 24 * 60 * 60 * 1000; + + let entries; + try { + entries = readdirSync(backupRoot, { withFileTypes: true }); + } catch (err) { + warn(`cleanupOldBackups: cannot read ${backupRoot}: ${err.message}`); + return result; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const full = join(backupRoot, entry.name); + const ageMs = resolveBackupAge(full); + if (ageMs == null) { + warn(`cleanupOldBackups: skipping ${full} — cannot resolve age`); + result.skipped.push(full); + continue; + } + if (ageMs < cutoffMs) { + try { + rmSync(full, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); + result.deleted.push(full); + } catch (err) { + warn(`cleanupOldBackups: failed to delete ${full}: ${err.message}`); + result.skipped.push(full); + } + } else { + result.kept.push(full); + } + } + return result; +} diff --git a/plugins/ms-ai-architect/tests/kb-update/test-backup-restore.test.mjs b/plugins/ms-ai-architect/tests/kb-update/test-backup-restore.test.mjs new file mode 100644 index 0000000..d0246a6 --- /dev/null +++ b/plugins/ms-ai-architect/tests/kb-update/test-backup-restore.test.mjs @@ -0,0 +1,239 @@ +// tests/kb-update/test-backup-restore.test.mjs +// Unit tests for scripts/kb-update/lib/backup.mjs + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, + readFileSync, + readdirSync, + existsSync, + utimesSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + backupDir, + detectStaleRollback, + cleanupOldBackups, + backupTimestamp, +} from '../../scripts/kb-update/lib/backup.mjs'; + +function withTmp(fn) { + const dir = mkdtempSync(join(tmpdir(), 'bk-test-')); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function makeSrc(root, files) { + mkdirSync(root, { recursive: true }); + for (const [rel, content] of Object.entries(files)) { + const path = join(root, rel); + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, content, 'utf8'); + } +} + +function readAll(root) { + const out = {}; + function walk(dir, prefix) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + walk(full, rel); + } else if (entry.isFile()) { + out[rel] = readFileSync(full, 'utf8'); + } + } + } + walk(root, ''); + return out; +} + +test('backupTimestamp — produces filesystem-safe ISO-ish format', () => { + const ts = backupTimestamp(new Date('2026-05-05T10:32:13.456Z')); + assert.equal(ts, '2026-05-05T10-32-13'); + assert.match(ts, /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/); +}); + +test('backupDir — creates timestamped subdir under backupRoot', () => { + withTmp((tmp) => { + const src = join(tmp, 'skills'); + const root = join(tmp, '.kb-backup'); + makeSrc(src, { 'foo.md': 'A' }); + + const { backupPath } = backupDir(src, root); + assert.match( + backupPath, + /\.kb-backup\/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/ + ); + assert.equal(existsSync(backupPath), true); + }); +}); + +test('backupDir — copies content faithfully (deep equal)', () => { + withTmp((tmp) => { + const src = join(tmp, 'skills'); + const root = join(tmp, '.kb-backup'); + makeSrc(src, { + 'a.md': 'alpha', + 'sub/b.md': 'beta', + 'sub/deep/c.md': 'gamma', + }); + + const { backupPath } = backupDir(src, root); + const original = readAll(src); + const copied = readAll(backupPath); + // The backup also contains .backup-meta.json — strip it before comparing. + delete copied['.backup-meta.json']; + assert.deepEqual(copied, original); + }); +}); + +test('backupDir — writes .backup-meta.json sentinel inside backup', () => { + withTmp((tmp) => { + const src = join(tmp, 'skills'); + const root = join(tmp, '.kb-backup'); + makeSrc(src, { 'foo.md': 'A' }); + const { backupPath } = backupDir(src, root); + const metaPath = join(backupPath, '.backup-meta.json'); + assert.equal(existsSync(metaPath), true); + const meta = JSON.parse(readFileSync(metaPath, 'utf8')); + assert.equal(meta.schema_version, 1); + assert.equal(meta.src_dir, src); + assert.match(meta.created_at, /^\d{4}-\d{2}-\d{2}T/); + }); +}); + +test('restore — round-trips content after src is mutated', () => { + withTmp((tmp) => { + const src = join(tmp, 'skills'); + const root = join(tmp, '.kb-backup'); + makeSrc(src, { 'a.md': 'original', 'sub/b.md': 'original-b' }); + const original = readAll(src); + + const handle = backupDir(src, root); + + // Mutate src. + writeFileSync(join(src, 'a.md'), 'mutated', 'utf8'); + writeFileSync(join(src, 'new.md'), 'extra', 'utf8'); + rmSync(join(src, 'sub'), { recursive: true, force: true }); + + handle.restore(); + + const restored = readAll(src); + assert.deepEqual(restored, original); + }); +}); + +test('restore — sentinel is removed after successful restore', () => { + withTmp((tmp) => { + const src = join(tmp, 'skills'); + const root = join(tmp, '.kb-backup'); + makeSrc(src, { 'foo.md': 'A' }); + const handle = backupDir(src, root); + handle.restore(); + assert.equal(detectStaleRollback(root), false); + }); +}); + +test('detectStaleRollback — true when sentinel exists, false when absent', () => { + withTmp((tmp) => { + const root = join(tmp, '.kb-backup'); + mkdirSync(root, { recursive: true }); + assert.equal(detectStaleRollback(root), false); + writeFileSync(join(root, '.rollback-in-progress'), '{}', 'utf8'); + assert.equal(detectStaleRollback(root), true); + }); +}); + +test('detectStaleRollback — sentinel persists when restore is interrupted', () => { + withTmp((tmp) => { + const root = join(tmp, '.kb-backup'); + mkdirSync(root, { recursive: true }); + // Simulate a crashed restore: sentinel was written but never removed. + writeFileSync( + join(root, '.rollback-in-progress'), + JSON.stringify({ started_at: new Date().toISOString() }), + 'utf8' + ); + // Sentinel must still be there until something explicitly clears it. + assert.equal(detectStaleRollback(root), true); + }); +}); + +test('cleanupOldBackups — deletes backups older than retentionDays', () => { + withTmp((tmp) => { + const src = join(tmp, 'skills'); + const root = join(tmp, '.kb-backup'); + makeSrc(src, { 'foo.md': 'A' }); + + // Two backups. Age the first by overwriting its meta.created_at. + const oldHandle = backupDir(src, root); + const oldMetaPath = join(oldHandle.backupPath, '.backup-meta.json'); + const oldMeta = JSON.parse(readFileSync(oldMetaPath, 'utf8')); + const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(); + oldMeta.created_at = tenDaysAgo; + writeFileSync(oldMetaPath, JSON.stringify(oldMeta, null, 2), 'utf8'); + + // Sleep-equivalent: bump to ensure distinct backup-id. + const newHandle = backupDir(src, root, { now: new Date(Date.now() + 1000) }); + + const result = cleanupOldBackups(root, 7); + assert.deepEqual(result.deleted, [oldHandle.backupPath]); + assert.deepEqual(result.kept, [newHandle.backupPath]); + assert.equal(existsSync(oldHandle.backupPath), false); + assert.equal(existsSync(newHandle.backupPath), true); + }); +}); + +test('cleanupOldBackups — falls back to dir mtime when meta is missing', () => { + withTmp((tmp) => { + const root = join(tmp, '.kb-backup'); + const oldDir = join(root, '2026-04-01T00-00-00'); + mkdirSync(oldDir, { recursive: true }); + writeFileSync(join(oldDir, 'orphan.md'), 'no meta', 'utf8'); + // No .backup-meta.json. Set dir mtime to 30 days ago. + const past = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + utimesSync(oldDir, past, past); + + const result = cleanupOldBackups(root, 7); + assert.deepEqual(result.deleted, [oldDir]); + assert.equal(existsSync(oldDir), false); + }); +}); + +test('cleanupOldBackups — skips dirs with unresolvable age', () => { + withTmp((tmp) => { + const root = join(tmp, '.kb-backup'); + const odd = join(root, 'questionable'); + mkdirSync(odd, { recursive: true }); + // Stub statSync via making the file behave normally; fallback to mtime + // works on real fs. To genuinely exercise the skip path we stub the warn + // hook and make the meta unparseable + mtime fresh enough to not delete. + writeFileSync(join(odd, '.backup-meta.json'), 'not json', 'utf8'); + // mtime fresh → kept (not deleted), so the skip path is not hit. The + // skip-path guard only fires when statSync ALSO throws, which on real fs + // requires deletion mid-iteration. Simulate with a dir that exists but + // becomes inaccessible — out of scope for portable tests. Instead verify + // the documented contract: unparseable meta with fresh mtime → kept. + const result = cleanupOldBackups(root, 7); + assert.deepEqual(result.kept, [odd]); + assert.deepEqual(result.deleted, []); + }); +}); + +test('cleanupOldBackups — handles non-existent backupRoot gracefully', () => { + withTmp((tmp) => { + const root = join(tmp, 'never-created'); + const result = cleanupOldBackups(root, 7); + assert.deepEqual(result, { kept: [], deleted: [], skipped: [] }); + }); +});