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>(); const timestampFields = [ "requestStartedAt", "scannerStartedAt", "captureStartedAt", "captureCompletedAt", "processingStartedAt", "processingCompletedAt", "resultsDurableAt", "resultsReconciledAt", ] as const satisfies ReadonlyArray; 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 { 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 { 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; 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) { 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"); }