Initial LinuxCNC WebAssembly workspace snapshot
This commit is contained in:
140
web/workspace/journal.mjs
Normal file
140
web/workspace/journal.mjs
Normal file
@@ -0,0 +1,140 @@
|
||||
import { canonicalJson, fnv1a64 } from "../worker/replay.mjs";
|
||||
|
||||
export const JOURNAL_SCHEMA = "workspace-journal.v1";
|
||||
|
||||
export class MemoryStorage {
|
||||
constructor(initial = {}) {
|
||||
this.records = new Map(Object.entries(initial));
|
||||
}
|
||||
|
||||
get(key) { return this.records.get(key); }
|
||||
set(key, value) { this.records.set(key, value); }
|
||||
entries() { return [...this.records.entries()].sort(([a], [b]) => a.localeCompare(b)); }
|
||||
}
|
||||
|
||||
function pathOf(value) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.startsWith("/") || value.includes("\\")) throw new Error("workspace_path_invalid");
|
||||
const parts = value.split("/").filter(Boolean);
|
||||
if (parts.some((part) => part === "." || part === "..")) throw new Error("workspace_path_invalid");
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function dataHash(data) {
|
||||
if (typeof data !== "string") throw new Error("workspace_data_invalid");
|
||||
return fnv1a64(data);
|
||||
}
|
||||
|
||||
export class WorkspaceJournal {
|
||||
constructor(storage = new MemoryStorage(), { projectId = "default", schemaVersion = 1 } = {}) {
|
||||
this.storage = storage;
|
||||
this.projectId = projectId;
|
||||
this.schemaVersion = schemaVersion;
|
||||
}
|
||||
|
||||
records() {
|
||||
return this.storage.entries().map(([, value]) => typeof value === "string" ? JSON.parse(value) : structuredClone(value));
|
||||
}
|
||||
|
||||
nextSequence() {
|
||||
return this.records().reduce((max, record) => Math.max(max, record.commit_seq ?? 0), 0) + 1;
|
||||
}
|
||||
|
||||
prepare(operationId, changes) {
|
||||
if (typeof operationId !== "string" || operationId.length === 0) throw new Error("operation_id_invalid");
|
||||
if (!Array.isArray(changes) || changes.length === 0) throw new Error("workspace_changes_empty");
|
||||
const existing = this.records().find((record) => record.operation_id === operationId && record.kind === "commit");
|
||||
if (existing) throw new Error("operation_already_committed");
|
||||
const normalized = changes.map((change) => {
|
||||
const path = pathOf(change.path);
|
||||
const data = change.data;
|
||||
return { path, data, data_hash: dataHash(data) };
|
||||
}).sort((left, right) => left.path.localeCompare(right.path));
|
||||
const commitSeq = this.nextSequence();
|
||||
const record = {
|
||||
schema: JOURNAL_SCHEMA,
|
||||
schema_version: this.schemaVersion,
|
||||
project_id: this.projectId,
|
||||
kind: "prepare",
|
||||
commit_seq: commitSeq,
|
||||
operation_id: operationId,
|
||||
changes: normalized,
|
||||
content_hash: fnv1a64({ commit_seq: commitSeq, operation_id: operationId, changes: normalized }),
|
||||
};
|
||||
this.storage.set(`journal/${String(commitSeq).padStart(12, "0")}.prepare`, canonicalJson(record));
|
||||
return record;
|
||||
}
|
||||
|
||||
commit(operationId) {
|
||||
const records = this.records();
|
||||
const prepare = records.find((record) => record.kind === "prepare" && record.operation_id === operationId);
|
||||
if (!prepare) throw new Error("prepare_missing");
|
||||
if (records.some((record) => record.kind === "commit" && record.operation_id === operationId)) throw new Error("operation_already_committed");
|
||||
const expectedHash = fnv1a64({ commit_seq: prepare.commit_seq, operation_id: prepare.operation_id, changes: prepare.changes });
|
||||
if (prepare.content_hash !== expectedHash) throw new Error("prepare_hash_mismatch");
|
||||
const record = {
|
||||
schema: JOURNAL_SCHEMA,
|
||||
schema_version: this.schemaVersion,
|
||||
project_id: this.projectId,
|
||||
kind: "commit",
|
||||
commit_seq: prepare.commit_seq,
|
||||
operation_id: operationId,
|
||||
prepare_hash: prepare.content_hash,
|
||||
};
|
||||
this.storage.set(`journal/${String(prepare.commit_seq).padStart(12, "0")}.commit`, canonicalJson(record));
|
||||
return record;
|
||||
}
|
||||
|
||||
abort(operationId) {
|
||||
const prepare = this.records().find((record) => record.kind === "prepare" && record.operation_id === operationId);
|
||||
if (!prepare) throw new Error("prepare_missing");
|
||||
const record = { schema: JOURNAL_SCHEMA, schema_version: this.schemaVersion, project_id: this.projectId, kind: "abort", commit_seq: prepare.commit_seq, operation_id: operationId };
|
||||
this.storage.set(`journal/${String(prepare.commit_seq).padStart(12, "0")}.abort`, canonicalJson(record));
|
||||
return record;
|
||||
}
|
||||
|
||||
recover() {
|
||||
const records = this.records();
|
||||
const incompatible = records.filter((record) => record.schema !== JOURNAL_SCHEMA || record.schema_version !== this.schemaVersion || record.project_id !== this.projectId);
|
||||
if (incompatible.length > 0) {
|
||||
return {
|
||||
schema: "workspace-recovery.v1",
|
||||
project_id: this.projectId,
|
||||
status: "blocked",
|
||||
files: {},
|
||||
applied: [],
|
||||
half_commits: [],
|
||||
blocked: incompatible.map((record) => ({ commit_seq: record.commit_seq ?? null, reason: "schema_or_project_mismatch" })),
|
||||
state_hash: fnv1a64({}),
|
||||
};
|
||||
}
|
||||
const prepares = new Map(records.filter((record) => record.kind === "prepare").map((record) => [record.commit_seq, record]));
|
||||
const commits = records.filter((record) => record.kind === "commit").sort((left, right) => left.commit_seq - right.commit_seq);
|
||||
const aborts = new Set(records.filter((record) => record.kind === "abort").map((record) => record.commit_seq));
|
||||
const files = new Map();
|
||||
const applied = [];
|
||||
const blocked = [];
|
||||
for (const commit of commits) {
|
||||
const prepare = prepares.get(commit.commit_seq);
|
||||
if (!prepare || aborts.has(commit.commit_seq)) { blocked.push({ commit_seq: commit.commit_seq, reason: "prepare_missing_or_aborted" }); continue; }
|
||||
const expectedPrepareHash = fnv1a64({ commit_seq: prepare.commit_seq, operation_id: prepare.operation_id, changes: prepare.changes });
|
||||
if (prepare.content_hash !== expectedPrepareHash || commit.prepare_hash !== expectedPrepareHash) { blocked.push({ commit_seq: commit.commit_seq, reason: "hash_mismatch" }); continue; }
|
||||
for (const change of prepare.changes) {
|
||||
if (dataHash(change.data) !== change.data_hash) { blocked.push({ commit_seq: commit.commit_seq, reason: "data_hash_mismatch", path: change.path }); continue; }
|
||||
files.set(change.path, change.data);
|
||||
}
|
||||
applied.push({ commit_seq: commit.commit_seq, operation_id: commit.operation_id, content_hash: expectedPrepareHash });
|
||||
}
|
||||
return {
|
||||
schema: "workspace-recovery.v1",
|
||||
project_id: this.projectId,
|
||||
status: blocked.length === 0 ? "recovered" : "blocked",
|
||||
files: Object.fromEntries([...files.entries()].sort(([a], [b]) => a.localeCompare(b))),
|
||||
applied,
|
||||
half_commits: [...prepares.values()].filter((prepare) => !commits.some((commit) => commit.commit_seq === prepare.commit_seq) && !aborts.has(prepare.commit_seq)).map((prepare) => prepare.commit_seq),
|
||||
blocked,
|
||||
state_hash: fnv1a64(Object.fromEntries([...files.entries()].sort(([a], [b]) => a.localeCompare(b)))),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { canonicalJson, dataHash, pathOf };
|
||||
6
web/workspace/recovery.mjs
Normal file
6
web/workspace/recovery.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
import { WorkspaceJournal } from "./journal.mjs";
|
||||
|
||||
export function recoverWorkspace(storage, options = {}) {
|
||||
const journal = new WorkspaceJournal(storage, options);
|
||||
return journal.recover();
|
||||
}
|
||||
46
web/workspace/test_recovery.mjs
Normal file
46
web/workspace/test_recovery.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { MemoryStorage, WorkspaceJournal, canonicalJson } from "./journal.mjs";
|
||||
import { recoverWorkspace } from "./recovery.mjs";
|
||||
|
||||
const storage = new MemoryStorage();
|
||||
const journal = new WorkspaceJournal(storage, { projectId: "demo" });
|
||||
const prepared = journal.prepare("op-1", [{ path: "config/main.ini", data: "[EMC]\n" }, { path: "programs/main.ngc", data: "G0 X1\n" }]);
|
||||
journal.commit("op-1");
|
||||
let recovered = recoverWorkspace(storage, { projectId: "demo" });
|
||||
assert.equal(recovered.status, "recovered");
|
||||
assert.deepEqual(recovered.files, { "config/main.ini": "[EMC]\n", "programs/main.ngc": "G0 X1\n" });
|
||||
assert.equal(recovered.applied[0].commit_seq, prepared.commit_seq);
|
||||
|
||||
const halfStorage = new MemoryStorage();
|
||||
const halfJournal = new WorkspaceJournal(halfStorage, { projectId: "half" });
|
||||
halfJournal.prepare("op-half", [{ path: "config/half.ini", data: "half" }]);
|
||||
recovered = recoverWorkspace(halfStorage, { projectId: "half" });
|
||||
assert.equal(recovered.status, "recovered");
|
||||
assert.deepEqual(recovered.files, {});
|
||||
assert.deepEqual(recovered.half_commits, [1]);
|
||||
|
||||
assert.throws(() => journal.prepare("op-path", [{ path: "../escape", data: "x" }]), /workspace_path_invalid/);
|
||||
assert.throws(() => journal.prepare("op-data", [{ path: "config/bad", data: 1 }]), /workspace_data_invalid/);
|
||||
assert.throws(() => journal.commit("missing"), /prepare_missing/);
|
||||
|
||||
const corrupted = new MemoryStorage(Object.fromEntries(storage.entries()));
|
||||
const commitKey = [...corrupted.records.keys()].find((key) => key.endsWith(".commit"));
|
||||
const commit = JSON.parse(corrupted.get(commitKey));
|
||||
commit.prepare_hash = "fnv1a64:0000000000000000";
|
||||
corrupted.set(commitKey, canonicalJson(commit));
|
||||
recovered = recoverWorkspace(corrupted, { projectId: "demo" });
|
||||
assert.equal(recovered.status, "blocked");
|
||||
assert.equal(recovered.blocked[0].reason, "hash_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`);
|
||||
console.log("workspace_recovery=passed commit=" + prepared.commit_seq);
|
||||
|
||||
function fnvHash(value) {
|
||||
let hash = 1469598103934665603n;
|
||||
for (const byte of new TextEncoder().encode(value)) hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 1099511628211n);
|
||||
return `fnv1a64:${hash.toString(16).padStart(16, "0")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user