198 lines
11 KiB
TypeScript
198 lines
11 KiB
TypeScript
import type { SceneSnapshotIR } from "./scene-ir";
|
|
|
|
export const EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION = 1 as const;
|
|
export const EDITING_DOMAINS = ["CURVE", "GREASE_PENCIL", "PAINT"] as const;
|
|
export type EditingDomain = typeof EDITING_DOMAINS[number];
|
|
|
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
const DOMAIN_PREFIX: Record<EditingDomain, string> = {
|
|
CURVE: "",
|
|
GREASE_PENCIL: "grease-pencil:",
|
|
PAINT: "mesh:",
|
|
};
|
|
|
|
export interface EditingDomainIdentityIR {
|
|
objectIds: string[];
|
|
dataIds: string[];
|
|
objectCount: number;
|
|
}
|
|
|
|
export interface EditingDomainRecoveryEvidenceIR {
|
|
schemaVersion: typeof EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION;
|
|
domain: EditingDomain;
|
|
baseline: EditingDomainIdentityIR & { revision: number; identityHash: string };
|
|
workerRestart: {
|
|
status: "RECOVERED";
|
|
workerGeneration: number;
|
|
revisionBefore: number;
|
|
revisionAfter: number;
|
|
hashBefore: string;
|
|
hashAfter: string;
|
|
liveHandles: number;
|
|
temporaryResourcesAfter: 0;
|
|
};
|
|
oom: {
|
|
status: "RECOVERED";
|
|
faultPoint: "GPU_GEOMETRY_UPLOAD";
|
|
code: "GPU_GEOMETRY_BUDGET_EXCEEDED";
|
|
revisionBefore: number;
|
|
revisionAfter: number;
|
|
hashBefore: string;
|
|
hashAfter: string;
|
|
releasedBytes: number;
|
|
temporaryResourcesAfter: 0;
|
|
};
|
|
gpuRelease: {
|
|
status: "RECOVERED";
|
|
backend: "WEBGL2";
|
|
releaseCount: number;
|
|
reinitCount: number;
|
|
disposedResources: number;
|
|
visiblePixels: number;
|
|
pixelHashBefore: string;
|
|
pixelHashAfter: string;
|
|
};
|
|
smallScene: {
|
|
status: "RECOVERED";
|
|
revision: number;
|
|
identityHash: string;
|
|
objectCount: number;
|
|
dataIds: string[];
|
|
visiblePixels: number;
|
|
};
|
|
}
|
|
|
|
function record(value: unknown, label: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`EDITING_RECOVERY_INVALID: ${label}`);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function exact(value: Record<string, unknown>, fields: readonly string[], label: string): void {
|
|
const allowed = new Set(fields);
|
|
if (Object.keys(value).some((field) => !allowed.has(field))) throw new Error(`EDITING_RECOVERY_INVALID: ${label} contains undeclared fields`);
|
|
}
|
|
|
|
function integer(value: unknown, label: string, minimum = 0): number {
|
|
if (!Number.isSafeInteger(value) || (value as number) < minimum) throw new Error(`EDITING_RECOVERY_INVALID: ${label}`);
|
|
return value as number;
|
|
}
|
|
|
|
function digest(value: unknown, label: string): string {
|
|
if (typeof value !== "string" || !SHA256.test(value)) throw new Error(`EDITING_RECOVERY_INVALID: ${label}`);
|
|
return value;
|
|
}
|
|
|
|
function ids(value: unknown, label: string, prefix?: string): string[] {
|
|
if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || item.length === 0 || (prefix !== undefined && !item.startsWith(prefix)))) {
|
|
throw new Error(`EDITING_RECOVERY_INVALID: ${label}`);
|
|
}
|
|
const result = [...new Set(value as string[])].sort();
|
|
if (result.length !== value.length) throw new Error(`EDITING_RECOVERY_INVALID: ${label} contains duplicates`);
|
|
return result;
|
|
}
|
|
|
|
function parseIdentity(value: unknown, label: string, domain: EditingDomain): EditingDomainIdentityIR & { revision?: number; identityHash?: string } {
|
|
const source = record(value, label);
|
|
exact(source, ["objectIds", "dataIds", "objectCount", "revision", "identityHash"], label);
|
|
const objectIds = ids(source.objectIds, `${label}.objectIds`);
|
|
const dataIds = ids(source.dataIds, `${label}.dataIds`, DOMAIN_PREFIX[domain] || undefined);
|
|
const objectCount = integer(source.objectCount, `${label}.objectCount`, 1);
|
|
if (objectCount !== objectIds.length) throw new Error(`EDITING_RECOVERY_INVALID: ${label}.objectCount does not match objectIds`);
|
|
if (source.revision !== undefined) integer(source.revision, `${label}.revision`);
|
|
if (source.identityHash !== undefined) digest(source.identityHash, `${label}.identityHash`);
|
|
return {
|
|
objectIds,
|
|
dataIds,
|
|
objectCount,
|
|
...(source.revision === undefined ? {} : { revision: source.revision as number }),
|
|
...(source.identityHash === undefined ? {} : { identityHash: source.identityHash as string }),
|
|
};
|
|
}
|
|
|
|
function parseHashPair(value: unknown, label: string, preserveRevision: boolean): { revisionBefore: number; revisionAfter: number; hashBefore: string; hashAfter: string } {
|
|
const source = record(value, label);
|
|
const revisionBefore = integer(source.revisionBefore, `${label}.revisionBefore`);
|
|
const revisionAfter = integer(source.revisionAfter, `${label}.revisionAfter`);
|
|
const hashBefore = digest(source.hashBefore, `${label}.hashBefore`);
|
|
const hashAfter = digest(source.hashAfter, `${label}.hashAfter`);
|
|
if (hashBefore !== hashAfter || (preserveRevision && revisionBefore !== revisionAfter)) throw new Error(`EDITING_RECOVERY_INVALID: ${label} did not preserve the committed identity`);
|
|
return { revisionBefore, revisionAfter, hashBefore, hashAfter };
|
|
}
|
|
|
|
export function parseEditingDomainRecoveryEvidence(value: unknown): EditingDomainRecoveryEvidenceIR {
|
|
const source = record(value, "evidence must be an object");
|
|
exact(source, ["schemaVersion", "domain", "baseline", "workerRestart", "oom", "gpuRelease", "smallScene"], "evidence");
|
|
if (source.schemaVersion !== EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION || !EDITING_DOMAINS.includes(source.domain as EditingDomain)) {
|
|
throw new Error("EDITING_RECOVERY_INVALID: schemaVersion or domain");
|
|
}
|
|
const domain = source.domain as EditingDomain;
|
|
const baseline = parseIdentity(source.baseline, "baseline", domain);
|
|
if (baseline.revision === undefined || baseline.identityHash === undefined) throw new Error("EDITING_RECOVERY_INVALID: baseline identity is incomplete");
|
|
|
|
const worker = record(source.workerRestart, "workerRestart");
|
|
exact(worker, ["status", "workerGeneration", "revisionBefore", "revisionAfter", "hashBefore", "hashAfter", "liveHandles", "temporaryResourcesAfter"], "workerRestart");
|
|
const workerPair = parseHashPair(worker, "workerRestart", false);
|
|
const workerGeneration = integer(worker.workerGeneration, "workerRestart.workerGeneration", 1);
|
|
const liveHandles = integer(worker.liveHandles, "workerRestart.liveHandles", 1);
|
|
if (worker.temporaryResourcesAfter !== 0) throw new Error("EDITING_RECOVERY_INVALID: workerRestart.temporaryResourcesAfter");
|
|
|
|
const oom = record(source.oom, "oom");
|
|
exact(oom, ["status", "faultPoint", "code", "revisionBefore", "revisionAfter", "hashBefore", "hashAfter", "releasedBytes", "temporaryResourcesAfter"], "oom");
|
|
const oomPair = parseHashPair(oom, "oom", true);
|
|
if (oom.faultPoint !== "GPU_GEOMETRY_UPLOAD" || oom.code !== "GPU_GEOMETRY_BUDGET_EXCEEDED" || integer(oom.releasedBytes, "oom.releasedBytes", 1) < 1 || oom.temporaryResourcesAfter !== 0) {
|
|
throw new Error("EDITING_RECOVERY_INVALID: oom fault mapping or cleanup");
|
|
}
|
|
|
|
const gpu = record(source.gpuRelease, "gpuRelease");
|
|
exact(gpu, ["status", "backend", "releaseCount", "reinitCount", "disposedResources", "visiblePixels", "pixelHashBefore", "pixelHashAfter"], "gpuRelease");
|
|
if (gpu.status !== "RECOVERED" || gpu.backend !== "WEBGL2") throw new Error("EDITING_RECOVERY_INVALID: gpuRelease status");
|
|
const releaseCount = integer(gpu.releaseCount, "gpuRelease.releaseCount", 1);
|
|
const reinitCount = integer(gpu.reinitCount, "gpuRelease.reinitCount", 1);
|
|
const disposedResources = integer(gpu.disposedResources, "gpuRelease.disposedResources", 1);
|
|
const visiblePixels = integer(gpu.visiblePixels, "gpuRelease.visiblePixels", 1);
|
|
const pixelHashBefore = digest(gpu.pixelHashBefore, "gpuRelease.pixelHashBefore");
|
|
const pixelHashAfter = digest(gpu.pixelHashAfter, "gpuRelease.pixelHashAfter");
|
|
if (releaseCount !== 1 || reinitCount !== 1) throw new Error("EDITING_RECOVERY_INVALID: gpuRelease must release and reinitialize exactly once");
|
|
|
|
const small = record(source.smallScene, "smallScene");
|
|
exact(small, ["status", "revision", "identityHash", "objectCount", "dataIds", "visiblePixels"], "smallScene");
|
|
if (small.status !== "RECOVERED") throw new Error("EDITING_RECOVERY_INVALID: smallScene.status");
|
|
const smallRevision = integer(small.revision, "smallScene.revision");
|
|
const smallIdentityHash = digest(small.identityHash, "smallScene.identityHash");
|
|
const smallObjectCount = integer(small.objectCount, "smallScene.objectCount", 1);
|
|
const smallDataIds = ids(small.dataIds, "smallScene.dataIds", DOMAIN_PREFIX[domain] || undefined);
|
|
const smallVisiblePixels = integer(small.visiblePixels, "smallScene.visiblePixels", 1);
|
|
if (smallIdentityHash !== baseline.identityHash || smallRevision !== baseline.revision || smallObjectCount !== baseline.objectCount || smallDataIds.join("\0") !== baseline.dataIds.join("\0")) {
|
|
throw new Error("EDITING_RECOVERY_INVALID: smallScene identity does not match the committed baseline");
|
|
}
|
|
|
|
return {
|
|
schemaVersion: EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION,
|
|
domain,
|
|
baseline: { ...baseline, revision: baseline.revision, identityHash: baseline.identityHash },
|
|
workerRestart: { status: "RECOVERED", ...workerPair, workerGeneration, liveHandles, temporaryResourcesAfter: 0 },
|
|
oom: { status: "RECOVERED", faultPoint: "GPU_GEOMETRY_UPLOAD", code: "GPU_GEOMETRY_BUDGET_EXCEEDED", ...oomPair, releasedBytes: oom.releasedBytes as number, temporaryResourcesAfter: 0 },
|
|
gpuRelease: { status: "RECOVERED", backend: "WEBGL2", releaseCount, reinitCount, disposedResources, visiblePixels, pixelHashBefore, pixelHashAfter },
|
|
smallScene: { status: "RECOVERED", revision: smallRevision, identityHash: smallIdentityHash, objectCount: smallObjectCount, dataIds: smallDataIds, visiblePixels: smallVisiblePixels },
|
|
};
|
|
}
|
|
|
|
export function parseEditingDomainRecoverySuite(value: unknown): EditingDomainRecoveryEvidenceIR[] {
|
|
if (!Array.isArray(value) || value.length !== EDITING_DOMAINS.length) throw new Error("EDITING_RECOVERY_INVALID: suite must contain all editing domains");
|
|
const reports = value.map(parseEditingDomainRecoveryEvidence);
|
|
if (new Set(reports.map((report) => report.domain)).size !== EDITING_DOMAINS.length) throw new Error("EDITING_RECOVERY_INVALID: duplicate editing domain");
|
|
return EDITING_DOMAINS.map((domain) => reports.find((report) => report.domain === domain)!);
|
|
}
|
|
|
|
export function summarizeEditingDomain(snapshot: SceneSnapshotIR, domain: EditingDomain): EditingDomainIdentityIR {
|
|
const nodes = snapshot.nodes.filter((node) => {
|
|
if (domain === "CURVE") return node.visible && (node.type === "CURVE" || node.type === "SURFACE") && node.dataId !== null;
|
|
if (domain === "GREASE_PENCIL") return node.visible && node.type === "GREASE_PENCIL" && node.dataId !== null;
|
|
return node.visible && node.type === "MESH" && node.dataId !== null;
|
|
});
|
|
const objectIds = [...new Set(nodes.map((node) => node.id))].sort();
|
|
const dataIds = [...new Set(nodes.flatMap((node) => node.dataId ? [node.dataId] : []))].sort();
|
|
if (objectIds.length === 0 || dataIds.length === 0) throw new Error(`EDITING_RECOVERY_DOMAIN_MISSING: ${domain}`);
|
|
return { objectIds, dataIds, objectCount: objectIds.length };
|
|
}
|