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"; 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_CHANGE", "PROBE_CONFIG", "HAL_COMMAND", "HAL_SET", ]); 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(); 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, }; } 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 result = recoverSimulation({ checkpoint, contextId: this.contextId, events, reconcile, apply: (state, event) => ({ ...state, tick: event.tick }), }); if (result.status === "recovered" || result.status === "aborted") { this.tick = result.simulation_tick; 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; }; 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"); 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; 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 "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 };