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
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue