370 lines
18 KiB
JavaScript
370 lines
18 KiB
JavaScript
import { ReplayTimeline, fnv1a64 } from "./replay.mjs";
|
|
import { capabilities, validateLimit } from "./capabilities.mjs";
|
|
import { mapCoreError } from "./error-map.mjs";
|
|
import { createCheckpoint, recoverSimulation } from "../workspace/recovery.mjs";
|
|
import { preflightConfiguration } from "../workspace/configuration.mjs";
|
|
import { preflightHalConfiguration } from "../workspace/hal-configuration.mjs";
|
|
import { DEVICE_COMMANDS, VirtualDevices } from "./virtual-devices.mjs";
|
|
import { PythonBridge } from "./python-bridge.mjs";
|
|
|
|
const PROTOCOL_SCHEMA = 1;
|
|
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
|
|
|
|
const OPERATION_KINDS = new Set([
|
|
"LOAD_WORKSPACE", "SIM_STEP", "DESTROY", "CHECKPOINT", "RECOVER", "ABORT",
|
|
"COMMAND", "START", "STOP", "RESET", "PROGRAM_LOAD", "PROGRAM_RUN",
|
|
"PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "JOG_START", "JOG_STOP",
|
|
"HOME", "SET_MODE", "SET_OVERRIDE", "SPINDLE_COMMAND", "TOOL_PREPARE",
|
|
"TOOL_CHANGED", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT",
|
|
"SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "IO_STEP", "IO_RECOVER",
|
|
"PROBE_CONFIG", "LIMIT_SET", "ESTOP", "HAL_COMMAND", "HAL_SET",
|
|
"PYTHON_IMPORT", "PYTHON_CALL", "PYTHON_CAPABILITY",
|
|
]);
|
|
|
|
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();
|
|
this.operations = new Map();
|
|
this.devices = new VirtualDevices();
|
|
this.python = new PythonBridge();
|
|
this.configurationState = null;
|
|
this.halState = null;
|
|
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);
|
|
}
|
|
|
|
operationState(input) {
|
|
if (!text(input.operation_id) || !OPERATION_KINDS.has(input.kind)) return null;
|
|
return this.operations.get(input.operation_id) ?? null;
|
|
}
|
|
|
|
stateForCheckpoint() {
|
|
return {
|
|
context_id: this.contextId,
|
|
session_id: this.sessionId,
|
|
tick: this.tick,
|
|
created: this.created,
|
|
protocol_seq: this.timeline.events.cursor,
|
|
devices: this.devices.export(),
|
|
configuration: this.configurationState,
|
|
hal: this.halState,
|
|
python: this.python.exportState(),
|
|
};
|
|
}
|
|
|
|
workspacePackageState() {
|
|
const value = {
|
|
context_id: this.contextId,
|
|
session_id: this.sessionId,
|
|
configuration: this.configurationState,
|
|
hal: this.halState,
|
|
devices: this.devices.export(),
|
|
python: this.python.exportState(),
|
|
tick: this.tick,
|
|
event_cursor: this.timeline.events.cursor,
|
|
};
|
|
return { ...value, state_hash: fnv1a64(value) };
|
|
}
|
|
|
|
createCheckpoint() {
|
|
const latest = this.timeline.snapshots.latest();
|
|
if (!latest || latest.seq !== this.timeline.events.cursor || latest.tick !== this.timeline.events.tick) {
|
|
this.timeline.checkpoint(
|
|
this.timeline.events.cursor,
|
|
this.timeline.events.tick,
|
|
this.stateForCheckpoint(),
|
|
latest?.snapshot_id ?? null,
|
|
);
|
|
}
|
|
return createCheckpoint({
|
|
contextId: this.contextId,
|
|
timeline: this.timeline,
|
|
state: this.stateForCheckpoint(),
|
|
operations: [...this.operations.values()],
|
|
});
|
|
}
|
|
|
|
restoreCheckpoint(checkpoint, events = null, reconcile = undefined) {
|
|
const replayDevices = new VirtualDevices({ state: checkpoint?.state?.devices });
|
|
const replayPython = new PythonBridge();
|
|
if (checkpoint?.state?.python) replayPython.restoreState(checkpoint.state.python);
|
|
const result = recoverSimulation({
|
|
checkpoint,
|
|
contextId: this.contextId,
|
|
events,
|
|
reconcile,
|
|
apply: (state, event) => {
|
|
if (DEVICE_COMMANDS.has(event.kind)) replayDevices.applyEvent(event.kind, event.payload);
|
|
if (event.kind === "PYTHON_IMPORT") replayPython.importModule(event.payload?.module);
|
|
if (event.kind === "PYTHON_CALL") replayPython.invoke(event.payload?.module, event.payload?.symbol, event.payload?.args ?? []);
|
|
if (event.kind === "PYTHON_CAPABILITY") replayPython.checkCapability(event.payload?.capability);
|
|
return { ...state, tick: event.tick, devices: replayDevices.export(), python: replayPython.exportState() };
|
|
},
|
|
});
|
|
if (result.status === "recovered" || result.status === "aborted") {
|
|
this.tick = result.simulation_tick;
|
|
this.devices = new VirtualDevices({ state: result.state?.devices ?? checkpoint?.state?.devices });
|
|
this.configurationState = result.state?.configuration ?? checkpoint?.state?.configuration ?? null;
|
|
this.halState = result.state?.hal ?? checkpoint?.state?.hal ?? null;
|
|
this.python = new PythonBridge();
|
|
if (result.state?.python) this.python.restoreState(result.state.python);
|
|
this.timeline = new ReplayTimeline(checkpoint.timeline);
|
|
if (events?.schema === "replay-timeline.v1") {
|
|
for (const event of events.events.events.slice(this.timeline.events.cursor)) this.timeline.appendEvent(event.seq, event.tick, event.kind, event.payload, event.payload_hash);
|
|
} else if (Array.isArray(events)) {
|
|
for (const event of events) this.timeline.appendEvent(event.seq, event.tick, event.kind, event.payload, event.payload_hash);
|
|
}
|
|
this.operations = new Map(result.operations.map((operation) => [operation.operation_id, operation]));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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");
|
|
const existingOperation = this.operationState(input);
|
|
if (existingOperation?.response) {
|
|
if (existingOperation.kind !== input.kind) return this.error(input, "recovery_operation_conflict", "operation_id_conflict");
|
|
try {
|
|
const replayTick = Number.isSafeInteger(input.simulation_tick) && input.simulation_tick >= this.tick ? input.simulation_tick : this.tick;
|
|
this.timeline.appendEvent(input.seq, replayTick, "OPERATION_REPLAY", { operation_id: input.operation_id, original_seq: existingOperation.seq });
|
|
const replayEventResult = appendCoreEvent(this.core, input.seq, replayTick, "OPERATION_REPLAY", { operation_id: input.operation_id, original_seq: existingOperation.seq });
|
|
if (replayEventResult !== 0) return this.error(input, "event_log", "core_event_append_failed");
|
|
this.tick = replayTick;
|
|
return { ...existingOperation.response, replayed: true, seq: input.seq };
|
|
} catch (error) {
|
|
return this.error(input, "event_log", String(error?.message ?? error));
|
|
}
|
|
}
|
|
if (text(input.operation_id) && OPERATION_KINDS.has(input.kind)) {
|
|
this.operations.set(input.operation_id, { operation_id: input.operation_id, status: "executing", seq: input.seq, kind: input.kind, result_hash: null });
|
|
}
|
|
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) {
|
|
if (text(input.operation_id)) this.operations.delete(input.operation_id);
|
|
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) {
|
|
if (text(input.operation_id)) this.operations.delete(input.operation_id);
|
|
return this.error(input, "event_log", "core_event_append_failed");
|
|
}
|
|
|
|
const finish = (response, status = "verified") => {
|
|
if (text(input.operation_id) && OPERATION_KINDS.has(input.kind)) {
|
|
const operation = this.operations.get(input.operation_id);
|
|
if (operation) {
|
|
operation.status = status;
|
|
operation.result_hash = fnv1a64(response.payload ?? {});
|
|
operation.response = response;
|
|
}
|
|
}
|
|
return response;
|
|
};
|
|
|
|
const deviceResponse = (kind, payload) => {
|
|
const result = this.devices.command(kind, payload ?? {});
|
|
const responseKind = result.status === "verified" ? "COMMAND_RESULT" : "ERROR";
|
|
return finish(this.envelope(responseKind, input, {
|
|
...result,
|
|
command: kind,
|
|
operation_id: input.operation_id ?? null,
|
|
simulation_tick: this.tick,
|
|
}), result.status === "verified" ? "verified" : "failed");
|
|
};
|
|
|
|
switch (input.kind) {
|
|
case "HELLO":
|
|
return finish(this.envelope("CAPABILITIES", input, capabilities(this.contextId)), "verified");
|
|
case "SESSION_ATTACH":
|
|
if (!this.created) {
|
|
const createResult = call(this.core, "sim_create", 0, 0);
|
|
if (createResult !== 0) return finish(this.coreError(input, createResult, "sim_create"), "failed");
|
|
this.created = true;
|
|
}
|
|
return finish(this.envelope("SESSION_READY", input, { state_hash: fnv1a64({ tick: this.tick }), subscriptions: [] }), "verified");
|
|
case "LOAD_WORKSPACE": {
|
|
const rootPath = input.payload?.root_path;
|
|
if (!text(rootPath) || !rootPath.startsWith("/") || rootPath.includes("..")) return finish(this.error(input, "workspace_path", "invalid_workspace_path"), "failed");
|
|
if (input.payload?.configuration) {
|
|
const preflight = preflightConfiguration(input.payload.configuration);
|
|
if (preflight.status !== "ready") {
|
|
return finish(this.envelope("ERROR", input, {
|
|
status: "blocked",
|
|
code: "configuration_preflight",
|
|
message_key: "configuration.preflight_blocked",
|
|
retryable: false,
|
|
preflight,
|
|
}), "failed");
|
|
}
|
|
this.configurationState = {
|
|
entrypoint: input.payload.configuration.entrypoint ?? null,
|
|
status: preflight.status,
|
|
resource_count: preflight.resource_count,
|
|
state_hash: fnv1a64(preflight),
|
|
};
|
|
}
|
|
if (input.payload?.hal) {
|
|
const preflight = preflightHalConfiguration(input.payload.hal);
|
|
if (preflight.status !== "ready") {
|
|
return finish(this.envelope("ERROR", input, {
|
|
status: "blocked",
|
|
code: "hal_preflight",
|
|
message_key: "hal.preflight_blocked",
|
|
retryable: false,
|
|
preflight,
|
|
}), "failed");
|
|
}
|
|
this.halState = {
|
|
status: preflight.status,
|
|
record_count: preflight.record_count,
|
|
state_hash: fnv1a64(preflight),
|
|
};
|
|
}
|
|
const loadResult = call(this.core, "sim_load_workspace", rootPath);
|
|
return loadResult === 0 ? finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, state_hash: fnv1a64({ tick: this.tick }) })) : finish(this.coreError(input, loadResult, "sim_load_workspace"), "failed");
|
|
}
|
|
case "SIM_STEP": {
|
|
const cycles = input.payload?.cycles;
|
|
const limit = validateLimit("max_tick_step", cycles);
|
|
if (limit.status !== "accepted") return finish(this.envelope("ERROR", input, { ...limit, operation_id: input.operation_id ?? null, simulation_tick: this.tick, retryable: false }), "failed");
|
|
const stepResult = call(this.core, "sim_step", cycles);
|
|
if (stepResult !== 0) return finish(this.coreError(input, stepResult, "sim_step"), "failed");
|
|
this.tick += cycles;
|
|
this.python.setTick(this.tick);
|
|
const protocolTick = call(this.core, "sim_protocol_set_tick", this.tick);
|
|
if (protocolTick !== 0) return finish(this.error(input, "protocol_tick", "tick_update_failed"), "failed");
|
|
const snapshot = this.timeline.checkpoint(input.seq, this.tick, { tick: this.tick, cycles }, this.timeline.snapshots.latest()?.snapshot_id ?? null);
|
|
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, state_hash: snapshot.state_hash, snapshot }));
|
|
}
|
|
case "SPINDLE_COMMAND": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT":
|
|
case "SPINDLE_ORIENT": case "SPINDLE_ORIENTED": case "IO_STEP":
|
|
case "TOOL_PREPARE": case "TOOL_PREPARED": case "TOOL_CHANGE":
|
|
case "TOOL_CHANGED": case "TOOL_ABORT": case "TOOL_FAULT": case "TOOL_CLEAR_FAULT":
|
|
case "PROBE_CONFIG": case "LIMIT_SET": case "ESTOP": case "IO_RECOVER":
|
|
return deviceResponse(input.kind, input.payload);
|
|
case "PYTHON_IMPORT": {
|
|
const result = this.python.importModule(input.payload?.module);
|
|
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, result), result.status === "verified" ? "verified" : "failed");
|
|
}
|
|
case "PYTHON_CALL": {
|
|
const result = this.python.invoke(input.payload?.module, input.payload?.symbol, input.payload?.args ?? []);
|
|
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, result), result.status === "verified" ? "verified" : "failed");
|
|
}
|
|
case "PYTHON_CAPABILITY": {
|
|
const result = this.python.checkCapability(input.payload?.capability);
|
|
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, result), result.status === "verified" ? "verified" : "failed");
|
|
}
|
|
case "SNAPSHOT_REQUEST":
|
|
return finish(this.envelope("STATE_SNAPSHOT", input, { snapshot_schema: 1, tick: this.tick, state_hash: fnv1a64({ tick: this.tick }), snapshot: this.timeline.snapshots.latest() }));
|
|
case "CHECKPOINT": {
|
|
try {
|
|
const operation = text(input.operation_id) ? this.operations.get(input.operation_id) : null;
|
|
if (operation) operation.status = "verified";
|
|
const checkpoint = this.createCheckpoint();
|
|
return finish(this.envelope("CHECKPOINT_READY", input, { status: "verified", checkpoint, state_hash: checkpoint.checkpoint_hash }));
|
|
} catch (error) {
|
|
return finish(this.error(input, "recovery_checkpoint", String(error?.message ?? error)), "failed");
|
|
}
|
|
}
|
|
case "RECOVER": {
|
|
const result = this.restoreCheckpoint(input.payload?.checkpoint, input.payload?.events, input.payload?.reconcile);
|
|
return finish(this.envelope("RECOVERY_STATUS", input, result), result.status === "blocked" ? "failed" : "verified");
|
|
}
|
|
case "ABORT": {
|
|
const target = input.payload?.operation_id;
|
|
const operation = text(target) ? this.operations.get(target) : null;
|
|
if (operation && operation.status !== "verified") operation.status = "aborted";
|
|
return finish(this.envelope("COMMAND_RESULT", input, { status: operation ? "aborted" : "verified", operation_id: target ?? null }));
|
|
}
|
|
case "DESTROY":
|
|
call(this.core, "sim_destroy");
|
|
this.created = false;
|
|
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", destroyed: true, replay: this.timeline.export() }));
|
|
default:
|
|
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind }));
|
|
}
|
|
}
|
|
}
|
|
|
|
export { CONTEXT_ID, PROTOCOL_SCHEMA };
|