Initial LinuxCNC WebAssembly workspace snapshot

This commit is contained in:
2026-08-20 18:21:15 +08:00
commit 5dd614c335
92 changed files with 200203 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
import { realExcluded } from "./error-map.mjs";
export const LIMITS = Object.freeze({
max_command_bytes: 65536,
max_snapshot_bytes: 1048576,
max_tick_step: 1000000,
max_event_records: 256,
max_workspace_path_bytes: 1024,
max_workspace_file_bytes: 64 * 1024 * 1024,
});
export const COMMANDS = Object.freeze([
"SESSION_ATTACH", "LOAD_WORKSPACE", "COMMAND", "SIM_STEP", "SNAPSHOT_REQUEST",
"PROGRAM_PAUSE", "PROGRAM_RESUME", "CHECKPOINT", "RECOVER", "DESTROY",
]);
export const EXCLUSIONS = Object.freeze([
realExcluded("real_hardware", "browser_has_no_direct_machine_control", "digital_twin"),
realExcluded("linux_realtime_kernel", "wasm_worker_uses_logical_ticks", "fixed_tick_scheduler"),
realExcluded("host_device_access", "browser_sandbox_disallows_arbitrary_devices", "virtual_device_adapter"),
]);
export function capabilities(contextId) {
return {
context_id: contextId,
abi: 1,
protocol: 1,
snapshot: 1,
event: 1,
workspace: 1,
commands: [...COMMANDS],
streams: ["STATE_SNAPSHOT", "EVENT", "ALARM"],
limits: { ...LIMITS },
exclusions: EXCLUSIONS.map((entry) => ({ ...entry })),
};
}
export function validateLimit(name, value) {
if (!(name in LIMITS)) return { status: "blocked", code: "capability_unknown_limit", message_key: "capability.unknown_limit", limit: name };
if (!Number.isSafeInteger(value) || value < 0) return { status: "blocked", code: "capability_invalid_value", message_key: "capability.invalid_value", limit: name, value };
if (value > LIMITS[name]) return { status: "blocked", code: "capability_limit_exceeded", message_key: "capability.limit_exceeded", limit: name, value, maximum: LIMITS[name] };
return { status: "accepted", limit: name, value, maximum: LIMITS[name] };
}

58
web/worker/error-map.mjs Normal file
View File

@@ -0,0 +1,58 @@
export const ERROR_DEFINITIONS = Object.freeze({
0: { code: "ok", message_key: "ok", retryable: false, category: "none" },
[-1]: { code: "sim_invalid_argument", message_key: "sim.invalid_argument", retryable: false, category: "core" },
[-2]: { code: "sim_not_created", message_key: "sim.not_created", retryable: false, category: "core" },
[-3]: { code: "sim_buffer_too_small", message_key: "sim.buffer_too_small", retryable: true, category: "core" },
[-4]: { code: "sim_invalid_state", message_key: "sim.invalid_state", retryable: false, category: "core" },
[-5]: { code: "sim_unsupported", message_key: "sim.unsupported", retryable: false, category: "capability" },
[-100]: { code: "protocol_schema", message_key: "protocol.unsupported_schema", retryable: false, category: "protocol" },
[-101]: { code: "protocol_sequence", message_key: "protocol.sequence_gap", retryable: true, category: "protocol" },
[-102]: { code: "protocol_state", message_key: "protocol.invalid_state", retryable: false, category: "protocol" },
[-103]: { code: "protocol_operation_id", message_key: "protocol.operation_id_required", retryable: false, category: "protocol" },
[-104]: { code: "protocol_kind", message_key: "protocol.invalid_kind", retryable: false, category: "protocol" },
[-105]: { code: "protocol_argument", message_key: "protocol.invalid_argument", retryable: false, category: "protocol" },
[-106]: { code: "protocol_tick", message_key: "protocol.tick_regression", retryable: false, category: "protocol" },
[-200]: { code: "event_sequence", message_key: "event.sequence_gap", retryable: true, category: "event" },
[-201]: { code: "event_tick", message_key: "event.tick_regression", retryable: false, category: "event" },
[-202]: { code: "event_argument", message_key: "event.invalid_argument", retryable: false, category: "event" },
[-203]: { code: "event_full", message_key: "event.capacity_exceeded", retryable: true, category: "event" },
[-204]: { code: "event_buffer", message_key: "event.buffer_too_small", retryable: true, category: "event" },
});
export function mapCoreError(value, context = {}) {
const definition = ERROR_DEFINITIONS[value];
if (!definition) {
return {
status: "error",
code: "core_unknown_error",
message_key: "core.unknown_error",
category: "core",
retryable: false,
original_code: value,
operation_id: context.operation_id ?? null,
simulation_tick: context.simulation_tick ?? 0,
source: context.source ?? null,
};
}
return {
status: value === 0 ? "ok" : "error",
...definition,
original_code: value,
operation_id: context.operation_id ?? null,
simulation_tick: context.simulation_tick ?? 0,
source: context.source ?? null,
};
}
export function realExcluded(capability, reason, substitute = null) {
return {
status: "REAL-EXCLUDED",
code: "real_hardware_excluded",
message_key: "capability.real_hardware_excluded",
category: "capability",
capability,
reason,
substitute,
retryable: false,
};
}

125
web/worker/replay.mjs Normal file
View File

@@ -0,0 +1,125 @@
const EVENT_SCHEMA = "event-log.v1";
const SNAPSHOT_SCHEMA = "snapshot.v1";
export function canonicalJson(value) {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
}
export function fnv1a64(value) {
const bytes = new TextEncoder().encode(typeof value === "string" ? value : canonicalJson(value));
let hash = 1469598103934665603n;
for (const byte of bytes) {
hash ^= BigInt(byte);
hash = BigInt.asUintN(64, hash * 1099511628211n);
}
return `fnv1a64:${hash.toString(16).padStart(16, "0")}`;
}
function requireInteger(value, name) {
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`invalid_${name}`);
}
export class EventLog {
constructor(events = []) {
this.events = [];
for (const event of events) this.append(event.seq, event.tick, event.kind, event.payload, event.payload_hash);
}
append(seq, tick, kind, payload = {}, expectedHash = undefined) {
requireInteger(seq, "seq");
requireInteger(tick, "tick");
if (seq !== this.cursor + 1) throw new Error("event_sequence_gap");
if (tick < this.tick) throw new Error("event_tick_regression");
if (typeof kind !== "string" || kind.length === 0 || kind.length > 64) throw new Error("event_kind_invalid");
const payloadHash = fnv1a64(payload);
if (expectedHash !== undefined && expectedHash !== payloadHash) throw new Error("event_payload_hash_mismatch");
const event = { seq, tick, kind, payload, payload_hash: payloadHash };
this.events.push(event);
return event;
}
get cursor() { return this.events.length === 0 ? 0 : this.events[this.events.length - 1].seq; }
get tick() { return this.events.length === 0 ? 0 : this.events[this.events.length - 1].tick; }
export() {
return { schema: EVENT_SCHEMA, cursor: this.cursor, tick: this.tick, events: this.events.map((event) => ({ ...event })) };
}
static import(value) {
if (!value || value.schema !== EVENT_SCHEMA || !Array.isArray(value.events)) throw new Error("event_log_schema");
const log = new EventLog(value.events);
if (log.cursor !== value.cursor || log.tick !== value.tick) throw new Error("event_log_cursor_mismatch");
return log;
}
}
export class SnapshotStore {
constructor(snapshots = []) {
this.snapshots = new Map();
for (const snapshot of snapshots) this.importSnapshot(snapshot);
}
create(seq, tick, state, baseSnapshotId = null) {
requireInteger(seq, "snapshot_seq");
requireInteger(tick, "snapshot_tick");
if (baseSnapshotId !== null && !this.snapshots.has(baseSnapshotId)) throw new Error("snapshot_base_missing");
const stateHash = fnv1a64(state);
const snapshot = {
schema: SNAPSHOT_SCHEMA,
snapshot_id: `snap:${seq}:${stateHash}`,
base_snapshot_id: baseSnapshotId,
seq,
tick,
state,
state_hash: stateHash,
};
this.snapshots.set(snapshot.snapshot_id, snapshot);
return snapshot;
}
importSnapshot(snapshot) {
if (!snapshot || snapshot.schema !== SNAPSHOT_SCHEMA || typeof snapshot.snapshot_id !== "string") throw new Error("snapshot_schema");
if (snapshot.base_snapshot_id !== null && !this.snapshots.has(snapshot.base_snapshot_id)) throw new Error("snapshot_base_missing");
if (fnv1a64(snapshot.state) !== snapshot.state_hash) throw new Error("snapshot_hash_mismatch");
if (snapshot.snapshot_id !== `snap:${snapshot.seq}:${snapshot.state_hash}`) throw new Error("snapshot_id_mismatch");
this.snapshots.set(snapshot.snapshot_id, { ...snapshot });
return snapshot;
}
latest() {
return this.snapshots.size === 0 ? null : [...this.snapshots.values()][this.snapshots.size - 1];
}
export() {
return { schema: SNAPSHOT_SCHEMA, snapshots: [...this.snapshots.values()].map((snapshot) => ({ ...snapshot })) };
}
}
export class ReplayTimeline {
constructor(value = null) {
this.events = value ? EventLog.import(value.events) : new EventLog();
this.snapshots = value ? new SnapshotStore(value.snapshots.snapshots) : new SnapshotStore();
}
appendEvent(seq, tick, kind, payload = {}) {
return this.events.append(seq, tick, kind, payload);
}
checkpoint(seq, tick, state, baseSnapshotId = null) {
return this.snapshots.create(seq, tick, state, baseSnapshotId);
}
replay(initialState, apply) {
let state = initialState;
for (const event of this.events.events) state = apply(state, event);
return { state, state_hash: fnv1a64(state), cursor: this.events.cursor, tick: this.events.tick };
}
export() {
return { events: this.events.export(), snapshots: this.snapshots.export() };
}
}
export { EVENT_SCHEMA, SNAPSHOT_SCHEMA };

144
web/worker/sim-session.mjs Normal file
View File

@@ -0,0 +1,144 @@
import { ReplayTimeline, fnv1a64 } from "./replay.mjs";
import { capabilities, validateLimit } from "./capabilities.mjs";
import { mapCoreError } from "./error-map.mjs";
const PROTOCOL_SCHEMA = 1;
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m0-c1";
function text(value) {
return typeof value === "string" && value.length > 0 ? value : null;
}
function call(core, name, ...args) {
if (args.some((value) => typeof value === "string") && typeof core.ccall === "function") {
return core.ccall(name, "number", args.map((value) => typeof value === "string" ? "string" : "number"), args);
}
const direct = core[`_${name}`] ?? core[name];
if (typeof direct === "function") return direct(...args);
if (typeof core.cwrap === "function") {
const wrapped = core.cwrap(name, "number", args.map((value) => typeof value === "string" ? "string" : "number"));
return wrapped(...args);
}
throw new Error(`missing core export: ${name}`);
}
function appendCoreEvent(core, seq, tick, kind, payload) {
if (typeof core.ccall !== "function") return 0;
const bytes = new TextEncoder().encode(canonicalPayload(payload));
return core.ccall("sim_event_append", "number", ["number", "number", "string", "array", "number"], [seq, tick, kind, bytes, bytes.length]);
}
function canonicalPayload(value) {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalPayload).join(",")}]`;
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalPayload(value[key])}`).join(",")}}`;
}
export class SimSession {
constructor(core, { contextId = CONTEXT_ID, sessionId = "" } = {}) {
this.core = core;
this.contextId = contextId;
this.sessionId = sessionId;
this.tick = 0;
this.created = false;
this.timeline = new ReplayTimeline();
call(core, "sim_protocol_reset");
if (typeof core.ccall === "function") core.ccall("sim_event_reset", null, [], []);
}
envelope(kind, input, payload, tick = this.tick) {
return {
schema_version: PROTOCOL_SCHEMA,
kind,
context_id: this.contextId,
session_id: this.sessionId,
seq: input.seq,
...(text(input.operation_id) ? { operation_id: input.operation_id } : {}),
simulation_tick: tick,
payload,
};
}
error(input, code, messageKey, retryable = false) {
return this.envelope("ERROR", input, {
status: "error",
code,
message_key: messageKey,
retryable,
operation_id: input.operation_id ?? null,
simulation_tick: this.tick,
});
}
coreError(input, result, source) {
const mapped = mapCoreError(result, { operation_id: input.operation_id, simulation_tick: this.tick, source });
return this.envelope("ERROR", input, mapped);
}
validateEnvelope(input) {
if (!input || typeof input !== "object") return ["protocol_argument", "invalid_envelope"];
if (input.schema_version !== PROTOCOL_SCHEMA) return ["protocol_schema", "unsupported_schema"];
if (input.context_id !== this.contextId) return ["protocol_context", "context_mismatch"];
if (!text(input.kind) || !Number.isSafeInteger(input.seq) || input.seq <= 0) return ["protocol_argument", "invalid_sequence_or_kind"];
if (this.sessionId && input.session_id !== this.sessionId) return ["protocol_session", "session_mismatch"];
if (!this.sessionId && text(input.session_id)) this.sessionId = input.session_id;
return null;
}
accept(input) {
const invalid = this.validateEnvelope(input);
if (invalid) return this.error(input ?? {}, invalid[0], invalid[1]);
const result = call(this.core, "sim_protocol_accept", input.schema_version, input.seq, input.kind, input.operation_id ?? "", input.simulation_tick ?? this.tick);
if (result !== 0) return this.coreError(input, result, "sim_protocol_accept");
if (Number.isSafeInteger(input.simulation_tick) && input.simulation_tick >= this.tick) this.tick = input.simulation_tick;
const eventTick = input.kind === "SIM_STEP" && Number.isSafeInteger(input.payload?.cycles)
? this.tick + input.payload.cycles : this.tick;
try {
this.timeline.appendEvent(input.seq, eventTick, input.kind, input.payload ?? {});
} catch (error) {
return this.error(input, "event_log", String(error?.message ?? error));
}
const coreEventResult = appendCoreEvent(this.core, input.seq, eventTick, input.kind, input.payload ?? {});
if (coreEventResult !== 0) return this.error(input, "event_log", "core_event_append_failed");
switch (input.kind) {
case "HELLO":
return this.envelope("CAPABILITIES", input, capabilities(this.contextId));
case "SESSION_ATTACH":
if (!this.created) {
const createResult = call(this.core, "sim_create", 0, 0);
if (createResult !== 0) return this.coreError(input, createResult, "sim_create");
this.created = true;
}
return this.envelope("SESSION_READY", input, { state_hash: fnv1a64({ tick: this.tick }), subscriptions: [] });
case "LOAD_WORKSPACE": {
const rootPath = input.payload?.root_path;
if (!text(rootPath) || !rootPath.startsWith("/") || rootPath.includes("..")) return this.error(input, "workspace_path", "invalid_workspace_path");
const loadResult = call(this.core, "sim_load_workspace", rootPath);
return loadResult === 0 ? this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, state_hash: fnv1a64({ tick: this.tick }) }) : this.coreError(input, loadResult, "sim_load_workspace");
}
case "SIM_STEP": {
const cycles = input.payload?.cycles;
const limit = validateLimit("max_tick_step", cycles);
if (limit.status !== "accepted") return this.envelope("ERROR", input, { ...limit, operation_id: input.operation_id ?? null, simulation_tick: this.tick, retryable: false });
const stepResult = call(this.core, "sim_step", cycles);
if (stepResult !== 0) return this.coreError(input, stepResult, "sim_step");
this.tick += cycles;
const protocolTick = call(this.core, "sim_protocol_set_tick", this.tick);
if (protocolTick !== 0) return this.error(input, "protocol_tick", "tick_update_failed");
const snapshot = this.timeline.checkpoint(input.seq, this.tick, { tick: this.tick, cycles }, this.timeline.snapshots.latest()?.snapshot_id ?? null);
return this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, state_hash: snapshot.state_hash, snapshot });
}
case "SNAPSHOT_REQUEST":
return this.envelope("STATE_SNAPSHOT", input, { snapshot_schema: 1, tick: this.tick, state_hash: fnv1a64({ tick: this.tick }), snapshot: this.timeline.snapshots.latest() });
case "DESTROY":
call(this.core, "sim_destroy");
this.created = false;
return this.envelope("COMMAND_RESULT", input, { status: "verified", destroyed: true, replay: this.timeline.export() });
default:
return this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind });
}
}
}
export { CONTEXT_ID, PROTOCOL_SCHEMA };

20
web/worker/sim-worker.mjs Normal file
View File

@@ -0,0 +1,20 @@
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { SimSession } from "./sim-session.mjs";
const core = await createModule();
const session = new SimSession(core);
self.onmessage = (event) => {
try {
self.postMessage(session.accept(event.data));
} catch (error) {
self.postMessage({
schema_version: 1,
kind: "ERROR",
context_id: session.contextId,
session_id: session.sessionId,
seq: event.data?.seq ?? 0,
payload: { status: "error", code: "worker_exception", message_key: String(error?.message ?? error), retryable: false },
});
}
};

View File

@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { EventLog, ReplayTimeline, SnapshotStore, fnv1a64 } from "./replay.mjs";
function buildTimeline() {
const timeline = new ReplayTimeline();
timeline.appendEvent(1, 0, "SESSION_READY", {});
timeline.appendEvent(2, 5, "SIM_STEP", { cycles: 5 });
timeline.checkpoint(2, 5, { tick: 5, position: 5 });
timeline.appendEvent(3, 9, "SIM_STEP", { cycles: 4 });
const base = timeline.snapshots.latest().snapshot_id;
timeline.checkpoint(3, 9, { tick: 9, position: 9 }, base);
return timeline;
}
const first = buildTimeline();
const second = buildTimeline();
assert.deepEqual(first.export(), second.export());
const replay = first.replay({ tick: 0, position: 0 }, (state, event) => ({
tick: event.tick,
position: state.position + (event.payload.cycles ?? 0),
}));
assert.equal(replay.tick, 9);
assert.equal(replay.state.position, 9);
assert.equal(replay.state_hash, fnv1a64({ tick: 9, position: 9 }));
assert.throws(() => {
const log = new EventLog();
log.append(2, 0, "GAP", {});
}, /event_sequence_gap/);
assert.throws(() => {
const snapshots = new SnapshotStore();
snapshots.create(1, 1, { tick: 1 }, "snap:missing");
}, /snapshot_base_missing/);
const exported = first.export();
exported.snapshots.snapshots[0].state_hash = "fnv1a64:0000000000000000";
assert.throws(() => new ReplayTimeline(exported), /snapshot_hash_mismatch/);
console.log("replay=passed cursor=" + replay.cursor + " tick=" + replay.tick);

View File

@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { writeFile } from "node:fs/promises";
import { capabilities, validateLimit } from "./capabilities.mjs";
import { ERROR_DEFINITIONS, mapCoreError, realExcluded } from "./error-map.mjs";
const codes = Object.keys(ERROR_DEFINITIONS).map(Number);
assert.equal(codes.length, new Set(codes).size);
for (const code of codes) {
const mapped = mapCoreError(code, { operation_id: "op", simulation_tick: 7, source: "test" });
assert.equal(mapped.original_code, code);
assert.equal(mapped.operation_id, "op");
assert.equal(mapped.simulation_tick, 7);
assert.ok(mapped.message_key.includes("." ) || mapped.message_key === "ok");
}
const unknown = mapCoreError(-999);
assert.equal(unknown.code, "core_unknown_error");
assert.equal(unknown.retryable, false);
const caps = capabilities("ctx");
assert.equal(caps.context_id, "ctx");
assert.ok(caps.commands.includes("SIM_STEP"));
assert.equal(caps.exclusions.every((item) => item.status === "REAL-EXCLUDED"), true);
assert.equal(validateLimit("max_tick_step", 1000000).status, "accepted");
assert.equal(validateLimit("max_tick_step", 1000001).code, "capability_limit_exceeded");
assert.equal(validateLimit("missing", 1).code, "capability_unknown_limit");
assert.equal(realExcluded("pci", "no_device").code, "real_hardware_excluded");
const report = {
schema: "runtime-mapping-report.v1",
status: "passed",
mapped_error_codes: codes.length,
commands: caps.commands.length,
limits: Object.keys(caps.limits).length,
real_exclusions: caps.exclusions.length,
};
await writeFile(new URL("../../coverage/runtime-mapping.report.json", import.meta.url), `${JSON.stringify(report, null, 2)}\n`);
console.log(`runtime_mapping=passed errors=${codes.length} exclusions=${caps.exclusions.length}`);

View File

@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const calls = [];
let lastSeq = 0;
const core = {
_sim_protocol_reset() { calls.push(["reset"]); },
_sim_protocol_accept(...args) {
calls.push(["accept", ...args]);
if (args[1] !== lastSeq + 1) return -101;
lastSeq = args[1];
return 0;
},
_sim_create(...args) { calls.push(["create", ...args]); return 0; },
_sim_load_workspace(...args) { calls.push(["load", ...args]); return 0; },
_sim_step(...args) { calls.push(["step", ...args]); return 0; },
_sim_protocol_set_tick(...args) { calls.push(["set_tick", ...args]); return 0; },
_sim_destroy(...args) { calls.push(["destroy", ...args]); },
};
const session = new SimSession(core);
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "s-1" };
let response = session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} });
assert.equal(response.kind, "CAPABILITIES");
response = session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, payload: { project_id: "demo" } });
assert.equal(response.kind, "SESSION_READY");
response = session.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "op-3", payload: { root_path: "/workspace" } });
assert.equal(response.payload.status, "verified");
response = session.accept({ ...base, kind: "SIM_STEP", seq: 4, operation_id: "op-4", payload: { cycles: 7 } });
assert.equal(response.simulation_tick, 7);
response = session.accept({ ...base, kind: "SNAPSHOT_REQUEST", seq: 5, payload: {} });
assert.equal(response.payload.tick, 7);
response = session.accept({ ...base, kind: "SIM_STEP", seq: 7, operation_id: "op-7", payload: { cycles: 1 } });
assert.equal(response.kind, "ERROR");
assert.equal(response.payload.code, "protocol_sequence");
console.log("sim_session=passed calls=" + calls.length);

View File

@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: "s-real" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "s-real" };
assert.equal(session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} }).kind, "CAPABILITIES");
assert.equal(session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, operation_id: "op-2", payload: {} }).kind, "SESSION_READY");
assert.equal(session.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "op-3", payload: { root_path: "/workspace" } }).payload.status, "verified");
const stepped = session.accept({ ...base, kind: "SIM_STEP", seq: 4, operation_id: "op-4", payload: { cycles: 11 } });
assert.equal(stepped.simulation_tick, 11);
assert.equal(core._sim_protocol_last_seq(), 4);
assert.equal(core._sim_protocol_tick(), 11);
assert.equal(core._sim_event_count(), 4);
assert.equal(session.accept({ ...base, kind: "SNAPSHOT_REQUEST", seq: 6, payload: {} }).payload.code, "protocol_sequence");
console.log("wasm_session=passed tick=" + stepped.simulation_tick);

140
web/workspace/journal.mjs Normal file
View File

@@ -0,0 +1,140 @@
import { canonicalJson, fnv1a64 } from "../worker/replay.mjs";
export const JOURNAL_SCHEMA = "workspace-journal.v1";
export class MemoryStorage {
constructor(initial = {}) {
this.records = new Map(Object.entries(initial));
}
get(key) { return this.records.get(key); }
set(key, value) { this.records.set(key, value); }
entries() { return [...this.records.entries()].sort(([a], [b]) => a.localeCompare(b)); }
}
function pathOf(value) {
if (typeof value !== "string" || value.length === 0 || value.startsWith("/") || value.includes("\\")) throw new Error("workspace_path_invalid");
const parts = value.split("/").filter(Boolean);
if (parts.some((part) => part === "." || part === "..")) throw new Error("workspace_path_invalid");
return parts.join("/");
}
function dataHash(data) {
if (typeof data !== "string") throw new Error("workspace_data_invalid");
return fnv1a64(data);
}
export class WorkspaceJournal {
constructor(storage = new MemoryStorage(), { projectId = "default", schemaVersion = 1 } = {}) {
this.storage = storage;
this.projectId = projectId;
this.schemaVersion = schemaVersion;
}
records() {
return this.storage.entries().map(([, value]) => typeof value === "string" ? JSON.parse(value) : structuredClone(value));
}
nextSequence() {
return this.records().reduce((max, record) => Math.max(max, record.commit_seq ?? 0), 0) + 1;
}
prepare(operationId, changes) {
if (typeof operationId !== "string" || operationId.length === 0) throw new Error("operation_id_invalid");
if (!Array.isArray(changes) || changes.length === 0) throw new Error("workspace_changes_empty");
const existing = this.records().find((record) => record.operation_id === operationId && record.kind === "commit");
if (existing) throw new Error("operation_already_committed");
const normalized = changes.map((change) => {
const path = pathOf(change.path);
const data = change.data;
return { path, data, data_hash: dataHash(data) };
}).sort((left, right) => left.path.localeCompare(right.path));
const commitSeq = this.nextSequence();
const record = {
schema: JOURNAL_SCHEMA,
schema_version: this.schemaVersion,
project_id: this.projectId,
kind: "prepare",
commit_seq: commitSeq,
operation_id: operationId,
changes: normalized,
content_hash: fnv1a64({ commit_seq: commitSeq, operation_id: operationId, changes: normalized }),
};
this.storage.set(`journal/${String(commitSeq).padStart(12, "0")}.prepare`, canonicalJson(record));
return record;
}
commit(operationId) {
const records = this.records();
const prepare = records.find((record) => record.kind === "prepare" && record.operation_id === operationId);
if (!prepare) throw new Error("prepare_missing");
if (records.some((record) => record.kind === "commit" && record.operation_id === operationId)) throw new Error("operation_already_committed");
const expectedHash = fnv1a64({ commit_seq: prepare.commit_seq, operation_id: prepare.operation_id, changes: prepare.changes });
if (prepare.content_hash !== expectedHash) throw new Error("prepare_hash_mismatch");
const record = {
schema: JOURNAL_SCHEMA,
schema_version: this.schemaVersion,
project_id: this.projectId,
kind: "commit",
commit_seq: prepare.commit_seq,
operation_id: operationId,
prepare_hash: prepare.content_hash,
};
this.storage.set(`journal/${String(prepare.commit_seq).padStart(12, "0")}.commit`, canonicalJson(record));
return record;
}
abort(operationId) {
const prepare = this.records().find((record) => record.kind === "prepare" && record.operation_id === operationId);
if (!prepare) throw new Error("prepare_missing");
const record = { schema: JOURNAL_SCHEMA, schema_version: this.schemaVersion, project_id: this.projectId, kind: "abort", commit_seq: prepare.commit_seq, operation_id: operationId };
this.storage.set(`journal/${String(prepare.commit_seq).padStart(12, "0")}.abort`, canonicalJson(record));
return record;
}
recover() {
const records = this.records();
const incompatible = records.filter((record) => record.schema !== JOURNAL_SCHEMA || record.schema_version !== this.schemaVersion || record.project_id !== this.projectId);
if (incompatible.length > 0) {
return {
schema: "workspace-recovery.v1",
project_id: this.projectId,
status: "blocked",
files: {},
applied: [],
half_commits: [],
blocked: incompatible.map((record) => ({ commit_seq: record.commit_seq ?? null, reason: "schema_or_project_mismatch" })),
state_hash: fnv1a64({}),
};
}
const prepares = new Map(records.filter((record) => record.kind === "prepare").map((record) => [record.commit_seq, record]));
const commits = records.filter((record) => record.kind === "commit").sort((left, right) => left.commit_seq - right.commit_seq);
const aborts = new Set(records.filter((record) => record.kind === "abort").map((record) => record.commit_seq));
const files = new Map();
const applied = [];
const blocked = [];
for (const commit of commits) {
const prepare = prepares.get(commit.commit_seq);
if (!prepare || aborts.has(commit.commit_seq)) { blocked.push({ commit_seq: commit.commit_seq, reason: "prepare_missing_or_aborted" }); continue; }
const expectedPrepareHash = fnv1a64({ commit_seq: prepare.commit_seq, operation_id: prepare.operation_id, changes: prepare.changes });
if (prepare.content_hash !== expectedPrepareHash || commit.prepare_hash !== expectedPrepareHash) { blocked.push({ commit_seq: commit.commit_seq, reason: "hash_mismatch" }); continue; }
for (const change of prepare.changes) {
if (dataHash(change.data) !== change.data_hash) { blocked.push({ commit_seq: commit.commit_seq, reason: "data_hash_mismatch", path: change.path }); continue; }
files.set(change.path, change.data);
}
applied.push({ commit_seq: commit.commit_seq, operation_id: commit.operation_id, content_hash: expectedPrepareHash });
}
return {
schema: "workspace-recovery.v1",
project_id: this.projectId,
status: blocked.length === 0 ? "recovered" : "blocked",
files: Object.fromEntries([...files.entries()].sort(([a], [b]) => a.localeCompare(b))),
applied,
half_commits: [...prepares.values()].filter((prepare) => !commits.some((commit) => commit.commit_seq === prepare.commit_seq) && !aborts.has(prepare.commit_seq)).map((prepare) => prepare.commit_seq),
blocked,
state_hash: fnv1a64(Object.fromEntries([...files.entries()].sort(([a], [b]) => a.localeCompare(b)))),
};
}
}
export { canonicalJson, dataHash, pathOf };

View File

@@ -0,0 +1,6 @@
import { WorkspaceJournal } from "./journal.mjs";
export function recoverWorkspace(storage, options = {}) {
const journal = new WorkspaceJournal(storage, options);
return journal.recover();
}

View File

@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { writeFile } from "node:fs/promises";
import { MemoryStorage, WorkspaceJournal, canonicalJson } from "./journal.mjs";
import { recoverWorkspace } from "./recovery.mjs";
const storage = new MemoryStorage();
const journal = new WorkspaceJournal(storage, { projectId: "demo" });
const prepared = journal.prepare("op-1", [{ path: "config/main.ini", data: "[EMC]\n" }, { path: "programs/main.ngc", data: "G0 X1\n" }]);
journal.commit("op-1");
let recovered = recoverWorkspace(storage, { projectId: "demo" });
assert.equal(recovered.status, "recovered");
assert.deepEqual(recovered.files, { "config/main.ini": "[EMC]\n", "programs/main.ngc": "G0 X1\n" });
assert.equal(recovered.applied[0].commit_seq, prepared.commit_seq);
const halfStorage = new MemoryStorage();
const halfJournal = new WorkspaceJournal(halfStorage, { projectId: "half" });
halfJournal.prepare("op-half", [{ path: "config/half.ini", data: "half" }]);
recovered = recoverWorkspace(halfStorage, { projectId: "half" });
assert.equal(recovered.status, "recovered");
assert.deepEqual(recovered.files, {});
assert.deepEqual(recovered.half_commits, [1]);
assert.throws(() => journal.prepare("op-path", [{ path: "../escape", data: "x" }]), /workspace_path_invalid/);
assert.throws(() => journal.prepare("op-data", [{ path: "config/bad", data: 1 }]), /workspace_data_invalid/);
assert.throws(() => journal.commit("missing"), /prepare_missing/);
const corrupted = new MemoryStorage(Object.fromEntries(storage.entries()));
const commitKey = [...corrupted.records.keys()].find((key) => key.endsWith(".commit"));
const commit = JSON.parse(corrupted.get(commitKey));
commit.prepare_hash = "fnv1a64:0000000000000000";
corrupted.set(commitKey, canonicalJson(commit));
recovered = recoverWorkspace(corrupted, { projectId: "demo" });
assert.equal(recovered.status, "blocked");
assert.equal(recovered.blocked[0].reason, "hash_mismatch");
const firstBytes = canonicalJson([...storage.entries()]);
const secondBytes = canonicalJson([...storage.entries()]);
assert.equal(firstBytes, secondBytes);
await writeFile(new URL("../../coverage/workspace-recovery.report.json", import.meta.url), `${JSON.stringify({ schema: "workspace-recovery-report.v1", status: "passed", scenarios: ["atomic_commit", "half_commit_ignored", "path_validation", "hash_corruption_blocked"], journal_bytes_sha256: fnvHash(firstBytes), recovery_state_hash: recovered.state_hash }, null, 2)}\n`);
console.log("workspace_recovery=passed commit=" + prepared.commit_seq);
function fnvHash(value) {
let hash = 1469598103934665603n;
for (const byte of new TextEncoder().encode(value)) hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 1099511628211n);
return `fnv1a64:${hash.toString(16).padStart(16, "0")}`;
}