100 lines
3.7 KiB
JavaScript
100 lines
3.7 KiB
JavaScript
|
|
/* patch-c2-exit-code-normalize:v1 */
|
|||
|
|
#!/usr/bin/env node
|
|||
|
|
/**
|
|||
|
|
* rollback-on-fail.js · Phase α 冲刺 3 · 2026-04-25
|
|||
|
|
* Restore original file from file-history, move staging to quarantine.
|
|||
|
|
* Called only in enforce mode by staging-validator on failure.
|
|||
|
|
*/
|
|||
|
|
'use strict';
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
const crypto = require('crypto');
|
|||
|
|
|
|||
|
|
const ROOT = path.resolve(__dirname, '..');
|
|||
|
|
const FILE_HISTORY = path.join(ROOT, 'file-history');
|
|||
|
|
const PIPELINE_DIR = path.join(ROOT, 'ai-delivery-pipeline');
|
|||
|
|
const QUARANTINE_DIR = path.join(PIPELINE_DIR, 'quarantine');
|
|||
|
|
const MANIFEST = path.join(PIPELINE_DIR, 'manifest.jsonl');
|
|||
|
|
|
|||
|
|
function appendManifest(entry) {
|
|||
|
|
try { fs.appendFileSync(MANIFEST, JSON.stringify(entry) + '\n', 'utf8'); } catch (_) {}
|
|||
|
|
}
|
|||
|
|
function ensureDir(d) { try { fs.mkdirSync(d, { recursive: true }); } catch (_) {} }
|
|||
|
|
function sha256(buf) { return crypto.createHash('sha256').update(buf).digest('hex'); }
|
|||
|
|
|
|||
|
|
function findLatestHistorySnapshot(originalPath) {
|
|||
|
|
if (!fs.existsSync(FILE_HISTORY)) return null;
|
|||
|
|
const baseName = path.basename(originalPath);
|
|||
|
|
const candidates = [];
|
|||
|
|
try {
|
|||
|
|
const walk = (dir) => {
|
|||
|
|
for (const name of fs.readdirSync(dir)) {
|
|||
|
|
const p = path.join(dir, name);
|
|||
|
|
let s; try { s = fs.statSync(p); } catch { continue; }
|
|||
|
|
if (s.isDirectory()) { walk(p); continue; }
|
|||
|
|
if (name.startsWith(baseName)) candidates.push({ path: p, mtime: s.mtimeMs });
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
walk(FILE_HISTORY);
|
|||
|
|
} catch (_) {}
|
|||
|
|
if (candidates.length === 0) return null;
|
|||
|
|
candidates.sort((a, b) => b.mtime - a.mtime);
|
|||
|
|
return candidates[0].path;
|
|||
|
|
}
|
|||
|
|
function moveToQuarantine(stagingPath, hash) {
|
|||
|
|
const today = new Date().toISOString().slice(0, 10);
|
|||
|
|
const bucket = path.join(QUARANTINE_DIR, today);
|
|||
|
|
ensureDir(bucket);
|
|||
|
|
const target = path.join(bucket, hash + '_' + path.basename(stagingPath));
|
|||
|
|
try { fs.renameSync(stagingPath, target); return target; }
|
|||
|
|
catch (e) {
|
|||
|
|
try { fs.copyFileSync(stagingPath, target); fs.unlinkSync(stagingPath); return target; }
|
|||
|
|
catch (_) { return null; }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
function atomicRestore(sourcePath, targetPath) {
|
|||
|
|
const tmp = targetPath + '.rollback.tmp.' + process.pid;
|
|||
|
|
try {
|
|||
|
|
fs.copyFileSync(sourcePath, tmp);
|
|||
|
|
const fd = fs.openSync(tmp, 'r+');
|
|||
|
|
try { fs.fsyncSync(fd); } catch (_) {}
|
|||
|
|
try { fs.closeSync(fd); } catch (_) {}
|
|||
|
|
fs.renameSync(tmp, targetPath);
|
|||
|
|
return true;
|
|||
|
|
} catch (_) {
|
|||
|
|
try { fs.unlinkSync(tmp); } catch (_) {}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
function main() {
|
|||
|
|
const [, , stagingPath, originalPath, hash, failuresJson] = process.argv;
|
|||
|
|
if (!stagingPath || !originalPath || !hash) {
|
|||
|
|
appendManifest({ ts: new Date().toISOString(), event: 'rollback-bad-args' });
|
|||
|
|
process.exit(2);
|
|||
|
|
}
|
|||
|
|
let failures = [];
|
|||
|
|
try { failures = JSON.parse(failuresJson || '[]'); } catch (_) {}
|
|||
|
|
const historySnap = findLatestHistorySnapshot(originalPath);
|
|||
|
|
if (!historySnap) {
|
|||
|
|
appendManifest({
|
|||
|
|
ts: new Date().toISOString(), event: 'rollback-skip-no-history',
|
|||
|
|
originalPath, hash, note: 'no file-history snapshot, conservative no-op',
|
|||
|
|
});
|
|||
|
|
process.exit(0);
|
|||
|
|
}
|
|||
|
|
const restored = atomicRestore(historySnap, originalPath);
|
|||
|
|
let restoredHash = null;
|
|||
|
|
if (restored) { try { restoredHash = sha256(fs.readFileSync(originalPath)); } catch (_) {} }
|
|||
|
|
const quarantined = moveToQuarantine(stagingPath, hash);
|
|||
|
|
appendManifest({
|
|||
|
|
ts: new Date().toISOString(),
|
|||
|
|
event: restored ? 'rolled-back' : 'rollback-failed',
|
|||
|
|
originalPath, historySnap, quarantinedTo: quarantined, restoredHash, hash, failures,
|
|||
|
|
});
|
|||
|
|
process.exit(restored ? 0 : 1);
|
|||
|
|
}
|
|||
|
|
try { main(); } catch (e) {
|
|||
|
|
appendManifest({ ts: new Date().toISOString(), event: 'rollback-crash', error: String(e).slice(0, 200) });
|
|||
|
|
process.exit(2);
|
|||
|
|
}
|