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:
parent
b68514487c
commit
b3011da017
11 changed files with 406 additions and 100 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue