461 lines
15 KiB
JavaScript
461 lines
15 KiB
JavaScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { promises as fs } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
/** The headings that make up a retained develop-workflow record. */
|
|
export const REQUIRED_SECTIONS = Object.freeze([
|
|
"Status",
|
|
"Request and scope",
|
|
"Git identity",
|
|
"Acceptance criteria",
|
|
"Approved implementation plan and gates",
|
|
"Ownership",
|
|
"Verification matrix",
|
|
"Review",
|
|
"Integration and rollback",
|
|
"Checkpoints and blockers",
|
|
]);
|
|
|
|
const REQUIRED_SECTION_SET = new Set(REQUIRED_SECTIONS);
|
|
const FILE_MODE = 0o600;
|
|
const MAX_TEMP_NAME_ATTEMPTS = 16;
|
|
|
|
/**
|
|
* Error raised for a checkpoint precondition or atomic-update failure.
|
|
* `code` is stable for callers that need to distinguish a stale record or lock.
|
|
*/
|
|
export class CheckpointError extends Error {
|
|
constructor(code, message, options = {}) {
|
|
super(message, options);
|
|
this.name = "CheckpointError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
const nodeIo = Object.freeze({
|
|
mkdir: (...args) => fs.mkdir(...args),
|
|
open: (...args) => fs.open(...args),
|
|
readFile: (...args) => fs.readFile(...args),
|
|
rename: (...args) => fs.rename(...args),
|
|
unlink: (...args) => fs.unlink(...args),
|
|
randomUUID: () => randomUUID(),
|
|
});
|
|
|
|
/** The default filesystem adapter, useful when composing or testing the helper. */
|
|
export const defaultIo = nodeIo;
|
|
|
|
function asBytes(input) {
|
|
if (typeof input === "string") return Buffer.from(input, "utf8");
|
|
if (input instanceof Uint8Array) return Buffer.from(input);
|
|
if (input instanceof ArrayBuffer) return Buffer.from(new Uint8Array(input));
|
|
throw new CheckpointError(
|
|
"INVALID_INPUT",
|
|
"checkpoint input must be a UTF-8 string or byte array",
|
|
);
|
|
}
|
|
|
|
function headingText(line, level) {
|
|
const prefix = "#".repeat(level);
|
|
const match = line.match(new RegExp(`^${prefix}(?!#)[ \\t]+(.+?)[ \\t]*$`));
|
|
if (!match) return null;
|
|
|
|
// Closing ATX hashes are syntax, not part of the heading title.
|
|
return match[1].replace(/[ \\t]+#+[ \\t]*$/, "").trim();
|
|
}
|
|
|
|
/**
|
|
* Validate a retained record without accessing the destination path.
|
|
* Markdown headings inside fenced code blocks are not record sections.
|
|
*/
|
|
export function validateRecord(input) {
|
|
const bytes = asBytes(input);
|
|
let text;
|
|
try {
|
|
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
} catch (error) {
|
|
throw new CheckpointError("INVALID_RECORD", "checkpoint input is not valid UTF-8", {
|
|
cause: error,
|
|
});
|
|
}
|
|
|
|
const h1 = [];
|
|
const h2 = [];
|
|
let fence = null;
|
|
for (const line of text.split(/\r?\n/)) {
|
|
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
|
|
if (fenceMatch) {
|
|
const marker = fenceMatch[1][0];
|
|
if (fence === null) fence = marker;
|
|
else if (fence === marker) fence = null;
|
|
continue;
|
|
}
|
|
if (fence !== null) continue;
|
|
|
|
const first = headingText(line, 1);
|
|
if (first !== null) h1.push(first);
|
|
const second = headingText(line, 2);
|
|
if (second !== null) h2.push(second);
|
|
}
|
|
|
|
if (h1.length !== 1 || h1[0].length === 0) {
|
|
throw new CheckpointError(
|
|
"INVALID_RECORD",
|
|
`checkpoint must contain exactly one non-empty H1 (found ${h1.length})`,
|
|
);
|
|
}
|
|
|
|
const counts = new Map();
|
|
for (const section of h2) counts.set(section, (counts.get(section) ?? 0) + 1);
|
|
const missing = REQUIRED_SECTIONS.filter((section) => !counts.has(section));
|
|
const duplicate = REQUIRED_SECTIONS.filter((section) => (counts.get(section) ?? 0) > 1);
|
|
const unknown = h2.filter((section) => !REQUIRED_SECTION_SET.has(section));
|
|
if (missing.length > 0 || duplicate.length > 0 || unknown.length > 0) {
|
|
const details = [];
|
|
if (missing.length > 0) details.push(`missing: ${missing.join(", ")}`);
|
|
if (duplicate.length > 0) details.push(`duplicate: ${duplicate.join(", ")}`);
|
|
if (unknown.length > 0) details.push(`unknown: ${[...new Set(unknown)].join(", ")}`);
|
|
throw new CheckpointError("INVALID_RECORD", `invalid checkpoint sections (${details.join("; ")})`);
|
|
}
|
|
|
|
return { bytes, text, h1: h1[0], sections: [...REQUIRED_SECTIONS] };
|
|
}
|
|
|
|
function sha256(bytes) {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
|
|
function expectedDigest(value) {
|
|
if (value === undefined || value === null) return undefined;
|
|
if (typeof value !== "string" || !/^[0-9a-f]{64}$/i.test(value)) {
|
|
throw new CheckpointError("INVALID_DIGEST", "expected SHA-256 must be exactly 64 hexadecimal characters");
|
|
}
|
|
return value.toLowerCase();
|
|
}
|
|
|
|
function errorCode(error) {
|
|
return error && typeof error === "object" && "code" in error ? error.code : undefined;
|
|
}
|
|
|
|
function isMissing(error) {
|
|
return errorCode(error) === "ENOENT";
|
|
}
|
|
|
|
function isAlreadyExists(error) {
|
|
return errorCode(error) === "EEXIST";
|
|
}
|
|
|
|
function messageFor(error) {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
function withIo(io) {
|
|
return { ...nodeIo, ...io };
|
|
}
|
|
|
|
async function closeQuietly(handle) {
|
|
if (handle === null) return undefined;
|
|
try {
|
|
await handle.close();
|
|
} catch (error) {
|
|
return error;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function unlinkQuietly(io, path) {
|
|
try {
|
|
await io.unlink(path);
|
|
return undefined;
|
|
} catch (error) {
|
|
return error;
|
|
}
|
|
}
|
|
|
|
async function readExisting(io, target) {
|
|
try {
|
|
return { exists: true, bytes: await io.readFile(target) };
|
|
} catch (error) {
|
|
if (isMissing(error)) return { exists: false, bytes: undefined };
|
|
throw new CheckpointError("READ_FAILED", `could not read checkpoint target: ${messageFor(error)}`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function openUniqueTemp(io, target) {
|
|
const directory = dirname(target);
|
|
for (let attempt = 0; attempt < MAX_TEMP_NAME_ATTEMPTS; attempt += 1) {
|
|
const id = io.randomUUID();
|
|
const path = `${target}.tmp-${process.pid}-${id}`;
|
|
try {
|
|
const handle = await io.open(path, "wx", FILE_MODE);
|
|
return { path, handle, directory };
|
|
} catch (error) {
|
|
// A collision is not stale-file cleanup. Leave the old file alone and try a new name.
|
|
if (isAlreadyExists(error)) continue;
|
|
throw new CheckpointError("TEMP_OPEN_FAILED", `could not create checkpoint temporary file: ${messageFor(error)}`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
}
|
|
throw new CheckpointError("TEMP_OPEN_FAILED", "could not create a unique checkpoint temporary file");
|
|
}
|
|
|
|
async function writeAll(handle, bytes) {
|
|
let offset = 0;
|
|
while (offset < bytes.byteLength) {
|
|
const result = await handle.write(bytes, offset, bytes.byteLength - offset);
|
|
const written = typeof result === "number" ? result : result.bytesWritten;
|
|
if (!Number.isInteger(written) || written <= 0) {
|
|
throw new CheckpointError("WRITE_FAILED", "checkpoint temporary file write made no progress");
|
|
}
|
|
offset += written;
|
|
}
|
|
}
|
|
|
|
async function syncDirectory(io, directory) {
|
|
let handle = null;
|
|
let operationError;
|
|
try {
|
|
handle = await io.open(directory, "r");
|
|
await handle.sync();
|
|
} catch (error) {
|
|
operationError = new CheckpointError("DIRECTORY_SYNC_FAILED", `could not flush checkpoint directory: ${messageFor(error)}`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
const closeError = await closeQuietly(handle);
|
|
if (operationError !== undefined) throw operationError;
|
|
if (closeError !== undefined) {
|
|
throw new CheckpointError("DIRECTORY_CLOSE_FAILED", `could not close checkpoint directory: ${messageFor(closeError)}`, {
|
|
cause: closeError,
|
|
});
|
|
}
|
|
}
|
|
|
|
function requestFromArgs(targetOrRequest, input, options) {
|
|
if (typeof targetOrRequest === "string" || targetOrRequest instanceof URL) {
|
|
return { ...(options ?? {}), target: targetOrRequest, input };
|
|
}
|
|
if (targetOrRequest === null || typeof targetOrRequest !== "object") {
|
|
throw new CheckpointError("INVALID_REQUEST", "checkpoint request must provide a target and input");
|
|
}
|
|
return targetOrRequest;
|
|
}
|
|
|
|
/**
|
|
* Atomically create or replace a validated workflow record.
|
|
*
|
|
* Object form: `checkpointRecord({ target, input, expectedSha256, io })`.
|
|
* Positional form is also supported by `recordCheckpoint(target, input, options)`.
|
|
*/
|
|
export async function checkpointRecord(targetOrRequest, input, options) {
|
|
const request = requestFromArgs(targetOrRequest, input, options);
|
|
const targetValue = request.target;
|
|
if (typeof targetValue !== "string" && !(targetValue instanceof URL)) {
|
|
throw new CheckpointError("INVALID_REQUEST", "checkpoint target is required");
|
|
}
|
|
const target = resolve(targetValue instanceof URL ? fileURLToPath(targetValue) : targetValue);
|
|
const source = request.input ?? request.content;
|
|
// This deliberately happens before opening the lock or reading the destination.
|
|
const { bytes } = validateRecord(source);
|
|
const expected = expectedDigest(request.expectedSha256 ?? request.expectedDigest);
|
|
const io = withIo(request.io);
|
|
const lock = `${target}.lock`;
|
|
const directory = dirname(target);
|
|
|
|
try {
|
|
await io.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
} catch (error) {
|
|
throw new CheckpointError(
|
|
"DIRECTORY_CREATE_FAILED",
|
|
`could not create checkpoint directory: ${messageFor(error)}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
|
|
let lockHandle = null;
|
|
let tempHandle = null;
|
|
let tempPath = null;
|
|
let primaryError;
|
|
let result;
|
|
try {
|
|
try {
|
|
lockHandle = await io.open(lock, "wx", FILE_MODE);
|
|
} catch (error) {
|
|
if (isAlreadyExists(error)) {
|
|
throw new CheckpointError("LOCK_EXISTS", "checkpoint target is locked; refusing to recover or overwrite the lock", {
|
|
cause: error,
|
|
});
|
|
}
|
|
throw new CheckpointError("LOCK_FAILED", `could not acquire checkpoint lock: ${messageFor(error)}`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
|
|
const current = await readExisting(io, target);
|
|
if (current.exists) {
|
|
if (expected === undefined) {
|
|
throw new CheckpointError(
|
|
"EXPECTED_SHA256_REQUIRED",
|
|
"replacing an existing checkpoint requires --expected-sha256",
|
|
);
|
|
}
|
|
const actual = sha256(current.bytes);
|
|
if (actual !== expected) {
|
|
throw new CheckpointError(
|
|
"STALE_DIGEST",
|
|
`checkpoint target digest is stale (expected ${expected}, found ${actual})`,
|
|
);
|
|
}
|
|
} else if (expected !== undefined) {
|
|
throw new CheckpointError(
|
|
"TARGET_MISSING",
|
|
"checkpoint target is absent; do not provide an expected SHA-256 when creating it",
|
|
);
|
|
}
|
|
|
|
const temporary = await openUniqueTemp(io, target);
|
|
tempPath = temporary.path;
|
|
tempHandle = temporary.handle;
|
|
let temporaryError;
|
|
try {
|
|
await writeAll(tempHandle, bytes);
|
|
} catch (error) {
|
|
temporaryError = error instanceof CheckpointError
|
|
? error
|
|
: new CheckpointError("WRITE_FAILED", `could not write checkpoint temporary file: ${messageFor(error)}`, { cause: error });
|
|
}
|
|
if (temporaryError === undefined) {
|
|
try {
|
|
await tempHandle.sync();
|
|
} catch (error) {
|
|
temporaryError = new CheckpointError("TEMP_SYNC_FAILED", `could not flush checkpoint temporary file: ${messageFor(error)}`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
}
|
|
const closeError = await closeQuietly(tempHandle);
|
|
tempHandle = null;
|
|
if (temporaryError !== undefined) throw temporaryError;
|
|
if (closeError !== undefined) {
|
|
throw new CheckpointError("TEMP_CLOSE_FAILED", `could not close checkpoint temporary file: ${messageFor(closeError)}`, {
|
|
cause: closeError,
|
|
});
|
|
}
|
|
|
|
try {
|
|
await io.rename(tempPath, target);
|
|
} catch (error) {
|
|
throw new CheckpointError("RENAME_FAILED", `could not atomically replace checkpoint target: ${messageFor(error)}`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
tempPath = null;
|
|
await syncDirectory(io, directory);
|
|
result = {
|
|
target,
|
|
created: !current.exists,
|
|
sha256: sha256(bytes),
|
|
};
|
|
} catch (error) {
|
|
primaryError = error;
|
|
}
|
|
|
|
// Cleanup is best effort. In particular, never remove a pre-existing stale temp
|
|
// or lock and never replace an operation error with a cleanup error.
|
|
const tempCloseError = await closeQuietly(tempHandle);
|
|
if (tempCloseError !== undefined && primaryError === undefined) {
|
|
primaryError = new CheckpointError("TEMP_CLOSE_FAILED", `could not close checkpoint temporary file: ${messageFor(tempCloseError)}`, {
|
|
cause: tempCloseError,
|
|
});
|
|
}
|
|
if (tempPath !== null) {
|
|
const tempUnlinkError = await unlinkQuietly(io, tempPath);
|
|
if (tempUnlinkError !== undefined && !isMissing(tempUnlinkError) && primaryError === undefined) {
|
|
primaryError = new CheckpointError("TEMP_CLEANUP_FAILED", `could not remove checkpoint temporary file: ${messageFor(tempUnlinkError)}`, {
|
|
cause: tempUnlinkError,
|
|
});
|
|
}
|
|
}
|
|
const lockCloseError = await closeQuietly(lockHandle);
|
|
if (lockCloseError !== undefined && primaryError === undefined) {
|
|
primaryError = new CheckpointError("LOCK_CLOSE_FAILED", `could not close checkpoint lock: ${messageFor(lockCloseError)}`, {
|
|
cause: lockCloseError,
|
|
});
|
|
}
|
|
if (lockHandle !== null) {
|
|
const lockUnlinkError = await unlinkQuietly(io, lock);
|
|
if (lockUnlinkError !== undefined && !isMissing(lockUnlinkError) && primaryError === undefined) {
|
|
primaryError = new CheckpointError("LOCK_CLEANUP_FAILED", `could not remove checkpoint lock: ${messageFor(lockUnlinkError)}`, {
|
|
cause: lockUnlinkError,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (primaryError !== undefined) throw primaryError;
|
|
return result;
|
|
}
|
|
|
|
export async function recordCheckpoint(target, input, options = {}) {
|
|
return checkpointRecord(target, input, options);
|
|
}
|
|
|
|
export const writeCheckpoint = recordCheckpoint;
|
|
|
|
function usage() {
|
|
return "Usage: node checkpoint-record.mjs <target> [--input <path>] [--expected-sha256 <digest>]";
|
|
}
|
|
|
|
function parseCli(argv) {
|
|
let target;
|
|
let inputPath;
|
|
let expectedSha256;
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index];
|
|
if (argument === "--input") {
|
|
if (inputPath !== undefined || index + 1 >= argv.length || argv[index + 1].startsWith("--")) {
|
|
throw new CheckpointError("CLI_USAGE", usage());
|
|
}
|
|
inputPath = argv[++index];
|
|
} else if (argument === "--expected-sha256") {
|
|
if (expectedSha256 !== undefined || index + 1 >= argv.length || argv[index + 1].startsWith("--")) {
|
|
throw new CheckpointError("CLI_USAGE", usage());
|
|
}
|
|
expectedSha256 = argv[++index];
|
|
} else if (argument.startsWith("--")) {
|
|
throw new CheckpointError("CLI_USAGE", usage());
|
|
} else if (target === undefined) {
|
|
target = argument;
|
|
} else {
|
|
throw new CheckpointError("CLI_USAGE", usage());
|
|
}
|
|
}
|
|
if (target === undefined) throw new CheckpointError("CLI_USAGE", usage());
|
|
return { target, inputPath, expectedSha256 };
|
|
}
|
|
|
|
async function readStdin() {
|
|
const chunks = [];
|
|
for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
export async function main(argv = process.argv.slice(2)) {
|
|
try {
|
|
const { target, inputPath, expectedSha256 } = parseCli(argv);
|
|
const input = inputPath === undefined ? await readStdin() : await fs.readFile(inputPath);
|
|
await checkpointRecord({ target, input, expectedSha256 });
|
|
return 0;
|
|
} catch (error) {
|
|
process.stderr.write(`checkpoint-record: ${messageFor(error)}\n`);
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
const invokedPath = process.argv[1] === undefined ? null : resolve(process.argv[1]);
|
|
if (invokedPath !== null && invokedPath === fileURLToPath(import.meta.url)) {
|
|
process.exitCode = await main();
|
|
}
|