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

@ -1,9 +1,12 @@
// backup.mjs — Backup + sentinel-guarded rollback for skills/-tree.
// backup.mjs — Backup + sentinel-guarded, SCOPED 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.
// type bug). restore(relPaths) rolls back ONLY the relative paths the calling
// run wrote — never the whole tree — so a parallel session's writes to sibling
// files survive and there is no destructive rm→cp window. It writes a
// .rollback-in-progress sentinel at backupRoot BEFORE the first per-file restore
// and removes it on success — a crash mid-restore leaves the sentinel behind so
// detectStaleRollback() can flag it.
import {
cpSync,
@ -15,8 +18,8 @@ import {
unlinkSync,
mkdirSync,
} from 'node:fs';
import { join, basename } from 'node:path';
import { atomicWriteJson } from './atomic-write.mjs';
import { join, basename, dirname } from 'node:path';
import { atomicWriteJson, atomicWriteSync } from './atomic-write.mjs';
const META_FILENAME = '.backup-meta.json';
const SENTINEL_FILENAME = '.rollback-in-progress';
@ -84,39 +87,41 @@ export function backupDir(srcDir, backupRoot, opts = {}) {
schema_version: 1,
});
const restore = () => {
// Scoped rollback: restore ONLY the relative paths (relative to srcDir) that the calling run
// wrote this session. A file present in the backup is restored to its prior bytes atomically; a
// file ABSENT from the backup was created this run, so rolling back means deleting it. Sibling
// files a parallel session touched after this backup are never read or written, and there is no
// window where srcDir is deleted. The sentinel is written before the first mutation and cleared
// only on the success path — a throw mid-restore leaves it for detectStaleRollback().
const restore = (relPaths) => {
if (!Array.isArray(relPaths)) {
throw new Error(
"backupDir.restore: relPaths must be an array — restore is scoped to the run's own writes",
);
}
const sentinelPath = join(backupRoot, SENTINEL_FILENAME);
atomicWriteJson(sentinelPath, {
backup_path: backupPath,
src_dir: srcDir,
rel_paths: relPaths,
started_at: new Date().toISOString(),
schema_version: 1,
schema_version: 2,
});
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)) {
for (const rel of relPaths) {
const from = join(backupPath, rel);
const to = join(srcDir, rel);
if (existsSync(from)) {
mkdirSync(dirname(to), { recursive: true });
atomicWriteSync(to, readFileSync(from)); // prior bytes, crash-safe (Buffer → byte-exact)
} else if (existsSync(to)) {
try {
unlinkSync(restoredMeta);
unlinkSync(to); // created this run — must not exist in the rolled-back state
} catch {
// best-effort
}
}
} finally {
// Sentinel removed only on success path — leave it on throw so the
// post-mortem detector can see the orphan.
}
// Success — clear the sentinel. On a throw above it is left behind for detectStaleRollback().
try {
unlinkSync(sentinelPath);
} catch {