Advance M7 workflows and release operations
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-08-15 17:43:53 -04:00
parent 17ab961485
commit 7c16b279ae
103 changed files with 8064 additions and 429 deletions

View File

@@ -0,0 +1,53 @@
export interface DirtyState {
currentMainRevision: number;
committedMainRevision: number;
dirty: boolean;
}
export type DirtyStateResult =
| { ok: true; state: DirtyState }
| { ok: false; state: DirtyState; errorCode: "DIRTY_REVISION_INVALID" | "DIRTY_REVISION_STALE" | "DIRTY_SAVE_REVISION_MISMATCH" };
function validRevision(revision: number): boolean {
return Number.isSafeInteger(revision) && revision >= 0;
}
export function createDirtyState(revision = 0): DirtyState {
if (!validRevision(revision)) throw new Error("DIRTY_REVISION_INVALID");
return { currentMainRevision: revision, committedMainRevision: revision, dirty: false };
}
export function recoverDirtyState(currentMainRevision: number, committedMainRevision: number): DirtyState {
if (!validRevision(currentMainRevision) || !validRevision(committedMainRevision) || currentMainRevision < committedMainRevision) {
throw new Error("DIRTY_REVISION_INVALID");
}
return { currentMainRevision, committedMainRevision, dirty: currentMainRevision !== committedMainRevision };
}
export function acceptMainTransaction(state: DirtyState, revision: number): DirtyStateResult {
if (!validRevision(revision)) return { ok: false, state, errorCode: "DIRTY_REVISION_INVALID" };
if (revision <= state.currentMainRevision) return { ok: false, state, errorCode: "DIRTY_REVISION_STALE" };
return {
ok: true,
state: { ...state, currentMainRevision: revision, dirty: revision !== state.committedMainRevision },
};
}
export function acceptMainSave(state: DirtyState, revision: number): DirtyStateResult {
if (!validRevision(revision)) return { ok: false, state, errorCode: "DIRTY_REVISION_INVALID" };
if (revision !== state.currentMainRevision) return { ok: false, state, errorCode: "DIRTY_SAVE_REVISION_MISMATCH" };
return { ok: true, state: { currentMainRevision: revision, committedMainRevision: revision, dirty: false } };
}
export function acceptHistoryTransaction(state: DirtyState, revision: number, matchesCommittedContent: boolean): DirtyStateResult {
if (!validRevision(revision)) return { ok: false, state, errorCode: "DIRTY_REVISION_INVALID" };
if (revision <= state.currentMainRevision) return { ok: false, state, errorCode: "DIRTY_REVISION_STALE" };
return {
ok: true,
state: {
currentMainRevision: revision,
committedMainRevision: matchesCommittedContent ? revision : state.committedMainRevision,
dirty: !matchesCommittedContent,
},
};
}

View File

@@ -0,0 +1,96 @@
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { WebEngineManifestV2, WebEngineVariantV2 } from "./manifest";
export interface WasmThreadingCapabilities {
crossOriginIsolated: boolean;
sharedArrayBuffer: boolean;
worker: boolean;
}
export type WebEngineVariantPolicy = "AUTO" | "SINGLE_REQUIRED" | "PTHREAD_REQUIRED";
export interface WebEngineVariantSelection {
policy: WebEngineVariantPolicy;
selectedVariant: WebEngineVariantV2 | null;
pthreadGate: CapabilityGateResult;
}
export type WebEngineReleaseBinding =
| { status: "READY"; releaseId: string; manifest: WebEngineManifestV2 }
| {
status: "REFRESH_REQUIRED";
expectedReleaseId: string;
actualReleaseId: string;
manifest: null;
};
export function bindWebEngineRelease(
expectedReleaseId: string,
manifest: WebEngineManifestV2,
): WebEngineReleaseBinding {
if (manifest.releaseId === expectedReleaseId) {
return { status: "READY", releaseId: manifest.releaseId, manifest };
}
return {
status: "REFRESH_REQUIRED",
expectedReleaseId,
actualReleaseId: manifest.releaseId,
manifest: null,
};
}
export function gateWasmThreadingCapability(capabilities: WasmThreadingCapabilities): CapabilityGateResult {
const issues = [];
if (!capabilities.crossOriginIsolated) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"Cross-origin isolation is required for the pthread WASM engine",
"crossOriginIsolated",
));
}
if (!capabilities.sharedArrayBuffer) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"SharedArrayBuffer is required for the pthread WASM engine",
"sharedArrayBuffer",
));
}
if (!capabilities.worker) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"Worker is required for the pthread WASM engine",
"worker",
));
}
return issues.length > 0
? blockedGate("M6-02", "WASM_PTHREAD_ENGINE", issues)
: readyGate("M6-02", "WASM_PTHREAD_ENGINE");
}
export function selectWebEngineVariant(
manifest: WebEngineManifestV2,
policy: WebEngineVariantPolicy,
capabilities: WasmThreadingCapabilities,
): WebEngineVariantSelection {
const single = manifest.variants.find((variant) => variant.id === "single");
const pthread = manifest.variants.find((variant) => variant.id === "pthread");
if (!single || !pthread) throw new Error("ENGINE_MANIFEST_VARIANTS_MISSING");
const pthreadGate = gateWasmThreadingCapability(capabilities);
if (policy === "SINGLE_REQUIRED") return { policy, selectedVariant: single, pthreadGate };
if (policy === "AUTO") {
return {
policy,
selectedVariant: pthreadGate.status === "READY" ? pthread : single,
pthreadGate,
};
}
if (policy === "PTHREAD_REQUIRED") {
return {
policy,
selectedVariant: pthreadGate.status === "READY" ? pthread : null,
pthreadGate,
};
}
throw new Error("ENGINE_VARIANT_POLICY_INVALID");
}

View File

@@ -8,6 +8,7 @@ export type ErrorCode =
| "WASM_INIT_FAILED"
| "WASM_OUT_OF_MEMORY"
| "BLEND_READ_FAILED"
| "OPEN_CANCELLED"
| "BLEND_WRITE_FAILED"
| "STORAGE_QUOTA"
| "STORAGE_TRANSACTION"

View File

@@ -0,0 +1,132 @@
export const LARGE_FILE_IMPORT_BYTES = 512 * 1024;
export const DEFAULT_FILE_READ_PROGRESS_CHUNK_BYTES = 1024 * 1024;
export const DEFAULT_FILE_READ_YIELD_BYTES = 4 * 1024 * 1024;
export type FileReadPhase = "STARTED" | "READING" | "COMPLETED" | "CANCELLED";
export interface FileReadProgress {
phase: FileReadPhase;
bytesRead: number;
totalBytes: number;
fraction: number;
}
export interface FileByteSource {
readonly size: number;
stream(): ReadableStream<Uint8Array>;
}
export interface FileByteReadOptions {
signal: AbortSignal;
onProgress?: (progress: FileReadProgress) => void;
onResourceState?: (state: FileByteReadResourceState) => void;
yieldControl?: () => Promise<void>;
progressChunkBytes?: number;
yieldEveryBytes?: number;
}
export interface FileByteReadResourceState {
liveReaders: number;
liveInputBytes: number;
liveStagingFiles: number;
}
export type FileByteReadErrorCode =
| "FILE_READ_CANCELLED"
| "FILE_READ_ALLOCATION_FAILED"
| "FILE_READ_SIZE_MISMATCH";
export class FileByteReadError extends Error {
readonly code: FileByteReadErrorCode;
constructor(code: FileByteReadErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "FileByteReadError";
this.code = code;
}
}
function progress(phase: FileReadPhase, bytesRead: number, totalBytes: number): FileReadProgress {
return {
phase,
bytesRead,
totalBytes,
fraction: totalBytes === 0 ? (phase === "COMPLETED" ? 1 : 0) : bytesRead / totalBytes,
};
}
export async function readFileBytes(source: FileByteSource, options: FileByteReadOptions): Promise<ArrayBuffer> {
const { signal, onProgress, onResourceState, yieldControl } = options;
const totalBytes = source.size;
const progressChunkBytes = options.progressChunkBytes ?? DEFAULT_FILE_READ_PROGRESS_CHUNK_BYTES;
const yieldEveryBytes = options.yieldEveryBytes ?? DEFAULT_FILE_READ_YIELD_BYTES;
if (!Number.isSafeInteger(totalBytes) || totalBytes < 0) {
throw new FileByteReadError("FILE_READ_SIZE_MISMATCH", "file size is not a non-negative safe integer");
}
if (!Number.isSafeInteger(progressChunkBytes) || progressChunkBytes <= 0 || !Number.isSafeInteger(yieldEveryBytes) || yieldEveryBytes <= 0) {
throw new FileByteReadError("FILE_READ_SIZE_MISMATCH", "progress or yield byte interval is invalid");
}
let outputBuffer: ArrayBuffer;
try {
outputBuffer = new ArrayBuffer(totalBytes);
}
catch {
throw new FileByteReadError("FILE_READ_ALLOCATION_FAILED", `could not allocate ${totalBytes} bytes`);
}
const output = new Uint8Array(outputBuffer);
const reader = source.stream().getReader();
onResourceState?.({ liveReaders: 1, liveInputBytes: totalBytes, liveStagingFiles: 0 });
let bytesRead = 0;
let nextYield = yieldEveryBytes;
let aborted = signal.aborted;
let completed = false;
const abort = (): void => {
aborted = true;
void reader.cancel("FILE_READ_CANCELLED").catch(() => undefined);
};
signal.addEventListener("abort", abort, { once: true });
onProgress?.(progress("STARTED", 0, totalBytes));
try {
while (true) {
if (aborted) throw new FileByteReadError("FILE_READ_CANCELLED", `cancelled after ${bytesRead} bytes`);
const { done, value } = await reader.read();
if (aborted) throw new FileByteReadError("FILE_READ_CANCELLED", `cancelled after ${bytesRead} bytes`);
if (done) break;
if (!value || bytesRead + value.byteLength > totalBytes) {
throw new FileByteReadError("FILE_READ_SIZE_MISMATCH", "stream exceeded the declared file size");
}
for (let offset = 0; offset < value.byteLength; offset += progressChunkBytes) {
if (aborted) throw new FileByteReadError("FILE_READ_CANCELLED", `cancelled after ${bytesRead} bytes`);
const chunk = value.subarray(offset, Math.min(value.byteLength, offset + progressChunkBytes));
output.set(chunk, bytesRead);
bytesRead += chunk.byteLength;
onProgress?.(progress("READING", bytesRead, totalBytes));
if (yieldControl && bytesRead >= nextYield && bytesRead < totalBytes) {
nextYield = bytesRead + yieldEveryBytes;
await yieldControl();
}
}
}
if (bytesRead !== totalBytes) {
throw new FileByteReadError("FILE_READ_SIZE_MISMATCH", `stream ended at ${bytesRead} of ${totalBytes} bytes`);
}
onProgress?.(progress("COMPLETED", bytesRead, totalBytes));
completed = true;
return outputBuffer;
}
catch (error) {
if (error instanceof FileByteReadError && error.code === "FILE_READ_CANCELLED") {
onProgress?.(progress("CANCELLED", bytesRead, totalBytes));
}
throw error;
}
finally {
signal.removeEventListener("abort", abort);
reader.releaseLock();
if (!completed) output.fill(0);
onResourceState?.({ liveReaders: 0, liveInputBytes: 0, liveStagingFiles: 0 });
}
}

View File

@@ -19,26 +19,256 @@ export interface WebEngineManifest {
wasm: WasmResource[];
}
export async function loadWebEngineManifest(url = "/engine-manifest.json"): Promise<WebEngineManifest> {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error(`Engine manifest request failed: ${response.status}`);
const manifest = await response.json() as Partial<WebEngineManifest>;
if (manifest.schemaVersion !== 1 || manifest.protocolVersion !== 1) {
throw new Error("Unsupported engine manifest version");
}
if (!manifest.engine || !manifest.memory || !Array.isArray(manifest.wasm)) {
throw new Error("Invalid engine manifest shape");
}
return manifest as WebEngineManifest;
export const WEB_ENGINE_MANIFEST_V2_SCHEMA = 2 as const;
export const WEB_ENGINE_WASM_PAGE_BYTES = 65_536 as const;
export const WEB_ENGINE_MEMORY_LIMITS = {
minimumInitialPages: 256,
maximumPages: 32_768,
} as const;
export type WebEngineVariantId = "single" | "pthread";
export interface WebEngineVariantResourceV2 {
fileName: string;
url: string;
sha256: string;
}
export async function verifyWasmResource(resource: WasmResource): Promise<void> {
export interface WebEngineVariantMemoryV2<Shared extends boolean = boolean> {
initialPages: number;
maximumPages: number;
shared: Shared;
}
export interface WebEngineSingleVariantV2 {
id: "single";
memory: WebEngineVariantMemoryV2<false>;
resources: {
js: WebEngineVariantResourceV2;
wasm: WebEngineVariantResourceV2;
pthreadWorker?: never;
};
}
export interface WebEnginePthreadVariantV2 {
id: "pthread";
memory: WebEngineVariantMemoryV2<true>;
resources: {
js: WebEngineVariantResourceV2;
wasm: WebEngineVariantResourceV2;
pthreadWorker: WebEngineVariantResourceV2;
};
}
export type WebEngineVariantV2 = WebEngineSingleVariantV2 | WebEnginePthreadVariantV2;
export interface WebEngineManifestV2 {
schemaVersion: typeof WEB_ENGINE_MANIFEST_V2_SCHEMA;
protocolVersion: 1;
releaseId: string;
engineVersion: string;
engine: "blender-wasm";
variants: [WebEngineSingleVariantV2, WebEnginePthreadVariantV2];
}
export type LoadedWebEngineManifest = WebEngineManifest | WebEngineManifestV2;
export type WebEngineManifestValidationCode = "PROTOCOL_MISMATCH" | "ENGINE_MANIFEST_INVALID";
export class WebEngineManifestValidationError extends Error {
readonly code: WebEngineManifestValidationCode;
readonly path?: string;
constructor(code: WebEngineManifestValidationCode, message: string, path?: string) {
super(message);
this.name = "WebEngineManifestValidationError";
this.code = code;
this.path = path;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const ENGINE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/;
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function fail(code: WebEngineManifestValidationCode, message: string, path?: string): never {
throw new WebEngineManifestValidationError(code, message, path);
}
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], path: string): void {
const allowedKeys = new Set(allowed);
const unexpected = Object.keys(value).find((key) => !allowedKeys.has(key));
if (unexpected) fail("ENGINE_MANIFEST_INVALID", `${path}.${unexpected} is not allowed`, `${path}.${unexpected}`);
}
function boundedText(value: unknown, path: string, pattern: RegExp): string {
if (typeof value !== "string" || !pattern.test(value)) {
fail("ENGINE_MANIFEST_INVALID", `${path} is invalid`, path);
}
return value;
}
function parseResource(
value: unknown,
path: string,
extension: ".js" | ".wasm",
): WebEngineVariantResourceV2 {
if (!record(value)) fail("ENGINE_MANIFEST_INVALID", `${path} is required`, path);
exactKeys(value, ["fileName", "url", "sha256"], path);
const fileName = boundedText(value.fileName, `${path}.fileName`, FILE_NAME);
if (!fileName.endsWith(extension)) {
fail("ENGINE_MANIFEST_INVALID", `${path}.fileName must end with ${extension}`, `${path}.fileName`);
}
if (typeof value.url !== "string" || !value.url.startsWith("/") || /[?#\\\\]/.test(value.url)) {
fail("ENGINE_MANIFEST_INVALID", `${path}.url must be a root-relative URL`, `${path}.url`);
}
const segments = value.url.slice(1).split("/");
if (segments.length === 0 || segments.some((segment) => !FILE_NAME.test(segment) || segment === "." || segment === "..")) {
fail("ENGINE_MANIFEST_INVALID", `${path}.url contains an invalid path segment`, `${path}.url`);
}
if (segments.at(-1) !== fileName) {
fail("ENGINE_MANIFEST_INVALID", `${path}.url must end with fileName`, `${path}.url`);
}
const sha256 = boundedText(value.sha256, `${path}.sha256`, SHA256);
return { fileName, url: value.url, sha256 };
}
function parseMemory(value: unknown, id: WebEngineVariantId, path: string): WebEngineVariantMemoryV2 {
if (!record(value)) fail("ENGINE_MANIFEST_INVALID", `${path} is required`, path);
exactKeys(value, ["initialPages", "maximumPages", "shared"], path);
const { initialPages, maximumPages, shared } = value;
if (!Number.isSafeInteger(initialPages) ||
(initialPages as number) < WEB_ENGINE_MEMORY_LIMITS.minimumInitialPages ||
(initialPages as number) > WEB_ENGINE_MEMORY_LIMITS.maximumPages) {
fail("ENGINE_MANIFEST_INVALID", `${path}.initialPages is outside the supported range`, `${path}.initialPages`);
}
if (!Number.isSafeInteger(maximumPages) ||
(maximumPages as number) < (initialPages as number) ||
(maximumPages as number) > WEB_ENGINE_MEMORY_LIMITS.maximumPages) {
fail("ENGINE_MANIFEST_INVALID", `${path}.maximumPages is outside the supported range`, `${path}.maximumPages`);
}
if (shared !== (id === "pthread")) {
fail("ENGINE_MANIFEST_INVALID", `${path}.shared must be ${id === "pthread"}`, `${path}.shared`);
}
return { initialPages: initialPages as number, maximumPages: maximumPages as number, shared };
}
function parseVariant(value: unknown, index: number): WebEngineVariantV2 {
const path = `variants[${index}]`;
if (!record(value)) fail("ENGINE_MANIFEST_INVALID", `${path} is invalid`, path);
exactKeys(value, ["id", "memory", "resources"], path);
if (value.id !== "single" && value.id !== "pthread") {
fail("ENGINE_MANIFEST_INVALID", `${path}.id is invalid`, `${path}.id`);
}
const id = value.id;
if (!record(value.resources)) fail("ENGINE_MANIFEST_INVALID", `${path}.resources is required`, `${path}.resources`);
const resourcePath = `${path}.resources`;
exactKeys(value.resources, id === "pthread" ? ["js", "wasm", "pthreadWorker"] : ["js", "wasm"], resourcePath);
if (id === "pthread" && value.resources.pthreadWorker === undefined) {
fail("ENGINE_MANIFEST_INVALID", `${resourcePath}.pthreadWorker is required`, `${resourcePath}.pthreadWorker`);
}
const js = parseResource(value.resources.js, `${resourcePath}.js`, ".js");
const wasm = parseResource(value.resources.wasm, `${resourcePath}.wasm`, ".wasm");
const memory = parseMemory(value.memory, id, `${path}.memory`);
if (id === "pthread") {
const pthreadWorker = parseResource(
value.resources.pthreadWorker,
`${resourcePath}.pthreadWorker`,
".js",
);
return { id, memory: { ...memory, shared: true }, resources: { js, wasm, pthreadWorker } };
}
return { id, memory: { ...memory, shared: false }, resources: { js, wasm } };
}
export function validateWebEngineManifestV2(value: unknown): WebEngineManifestV2 {
if (!record(value) || value.schemaVersion !== WEB_ENGINE_MANIFEST_V2_SCHEMA) {
fail("PROTOCOL_MISMATCH", "Unsupported engine manifest schema", "schemaVersion");
}
exactKeys(value, ["schemaVersion", "protocolVersion", "releaseId", "engineVersion", "engine", "variants"], "manifest");
if (value.protocolVersion !== 1) {
fail("PROTOCOL_MISMATCH", "Unsupported WebEngine protocol version", "protocolVersion");
}
if (value.engine !== "blender-wasm") {
fail("ENGINE_MANIFEST_INVALID", "engine must be blender-wasm", "engine");
}
const engineVersion = boundedText(value.engineVersion, "engineVersion", ENGINE_VERSION);
const releaseId = boundedText(value.releaseId, "releaseId", ENGINE_VERSION);
if (!Array.isArray(value.variants) || value.variants.length !== 2) {
fail("ENGINE_MANIFEST_INVALID", "variants must contain exactly single and pthread", "variants");
}
const variants = value.variants.map(parseVariant);
const byId = new Map(variants.map((variant) => [variant.id, variant]));
if (byId.size !== 2 || !byId.has("single") || !byId.has("pthread")) {
fail("ENGINE_MANIFEST_INVALID", "variants must contain exactly one single and one pthread", "variants");
}
const single = byId.get("single") as WebEngineSingleVariantV2;
const pthread = byId.get("pthread") as WebEnginePthreadVariantV2;
for (const role of ["js", "wasm"] as const) {
if (single.resources[role].url === pthread.resources[role].url) {
fail(
"ENGINE_MANIFEST_INVALID",
`single and pthread ${role} resources must have distinct URLs`,
`variants.${role}`,
);
}
}
if (pthread.resources.pthreadWorker.url === single.resources.js.url) {
fail(
"ENGINE_MANIFEST_INVALID",
"pthread worker must not alias the single JS resource",
"variants.pthreadWorker",
);
}
if (pthread.resources.pthreadWorker.url === pthread.resources.js.url &&
pthread.resources.pthreadWorker.sha256 !== pthread.resources.js.sha256) {
fail(
"ENGINE_MANIFEST_INVALID",
"resources that share a URL must share the same SHA-256",
"variants.pthreadWorker.sha256",
);
}
return {
schemaVersion: WEB_ENGINE_MANIFEST_V2_SCHEMA,
protocolVersion: 1,
releaseId,
engineVersion,
engine: "blender-wasm",
variants: [single, pthread],
};
}
export async function loadWebEngineManifest(url = "/engine-manifest.json"): Promise<LoadedWebEngineManifest> {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error(`Engine manifest request failed: ${response.status}`);
const manifest = await response.json() as unknown;
if (record(manifest) && manifest.schemaVersion === WEB_ENGINE_MANIFEST_V2_SCHEMA) {
return validateWebEngineManifestV2(manifest);
}
const legacyManifest = manifest as Partial<WebEngineManifest>;
if (legacyManifest.schemaVersion !== 1 || legacyManifest.protocolVersion !== 1) {
throw new Error("Unsupported engine manifest version");
}
if (!legacyManifest.engine || !legacyManifest.memory || !Array.isArray(legacyManifest.wasm)) {
throw new Error("Invalid engine manifest shape");
}
return legacyManifest as WebEngineManifest;
}
export async function verifyWasmResource(
resource: Pick<WasmResource, "url" | "sha256"> & Partial<Pick<WasmResource, "id">>,
): Promise<void> {
const response = await fetch(resource.url, { cache: "no-store" });
if (!response.ok) throw new Error(`WASM resource request failed: ${resource.id}`);
const resourceId = resource.id ?? resource.url;
if (!response.ok) throw new Error(`WASM resource request failed: ${resourceId}`);
const bytes = await response.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", bytes);
const actual = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
if (actual !== resource.sha256.toLowerCase()) {
throw new Error(`WASM resource hash mismatch: ${resource.id}`);
throw new Error(`WASM resource hash mismatch: ${resourceId}`);
}
}

View File

@@ -5,7 +5,11 @@ export interface ProgressEvent {
operation: string;
phase: ProgressPhase;
fraction?: number;
bytesRead?: number;
totalBytes?: number;
message?: string;
revision?: number;
errorCode?: string;
stage?: string;
cancellable?: boolean;
}

View File

@@ -0,0 +1,79 @@
export const PROJECT_ACTION_KINDS = ["OPEN", "SAVE", "CLOSE"] as const;
export type ProjectActionKind = typeof PROJECT_ACTION_KINDS[number];
export interface ProjectActionIdentity {
actionId: string;
kind: ProjectActionKind;
}
export interface ProjectActionMutexState {
owner: ProjectActionIdentity | null;
}
export type ProjectActionConflictReason =
| "REPEATED_OPEN"
| "CONCURRENT_SAVE"
| "CLOSE_DURING_SAVE"
| "PROJECT_ACTION_EXCLUSIVE";
export interface ProjectActionConflict {
code: "USER_ACTION_CONFLICT";
reason: ProjectActionConflictReason;
requested: ProjectActionIdentity;
owner: ProjectActionIdentity;
}
export type ProjectActionAcquireResult =
| { granted: true; state: ProjectActionMutexState; conflict: null }
| { granted: false; state: ProjectActionMutexState; conflict: ProjectActionConflict };
export type ProjectActionReleaseResult =
| { released: true; state: ProjectActionMutexState; errorCode: null }
| {
released: false;
state: ProjectActionMutexState;
errorCode: "PROJECT_ACTION_LOCK_NOT_HELD" | "PROJECT_ACTION_LOCK_IDENTITY_MISMATCH";
};
export const PROJECT_ACTION_CONFLICT_MATRIX: Readonly<Record<ProjectActionKind, Readonly<Record<ProjectActionKind, boolean>>>> = {
OPEN: { OPEN: true, SAVE: true, CLOSE: true },
SAVE: { OPEN: true, SAVE: true, CLOSE: true },
CLOSE: { OPEN: true, SAVE: true, CLOSE: true },
};
export function createProjectActionMutexState(): ProjectActionMutexState {
return { owner: null };
}
function conflictReason(requested: ProjectActionKind, owner: ProjectActionKind): ProjectActionConflictReason {
if (requested === "OPEN" && owner === "OPEN") return "REPEATED_OPEN";
if (requested === "SAVE" && owner === "SAVE") return "CONCURRENT_SAVE";
if (requested === "CLOSE" && owner === "SAVE") return "CLOSE_DURING_SAVE";
return "PROJECT_ACTION_EXCLUSIVE";
}
export function acquireProjectAction(state: ProjectActionMutexState, requested: ProjectActionIdentity): ProjectActionAcquireResult {
const owner = state.owner;
if (!owner) return { granted: true, state: { owner: requested }, conflict: null };
if (!PROJECT_ACTION_CONFLICT_MATRIX[requested.kind][owner.kind]) {
return { granted: true, state: { owner: requested }, conflict: null };
}
return {
granted: false,
state,
conflict: {
code: "USER_ACTION_CONFLICT",
reason: conflictReason(requested.kind, owner.kind),
requested,
owner,
},
};
}
export function releaseProjectAction(state: ProjectActionMutexState, identity: ProjectActionIdentity): ProjectActionReleaseResult {
if (!state.owner) return { released: false, state, errorCode: "PROJECT_ACTION_LOCK_NOT_HELD" };
if (state.owner.kind !== identity.kind || state.owner.actionId !== identity.actionId) {
return { released: false, state, errorCode: "PROJECT_ACTION_LOCK_IDENTITY_MISMATCH" };
}
return { released: true, state: createProjectActionMutexState(), errorCode: null };
}

View File

@@ -0,0 +1,81 @@
export const SAVE_ATTEMPT_STAGES = ["SERIALIZE", "OPFS_STAGE", "SCENE_COMMIT", "METADATA_COMMIT"] as const;
export type SaveAttemptStage = typeof SAVE_ATTEMPT_STAGES[number];
export interface SaveCommitIdentity {
revision: number;
sha256: string | null;
}
export interface SaveTransactionState {
status: "IDLE" | "RUNNING" | "SUCCEEDED" | "FAILED";
stage: SaveAttemptStage | null;
committed: SaveCommitIdentity;
candidate: SaveCommitIdentity | null;
errorCode: string | null;
}
export type SaveTransactionResult =
| { ok: true; state: SaveTransactionState }
| { ok: false; state: SaveTransactionState; errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" | "SAVE_TRANSACTION_COMMIT_MISMATCH" };
const SHA256 = /^[a-f0-9]{64}$/;
function validIdentity(identity: SaveCommitIdentity, allowNullHash: boolean): boolean {
return Number.isSafeInteger(identity.revision) && identity.revision >= 0 &&
(allowNullHash ? identity.sha256 === null || SHA256.test(identity.sha256) : typeof identity.sha256 === "string" && SHA256.test(identity.sha256));
}
export function createSaveTransactionState(committed: SaveCommitIdentity): SaveTransactionState {
if (!validIdentity(committed, true)) throw new Error("SAVE_TRANSACTION_COMMIT_INVALID");
return { status: "IDLE", stage: null, committed, candidate: null, errorCode: null };
}
export function beginSaveTransaction(state: SaveTransactionState, targetRevision: number): SaveTransactionResult {
if (state.status === "RUNNING" || !Number.isSafeInteger(targetRevision) || targetRevision < state.committed.revision) {
return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" };
}
return {
ok: true,
state: {
status: "RUNNING",
stage: "SERIALIZE",
committed: state.committed,
candidate: { revision: targetRevision, sha256: null },
errorCode: null,
},
};
}
export function advanceSaveTransaction(state: SaveTransactionState, stage: SaveAttemptStage, candidateSha256?: string): SaveTransactionResult {
if (state.status !== "RUNNING" || !state.stage || !state.candidate) {
return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" };
}
const currentIndex = SAVE_ATTEMPT_STAGES.indexOf(state.stage);
const nextIndex = SAVE_ATTEMPT_STAGES.indexOf(stage);
if (nextIndex !== currentIndex + 1 || (stage === "OPFS_STAGE" && (!candidateSha256 || !SHA256.test(candidateSha256)))) {
return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" };
}
return {
ok: true,
state: {
...state,
stage,
candidate: stage === "OPFS_STAGE" ? { ...state.candidate, sha256: candidateSha256! } : state.candidate,
},
};
}
export function failSaveTransaction(state: SaveTransactionState, errorCode: string): SaveTransactionState {
if (state.status !== "RUNNING" || !/^[A-Z][A-Z0-9_]*$/.test(errorCode)) return state;
return { ...state, status: "FAILED", errorCode };
}
export function commitSaveTransaction(state: SaveTransactionState, persisted: SaveCommitIdentity): SaveTransactionResult {
if (state.status !== "RUNNING" || state.stage !== "METADATA_COMMIT" || !state.candidate || !validIdentity(persisted, false)) {
return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" };
}
if (persisted.revision !== state.candidate.revision || persisted.sha256 !== state.candidate.sha256) {
return { ok: false, state, errorCode: "SAVE_TRANSACTION_COMMIT_MISMATCH" };
}
return { ok: true, state: { status: "SUCCEEDED", stage: "METADATA_COMMIT", committed: persisted, candidate: persisted, errorCode: null } };
}

View File

@@ -192,7 +192,7 @@ export interface StorageRequest {
| { type: "smoke" }
| { type: "info" }
| { type: "ensureProject"; projectId: string }
| { type: "saveProject"; projectId: string; revision: number; buffer: ArrayBuffer; faultAt?: "after-stage" | "after-scene-commit" | "quota" }
| { type: "saveProject"; projectId: string; revision: number; buffer: ArrayBuffer; faultAt?: "after-stage" | "after-scene-commit" | "before-metadata-commit" | "quota" }
| { type: "recoverProject"; projectId: string }
| { type: "readProject"; projectId: string }
| { type: "appendOperation"; id: string; projectId: string; revision: number; payload: unknown; inversePayload?: unknown }

View File

@@ -0,0 +1,109 @@
export const USER_ACTION_KINDS = ["IMPORT", "OPEN", "SAVE", "SAVE_AS", "EXPORT"] as const;
export type UserActionKind = typeof USER_ACTION_KINDS[number];
export const USER_ACTION_STATUSES = ["IDLE", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"] as const;
export type UserActionStatus = typeof USER_ACTION_STATUSES[number];
export interface UserActionIdentity {
actionId: string;
kind: UserActionKind;
}
export interface UserActionState {
kind: UserActionKind;
status: UserActionStatus;
identity: UserActionIdentity | null;
errorCode: string | null;
}
export type UserActionEvent =
| { type: "START"; identity: UserActionIdentity }
| { type: "SUCCEED"; identity: UserActionIdentity }
| { type: "FAIL"; identity: UserActionIdentity; errorCode: string }
| { type: "CANCEL"; identity: UserActionIdentity; errorCode?: string }
| { type: "RESET"; kind: UserActionKind };
export type UserActionTransitionErrorCode =
| "USER_ACTION_KIND_MISMATCH"
| "USER_ACTION_IDENTITY_MISMATCH"
| "USER_ACTION_IDENTITY_REUSED"
| "USER_ACTION_INVALID_ERROR_CODE"
| "USER_ACTION_INVALID_TRANSITION";
export type UserActionTransitionResult =
| { ok: true; state: UserActionState }
| { ok: false; state: UserActionState; errorCode: UserActionTransitionErrorCode };
export type UserActionStates = Record<UserActionKind, UserActionState>;
export const USER_ACTION_ALLOWED_TRANSITIONS: Readonly<Record<UserActionStatus, readonly UserActionStatus[]>> = {
IDLE: ["RUNNING"],
RUNNING: ["SUCCEEDED", "FAILED", "CANCELLED"],
SUCCEEDED: ["IDLE", "RUNNING"],
FAILED: ["IDLE", "RUNNING"],
CANCELLED: ["IDLE", "RUNNING"],
};
function idleState(kind: UserActionKind): UserActionState {
return { kind, status: "IDLE", identity: null, errorCode: null };
}
export function createInitialUserActionStates(): UserActionStates {
return Object.fromEntries(USER_ACTION_KINDS.map((kind) => [kind, idleState(kind)])) as UserActionStates;
}
function rejected(state: UserActionState, errorCode: UserActionTransitionErrorCode): UserActionTransitionResult {
return { ok: false, state, errorCode };
}
function isStableErrorCode(value: string): boolean {
return /^[A-Z][A-Z0-9_]*$/.test(value);
}
export function transitionUserAction(state: UserActionState, event: UserActionEvent): UserActionTransitionResult {
if (event.type === "RESET") {
if (event.kind !== state.kind) return rejected(state, "USER_ACTION_KIND_MISMATCH");
if (!USER_ACTION_ALLOWED_TRANSITIONS[state.status].includes("IDLE")) {
return rejected(state, "USER_ACTION_INVALID_TRANSITION");
}
return { ok: true, state: idleState(state.kind) };
}
if (event.identity.kind !== state.kind) return rejected(state, "USER_ACTION_KIND_MISMATCH");
if (!event.identity.actionId) return rejected(state, "USER_ACTION_IDENTITY_MISMATCH");
if (event.type === "START") {
if (!USER_ACTION_ALLOWED_TRANSITIONS[state.status].includes("RUNNING")) {
return rejected(state, "USER_ACTION_INVALID_TRANSITION");
}
if (state.identity?.actionId === event.identity.actionId) {
return rejected(state, "USER_ACTION_IDENTITY_REUSED");
}
return {
ok: true,
state: { kind: state.kind, status: "RUNNING", identity: event.identity, errorCode: null },
};
}
if (state.status !== "RUNNING") return rejected(state, "USER_ACTION_INVALID_TRANSITION");
if (state.identity?.actionId !== event.identity.actionId) {
return rejected(state, "USER_ACTION_IDENTITY_MISMATCH");
}
if (event.type === "SUCCEED") {
return { ok: true, state: { ...state, status: "SUCCEEDED", errorCode: null } };
}
const errorCode = event.type === "CANCEL" ? event.errorCode ?? "USER_ACTION_CANCELLED" : event.errorCode;
if (!isStableErrorCode(errorCode)) return rejected(state, "USER_ACTION_INVALID_ERROR_CODE");
return {
ok: true,
state: { ...state, status: event.type === "CANCEL" ? "CANCELLED" : "FAILED", errorCode },
};
}
export function reduceUserActionStates(states: UserActionStates, event: UserActionEvent): UserActionStates {
const kind = event.type === "RESET" ? event.kind : event.identity.kind;
const result = transitionUserAction(states[kind], event);
return result.ok ? { ...states, [kind]: result.state } : states;
}

View File

@@ -137,6 +137,8 @@ export type WebEngineEditCommand =
export type WebEngineRequest =
| { requestId: string; command: { type: "init" } }
| { requestId: string; command: { type: "openBlend"; buffer: ArrayBuffer }; }
| { requestId: string; command: { type: "cancelOpen"; targetRequestId: string } }
| { requestId: string; command: { type: "openResourceStatus" } }
| { requestId: string; command: { type: "snapshot" } }
| { requestId: string; command: { type: "applyCommand"; payload: WebEngineEditCommand } }
| { requestId: string; command: { type: "generateLOD"; payload: LODGenerationRequest } }
@@ -154,6 +156,13 @@ export interface WebEngineStatus {
allocatedBytes: number;
}
export interface WebEngineOpenResourceStatus {
activeRequests: number;
liveInputBytes: number;
liveNativeHandles: number;
liveStagingFiles: number;
}
export interface AssetRequestResult {
assetId: string;
status: "external" | "packed" | "packed-unavailable" | "missing" | "blocked";
@@ -202,6 +211,7 @@ export interface WebEngineResult {
capabilityGate?: CapabilityGateResult;
depsgraph?: DepsgraphEvaluationIR;
blend?: ArrayBuffer;
openResources?: WebEngineOpenResourceStatus;
}
export type WebEngineResponse =