Add OPFS G-code staging and browser simulation checks

This commit is contained in:
2026-06-22 10:56:21 +08:00
parent 8321055934
commit 61e2fe8441
19 changed files with 1869 additions and 35 deletions

View File

@@ -90,7 +90,7 @@ export async function saveFiveAxisSessionSnapshot(sessionId, payload, options =
const path = sessionSnapshotPath(sessionId, options.filename);
const storage = resolveSessionStorage(options);
await saveTextFile(path, `${JSON.stringify(snapshot, null, 2)}\n`, storage.storage);
return { snapshot, path, storageMode: storage.mode };
return { snapshot, path, storageMode: storage.mode, storageCapability: storage.capability };
}
export async function loadFiveAxisSessionSnapshot(sessionId, options = {}) {
@@ -103,7 +103,12 @@ export async function loadFiveAxisSessionSnapshot(sessionId, options = {}) {
} catch (error) {
throw new Error(`Invalid five-axis session snapshot JSON: ${error.message}`);
}
return { snapshot: validateFiveAxisSessionSnapshot(snapshot, sessionId), path, storageMode: storage.mode };
return {
snapshot: validateFiveAxisSessionSnapshot(snapshot, sessionId),
path,
storageMode: storage.mode,
storageCapability: storage.capability,
};
}
export function restoreFiveAxisSessionState(snapshot) {
@@ -150,24 +155,65 @@ export function createMemorySessionStorage(seed = {}) {
let browserMemorySessionStorage = null;
export function detectBrowserStorageCapability(globalScope = globalThis) {
const forcedUnavailable = Boolean(globalScope.__WEB_RTCP_FORCE_OPFS_UNAVAILABLE__);
const isBrowser = typeof globalScope.window === "object" || typeof globalScope.document === "object";
const secureContext = !isBrowser || globalScope.isSecureContext !== false;
const hasOpfs = typeof globalScope.navigator?.storage?.getDirectory === "function";
const opfsAvailable = hasOpfs && secureContext && !forcedUnavailable;
return {
apiName: "web-rtcp-5axis-storage-capability",
opfsAvailable,
opfsUnavailable: !opfsAvailable,
secureContext,
forcedUnavailable,
hasNavigatorStorage: Boolean(globalScope.navigator?.storage),
hasGetDirectory: hasOpfs,
fallbackMode: opfsAvailable ? null : "memory-fallback",
reason: opfsAvailable
? "opfs_available"
: forcedUnavailable
? "forced_unavailable"
: !secureContext
? "non_secure_context"
: !hasOpfs
? "missing_opfs_get_directory"
: "opfs_unavailable",
};
}
function resolveSessionStorage(options = {}) {
if (options.storage) {
return {
storage: options.storage,
mode: options.storageMode || storageModeFor(options.storage),
capability: {
apiName: "web-rtcp-5axis-storage-capability",
opfsAvailable: options.storageMode === "opfs",
opfsUnavailable: options.storageMode !== "opfs",
secureContext: true,
forcedUnavailable: false,
hasNavigatorStorage: false,
hasGetDirectory: typeof options.storage.getDirectory === "function",
fallbackMode: options.storageMode === "opfs" ? null : options.storageMode || "custom",
reason: "explicit_storage",
},
};
}
const capability = detectBrowserStorageCapability();
const browserStorage = globalThis.navigator?.storage;
if (browserStorage?.getDirectory) {
if (capability.opfsAvailable) {
return {
storage: browserStorage,
mode: "opfs",
capability,
};
}
browserMemorySessionStorage ??= createMemorySessionStorage();
return {
storage: browserMemorySessionStorage,
mode: "memory-fallback",
capability,
};
}

View File

@@ -200,13 +200,14 @@ function runPlannerTiming(tpRuntime, motion) {
return null;
}
try {
const sampleStride = motion.length > 1000 ? Math.ceil(motion.length / 90) : 10;
return tpRuntime.sdk.runCanonicalMotionTiming({
motion,
options: {
cycleTime: 0.001,
queueSize: 32,
maxCycles: 2000000,
sampleStride: 10,
sampleStride,
maxVelocity: 35,
maxAcceleration: 500,
maxJerk: 1000,
@@ -334,6 +335,9 @@ export function prepareLinuxCncProgramForRuntime(programText) {
const switchkinsEvents = [];
const runtimeLines = String(programText).split(/\r?\n/).map((line, index) => {
const lineNumber = index + 1;
if (String(line).trim() === "%") {
return "(linuxcnc program delimiter)";
}
const events = readSwitchkinsEventsFromLine(line, lineNumber);
if (events.length === 0) return line;
switchkinsEvents.push(...events);

View File

@@ -10,6 +10,7 @@ const DEFAULT_VENDOR_ROOT_URLS = [
const TRT_MACHINE_REL = "axis/vismach/5axis/table-rotary-tilting";
const TRT_DEMO_SOURCE_PREFIX = `configs/sim/${TRT_MACHINE_REL}/demos/`;
const OPFS_ROOT = "web-rtcp-5axis-sim-plan/machines";
let browserMemoryMachineFileStorage = null;
export async function createMachineFileStagingPlan({
profile,
@@ -79,6 +80,23 @@ export function listLinuxCncGcodeSources(save) {
.sort((left, right) => left.filename.localeCompare(right.filename));
}
export function listProjectGcodeFiles(save) {
return [...(save?.files || [])]
.filter((file) => file.kind === "demo" || file.kind === "remap")
.map((file) => ({
sourceRel: file.sourceRel,
wasmPath: file.wasmPath,
opfsPath: file.opfsPath,
filename: basename(file.sourceRel),
bytes: file.bytes,
kind: file.kind,
semanticBoundary: file.kind === "demo"
? "linuxcnc_vendored_5axis_gcode_source_file"
: "linuxcnc_vendored_5axis_remap_subroutine_file",
}))
.sort((left, right) => left.sourceRel.localeCompare(right.sourceRel));
}
export function selectMachineFileProgram(plan, save, sourceRel) {
if (!isLinuxCncFiveAxisGcodeSourceRel(sourceRel)) {
throw new Error(`5-axis G-code source must come from LinuxCNC source demos: ${sourceRel}`);
@@ -106,10 +124,11 @@ export async function saveMachineFileStagingPlan(plan, options = {}) {
if (plan?.apiName !== "web-rtcp-5axis-machine-file-staging-plan") {
throw new Error("saveMachineFileStagingPlan requires a machine-file staging plan");
}
const storage = resolveMachineFileStorage(options);
const savedFiles = [];
for (const file of plan.files) {
const text = await readTextFromCandidateUrls(sourceUrlsFor(file.sourceRel));
await saveTextFile(file.opfsPath, text, options.storage);
await saveTextFile(file.opfsPath, text, storage.storage);
savedFiles.push({
sourceRel: file.sourceRel,
opfsPath: file.opfsPath,
@@ -129,8 +148,11 @@ export async function saveMachineFileStagingPlan(plan, options = {}) {
savedAt: new Date().toISOString(),
fileCount: savedFiles.length,
opfsRoot: `${OPFS_ROOT}/${plan.profileId}`,
storageMode: storage.mode,
storageCapability: storage.capability,
files: savedFiles,
gcodeSources: listLinuxCncGcodeSources({ files: savedFiles }),
gcodeFiles: listProjectGcodeFiles({ files: savedFiles }),
summary: summarizeSavedFiles(savedFiles),
taskHalSession: {
...(plan.taskHalSession || {}),
@@ -142,7 +164,9 @@ export async function saveMachineFileStagingPlan(plan, options = {}) {
bytes: file.bytes,
})),
},
semanticBoundary: "opfs_machine_file_text_staging_only",
semanticBoundary: storage.mode === "opfs"
? "opfs_machine_file_text_staging_only"
: "memory_machine_file_text_staging_current_page_lifecycle_only",
};
}
@@ -155,15 +179,95 @@ export async function stageProfileMachineFiles(profile, options = {}) {
manifestUrl: options.manifestUrl,
wasmDir: options.wasmDir,
});
const save = await saveMachineFileStagingPlan(plan, { storage: options.storage });
const save = await saveMachineFileStagingPlan(plan, {
storage: options.storage,
storageMode: options.storageMode,
});
return { plan, save };
}
export function detectMachineFileStorageCapability(globalScope = globalThis) {
const forcedUnavailable = Boolean(globalScope.__WEB_RTCP_FORCE_OPFS_UNAVAILABLE__);
const isBrowser = typeof globalScope.window === "object" || typeof globalScope.document === "object";
const secureContext = !isBrowser || globalScope.isSecureContext !== false;
const hasOpfs = typeof globalScope.navigator?.storage?.getDirectory === "function";
const opfsAvailable = hasOpfs && secureContext && !forcedUnavailable;
return {
apiName: "web-rtcp-5axis-machine-file-storage-capability",
opfsAvailable,
opfsUnavailable: !opfsAvailable,
secureContext,
forcedUnavailable,
hasNavigatorStorage: Boolean(globalScope.navigator?.storage),
hasGetDirectory: hasOpfs,
fallbackMode: opfsAvailable ? null : "memory-fallback",
reason: opfsAvailable
? "opfs_available"
: forcedUnavailable
? "forced_unavailable"
: !secureContext
? "non_secure_context"
: !hasOpfs
? "missing_opfs_get_directory"
: "opfs_unavailable",
};
}
function resolveMachineFileStorage(options = {}) {
if (options.storage) {
return {
storage: options.storage,
mode: options.storageMode || storageModeFor(options.storage),
capability: {
apiName: "web-rtcp-5axis-machine-file-storage-capability",
opfsAvailable: options.storageMode === "opfs",
opfsUnavailable: options.storageMode !== "opfs",
secureContext: true,
fallbackMode: options.storageMode === "opfs" ? null : options.storageMode || "custom",
reason: "explicit_storage",
},
};
}
const capability = detectMachineFileStorageCapability();
if (capability.opfsAvailable) {
return {
storage: globalThis.navigator.storage,
mode: "opfs",
capability,
};
}
browserMemoryMachineFileStorage ??= createMemoryStorage();
return {
storage: browserMemoryMachineFileStorage,
mode: "memory-fallback",
capability,
};
}
function storageModeFor(storage) {
return storage?.apiName === "web-rtcp-5axis-memory-machine-file-storage" ||
storage?.apiName === "web-rtcp-5axis-memory-session-storage"
? "memory"
: "custom";
}
function createMemoryStorage(seed = {}) {
const files = new Map(Object.entries(seed));
return {
apiName: "web-rtcp-5axis-memory-machine-file-storage",
files,
async getDirectory() {
return createDirectoryHandle(files, []);
},
};
}
function summarizePlan(files) {
const kinds = countKinds(files.map((file) => classifySourceRel(file.sourceRel)));
return {
fileCount: files.length,
requiredFileCount: files.filter((file) => file.sourceRel.endsWith(".ini") || file.sourceRel.includes("/demos/")).length,
gcodeFileCount: (kinds.demo || 0) + (kinds.remap || 0),
remapFileCount: kinds.remap || 0,
demoFileCount: kinds.demo || 0,
toolTableFileCount: kinds.toolTable || 0,
@@ -208,10 +312,12 @@ function isLinuxCncFiveAxisGcodeSourceRel(sourceRel) {
}
function summarizeSavedFiles(files) {
const kinds = countKinds(files.map((file) => file.kind));
return {
fileCount: files.length,
totalBytes: files.reduce((total, file) => total + file.bytes, 0),
kinds: countKinds(files.map((file) => file.kind)),
gcodeFileCount: (kinds.demo || 0) + (kinds.remap || 0),
kinds,
opfsPaths: files.map((file) => file.opfsPath),
};
}
@@ -266,7 +372,11 @@ async function readTextFromUrl(url) {
if (!response.ok) {
throw new Error(`failed to fetch machine staging asset ${url}: ${response.status}`);
}
return response.text();
const text = await response.text();
if (/^\s*<!doctype html/i.test(text) || /^\s*<html[\s>]/i.test(text)) {
throw new Error(`machine staging asset resolved to HTML fallback ${url}`);
}
return text;
}
async function saveTextFile(path, text, storage = globalThis.navigator?.storage) {
@@ -294,6 +404,34 @@ async function ensureParentDir(root, path) {
return current;
}
function createDirectoryHandle(files, prefix) {
return {
async getDirectoryHandle(name) {
return createDirectoryHandle(files, [...prefix, name]);
},
async getFileHandle(name) {
const path = [...prefix, name].join("/");
return {
async createWritable() {
let content = "";
return {
async write(text) {
content += String(text);
},
async close() {
files.set(path, content);
},
};
},
async getFile() {
if (!files.has(path)) throw new Error(`Missing memory machine file: ${path}`);
return { async text() { return files.get(path); } };
},
};
},
};
}
function splitPath(path) {
const parts = String(path || "").replaceAll("\\", "/").split("/").filter(Boolean);
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {

View File

@@ -0,0 +1,161 @@
const WEB_SIMULATION_BOUNDARY = "linuxcnc_task_motion_hal_wasm_simulation_runtime";
const ALLOWED_NATIVE_PROBE_STATUSES = new Set([
"passed",
"ready_disabled_by_default",
"skipped_missing_host_runtime",
]);
export function createNativeTaskHalReadinessAudit({
sourceManifest = {},
nativeProbe = {},
fullExecutionBoundary = {},
taskHalRuntimeReadiness = {},
taskHalStatus = {},
generatedAt = new Date().toISOString(),
artifactPaths = {},
} = {}) {
const taskSourceCount = numberFrom(sourceManifest.task_source_count);
const halSourceCount = numberFrom(sourceManifest.hal_source_count);
const motionSourceCount = numberFrom(sourceManifest.motion_source_count);
const sourceManifestReady = truthy(sourceManifest.task_hal_source_manifest_ready)
&& truthy(sourceManifest.task_hal_reference_source_ready)
&& taskSourceCount > 0
&& halSourceCount > 0
&& motionSourceCount > 0;
const nativeProbeStatus = String(
nativeProbe.native_probe_status
|| nativeProbe.trt_task_hal_runtime_probe_status
|| "missing",
);
const nativeProbeOk = String(nativeProbe.native_task_hal_probe || "") === "ok"
&& ALLOWED_NATIVE_PROBE_STATUSES.has(nativeProbeStatus);
const nativePromotionBlocked = !truthy(nativeProbe.trt_task_hal_promotion_allowed)
&& String(nativeProbe.nativeTaskReady) !== "true"
&& String(nativeProbe.nativeHalSyncReady) !== "true";
const taskReady = Boolean(
fullExecutionBoundary.taskRuntimeReady
|| taskHalRuntimeReadiness.taskRuntimeReady
|| taskHalStatus.summary?.taskRuntimeReady,
);
const motionReady = Boolean(
fullExecutionBoundary.motionRuntimeReady
|| taskHalRuntimeReadiness.motionRuntimeReady
|| taskHalStatus.summary?.motionRuntimeReady,
);
const halReady = Boolean(
fullExecutionBoundary.halRuntimeReady
|| taskHalRuntimeReadiness.halRuntimeReady
|| taskHalStatus.summary?.halRuntimeReady,
);
const halSyncReady = Boolean(
fullExecutionBoundary.halSyncReady
|| taskHalRuntimeReadiness.halSyncReady
|| taskHalStatus.summary?.halSyncReady,
);
const fullBoundarySimulationReady = Boolean(
fullExecutionBoundary.semanticBoundary === WEB_SIMULATION_BOUNDARY
&& fullExecutionBoundary.fullLinuxCncProgramExecutionReady === true
&& fullExecutionBoundary.promotionAllowed === true,
);
const hardwareBlocked = fullExecutionBoundary.hardwareDrive === false
&& fullExecutionBoundary.hostRealtimeKernel === false
&& fullExecutionBoundary.externalUserMProcessReady === false
&& fullExecutionBoundary.toolDbProcessReady === false;
const webSimulationConsistent = sourceManifestReady
&& nativeProbeOk
&& nativePromotionBlocked
&& taskReady
&& motionReady
&& halReady
&& halSyncReady
&& fullBoundarySimulationReady
&& hardwareBlocked;
return {
apiName: "web-rtcp-5axis-native-task-hal-readiness-audit",
batch: "M18-native-task-hal-source-and-artifact-audit",
generatedAt,
status: webSimulationConsistent ? "ok" : "blocked",
semanticBoundary: WEB_SIMULATION_BOUNDARY,
promotionScope: "web_simulation_only",
taskHalWebSimulationBoundaryConsistent: webSimulationConsistent,
webSimulation: {
promoted: fullBoundarySimulationReady,
taskRuntimeReady: taskReady,
motionRuntimeReady: motionReady,
halRuntimeReady: halReady,
nativeTaskReady: fullExecutionBoundary.nativeTaskReady === true,
nativeHalSyncReady: fullExecutionBoundary.nativeHalSyncReady === true,
fullLinuxCncProgramExecutionReady:
fullExecutionBoundary.fullLinuxCncProgramExecutionReady === true,
promotionAllowed: fullExecutionBoundary.promotionAllowed === true,
},
nativeHostAndHardware: {
nativeProbe: nativeProbeOk ? "ok" : "blocked",
nativeProbeStatus,
nativePromotionAllowed: truthy(nativeProbe.trt_task_hal_promotion_allowed),
hardwareDrive: false,
hostRealtimeKernel: false,
externalUserMProcessReady: false,
toolDbProcessReady: false,
},
sourceManifest: {
ready: sourceManifestReady,
taskSourceCount,
halSourceCount,
motionSourceCount,
nmlSourceCount: numberFrom(sourceManifest.nml_source_count),
libnmlSourceCount: numberFrom(sourceManifest.libnml_source_count),
referenceSourceReady: truthy(sourceManifest.task_hal_reference_source_ready),
vendorSourceReady: truthy(sourceManifest.task_hal_vendor_source_ready),
vendorHashMatchReady: truthy(sourceManifest.task_hal_vendor_hash_match_ready),
},
gates: {
task_hal_web_simulation_boundary_consistent: webSimulationConsistent ? 1 : 0,
native_task_hal_host_probe_status: nativeProbeStatus,
hardware_drive: 0,
host_realtime_kernel: 0,
external_user_m_process_ready: 0,
tool_db_process_ready: 0,
promotion_scope: "web_simulation_only",
},
artifacts: artifactPaths,
blockers: webSimulationConsistent ? [] : buildBlockers({
sourceManifestReady,
nativeProbeOk,
nativePromotionBlocked,
taskReady,
motionReady,
halReady,
halSyncReady,
fullBoundarySimulationReady,
hardwareBlocked,
}),
};
}
function buildBlockers(checks) {
const blockers = [];
if (!checks.sourceManifestReady) blockers.push("task/HAL source manifest proof is incomplete");
if (!checks.nativeProbeOk) blockers.push("native host probe did not produce an accepted default status");
if (!checks.nativePromotionBlocked) blockers.push("native probe unexpectedly allowed promotion");
if (!checks.taskReady) blockers.push("task runtime is not ready in the Web simulation boundary");
if (!checks.motionReady) blockers.push("motion runtime is not ready in the Web simulation boundary");
if (!checks.halReady) blockers.push("HAL runtime is not ready in the Web simulation boundary");
if (!checks.halSyncReady) blockers.push("task/motion/HAL sync is not ready in the Web simulation boundary");
if (!checks.fullBoundarySimulationReady) blockers.push("full execution boundary is not promoted for Web simulation");
if (!checks.hardwareBlocked) blockers.push("hardware/native process blocked fields are not explicitly false");
return blockers;
}
function truthy(value) {
return value === true || value === 1 || value === "1" || value === "true";
}
function numberFrom(value) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : 0;
}