import { fnv1a64 } from "./replay.mjs"; export const ENDPOINT_REPLAY_SCHEMA = "endpoint-replay.v1"; const clone = (value) => structuredClone(value); const unsigned = (value) => { const copy = clone(value); delete copy.state_hash; return copy; }; export function firstDifference(expected, actual, path = "$") { if (Object.is(expected, actual)) return null; if (typeof expected !== typeof actual) return { path, expected, actual }; if (expected === null || actual === null || typeof expected !== "object") return { path, expected, actual }; if (Array.isArray(expected) !== Array.isArray(actual)) return { path, expected, actual }; if (Array.isArray(expected)) { if (expected.length !== actual.length) return { path: `${path}.length`, expected: expected.length, actual: actual.length }; for (let index = 0; index < expected.length; index += 1) { const difference = firstDifference(expected[index], actual[index], `${path}[${index}]`); if (difference) return difference; } return null; } const keys = [...new Set([...Object.keys(expected), ...Object.keys(actual)])].sort(); for (const key of keys) { const difference = firstDifference(expected[key], actual[key], `${path}.${key}`); if (difference) return difference; } return null; } export function endpointHash(endpoint) { return fnv1a64(endpoint); } export function compareEndpoints(expected, actual) { const difference = firstDifference(expected, actual); return { equal: difference === null, difference, expected_hash: endpointHash(expected), actual_hash: endpointHash(actual) }; } export function createEndpoint({ session, includeTimeline = false } = {}) { if (!session) throw new Error("endpoint_session_required"); const task = session.task.export(); const scheduler = session.scheduler.export(); const endpoint = { tick: session.tick, task: { schema: task.schema, state: task.state, mode: task.mode, program_status: task.program_status, simulation_tick: task.simulation_tick, current_line: task.current_line }, scheduler: { schema: scheduler.schema, budget: scheduler.budget, phase_trace: scheduler.phase_trace, tick: scheduler.tick }, authoritative: unsigned(session.authoritative.export()), motion: unsigned(session.motion.export()), plc: unsigned(session.plc.export()), hal_runtime: unsigned(session.hal.export()), device_io: unsigned(session.deviceIo.export()), configuration_batch: unsigned(session.configurationBatch.export()), io_ack: unsigned(session.ioAck.export()), callbacks: unsigned(session.callbacks.export()), python: session.python.exportState(), }; if (includeTimeline) endpoint.timeline = session.timeline.export(); return clone(endpoint); }