/* 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); }