231 lines
11 KiB
JavaScript
231 lines
11 KiB
JavaScript
import { fnv1a64 } from "./replay.mjs";
|
|
|
|
export const TASK_SCHEMA = "sim_task.v1";
|
|
export const TASK_STATES = Object.freeze(["NEW", "READY", "RUNNING", "PAUSED", "ESTOP", "FAULT", "STOPPED", "DESTROYED"]);
|
|
export const TASK_MODES = Object.freeze(["MANUAL", "MDI", "AUTO"]);
|
|
export const PROGRAM_STATES = Object.freeze(["idle", "loaded", "running", "paused", "completed", "aborted", "fault"]);
|
|
|
|
const CORE_STATES = Object.freeze({ NEW: 0, READY: 1, RUNNING: 2, PAUSED: 3, ESTOP: 4, FAULT: 5, STOPPED: 6, DESTROYED: 7 });
|
|
const CORE_MODES = Object.freeze({ MANUAL: 0, MDI: 1, AUTO: 2 });
|
|
const CORE_PROGRAMS = Object.freeze({ idle: 0, loaded: 1, running: 2, paused: 3, completed: 4, aborted: 5, fault: 6 });
|
|
|
|
function clone(value) { return structuredClone(value); }
|
|
function safeString(value) { return typeof value === "string" && value.length > 0 ? value : null; }
|
|
function validInteger(value, minimum = 0) { return Number.isSafeInteger(value) && value >= minimum; }
|
|
|
|
function coreCall(core, name, returnType, argTypes, args) {
|
|
if (typeof core?.ccall === "function") return core.ccall(name, returnType, argTypes, args);
|
|
const direct = core?.[`_${name}`] ?? core?.[name];
|
|
if (typeof direct === "function") return direct(...args);
|
|
throw new Error(`missing core export: ${name}`);
|
|
}
|
|
|
|
function readCoreString(core, pointer, length) {
|
|
const bytes = core?.HEAPU8;
|
|
if (!bytes || !pointer || !length) throw new Error("task_core_memory_unavailable");
|
|
const view = bytes.subarray(pointer, pointer + length);
|
|
const end = view.indexOf(0);
|
|
return new TextDecoder().decode(view.subarray(0, end < 0 ? view.length : end));
|
|
}
|
|
|
|
export class SimTask {
|
|
constructor(core, { contextId, sessionId = null } = {}) {
|
|
if (!safeString(contextId)) throw new Error("task_context_required");
|
|
this.core = core;
|
|
this.contextId = contextId;
|
|
this.sessionId = safeString(sessionId);
|
|
this.coreTask = 0;
|
|
this.coreSize = 0;
|
|
this.lastError = 0;
|
|
this.state = {
|
|
schema: TASK_SCHEMA,
|
|
context_id: contextId,
|
|
session_id: this.sessionId,
|
|
state: "NEW",
|
|
mode: "MANUAL",
|
|
program_status: "idle",
|
|
seq: 0,
|
|
simulation_tick: 0,
|
|
current_line: null,
|
|
};
|
|
}
|
|
|
|
bindSession(sessionId) {
|
|
if (!safeString(sessionId)) throw new Error("task_session_required");
|
|
if (this.state.seq !== 0 && this.sessionId !== sessionId) throw new Error("task_session_immutable");
|
|
this.sessionId = sessionId;
|
|
this.state.session_id = sessionId;
|
|
this.ensureCore();
|
|
}
|
|
|
|
ensureCore() {
|
|
if (this.coreTask || typeof this.core?._sim_task_size !== "function") return;
|
|
if (typeof this.core?._malloc !== "function" || typeof this.core?._free !== "function") throw new Error("task_core_memory_unavailable");
|
|
if (!this.sessionId) return;
|
|
this.coreSize = coreCall(this.core, "sim_task_size", "number", [], []);
|
|
this.coreTask = this.core._malloc(this.coreSize);
|
|
if (!this.coreTask) throw new Error("task_core_allocation_failed");
|
|
const result = coreCall(this.core, "sim_task_init", "number", ["number", "string", "string"], [this.coreTask, this.contextId, this.sessionId]);
|
|
if (result !== 0) throw new Error(`task_core_init_${result}`);
|
|
}
|
|
|
|
mirror(name, args = [], code = "task_core_error") {
|
|
this.ensureCore();
|
|
if (!this.coreTask) return;
|
|
const result = coreCall(this.core, name, "number", ["number", ...args.map(() => "number")], [this.coreTask, ...args]);
|
|
if (result !== 0) throw new Error(`${code}_${result}`);
|
|
this.syncFromCore();
|
|
}
|
|
|
|
mirrorCommand(seq, tick) {
|
|
this.ensureCore();
|
|
if (!this.coreTask) return;
|
|
const result = coreCall(this.core, "sim_task_set_command", "number", ["number", "number", "number"], [this.coreTask, seq, tick]);
|
|
if (result !== 0) throw new Error(`task_command_${result}`);
|
|
this.syncFromCore();
|
|
}
|
|
|
|
syncFromCore() {
|
|
if (!this.coreTask || typeof this.core?._sim_task_canonical !== "function") return false;
|
|
const required = coreCall(this.core, "sim_task_canonical", "number", ["number", "number", "number"], [this.coreTask, 0, 0]);
|
|
if (!Number.isSafeInteger(required) || required <= 1 || required > 4096) throw new Error("task_core_canonical_invalid");
|
|
const pointer = this.core._malloc(required);
|
|
if (!pointer) throw new Error("task_core_canonical_allocation_failed");
|
|
try {
|
|
const written = coreCall(this.core, "sim_task_canonical", "number", ["number", "number", "number"], [this.coreTask, pointer, required]);
|
|
const canonical = JSON.parse(readCoreString(this.core, pointer, written));
|
|
if (canonical.schema !== TASK_SCHEMA || canonical.context_id !== this.contextId || canonical.session_id !== this.sessionId) throw new Error("task_core_canonical_context");
|
|
this.state = {
|
|
...this.state,
|
|
state: canonical.state,
|
|
mode: canonical.mode,
|
|
program_status: canonical.program_status,
|
|
seq: canonical.seq,
|
|
simulation_tick: canonical.simulation_tick,
|
|
current_line: canonical.current_line,
|
|
};
|
|
return true;
|
|
} finally {
|
|
this.core._free(pointer);
|
|
}
|
|
}
|
|
|
|
authority() {
|
|
return this.coreTask && typeof this.core?._sim_task_canonical === "function" ? "wasm_task_canonical" : "js_compat";
|
|
}
|
|
|
|
setCommand(seq, tick) {
|
|
if (!validInteger(seq, 1)) throw new Error("task_sequence_invalid");
|
|
if (!validInteger(tick, 0)) throw new Error("task_tick_invalid");
|
|
if (seq !== this.state.seq + 1) throw new Error("task_sequence_gap");
|
|
if (tick < this.state.simulation_tick) throw new Error("task_tick_regression");
|
|
this.mirrorCommand(seq, tick);
|
|
this.state.seq = seq;
|
|
this.state.simulation_tick = tick;
|
|
this.lastError = 0;
|
|
}
|
|
|
|
setTick(tick) {
|
|
if (!validInteger(tick, 0)) throw new Error("task_tick_invalid");
|
|
if (tick < this.state.simulation_tick) throw new Error("task_tick_regression");
|
|
this.mirror("sim_task_set_tick", [tick], "task_tick");
|
|
this.state.simulation_tick = tick;
|
|
}
|
|
|
|
transition(next) {
|
|
if (!TASK_STATES.includes(next)) throw new Error("task_state_invalid");
|
|
this.mirror("sim_task_transition", [CORE_STATES[next]], "task_transition");
|
|
this.state.state = next;
|
|
}
|
|
|
|
setMode(mode) {
|
|
if (!TASK_MODES.includes(mode)) throw new Error("task_mode_invalid");
|
|
this.mirror("sim_task_set_mode", [CORE_MODES[mode]], "task_mode");
|
|
this.state.mode = mode;
|
|
}
|
|
|
|
setProgram(programStatus, currentLine = null) {
|
|
if (!PROGRAM_STATES.includes(programStatus)) throw new Error("task_program_invalid");
|
|
if (currentLine !== null && !validInteger(currentLine, 0)) throw new Error("task_line_invalid");
|
|
this.mirror("sim_task_set_program", [CORE_PROGRAMS[programStatus], currentLine ?? 0xffffffff], "task_program");
|
|
this.state.program_status = programStatus;
|
|
this.state.current_line = currentLine;
|
|
}
|
|
|
|
fault(errorCode) {
|
|
this.mirror("sim_task_fault", [errorCode], "task_fault");
|
|
this.state.state = "FAULT";
|
|
this.lastError = errorCode;
|
|
}
|
|
|
|
estop(errorCode) {
|
|
this.mirror("sim_task_estop", [errorCode], "task_estop");
|
|
this.state.state = "ESTOP";
|
|
this.lastError = errorCode;
|
|
}
|
|
|
|
recover() {
|
|
this.mirror("sim_task_recover", [], "task_recover");
|
|
this.state.state = "READY";
|
|
this.lastError = 0;
|
|
}
|
|
|
|
commandGate(command, { requested = 0, ioReady = false, ioEstop = false, homed = false, queueDepth = 0, queueLimit = 10 } = {}) {
|
|
this.ensureCore();
|
|
if (!this.coreTask || (typeof this.core?._sim_task_command_gate !== "function" && typeof this.core?.sim_task_command_gate !== "function")) return null;
|
|
return coreCall(this.core, "sim_task_command_gate", "number", ["number", "number", "number", "number", "number", "number", "number", "number"], [this.coreTask, command, requested, ioReady ? 1 : 0, ioEstop ? 1 : 0, homed ? 1 : 0, queueDepth, queueLimit]);
|
|
}
|
|
|
|
export() {
|
|
this.syncFromCore();
|
|
const unsigned = clone(this.state);
|
|
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
|
}
|
|
|
|
hash() { return this.export().state_hash; }
|
|
|
|
assertCoreCanonical() {
|
|
if (!this.coreTask) return true;
|
|
const required = coreCall(this.core, "sim_task_canonical", "number", ["number", "number", "number"], [this.coreTask, 0, 0]);
|
|
if (!required || required > 4096) throw new Error("task_core_canonical_invalid");
|
|
const pointer = this.core._malloc(required);
|
|
if (!pointer) throw new Error("task_core_canonical_allocation_failed");
|
|
try {
|
|
const written = coreCall(this.core, "sim_task_canonical", "number", ["number", "number", "number"], [this.coreTask, pointer, required]);
|
|
const actual = readCoreString(this.core, pointer, written);
|
|
const expected = canonicalTask(this.state);
|
|
if (actual !== expected) throw new Error("task_core_canonical_mismatch");
|
|
return true;
|
|
} finally {
|
|
this.core._free(pointer);
|
|
}
|
|
}
|
|
|
|
restore(value) {
|
|
if (!value || value.schema !== TASK_SCHEMA || value.context_id !== this.contextId || value.session_id !== this.sessionId) throw new Error("task_restore_schema");
|
|
const unsigned = { ...value };
|
|
delete unsigned.state_hash;
|
|
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("task_restore_hash");
|
|
if (!TASK_STATES.includes(value.state) || !TASK_MODES.includes(value.mode) || !PROGRAM_STATES.includes(value.program_status)) throw new Error("task_restore_enum");
|
|
if (!validInteger(value.seq, 1) || !validInteger(value.simulation_tick, 0) || (value.current_line !== null && !validInteger(value.current_line, 0))) throw new Error("task_restore_range");
|
|
this.ensureCore();
|
|
if (this.coreTask) {
|
|
const result = coreCall(this.core, "sim_task_restore", "number", ["number", "number", "number", "number", "number", "number", "number", "number"], [this.coreTask, CORE_STATES[value.state], CORE_MODES[value.mode], CORE_PROGRAMS[value.program_status], value.seq, value.simulation_tick, value.current_line ?? 0xffffffff, this.lastError]);
|
|
if (result !== 0) throw new Error(`task_restore_core_${result}`);
|
|
}
|
|
this.state = { schema: TASK_SCHEMA, context_id: value.context_id, session_id: value.session_id, state: value.state, mode: value.mode, program_status: value.program_status, seq: value.seq, simulation_tick: value.simulation_tick, current_line: value.current_line };
|
|
this.assertCoreCanonical();
|
|
return this.export();
|
|
}
|
|
|
|
destroy() {
|
|
if (this.coreTask && typeof this.core?._free === "function") this.core._free(this.coreTask);
|
|
this.coreTask = 0;
|
|
this.state.state = "DESTROYED";
|
|
}
|
|
}
|
|
|
|
export function canonicalTask(state) {
|
|
return `{"context_id":${JSON.stringify(state.context_id)},"current_line":${state.current_line === null ? "null" : state.current_line},"mode":${JSON.stringify(state.mode)},"program_status":${JSON.stringify(state.program_status)},"schema":"sim_task.v1","seq":${state.seq},"session_id":${JSON.stringify(state.session_id)},"simulation_tick":${state.simulation_tick},"state":${JSON.stringify(state.state)}}`;
|
|
}
|