feat(scanner): complete localized artifact quality checkpoint
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { NativeScannerRunTiming, NativeScannerRunTimingPatch } from "../../src/types/global.js";
|
||||
|
||||
export const NATIVE_SCANNER_RUN_TIMING_FILE = "run-timing.json";
|
||||
export const NATIVE_SCANNER_RUN_TIMING_VERSION = "native-scanner-run-timing-v1" as const;
|
||||
|
||||
const timingWriteQueues = new Map<string, Promise<void>>();
|
||||
|
||||
const timestampFields = [
|
||||
"requestStartedAt",
|
||||
"scannerStartedAt",
|
||||
"captureStartedAt",
|
||||
"captureCompletedAt",
|
||||
"processingStartedAt",
|
||||
"processingCompletedAt",
|
||||
"resultsDurableAt",
|
||||
"resultsReconciledAt",
|
||||
] as const satisfies ReadonlyArray<keyof NativeScannerRunTimingPatch>;
|
||||
|
||||
type TimingTimestampField = typeof timestampFields[number];
|
||||
|
||||
export function nativeScannerRunTimingPath(runDir: string) {
|
||||
return path.join(path.resolve(runDir), NATIVE_SCANNER_RUN_TIMING_FILE);
|
||||
}
|
||||
|
||||
export async function readNativeScannerRunTiming(runDir: string): Promise<NativeScannerRunTiming | null> {
|
||||
const timingPath = nativeScannerRunTimingPath(runDir);
|
||||
try {
|
||||
const raw = JSON.parse(await fs.readFile(timingPath, "utf8")) as unknown;
|
||||
return normalizeTiming(raw);
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds first-observed lifecycle markers to one run's durable timing record.
|
||||
* A marker is deliberately immutable after the first valid observation so
|
||||
* polling or a later renderer retry cannot rewrite the measured interval.
|
||||
*/
|
||||
export async function recordNativeScannerRunTiming(
|
||||
runDir: string,
|
||||
patch: NativeScannerRunTimingPatch,
|
||||
): Promise<NativeScannerRunTiming> {
|
||||
const resolvedRunDir = path.resolve(runDir);
|
||||
const previous = timingWriteQueues.get(resolvedRunDir) ?? Promise.resolve();
|
||||
const task = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const current = await readNativeScannerRunTiming(resolvedRunDir);
|
||||
const next = mergeTiming(current, patch);
|
||||
if (!sameTiming(current, next)) {
|
||||
await writeJsonAtomic(nativeScannerRunTimingPath(resolvedRunDir), next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const settled = task.then(() => undefined, () => undefined);
|
||||
timingWriteQueues.set(resolvedRunDir, settled);
|
||||
void settled.finally(() => {
|
||||
if (timingWriteQueues.get(resolvedRunDir) === settled) timingWriteQueues.delete(resolvedRunDir);
|
||||
});
|
||||
return task;
|
||||
}
|
||||
|
||||
export function validateNativeScannerRunTiming(timing: NativeScannerRunTiming): string[] {
|
||||
const issues: string[] = [];
|
||||
if (timing.version !== NATIVE_SCANNER_RUN_TIMING_VERSION) {
|
||||
issues.push(`unsupported timing version ${String(timing.version)}`);
|
||||
}
|
||||
|
||||
for (const field of timestampFields) {
|
||||
const value = timing[field];
|
||||
if (value !== undefined && !isValidTimestamp(value)) {
|
||||
issues.push(`${field} is not a valid ISO timestamp`);
|
||||
}
|
||||
}
|
||||
|
||||
validateOrder(issues, timing, "requestStartedAt", "scannerStartedAt");
|
||||
validateOrder(issues, timing, "requestStartedAt", "captureStartedAt");
|
||||
validateOrder(issues, timing, "requestStartedAt", "processingStartedAt");
|
||||
validateOrder(issues, timing, "captureStartedAt", "captureCompletedAt");
|
||||
validateOrder(issues, timing, "captureCompletedAt", "processingCompletedAt");
|
||||
validateOrder(issues, timing, "processingStartedAt", "processingCompletedAt");
|
||||
validateOrder(issues, timing, "processingCompletedAt", "resultsDurableAt");
|
||||
validateOrder(issues, timing, "resultsDurableAt", "resultsReconciledAt");
|
||||
|
||||
validateDuration(issues, timing, "requestToResultsDurableMs", "requestStartedAt", "resultsDurableAt");
|
||||
validateDuration(issues, timing, "requestToResultsReconciledMs", "requestStartedAt", "resultsReconciledAt");
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function hasCompleteNativeScannerRunTiming(timing: NativeScannerRunTiming | null | undefined) {
|
||||
return Boolean(
|
||||
timing?.requestStartedAt
|
||||
&& timing.scannerStartedAt
|
||||
&& timing.captureCompletedAt
|
||||
&& timing.processingStartedAt
|
||||
&& timing.processingCompletedAt
|
||||
&& timing.resultsDurableAt
|
||||
&& timing.resultsReconciledAt,
|
||||
);
|
||||
}
|
||||
|
||||
function mergeTiming(current: NativeScannerRunTiming | null, patch: NativeScannerRunTimingPatch): NativeScannerRunTiming {
|
||||
const next: NativeScannerRunTiming = { version: NATIVE_SCANNER_RUN_TIMING_VERSION };
|
||||
for (const field of timestampFields) {
|
||||
const value = firstValidTimestamp(current?.[field], patch[field]);
|
||||
if (value) next[field] = value;
|
||||
}
|
||||
const durableMs = durationMs(next.requestStartedAt, next.resultsDurableAt);
|
||||
if (durableMs !== null) next.requestToResultsDurableMs = durableMs;
|
||||
const reconciledMs = durationMs(next.requestStartedAt, next.resultsReconciledAt);
|
||||
if (reconciledMs !== null) next.requestToResultsReconciledMs = reconciledMs;
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeTiming(value: unknown): NativeScannerRunTiming {
|
||||
if (!value || typeof value !== "object") throw new Error("Native scanner timing file is not an object.");
|
||||
const candidate = value as Partial<NativeScannerRunTiming>;
|
||||
const raw: NativeScannerRunTiming = {
|
||||
version: candidate.version as NativeScannerRunTiming["version"],
|
||||
};
|
||||
for (const field of timestampFields) {
|
||||
if (candidate[field] !== undefined) raw[field] = candidate[field] as string;
|
||||
}
|
||||
if (candidate.requestToResultsDurableMs !== undefined) {
|
||||
raw.requestToResultsDurableMs = candidate.requestToResultsDurableMs as number;
|
||||
}
|
||||
if (candidate.requestToResultsReconciledMs !== undefined) {
|
||||
raw.requestToResultsReconciledMs = candidate.requestToResultsReconciledMs as number;
|
||||
}
|
||||
const issues = validateNativeScannerRunTiming(raw);
|
||||
if (issues.length > 0) throw new Error(`Invalid native scanner timing file: ${issues.join("; ")}`);
|
||||
return mergeTiming(null, raw);
|
||||
}
|
||||
|
||||
function validateOrder(
|
||||
issues: string[],
|
||||
timing: NativeScannerRunTiming,
|
||||
earlier: TimingTimestampField,
|
||||
later: TimingTimestampField,
|
||||
) {
|
||||
const earlierMs = timestampMs(timing[earlier]);
|
||||
const laterMs = timestampMs(timing[later]);
|
||||
if (earlierMs !== null && laterMs !== null && laterMs < earlierMs) {
|
||||
issues.push(`${later} is before ${earlier}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateDuration(
|
||||
issues: string[],
|
||||
timing: NativeScannerRunTiming,
|
||||
field: "requestToResultsDurableMs" | "requestToResultsReconciledMs",
|
||||
startedField: TimingTimestampField,
|
||||
completedField: TimingTimestampField,
|
||||
) {
|
||||
const declared = timing[field];
|
||||
const expected = durationMs(timing[startedField], timing[completedField]);
|
||||
if (declared !== undefined && (!Number.isInteger(declared) || declared < 0)) {
|
||||
issues.push(`${field} is not a non-negative integer`);
|
||||
}
|
||||
if (expected !== null && declared !== expected) {
|
||||
issues.push(`${field} does not match ${startedField} to ${completedField}`);
|
||||
}
|
||||
if (expected === null && declared !== undefined) {
|
||||
issues.push(`${field} exists without both endpoint timestamps`);
|
||||
}
|
||||
}
|
||||
|
||||
function firstValidTimestamp(...values: Array<string | null | undefined>) {
|
||||
return values.find((value): value is string => isValidTimestamp(value));
|
||||
}
|
||||
|
||||
function isValidTimestamp(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
|
||||
}
|
||||
|
||||
function timestampMs(value: unknown) {
|
||||
return isValidTimestamp(value) ? Date.parse(value) : null;
|
||||
}
|
||||
|
||||
function durationMs(startedAt: string | undefined, completedAt: string | undefined) {
|
||||
const started = timestampMs(startedAt);
|
||||
const completed = timestampMs(completedAt);
|
||||
if (started === null || completed === null || completed < started) return null;
|
||||
return Math.round(completed - started);
|
||||
}
|
||||
|
||||
function sameTiming(left: NativeScannerRunTiming | null, right: NativeScannerRunTiming) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
async function writeJsonAtomic(filePath: string, payload: unknown) {
|
||||
const temporaryPath = `${filePath}.tmp-${process.pid}`;
|
||||
await fs.writeFile(temporaryPath, JSON.stringify(payload, null, 2), "utf8");
|
||||
await fs.rename(temporaryPath, filePath);
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown) {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
||||
}
|
||||
Reference in New Issue
Block a user