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 = { 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 { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`EDITING_RECOVERY_INVALID: ${label}`); return value as Record; } function exact(value: Record, 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 }; }