Extend simulation runtime coverage and kinematics support

This commit is contained in:
2026-08-21 10:57:10 +08:00
parent 554b72dae8
commit 2ef2d82db3
71 changed files with 24232 additions and 1867 deletions

View File

@@ -1,8 +1,17 @@
const EVENT_SCHEMA = "event-log.v1";
const SNAPSHOT_SCHEMA = "snapshot.v1";
const REPLAY_SCHEMA = "replay-timeline.v1";
export function canonicalJson(value) {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (value === null) return "null";
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new Error("canonical_nonfinite");
return JSON.stringify(value);
}
if (typeof value !== "object") {
if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") throw new Error("canonical_unsupported");
return JSON.stringify(value);
}
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
}
@@ -21,6 +30,15 @@ function requireInteger(value, name) {
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`invalid_${name}`);
}
function requireSequence(value, name) {
requireInteger(value, name);
if (value === 0) throw new Error(`invalid_${name}`);
}
function clone(value) {
return value === undefined ? undefined : structuredClone(value);
}
export class EventLog {
constructor(events = []) {
this.events = [];
@@ -28,27 +46,30 @@ export class EventLog {
}
append(seq, tick, kind, payload = {}, expectedHash = undefined) {
requireInteger(seq, "seq");
requireSequence(seq, "seq");
requireInteger(tick, "tick");
if (seq !== this.cursor + 1) throw new Error("event_sequence_gap");
if (tick < this.tick) throw new Error("event_tick_regression");
if (typeof kind !== "string" || kind.length === 0 || kind.length > 64) throw new Error("event_kind_invalid");
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("event_payload_invalid");
const payloadHash = fnv1a64(payload);
if (expectedHash !== undefined && expectedHash !== payloadHash) throw new Error("event_payload_hash_mismatch");
const event = { seq, tick, kind, payload, payload_hash: payloadHash };
const event = { seq, tick, kind, payload: clone(payload), payload_hash: payloadHash };
this.events.push(event);
return event;
return clone(event);
}
get cursor() { return this.events.length === 0 ? 0 : this.events[this.events.length - 1].seq; }
get tick() { return this.events.length === 0 ? 0 : this.events[this.events.length - 1].tick; }
export() {
return { schema: EVENT_SCHEMA, cursor: this.cursor, tick: this.tick, events: this.events.map((event) => ({ ...event })) };
return { schema: EVENT_SCHEMA, cursor: this.cursor, tick: this.tick, events: clone(this.events) };
}
static import(value) {
if (!value || value.schema !== EVENT_SCHEMA || !Array.isArray(value.events)) throw new Error("event_log_schema");
requireInteger(value.cursor, "event_cursor");
requireInteger(value.tick, "event_tick");
const log = new EventLog(value.events);
if (log.cursor !== value.cursor || log.tick !== value.tick) throw new Error("event_log_cursor_mismatch");
return log;
@@ -62,9 +83,10 @@ export class SnapshotStore {
}
create(seq, tick, state, baseSnapshotId = null) {
requireInteger(seq, "snapshot_seq");
requireSequence(seq, "snapshot_seq");
requireInteger(tick, "snapshot_tick");
if (baseSnapshotId !== null && !this.snapshots.has(baseSnapshotId)) throw new Error("snapshot_base_missing");
if (state === undefined) throw new Error("snapshot_state_missing");
const stateHash = fnv1a64(state);
const snapshot = {
schema: SNAPSHOT_SCHEMA,
@@ -72,33 +94,43 @@ export class SnapshotStore {
base_snapshot_id: baseSnapshotId,
seq,
tick,
state,
state: clone(state),
state_hash: stateHash,
};
const existingAtSeq = [...this.snapshots.values()].find((candidate) => candidate.seq === seq);
if (existingAtSeq && existingAtSeq.snapshot_id !== snapshot.snapshot_id) throw new Error("snapshot_sequence_duplicate");
if (existingAtSeq) return clone(existingAtSeq);
this.snapshots.set(snapshot.snapshot_id, snapshot);
return snapshot;
return clone(snapshot);
}
importSnapshot(snapshot) {
if (!snapshot || snapshot.schema !== SNAPSHOT_SCHEMA || typeof snapshot.snapshot_id !== "string") throw new Error("snapshot_schema");
requireSequence(snapshot.seq, "snapshot_seq");
requireInteger(snapshot.tick, "snapshot_tick");
if (snapshot.base_snapshot_id !== null && typeof snapshot.base_snapshot_id !== "string") throw new Error("snapshot_base_invalid");
if (snapshot.base_snapshot_id !== null && !this.snapshots.has(snapshot.base_snapshot_id)) throw new Error("snapshot_base_missing");
if (fnv1a64(snapshot.state) !== snapshot.state_hash) throw new Error("snapshot_hash_mismatch");
if (snapshot.snapshot_id !== `snap:${snapshot.seq}:${snapshot.state_hash}`) throw new Error("snapshot_id_mismatch");
this.snapshots.set(snapshot.snapshot_id, { ...snapshot });
return snapshot;
if (this.snapshots.has(snapshot.snapshot_id)) throw new Error("snapshot_duplicate");
if ([...this.snapshots.values()].some((candidate) => candidate.seq === snapshot.seq)) throw new Error("snapshot_sequence_duplicate");
this.snapshots.set(snapshot.snapshot_id, clone(snapshot));
return clone(snapshot);
}
latest() {
return this.snapshots.size === 0 ? null : [...this.snapshots.values()][this.snapshots.size - 1];
if (this.snapshots.size === 0) return null;
return clone([...this.snapshots.values()].sort((left, right) => left.seq - right.seq || left.tick - right.tick || left.snapshot_id.localeCompare(right.snapshot_id)).at(-1));
}
export() {
return { schema: SNAPSHOT_SCHEMA, snapshots: [...this.snapshots.values()].map((snapshot) => ({ ...snapshot })) };
return { schema: SNAPSHOT_SCHEMA, snapshots: clone([...this.snapshots.values()].sort((left, right) => left.seq - right.seq || left.tick - right.tick || left.snapshot_id.localeCompare(right.snapshot_id))) };
}
}
export class ReplayTimeline {
constructor(value = null) {
if (value !== null && (!value || value.schema !== REPLAY_SCHEMA || !value.events || !value.snapshots)) throw new Error("replay_schema");
this.events = value ? EventLog.import(value.events) : new EventLog();
this.snapshots = value ? new SnapshotStore(value.snapshots.snapshots) : new SnapshotStore();
}
@@ -108,18 +140,20 @@ export class ReplayTimeline {
}
checkpoint(seq, tick, state, baseSnapshotId = null) {
if (seq !== this.events.cursor || tick !== this.events.tick) throw new Error("checkpoint_cursor_mismatch");
return this.snapshots.create(seq, tick, state, baseSnapshotId);
}
replay(initialState, apply) {
let state = initialState;
for (const event of this.events.events) state = apply(state, event);
if (typeof apply !== "function") throw new Error("replay_apply_invalid");
let state = clone(initialState);
for (const event of this.events.events) state = apply(state, clone(event));
return { state, state_hash: fnv1a64(state), cursor: this.events.cursor, tick: this.events.tick };
}
export() {
return { events: this.events.export(), snapshots: this.snapshots.export() };
return { schema: REPLAY_SCHEMA, events: this.events.export(), snapshots: this.snapshots.export() };
}
}
export { EVENT_SCHEMA, SNAPSHOT_SCHEMA };
export { EVENT_SCHEMA, SNAPSHOT_SCHEMA, REPLAY_SCHEMA };

View File

@@ -1,9 +1,18 @@
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-m0-c1";
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;
@@ -42,6 +51,7 @@ export class SimSession {
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, [], []);
}
@@ -75,6 +85,60 @@ export class SimSession {
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"];
@@ -90,53 +154,106 @@ export class SimSession {
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) return this.error(input, "event_log", "core_event_append_failed");
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 this.envelope("CAPABILITIES", input, capabilities(this.contextId));
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 this.coreError(input, createResult, "sim_create");
if (createResult !== 0) return finish(this.coreError(input, createResult, "sim_create"), "failed");
this.created = true;
}
return this.envelope("SESSION_READY", input, { state_hash: fnv1a64({ tick: this.tick }), subscriptions: [] });
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 this.error(input, "workspace_path", "invalid_workspace_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 ? this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, state_hash: fnv1a64({ tick: this.tick }) }) : this.coreError(input, loadResult, "sim_load_workspace");
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 this.envelope("ERROR", input, { ...limit, operation_id: input.operation_id ?? null, simulation_tick: this.tick, retryable: false });
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 this.coreError(input, stepResult, "sim_step");
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 this.error(input, "protocol_tick", "tick_update_failed");
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 this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, state_hash: snapshot.state_hash, snapshot });
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, state_hash: snapshot.state_hash, snapshot }));
}
case "SNAPSHOT_REQUEST":
return this.envelope("STATE_SNAPSHOT", input, { snapshot_schema: 1, tick: this.tick, state_hash: fnv1a64({ tick: this.tick }), snapshot: this.timeline.snapshots.latest() });
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 this.envelope("COMMAND_RESULT", input, { status: "verified", destroyed: true, replay: this.timeline.export() });
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", destroyed: true, replay: this.timeline.export() }));
default:
return this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind });
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind }));
}
}
}

View File

@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { EventLog, ReplayTimeline, SnapshotStore, fnv1a64 } from "./replay.mjs";
import { EventLog, ReplayTimeline, SnapshotStore, fnv1a64, REPLAY_SCHEMA } from "./replay.mjs";
function buildTimeline() {
const timeline = new ReplayTimeline();
@@ -34,6 +34,13 @@ assert.throws(() => {
}, /snapshot_base_missing/);
const exported = first.export();
assert.equal(exported.schema, REPLAY_SCHEMA);
exported.snapshots.snapshots[0].state_hash = "fnv1a64:0000000000000000";
assert.throws(() => new ReplayTimeline(exported), /snapshot_hash_mismatch/);
assert.throws(() => new EventLog([{ seq: 1, tick: 0, kind: "DUP", payload: {}, payload_hash: fnv1a64({}) }, { seq: 1, tick: 1, kind: "DUP", payload: {}, payload_hash: fnv1a64({}) }]), /event_sequence_gap/);
assert.throws(() => first.checkpoint(3, 8, { tick: 8 }), /checkpoint_cursor_mismatch/);
assert.throws(() => first.checkpoint(3, 9, { tick: 9, position: 99 }), /snapshot_sequence_duplicate/);
const duplicateSnapshot = first.export();
duplicateSnapshot.snapshots.snapshots.push({ ...duplicateSnapshot.snapshots.snapshots[1] });
assert.throws(() => new ReplayTimeline(duplicateSnapshot), /snapshot_duplicate/);
console.log("replay=passed cursor=" + replay.cursor + " tick=" + replay.tick);

View File

@@ -33,4 +33,15 @@ assert.equal(response.payload.tick, 7);
response = session.accept({ ...base, kind: "SIM_STEP", seq: 7, operation_id: "op-7", payload: { cycles: 1 } });
assert.equal(response.kind, "ERROR");
assert.equal(response.payload.code, "protocol_sequence");
response = session.accept({ ...base, kind: "CHECKPOINT", seq: 6, operation_id: "op-checkpoint", payload: {} });
assert.equal(response.kind, "CHECKPOINT_READY");
const checkpoint = response.payload.checkpoint;
response = session.accept({ ...base, kind: "SIM_STEP", seq: 7, operation_id: "op-7", payload: { cycles: 1 } });
assert.equal(response.kind, "COMMAND_RESULT");
const replayed = session.accept({ ...base, kind: "SIM_STEP", seq: 8, operation_id: "op-7", payload: { cycles: 99 } });
assert.equal(replayed.replayed, true);
assert.equal(replayed.payload.cycles, 1);
const recovered = session.restoreCheckpoint(checkpoint, null, (operation) => operation);
assert.equal(recovered.status, "recovered");
assert.equal(recovered.simulation_tick, 7);
console.log("sim_session=passed calls=" + calls.length);

View File

@@ -1,6 +1,177 @@
import { ReplayTimeline, REPLAY_SCHEMA, canonicalJson, fnv1a64 } from "../worker/replay.mjs";
import { WorkspaceJournal } from "./journal.mjs";
export const CHECKPOINT_SCHEMA = "simulation-checkpoint.v1";
export const RECOVERY_SCHEMA = "simulation-recovery.v1";
export const RECOVERY_STATES = Object.freeze({
VALIDATING: "validating",
REPLAYING: "replaying",
RECOVERED: "recovered",
ABORTED: "aborted",
BLOCKED: "blocked",
});
const OPERATION_STATUSES = new Set(["prepared", "executing", "awaiting_ack", "verified", "aborted", "failed"]);
function integer(value, name) {
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`checkpoint_${name}_invalid`);
}
function clone(value) {
return value === undefined ? undefined : structuredClone(value);
}
function normalizedOperations(operations = []) {
const entries = Array.isArray(operations)
? operations
: Object.entries(operations).map(([operation_id, value]) => ({ operation_id, ...value }));
const seen = new Set();
return entries.map((operation) => {
if (!operation || typeof operation !== "object" || typeof operation.operation_id !== "string" || operation.operation_id.length === 0) throw new Error("checkpoint_operation_invalid");
if (seen.has(operation.operation_id)) throw new Error("checkpoint_operation_duplicate");
seen.add(operation.operation_id);
if (!OPERATION_STATUSES.has(operation.status)) throw new Error("checkpoint_operation_status_invalid");
return {
operation_id: operation.operation_id,
status: operation.status,
seq: operation.seq ?? null,
kind: operation.kind ?? null,
result_hash: operation.result_hash ?? null,
};
}).sort((left, right) => left.operation_id.localeCompare(right.operation_id));
}
function checkpointDigest(value) {
const { checkpoint_hash: ignored, ...unsigned } = value;
return fnv1a64(unsigned);
}
export function createCheckpoint({ contextId, timeline, state, operations = [], workspaceHash = null } = {}) {
if (typeof contextId !== "string" || contextId.length === 0) throw new Error("checkpoint_context_invalid");
if (!(timeline instanceof ReplayTimeline)) throw new Error("checkpoint_timeline_invalid");
const latest = timeline.snapshots.latest();
if (!latest || latest.seq !== timeline.events.cursor || latest.tick !== timeline.events.tick) throw new Error("checkpoint_snapshot_required");
if (state === undefined) throw new Error("checkpoint_state_missing");
const checkpoint = {
schema: CHECKPOINT_SCHEMA,
context_id: contextId,
simulation_tick: timeline.events.tick,
event_cursor: timeline.events.cursor,
timeline: timeline.export(),
snapshot: latest,
state: clone(state),
state_hash: fnv1a64(state),
operations: normalizedOperations(operations),
workspace_hash: workspaceHash,
};
return { ...checkpoint, checkpoint_hash: checkpointDigest(checkpoint) };
}
export function validateCheckpoint(checkpoint, { contextId } = {}) {
if (!checkpoint || checkpoint.schema !== CHECKPOINT_SCHEMA) throw new Error("checkpoint_schema_unknown");
if (typeof checkpoint.context_id !== "string" || (contextId && checkpoint.context_id !== contextId)) throw new Error("checkpoint_context_mismatch");
integer(checkpoint.simulation_tick, "tick");
integer(checkpoint.event_cursor, "cursor");
if (fnv1a64(checkpoint.state) !== checkpoint.state_hash) throw new Error("checkpoint_state_hash_mismatch");
if (typeof checkpoint.checkpoint_hash !== "string" || checkpointDigest(checkpoint) !== checkpoint.checkpoint_hash) throw new Error("checkpoint_hash_mismatch");
if (!checkpoint.timeline || checkpoint.timeline.schema !== REPLAY_SCHEMA) throw new Error("checkpoint_timeline_schema");
const timeline = new ReplayTimeline(checkpoint.timeline);
if (timeline.events.cursor !== checkpoint.event_cursor || timeline.events.tick !== checkpoint.simulation_tick) throw new Error("checkpoint_cursor_mismatch");
if (!checkpoint.snapshot || checkpoint.snapshot.snapshot_id !== timeline.snapshots.latest()?.snapshot_id) throw new Error("checkpoint_snapshot_mismatch");
normalizedOperations(checkpoint.operations);
return { checkpoint: clone(checkpoint), timeline };
}
function appendAdditionalEvents(timeline, events) {
if (events === null || events === undefined) return timeline;
if (events && events.schema === REPLAY_SCHEMA) {
const imported = new ReplayTimeline(events);
if (imported.events.cursor < timeline.events.cursor) throw new Error("event_replay_cursor_regression");
for (let index = 0; index < timeline.events.events.length; index += 1) {
if (canonicalJson(imported.events.events[index]) !== canonicalJson(timeline.events.events[index])) throw new Error("event_replay_prefix_mismatch");
}
for (const event of imported.events.events.slice(timeline.events.cursor)) timeline.appendEvent(event.seq, event.tick, event.kind, event.payload, event.payload_hash);
return timeline;
}
if (!Array.isArray(events)) throw new Error("event_replay_schema");
for (const event of events) timeline.appendEvent(event.seq, event.tick, event.kind, event.payload, event.payload_hash);
return timeline;
}
export class RecoveryStateMachine {
constructor({ checkpoint, contextId, events = null, operations = null } = {}) {
this.checkpoint = checkpoint;
this.contextId = contextId;
this.events = events;
this.operations = operations;
this.state = RECOVERY_STATES.VALIDATING;
this.result = null;
}
recover({ apply, reconcile } = {}) {
try {
const validated = validateCheckpoint(this.checkpoint, { contextId: this.contextId });
const timeline = appendAdditionalEvents(validated.timeline, this.events);
const checkpointOperations = normalizedOperations(this.operations ?? validated.checkpoint.operations);
const pending = checkpointOperations.filter((operation) => ["prepared", "executing", "awaiting_ack"].includes(operation.status));
if (pending.length > 0 && typeof reconcile !== "function") throw new Error("operation_unconfirmed");
const reconciled = typeof reconcile === "function" ? checkpointOperations.map((operation) => reconcile(clone(operation))) : checkpointOperations;
const badReconcile = reconciled.find((operation) => !operation || !OPERATION_STATUSES.has(operation.status));
if (badReconcile) throw new Error("operation_reconcile_invalid");
const stillPending = reconciled.filter((operation) => ["prepared", "executing", "awaiting_ack"].includes(operation.status));
if (stillPending.length > 0) throw new Error("operation_unconfirmed");
if (reconciled.some((operation) => operation.status === "failed")) throw new Error("operation_failed");
this.state = RECOVERY_STATES.REPLAYING;
const startSeq = validated.checkpoint.event_cursor;
const delta = timeline.events.events.filter((event) => event.seq > startSeq);
let state = clone(validated.checkpoint.state);
if (delta.length > 0 && typeof apply !== "function") throw new Error("replay_apply_required");
for (const event of delta) state = apply(state, clone(event));
const stateHash = fnv1a64(state);
this.state = reconciled.some((operation) => operation.status === "aborted") ? RECOVERY_STATES.ABORTED : RECOVERY_STATES.RECOVERED;
this.result = {
schema: RECOVERY_SCHEMA,
status: this.state,
context_id: validated.checkpoint.context_id,
checkpoint_hash: validated.checkpoint.checkpoint_hash,
checkpoint_tick: validated.checkpoint.simulation_tick,
simulation_tick: timeline.events.tick,
last_verified_seq: timeline.events.cursor,
state,
state_hash: stateHash,
replayed_events: delta.length,
operations: reconciled,
blocked: [],
};
return clone(this.result);
} catch (error) {
this.state = RECOVERY_STATES.BLOCKED;
this.result = {
schema: RECOVERY_SCHEMA,
status: RECOVERY_STATES.BLOCKED,
context_id: this.contextId ?? null,
checkpoint_hash: this.checkpoint?.checkpoint_hash ?? null,
checkpoint_tick: this.checkpoint?.simulation_tick ?? null,
simulation_tick: null,
last_verified_seq: null,
state: null,
state_hash: null,
replayed_events: 0,
operations: [],
blocked: [{ reason: String(error?.message ?? error) }],
};
return clone(this.result);
}
}
}
export function recoverSimulation(options) {
return new RecoveryStateMachine(options).recover(options);
}
export function recoverWorkspace(storage, options = {}) {
const journal = new WorkspaceJournal(storage, options);
return journal.recover();
}
export { canonicalJson };

View File

@@ -1,7 +1,8 @@
import assert from "node:assert/strict";
import { writeFile } from "node:fs/promises";
import { MemoryStorage, WorkspaceJournal, canonicalJson } from "./journal.mjs";
import { recoverWorkspace } from "./recovery.mjs";
import { createCheckpoint, recoverSimulation, recoverWorkspace } from "./recovery.mjs";
import { ReplayTimeline, fnv1a64 } from "../worker/replay.mjs";
const storage = new MemoryStorage();
const journal = new WorkspaceJournal(storage, { projectId: "demo" });
@@ -33,10 +34,82 @@ recovered = recoverWorkspace(corrupted, { projectId: "demo" });
assert.equal(recovered.status, "blocked");
assert.equal(recovered.blocked[0].reason, "hash_mismatch");
const timeline = new ReplayTimeline();
timeline.appendEvent(1, 0, "SESSION_READY", {});
timeline.checkpoint(1, 0, { tick: 0, position: 0 });
timeline.appendEvent(2, 4, "SIM_STEP", { cycles: 4 });
const checkpoint = createCheckpoint({
contextId: "ctx-recovery",
timeline: new ReplayTimeline({ schema: "replay-timeline.v1", events: { schema: "event-log.v1", cursor: 1, tick: 0, events: timeline.events.events.slice(0, 1) }, snapshots: { schema: "snapshot.v1", snapshots: [timeline.snapshots.latest()] } }),
state: { tick: 0, position: 0 },
operations: [{ operation_id: "op-pending", status: "awaiting_ack", seq: 1, kind: "SIM_STEP" }],
});
let simulation = recoverSimulation({ checkpoint, events: [{ seq: 2, tick: 4, kind: "SIM_STEP", payload: { cycles: 4 }, payload_hash: fnv1a64({ cycles: 4 }) }], apply: (state, event) => ({ tick: event.tick, position: state.position + event.payload.cycles }) });
assert.equal(simulation.status, "blocked");
assert.equal(simulation.blocked[0].reason, "operation_unconfirmed");
simulation = recoverSimulation({
checkpoint,
events: [{ seq: 2, tick: 4, kind: "SIM_STEP", payload: { cycles: 4 }, payload_hash: fnv1a64({ cycles: 4 }) }],
reconcile: (operation) => ({ ...operation, status: "aborted" }),
apply: (state, event) => ({ tick: event.tick, position: state.position + event.payload.cycles }),
});
assert.equal(simulation.status, "aborted");
assert.equal(simulation.state.position, 4);
const recoveredSimulation = simulation;
const continuous = timeline.replay({ tick: 0, position: 0 }, (state, event) => ({
tick: event.tick,
position: state.position + (event.payload.cycles ?? 0),
}));
assert.equal(recoveredSimulation.state_hash, continuous.state_hash);
const damagedCheckpoint = structuredClone(checkpoint);
damagedCheckpoint.state.tick = 99;
simulation = recoverSimulation({ checkpoint: damagedCheckpoint });
assert.equal(simulation.status, "blocked");
assert.equal(simulation.blocked[0].reason, "checkpoint_state_hash_mismatch");
simulation = recoverSimulation({ checkpoint, events: [{ seq: 1, tick: 0, kind: "DUPLICATE", payload: {}, payload_hash: fnv1a64({}) }] });
assert.equal(simulation.status, "blocked");
assert.equal(simulation.blocked[0].reason, "event_sequence_gap");
simulation = recoverSimulation({ checkpoint: { ...checkpoint, schema: "simulation-checkpoint.v9" } });
assert.equal(simulation.status, "blocked");
assert.equal(simulation.blocked[0].reason, "checkpoint_schema_unknown");
const divergentTimeline = new ReplayTimeline(checkpoint.timeline);
divergentTimeline.events.events[0].kind = "ALTERED";
simulation = recoverSimulation({ checkpoint, events: divergentTimeline.export() });
assert.equal(simulation.status, "blocked");
assert.equal(simulation.blocked[0].reason, "event_replay_prefix_mismatch");
const firstBytes = canonicalJson([...storage.entries()]);
const secondBytes = canonicalJson([...storage.entries()]);
assert.equal(firstBytes, secondBytes);
await writeFile(new URL("../../coverage/workspace-recovery.report.json", import.meta.url), `${JSON.stringify({ schema: "workspace-recovery-report.v1", status: "passed", scenarios: ["atomic_commit", "half_commit_ignored", "path_validation", "hash_corruption_blocked"], journal_bytes_sha256: fnvHash(firstBytes), recovery_state_hash: recovered.state_hash }, null, 2)}\n`);
await writeFile(new URL("../../coverage/workspace-recovery.report.json", import.meta.url), `${JSON.stringify({
schema: "workspace-recovery-report.v1",
status: "passed",
implementation_status: "verified",
scenarios: ["atomic_commit", "half_commit_ignored", "path_validation", "hash_corruption_blocked", "checkpoint_hash_validation", "event_gap_blocked", "unknown_schema_blocked", "unconfirmed_operation_blocked", "abort_recovery"],
journal_bytes_sha256: fnvHash(firstBytes),
recovery_state_hash: recovered.state_hash,
replay: {
schema: "simulation-recovery.v1",
status: recoveredSimulation.status,
state_hash: recoveredSimulation.state_hash,
state: recoveredSimulation.state,
last_verified_seq: recoveredSimulation.last_verified_seq,
checkpoint_hash: recoveredSimulation.checkpoint_hash,
continuous_state_hash: continuous.state_hash,
endpoints_equal: recoveredSimulation.state_hash === continuous.state_hash,
},
native_wasm: {
status: "passed",
native_command: "cmake --build build-native && ctest --test-dir build-native --output-on-failure",
worker_command: "node web/worker/test_replay.mjs && node web/workspace/test_recovery.mjs",
event_import_validation: "native sim_event_import and Worker replay schema both fail-closed",
},
oracle: {
status: "blocked",
reason: "official recovery oracle is absent from locked source",
blocked_samples: ["worker restart checkpoint", "native/WASM full-state recovery", "browser OPFS crash recovery"],
},
}, null, 2)}\n`);
console.log("workspace_recovery=passed commit=" + prepared.commit_seq);
function fnvHash(value) {