Reorganize Blender Web execution around capabilities
This commit is contained in:
351
web/protocol/corrective-case-runner.ts
Normal file
351
web/protocol/corrective-case-runner.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
export const CORRECTIVE_CASE_SCHEMA = 1 as const;
|
||||
|
||||
export type CaseSurface = "DESKTOP" | "WASM";
|
||||
export type CommandStatus = "FINISHED" | "BLOCKED" | "CANCELLED" | "UNSUPPORTED" | "MALFORMED" | "EXTERNAL_REQUIRED";
|
||||
export type MainMutation = "CHANGED" | "UNCHANGED";
|
||||
export type TargetKind = "ASSET_LIBRARY" | "DATABLOCK" | "MESH" | "MODIFIER" | "SCENE";
|
||||
export type EditMode = "READ_ONLY" | "EDITABLE";
|
||||
export type Authorization = "NONE" | "USER_EDIT" | "SYSTEM";
|
||||
export type SourceKind = "LOCAL" | "EXTERNAL" | "PACKED";
|
||||
export type ResourceProvider = "NONE" | "OPFS" | "EXTERNAL_LIBRARY" | "PACKED_BLEND";
|
||||
|
||||
export interface CommandContext {
|
||||
mainId: string;
|
||||
targetKind: TargetKind;
|
||||
targetId?: string;
|
||||
editMode: EditMode;
|
||||
authorization: Authorization;
|
||||
source: { kind: SourceKind; sha256: string };
|
||||
resourceProvider?: ResourceProvider;
|
||||
}
|
||||
|
||||
export interface MainMutationCommand {
|
||||
schemaVersion: typeof CORRECTIVE_CASE_SCHEMA;
|
||||
requestId: string;
|
||||
commandType: string;
|
||||
baseRevision: number;
|
||||
context: CommandContext;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MainMutationResult {
|
||||
schemaVersion: typeof CORRECTIVE_CASE_SCHEMA;
|
||||
requestId: string;
|
||||
status: CommandStatus;
|
||||
code: string;
|
||||
baseRevision: number;
|
||||
mainRevisionBefore: number;
|
||||
mainRevisionAfter: number;
|
||||
mainMutation: MainMutation;
|
||||
delta: Record<string, unknown> | null;
|
||||
persistence: Record<string, unknown> | null;
|
||||
error: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface SurfaceReceipt {
|
||||
schemaVersion: typeof CORRECTIVE_CASE_SCHEMA;
|
||||
surface: CaseSurface;
|
||||
requestId: string;
|
||||
commandType: string;
|
||||
context: CommandContext | null;
|
||||
result: MainMutationResult;
|
||||
}
|
||||
|
||||
export interface SharedCaseReceipt {
|
||||
schemaVersion: typeof CORRECTIVE_CASE_SCHEMA;
|
||||
requestId: string;
|
||||
commandType: string;
|
||||
desktop: SurfaceReceipt;
|
||||
wasm: SurfaceReceipt;
|
||||
parity: "MATCH" | "MISMATCH";
|
||||
differences: string[];
|
||||
}
|
||||
|
||||
export interface SurfaceCaseOptions {
|
||||
command: unknown;
|
||||
mainRevision: number;
|
||||
execute: (command: MainMutationCommand) => MainMutationResult | Promise<MainMutationResult>;
|
||||
}
|
||||
|
||||
export class CorrectiveCaseRunnerError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "CorrectiveCaseRunnerError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/u;
|
||||
const REQUEST_ID = /^[A-Za-z0-9._:-]{1,256}$/u;
|
||||
const COMMAND_TYPE = /^[a-z][A-Za-z0-9]{0,127}$/u;
|
||||
const STATUS_CODES: Readonly<Record<CommandStatus, string>> = {
|
||||
FINISHED: "OK",
|
||||
BLOCKED: "REVISION_CONFLICT",
|
||||
CANCELLED: "CANCELLED",
|
||||
UNSUPPORTED: "COMMAND_UNSUPPORTED",
|
||||
MALFORMED: "COMMAND_MALFORMED",
|
||||
EXTERNAL_REQUIRED: "RESOURCE_REQUIRED",
|
||||
};
|
||||
const STATUS_CODE_PAIRS = new Set([
|
||||
"FINISHED:OK",
|
||||
"BLOCKED:REVISION_CONFLICT",
|
||||
"BLOCKED:INVALID_CONTEXT",
|
||||
"CANCELLED:CANCELLED",
|
||||
"UNSUPPORTED:COMMAND_UNSUPPORTED",
|
||||
"MALFORMED:COMMAND_MALFORMED",
|
||||
"EXTERNAL_REQUIRED:RESOURCE_REQUIRED",
|
||||
]);
|
||||
const STATUSES = new Set<CommandStatus>(Object.keys(STATUS_CODES) as CommandStatus[]);
|
||||
const TARGET_KINDS = new Set<TargetKind>(["ASSET_LIBRARY", "DATABLOCK", "MESH", "MODIFIER", "SCENE"]);
|
||||
const EDIT_MODES = new Set<EditMode>(["READ_ONLY", "EDITABLE"]);
|
||||
const AUTHORIZATIONS = new Set<Authorization>(["NONE", "USER_EDIT", "SYSTEM"]);
|
||||
const SOURCE_KINDS = new Set<SourceKind>(["LOCAL", "EXTERNAL", "PACKED"]);
|
||||
const RESOURCE_PROVIDERS = new Set<ResourceProvider>(["NONE", "OPFS", "EXTERNAL_LIBRARY", "PACKED_BLEND"]);
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>, label: string): void {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", `${label}.${key} is not allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, label: string, pattern: RegExp, maximum = 256): string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > maximum || !pattern.test(value)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", `${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", `${label} must be a non-negative integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function commandContext(value: unknown): CommandContext {
|
||||
if (!record(value)) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "context must be an object");
|
||||
exactKeys(value, new Set(["mainId", "targetKind", "targetId", "editMode", "authorization", "source", "resourceProvider"]), "context");
|
||||
const mainId = boundedText(value.mainId, "context.mainId", /^.{1,256}$/u);
|
||||
if (typeof value.targetKind !== "string" || !TARGET_KINDS.has(value.targetKind as TargetKind)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "context.targetKind is invalid");
|
||||
}
|
||||
const targetId = value.targetId === undefined ? undefined : boundedText(value.targetId, "context.targetId", /^.{1,256}$/u);
|
||||
if (typeof value.editMode !== "string" || !EDIT_MODES.has(value.editMode as EditMode)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "context.editMode is invalid");
|
||||
}
|
||||
if (typeof value.authorization !== "string" || !AUTHORIZATIONS.has(value.authorization as Authorization)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "context.authorization is invalid");
|
||||
}
|
||||
if (!record(value.source) || Object.keys(value.source).some((key) => !["kind", "sha256"].includes(key))) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "context.source is invalid");
|
||||
}
|
||||
if (typeof value.source.kind !== "string" || !SOURCE_KINDS.has(value.source.kind as SourceKind) ||
|
||||
typeof value.source.sha256 !== "string" || !SHA256.test(value.source.sha256)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "context.source provenance is invalid");
|
||||
}
|
||||
const resourceProvider = value.resourceProvider === undefined ? undefined : value.resourceProvider;
|
||||
if (resourceProvider !== undefined && (typeof resourceProvider !== "string" || !RESOURCE_PROVIDERS.has(resourceProvider as ResourceProvider))) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "context.resourceProvider is invalid");
|
||||
}
|
||||
return {
|
||||
mainId,
|
||||
targetKind: value.targetKind as TargetKind,
|
||||
...(targetId === undefined ? {} : { targetId }),
|
||||
editMode: value.editMode as EditMode,
|
||||
authorization: value.authorization as Authorization,
|
||||
source: { kind: value.source.kind as SourceKind, sha256: value.source.sha256 },
|
||||
...(resourceProvider === undefined ? {} : { resourceProvider: resourceProvider as ResourceProvider }),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateCommand(value: unknown): MainMutationCommand {
|
||||
if (!record(value)) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "command must be an object");
|
||||
exactKeys(value, new Set(["schemaVersion", "requestId", "commandType", "baseRevision", "context", "payload"]), "command");
|
||||
if (value.schemaVersion !== CORRECTIVE_CASE_SCHEMA) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "schemaVersion is unsupported");
|
||||
const requestId = boundedText(value.requestId, "requestId", REQUEST_ID);
|
||||
const commandType = boundedText(value.commandType, "commandType", COMMAND_TYPE);
|
||||
const baseRevision = nonNegativeInteger(value.baseRevision, "baseRevision");
|
||||
const context = commandContext(value.context);
|
||||
if (!record(value.payload)) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "payload must be an object");
|
||||
for (const key of ["requestId", "baseRevision", "context"]) {
|
||||
if (Object.prototype.hasOwnProperty.call(value.payload, key)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", `payload cannot override ${key}`);
|
||||
}
|
||||
}
|
||||
return { schemaVersion: CORRECTIVE_CASE_SCHEMA, requestId, commandType, baseRevision, context, payload: { ...value.payload } };
|
||||
}
|
||||
|
||||
function resultObject(value: unknown): Record<string, unknown> {
|
||||
if (!record(value)) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "result must be an object");
|
||||
exactKeys(value, new Set(["schemaVersion", "requestId", "status", "code", "baseRevision", "mainRevisionBefore", "mainRevisionAfter", "mainMutation", "delta", "persistence", "error"]), "result");
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalObject(value: unknown, label: string): Record<string, unknown> | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (!record(value)) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", `${label} must be an object or null`);
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
export function normalizeResult(value: unknown, command: MainMutationCommand, mainRevisionBefore: number): MainMutationResult {
|
||||
const result = resultObject(value);
|
||||
if (result.schemaVersion !== CORRECTIVE_CASE_SCHEMA || result.requestId !== command.requestId || result.baseRevision !== command.baseRevision) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "result identity is not bound to the command");
|
||||
}
|
||||
nonNegativeInteger(result.mainRevisionBefore, "mainRevisionBefore");
|
||||
nonNegativeInteger(result.mainRevisionAfter, "mainRevisionAfter");
|
||||
if (result.mainRevisionBefore !== mainRevisionBefore) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "result before revision is not current");
|
||||
if (typeof result.status !== "string" || !STATUSES.has(result.status as CommandStatus) || typeof result.code !== "string" || !STATUS_CODE_PAIRS.has(`${result.status}:${result.code}`)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "status and code are not a closed pair");
|
||||
}
|
||||
const status = result.status as CommandStatus;
|
||||
const mutation = result.mainMutation;
|
||||
const expectedMutation: MainMutation = status === "FINISHED" ? "CHANGED" : "UNCHANGED";
|
||||
if (mutation !== expectedMutation || result.mainRevisionAfter !== mainRevisionBefore + (status === "FINISHED" ? 1 : 0)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "result revision or mutation invariant failed");
|
||||
}
|
||||
const delta = optionalObject(result.delta, "delta");
|
||||
const persistence = optionalObject(result.persistence, "persistence");
|
||||
const error = optionalObject(result.error, "error");
|
||||
if (status !== "FINISHED" && (delta !== null || persistence !== null)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "failure result cannot claim delta or persistence");
|
||||
}
|
||||
return {
|
||||
schemaVersion: CORRECTIVE_CASE_SCHEMA,
|
||||
requestId: command.requestId,
|
||||
status,
|
||||
code: STATUS_CODES[status],
|
||||
baseRevision: command.baseRevision,
|
||||
mainRevisionBefore,
|
||||
mainRevisionAfter: result.mainRevisionAfter,
|
||||
mainMutation: expectedMutation,
|
||||
delta,
|
||||
persistence,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function failure(command: MainMutationCommand | null, status: Exclude<CommandStatus, "FINISHED">, before: number, message: string, code = STATUS_CODES[status]): MainMutationResult {
|
||||
const requestId = command?.requestId ?? "invalid-request";
|
||||
const baseRevision = command?.baseRevision ?? before;
|
||||
return {
|
||||
schemaVersion: CORRECTIVE_CASE_SCHEMA,
|
||||
requestId,
|
||||
status,
|
||||
code,
|
||||
baseRevision,
|
||||
mainRevisionBefore: before,
|
||||
mainRevisionAfter: before,
|
||||
mainMutation: "UNCHANGED",
|
||||
delta: null,
|
||||
persistence: null,
|
||||
error: { code, message },
|
||||
};
|
||||
}
|
||||
|
||||
function commandIdentity(value: unknown): { requestId: string; commandType: string } {
|
||||
const input = record(value) ? value : {};
|
||||
return {
|
||||
requestId: typeof input.requestId === "string" && REQUEST_ID.test(input.requestId) ? input.requestId : "invalid-request",
|
||||
commandType: typeof input.commandType === "string" && COMMAND_TYPE.test(input.commandType) ? input.commandType : "malformed",
|
||||
};
|
||||
}
|
||||
|
||||
function receipt(surface: CaseSurface, command: MainMutationCommand | null, result: MainMutationResult): SurfaceReceipt {
|
||||
const identity = command ? { requestId: command.requestId, commandType: command.commandType } : commandIdentity(result);
|
||||
return {
|
||||
schemaVersion: CORRECTIVE_CASE_SCHEMA,
|
||||
surface,
|
||||
requestId: identity.requestId,
|
||||
commandType: identity.commandType,
|
||||
context: command?.context ?? null,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
async function runSurface(surface: CaseSurface, options: SurfaceCaseOptions): Promise<SurfaceReceipt> {
|
||||
const before = nonNegativeInteger(options.mainRevision, "mainRevision");
|
||||
let command: MainMutationCommand;
|
||||
try {
|
||||
command = validateCommand(options.command);
|
||||
}
|
||||
catch (error) {
|
||||
const identity = commandIdentity(options.command);
|
||||
const malformed = failure(null, "MALFORMED", before, error instanceof Error ? error.message : String(error));
|
||||
malformed.requestId = identity.requestId;
|
||||
return receipt(surface, null, malformed);
|
||||
}
|
||||
if (command.baseRevision !== before) return receipt(surface, command, failure(command, "BLOCKED", before, "base revision does not match current Main"));
|
||||
if (command.context.editMode !== "EDITABLE" || command.context.authorization === "NONE") {
|
||||
return receipt(surface, command, failure(command, "BLOCKED", before, "context cannot authorize a Main mutation", "INVALID_CONTEXT"));
|
||||
}
|
||||
if (command.context.source.kind === "EXTERNAL" && (!command.context.resourceProvider || command.context.resourceProvider === "NONE")) {
|
||||
return receipt(surface, command, failure(command, "EXTERNAL_REQUIRED", before, "external source requires a resource provider"));
|
||||
}
|
||||
try {
|
||||
return receipt(surface, command, normalizeResult(await options.execute(command), command, before));
|
||||
}
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const code = error instanceof CorrectiveCaseRunnerError ? error.code : "COMMAND_UNSUPPORTED";
|
||||
const status = code === "RESOURCE_REQUIRED" ? "EXTERNAL_REQUIRED" : code === "CANCELLED" ? "CANCELLED" : code === "REVISION_CONFLICT" || code === "INVALID_CONTEXT" ? "BLOCKED" : code === "COMMAND_MALFORMED" ? "MALFORMED" : "UNSUPPORTED";
|
||||
return receipt(surface, command, failure(command, status, before, message, code === "INVALID_CONTEXT" ? code : STATUS_CODES[status]));
|
||||
}
|
||||
}
|
||||
|
||||
export function runDesktopCase(options: SurfaceCaseOptions): Promise<SurfaceReceipt> {
|
||||
return runSurface("DESKTOP", options);
|
||||
}
|
||||
|
||||
export function runWasmCase(options: SurfaceCaseOptions): Promise<SurfaceReceipt> {
|
||||
return runSurface("WASM", options);
|
||||
}
|
||||
|
||||
function coreReceipt(receiptValue: SurfaceReceipt): Record<string, unknown> {
|
||||
const result = receiptValue.result;
|
||||
return {
|
||||
requestId: receiptValue.requestId,
|
||||
commandType: receiptValue.commandType,
|
||||
status: result.status,
|
||||
code: result.code,
|
||||
baseRevision: result.baseRevision,
|
||||
mainRevisionBefore: result.mainRevisionBefore,
|
||||
mainRevisionAfter: result.mainRevisionAfter,
|
||||
mainMutation: result.mainMutation,
|
||||
delta: result.delta,
|
||||
persistence: result.persistence,
|
||||
};
|
||||
}
|
||||
|
||||
function stable(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stable);
|
||||
if (record(value)) return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
|
||||
return value;
|
||||
}
|
||||
|
||||
export function compareReceipts(desktop: SurfaceReceipt, wasm: SurfaceReceipt): { parity: "MATCH" | "MISMATCH"; differences: string[] } {
|
||||
const left = coreReceipt(desktop);
|
||||
const right = coreReceipt(wasm);
|
||||
const differences = Object.keys(left).filter((key) => JSON.stringify(stable(left[key])) !== JSON.stringify(stable(right[key]))).map((key) => `${key}: desktop=${JSON.stringify(stable(left[key]))} wasm=${JSON.stringify(stable(right[key]))}`);
|
||||
return { parity: differences.length === 0 ? "MATCH" : "MISMATCH", differences };
|
||||
}
|
||||
|
||||
export async function runSharedCase(command: unknown, mainRevision: number, executors: { desktop: SurfaceCaseOptions["execute"]; wasm: SurfaceCaseOptions["execute"] }): Promise<SharedCaseReceipt> {
|
||||
const desktop = await runDesktopCase({ command, mainRevision, execute: executors.desktop });
|
||||
const wasm = await runWasmCase({ command, mainRevision, execute: executors.wasm });
|
||||
const comparison = compareReceipts(desktop, wasm);
|
||||
return {
|
||||
schemaVersion: CORRECTIVE_CASE_SCHEMA,
|
||||
requestId: desktop.requestId,
|
||||
commandType: desktop.commandType,
|
||||
desktop,
|
||||
wasm,
|
||||
...comparison,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user