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?.();
}
}

View File

@@ -0,0 +1,104 @@
import manifest from "../../python-runtime/manifest.json" with { type: "json" };
import { fnv1a64 } from "./replay.mjs";
export const PYTHON_BRIDGE_SCHEMA = "python-worker-bridge.v1";
function clone(value) { return structuredClone(value); }
function finiteJson(value) {
if (value === null || typeof value !== "object") return true;
if (typeof value === "number") return Number.isFinite(value);
return Array.isArray(value) ? value.every(finiteJson) : Object.values(value).every(finiteJson);
}
export class PythonBridge {
constructor({ loadPyodide = globalThis.loadPyodide, maxSourceBytes = 65536, maxCalls = 256 } = {}) {
this.loadPyodide = loadPyodide;
this.maxSourceBytes = maxSourceBytes;
this.maxCalls = maxCalls;
this.pyodide = null;
this.initialized = false;
this.state = { imports: [], calls: [], errors: [], capabilities: [], tick: 0 };
}
capabilities() {
return {
schema: PYTHON_BRIDGE_SCHEMA,
runtime: manifest.runtime,
version: manifest.version,
python_version: manifest.python_version,
abi_version: manifest.abi_version,
platform: manifest.platform,
worker_only: manifest.worker_only,
virtual_modules: clone(manifest.virtual_modules),
denied_capabilities: [...manifest.denied_capabilities],
initialized: this.initialized,
};
}
async initialize() {
if (this.initialized) return { status: "verified", capabilities: this.capabilities() };
if (typeof this.loadPyodide !== "function") return this.fail("python_runtime_unavailable", "python.runtime_unavailable");
try {
this.pyodide = await this.loadPyodide({ indexURL: manifest.index_url });
this.initialized = true;
return { status: "verified", capabilities: this.capabilities() };
} catch (error) {
return this.fail("python_runtime_init", String(error?.message ?? error));
}
}
importModule(module) {
const symbols = manifest.virtual_modules[module];
if (!symbols) return this.fail("python_unsupported_module", "python.unsupported_module", { module });
if (!this.state.imports.includes(module)) this.state.imports.push(module);
return { status: "verified", module, symbols: [...symbols], state_hash: this.hash() };
}
checkCapability(capability) {
if (manifest.denied_capabilities.includes(capability)) return this.fail("real_hardware_excluded", "python.host_capability_denied", { capability, status: "REAL-EXCLUDED" });
if (typeof capability !== "string" || capability.length === 0) return this.fail("python_capability_invalid", "python.capability_invalid");
if (!this.state.capabilities.includes(capability)) this.state.capabilities.push(capability);
return { status: "verified", capability, state_hash: this.hash() };
}
invoke(module, symbol, args = []) {
const symbols = manifest.virtual_modules[module];
if (!symbols || !symbols.includes(symbol)) return this.fail("python_symbol_unsupported", "python.symbol_unsupported", { module, symbol });
if (!Array.isArray(args) || !finiteJson(args)) return this.fail("python_argument_invalid", "python.argument_invalid");
if (this.state.calls.length >= this.maxCalls) return this.fail("python_call_limit", "python.call_limit");
if (!this.state.imports.includes(module)) this.state.imports.push(module);
const call = { module, symbol, args: clone(args), tick: this.state.tick };
this.state.calls.push(call);
return { status: "verified", value: { module, symbol, args: clone(args) }, state_hash: this.hash() };
}
async execute(source) {
if (typeof source !== "string" || new TextEncoder().encode(source).length > this.maxSourceBytes) return this.fail("python_source_limit", "python.source_limit");
const denied = manifest.denied_capabilities.find((capability) => new RegExp(`(?:^|[^A-Za-z0-9_])${capability.replaceAll(".", "\\.")}(?:$|[^A-Za-z0-9_])`).test(source));
if (denied) return this.fail("real_hardware_excluded", "python.host_capability_denied", { capability: denied, status: "REAL-EXCLUDED" });
if (!this.pyodide || typeof this.pyodide.runPythonAsync !== "function") return this.fail("python_runtime_unavailable", "python.runtime_unavailable");
try {
const value = await this.pyodide.runPythonAsync(source);
return { status: "verified", value: value === undefined ? null : value, state_hash: this.hash() };
} catch (error) {
return this.fail("python_exception", "python.exception", { detail: String(error?.message ?? error) });
}
}
fail(code, message_key, extra = {}) {
this.state.errors.push({ code, tick: this.state.tick });
return { status: extra.status ?? "blocked", code, message_key, retryable: false, ...extra, state_hash: this.hash() };
}
setTick(tick) { if (Number.isSafeInteger(tick) && tick >= 0) this.state.tick = tick; }
exportState() { return { schema: PYTHON_BRIDGE_SCHEMA, initialized: this.initialized, state: clone(this.state) }; }
restoreState(value) {
if (!value || value.schema !== PYTHON_BRIDGE_SCHEMA || !value.state) return this.fail("python_state_invalid", "python.state_invalid");
this.initialized = Boolean(value.initialized);
this.state = clone(value.state);
return { status: "verified", state_hash: this.hash() };
}
hash() { return fnv1a64(this.state); }
}
export { manifest };

View File

@@ -2,6 +2,10 @@ 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";
import { preflightConfiguration } from "../workspace/configuration.mjs";
import { preflightHalConfiguration } from "../workspace/hal-configuration.mjs";
import { DEVICE_COMMANDS, VirtualDevices } from "./virtual-devices.mjs";
import { PythonBridge } from "./python-bridge.mjs";
const PROTOCOL_SCHEMA = 1;
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
@@ -11,7 +15,10 @@ const OPERATION_KINDS = new Set([
"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",
"TOOL_CHANGED", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT",
"SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "IO_STEP", "IO_RECOVER",
"PROBE_CONFIG", "LIMIT_SET", "ESTOP", "HAL_COMMAND", "HAL_SET",
"PYTHON_IMPORT", "PYTHON_CALL", "PYTHON_CAPABILITY",
]);
function text(value) {
@@ -52,6 +59,10 @@ export class SimSession {
this.created = false;
this.timeline = new ReplayTimeline();
this.operations = new Map();
this.devices = new VirtualDevices();
this.python = new PythonBridge();
this.configurationState = null;
this.halState = null;
call(core, "sim_protocol_reset");
if (typeof core.ccall === "function") core.ccall("sim_event_reset", null, [], []);
}
@@ -97,9 +108,27 @@ export class SimSession {
tick: this.tick,
created: this.created,
protocol_seq: this.timeline.events.cursor,
devices: this.devices.export(),
configuration: this.configurationState,
hal: this.halState,
python: this.python.exportState(),
};
}
workspacePackageState() {
const value = {
context_id: this.contextId,
session_id: this.sessionId,
configuration: this.configurationState,
hal: this.halState,
devices: this.devices.export(),
python: this.python.exportState(),
tick: this.tick,
event_cursor: this.timeline.events.cursor,
};
return { ...value, state_hash: fnv1a64(value) };
}
createCheckpoint() {
const latest = this.timeline.snapshots.latest();
if (!latest || latest.seq !== this.timeline.events.cursor || latest.tick !== this.timeline.events.tick) {
@@ -119,15 +148,29 @@ export class SimSession {
}
restoreCheckpoint(checkpoint, events = null, reconcile = undefined) {
const replayDevices = new VirtualDevices({ state: checkpoint?.state?.devices });
const replayPython = new PythonBridge();
if (checkpoint?.state?.python) replayPython.restoreState(checkpoint.state.python);
const result = recoverSimulation({
checkpoint,
contextId: this.contextId,
events,
reconcile,
apply: (state, event) => ({ ...state, tick: event.tick }),
apply: (state, event) => {
if (DEVICE_COMMANDS.has(event.kind)) replayDevices.applyEvent(event.kind, event.payload);
if (event.kind === "PYTHON_IMPORT") replayPython.importModule(event.payload?.module);
if (event.kind === "PYTHON_CALL") replayPython.invoke(event.payload?.module, event.payload?.symbol, event.payload?.args ?? []);
if (event.kind === "PYTHON_CAPABILITY") replayPython.checkCapability(event.payload?.capability);
return { ...state, tick: event.tick, devices: replayDevices.export(), python: replayPython.exportState() };
},
});
if (result.status === "recovered" || result.status === "aborted") {
this.tick = result.simulation_tick;
this.devices = new VirtualDevices({ state: result.state?.devices ?? checkpoint?.state?.devices });
this.configurationState = result.state?.configuration ?? checkpoint?.state?.configuration ?? null;
this.halState = result.state?.hal ?? checkpoint?.state?.hal ?? null;
this.python = new PythonBridge();
if (result.state?.python) this.python.restoreState(result.state.python);
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);
@@ -198,6 +241,17 @@ export class SimSession {
return response;
};
const deviceResponse = (kind, payload) => {
const result = this.devices.command(kind, payload ?? {});
const responseKind = result.status === "verified" ? "COMMAND_RESULT" : "ERROR";
return finish(this.envelope(responseKind, input, {
...result,
command: kind,
operation_id: input.operation_id ?? null,
simulation_tick: this.tick,
}), result.status === "verified" ? "verified" : "failed");
};
switch (input.kind) {
case "HELLO":
return finish(this.envelope("CAPABILITIES", input, capabilities(this.contextId)), "verified");
@@ -211,6 +265,41 @@ export class SimSession {
case "LOAD_WORKSPACE": {
const rootPath = input.payload?.root_path;
if (!text(rootPath) || !rootPath.startsWith("/") || rootPath.includes("..")) return finish(this.error(input, "workspace_path", "invalid_workspace_path"), "failed");
if (input.payload?.configuration) {
const preflight = preflightConfiguration(input.payload.configuration);
if (preflight.status !== "ready") {
return finish(this.envelope("ERROR", input, {
status: "blocked",
code: "configuration_preflight",
message_key: "configuration.preflight_blocked",
retryable: false,
preflight,
}), "failed");
}
this.configurationState = {
entrypoint: input.payload.configuration.entrypoint ?? null,
status: preflight.status,
resource_count: preflight.resource_count,
state_hash: fnv1a64(preflight),
};
}
if (input.payload?.hal) {
const preflight = preflightHalConfiguration(input.payload.hal);
if (preflight.status !== "ready") {
return finish(this.envelope("ERROR", input, {
status: "blocked",
code: "hal_preflight",
message_key: "hal.preflight_blocked",
retryable: false,
preflight,
}), "failed");
}
this.halState = {
status: preflight.status,
record_count: preflight.record_count,
state_hash: fnv1a64(preflight),
};
}
const loadResult = call(this.core, "sim_load_workspace", rootPath);
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");
}
@@ -221,11 +310,30 @@ export class SimSession {
const stepResult = call(this.core, "sim_step", cycles);
if (stepResult !== 0) return finish(this.coreError(input, stepResult, "sim_step"), "failed");
this.tick += cycles;
this.python.setTick(this.tick);
const protocolTick = call(this.core, "sim_protocol_set_tick", this.tick);
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 finish(this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, state_hash: snapshot.state_hash, snapshot }));
}
case "SPINDLE_COMMAND": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT":
case "SPINDLE_ORIENT": case "SPINDLE_ORIENTED": case "IO_STEP":
case "TOOL_PREPARE": case "TOOL_PREPARED": case "TOOL_CHANGE":
case "TOOL_CHANGED": case "TOOL_ABORT": case "TOOL_FAULT": case "TOOL_CLEAR_FAULT":
case "PROBE_CONFIG": case "LIMIT_SET": case "ESTOP": case "IO_RECOVER":
return deviceResponse(input.kind, input.payload);
case "PYTHON_IMPORT": {
const result = this.python.importModule(input.payload?.module);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, result), result.status === "verified" ? "verified" : "failed");
}
case "PYTHON_CALL": {
const result = this.python.invoke(input.payload?.module, input.payload?.symbol, input.payload?.args ?? []);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, result), result.status === "verified" ? "verified" : "failed");
}
case "PYTHON_CAPABILITY": {
const result = this.python.checkCapability(input.payload?.capability);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, result), result.status === "verified" ? "verified" : "failed");
}
case "SNAPSHOT_REQUEST":
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": {

View File

@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
let lastSeq = 0;
const core = {
_sim_protocol_reset() {},
_sim_protocol_accept(...args) { if (args[1] !== lastSeq + 1) return -101; lastSeq = args[1]; return 0; },
_sim_create() { return 0; },
_sim_load_workspace() { return 0; },
};
const session = new SimSession(core);
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "config-s-1" };
assert.equal(session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} }).kind, "CAPABILITIES");
assert.equal(session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, payload: {} }).kind, "SESSION_READY");
const response = session.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "op-config", payload: {
root_path: "/workspace",
configuration: {
entrypoint: "linuxcnc/configs/sim/demo/main.ini",
availablePaths: ["linuxcnc/configs/sim/demo/main.ini"],
rows: [{ resource_source_id: "missing", entrypoint: "linuxcnc/configs/sim/demo/main.ini", raw_value: "missing.hal", path: "missing.hal", parse_status: "missing" }],
},
} });
assert.equal(response.kind, "ERROR");
assert.equal(response.payload.code, "configuration_preflight");
assert.equal(response.payload.preflight.blocked_count, 1);
console.log("configuration_preflight=passed");

View File

@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
let lastSeq = 0;
const core = {
_sim_protocol_reset() {},
_sim_protocol_accept(...args) { if (args[1] !== lastSeq + 1) return -101; lastSeq = args[1]; return 0; },
_sim_create() { return 0; },
_sim_load_workspace() { return 0; },
_sim_step() { return 0; },
_sim_protocol_set_tick() { return 0; },
};
const session = new SimSession(core);
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "device-recovery-1" };
session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} });
session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, payload: {} });
session.accept({ ...base, kind: "SPINDLE_COMMAND", seq: 3, operation_id: "spindle", payload: { spindle: 0, rpm: 600, enabled: true } });
session.accept({ ...base, kind: "IO_STEP", seq: 4, operation_id: "step-1", payload: { ticks: 5 } });
const checkpointResponse = session.accept({ ...base, kind: "CHECKPOINT", seq: 5, operation_id: "checkpoint", payload: {} });
assert.equal(checkpointResponse.kind, "CHECKPOINT_READY");
const checkpoint = checkpointResponse.payload.checkpoint;
session.accept({ ...base, kind: "SPINDLE_COMMAND", seq: 6, operation_id: "spindle-stop", payload: { spindle: 0, rpm: 0, enabled: false } });
session.accept({ ...base, kind: "IO_STEP", seq: 7, operation_id: "step-2", payload: { ticks: 5 } });
const expectedHash = session.devices.hash();
const recovered = session.restoreCheckpoint(checkpoint, session.timeline.export(), (operation) => operation);
assert.equal(recovered.status, "recovered");
assert.equal(session.devices.hash(), expectedHash);
assert.deepEqual(session.devices.export(), recovered.state.devices);
console.log(`device_recovery=passed state_hash=${expectedHash}`);

View File

@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
let lastSeq = 0;
const core = {
_sim_protocol_reset() {},
_sim_protocol_accept(...args) { if (args[1] !== lastSeq + 1) return -101; lastSeq = args[1]; return 0; },
_sim_create() { return 0; },
_sim_load_workspace() { return 0; },
};
const session = new SimSession(core);
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "hal-s-1" };
assert.equal(session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} }).kind, "CAPABILITIES");
assert.equal(session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, payload: {} }).kind, "SESSION_READY");
const response = session.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "op-hal", payload: {
root_path: "/workspace",
hal: { manifest: [{ component: "demo" }], lines: ["loadrt demo names=d0", "loadusr -W external"] },
} });
assert.equal(response.kind, "ERROR");
assert.equal(response.payload.code, "hal_preflight");
assert.equal(response.payload.preflight.blocked_count, 1);
console.log("hal_worker_preflight=passed");

View File

@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import { PythonBridge, manifest } from "./python-bridge.mjs";
let loadOptions = null;
const bridge = new PythonBridge({ loadPyodide: async (options) => { loadOptions = options; return { runPythonAsync: async (source) => source.length }; } });
assert.equal((await bridge.initialize()).status, "verified");
assert.equal(loadOptions.indexURL, manifest.index_url);
assert.equal(bridge.importModule("linuxcnc").status, "verified");
assert.equal(bridge.importModule("os").code, "python_unsupported_module");
assert.equal(bridge.checkCapability("subprocess").status, "REAL-EXCLUDED");
assert.equal(bridge.invoke("hal", "pin", ["demo.pin"]).status, "verified");
assert.equal((await bridge.execute("1 + 1")).value, 5);
assert.equal((await bridge.execute("import subprocess")).status, "REAL-EXCLUDED");
assert.equal(bridge.restoreState(bridge.exportState()).status, "verified");
console.log(`python_bridge=passed calls=${bridge.state.calls.length} errors=${bridge.state.errors.length} hash=${bridge.hash()}`);

View File

@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
let lastSeq = 0;
const core = {
_sim_protocol_reset() {},
_sim_protocol_accept(...args) { if (args[1] !== lastSeq + 1) return -101; lastSeq = args[1]; return 0; },
_sim_create() { return 0; },
};
const session = new SimSession(core);
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "python-session-1" };
session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} });
session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, payload: {} });
assert.equal(session.accept({ ...base, kind: "PYTHON_IMPORT", seq: 3, operation_id: "py-import", payload: { module: "hal" } }).payload.status, "verified");
assert.equal(session.accept({ ...base, kind: "PYTHON_CALL", seq: 4, operation_id: "py-call", payload: { module: "hal", symbol: "pin", args: ["demo.pin"] } }).payload.status, "verified");
const checkpointResponse = session.accept({ ...base, kind: "CHECKPOINT", seq: 5, operation_id: "checkpoint", payload: {} });
const checkpoint = checkpointResponse.payload.checkpoint;
session.accept({ ...base, kind: "PYTHON_CALL", seq: 6, operation_id: "py-call-2", payload: { module: "gstat", symbol: "state", args: [] } });
const expected = session.python.hash();
const result = session.restoreCheckpoint(checkpoint, session.timeline.export(), (operation) => operation);
assert.equal(result.status, "recovered");
assert.equal(session.python.hash(), expected);
console.log(`python_session=passed hash=${expected}`);

View File

@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import { MemoryStorage, WorkspaceJournal } from "../workspace/journal.mjs";
import { verifyOfflinePackage } from "../workspace/migration.mjs";
import { ProjectWorkflow } from "../app/project-workflow.mjs";
import { ReplayTimeline } from "./replay.mjs";
import { createCheckpoint, recoverSimulation } from "../workspace/recovery.mjs";
const contextId = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
const storage = new MemoryStorage();
const journal = new WorkspaceJournal(storage, { projectId: "faults" });
journal.prepare("op-1", [{ path: "config/main.ini", data: "[EMC]\n" }]);
journal.commit("op-1");
const half = new MemoryStorage(Object.fromEntries(storage.entries()));
const halfJournal = new WorkspaceJournal(half, { projectId: "faults" });
halfJournal.prepare("op-half", [{ path: "programs/main.ngc", data: "G0 X1\n" }]);
assert.equal(halfJournal.recover().half_commits.length, 1);
const timeline = new ReplayTimeline();
timeline.appendEvent(1, 0, "SESSION_READY", {});
timeline.checkpoint(1, 0, { tick: 0 });
const checkpoint = createCheckpoint({ contextId, timeline, state: { tick: 0 }, operations: [] });
const damaged = structuredClone(checkpoint); damaged.state.tick = 9;
assert.equal(recoverSimulation({ checkpoint: damaged }).status, "blocked");
const workflow = new ProjectWorkflow({ contextId, storage: new MemoryStorage(), projectId: "offline" });
workflow.importFiles([{ path: "config/main.ini", data: "[EMC]\n" }]);
const pkg = workflow.package({ timeline, checkpoint, committedSeq: 1 });
const offline = workflow.offline(pkg, [{ path: "ui/index.html", data: "<main>" }]);
assert.throws(() => verifyOfflinePackage(offline, { contextId, workspacePackage: pkg, available: [], online: false }), /offline_resource_invalid/);
assert.equal(verifyOfflinePackage(offline, { contextId, workspacePackage: pkg, available: [], online: true }).status, "network_required");
console.log("recovery_offline_faults=passed scenarios=5");

View File

@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { ReplayTimeline } from "./replay.mjs";
import { VirtualDevices } from "./virtual-devices.mjs";
const commands = [
["SPINDLE_COMMAND", { spindle: 0, rpm: 1200, enabled: true }],
["IO_STEP", { ticks: 20 }],
["TOOL_PREPARE", { tool: 7, pocket: 3 }],
["TOOL_PREPARED", { prepared: true }],
["TOOL_CHANGE", {}],
["SPINDLE_COMMAND", { spindle: 0, rpm: 0, enabled: false }],
["IO_STEP", { ticks: 20 }],
["TOOL_CHANGE", {}],
["TOOL_CHANGED", { changed: true }],
["TOOL_ABORT", { reason: 17 }],
["TOOL_PREPARE", { tool: 7, pocket: 3 }],
["TOOL_PREPARED", { prepared: true }],
["TOOL_CHANGE", {}],
["TOOL_CHANGED", { changed: true }],
["PROBE_CONFIG", { action: "arm", probe_type: 0 }],
["PROBE_CONFIG", { action: "update", input: true, position: { x: 1, y: 2, z: 3 } }],
["LIMIT_SET", { name: "x-positive", active: true }],
["TOOL_FAULT", { fault: true, reason: 42 }],
["TOOL_CLEAR_FAULT", {}],
["TOOL_FAULT", { fault: false, reason: 0 }],
["TOOL_CLEAR_FAULT", {}],
["ESTOP", { active: true }],
["SPINDLE_COMMAND", { spindle: 0, rpm: 100, enabled: true }],
["ESTOP", { active: false }],
["IO_RECOVER", {}],
];
function run() {
const devices = new VirtualDevices();
const timeline = new ReplayTimeline();
const scenarios = [];
let seq = 0;
const nonfinite = devices.command("SPINDLE_COMMAND", { spindle: 0, rpm: Number.NaN, enabled: true });
scenarios.push({ kind: "NONFINITE_INPUT", code: nonfinite.code, status: nonfinite.status, state_hash: nonfinite.state_hash });
for (const [kind, payload] of commands) {
seq += 1;
const result = devices.command(kind, payload);
timeline.appendEvent(seq, devices.state.tick, kind, payload);
scenarios.push({ kind, code: result.code, status: result.status, state_hash: result.state_hash });
}
const replay = new VirtualDevices();
for (const event of timeline.events.events) replay.applyEvent(event.kind, event.payload);
return { schema: "virtual-device-fault-sample.v1", scenarios, events: timeline.events.cursor, state_hash: devices.hash(), replay_state_hash: replay.hash(), state_hash_equal: devices.hash() === replay.hash(), final: devices.export() };
}
const first = run();
const second = run();
assert.equal(first.state_hash_equal, true);
assert.equal(first.state_hash, first.replay_state_hash);
assert.deepEqual(first, second);
assert.equal(first.scenarios.find((row) => row.kind === "TOOL_CHANGE" && row.code === "sim_io_busy")?.status, "blocked");
assert.equal(first.scenarios.find((row) => row.kind === "SPINDLE_COMMAND" && row.code === "sim_io_estop")?.status, "blocked");
assert.equal(first.scenarios.find((row) => row.kind === "TOOL_CLEAR_FAULT" && row.code === "sim_io_fault")?.status, "blocked");
assert.equal(first.scenarios.find((row) => row.kind === "NONFINITE_INPUT")?.code, "sim_io_nonfinite");
console.log(JSON.stringify(first));

View File

@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
import { MemoryStorage, WorkspaceJournal } from "../workspace/journal.mjs";
import { createWorkspacePackage, migrateWorkspacePackage, verifyWorkspacePackage } from "../workspace/migration.mjs";
import { fnv1a64 } from "./replay.mjs";
let lastSeq = 0;
const core = {
_sim_protocol_reset() {},
_sim_protocol_accept(...args) { if (args[1] !== lastSeq + 1) return -101; lastSeq = args[1]; return 0; },
_sim_create() { return 0; },
_sim_load_workspace() { return 0; },
_sim_step() { return 0; },
_sim_protocol_set_tick() { return 0; },
};
const session = new SimSession(core);
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "workspace-session-1" };
const config = { entrypoint: "linuxcnc/configs/sim/demo/main.ini", availablePaths: ["linuxcnc/configs/sim/demo/main.ini"], rows: [] };
const hal = { manifest: [{ component: "demo" }], lines: ["loadrt demo names=d0", "addf d0 servo-thread"] };
assert.equal(session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} }).kind, "CAPABILITIES");
assert.equal(session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, payload: {} }).kind, "SESSION_READY");
assert.equal(session.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "load", payload: { root_path: "/workspace", configuration: config, hal } }).payload.status, "verified");
session.accept({ ...base, kind: "SPINDLE_COMMAND", seq: 4, operation_id: "spindle", payload: { spindle: 0, rpm: 600, enabled: true } });
session.accept({ ...base, kind: "IO_STEP", seq: 5, operation_id: "step-1", payload: { ticks: 5 } });
const checkpointResponse = session.accept({ ...base, kind: "CHECKPOINT", seq: 6, operation_id: "checkpoint", payload: {} });
assert.equal(checkpointResponse.kind, "CHECKPOINT_READY");
const checkpoint = checkpointResponse.payload.checkpoint;
assert.equal(checkpoint.state.configuration.status, "ready");
assert.equal(checkpoint.state.hal.status, "ready");
assert.ok(checkpoint.state.devices);
session.accept({ ...base, kind: "TOOL_PREPARE", seq: 7, operation_id: "tool", payload: { tool: 7, pocket: 3 } });
session.accept({ ...base, kind: "TOOL_PREPARED", seq: 8, operation_id: "tool-prepared", payload: { prepared: true } });
session.accept({ ...base, kind: "SPINDLE_COMMAND", seq: 9, operation_id: "spindle-stop", payload: { spindle: 0, rpm: 0, enabled: false } });
session.accept({ ...base, kind: "IO_STEP", seq: 10, operation_id: "step-2", payload: { ticks: 10 } });
session.accept({ ...base, kind: "TOOL_CHANGE", seq: 11, operation_id: "change", payload: {} });
session.accept({ ...base, kind: "TOOL_CHANGED", seq: 12, operation_id: "changed", payload: { changed: true } });
const expectedState = session.workspacePackageState();
const recovery = session.restoreCheckpoint(checkpoint, session.timeline.export(), (operation) => operation);
assert.equal(recovery.status, "recovered");
assert.equal(session.workspacePackageState().state_hash, expectedState.state_hash);
const storage = new MemoryStorage();
const journal = new WorkspaceJournal(storage, { projectId: "workspace-session" });
journal.prepare("op-config", [{ path: "config/main.ini", data: "[EMC]\n" }]);
journal.commit("op-config");
const timeline = session.timeline;
const packageV1 = createWorkspacePackage({ contextId: CONTEXT_ID, timeline, checkpoint, journal: journal.records(), committedSeq: journal.records().at(-1).commit_seq });
const packageV2 = migrateWorkspacePackage(packageV1, 2);
const roundTrip = migrateWorkspacePackage(packageV2, 1);
assert.equal(verifyWorkspacePackage(packageV2, { contextId: CONTEXT_ID, expectedVersion: 2 }).format_version, 2);
assert.deepEqual(roundTrip, packageV1);
const report = {
schema: "workspace-session-integration-sample.v1",
context_id: CONTEXT_ID,
session_state_hash: expectedState.state_hash,
recovery_state_hash: session.workspacePackageState().state_hash,
checkpoint_hash: checkpoint.checkpoint_hash,
package_v1_hash: packageV1.package_hash,
package_v2_hash: packageV2.package_hash,
round_trip_equal: roundTrip.package_hash === packageV1.package_hash,
event_cursor: session.timeline.events.cursor,
journal_hash: fnv1a64(journal.records()),
operation_count: session.operations.size,
};
assert.equal(report.session_state_hash, report.recovery_state_hash);
assert.equal(report.round_trip_equal, true);
console.log(JSON.stringify(report));

View File

@@ -0,0 +1,219 @@
import { fnv1a64 } from "./replay.mjs";
export const IO_CODES = Object.freeze({
ok: "sim_io_ok",
argument: "sim_io_argument",
range: "sim_io_range",
state: "sim_io_state",
not_ready: "sim_io_not_ready",
estop: "sim_io_estop",
fault: "sim_io_fault",
limit: "sim_io_limit",
nonfinite: "sim_io_nonfinite",
busy: "sim_io_busy",
probe: "sim_io_probe",
timeout: "sim_io_timeout",
recovery: "sim_io_recovery",
});
export const DEVICE_COMMANDS = Object.freeze(new Set([
"SPINDLE_COMMAND", "SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "IO_STEP",
"TOOL_PREPARE", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_CHANGED", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT",
"PROBE_CONFIG", "LIMIT_SET", "ESTOP", "IO_RECOVER",
]));
function clone(value) {
return structuredClone(value);
}
function finite(value) {
return typeof value === "number" && Number.isFinite(value);
}
function spindleState() {
return {
command_rpm: 0,
speed_rpm: 0,
at_speed: true,
enabled: false,
inhibit: false,
amp_fault: false,
brake: false,
orient: false,
locked: false,
is_oriented: false,
orient_angle: 0,
orient_mode: 0,
orient_fault: 0,
index_enable: false,
revs: 0,
};
}
function defaultState(spindleCount, toolCapacity) {
return {
ready: true,
estop: false,
fault: false,
fault_reason: 0,
tick: 0,
recovery_epoch: 0,
spindle_count: spindleCount,
tool_capacity: toolCapacity,
spindles: Array.from({ length: spindleCount }, spindleState),
tool: { state: "idle", current: 0, prepared: null, pocket: null, prepare: false, prepared_ack: false, changed: false, fault: false, reason: 0 },
probe: { state: "idle", type: 0, input: false, tripped: false, tick: 0, position: null },
limits: {},
last_error: IO_CODES.ok,
};
}
export class VirtualDevices {
constructor({ spindleCount = 1, toolCapacity = 1001, state = null } = {}) {
if (!Number.isSafeInteger(spindleCount) || spindleCount < 1 || spindleCount > 8) throw new Error("sim_io_spindle_capacity");
if (!Number.isSafeInteger(toolCapacity) || toolCapacity < 1 || toolCapacity > 1001) throw new Error("sim_io_tool_capacity");
this.state = state ? clone(state) : defaultState(spindleCount, toolCapacity);
}
export() { return clone(this.state); }
hash() { return fnv1a64(this.state); }
result(code = IO_CODES.ok, extra = {}) {
this.state.last_error = code;
return { status: code === IO_CODES.ok ? "verified" : "blocked", code, state_hash: this.hash(), state: this.export(), ...extra };
}
spindle(payload, action = "command") {
const index = payload?.spindle ?? 0;
const spindle = this.state.spindles[index];
if (!spindle) return this.result(IO_CODES.argument);
if (action === "command") {
const rpm = payload?.rpm;
if (!finite(rpm) || typeof payload?.enabled !== "boolean") return this.result(IO_CODES.nonfinite);
if (Math.abs(rpm) > 100000) return this.result(IO_CODES.limit);
if (this.state.estop) return this.result(IO_CODES.estop);
if (!this.state.ready) return this.result(IO_CODES.not_ready);
spindle.command_rpm = payload.enabled && !spindle.inhibit && !spindle.amp_fault ? rpm : 0;
spindle.enabled = payload.enabled;
} else if (action === "fault" || action === "inhibit") {
if (typeof payload?.value !== "boolean") return this.result(IO_CODES.argument);
spindle[action === "fault" ? "amp_fault" : "inhibit"] = payload.value;
if (payload.value) spindle.command_rpm = 0;
} else if (action === "orient") {
if (!finite(payload?.angle) || !Number.isSafeInteger(payload?.mode) || payload.mode < 0 || payload.mode > 2) return this.result(IO_CODES.range);
if (this.state.estop) return this.result(IO_CODES.estop);
if (Math.abs(spindle.speed_rpm) > 5 || spindle.command_rpm !== 0) return this.result(IO_CODES.busy);
spindle.orient = true; spindle.locked = false; spindle.is_oriented = false; spindle.orient_angle = payload.angle; spindle.orient_mode = payload.mode; spindle.orient_fault = 0;
} else if (action === "oriented") {
if (typeof payload?.oriented !== "boolean") return this.result(IO_CODES.argument);
if (!spindle.orient) return this.result(IO_CODES.state);
spindle.is_oriented = payload.oriented;
spindle.orient_fault = payload.fault ?? 0;
if (payload.fault) { spindle.orient = false; spindle.locked = false; }
else if (payload.oriented) { spindle.orient = false; spindle.locked = true; }
}
return this.result();
}
step(ticks) {
if (!Number.isSafeInteger(ticks) || ticks < 0 || ticks > 1000000) return this.result(IO_CODES.range);
for (let tick = 0; tick < ticks; tick += 1) {
this.state.tick += 1;
for (const spindle of this.state.spindles) {
const target = this.state.ready && !this.state.estop && !spindle.amp_fault && !spindle.inhibit ? spindle.command_rpm : 0;
const delta = 60;
if (spindle.speed_rpm < target) spindle.speed_rpm = Math.min(target, spindle.speed_rpm + delta);
else if (spindle.speed_rpm > target) spindle.speed_rpm = Math.max(target, spindle.speed_rpm - delta);
spindle.at_speed = Math.abs(spindle.speed_rpm - target) <= 5;
spindle.brake = target === 0 && Math.abs(spindle.speed_rpm) > 5;
spindle.revs += spindle.speed_rpm * 0.1 / 60;
if (spindle.orient && spindle.at_speed && spindle.speed_rpm === 0) { spindle.orient = false; spindle.locked = true; spindle.is_oriented = true; }
}
}
return this.result(IO_CODES.ok, { ticks });
}
toolAction(kind, payload = {}) {
const tool = this.state.tool;
if (kind === "TOOL_PREPARE") {
if (!Number.isSafeInteger(payload.tool) || !Number.isSafeInteger(payload.pocket) || payload.tool < 0 || payload.pocket <= 0 || payload.pocket >= this.state.tool_capacity) return this.result(IO_CODES.range);
if (this.state.estop) return this.result(IO_CODES.estop);
if (!this.state.ready) return this.result(IO_CODES.not_ready);
if (this.state.fault) return this.result(IO_CODES.fault);
Object.assign(tool, { state: "preparing", prepared: payload.tool, pocket: payload.pocket, prepare: true, prepared_ack: false, changed: false });
} else if (kind === "TOOL_PREPARED") {
if (tool.state !== "preparing") return this.result(IO_CODES.state);
if (payload.prepared !== true) return this.result();
Object.assign(tool, { state: "prepared", prepare: false, prepared_ack: true });
} else if (kind === "TOOL_CHANGE") {
if (this.state.estop) return this.result(IO_CODES.estop);
if (!this.state.ready) return this.result(IO_CODES.not_ready);
if (this.state.fault) return this.result(IO_CODES.fault);
if (tool.state !== "prepared" || !tool.prepared_ack) return this.result(IO_CODES.state);
if (this.state.spindles.some((spindle) => Math.abs(spindle.speed_rpm) > 5 || spindle.command_rpm !== 0)) return this.result(IO_CODES.busy);
Object.assign(tool, { state: "changing", changed: false });
} else if (kind === "TOOL_CHANGED") {
if (tool.state !== "changing") return this.result(IO_CODES.state);
if (payload.changed !== true) return this.result();
Object.assign(tool, { state: "ready", current: tool.prepared, changed: true, prepared_ack: false });
} else if (kind === "TOOL_ABORT") {
Object.assign(tool, { state: "aborted", prepare: false, prepared_ack: false, changed: false });
this.state.last_error = IO_CODES.recovery;
} else if (kind === "TOOL_FAULT") {
if (typeof payload.fault !== "boolean") return this.result(IO_CODES.argument);
tool.fault = payload.fault; tool.reason = payload.reason ?? 0; this.state.fault = payload.fault; this.state.fault_reason = tool.reason;
if (payload.fault) Object.assign(tool, { state: "fault", prepare: false, prepared_ack: false, changed: false });
} else if (kind === "TOOL_CLEAR_FAULT") {
if (tool.fault) return this.result(IO_CODES.fault);
this.state.fault = false; this.state.fault_reason = 0;
if (tool.state === "fault" || tool.state === "aborted") tool.state = "idle";
}
return this.result();
}
probeAction(payload = {}) {
const probe = this.state.probe;
if (payload.action === "arm") {
if (this.state.estop) return this.result(IO_CODES.estop);
if (!this.state.ready) return this.result(IO_CODES.not_ready);
Object.assign(probe, { state: "armed", type: payload.probe_type ?? 0, input: false, tripped: false, tick: this.state.tick, position: null });
} else if (payload.action === "update") {
const position = payload.position;
if (!position || !Object.values(position).every(finite)) return this.result(IO_CODES.nonfinite);
probe.input = Boolean(payload.input);
if (probe.state === "armed" && probe.input) Object.assign(probe, { state: "tripped", tripped: true, tick: this.state.tick, position: clone(position) });
} else if (payload.action === "disarm") {
Object.assign(probe, { state: "idle", type: 0, tripped: false });
} else return this.result(IO_CODES.argument);
return this.result();
}
command(kind, payload = {}) {
if (kind === "SPINDLE_COMMAND") return this.spindle(payload, "command");
if (kind === "SPINDLE_FAULT") return this.spindle(payload, "fault");
if (kind === "SPINDLE_INHIBIT") return this.spindle(payload, "inhibit");
if (kind === "SPINDLE_ORIENT") return this.spindle(payload, "orient");
if (kind === "SPINDLE_ORIENTED") return this.spindle(payload, "oriented");
if (kind === "IO_STEP") return this.step(payload.ticks);
if (kind.startsWith("TOOL_")) return this.toolAction(kind, payload);
if (kind === "PROBE_CONFIG") return this.probeAction(payload);
if (kind === "LIMIT_SET") {
if (typeof payload.name !== "string" || typeof payload.active !== "boolean") return this.result(IO_CODES.argument);
this.state.limits[payload.name] = payload.active; return this.result();
}
if (kind === "ESTOP") {
if (typeof payload.active !== "boolean") return this.result(IO_CODES.argument);
this.state.estop = payload.active;
if (payload.active) { this.state.ready = false; this.state.tool.state = "aborted"; for (const spindle of this.state.spindles) { spindle.command_rpm = 0; spindle.enabled = false; spindle.brake = true; } return this.result(IO_CODES.estop); }
return this.result();
}
if (kind === "IO_RECOVER") {
if (this.state.estop) return this.result(IO_CODES.estop);
if (this.state.fault || this.state.tool.fault) return this.result(IO_CODES.fault);
if (this.state.spindles.some((spindle) => Math.abs(spindle.speed_rpm) > 5 || spindle.command_rpm !== 0)) return this.result(IO_CODES.busy);
this.state.ready = true; this.state.recovery_epoch += 1; this.state.tool.state = "idle"; return this.result();
}
return this.result(IO_CODES.argument);
}
applyEvent(kind, payload) { return this.command(kind, payload); }
}

View File

@@ -0,0 +1,145 @@
const CONFIGURATION_SCHEMA = "configuration-startup-closure.v1";
export const CONFIGURATION_ERRORS = Object.freeze({
invalid_reference: "configuration_reference_invalid",
empty_reference: "configuration_reference_empty",
host_path: "configuration_host_path",
outside_repo: "configuration_outside_repo",
path_escape: "configuration_path_escape",
missing: "configuration_resource_missing",
ambiguous: "configuration_resource_ambiguous",
unavailable: "configuration_resource_unavailable",
});
export const LIBRARY_ROOTS = Object.freeze([
"linuxcnc/lib/hallib",
"linuxcnc/lib",
]);
function asPathSet(paths) {
if (paths instanceof Set) return paths;
if (Array.isArray(paths)) return new Set(paths);
if (paths && typeof paths === "object") return new Set(Object.keys(paths));
return new Set();
}
function normalizePath(value) {
if (typeof value !== "string" || value.length === 0) {
return { status: "blocked", code: CONFIGURATION_ERRORS.invalid_reference, path: null };
}
if (value.includes("\0")) {
return { status: "blocked", code: CONFIGURATION_ERRORS.invalid_reference, path: null };
}
const parts = [];
for (const part of value.replaceAll("\\", "/").split("/")) {
if (!part || part === ".") continue;
if (part === "..") {
if (parts.length === 0) return { status: "blocked", code: CONFIGURATION_ERRORS.path_escape, path: null };
parts.pop();
continue;
}
parts.push(part);
}
return { status: "accepted", code: null, path: parts.join("/") };
}
function dirname(path) {
const index = path.lastIndexOf("/");
return index < 0 ? "" : path.slice(0, index);
}
function joinPath(base, value) {
return normalizePath(base ? `${base}/${value}` : value);
}
export function splitReference(rawValue) {
if (typeof rawValue !== "string") {
return { status: "blocked", code: CONFIGURATION_ERRORS.invalid_reference, value: null, args: [] };
}
const raw = rawValue.trim().replace(/^['"]|['"]$/g, "");
if (!raw) return { status: "blocked", code: CONFIGURATION_ERRORS.empty_reference, value: null, args: [] };
const tokens = raw.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
if (tokens.length === 0) return { status: "blocked", code: CONFIGURATION_ERRORS.empty_reference, value: null, args: [] };
const value = tokens[0].replace(/^['"]|['"]$/g, "");
const args = tokens.slice(1).map((token) => token.replace(/^['"]|['"]$/g, ""));
return { status: "accepted", code: null, raw, value, args };
}
export function resolveReference({ rawValue, entrypoint, availablePaths = [], libraryRoots = LIBRARY_ROOTS } = {}) {
const parsed = splitReference(rawValue);
if (parsed.status !== "accepted") return { ...parsed, path: null, attempted_paths: [] };
if (typeof entrypoint !== "string" || entrypoint.length === 0) {
return { status: "blocked", code: CONFIGURATION_ERRORS.invalid_reference, value: parsed.value, args: parsed.args, path: null, attempted_paths: [] };
}
if (parsed.value.startsWith("~") || parsed.value.startsWith("$") || parsed.value.startsWith("${")) {
return { status: "blocked", code: CONFIGURATION_ERRORS.host_path, value: parsed.value, args: parsed.args, path: null, attempted_paths: [] };
}
const available = asPathSet(availablePaths);
const entry = normalizePath(entrypoint);
if (entry.status !== "accepted") return { ...entry, value: parsed.value, args: parsed.args, attempted_paths: [] };
let candidates;
let resolution = "relative";
if (parsed.value.startsWith("LIB:")) {
resolution = "library";
const libraryName = parsed.value.slice("LIB:".length);
if (!libraryName || libraryName.startsWith("/") || libraryName.includes("..")) {
return { status: "blocked", code: CONFIGURATION_ERRORS.invalid_reference, value: parsed.value, args: parsed.args, path: null, attempted_paths: [] };
}
candidates = libraryRoots.map((root) => normalizePath(`${root}/${libraryName}`)).filter((item) => item.status === "accepted").map((item) => item.path);
} else if (parsed.value.startsWith("/")) {
return { status: "blocked", code: CONFIGURATION_ERRORS.outside_repo, value: parsed.value, args: parsed.args, path: null, attempted_paths: [] };
} else if (parsed.value.startsWith("linuxcnc/")) {
resolution = "repo_relative";
const normalized = normalizePath(parsed.value);
if (normalized.status !== "accepted") return { ...normalized, value: parsed.value, args: parsed.args, attempted_paths: [] };
candidates = [normalized.path];
} else {
const normalized = joinPath(dirname(entry.path), parsed.value);
if (normalized.status !== "accepted") return { ...normalized, value: parsed.value, args: parsed.args, attempted_paths: [] };
if (normalized.path !== "linuxcnc" && !normalized.path.startsWith("linuxcnc/")) {
return { status: "blocked", code: CONFIGURATION_ERRORS.outside_repo, value: parsed.value, args: parsed.args, path: null, attempted_paths: [normalized.path], resolution };
}
candidates = [normalized.path];
}
const existing = candidates.filter((candidate) => available.has(candidate));
if (existing.length > 1) {
return { status: "blocked", code: CONFIGURATION_ERRORS.ambiguous, value: parsed.value, args: parsed.args, path: null, attempted_paths: candidates, resolution };
}
if (existing.length === 1) {
return { status: "resolved", code: null, value: parsed.value, args: parsed.args, path: existing[0], attempted_paths: candidates, resolution };
}
return { status: "blocked", code: CONFIGURATION_ERRORS.missing, value: parsed.value, args: parsed.args, path: null, attempted_paths: candidates, resolution };
}
export function resolveResourceRow(row, availablePaths, options = {}) {
if (!row || typeof row !== "object") return { status: "blocked", code: CONFIGURATION_ERRORS.invalid_reference, path: null };
const result = resolveReference({
rawValue: row.raw_value ?? row.path,
entrypoint: row.entrypoint,
availablePaths,
...options,
});
return {
resource_source_id: row.resource_source_id ?? null,
entrypoint: row.entrypoint ?? null,
original_path: row.path ?? null,
original_status: row.parse_status ?? null,
...result,
};
}
export function preflightConfiguration({ entrypoint, rows = [], availablePaths = [], libraryRoots = LIBRARY_ROOTS } = {}) {
const resolved = rows.map((row) => resolveResourceRow(row, availablePaths, { libraryRoots }));
const blocked = resolved.filter((row) => row.status !== "resolved");
return {
schema: CONFIGURATION_SCHEMA,
entrypoint,
status: blocked.length === 0 ? "ready" : "blocked",
resource_count: resolved.length,
resolved_count: resolved.length - blocked.length,
blocked_count: blocked.length,
resources: resolved,
};
}
export { CONFIGURATION_SCHEMA };

View File

@@ -0,0 +1,49 @@
export const HAL_CONFIGURATION_SCHEMA = "hal-configuration-preflight.v1";
const BUILTIN_THREADS = new Set(["servo-thread", "base-thread"]);
function text(value) {
return typeof value === "string" && value.length > 0 ? value : null;
}
export function parseHalLine(raw, line = 0) {
if (typeof raw !== "string") return { status: "blocked", code: "hal_line_invalid", line };
const clean = raw.split("#", 1)[0].trim();
if (!clean) return null;
const tokens = clean.split(/\s+/);
const command = tokens[0];
if (!["loadrt", "loadusr", "addf", "net", "setp", "source", "newsig", "linksp", "linkps", "linkpp"].includes(command)) return null;
if (command === "loadrt") {
const module = tokens[1] ?? "";
const namesToken = tokens.find((token) => token.startsWith("names="));
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, module, instances: namesToken ? namesToken.slice(6).split(",") : [], status: module.startsWith("[") ? "blocked" : "declared", code: module.startsWith("[") ? "hal_component_variable" : null };
}
if (command === "loadusr") return { schema: HAL_CONFIGURATION_SCHEMA, line, command, status: "blocked", code: "hal_host_dynamic_loader", program: tokens[1] ?? "" };
if (command === "addf") {
const thread = tokens[2] ?? "";
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, function: tokens[1] ?? null, thread, status: text(thread) && BUILTIN_THREADS.has(thread) ? "checked" : "blocked", code: text(thread) && BUILTIN_THREADS.has(thread) ? null : "hal_thread_undeclared" };
}
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, tokens: tokens.slice(1), status: "checked", code: null };
}
export function preflightHalConfiguration({ lines = [], manifest = [] } = {}) {
const declared = new Set((Array.isArray(manifest) ? manifest : []).map((row) => row?.component).filter(Boolean));
const records = [];
for (let index = 0; index < lines.length; index += 1) {
const parsed = parseHalLine(lines[index], index + 1);
if (!parsed) continue;
if (parsed.command === "loadrt" && parsed.status === "declared" && !declared.has(parsed.module)) {
parsed.status = "blocked";
parsed.code = "hal_component_undeclared";
}
records.push(parsed);
}
const blocked = records.filter((record) => record.status === "blocked");
return {
schema: HAL_CONFIGURATION_SCHEMA,
status: blocked.length === 0 ? "ready" : "blocked",
records,
record_count: records.length,
blocked_count: blocked.length,
};
}

View File

@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import { preflightConfiguration, resolveReference, splitReference } from "./configuration.mjs";
assert.deepEqual(splitReference("LIB:basic_sim.tcl -no_sim_spindle"), {
status: "accepted",
code: null,
raw: "LIB:basic_sim.tcl -no_sim_spindle",
value: "LIB:basic_sim.tcl",
args: ["-no_sim_spindle"],
});
const available = new Set([
"linuxcnc/lib/hallib/basic_sim.tcl",
"linuxcnc/configs/sim/demo/core.hal",
"linuxcnc/nc_files/probe.ngc",
]);
assert.equal(resolveReference({ rawValue: "LIB:basic_sim.tcl -no_sim_spindle", entrypoint: "linuxcnc/configs/sim/demo/main.ini", availablePaths: available }).path, "linuxcnc/lib/hallib/basic_sim.tcl");
assert.equal(resolveReference({ rawValue: "core.hal", entrypoint: "linuxcnc/configs/sim/demo/main.ini", availablePaths: available }).path, "linuxcnc/configs/sim/demo/core.hal");
assert.equal(resolveReference({ rawValue: "linuxcnc/nc_files/probe.ngc", entrypoint: "linuxcnc/configs/sim/demo/main.ini", availablePaths: available }).path, "linuxcnc/nc_files/probe.ngc");
assert.equal(resolveReference({ rawValue: "~/linuxcnc/nc_files", entrypoint: "linuxcnc/configs/sim/demo/main.ini", availablePaths: available }).code, "configuration_host_path");
assert.equal(resolveReference({ rawValue: "../../../../etc/passwd", entrypoint: "linuxcnc/configs/sim/demo/main.ini", availablePaths: available }).code, "configuration_outside_repo");
const report = preflightConfiguration({
entrypoint: "linuxcnc/configs/sim/demo/main.ini",
availablePaths: available,
rows: [
{ resource_source_id: "r1", entrypoint: "linuxcnc/configs/sim/demo/main.ini", path: "LIB:basic_sim.tcl -no_sim_spindle", raw_value: "LIB:basic_sim.tcl -no_sim_spindle", parse_status: "missing" },
{ resource_source_id: "r2", entrypoint: "linuxcnc/configs/sim/demo/main.ini", path: "missing.hal", raw_value: "missing.hal", parse_status: "missing" },
],
});
assert.equal(report.schema, "configuration-startup-closure.v1");
assert.equal(report.status, "blocked");
assert.equal(report.resolved_count, 1);
assert.equal(report.blocked_count, 1);
console.log(`configuration_resolver=passed resolved=${report.resolved_count} blocked=${report.blocked_count}`);

View File

@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import { preflightHalConfiguration } from "./hal-configuration.mjs";
const report = preflightHalConfiguration({
manifest: [{ component: "demo" }],
lines: ["loadrt demo names=d0", "addf d0 servo-thread", "loadusr -W external"],
});
assert.equal(report.schema, "hal-configuration-preflight.v1");
assert.equal(report.status, "blocked");
assert.equal(report.blocked_count, 1);
assert.equal(report.records[2].code, "hal_host_dynamic_loader");
console.log(`hal_preflight=passed records=${report.record_count} blocked=${report.blocked_count}`);