Complete V1 performance and OOM release gates
This commit is contained in:
@@ -37,6 +37,7 @@ export type ErrorCode =
|
||||
| "GPU_TEXTURE_HASH_MISMATCH"
|
||||
| "GPU_TEXTURE_BUDGET_EXCEEDED"
|
||||
| "GPU_TEXTURE_DECODE_FAILED"
|
||||
| "GPU_GEOMETRY_BUDGET_EXCEEDED"
|
||||
| "UDIM_MANIFEST_INVALID"
|
||||
| "UDIM_TILE_MISSING"
|
||||
| "UDIM_MULTI_TILE_UNAVAILABLE"
|
||||
@@ -97,6 +98,7 @@ export type ErrorCode =
|
||||
| "SEQUENCER_RESOURCE_MISSING"
|
||||
| "SEQUENCER_RESOURCE_OUTSIDE_PROJECT"
|
||||
| "SEQUENCER_CODEC_UNSUPPORTED"
|
||||
| "SEQUENCER_CANCELLED"
|
||||
| "TRACKING_SCHEMA_INVALID"
|
||||
| "TRACKING_BUDGET_EXCEEDED"
|
||||
| "TRACKING_RESOURCE_OUTSIDE_PROJECT"
|
||||
|
||||
210
web/protocol/geometry-stream.ts
Normal file
210
web/protocol/geometry-stream.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
export const MESH_GEOMETRY_STREAM_SCHEMA = 1 as const;
|
||||
export const MESH_GEOMETRY_STREAM_MAX_TRIANGLES = 20_000_000;
|
||||
export const MESH_GEOMETRY_STREAM_MAX_CHUNK_TRIANGLES = 500_000;
|
||||
export const MESH_GEOMETRY_STREAM_MAX_LOD_TRIANGLES = 100_000;
|
||||
|
||||
export interface MeshGeometryStreamRequestIR {
|
||||
schemaVersion: typeof MESH_GEOMETRY_STREAM_SCHEMA;
|
||||
streamId: string;
|
||||
meshId: string;
|
||||
triangleCount: number;
|
||||
chunkTriangleCount: number;
|
||||
lodTriangleCount: number;
|
||||
}
|
||||
|
||||
export interface MeshGeometryStreamChunkIR {
|
||||
schemaVersion: typeof MESH_GEOMETRY_STREAM_SCHEMA;
|
||||
streamId: string;
|
||||
meshId: string;
|
||||
chunkIndex: number;
|
||||
chunkCount: number;
|
||||
triangleOffset: number;
|
||||
triangleCount: number;
|
||||
vertexCount: number;
|
||||
byteLength: number;
|
||||
positions: ArrayBuffer;
|
||||
indices: ArrayBuffer;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface MeshGeometryStreamChunkRecordIR {
|
||||
chunkIndex: number;
|
||||
triangleOffset: number;
|
||||
triangleCount: number;
|
||||
vertexCount: number;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface MeshGeometryStreamReportIR {
|
||||
streamId: string;
|
||||
meshId: string;
|
||||
status: "COMPLETED" | "CANCELLED";
|
||||
triangleCount: number;
|
||||
chunkCount: number;
|
||||
transferredBytes: number;
|
||||
peakWorkingSetBytes: number;
|
||||
manifestSha256?: string;
|
||||
}
|
||||
|
||||
export type MeshGeometryStreamWorkerRequest =
|
||||
| { type: "start"; request: MeshGeometryStreamRequestIR }
|
||||
| { type: "ack"; streamId: string; chunkIndex: number }
|
||||
| { type: "cancel"; streamId: string };
|
||||
|
||||
export type MeshGeometryStreamWorkerResponse =
|
||||
| { type: "lod"; streamId: string; geometry: MeshGeometryStreamChunkIR }
|
||||
| { type: "chunk"; streamId: string; chunk: MeshGeometryStreamChunkIR }
|
||||
| { type: "detached"; streamId: string; chunkIndex: number; positionsByteLength: number; indicesByteLength: number }
|
||||
| { type: "complete"; report: MeshGeometryStreamReportIR; chunks: MeshGeometryStreamChunkRecordIR[] }
|
||||
| { type: "cancelled"; report: MeshGeometryStreamReportIR }
|
||||
| { type: "error"; streamId?: string; code: string; message: string };
|
||||
|
||||
export class MeshGeometryStreamError extends Error {
|
||||
constructor(readonly code: "GEOMETRY_STREAM_INVALID" | "GEOMETRY_STREAM_RANGE_INVALID" | "GEOMETRY_STREAM_BUDGET_EXCEEDED", message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "MeshGeometryStreamError";
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function boundedInteger(value: unknown, name: string, minimum: number, maximum: number): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", `${name} must be an integer in [${minimum}, ${maximum}]`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, name: string): string {
|
||||
if (typeof value !== "string" || !/^[A-Za-z0-9:._-]{1,128}$/.test(value)) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", `${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseMeshGeometryStreamRequest(value: unknown): MeshGeometryStreamRequestIR {
|
||||
if (!record(value) || value.schemaVersion !== MESH_GEOMETRY_STREAM_SCHEMA) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", "unsupported stream request schema");
|
||||
}
|
||||
const request: MeshGeometryStreamRequestIR = {
|
||||
schemaVersion: MESH_GEOMETRY_STREAM_SCHEMA,
|
||||
streamId: identifier(value.streamId, "streamId"),
|
||||
meshId: identifier(value.meshId, "meshId"),
|
||||
triangleCount: boundedInteger(value.triangleCount, "triangleCount", 1, MESH_GEOMETRY_STREAM_MAX_TRIANGLES),
|
||||
chunkTriangleCount: boundedInteger(value.chunkTriangleCount, "chunkTriangleCount", 1, MESH_GEOMETRY_STREAM_MAX_CHUNK_TRIANGLES),
|
||||
lodTriangleCount: boundedInteger(value.lodTriangleCount, "lodTriangleCount", 1, MESH_GEOMETRY_STREAM_MAX_LOD_TRIANGLES),
|
||||
};
|
||||
if (request.lodTriangleCount > request.triangleCount) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", "lodTriangleCount exceeds triangleCount");
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
function hex(bytes: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(bytes), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function sha256(value: BufferSource): Promise<string> {
|
||||
return hex(await crypto.subtle.digest("SHA-256", value));
|
||||
}
|
||||
|
||||
export async function meshGeometryStreamChunkSha256(chunk: Omit<MeshGeometryStreamChunkIR, "sha256">): Promise<string> {
|
||||
const metadata = JSON.stringify([
|
||||
chunk.schemaVersion,
|
||||
chunk.streamId,
|
||||
chunk.meshId,
|
||||
chunk.chunkIndex,
|
||||
chunk.chunkCount,
|
||||
chunk.triangleOffset,
|
||||
chunk.triangleCount,
|
||||
chunk.vertexCount,
|
||||
chunk.byteLength,
|
||||
await sha256(chunk.positions),
|
||||
await sha256(chunk.indices),
|
||||
]);
|
||||
return sha256(new TextEncoder().encode(metadata));
|
||||
}
|
||||
|
||||
export async function meshGeometryStreamManifestSha256(
|
||||
request: MeshGeometryStreamRequestIR,
|
||||
chunks: readonly MeshGeometryStreamChunkRecordIR[],
|
||||
): Promise<string> {
|
||||
const canonical = JSON.stringify({
|
||||
schemaVersion: request.schemaVersion,
|
||||
streamId: request.streamId,
|
||||
meshId: request.meshId,
|
||||
triangleCount: request.triangleCount,
|
||||
chunkTriangleCount: request.chunkTriangleCount,
|
||||
lodTriangleCount: request.lodTriangleCount,
|
||||
chunks,
|
||||
});
|
||||
return sha256(new TextEncoder().encode(canonical));
|
||||
}
|
||||
|
||||
export class MeshGeometryStreamValidator {
|
||||
private readonly request: MeshGeometryStreamRequestIR;
|
||||
private readonly expectedChunkCount: number;
|
||||
private nextChunkIndex = 0;
|
||||
private nextTriangleOffset = 0;
|
||||
private transferredBytes = 0;
|
||||
private peakWorkingSetBytes = 0;
|
||||
private readonly chunks: MeshGeometryStreamChunkRecordIR[] = [];
|
||||
|
||||
constructor(value: MeshGeometryStreamRequestIR) {
|
||||
this.request = parseMeshGeometryStreamRequest(value);
|
||||
this.expectedChunkCount = Math.ceil(this.request.triangleCount / this.request.chunkTriangleCount);
|
||||
}
|
||||
|
||||
async accept(value: MeshGeometryStreamChunkIR): Promise<void> {
|
||||
if (!record(value) || value.schemaVersion !== MESH_GEOMETRY_STREAM_SCHEMA || value.streamId !== this.request.streamId || value.meshId !== this.request.meshId) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk envelope does not match the active stream");
|
||||
}
|
||||
const expectedTriangles = Math.min(this.request.chunkTriangleCount, this.request.triangleCount - this.nextTriangleOffset);
|
||||
if (value.chunkIndex !== this.nextChunkIndex || value.chunkCount !== this.expectedChunkCount || value.triangleOffset !== this.nextTriangleOffset || value.triangleCount !== expectedTriangles) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk ranges are not contiguous and complete");
|
||||
}
|
||||
if (!(value.positions instanceof ArrayBuffer) || !(value.indices instanceof ArrayBuffer) ||
|
||||
!Number.isSafeInteger(value.vertexCount) || value.vertexCount < 3 ||
|
||||
value.positions.byteLength !== value.vertexCount * 3 * Float32Array.BYTES_PER_ELEMENT ||
|
||||
value.indices.byteLength !== value.triangleCount * 3 * Uint32Array.BYTES_PER_ELEMENT ||
|
||||
value.byteLength !== value.positions.byteLength + value.indices.byteLength) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk binary lengths are invalid");
|
||||
}
|
||||
const indices = new Uint32Array(value.indices);
|
||||
for (const index of indices) {
|
||||
if (index >= value.vertexCount) throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk index exceeds its local vertex range");
|
||||
}
|
||||
const { sha256: declaredSha256, ...unsigned } = value;
|
||||
const digest = await meshGeometryStreamChunkSha256(unsigned);
|
||||
if (!/^[a-f0-9]{64}$/.test(declaredSha256) || digest !== declaredSha256) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk SHA-256 mismatch");
|
||||
}
|
||||
this.chunks.push({
|
||||
chunkIndex: value.chunkIndex,
|
||||
triangleOffset: value.triangleOffset,
|
||||
triangleCount: value.triangleCount,
|
||||
vertexCount: value.vertexCount,
|
||||
byteLength: value.byteLength,
|
||||
sha256: declaredSha256,
|
||||
});
|
||||
this.nextChunkIndex += 1;
|
||||
this.nextTriangleOffset += value.triangleCount;
|
||||
this.transferredBytes += value.byteLength;
|
||||
this.peakWorkingSetBytes = Math.max(this.peakWorkingSetBytes, value.byteLength);
|
||||
}
|
||||
|
||||
async finish(): Promise<{ chunks: MeshGeometryStreamChunkRecordIR[]; transferredBytes: number; peakWorkingSetBytes: number; manifestSha256: string }> {
|
||||
if (this.nextChunkIndex !== this.expectedChunkCount || this.nextTriangleOffset !== this.request.triangleCount) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "stream ended before all triangle ranges arrived");
|
||||
}
|
||||
return {
|
||||
chunks: [...this.chunks],
|
||||
transferredBytes: this.transferredBytes,
|
||||
peakWorkingSetBytes: this.peakWorkingSetBytes,
|
||||
manifestSha256: await meshGeometryStreamManifestSha256(this.request, this.chunks),
|
||||
};
|
||||
}
|
||||
}
|
||||
172
web/protocol/oom-recovery.ts
Normal file
172
web/protocol/oom-recovery.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const OOM_RECOVERY_REPORT_SCHEMA = 1 as const;
|
||||
|
||||
export const OOM_RECOVERY_SCENARIOS = [
|
||||
"WASM_MAIN",
|
||||
"OPFS_STAGING",
|
||||
"GPU_RESOURCES",
|
||||
"NANOVDB_RESIDENT",
|
||||
] as const;
|
||||
|
||||
export type OOMRecoveryScenario = typeof OOM_RECOVERY_SCENARIOS[number];
|
||||
|
||||
export const OOM_FAULT_POINTS = [
|
||||
"WASM_MAIN_OPEN_INPUT",
|
||||
"WASM_MAIN_EDIT_COMMAND",
|
||||
"WASM_MAIN_SAVE_RESULT",
|
||||
"OPFS_STAGING_WRITE",
|
||||
"GPU_GEOMETRY_UPLOAD",
|
||||
"GPU_TEXTURE_UPLOAD",
|
||||
"NANOVDB_RESIDENT_BUFFER",
|
||||
"NANOVDB_PAGE_TABLE",
|
||||
] as const;
|
||||
|
||||
export type OOMFaultPoint = typeof OOM_FAULT_POINTS[number];
|
||||
|
||||
export const OOM_FAULT_ERROR: Record<OOMFaultPoint, { code: ErrorCode; stage: string }> = {
|
||||
WASM_MAIN_OPEN_INPUT: { code: "WASM_OUT_OF_MEMORY", stage: "WASM_OPEN_INPUT" },
|
||||
WASM_MAIN_EDIT_COMMAND: { code: "WASM_OUT_OF_MEMORY", stage: "MAIN_EDIT_COMMAND" },
|
||||
WASM_MAIN_SAVE_RESULT: { code: "WASM_OUT_OF_MEMORY", stage: "WASM_SAVE_RESULT" },
|
||||
OPFS_STAGING_WRITE: { code: "STORAGE_QUOTA", stage: "OPFS_STAGING_WRITE" },
|
||||
GPU_GEOMETRY_UPLOAD: { code: "GPU_GEOMETRY_BUDGET_EXCEEDED", stage: "GPU_GEOMETRY_UPLOAD" },
|
||||
GPU_TEXTURE_UPLOAD: { code: "GPU_TEXTURE_BUDGET_EXCEEDED", stage: "GPU_TEXTURE_UPLOAD" },
|
||||
NANOVDB_RESIDENT_BUFFER: { code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_RESIDENT_BUFFER" },
|
||||
NANOVDB_PAGE_TABLE: { code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_PAGE_TABLE" },
|
||||
};
|
||||
|
||||
export interface OOMFaultObservationIR {
|
||||
point: OOMFaultPoint;
|
||||
code: ErrorCode;
|
||||
stage: string;
|
||||
attemptedBytes: number;
|
||||
allocationCount: number;
|
||||
failAfterBytes?: number;
|
||||
failAfterCount?: number;
|
||||
}
|
||||
|
||||
export interface OOMMemoryReportIR {
|
||||
beforeBytes: number;
|
||||
peakBytes: number;
|
||||
afterBytes: number;
|
||||
releasedBytes: number;
|
||||
}
|
||||
|
||||
export interface OOMStateReportIR {
|
||||
revisionBefore: number;
|
||||
revisionAfter: number;
|
||||
hashBefore?: string;
|
||||
hashAfter?: string;
|
||||
temporaryResourcesBefore: number;
|
||||
temporaryResourcesPeak: number;
|
||||
temporaryResourcesAfter: number;
|
||||
}
|
||||
|
||||
export interface OOMRecoveryReportIR {
|
||||
schemaVersion: typeof OOM_RECOVERY_REPORT_SCHEMA;
|
||||
scenario: OOMRecoveryScenario;
|
||||
faults: OOMFaultObservationIR[];
|
||||
memory: OOMMemoryReportIR;
|
||||
state: OOMStateReportIR;
|
||||
recovery: {
|
||||
recovered: boolean;
|
||||
sameSession: boolean;
|
||||
restartedSession: boolean;
|
||||
tokenIsolated: boolean;
|
||||
};
|
||||
checks: string[];
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`OOM_REPORT_INVALID: ${label}`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`OOM_REPORT_INVALID: ${label}`);
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function parseOOMRecoveryReport(value: unknown): OOMRecoveryReportIR {
|
||||
const source = record(value, "report must be an object");
|
||||
if (source.schemaVersion !== OOM_RECOVERY_REPORT_SCHEMA || !OOM_RECOVERY_SCENARIOS.includes(source.scenario as OOMRecoveryScenario)) {
|
||||
throw new Error("OOM_REPORT_INVALID: schema or scenario");
|
||||
}
|
||||
if (!Array.isArray(source.faults) || source.faults.length === 0) throw new Error("OOM_REPORT_INVALID: faults");
|
||||
const faults = source.faults.map((candidate, index): OOMFaultObservationIR => {
|
||||
const fault = record(candidate, `fault ${index}`);
|
||||
const point = fault.point as OOMFaultPoint;
|
||||
if (!OOM_FAULT_POINTS.includes(point)) throw new Error(`OOM_REPORT_INVALID: fault ${index} point`);
|
||||
const expected = OOM_FAULT_ERROR[point];
|
||||
if (fault.code !== expected.code || fault.stage !== expected.stage) throw new Error(`OOM_REPORT_INVALID: fault ${index} mapping`);
|
||||
const parsed: OOMFaultObservationIR = {
|
||||
point,
|
||||
code: expected.code,
|
||||
stage: expected.stage,
|
||||
attemptedBytes: integer(fault.attemptedBytes, `fault ${index} attemptedBytes`),
|
||||
allocationCount: integer(fault.allocationCount, `fault ${index} allocationCount`),
|
||||
};
|
||||
if (fault.failAfterBytes !== undefined) parsed.failAfterBytes = integer(fault.failAfterBytes, `fault ${index} failAfterBytes`);
|
||||
if (fault.failAfterCount !== undefined) parsed.failAfterCount = integer(fault.failAfterCount, `fault ${index} failAfterCount`);
|
||||
if (parsed.failAfterBytes === undefined && parsed.failAfterCount === undefined) throw new Error(`OOM_REPORT_INVALID: fault ${index} threshold`);
|
||||
return parsed;
|
||||
});
|
||||
|
||||
const rawMemory = record(source.memory, "memory");
|
||||
const memory: OOMMemoryReportIR = {
|
||||
beforeBytes: integer(rawMemory.beforeBytes, "memory.beforeBytes"),
|
||||
peakBytes: integer(rawMemory.peakBytes, "memory.peakBytes"),
|
||||
afterBytes: integer(rawMemory.afterBytes, "memory.afterBytes"),
|
||||
releasedBytes: integer(rawMemory.releasedBytes, "memory.releasedBytes"),
|
||||
};
|
||||
if (memory.peakBytes < memory.beforeBytes || memory.peakBytes < memory.afterBytes) throw new Error("OOM_REPORT_INVALID: memory peak");
|
||||
|
||||
const rawState = record(source.state, "state");
|
||||
const state: OOMStateReportIR = {
|
||||
revisionBefore: integer(rawState.revisionBefore, "state.revisionBefore"),
|
||||
revisionAfter: integer(rawState.revisionAfter, "state.revisionAfter"),
|
||||
temporaryResourcesBefore: integer(rawState.temporaryResourcesBefore, "state.temporaryResourcesBefore"),
|
||||
temporaryResourcesPeak: integer(rawState.temporaryResourcesPeak, "state.temporaryResourcesPeak"),
|
||||
temporaryResourcesAfter: integer(rawState.temporaryResourcesAfter, "state.temporaryResourcesAfter"),
|
||||
};
|
||||
if (rawState.hashBefore !== undefined) {
|
||||
if (typeof rawState.hashBefore !== "string" || !SHA256.test(rawState.hashBefore)) throw new Error("OOM_REPORT_INVALID: state.hashBefore");
|
||||
state.hashBefore = rawState.hashBefore;
|
||||
}
|
||||
if (rawState.hashAfter !== undefined) {
|
||||
if (typeof rawState.hashAfter !== "string" || !SHA256.test(rawState.hashAfter)) throw new Error("OOM_REPORT_INVALID: state.hashAfter");
|
||||
state.hashAfter = rawState.hashAfter;
|
||||
}
|
||||
if ((state.hashBefore === undefined) !== (state.hashAfter === undefined)) throw new Error("OOM_REPORT_INVALID: state hashes must be paired");
|
||||
if (state.temporaryResourcesPeak < state.temporaryResourcesBefore || state.temporaryResourcesPeak < state.temporaryResourcesAfter) {
|
||||
throw new Error("OOM_REPORT_INVALID: temporary resource peak");
|
||||
}
|
||||
|
||||
const rawRecovery = record(source.recovery, "recovery");
|
||||
const recovery = {
|
||||
recovered: rawRecovery.recovered,
|
||||
sameSession: rawRecovery.sameSession,
|
||||
restartedSession: rawRecovery.restartedSession,
|
||||
tokenIsolated: rawRecovery.tokenIsolated,
|
||||
};
|
||||
if (Object.values(recovery).some((candidate) => typeof candidate !== "boolean") || !recovery.recovered || !recovery.tokenIsolated || (!recovery.sameSession && !recovery.restartedSession)) {
|
||||
throw new Error("OOM_REPORT_INVALID: recovery");
|
||||
}
|
||||
if (!Array.isArray(source.checks) || source.checks.length === 0 || source.checks.some((check) => typeof check !== "string" || check.length === 0)) {
|
||||
throw new Error("OOM_REPORT_INVALID: checks");
|
||||
}
|
||||
|
||||
return { schemaVersion: OOM_RECOVERY_REPORT_SCHEMA, scenario: source.scenario as OOMRecoveryScenario, faults, memory, state, recovery: recovery as OOMRecoveryReportIR["recovery"], checks: [...new Set(source.checks as string[])] };
|
||||
}
|
||||
|
||||
export function parseOOMRecoverySuite(value: unknown): OOMRecoveryReportIR[] {
|
||||
if (!Array.isArray(value)) throw new Error("OOM_REPORT_INVALID: suite");
|
||||
const reports = value.map(parseOOMRecoveryReport);
|
||||
if (reports.length !== OOM_RECOVERY_SCENARIOS.length || new Set(reports.map((report) => report.scenario)).size !== OOM_RECOVERY_SCENARIOS.length) {
|
||||
throw new Error("OOM_REPORT_INVALID: suite must contain each scenario exactly once");
|
||||
}
|
||||
for (const scenario of OOM_RECOVERY_SCENARIOS) if (!reports.some((report) => report.scenario === scenario)) throw new Error(`OOM_REPORT_INVALID: missing ${scenario}`);
|
||||
return reports;
|
||||
}
|
||||
@@ -1,9 +1,25 @@
|
||||
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const RELEASE_GATE_SCHEMA = 3 as const;
|
||||
export type ParityStatus = "LOCAL_EXACT" | "LOCAL_BOUNDED" | "SERVER" | "BLOCKED";
|
||||
export interface ParityFamilyEvidenceIR { id: string; name: string; status: ParityStatus; roadmapStatus: "completed" | "in_progress" | "planned"; completedSlices: string[]; blockedSlices: string[]; excludedSlices: string[]; acceptance: string[]; dependencies: string[] }
|
||||
export const RELEASE_GATE_SCHEMA = 4 as const;
|
||||
export type ParityStatus = "COMPLETE" | "BLOCKED";
|
||||
export type ReleaseClass = "LOCAL_EXACT" | "LOCAL_BOUNDED" | "SERVER" | "EXCLUDED";
|
||||
export type ReleaseStatus = "READY" | "BLOCKED";
|
||||
export interface ParityFamilyEvidenceIR {
|
||||
id: string;
|
||||
name: string;
|
||||
parityStatus: ParityStatus;
|
||||
releaseClass: ReleaseClass;
|
||||
releaseStatus: ReleaseStatus;
|
||||
roadmapStatus: "completed" | "in_progress" | "planned";
|
||||
completedSlices: string[];
|
||||
blockedSlices: string[];
|
||||
excludedSlices: string[];
|
||||
v1RequiredSlices: string[];
|
||||
v1ExcludedSlices: string[];
|
||||
acceptance: string[];
|
||||
dependencies: string[];
|
||||
}
|
||||
export interface ReleaseEvidenceIR {
|
||||
browser: { chromium: boolean };
|
||||
runtime: { offline: boolean; workerRestart: boolean; opfsRecovery: boolean };
|
||||
@@ -21,7 +37,9 @@ export class ReleaseGateValidationError extends Error {
|
||||
constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "ReleaseGateValidationError"; this.code = code; }
|
||||
}
|
||||
|
||||
const STATUSES = new Set<ParityStatus>(["LOCAL_EXACT", "LOCAL_BOUNDED", "SERVER", "BLOCKED"]);
|
||||
const PARITY_STATUSES = new Set<ParityStatus>(["COMPLETE", "BLOCKED"]);
|
||||
const RELEASE_CLASSES = new Set<ReleaseClass>(["LOCAL_EXACT", "LOCAL_BOUNDED", "SERVER", "EXCLUDED"]);
|
||||
const RELEASE_STATUSES = new Set<ReleaseStatus>(["READY", "BLOCKED"]);
|
||||
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
||||
function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); return value; }
|
||||
function bool(value: unknown, name: string): boolean { if (typeof value !== "boolean") throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must be boolean`); return value; }
|
||||
@@ -59,7 +77,59 @@ function assertDependencies(families: readonly ParityFamilyEvidenceIR[]): void {
|
||||
|
||||
export function parseReleaseManifest(value: unknown): ReleaseManifestIR {
|
||||
if (!record(value) || value.schemaVersion !== RELEASE_GATE_SCHEMA || !Array.isArray(value.families)) throw new ReleaseGateValidationError("PROTOCOL_MISMATCH", "Unsupported release manifest schema");
|
||||
const ids = new Set<string>(); const families = value.families.map((item, index): ParityFamilyEvidenceIR => { const name = `families[${index}]`; if (!record(item) || !STATUSES.has(item.status as ParityStatus) || !["completed", "in_progress", "planned"].includes(item.roadmapStatus as string)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate family ${id}`); ids.add(id); const completedSlices = strings(item.completedSlices, `${name}.completedSlices`); const blockedSlices = strings(item.blockedSlices, `${name}.blockedSlices`); const excludedSlices = strings(item.excludedSlices ?? [], `${name}.excludedSlices`); const declared = [...completedSlices, ...blockedSlices, ...excludedSlices]; if (new Set(declared).size !== declared.length) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} declares a slice in more than one state`); if (item.status !== "BLOCKED" && completedSlices.length === 0) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must declare completed slices`); return { id, name: text(item.name, `${name}.name`), status: item.status as ParityStatus, roadmapStatus: item.roadmapStatus as ParityFamilyEvidenceIR["roadmapStatus"], completedSlices, blockedSlices, excludedSlices, acceptance: strings(item.acceptance, `${name}.acceptance`), dependencies: strings(item.dependencies, `${name}.dependencies`) }; });
|
||||
const ids = new Set<string>();
|
||||
const families = value.families.map((item, index): ParityFamilyEvidenceIR => {
|
||||
const name = `families[${index}]`;
|
||||
if (!record(item) || !PARITY_STATUSES.has(item.parityStatus as ParityStatus) ||
|
||||
!RELEASE_CLASSES.has(item.releaseClass as ReleaseClass) || !RELEASE_STATUSES.has(item.releaseStatus as ReleaseStatus) ||
|
||||
!["completed", "in_progress", "planned"].includes(item.roadmapStatus as string)) {
|
||||
throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`);
|
||||
}
|
||||
const id = text(item.id, `${name}.id`);
|
||||
if (ids.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate family ${id}`);
|
||||
ids.add(id);
|
||||
const completedSlices = strings(item.completedSlices, `${name}.completedSlices`);
|
||||
const blockedSlices = strings(item.blockedSlices, `${name}.blockedSlices`);
|
||||
const excludedSlices = strings(item.excludedSlices ?? [], `${name}.excludedSlices`);
|
||||
const v1RequiredSlices = strings(item.v1RequiredSlices, `${name}.v1RequiredSlices`);
|
||||
const v1ExcludedSlices = strings(item.v1ExcludedSlices, `${name}.v1ExcludedSlices`);
|
||||
const declared = [...completedSlices, ...blockedSlices, ...excludedSlices];
|
||||
if (new Set(declared).size !== declared.length) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} declares a slice in more than one parity state`);
|
||||
if (v1RequiredSlices.length === 0 || new Set(v1RequiredSlices).size !== v1RequiredSlices.length || new Set(v1ExcludedSlices).size !== v1ExcludedSlices.length) {
|
||||
throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} has invalid V1 slices`);
|
||||
}
|
||||
const declaredStates = new Map(declared.map((slice) => [slice, completedSlices.includes(slice) ? "completed" : blockedSlices.includes(slice) ? "blocked" : "excluded"]));
|
||||
if (v1RequiredSlices.some((slice) => !declaredStates.has(slice)) || v1ExcludedSlices.some((slice) => !declaredStates.has(slice))) {
|
||||
throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} references an undeclared V1 slice`);
|
||||
}
|
||||
if (v1RequiredSlices.some((slice) => v1ExcludedSlices.includes(slice)) || v1ExcludedSlices.some((slice) => declaredStates.get(slice) === "completed")) {
|
||||
throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} overlaps required/excluded V1 slices or excludes a completed slice`);
|
||||
}
|
||||
const blockedRequired = v1RequiredSlices.filter((slice) => declaredStates.get(slice) !== "completed");
|
||||
if ((item.releaseStatus === "READY" && blockedRequired.length > 0) || (item.releaseStatus === "BLOCKED" && blockedRequired.length === 0)) {
|
||||
throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.releaseStatus disagrees with its V1 required slices`);
|
||||
}
|
||||
if (item.parityStatus === "COMPLETE" && (blockedSlices.length > 0 || excludedSlices.length > 0)) {
|
||||
throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.parityStatus cannot be COMPLETE with unresolved slices`);
|
||||
}
|
||||
const acceptance = strings(item.acceptance, `${name}.acceptance`);
|
||||
if (item.releaseStatus === "READY" && acceptance.length === 0) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} has no V1 acceptance command`);
|
||||
return {
|
||||
id,
|
||||
name: text(item.name, `${name}.name`),
|
||||
parityStatus: item.parityStatus as ParityStatus,
|
||||
releaseClass: item.releaseClass as ReleaseClass,
|
||||
releaseStatus: item.releaseStatus as ReleaseStatus,
|
||||
roadmapStatus: item.roadmapStatus as ParityFamilyEvidenceIR["roadmapStatus"],
|
||||
completedSlices,
|
||||
blockedSlices,
|
||||
excludedSlices,
|
||||
v1RequiredSlices,
|
||||
v1ExcludedSlices,
|
||||
acceptance,
|
||||
dependencies: strings(item.dependencies, `${name}.dependencies`),
|
||||
};
|
||||
});
|
||||
assertDependencies(families);
|
||||
const sourceSha256 = text(value.sourceSha256, "sourceSha256", 64); if (!/^[a-f0-9]{64}$/.test(sourceSha256)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "sourceSha256 is invalid");
|
||||
return { schemaVersion: RELEASE_GATE_SCHEMA, source: text(value.source, "source", 2048), sourceSha256, generatedAt: utcTimestamp(value.generatedAt, "generatedAt"), families, evidence: parseEvidence(value.evidence) };
|
||||
@@ -68,7 +138,12 @@ export function parseReleaseManifest(value: unknown): ReleaseManifestIR {
|
||||
export function evaluateReleaseManifest(value: unknown): ReleaseGateEvaluationIR {
|
||||
const manifest = parseReleaseManifest(value); const missing: string[] = []; const issueCodes: ErrorCode[] = [];
|
||||
const add = (path: string, code: ErrorCode): void => { missing.push(path); if (!issueCodes.includes(code)) issueCodes.push(code); };
|
||||
manifest.families.forEach((family) => { if (family.status === "BLOCKED") add(`family.${family.id}`, "RELEASE_EVIDENCE_MISSING"); if (family.status !== "BLOCKED" && family.acceptance.length === 0) add(`family.${family.id}.acceptance`, "RELEASE_EVIDENCE_MISSING"); });
|
||||
manifest.families.forEach((family) => {
|
||||
if (family.releaseStatus !== "BLOCKED") return;
|
||||
for (const slice of family.v1RequiredSlices.filter((item) => !family.completedSlices.includes(item))) {
|
||||
add(`family.${family.id}.${slice}`, "RELEASE_EVIDENCE_MISSING");
|
||||
}
|
||||
});
|
||||
(Object.entries(manifest.evidence.browser) as [string, boolean][]).forEach(([key, ok]) => { if (!ok) add(`browser.${key}`, "RELEASE_TEST_CHANNEL_MISSING"); });
|
||||
(Object.entries(manifest.evidence.runtime) as [string, boolean][]).forEach(([key, ok]) => { if (!ok) add(`runtime.${key}`, "RELEASE_EVIDENCE_MISSING"); });
|
||||
(Object.entries(manifest.evidence.performance) as [string, boolean][]).forEach(([key, ok]) => { if (!ok) add(`performance.${key}`, "RELEASE_PERFORMANCE_MISSING"); });
|
||||
|
||||
Reference in New Issue
Block a user