Implement configuration, runtime, frontend, and task governance closure

This commit is contained in:
2026-08-21 16:51:42 +08:00
parent b1b5a3cc8a
commit 63c0408ce7
92 changed files with 343619 additions and 172 deletions

108
web/app/app.mjs Normal file
View File

@@ -0,0 +1,108 @@
import { WorkerClient } from "./worker-client.mjs";
import { CommandController } from "./controls.mjs";
import { EMPTY_STATE, reduceWorkerState, renderStatePanel } from "./state-panel.mjs";
import { ProjectWorkflow } from "./project-workflow.mjs";
import { renderRecoveryPanel } from "./recovery-panel.mjs";
import { renderDevicePanel } from "./device-panel.mjs";
import { renderHalPanel } from "./hal-panel.mjs";
import { renderMachineView } from "./machine-view.mjs";
const SAMPLE_CONFIGURATION = { entrypoint: "linuxcnc/configs/sim/demo/main.ini", availablePaths: ["linuxcnc/configs/sim/demo/main.ini"], rows: [] };
const SAMPLE_HAL = { manifest: [{ component: "demo" }], lines: ["loadrt demo names=d0", "addf d0 servo-thread"] };
function element(document, tag, className, text = "") {
const node = document.createElement(tag);
if (className) node.className = className;
if (text) node.textContent = text;
return node;
}
function statusLine(document) {
const node = element(document, "p", "status-line", "Disconnected");
node.setAttribute("role", "status");
return node;
}
export function mountApp({ document = globalThis.document, client = null } = {}) {
if (!document) throw new Error("frontend_document_required");
const workerClient = client ?? new WorkerClient();
const controller = new CommandController(workerClient);
const project = new ProjectWorkflow({ contextId: workerClient.contextId, client: workerClient });
let workerState = { ...EMPTY_STATE };
const root = document.querySelector("#app") ?? document.body;
root.replaceChildren();
const shell = element(document, "main", "app-shell");
const header = element(document, "header", "app-header");
header.append(element(document, "div", "brand", "LinuxCNC Web/WASM"));
const context = element(document, "span", "context-chip", workerClient.contextId);
header.append(context);
shell.append(header);
const content = element(document, "section", "workspace-grid");
const sessionPanel = element(document, "section", "panel");
sessionPanel.append(element(document, "h1", "panel-title", "Simulation session"));
const status = statusLine(document);
sessionPanel.append(status);
const controls = element(document, "div", "controls");
const buttons = [
["Connect", async () => { await workerClient.hello(); await workerClient.attach(); status.textContent = "Session ready"; }],
["Load sample", async () => { await workerClient.loadWorkspace("/workspace", SAMPLE_CONFIGURATION, SAMPLE_HAL); status.textContent = "Sample workspace loaded"; }],
["Step 1 tick", async () => { const response = await workerClient.step(1); status.textContent = `Tick ${response.simulation_tick}`; }],
["Snapshot", async () => { const response = await workerClient.snapshot(); status.textContent = `Snapshot tick ${response.payload.tick}`; }],
];
for (const [label, action] of buttons) {
const button = element(document, "button", "control-button", label);
button.type = "button";
button.addEventListener("click", () => Promise.resolve(action()).catch((error) => {
status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`;
}));
controls.append(button);
}
sessionPanel.append(controls);
const operationControls = element(document, "div", "operation-controls");
const operationButtons = [
["Run", "run", { program: "main.ngc" }],
["Pause", "pause", {}],
["Resume", "resume", {}],
["Abort", "abort", {}],
["Home", "home", {}],
["E-stop", "estop", { active: true }],
["Recover", "recover", {}],
];
for (const [label, action, payload] of operationButtons) {
const button = element(document, "button", "control-button", label);
button.type = "button";
button.addEventListener("click", () => controller.execute(action, payload).catch((error) => {
status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`;
}));
operationControls.append(button);
}
sessionPanel.append(operationControls);
content.append(sessionPanel);
const snapshotPanel = element(document, "section", "panel snapshot-panel");
snapshotPanel.append(element(document, "h2", "panel-title", "Authoritative state"));
const state = element(document, "pre", "state-view", "Waiting for Worker state");
state.setAttribute("aria-live", "polite");
snapshotPanel.append(state);
content.append(snapshotPanel);
const statePanel = renderStatePanel(document, workerState);
content.append(statePanel);
const recoveryPanel = renderRecoveryPanel(document, { status: "idle" });
content.append(recoveryPanel);
content.append(renderDevicePanel(document, { controller, snapshot: {} }));
content.append(renderHalPanel(document, { components: 145, blocked: [] }, []));
content.append(renderMachineView(document, { pose: { x: 0, y: 0, z: 0 }, stateHash: null }));
shell.append(content);
root.append(shell);
workerClient.onMessage((message) => {
workerState = reduceWorkerState(workerState, message);
const replacement = renderStatePanel(document, workerState);
statePanel.replaceChildren(...replacement.childNodes);
if (message.kind === "CAPABILITIES" || message.kind === "SESSION_READY" || message.kind === "STATE_SNAPSHOT" || message.kind === "COMMAND_RESULT") {
state.textContent = JSON.stringify(message.payload, null, 2);
}
if (message.kind === "ERROR") status.textContent = `Error: ${message.payload?.code ?? "worker_error"}`;
});
return { client: workerClient, controller, project, root: shell, status, state, recoveryPanel, get workerState() { return workerState; } };
}
if (typeof document !== "undefined" && document.querySelector("#app")) mountApp();

42
web/app/controls.mjs Normal file
View File

@@ -0,0 +1,42 @@
const CONTROL_COMMANDS = Object.freeze({
run: "PROGRAM_RUN",
pause: "PROGRAM_PAUSE",
resume: "PROGRAM_RESUME",
abort: "PROGRAM_ABORT",
mdi: "COMMAND",
jogStart: "JOG_START",
jogStop: "JOG_STOP",
home: "HOME",
override: "SET_OVERRIDE",
estop: "ESTOP",
recover: "IO_RECOVER",
});
function operationId(kind, payload, counter) {
const identity = JSON.stringify(payload, Object.keys(payload ?? {}).sort()).replace(/[^A-Za-z0-9]+/g, "-").slice(0, 40);
return `ui-${kind.toLowerCase()}-${counter}${identity ? `-${identity}` : ""}`;
}
export class CommandController {
constructor(client) {
if (!client || typeof client.send !== "function") throw new Error("command_client_required");
this.client = client;
this.counter = 0;
this.busy = new Set();
}
async execute(action, payload = {}, { operationId: supplied = null } = {}) {
const kind = CONTROL_COMMANDS[action];
if (!kind) throw new Error("command_action_unknown");
const id = supplied ?? operationId(kind, payload, ++this.counter);
if (this.busy.has(id)) throw new Error("command_operation_busy");
this.busy.add(id);
try {
return await this.client.send(kind, payload, { operationId: id });
} finally {
this.busy.delete(id);
}
}
}
export { CONTROL_COMMANDS };

10
web/app/device-panel.mjs Normal file
View File

@@ -0,0 +1,10 @@
export function renderDevicePanel(document, { controller, snapshot = {} } = {}) {
const section = document.createElement("section"); section.className = "panel device-panel";
const title = document.createElement("h2"); title.className = "panel-title"; title.textContent = "Devices and faults"; section.append(title);
const controls = [["Spindle start", "SPINDLE_COMMAND", { spindle: 0, rpm: 600, enabled: true }], ["Spindle stop", "SPINDLE_COMMAND", { spindle: 0, rpm: 0, enabled: false }], ["Tool prepare", "TOOL_PREPARE", { tool: 1, pocket: 1 }], ["Probe arm", "PROBE_CONFIG", { action: "arm", probe_type: 0 }], ["Recover IO", "IO_RECOVER", {}]];
const buttons = document.createElement("div"); buttons.className = "device-buttons";
for (const [label, kind, payload] of controls) { const button = document.createElement("button"); button.type = "button"; button.className = "control-button"; button.textContent = label; button.addEventListener("click", () => controller.client.send(kind, payload, { operationId: `device-${kind}-${label.replaceAll(" ", "-")}` })); buttons.append(button); }
section.append(buttons);
const state = document.createElement("pre"); state.className = "state-view device-state"; state.textContent = JSON.stringify(snapshot, null, 2); section.append(state);
return section;
}

9
web/app/hal-panel.mjs Normal file
View File

@@ -0,0 +1,9 @@
export function renderHalPanel(document, manifest = {}, graph = []) {
const section = document.createElement("section"); section.className = "panel hal-panel";
const title = document.createElement("h2"); title.className = "panel-title"; title.textContent = "HAL diagnostics"; section.append(title);
const summary = document.createElement("p"); summary.className = "status-line"; summary.textContent = `${manifest.components ?? 0} components | ${graph.length} graph records`; section.append(summary);
const list = document.createElement("ul"); list.className = "hal-list";
for (const row of (manifest.blocked ?? []).slice(0, 8)) { const item = document.createElement("li"); item.textContent = `${row.code ?? "blocked"}: ${row.path ?? "HAL resource"}`; list.append(item); }
section.append(list);
return section;
}

14
web/app/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#111820">
<title>LinuxCNC Web/WASM</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="./app.mjs"></script>
</body>
</html>

10
web/app/machine-view.mjs Normal file
View File

@@ -0,0 +1,10 @@
export function renderMachineView(document, { pose = { x: 0, y: 0, z: 0 }, stateHash = null } = {}) {
const section = document.createElement("section"); section.className = "panel machine-panel";
const title = document.createElement("h2"); title.className = "panel-title"; title.textContent = "Machine view"; section.append(title);
const canvas = document.createElement("canvas"); canvas.className = "machine-canvas"; canvas.width = 640; canvas.height = 320; canvas.setAttribute("aria-label", "Machine position view");
const context = canvas.getContext?.("2d");
if (context) { context.fillStyle = "#0b1016"; context.fillRect(0, 0, canvas.width, canvas.height); context.strokeStyle = "#40515f"; context.strokeRect(20, 20, 600, 280); context.fillStyle = "#b4e15d"; context.beginPath(); context.arc(320 + Number(pose.x || 0) * 10, 160 - Number(pose.y || 0) * 10, 8, 0, Math.PI * 2); context.fill(); }
section.append(canvas);
const meta = document.createElement("small"); meta.textContent = `X ${pose.x ?? 0} | Y ${pose.y ?? 0} | Z ${pose.z ?? 0} | ${stateHash ?? "no hash"}`; section.append(meta);
return section;
}

View File

@@ -0,0 +1,48 @@
import { MemoryStorage, WorkspaceJournal } from "../workspace/journal.mjs";
import { createOfflinePackage, createWorkspacePackage, migrateWorkspacePackage, verifyOfflinePackage, verifyWorkspacePackage } from "../workspace/migration.mjs";
export class ProjectWorkflow {
constructor({ projectId = "frontend-project", storage = new MemoryStorage(), contextId, client = null } = {}) {
this.projectId = projectId;
this.contextId = contextId;
this.storage = storage;
this.journal = new WorkspaceJournal(storage, { projectId });
this.client = client;
this.files = {};
this.lastRecovery = null;
}
importFiles(files) {
if (!Array.isArray(files) || files.length === 0) throw new Error("project_files_empty");
const operationId = `project-import-${this.journal.nextSequence()}`;
this.journal.prepare(operationId, files);
this.journal.commit(operationId);
const recovery = this.journal.recover();
this.lastRecovery = recovery;
if (recovery.status !== "recovered") throw new Error("project_import_blocked");
this.files = recovery.files;
return { status: "verified", operation_id: operationId, files: structuredClone(this.files), state_hash: recovery.state_hash };
}
recover() {
const recovery = this.journal.recover();
this.lastRecovery = recovery;
if (recovery.status === "recovered") this.files = recovery.files;
return recovery;
}
exportFiles() { return Object.entries(this.files).sort(([left], [right]) => left.localeCompare(right)).map(([path, data]) => ({ path, data })); }
package({ timeline, checkpoint, committedSeq }) {
return createWorkspacePackage({ contextId: this.contextId, timeline, checkpoint, journal: this.journal.records(), committedSeq });
}
migrate(packageValue, targetVersion) {
return migrateWorkspacePackage(packageValue, targetVersion);
}
verify(packageValue, options = {}) { return verifyWorkspacePackage(packageValue, { contextId: this.contextId, ...options }); }
offline(packageValue, resources) { return createOfflinePackage({ workspacePackage: packageValue, resources }); }
verifyOffline(bundle, options = {}) { return verifyOfflinePackage(bundle, { contextId: this.contextId, ...options }); }
}

View File

@@ -0,0 +1,27 @@
export function recoveryView(state = {}) {
return {
status: state.status ?? "idle",
message: state.blocked?.[0]?.reason ?? (state.half_commits?.length ? "half_commit_ignored" : state.status ?? "idle"),
state_hash: state.state_hash ?? null,
applied: state.applied?.length ?? 0,
half_commits: state.half_commits?.length ?? 0,
};
}
export function renderRecoveryPanel(document, state = {}) {
const view = recoveryView(state);
const section = document.createElement("section");
section.className = "panel recovery-panel";
const title = document.createElement("h2");
title.className = "panel-title";
title.textContent = "Project recovery";
section.append(title);
const body = document.createElement("p");
body.className = "recovery-status";
body.textContent = `${view.status}: ${view.message}`;
section.append(body);
const meta = document.createElement("small");
meta.textContent = `commits ${view.applied} | half-commits ${view.half_commits}`;
section.append(meta);
return section;
}

42
web/app/state-panel.mjs Normal file
View File

@@ -0,0 +1,42 @@
const EMPTY_STATE = Object.freeze({
connected: false,
tick: 0,
status: "disconnected",
alarm: null,
mode: null,
snapshot: null,
lastCommand: null,
error: null,
});
export function reduceWorkerState(state = EMPTY_STATE, message) {
if (!message || typeof message !== "object") return { ...state, error: "worker_message_invalid" };
if (message.kind === "ERROR") return { ...state, status: "error", error: message.payload?.code ?? "worker_error", alarm: message.payload?.message_key ?? null };
if (message.kind === "CAPABILITIES") return { ...state, connected: true, status: "capabilities", error: null, capabilities: message.payload };
if (message.kind === "SESSION_READY") return { ...state, connected: true, status: "ready", tick: message.simulation_tick ?? state.tick, error: null };
if (message.kind === "STATE_SNAPSHOT") return { ...state, status: "snapshot", tick: message.payload?.tick ?? message.simulation_tick ?? state.tick, snapshot: message.payload, error: null };
if (message.kind === "COMMAND_RESULT") return { ...state, status: message.payload?.status ?? "verified", tick: message.simulation_tick ?? state.tick, lastCommand: message.payload?.command ?? null, snapshot: message.payload?.snapshot ?? state.snapshot, error: null };
if (message.kind === "ALARM") return { ...state, status: "alarm", alarm: message.payload, error: message.payload?.code ?? null };
return { ...state, tick: message.simulation_tick ?? state.tick };
}
export function renderStatePanel(document, state) {
const section = document.createElement("section");
section.className = "panel state-panel";
const title = document.createElement("h2");
title.className = "panel-title";
title.textContent = "Machine state";
section.append(title);
const grid = document.createElement("dl");
grid.className = "state-grid";
const fields = [["Connection", state.connected ? "Connected" : "Disconnected"], ["Status", state.status], ["Tick", String(state.tick)], ["Mode", state.mode ?? "-"], ["Alarm", state.alarm ? (typeof state.alarm === "string" ? state.alarm : JSON.stringify(state.alarm)) : "None"]];
for (const [name, value] of fields) {
const term = document.createElement("dt"); term.textContent = name;
const detail = document.createElement("dd"); detail.textContent = value;
grid.append(term, detail);
}
section.append(grid);
return section;
}
export { EMPTY_STATE };

36
web/app/styles.css Normal file
View File

@@ -0,0 +1,36 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
background: #0e141b;
color: #e7edf2;
--line: #2b3742;
--muted: #9aa9b5;
--accent: #b4e15d;
--panel: #151e27;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: #0e141b; }
button { font: inherit; }
.app-shell { min-height: 100vh; padding: 24px; }
.app-header { display: flex; align-items: center; gap: 14px; max-width: 1180px; margin: 0 auto 22px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
.brand { font-size: 20px; font-weight: 700; letter-spacing: 0; }
.context-chip { color: var(--muted); font: 12px ui-monospace, monospace; overflow-wrap: anywhere; }
.workspace-grid { display: grid; grid-template-columns: minmax(280px, 0.8fr) minmax(320px, 1.2fr); gap: 18px; max-width: 1180px; margin: 0 auto; }
.panel { min-width: 0; padding: 20px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
.panel-title { margin: 0 0 12px; font-size: 16px; letter-spacing: 0; }
.status-line { min-height: 24px; margin: 0 0 18px; color: var(--muted); }
.controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.operation-controls { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 10px; }
.control-button { min-height: 42px; border: 1px solid #516173; border-radius: 4px; background: #202c38; color: #f2f6f8; cursor: pointer; }
.control-button:hover, .control-button:focus-visible { border-color: var(--accent); color: var(--accent); outline: none; }
.state-view { min-height: 260px; max-height: 520px; margin: 0; overflow: auto; color: #c9e58e; font: 12px/1.55 ui-monospace, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.state-grid { display: grid; grid-template-columns: minmax(90px, 0.4fr) minmax(0, 1fr); gap: 8px 14px; margin: 0; }
.state-grid dt { color: var(--muted); }
.state-grid dd { margin: 0; overflow-wrap: anywhere; }
.recovery-status { margin: 0 0 8px; color: var(--muted); overflow-wrap: anywhere; }
.device-buttons { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; }
.hal-list { margin: 0; padding-left: 20px; color: #f0b36a; font: 12px/1.5 ui-monospace, monospace; }
.machine-canvas { display: block; width: 100%; height: auto; aspect-ratio: 2 / 1; border: 1px solid var(--line); border-radius: 4px; }
@media (max-width: 760px) { .device-buttons { grid-template-columns: 1fr; } }
@media (max-width: 760px) { .app-shell { padding: 16px; } .app-header { align-items: flex-start; flex-direction: column; } .workspace-grid { grid-template-columns: 1fr; } .controls, .operation-controls { grid-template-columns: 1fr; } }

View File

@@ -0,0 +1,13 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
const files = ["index.html", "app.mjs", "worker-client.mjs", "styles.css", "controls.mjs", "state-panel.mjs", "project-workflow.mjs", "recovery-panel.mjs", "device-panel.mjs", "hal-panel.mjs", "machine-view.mjs"];
const measurements = [];
for (const file of files) {
const value = await readFile(new URL(`./${file}`, import.meta.url), "utf8");
assert.ok(value.length > 0);
measurements.push({ file, bytes: Buffer.byteLength(value) });
}
const totalBytes = measurements.reduce((total, row) => total + row.bytes, 0);
assert.ok(totalBytes > 1000);
console.log(JSON.stringify({ schema: "browser-performance-sample.v1", files: measurements, total_bytes: totalBytes, stable: true }));

View File

@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { WorkerClient, CONTEXT_ID } from "./worker-client.mjs";
class FakeWorker {
constructor() { this.sent = []; this.onmessage = null; this.onerror = null; }
postMessage(message) {
this.sent.push(message);
queueMicrotask(() => this.onmessage?.({ data: {
schema_version: 1,
kind: message.kind === "HELLO" ? "CAPABILITIES" : message.kind === "SESSION_ATTACH" ? "SESSION_READY" : message.kind === "SNAPSHOT_REQUEST" ? "STATE_SNAPSHOT" : "COMMAND_RESULT",
context_id: message.context_id,
session_id: message.session_id,
seq: message.seq,
operation_id: message.operation_id,
simulation_tick: message.kind === "SIM_STEP" ? message.payload.cycles : 0,
payload: { status: "verified", tick: message.payload.cycles ?? 0 },
} }));
}
terminate() { this.terminated = true; }
}
const worker = new FakeWorker();
const client = new WorkerClient({ worker, contextId: CONTEXT_ID, sessionId: "frontend-test", timeoutMs: 1000 });
assert.equal((await client.hello()).kind, "CAPABILITIES");
assert.equal((await client.attach()).kind, "SESSION_READY");
const first = await client.step(3, "same-operation");
const sentCount = worker.sent.length;
const replay = await client.step(99, "same-operation");
assert.deepEqual(replay, first);
assert.equal(worker.sent.length, sentCount);
assert.equal(worker.sent.every((message) => message.context_id === CONTEXT_ID), true);
client.close();
assert.equal(worker.terminated, true);
const [html, app, css] = await Promise.all([
readFile(new URL("./index.html", import.meta.url), "utf8"),
readFile(new URL("./app.mjs", import.meta.url), "utf8"),
readFile(new URL("./styles.css", import.meta.url), "utf8"),
]);
assert.match(html, /type="module"/);
assert.match(app, /WorkerClient/);
assert.match(app, /loadWorkspace/);
assert.doesNotMatch(app, /_sim_|wasm-core|FileSystemSyncAccessHandle/);
assert.match(css, /@media/);
console.log("frontend_m1=passed messages=" + worker.sent.length);

View File

@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import { CommandController } from "./controls.mjs";
import { EMPTY_STATE, reduceWorkerState } from "./state-panel.mjs";
const calls = [];
const client = { send: async (kind, payload, options) => { calls.push({ kind, payload, options }); return { kind: "COMMAND_RESULT", simulation_tick: 3, payload: { status: "verified", command: kind } }; } };
const controller = new CommandController(client);
const first = await controller.execute("run", { program: "main.ngc" });
assert.equal(first.payload.command, "PROGRAM_RUN");
await controller.execute("estop", { active: true }, { operationId: "estop-fixed" });
assert.equal(calls[1].kind, "ESTOP");
assert.equal(calls[1].options.operationId, "estop-fixed");
assert.equal(reduceWorkerState(EMPTY_STATE, { kind: "SESSION_READY", simulation_tick: 2 }).status, "ready");
const errorState = reduceWorkerState(EMPTY_STATE, { kind: "ERROR", payload: { code: "sim_io_estop", message_key: "sim.estop" } });
assert.equal(errorState.status, "error");
assert.equal(errorState.error, "sim_io_estop");
console.log(`frontend_m2=passed commands=${calls.length}`);

View File

@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { MemoryStorage } from "../workspace/journal.mjs";
import { ReplayTimeline } from "../worker/replay.mjs";
import { createCheckpoint } from "../workspace/recovery.mjs";
import { ProjectWorkflow } from "./project-workflow.mjs";
import { recoveryView } from "./recovery-panel.mjs";
const workflow = new ProjectWorkflow({ contextId: "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", storage: new MemoryStorage(), projectId: "frontend-m3" });
const imported = workflow.importFiles([{ path: "config/main.ini", data: "[EMC]\n" }, { path: "programs/main.ngc", data: "G0 X1\n" }]);
assert.equal(imported.status, "verified");
assert.deepEqual(workflow.exportFiles(), [{ path: "config/main.ini", data: "[EMC]\n" }, { path: "programs/main.ngc", data: "G0 X1\n" }]);
const timeline = new ReplayTimeline();
timeline.appendEvent(1, 0, "SESSION_READY", {});
timeline.checkpoint(1, 0, { tick: 0 });
const checkpoint = createCheckpoint({ contextId: workflow.contextId, timeline, state: { tick: 0 }, operations: [] });
const pkg = workflow.package({ timeline, checkpoint, committedSeq: 1 });
assert.equal(workflow.verify(pkg).package_hash, pkg.package_hash);
const migrated = workflow.migrate(pkg, 2);
assert.equal(workflow.verify(migrated, { expectedVersion: 2 }).format_version, 2);
const offline = workflow.offline(migrated, [{ path: "ui/index.html", data: "<main>" }]);
assert.equal(workflow.verifyOffline(offline, { workspacePackage: migrated, available: [{ path: "ui/index.html", data: "<main>" }] }).status, "ready");
assert.equal(recoveryView({ status: "blocked", half_commits: [2] }).message, "half_commit_ignored");
console.log(`frontend_m3=passed files=${workflow.exportFiles().length}`);

View File

@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import { renderDevicePanel } from "./device-panel.mjs";
import { renderHalPanel } from "./hal-panel.mjs";
import { renderMachineView } from "./machine-view.mjs";
class Node { constructor(tag) { this.tagName = tag; this.children = []; this.textContent = ""; this.className = ""; } append(...nodes) { this.children.push(...nodes); } addEventListener() {} setAttribute() {} getContext() { return { fillRect() {}, strokeRect() {}, beginPath() {}, arc() {}, fill() {} }; } }
const document = { createElement: (tag) => new Node(tag) };
const controller = { client: { send: async () => ({ kind: "COMMAND_RESULT" }) } };
const device = renderDevicePanel(document, { controller, snapshot: { estop: false } });
const hal = renderHalPanel(document, { components: 145, blocked: [{ code: "host_dynamic_loader", path: "x.hal" }] }, [{}, {}]);
const machine = renderMachineView(document, { pose: { x: 1, y: 2, z: 3 }, stateHash: "fnv1a64:test" });
assert.equal(device.className, "panel device-panel");
assert.equal(hal.className, "panel hal-panel");
assert.equal(machine.className, "panel machine-panel");
assert.equal(machine.children[1].tagName, "canvas");
console.log("frontend_m4=passed panels=3");

105
web/app/worker-client.mjs Normal file
View File

@@ -0,0 +1,105 @@
export const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
export const PROTOCOL_SCHEMA = 1;
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
export class WorkerClient {
constructor({ worker = null, workerFactory = null, contextId = CONTEXT_ID, sessionId = `ui-${Date.now().toString(36)}`, timeoutMs = 10000 } = {}) {
this.contextId = contextId;
this.sessionId = sessionId;
this.timeoutMs = timeoutMs;
this.worker = worker ?? (workerFactory ? workerFactory() : new Worker("../worker/sim-worker.mjs", { type: "module" }));
this.seq = 0;
this.pending = new Map();
this.completed = new Map();
this.listeners = new Set();
this.closed = false;
this.worker.onmessage = (event) => this.#receive(event.data);
this.worker.onerror = (event) => this.#failAll(new Error(event?.message ?? "worker_exception"));
}
onMessage(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
#emit(message) {
for (const listener of this.listeners) listener(message);
}
#receive(message) {
this.#emit(message);
if (!isObject(message)) return;
const operationId = message.operation_id ?? message.payload?.operation_id;
const pending = operationId ? this.pending.get(operationId) : this.pending.get(`seq:${message.seq}`);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(operationId ?? `seq:${message.seq}`);
this.completed.set(operationId, message);
if (message.kind === "ERROR" || message.payload?.status === "error" || message.payload?.status === "blocked") {
const error = new Error(message.payload?.message_key ?? message.payload?.code ?? "worker_error");
error.response = message;
pending.reject(error);
} else pending.resolve(message);
}
#failAll(error) {
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
send(kind, payload = {}, { operationId = null, timeoutMs = this.timeoutMs } = {}) {
if (this.closed) return Promise.reject(new Error("worker_client_closed"));
if (operationId && this.completed.has(operationId)) return Promise.resolve(this.completed.get(operationId));
if (operationId && this.pending.has(operationId)) return this.pending.get(operationId).promise;
const seq = ++this.seq;
const message = {
schema_version: PROTOCOL_SCHEMA,
context_id: this.contextId,
session_id: this.sessionId,
seq,
kind,
...(operationId ? { operation_id: operationId } : {}),
payload,
};
let resolvePromise;
let rejectPromise;
const promise = new Promise((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; });
const timer = setTimeout(() => {
this.pending.delete(operationId);
const error = new Error("worker_timeout");
error.code = "worker_timeout";
rejectPromise(error);
}, timeoutMs);
if (operationId) this.pending.set(operationId, { promise, resolve: resolvePromise, reject: rejectPromise, timer, seq });
else this.pending.set(`seq:${seq}`, { promise, resolve: resolvePromise, reject: rejectPromise, timer, seq });
try {
this.worker.postMessage(message);
} catch (error) {
clearTimeout(timer);
this.pending.delete(operationId ?? `seq:${seq}`);
rejectPromise(error);
}
return promise;
}
hello() { return this.send("HELLO", {}); }
attach(operationId = "session-attach") { return this.send("SESSION_ATTACH", {}, { operationId }); }
loadWorkspace(rootPath = "/workspace", configuration = undefined, hal = undefined, operationId = "workspace-load") {
return this.send("LOAD_WORKSPACE", { root_path: rootPath, ...(configuration ? { configuration } : {}), ...(hal ? { hal } : {}) }, { operationId });
}
step(cycles = 1, operationId = `step-${this.seq + 1}`) { return this.send("SIM_STEP", { cycles }, { operationId }); }
snapshot(operationId = `snapshot-${this.seq + 1}`) { return this.send("SNAPSHOT_REQUEST", {}, { operationId }); }
checkpoint(operationId = `checkpoint-${this.seq + 1}`) { return this.send("CHECKPOINT", {}, { operationId }); }
close() {
if (this.closed) return;
this.closed = true;
this.#failAll(new Error("worker_client_closed"));
this.worker.terminate?.();
}
}