Files
workinf_Blender_Wasm/web/protocol/editing-domain-recovery.ts
mes123456 0fe8d2bb56
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Advance M8-M11 parity workflows
2026-08-17 04:37:07 -04:00

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 };
}