Files
playbook/antigravity-awesome-skills/tools/lib/aas-v1/transaction/journal.js
T
2026-07-18 00:02:59 +00:00

220 lines
8.0 KiB
JavaScript

"use strict";
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const { canonicalJson, canonicalize, sha256 } = require("../canonical-json");
const { fsyncDirectory, writeFileDurable } = require("./state");
const { transactionError } = require("./errors");
const { validateInstance } = require("../schema-validator");
const MAX_JOURNAL_BYTES = 4 * 1024 * 1024;
function recoveryIdFor(planDigest, identityDigest) {
return `recovery-${sha256(`${planDigest}:${identityDigest}`).slice(7, 55)}`;
}
function journalPath(layoutRoot, recoveryId) {
if (!/^recovery-[a-f0-9]{32,64}$/.test(recoveryId)) {
throw transactionError("AAS_TRANSACTION_JOURNAL_ID_INVALID", "invalidInput", {});
}
return path.join(layoutRoot, `.aas-transaction-${recoveryId}.wal`);
}
function digestRecords(records) {
return sha256(canonicalJson(records));
}
function appendRecord(context, event, details = {}) {
if (context.tornTailDigest) {
throw transactionError("AAS_TRANSACTION_JOURNAL_TORN_TAIL", "recovery", {});
}
const previous = context.records.at(-1) || null;
const body = canonicalize({
schemaVersion: 1,
recoveryId: context.recoveryId,
sequence: context.records.length,
event,
planDigest: context.planDigest,
targetIdentityDigest: context.targetIdentityDigest,
details,
previousRecordDigest: previous ? previous.recordDigest : null,
});
const record = { ...body, recordDigest: sha256(canonicalJson(body)) };
const bytes = `${canonicalJson(record)}\n`;
const descriptor = fs.openSync(context.path, fs.constants.O_APPEND | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0));
try {
fs.writeFileSync(descriptor, bytes);
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
context.records.push(record);
context.validBytes += Buffer.byteLength(bytes);
context.validBytesDigest = sha256(fs.readFileSync(context.path).subarray(0, context.validBytes));
context.digest = digestRecords(context.records);
return record;
}
function createJournal(layoutRoot, recoveryId, planDigest, targetIdentityDigest) {
const target = journalPath(layoutRoot, recoveryId);
const temporary = `${target}.pending-${crypto.randomBytes(16).toString("hex")}`;
if (fs.existsSync(target)) throw transactionError("AAS_TRANSACTION_RECOVERY_REQUIRED", "recovery", { recoveryId });
const context = {
recoveryId,
planDigest,
targetIdentityDigest,
path: temporary,
records: [],
validBytes: 0,
validBytesDigest: sha256(Buffer.alloc(0)),
tornTailDigest: null,
};
try {
writeFileDurable(temporary, "", 0o600);
appendRecord(context, "started", {});
fs.renameSync(temporary, target);
fsyncDirectory(layoutRoot);
context.path = target;
return context;
} catch (cause) {
try { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); } catch {}
throw cause;
}
}
function readJournalFile(target) {
const stat = fs.lstatSync(target);
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 || stat.size > MAX_JOURNAL_BYTES) {
throw transactionError("AAS_TRANSACTION_JOURNAL_UNSAFE", "filesystem", {});
}
const bytes = fs.readFileSync(target);
const finalNewline = bytes.length > 0 && bytes.at(-1) === 0x0a;
const lastNewline = bytes.lastIndexOf(0x0a);
const validBytes = finalNewline ? bytes.length : (lastNewline < 0 ? 0 : lastNewline + 1);
const validText = bytes.subarray(0, validBytes).toString("utf8");
let records;
try {
records = validText.split("\n").filter(Boolean).map((line) => JSON.parse(line));
} catch (cause) {
throw transactionError("AAS_TRANSACTION_JOURNAL_CORRUPT", "integrity", {}, cause);
}
if (!records.length) throw transactionError("AAS_TRANSACTION_JOURNAL_CORRUPT", "integrity", {});
for (let index = 0; index < records.length; index += 1) {
const record = records[index];
validateInstance("journal.schema.json", record, "AAS_TRANSACTION_JOURNAL_SCHEMA_INVALID");
const { recordDigest, ...body } = record || {};
if (record.sequence !== index || record.previousRecordDigest !== (index ? records[index - 1].recordDigest : null)
|| recordDigest !== sha256(canonicalJson(body)) || record.recoveryId !== records[0].recoveryId
|| record.planDigest !== records[0].planDigest || record.targetIdentityDigest !== records[0].targetIdentityDigest) {
throw transactionError("AAS_TRANSACTION_JOURNAL_CORRUPT", "integrity", {});
}
}
const tornTail = bytes.subarray(validBytes);
return {
recoveryId: records[0].recoveryId,
planDigest: records[0].planDigest,
targetIdentityDigest: records[0].targetIdentityDigest,
path: target,
records,
digest: digestRecords(records),
validBytes,
validBytesDigest: sha256(bytes.subarray(0, validBytes)),
tornTailDigest: tornTail.length ? sha256(tornTail) : null,
};
}
function readJournal(layoutRoot, recoveryId) {
return readJournalFile(journalPath(layoutRoot, recoveryId));
}
function journalCheckpoint(journal) {
return canonicalize({
recordCount: journal.records.length,
recordsDigest: journal.digest,
validBytes: journal.validBytes,
validBytesDigest: journal.validBytesDigest,
tornTailDigest: journal.tornTailDigest,
});
}
function verifyCheckpoint(journal, checkpoint) {
if (!checkpoint || !Number.isSafeInteger(checkpoint.recordCount) || checkpoint.recordCount < 1
|| !Number.isSafeInteger(checkpoint.validBytes) || checkpoint.validBytes < 1
|| journal.records.length < checkpoint.recordCount || journal.validBytes < checkpoint.validBytes) {
throw transactionError("AAS_RECOVERY_JOURNAL_DRIFT", "drift", {});
}
const prefix = journal.records.slice(0, checkpoint.recordCount);
const raw = fs.readFileSync(journal.path).subarray(0, checkpoint.validBytes);
if (digestRecords(prefix) !== checkpoint.recordsDigest || sha256(raw) !== checkpoint.validBytesDigest) {
throw transactionError("AAS_RECOVERY_JOURNAL_DRIFT", "drift", {});
}
if (journal.records.length === checkpoint.recordCount && journal.tornTailDigest !== (checkpoint.tornTailDigest || null)) {
throw transactionError("AAS_RECOVERY_JOURNAL_DRIFT", "drift", {});
}
return true;
}
function repairTornTail(journal, checkpoint) {
verifyCheckpoint(journal, checkpoint);
if (!journal.tornTailDigest) return journal;
if (journal.records.length !== checkpoint.recordCount) {
throw transactionError("AAS_RECOVERY_JOURNAL_DRIFT", "drift", {});
}
const descriptor = fs.openSync(journal.path, fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0));
try {
fs.ftruncateSync(descriptor, checkpoint.validBytes);
fs.fsyncSync(descriptor);
} finally { fs.closeSync(descriptor); }
fsyncDirectory(path.dirname(journal.path));
return readJournalFile(journal.path);
}
function truncateTornTail(journal) {
if (!journal.tornTailDigest) return journal;
const descriptor = fs.openSync(journal.path, fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0));
try {
fs.ftruncateSync(descriptor, journal.validBytes);
fs.fsyncSync(descriptor);
} finally { fs.closeSync(descriptor); }
fsyncDirectory(path.dirname(journal.path));
return readJournalFile(journal.path);
}
function removeJournal(journal) {
const observed = readJournalFile(journal.path);
if (observed.recoveryId !== journal.recoveryId || observed.planDigest !== journal.planDigest) {
throw transactionError("AAS_TRANSACTION_JOURNAL_DRIFT", "drift", {});
}
fs.unlinkSync(journal.path);
fsyncDirectory(path.dirname(journal.path));
}
function listJournalIds(layoutRoot) {
return fs.readdirSync(layoutRoot)
.map((name) => /^\.aas-transaction-(recovery-[a-f0-9]{32,64})\.wal$/.exec(name))
.filter(Boolean)
.map((match) => match[1])
.sort();
}
function journalEvents(journal) {
return new Set(journal.records.map((record) => record.event));
}
module.exports = {
appendRecord,
createJournal,
journalCheckpoint,
journalEvents,
journalPath,
listJournalIds,
readJournal,
readJournalFile,
recoveryIdFor,
removeJournal,
repairTornTail,
truncateTornTail,
verifyCheckpoint,
};