import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import createModule from "../../build-wasm/linuxcnc-sim-core.js"; import { MemoryStorage, WorkspaceJournal } from "../workspace/journal.mjs"; import { opfsCapability, runOpfsProbe } from "../workspace/opfs-runtime.mjs"; import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs"; import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs"; import { fnv1a64 } from "./replay.mjs"; const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini"; const SOURCE = "G21 G90\nG0 X0 Y0 Z10 A0 C0\nG1 X40 Y20 Z5 A30 C45 F100\nG1 X0 Y0 Z10 A0 C0 F100\nM2\n"; const fetchImpl = async (url) => { try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; } catch { return { ok: false, text: async () => "" }; } }; const bundle = await loadSimulationCase({ path: CASE, fetchImpl }); assert.equal(bundle.status, "ready"); const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)); async function create(id) { const core = await createModule({ wasmBinary }); const session = new XyzacSession(core, { sessionId: id }); const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: id }; let seq = 0; const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload }); send("HELLO", "hello"); send("SESSION_ATTACH", "attach"); const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }); assert.equal(loaded.payload.status, "verified"); assert.equal(send("POWER", "power-on", { active: true }).payload.status, "verified"); assert.equal(send("HOME", "home-all", { all: true }).payload.homing.phase, "accepted"); assert.equal(send("SIM_STEP", "home-complete", { cycles: 1 }).payload.panel_snapshot.motion.homed, true); return { core, session, send, adoptSequence: (value) => { seq = value; } }; } const continuous = await create("xyzac-recovery-continuous"); const switched = continuous.send("MDI_EXECUTE", "switch-once", { block: "M428" }); assert.equal(switched.payload.kinematics.active_type, 1); const loaded = continuous.send("PROGRAM_LOAD", "program-load", { path: "recovery.ngc", source: SOURCE }); assert.equal(loaded.payload.status, "verified"); continuous.send("PROGRAM_RUN", "program-run"); const stepped = continuous.send("SIM_STEP", "motion-step", { cycles: 2 }); assert.equal(stepped.payload.status, "verified"); const checkpointResponse = continuous.send("CHECKPOINT", "checkpoint"); assert.equal(checkpointResponse.kind, "CHECKPOINT_READY", checkpointResponse.payload.message_key); const checkpoint = checkpointResponse.payload.checkpoint; assert.equal(checkpoint.state.kinematics.active_type, 1); assert.equal(checkpoint.state.kinematics.coordinates, "XYZAC"); assert.equal(checkpoint.state.kinematics.joints.length, 5); assert.equal(checkpoint.state.kinematics.offsets.y_offset, 20); assert.equal(checkpoint.state.kinematics.offsets.z_offset, 10); assert.equal(checkpoint.state.kinematics.offsets.tool_offset, 0); assert.equal(checkpoint.state.motion_authority, "wasm"); assert.equal(fnv1a64(checkpoint.state), checkpoint.state_hash); const before = continuous.session.workspacePackageState(); const switchCountBefore = checkpoint.state.kinematics.switch_count; continuous.send("SIM_STEP", "after-checkpoint", { cycles: 2 }); const endpoint = continuous.session.workspacePackageState(); const restarted = await create("xyzac-recovery-continuous"); const rejectedTail = restarted.session.restoreCheckpoint(checkpoint, continuous.session.timeline.export(), (operation) => operation); assert.equal(rejectedTail.status, "blocked"); assert.equal(rejectedTail.blocked[0].reason, "trt_checkpoint_tail_requires_explicit_commands"); const recovery = restarted.session.restoreCheckpoint(checkpoint); if (recovery.status !== "recovered") console.error(JSON.stringify(recovery)); assert.equal(recovery.status, "recovered"); const restored = restarted.session.workspacePackageState(); assert.equal(restored.kinematics.active_type, 1); assert.deepEqual(restored.kinematics.joints, before.kinematics.joints); assert.deepEqual(restored.kinematics.pose, before.kinematics.pose); assert.equal(restored.kinematics.state_hash, before.kinematics.state_hash); assert.equal(restored.kinematics.switch_count, switchCountBefore); assert.equal(restored.program.source_hash, before.program.source_hash); assert.equal(restored.motion_authority, "wasm"); assert.equal(fnv1a64(restored), fnv1a64(before), "checkpoint must restore complete runtime state"); const replay = restarted.session.ledger.reconcile("switch-once"); assert.equal(replay.status, "committed"); assert.equal(restarted.session.xyzacKinematics.snapshot().switch_count, switchCountBefore); restarted.adoptSequence(checkpoint.event_cursor); const replayedSwitch = restarted.send("MDI_EXECUTE", "switch-once", { block: "M428" }); assert.equal(replayedSwitch.replayed, true); assert.equal(restarted.session.xyzacKinematics.snapshot().switch_count, switchCountBefore); assert.deepEqual(replayedSwitch.payload.panel_snapshot.visual.joints, before.motion.joints); const corruptedState = structuredClone(checkpoint); corruptedState.state.kinematics.pose.x += 1; corruptedState.state_hash = fnv1a64(corruptedState.state); const corrupted = restarted.session.restoreCheckpoint(corruptedState, null, (operation) => operation); assert.equal(corrupted.status, "blocked"); assert.equal(corrupted.blocked[0].reason, "checkpoint_hash_mismatch"); const schemaMismatch = { ...checkpoint, schema: "simulation-checkpoint.v9" }; const schemaBlocked = restarted.session.restoreCheckpoint(schemaMismatch, null, (operation) => operation); assert.equal(schemaBlocked.status, "blocked"); const storage = new MemoryStorage(); const journal = new WorkspaceJournal(storage, { projectId: "xyzac-recovery" }); const prepare = journal.prepare("xyzac-journal-op", [{ path: "programs/recovery.ngc", data: SOURCE }]); const commit = journal.commit("xyzac-journal-op"); const journalRecovered = journal.recover(); assert.equal(journalRecovered.status, "recovered"); assert.equal(journalRecovered.files["programs/recovery.ngc"], SOURCE); const journalCorrupt = new MemoryStorage(Object.fromEntries(storage.entries().map(([key, value]) => { const record = JSON.parse(String(value)); if (record.kind === "prepare") record.changes[0].data = `${record.changes[0].data}X`; return [key, JSON.stringify(record)]; }))); assert.equal(new WorkspaceJournal(journalCorrupt, { projectId: "xyzac-recovery" }).recover().status, "blocked"); const journalSchemaMismatch = new WorkspaceJournal(new MemoryStorage({ bad: JSON.stringify({ schema: "other.v1", schema_version: 9, project_id: "xyzac-recovery" }) }), { projectId: "xyzac-recovery" }).recover(); assert.equal(journalSchemaMismatch.status, "blocked"); assert.ok(prepare.content_hash && commit.prepare_hash); const opfsUnavailable = opfsCapability({ navigator: { storage: null } }); assert.equal(opfsUnavailable.status, "blocked"); const opfsProbe = await runOpfsProbe({ storage: null, projectId: "xyzac-recovery" }); assert.equal(opfsProbe.status, "blocked"); console.log(JSON.stringify({ schema: "xyzac-recovery-rtcp-11.v1", status: "verified", case: CASE, checkpoint: { required_fields: ["context_id", "simulation_tick", "event_cursor", "state_hash", "task", "program", "kinematics", "motion", "wasm_motion", "ledger"], active_type: checkpoint.state.kinematics.active_type, state_hash: checkpoint.state.kinematics.state_hash }, continuous_endpoint_hash: fnv1a64(before), restored_endpoint_hash: fnv1a64(restored), checkpoint_tick: checkpoint.simulation_tick, unreplayed_tail_endpoint_hash: fnv1a64(endpoint), endpoint_at_checkpoint_equal: fnv1a64(before) === fnv1a64(restored), no_duplicate_m428: restarted.session.xyzacKinematics.snapshot().switch_count === switchCountBefore, replayed_operation: replay.status === "committed", corrupt_checkpoint: corrupted.status, schema_mismatch: schemaBlocked.status, journal: { status: journalRecovered.status, corrupt_status: "blocked", schema_status: journalSchemaMismatch.status }, opfs: { unavailable_status: opfsUnavailable.status, probe_status: opfsProbe.status, physical_crash: "REAL-EXCLUDED" }, first_divergent_tick: null, }));