Files
linuxcncWebWASM/web/worker/contracts.mjs

211 lines
11 KiB
JavaScript

import { fnv1a64 } from "./replay.mjs";
export const CONTRACT_SCHEMA = "sim-contracts.v1";
export const CONTRACT_VERSION = 1;
export const SCHEMAS = Object.freeze({
task: "sim_task.v1",
command: "command-envelope.v1",
snapshot: "snapshot.v1",
event: "event-log.v1",
workspace: "workspace-package.v1",
});
const COMMAND_FIELDS = new Set([
"schema_version", "kind", "context_id", "session_id", "seq",
"operation_id", "simulation_tick", "sent_at_monotonic_ms", "payload",
]);
const TASK_FIELDS = new Set([
"schema", "context_id", "session_id", "state", "mode", "program_status",
"seq", "simulation_tick", "current_line", "state_hash",
]);
const SNAPSHOT_FIELDS = new Set([
"schema", "snapshot_id", "base_snapshot_id", "seq", "tick", "state", "state_hash",
]);
const EVENT_FIELDS = new Set(["seq", "tick", "kind", "payload", "payload_hash"]);
const PACKAGE_V1_FIELDS = new Set([
"schema", "format_version", "context_id", "committed_seq", "checkpoint",
"timeline", "journal", "content_hash", "package_hash",
]);
const PACKAGE_V2_FIELDS = new Set([...PACKAGE_V1_FIELDS, "migration", "integrity"]);
const SIDE_EFFECT_KINDS = new Set([
"SESSION_CREATE", "LOAD_PROJECT", "START", "STOP", "RESET", "LOAD_WORKSPACE", "SIM_STEP", "COMMAND",
"PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "PROGRAM_STEP", "PROGRAM_SEEK",
"JOG_START", "JOG_STOP", "HOME", "SET_MODE", "SET_OVERRIDE", "SPINDLE_COMMAND", "COOLANT_COMMAND",
"TOOL_PREPARE", "TOOL_CHANGE", "PROBE_CONFIG", "HAL_COMMAND", "HAL_SET", "PLC_START", "PLC_STOP",
"ENCODER_SET", "COOLANT_COMMAND", "COOLANT_FAULT", "DOOR_SET", "INTERLOCK_SET", "INTERLOCK_RESET", "DEVICE_ESTOP", "DEVICE_ACK_ISSUE", "DEVICE_ACK", "DEVICE_ACK_ABORT",
"CONFIGURATION_BATCH",
"PROJECT_SAVE", "IMPORT", "EXPORT", "CHECKPOINT", "RECOVER", "SUBSCRIBE", "UNSUBSCRIBE", "CANCEL",
]);
function fail(code) {
throw new Error(code);
}
function object(value, code) {
if (!value || typeof value !== "object" || Array.isArray(value)) fail(code);
return value;
}
function string(value, code, max = 256) {
if (typeof value !== "string" || value.length === 0 || value.length > max) fail(code);
return value;
}
function integer(value, code, positive = false) {
if (!Number.isSafeInteger(value) || value < (positive ? 1 : 0)) fail(code);
return value;
}
function finite(value, code) {
if (typeof value !== "number" || !Number.isFinite(value)) fail(code);
return value;
}
function exactFields(value, allowed, code) {
for (const key of Object.keys(value)) if (!allowed.has(key)) fail(code);
}
function noNonFinite(value, code = "contract_nonfinite") {
if (typeof value === "number") {
if (!Number.isFinite(value)) fail(code);
return;
}
if (Array.isArray(value)) {
for (const item of value) noNonFinite(item, code);
return;
}
if (value && typeof value === "object") {
for (const item of Object.values(value)) noNonFinite(item, code);
}
}
function hashUnsigned(value, field) {
const unsigned = { ...value };
delete unsigned[field];
return fnv1a64(unsigned);
}
export function validateCommandEnvelope(value, { contextId, sessionId } = {}) {
const envelope = object(value, "contract_command_object");
exactFields(envelope, COMMAND_FIELDS, "contract_command_unknown_field");
if (envelope.schema_version !== CONTRACT_VERSION) fail("contract_command_schema");
string(envelope.kind, "contract_command_kind", 128);
string(envelope.context_id, "contract_command_context");
string(envelope.session_id, "contract_command_session");
integer(envelope.seq, "contract_command_seq", true);
if (envelope.operation_id !== undefined) string(envelope.operation_id, "contract_command_operation", 128);
if (SIDE_EFFECT_KINDS.has(envelope.kind) && envelope.operation_id === undefined) fail("contract_command_operation_required");
if (envelope.simulation_tick !== undefined) integer(envelope.simulation_tick, "contract_command_tick");
if (envelope.sent_at_monotonic_ms !== undefined) finite(envelope.sent_at_monotonic_ms, "contract_command_sent_at");
object(envelope.payload, "contract_command_payload");
noNonFinite(envelope.payload);
if (contextId !== undefined && envelope.context_id !== contextId) fail("contract_command_context_mismatch");
if (sessionId !== undefined && envelope.session_id !== sessionId) fail("contract_command_session_mismatch");
return structuredClone(envelope);
}
export function validateSimTask(value, { contextId, sessionId } = {}) {
const task = object(value, "contract_task_object");
exactFields(task, TASK_FIELDS, "contract_task_unknown_field");
if (task.schema !== SCHEMAS.task) fail("contract_task_schema");
string(task.context_id, "contract_task_context");
string(task.session_id, "contract_task_session");
if (!new Set(["NEW", "READY", "RUNNING", "PAUSED", "ESTOP", "FAULT", "STOPPED", "DESTROYED"]).has(task.state)) fail("contract_task_state");
if (!new Set(["MANUAL", "MDI", "AUTO"]).has(task.mode)) fail("contract_task_mode");
if (!new Set(["idle", "loaded", "running", "paused", "completed", "aborted", "fault"]).has(task.program_status)) fail("contract_task_program_status");
integer(task.seq, "contract_task_seq", true);
integer(task.simulation_tick, "contract_task_tick");
if (task.current_line !== null) integer(task.current_line, "contract_task_line");
noNonFinite(task);
if (typeof task.state_hash !== "string" || task.state_hash !== hashUnsigned(task, "state_hash")) fail("contract_task_hash");
if (contextId !== undefined && task.context_id !== contextId) fail("contract_task_context_mismatch");
if (sessionId !== undefined && task.session_id !== sessionId) fail("contract_task_session_mismatch");
return structuredClone(task);
}
export function validateSnapshot(value) {
const snapshot = object(value, "contract_snapshot_object");
exactFields(snapshot, SNAPSHOT_FIELDS, "contract_snapshot_unknown_field");
if (snapshot.schema !== SCHEMAS.snapshot) fail("contract_snapshot_schema");
string(snapshot.snapshot_id, "contract_snapshot_id");
if (snapshot.base_snapshot_id !== null) string(snapshot.base_snapshot_id, "contract_snapshot_base");
integer(snapshot.seq, "contract_snapshot_seq", true);
integer(snapshot.tick, "contract_snapshot_tick");
if (snapshot.state === undefined) fail("contract_snapshot_state");
noNonFinite(snapshot.state);
if (typeof snapshot.state_hash !== "string" || snapshot.state_hash !== fnv1a64(snapshot.state)) fail("contract_snapshot_hash");
if (snapshot.snapshot_id !== `snap:${snapshot.seq}:${snapshot.state_hash}`) fail("contract_snapshot_id_mismatch");
return structuredClone(snapshot);
}
export function validateEventLog(value) {
const log = object(value, "contract_event_log_object");
if (log.schema !== SCHEMAS.event || !Array.isArray(log.events)) fail("contract_event_log_schema");
integer(log.cursor, "contract_event_cursor");
integer(log.tick, "contract_event_tick");
let previousTick = 0;
for (let index = 0; index < log.events.length; index += 1) {
const event = object(log.events[index], "contract_event_object");
exactFields(event, EVENT_FIELDS, "contract_event_unknown_field");
if (event.seq !== index + 1) fail("contract_event_sequence");
integer(event.seq, "contract_event_seq", true);
integer(event.tick, "contract_event_tick");
if (event.tick < previousTick) fail("contract_event_tick_regression");
previousTick = event.tick;
string(event.kind, "contract_event_kind", 64);
object(event.payload, "contract_event_payload");
noNonFinite(event.payload);
if (event.payload_hash !== fnv1a64(event.payload)) fail("contract_event_payload_hash");
}
if (log.cursor !== (log.events.length === 0 ? 0 : log.events.at(-1).seq)) fail("contract_event_cursor_mismatch");
if (log.tick !== (log.events.length === 0 ? 0 : log.events.at(-1).tick)) fail("contract_event_tick_mismatch");
return structuredClone(log);
}
function validateTimeline(value) {
const timeline = object(value, "contract_workspace_timeline");
if (timeline.schema !== "replay-timeline.v1" || !timeline.events || !timeline.snapshots) fail("contract_workspace_timeline_schema");
validateEventLog(timeline.events);
const snapshots = object(timeline.snapshots, "contract_workspace_snapshots");
if (snapshots.schema !== SCHEMAS.snapshot || !Array.isArray(snapshots.snapshots)) fail("contract_workspace_snapshots_schema");
const seen = new Set();
for (const snapshot of snapshots.snapshots) {
validateSnapshot(snapshot);
if (snapshot.base_snapshot_id !== null && !seen.has(snapshot.base_snapshot_id)) fail("contract_workspace_snapshot_base_missing");
if (seen.has(snapshot.snapshot_id)) fail("contract_workspace_snapshot_duplicate");
seen.add(snapshot.snapshot_id);
}
return timeline;
}
export function validateWorkspacePackage(value, { contextId } = {}) {
const pkg = object(value, "contract_workspace_object");
if (pkg.schema !== SCHEMAS.workspace) fail("contract_workspace_schema");
integer(pkg.format_version, "contract_workspace_format");
if (pkg.format_version !== 1 && pkg.format_version !== 2) fail("contract_workspace_format_unsupported");
exactFields(pkg, pkg.format_version === 1 ? PACKAGE_V1_FIELDS : PACKAGE_V2_FIELDS, "contract_workspace_unknown_field");
string(pkg.context_id, "contract_workspace_context");
integer(pkg.committed_seq, "contract_workspace_committed_seq");
validateTimeline(pkg.timeline);
if (pkg.committed_seq > pkg.timeline.events.cursor) fail("contract_workspace_committed_seq_ahead");
if (!Array.isArray(pkg.journal)) fail("contract_workspace_journal");
if (pkg.checkpoint !== null && pkg.checkpoint !== undefined) object(pkg.checkpoint, "contract_workspace_checkpoint");
noNonFinite(pkg);
if (pkg.content_hash !== hashUnsigned({
context_id: pkg.context_id,
committed_seq: pkg.committed_seq,
checkpoint: pkg.checkpoint ?? null,
journal: pkg.journal ?? [],
timeline: pkg.timeline,
}, "ignored")) fail("contract_workspace_content_hash");
if (pkg.package_hash !== hashUnsigned(pkg, "package_hash")) fail("contract_workspace_package_hash");
if (pkg.format_version === 2) {
if (!pkg.migration || pkg.migration.from_version !== 1 || pkg.migration.to_version !== 2 || pkg.migration.lossless !== true || typeof pkg.migration.source_package_hash !== "string") fail("contract_workspace_migration");
if (!pkg.integrity || pkg.integrity.timeline_hash !== fnv1a64(pkg.timeline) || pkg.integrity.journal_hash !== fnv1a64(pkg.journal) || pkg.integrity.checkpoint_hash !== (pkg.checkpoint ? fnv1a64(pkg.checkpoint) : null)) fail("contract_workspace_integrity");
}
if (contextId !== undefined && pkg.context_id !== contextId) fail("contract_workspace_context_mismatch");
return structuredClone(pkg);
}
export { COMMAND_FIELDS, EVENT_FIELDS, SNAPSHOT_FIELDS, TASK_FIELDS };