Initial LinuxCNC WebAssembly workspace snapshot

This commit is contained in:
2026-08-20 18:21:15 +08:00
commit 5dd614c335
92 changed files with 200203 additions and 0 deletions

140
web/workspace/journal.mjs Normal file
View 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 };

View File

@@ -0,0 +1,6 @@
import { WorkspaceJournal } from "./journal.mjs";
export function recoverWorkspace(storage, options = {}) {
const journal = new WorkspaceJournal(storage, options);
return journal.recover();
}

View 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")}`;
}