fix(ms-ai-architect): RX-OPS2 skrive-sikkerhet i driverne — scoped restore + atomiske skriv [skip-docs]

- backup.mjs restore(relPaths): SCOPED per-fil-rollback erstatter hel-tre rmSync(srcDir)+cpSync.
  Ruller kun tilbake kjøringens egne skriv; parallelle økters skriv til søsken-filer overlever;
  intet destruktivt rm→cp-vindu. Nyskapte filer slettes; prior bytes gjenopprettes atomisk.
- migrate-corpus.mjs: returnert restore() er nå en null-arg closure bundet til kjøringens skrive-
  liste (public API uendret) → scoped. detectStaleRollback aborterer ved forrige krasj-sentinel;
  cleanupOldBackups pruner backups forbi retention etter vellykket batch.
- 5 drivere (backfill-status/-category, dedup-plain-header, relabel-dato/-dialect):
  writeFileSync → atomicWriteSync (crash-safe tmp+rename) + recovery-kontrakt i header.
- backfill-category.mjs: refaktorert importerbar (isMain-guard + run()/planCategoryBackfill/
  categoryForFile/FOLDER_CATEGORY-eksporter, parameterisert root) → testbar uten scan-side-effekt.
- Tester (+16): 6 scoped-restore (parallel-preservering, ny-fil-sletting, throws-guard) erstatter
  2 hel-tre; test-backfill-category (frosset taxonomi + hermetisk plan); test-driver-atomic-writes
  (5 drivere); migrate stale-abort + cleanup-wiring. Suite 859→875 exit 0. validate-plugin 250/0.
This commit is contained in:
Kjell Tore Guttormsen 2026-07-15 21:31:29 +02:00
commit b3011da017
11 changed files with 406 additions and 100 deletions

View file

@ -18,7 +18,10 @@
// - advisor allowlist: only the 4 non-advisor skills are walked; a target path that
// matches /ms-ai-advisor/ is a hard throw (belt-and-suspenders on the pure fn).
// - atomic: atomicWriteSync only, never the raw fs writer.
// - backup: backupDir(root, backupRoot) before the first write; restore() retained.
// - backup: backupDir(root, backupRoot) before the first write. Recovery contract — restore()
// is SCOPED: it rolls back ONLY the files this run wrote, so a parallel session's writes to
// sibling files survive; a stale rollback sentinel aborts the run; old backups are pruned
// past retention after a successful batch.
// - per-field idempotent: delegated to insertHeaderFields (skips set Type/Source) and
// insertToc (skips a file that already hasToc / is small).
// - post-write assertion: re-read each written file, assert parseTypeHeader (and, when a
@ -41,7 +44,7 @@ import {
} from './lib/transform.mjs';
import { parseTypeHeader, parseSourceHeader } from './lib/kb-headers.mjs';
import { atomicWriteSync } from './lib/atomic-write.mjs';
import { backupDir } from './lib/backup.mjs';
import { backupDir, detectStaleRollback, cleanupOldBackups } from './lib/backup.mjs';
// The 4 non-advisor skills, enumerated explicitly (NO shell brace-expansion). Advisor
// (ms-ai-advisor) is deliberately absent — it is classified in the manifest but never
@ -170,16 +173,30 @@ export function migrateCorpus({ root, backupRoot, manifest, write = false }) {
return { root, write, backupPath: null, restore: null, results, counts };
}
// ONE backup before the first write — restore() undoes the whole batch.
const { backupPath, restore } = backupDir(skillsDir, backupRoot);
// A stale rollback sentinel means a prior run crashed mid-restore and the tree may be
// inconsistent — refuse to write over it; the operator must recover and clear it first.
if (detectStaleRollback(backupRoot)) {
throw new Error(
`migrateCorpus: a stale rollback sentinel is present under ${backupRoot} — a prior run ` +
`crashed mid-restore. Recover the corpus and clear .rollback-in-progress before rerunning.`,
);
}
// ONE backup before the first write. restore() rolls back ONLY the files THIS run wrote
// (scoped) — a parallel session's writes to sibling files are never touched.
const { backupPath, retentionDays, restore: restoreFiles } = backupDir(skillsDir, backupRoot);
const writtenRel = [];
const restore = () => restoreFiles(writtenRel);
for (const r of changed) {
const rel = relative(skillsDir, r.full).split(sep).join('/');
atomicWriteSync(r.full, r.content);
writtenRel.push(rel);
const written = readFileSync(r.full, 'utf8');
const typeOk = !r.expectType || parseTypeHeader(written) !== null;
const sourceOk = !r.expectSource || parseSourceHeader(written) !== null;
if (!typeOk || !sourceOk) {
restore(); // roll the whole batch back — never leave a partial corpus
restore(); // roll back only this run's own writes — never leave a partial corpus
throw new Error(
`migrateCorpus: post-write header truncated for ${r.relpath} ` +
`(inserted field fell past the ${500}-byte scan window; corpus restored from backup)`,
@ -189,6 +206,9 @@ export function migrateCorpus({ root, backupRoot, manifest, write = false }) {
counts.written += 1;
}
// Successful batch — prune backups past retention (best-effort hygiene).
cleanupOldBackups(backupRoot, retentionDays);
return { root, write, backupPath, restore, results, counts };
}