export const APP_DIAGNOSTIC_SCHEMA_VERSION = 1 as const; export const MAX_APP_DIAGNOSTIC_ENTRIES = 200; export const APP_DIAGNOSTIC_MESSAGES = { VIEWPORT_INIT_FAILED: "Viewport: unavailable", ACTION_CONFLICT: "Action: another project operation is running", ACTION_LOCK_FAILED: "Action: retry required", RECENT_PROJECT_LIST_FAILED: "Storage: recent projects unavailable", RECENT_PROJECT_REPAIR_FAILED: "Storage: recent project repair failed", RECENT_PROJECT_UPDATE_FAILED: "Storage: recent project update failed", STORAGE_BUDGET_FAILED: "Storage: budget unavailable", STORAGE_CLEANUP_FAILED: "Storage: cleanup failed", OPERATION_LOG_FAILED: "Storage: operation log unavailable", COMMAND_FAILED: "Engine: command failed", IMAGE_IMPORT_FAILED: "Image import failed", GREASE_PENCIL_EDIT_FAILED: "Grease Pencil edit failed", CURVE_EDIT_FAILED: "Curve edit failed", LOD_CACHE_READ_FAILED: "Storage: LOD cache unavailable; generated geometry retained", LOD_GENERATION_FAILED: "Engine: LOD generation failed", ENGINE_WORKER_TERMINATED: "Engine: Worker stopped; project remains available", ENGINE_START_FAILED: "Engine: unavailable", MANIFEST_REJECTED: "Manifest: rejected", STORAGE_WORKER_TERMINATED: "Storage: Worker stopped; project remains available", STORAGE_START_FAILED: "Storage: unavailable", PBR_ASSET_INVALID: "PBR asset unavailable", BLEND_OPEN_FAILED: "Engine: .blend open failed", POST_COMMIT_MAINTENANCE_FAILED: "Storage: post-commit maintenance failed", PROJECT_RECOVERY_FAILED: "Recovery: project could not be restored", WORKER_RECOVERY_FAILED: "Recovery: Worker restart failed", BLEND_SAVE_FAILED: "Engine: .blend save failed", BLEND_DOWNLOAD_FAILED: "Engine: .blend download failed", GLB_PROJECT_UNAVAILABLE: "GLB: no open project", GLB_EXPORT_BLOCKED: "GLB: export blocked", GLB_EXPORT_FAILED: "GLB: export failed", AUTOSAVE_FAILED: "Engine: autosave failed", } as const; export type AppDiagnosticCode = keyof typeof APP_DIAGNOSTIC_MESSAGES; export type AppDiagnosticArea = "ACTION" | "ENGINE" | "STORAGE" | "VIEWPORT" | "EXPORT" | "RUNTIME"; export type AppDiagnosticContextValue = string | number | boolean | null; export interface AppDiagnosticEntry { schemaVersion: typeof APP_DIAGNOSTIC_SCHEMA_VERSION; sequence: number; occurredAt: string; area: AppDiagnosticArea; code: AppDiagnosticCode; summary: string; detail: string; sourceCode?: string; stack?: string; cause?: string; context?: Record; } export interface AppDiagnosticReport { schemaVersion: typeof APP_DIAGNOSTIC_SCHEMA_VERSION; product: "Web Blender Modeler V1"; generatedAt: string; runtime: { url: string; userAgent: string; language: string; crossOriginIsolated: boolean; }; project: { projectId: string; revision: number; }; entries: AppDiagnosticEntry[]; } function objectString(value: unknown): string | undefined { if (typeof value !== "object" || value === null) return undefined; try { const seen = new WeakSet(); return JSON.stringify(value, (_key, candidate: unknown) => { if (candidate instanceof Error) { return { name: candidate.name, message: candidate.message, stack: candidate.stack, cause: candidate.cause }; } if (typeof candidate === "object" && candidate !== null) { if (seen.has(candidate)) return "[Circular]"; seen.add(candidate); } return candidate; }); } catch { return "[Unserializable diagnostic detail]"; } } function propertyString(value: unknown, property: string): string | undefined { if (typeof value !== "object" || value === null || !(property in value)) return undefined; const candidate = (value as Record)[property]; return typeof candidate === "string" && candidate ? candidate : undefined; } function diagnosticDetail(error: unknown): Pick { if (error instanceof Error) { return { detail: error.message || error.name, sourceCode: propertyString(error, "code"), stack: error.stack, cause: error.cause instanceof Error ? error.cause.message : typeof error.cause === "string" ? error.cause : objectString(error.cause), }; } if (typeof error === "string") return { detail: error }; const message = propertyString(error, "message"); const extraDetail = propertyString(error, "detail"); return { detail: [message, extraDetail].filter(Boolean).join("; ") || objectString(error) || String(error), sourceCode: propertyString(error, "code"), stack: propertyString(error, "stack"), cause: propertyString(error, "cause"), }; } export function createAppDiagnosticEntry(input: { sequence: number; occurredAt: string; area: AppDiagnosticArea; code: AppDiagnosticCode; error: unknown; context?: Record; }): AppDiagnosticEntry { if (!Number.isSafeInteger(input.sequence) || input.sequence <= 0) throw new Error("APP_DIAGNOSTIC_SEQUENCE_INVALID"); if (!Number.isFinite(Date.parse(input.occurredAt)) || new Date(input.occurredAt).toISOString() !== input.occurredAt) throw new Error("APP_DIAGNOSTIC_TIMESTAMP_INVALID"); const detail = diagnosticDetail(input.error); return { schemaVersion: APP_DIAGNOSTIC_SCHEMA_VERSION, sequence: input.sequence, occurredAt: input.occurredAt, area: input.area, code: input.code, summary: APP_DIAGNOSTIC_MESSAGES[input.code], detail: detail.detail, ...(detail.sourceCode ? { sourceCode: detail.sourceCode } : {}), ...(detail.stack ? { stack: detail.stack } : {}), ...(detail.cause ? { cause: detail.cause } : {}), ...(input.context ? { context: { ...input.context } } : {}), }; } export function appendAppDiagnostic(entries: readonly AppDiagnosticEntry[], entry: AppDiagnosticEntry, limit = MAX_APP_DIAGNOSTIC_ENTRIES): AppDiagnosticEntry[] { if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("APP_DIAGNOSTIC_LIMIT_INVALID"); return [...entries, entry].slice(-limit); } export function createAppDiagnosticReport(input: Omit & { entries: readonly AppDiagnosticEntry[] }): AppDiagnosticReport { if (!Number.isFinite(Date.parse(input.generatedAt)) || new Date(input.generatedAt).toISOString() !== input.generatedAt) throw new Error("APP_DIAGNOSTIC_REPORT_TIMESTAMP_INVALID"); return { schemaVersion: APP_DIAGNOSTIC_SCHEMA_VERSION, product: "Web Blender Modeler V1", generatedAt: input.generatedAt, runtime: { ...input.runtime }, project: { ...input.project }, entries: [...input.entries].sort((left, right) => left.sequence - right.sequence), }; }