Implement AXIS parity UI and execution baseline

This commit is contained in:
2026-08-23 07:28:37 +08:00
parent 1325504544
commit 06f96f85e5
463 changed files with 84893 additions and 692 deletions

View File

@@ -6,9 +6,13 @@ import { renderRecoveryPanel } from "./recovery-panel.mjs";
import { renderDevicePanel } from "./device-panel.mjs";
import { renderHalPanel } from "./hal-panel.mjs";
import { renderMachineView } from "./machine-view.mjs";
import { SIM_CASE_CATALOG } from "../workspace/sim-case-catalog.mjs";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { renderDisplayFamily } from "./display-family.mjs";
import { appendAxisIcon } from "./source-assets.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"] };
const SAMPLE_PROGRAM = ["G21 G90", "G0 X0 Y0 Z0", "G1 X10 Y0 F120", "G1 X10 Y10", "G1 X0 Y10", "G1 X0 Y0", "M2"].join("\n");
const DEFAULT_CASE = "linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini";
function element(document, tag, className, text = "") {
const node = document.createElement(tag);
@@ -23,35 +27,145 @@ function statusLine(document) {
return node;
}
function axisButton(document, label, asset, className = "control-button") {
const button = element(document, "button", className);
button.type = "button";
return appendAxisIcon(document, button, asset, label);
}
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 };
let connected = false;
let loadedCase = null;
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 sourceMark = element(document, "span", "source-mark", "AXIS");
sourceMark.title = "AXIS source controls";
header.append(sourceMark);
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 sessionPanel = element(document, "section", "panel operator-panel");
sessionPanel.append(element(document, "h1", "panel-title", "AXIS control station"));
const status = statusLine(document);
sessionPanel.append(status);
let clock = null;
let clockBusy = false;
const stopClock = () => {
if (clock !== null) globalThis.clearInterval(clock);
clock = null;
clockBusy = false;
};
const startClock = () => {
if (clock !== null) return;
clock = globalThis.setInterval(async () => {
if (clockBusy) return;
clockBusy = true;
try {
const response = await workerClient.step(1);
const program = response.payload?.program;
status.textContent = program ? `Tick ${response.simulation_tick} | line ${program.line}/${program.line_count}` : `Tick ${response.simulation_tick}`;
if (program && ["completed", "aborted", "error"].includes(program.program_status)) stopClock();
} catch (error) {
stopClock();
status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`;
} finally {
clockBusy = false;
}
}, 50);
};
const ensureSession = async () => {
if (connected) return;
await workerClient.hello();
await workerClient.attach();
connected = true;
};
const casePanel = element(document, "section", "case-browser");
casePanel.append(element(document, "h2", "panel-title", "LinuxCNC sim cases"));
const caseSearch = element(document, "input", "case-search");
caseSearch.type = "search";
caseSearch.placeholder = "Filter cases";
caseSearch.setAttribute("aria-label", "Filter LinuxCNC simulation cases");
const caseSelect = element(document, "select", "case-select");
caseSelect.setAttribute("aria-label", "LinuxCNC simulation case");
const caseSummary = element(document, "p", "case-summary", "");
const caseDiagnostics = element(document, "pre", "case-diagnostics", "");
caseDiagnostics.setAttribute("aria-live", "polite");
const caseLoadButton = axisButton(document, "Load selected case", "tool_open.gif", "control-button primary-control");
const renderCases = () => {
const query = caseSearch.value.trim().toLowerCase();
const selected = caseSelect.value || DEFAULT_CASE;
caseSelect.replaceChildren();
for (const entry of SIM_CASE_CATALOG) {
if (query && !`${entry.path} ${entry.label} ${entry.category} ${entry.kinematics ?? ""}`.toLowerCase().includes(query)) continue;
const option = element(document, "option");
option.value = entry.path;
option.textContent = `${entry.status === "ready" ? "STATIC READY" : "BLOCKED"} | ${entry.label} | ${entry.path.replace("linuxcnc/configs/sim/", "")}`;
caseSelect.append(option);
}
if ([...caseSelect.options].some((option) => option.value === selected)) caseSelect.value = selected;
else if (caseSelect.options.length) caseSelect.selectedIndex = 0;
updateCaseSummary();
};
const updateCaseSummary = () => {
const entry = SIM_CASE_CATALOG.find((row) => row.path === caseSelect.value);
if (!entry) { caseSummary.textContent = "No matching case"; return; }
caseSummary.textContent = `${entry.status === "ready" ? "STATIC READY" : "BLOCKED"} | ${entry.coordinates ?? "coordinates in INI/include"} | ${entry.kinematics ?? "kinematics in INI/include"} | ${entry.missing_count} indexed missing resources`;
};
caseSearch.addEventListener("input", renderCases);
let displayFamilyPanel = renderDisplayFamily(document, { casePath: DEFAULT_CASE });
caseSelect.addEventListener("change", () => {
updateCaseSummary();
const nextDisplayFamilyPanel = renderDisplayFamily(document, { casePath: caseSelect.value });
displayFamilyPanel.replaceWith(nextDisplayFamilyPanel);
displayFamilyPanel = nextDisplayFamilyPanel;
});
renderCases();
casePanel.append(caseSearch, caseSelect, caseSummary, caseLoadButton, caseDiagnostics, displayFamilyPanel);
caseLoadButton.addEventListener("click", async () => {
caseLoadButton.disabled = true;
caseDiagnostics.textContent = "Loading case files...";
try {
await ensureSession();
const bundle = await loadSimulationCase({ path: caseSelect.value });
loadedCase = bundle;
caseDiagnostics.textContent = JSON.stringify({ status: bundle.status, entrypoint: bundle.entrypoint, fetched: bundle.fetched.length, missing: bundle.missing, state_hash: bundle.state_hash }, null, 2);
const response = await workerClient.loadWorkspace("/workspace", toWorkerConfiguration(bundle));
status.textContent = `Case loaded: ${bundle.entrypoint} (${response.payload?.configuration?.state ?? "ready"})`;
} catch (error) {
const response = error.response?.payload;
caseDiagnostics.textContent = JSON.stringify(response?.startup ?? response?.preflight ?? { code: error.response?.payload?.code ?? error.message }, null, 2);
status.textContent = `Case blocked: ${error.response?.payload?.code ?? error.message}`;
} finally {
caseLoadButton.disabled = false;
}
});
sessionPanel.append(casePanel);
const programLabel = element(document, "label", "program-label", "G-code program");
const programEditor = element(document, "textarea", "program-editor");
programEditor.value = SAMPLE_PROGRAM;
programEditor.rows = 10;
programEditor.spellcheck = false;
programEditor.setAttribute("aria-label", "G-code program");
programLabel.append(programEditor);
sessionPanel.append(programLabel);
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}`; }],
["Connect", "tool_power.gif", async () => { await ensureSession(); status.textContent = "Session ready"; }],
["Load program", "tool_open.gif", async () => { const response = await workerClient.loadProgram(programEditor.value, "demo.ngc"); status.textContent = `Program loaded (${response.payload.line_count} lines)`; }],
["Step 1 tick", "tool_step.gif", async () => { const response = await workerClient.step(1); status.textContent = `Tick ${response.simulation_tick}`; }],
["Snapshot", "tool_verify.gif", 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";
for (const [label, asset, action] of buttons) {
const button = axisButton(document, label, asset);
button.addEventListener("click", () => Promise.resolve(action()).catch((error) => {
status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`;
}));
@@ -60,49 +174,113 @@ export function mountApp({ document = globalThis.document, client = null } = {})
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", {}],
["Run", "tool_run.gif", "run", {}, startClock],
["Restart", "tool_reload.gif", "restart", {}, stopClock],
["Pause", "tool_pause.gif", "pause", {}, stopClock],
["Resume", "resume_inhibit.gif", "resume", {}, startClock],
["Abort", "tool_stop.gif", "abort", {}, stopClock],
["Home", "tool_clear.gif", "home", {}],
["E-stop", "tool_estop.gif", "estop", { active: true }],
["Recover", "tool_power.gif", "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) => {
for (const [label, asset, action, payload, after] of operationButtons) {
const button = axisButton(document, label, asset, action === "estop" ? "control-button danger-control" : "control-button");
button.addEventListener("click", () => controller.execute(action, payload).then(() => after?.()).catch((error) => {
stopClock();
status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`;
}));
operationControls.append(button);
}
sessionPanel.append(operationControls);
const mdiLabel = element(document, "label", "inline-field", "MDI");
const mdiEditor = element(document, "input", "mdi-editor");
mdiEditor.value = "G0 X0";
mdiEditor.setAttribute("aria-label", "MDI command");
mdiLabel.append(mdiEditor);
const mdiButton = axisButton(document, "Execute MDI", "tool_step.gif");
mdiButton.addEventListener("click", () => controller.execute("mdi", { block: mdiEditor.value }).catch((error) => { status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`; }));
const manual = element(document, "div", "manual-controls");
manual.append(element(document, "h2", "panel-title", "Manual control"));
const axisSelect = element(document, "select", "axis-select");
axisSelect.setAttribute("aria-label", "Jog axis");
for (const axis of ["x", "y", "z"]) { const option = element(document, "option"); option.value = axis; option.textContent = axis.toUpperCase(); axisSelect.append(option); }
const jogSpeed = element(document, "input", "jog-speed");
jogSpeed.type = "number"; jogSpeed.min = "0.01"; jogSpeed.max = "100"; jogSpeed.step = "0.01"; jogSpeed.value = "1"; jogSpeed.setAttribute("aria-label", "Jog speed");
const homeButton = axisButton(document, "Home all", "tool_clear.gif");
homeButton.addEventListener("click", () => controller.execute("home", {}).catch((error) => { status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`; }));
const jogStop = axisButton(document, "Stop jog", "tool_stop.gif");
jogStop.addEventListener("click", () => controller.execute("jogStop", {}));
const jogButtons = element(document, "div", "jog-buttons");
for (const [label, direction] of [["-", -1], ["+", 1]]) {
const button = axisButton(document, `${label} jog`, label === "+" ? "tool_axis_x.gif" : "tool_axis_z.gif");
button.addEventListener("pointerdown", () => controller.execute("jogStart", { axis: axisSelect.value, direction, speed: Number(jogSpeed.value) }).catch((error) => { status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`; }));
button.addEventListener("pointerup", () => controller.execute("jogStop", {}));
button.addEventListener("pointerleave", () => controller.execute("jogStop", {}));
jogButtons.append(button);
}
const feedOverride = element(document, "input", "override-slider");
feedOverride.type = "range"; feedOverride.min = "0"; feedOverride.max = "2"; feedOverride.step = "0.01"; feedOverride.value = "1"; feedOverride.setAttribute("aria-label", "Feed override");
const spindleOverride = element(document, "input", "override-slider");
spindleOverride.type = "range"; spindleOverride.min = "0"; spindleOverride.max = "2"; spindleOverride.step = "0.01"; spindleOverride.value = "1"; spindleOverride.setAttribute("aria-label", "Spindle override");
const overrideButton = axisButton(document, "Apply overrides", "tool_verify.gif");
overrideButton.addEventListener("click", () => controller.execute("override", { feed: Number(feedOverride.value), spindle: Number(spindleOverride.value) }));
manual.append(mdiLabel, mdiButton, axisSelect, jogSpeed, jogButtons, homeButton, jogStop, element(document, "label", "inline-field", "Feed override"), feedOverride, element(document, "label", "inline-field", "Spindle override"), spindleOverride, overrideButton);
sessionPanel.append(manual);
content.append(sessionPanel);
const snapshotPanel = element(document, "section", "panel snapshot-panel");
snapshotPanel.append(element(document, "h2", "panel-title", "Authoritative state"));
const snapshotPanel = element(document, "section", "panel snapshot-panel dro-panel");
snapshotPanel.append(element(document, "h2", "panel-title", "DRO / 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" });
const recoveryPanel = renderRecoveryPanel(document, { status: "idle" }, {
retry: () => workerClient.snapshot().catch(() => {}),
retry_online: () => workerClient.snapshot().catch(() => {}),
abort: () => controller.execute("abort", {}, {}).catch(() => {}),
});
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 }));
let devicePanel = renderDevicePanel(document, { controller, snapshot: {} });
content.append(devicePanel);
let halPanel = renderHalPanel(document, { components: 145, blocked: [] }, []);
content.append(halPanel);
let machinePanel = renderMachineView(document, { pose: { x: 0, y: 0, z: 0 }, stateHash: null });
content.append(machinePanel);
shell.append(content);
root.append(shell);
workerClient.onMessage((message) => {
workerState = reduceWorkerState(workerState, message);
const replacement = renderStatePanel(document, workerState);
statePanel.replaceChildren(...replacement.childNodes);
const program = workerState.program;
const motion = workerState.motion?.state ?? workerState.motion ?? {};
if (program?.state || motion.axes) {
const nextMachinePanel = renderMachineView(document, {
pose: motion.axes ?? program?.state ?? { x: 0, y: 0, z: 0 },
joints: motion.joints ?? [],
stateHash: motion.state_hash ?? program?.source_hash,
});
machinePanel.replaceWith(nextMachinePanel);
machinePanel = nextMachinePanel;
}
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"}`;
if (message.kind === "ERROR") {
stopClock();
status.textContent = `Error: ${message.payload?.code ?? "worker_error"}`;
}
if (message.kind === "COMMAND_RESULT" || message.kind === "STATE_SNAPSHOT") {
const nextDevicePanel = renderDevicePanel(document, { controller, snapshot: workerState.devices ?? {} });
devicePanel.replaceWith(nextDevicePanel);
devicePanel = nextDevicePanel;
const nextHalPanel = renderHalPanel(document, { components: 145, blocked: workerState.hal?.records?.filter((row) => row.status === "blocked") ?? [] }, workerState.hal?.records ?? []);
halPanel.replaceWith(nextHalPanel);
halPanel = nextHalPanel;
}
});
return { client: workerClient, controller, project, root: shell, status, state, recoveryPanel, get workerState() { return workerState; } };
return { client: workerClient, controller, project, root: shell, status, state, programEditor, recoveryPanel, caseSelect, caseLoadButton, caseDiagnostics, mdiEditor, get workerState() { return workerState; } };
}
if (typeof document !== "undefined" && document.querySelector("#app")) mountApp();

View File

@@ -0,0 +1,27 @@
export const BROWSER_COMPATIBILITY_SCHEMA = "browser-simulation-compatibility.v1";
export async function runCompatibilityMatrix(client, { cycles = 8, chunk = 2 } = {}) {
if (!client || typeof client.hello !== "function" || typeof client.attach !== "function" || typeof client.stepMany !== "function") throw new Error("browser_client_invalid");
if (!Number.isSafeInteger(cycles) || cycles < 0 || cycles > 1000000 || !Number.isSafeInteger(chunk) || chunk < 1) throw new Error("browser_matrix_limit");
const capabilities = await client.hello();
const ready = await client.attach("compat-attach");
const stepped = await client.stepMany(cycles, { chunk, operationPrefix: "compat-step" });
const snapshot = await client.snapshot("compat-snapshot");
return {
schema: BROWSER_COMPATIBILITY_SCHEMA,
status: "verified",
context_id: client.contextId,
session_id: client.sessionId,
capabilities: capabilities.kind,
session_ready: ready.kind,
cycles,
chunks: stepped.chunks,
snapshot_kind: snapshot.kind,
logical_tick: snapshot.simulation_tick ?? stepped.last?.simulation_tick ?? 0,
};
}
export function performanceBudget({ cycles, chunks, maxCycles = 1000000, maxChunks = 1000000 } = {}) {
if (!Number.isSafeInteger(cycles) || cycles < 0 || cycles > maxCycles || !Number.isSafeInteger(chunks) || chunks < 0 || chunks > maxChunks) return { status: "blocked", code: "browser_performance_budget" };
return { status: "verified", cycles, chunks, logical_only: true };
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<meta charset="utf-8">
<title>LinuxCNC Web/WASM OPFS probe</title>
<output id="result">pending</output>
<script type="module">
const result = document.querySelector("#result");
const worker = new Worker("./browser-opfs-worker.mjs", { type: "module" });
worker.onmessage = ({ data }) => { result.textContent = JSON.stringify(data); worker.terminate(); };
worker.onerror = (error) => { result.textContent = JSON.stringify({ status: "blocked", code: "opfs_worker_error", reason: error.message ?? "worker_error" }); worker.terminate(); };
const query = new URLSearchParams(location.search);
setTimeout(() => worker.postMessage({ project_id: query.get("project_id") ?? "s6-05-opfs", mode: query.get("mode") ?? "round_trip", cleanup: query.get("cleanup") !== "false" }), 50);
setTimeout(() => { if (result.textContent === "pending") result.textContent = JSON.stringify({ schema: "opfs-runtime.v1", status: "blocked", code: "opfs_worker_timeout" }); }, 3000);
</script>

View File

@@ -0,0 +1,10 @@
import { runOpfsProbe } from "../workspace/opfs-runtime.mjs";
self.onmessage = async (event) => {
try {
const result = await runOpfsProbe({ storage: self.navigator?.storage, projectId: event.data?.project_id ?? "s6-05-opfs", mode: event.data?.mode ?? "round_trip", cleanup: event.data?.cleanup !== false });
self.postMessage(result);
} catch (error) {
self.postMessage({ schema: "opfs-runtime.v1", status: "blocked", code: "opfs_worker_exception", reason: error?.name ?? "unknown" });
}
};

View File

@@ -3,7 +3,8 @@ const CONTROL_COMMANDS = Object.freeze({
pause: "PROGRAM_PAUSE",
resume: "PROGRAM_RESUME",
abort: "PROGRAM_ABORT",
mdi: "COMMAND",
mdi: "MDI_EXECUTE",
restart: "PROGRAM_RESTART",
jogStart: "JOG_START",
jogStop: "JOG_STOP",
home: "HOME",

View File

@@ -1,9 +1,12 @@
import { appendAxisIcon } from "./source-assets.mjs";
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); }
const assets = { "Spindle start": "spindle_cw.gif", "Spindle stop": "tool_stop.gif", "Tool prepare": "tool_open.gif", "Probe arm": "tool_axis_z.gif", "Recover IO": "tool_power.gif" };
for (const [label, kind, payload] of controls) { const button = document.createElement("button"); button.type = "button"; button.className = "control-button"; appendAxisIcon(document, button, assets[label], 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;

View File

@@ -0,0 +1,41 @@
import { SIM_DISPLAY_FAMILY_CATALOG } from "./sim-display-families.mjs";
const CAPABILITIES = Object.freeze({
axis: ["DRO", "jog", "home", "override", "G-code", "plot"],
qtvcp: ["DRO", "jog", "home", "override", "G-code", "VCP boundary"],
gscreen: ["DRO", "jog", "home", "override", "G-code", "VCP boundary"],
touchy: ["touch jog", "home", "MDI", "G-code"],
gladevcp: ["HAL pins", "jog", "home", "VCP boundary"],
pyvcp: ["HAL pins", "jog", "home", "VCP boundary"],
vismach: ["joints", "toolpath", "machine geometry", "G-code"],
other: ["Worker state", "G-code", "capability diagnostics"],
});
export function displayFamilyForCase(path) {
return SIM_DISPLAY_FAMILY_CATALOG.find((entry) => entry.path === path) ?? null;
}
export function renderDisplayFamily(document, { casePath = null } = {}) {
const entry = displayFamilyForCase(casePath);
const section = document.createElement("div");
section.className = "display-family";
const title = document.createElement("h3");
title.className = "display-family-title";
title.textContent = entry ? `${entry.family.toUpperCase()} controls` : "Display controls";
section.append(title);
const summary = document.createElement("p");
summary.className = "display-family-summary";
summary.textContent = entry ? `${entry.display ?? "display declared by include"} | ${entry.host_boundaries.length ? `boundary: ${entry.host_boundaries.join(", ")}` : "worker-native controls"}` : "Select a LinuxCNC case";
section.append(summary);
const list = document.createElement("ul");
list.className = "display-family-list";
for (const capability of CAPABILITIES[entry?.family ?? "other"]) {
const item = document.createElement("li");
item.textContent = capability;
list.append(item);
}
section.append(list);
return section;
}
export { CAPABILITIES };

View File

@@ -1,10 +1,24 @@
export function renderMachineView(document, { pose = { x: 0, y: 0, z: 0 }, stateHash = null } = {}) {
function finite(value) { return typeof value === "number" && Number.isFinite(value) ? value : 0; }
export function renderMachineView(document, { pose = { x: 0, y: 0, z: 0 }, joints = [], toolpath = [], stock = null, 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(); }
if (context) {
context.fillStyle = "#0b1016"; context.fillRect(0, 0, canvas.width, canvas.height);
context.strokeStyle = "#40515f"; context.strokeRect(20, 20, 600, 280);
const points = Array.isArray(toolpath) ? toolpath : [];
if (points.length > 0) {
context.strokeStyle = "#5fc4d8"; context.beginPath();
points.forEach((point, index) => { const x = 320 + finite(point?.x) * 10; const y = 160 - finite(point?.y) * 10; if (index === 0) context.moveTo?.(x, y); else context.lineTo?.(x, y); });
context.stroke?.();
}
if (stock) { context.fillStyle = "#343f48"; context.fillRect(80, 240, 480, 28); }
context.fillStyle = "#b4e15d"; context.beginPath(); context.arc(320 + finite(pose.x) * 10, 160 - finite(pose.y) * 10, 8, 0, Math.PI * 2); context.fill();
if (Array.isArray(joints) && joints.length > 0) { context.fillStyle = "#f2b56b"; joints.slice(0, 3).forEach((joint, index) => context.fillRect(40 + index * 24, 286, 12, Math.max(2, Math.min(12, Math.abs(finite(joint)))))); }
}
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);
const meta = document.createElement("small"); meta.textContent = `X ${finite(pose.x)} | Y ${finite(pose.y)} | Z ${finite(pose.z)} | joints ${Array.isArray(joints) ? joints.length : 0} | path ${Array.isArray(toolpath) ? toolpath.length : 0} | ${stateHash ?? "no hash"}`; section.append(meta);
return section;
}

View File

@@ -1,14 +1,17 @@
export function recoveryView(state = {}) {
const blocked = state.blocked?.[0]?.reason ?? null;
const status = state.status ?? "idle";
return {
status: state.status ?? "idle",
message: state.blocked?.[0]?.reason ?? (state.half_commits?.length ? "half_commit_ignored" : state.status ?? "idle"),
status,
message: blocked ?? (state.half_commits?.length ? "half_commit_ignored" : status),
state_hash: state.state_hash ?? null,
applied: state.applied?.length ?? 0,
half_commits: state.half_commits?.length ?? 0,
actions: status === "blocked" ? ["retry", "abort"] : status === "recovered" ? ["continue"] : status === "network_required" ? ["retry_online"] : [],
};
}
export function renderRecoveryPanel(document, state = {}) {
export function renderRecoveryPanel(document, state = {}, actions = {}) {
const view = recoveryView(state);
const section = document.createElement("section");
section.className = "panel recovery-panel";
@@ -23,5 +26,14 @@ export function renderRecoveryPanel(document, state = {}) {
const meta = document.createElement("small");
meta.textContent = `commits ${view.applied} | half-commits ${view.half_commits}`;
section.append(meta);
if (view.actions.length > 0) {
const controls = document.createElement("div"); controls.className = "recovery-actions";
for (const action of view.actions) {
const button = document.createElement("button"); button.type = "button"; button.className = "control-button"; button.textContent = action;
button.addEventListener?.("click", () => actions[action]?.());
controls.append(button);
}
section.append(controls);
}
return section;
}

File diff suppressed because one or more lines are too long

19
web/app/source-assets.mjs Normal file
View File

@@ -0,0 +1,19 @@
export const AXIS_ASSET_ROOT = "/linuxcnc/share/axis/images";
export function axisAsset(name) {
return `${AXIS_ASSET_ROOT}/${name}`;
}
export function appendAxisIcon(document, button, asset, label) {
const icon = document.createElement("img");
icon.className = "axis-icon";
icon.src = axisAsset(asset);
icon.alt = "";
icon.setAttribute("aria-hidden", "true");
icon.title = label;
const text = document.createElement("span");
text.className = "button-copy";
text.textContent = label;
button.append(icon, text);
return button;
}

View File

@@ -4,18 +4,26 @@ const EMPTY_STATE = Object.freeze({
status: "disconnected",
alarm: null,
mode: null,
program: null,
snapshot: null,
lastCommand: null,
error: null,
diagnostic: null,
motion: null,
devices: null,
hal: null,
plc: null,
python: null,
io_ack: 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 === "ERROR") return { ...state, status: "error", error: message.payload?.code ?? "worker_error", alarm: message.payload?.message_key ?? null, diagnostic: { code: message.payload?.code ?? "worker_error", line: message.payload?.line ?? message.payload?.source_line ?? null, column: message.payload?.column ?? null, text: message.payload?.text ?? 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 === "SESSION_READY") return { ...state, connected: true, status: "ready", mode: message.payload?.task?.mode ?? state.mode, tick: message.simulation_tick ?? state.tick, error: null };
if (message.kind === "STATE_SNAPSHOT") return { ...state, status: "snapshot", mode: message.payload?.task?.mode ?? state.mode, tick: message.payload?.tick ?? message.simulation_tick ?? state.tick, snapshot: message.payload, program: message.payload?.snapshot?.state?.program ?? state.program, motion: message.payload?.motion ?? state.motion, devices: message.payload?.devices ?? state.devices, hal: message.payload?.hal ?? state.hal, plc: message.payload?.plc ?? state.plc, python: message.payload?.python ?? state.python, io_ack: message.payload?.io_ack ?? state.io_ack, error: null };
if (message.kind === "COMMAND_RESULT") return { ...state, status: message.payload?.status ?? "verified", mode: message.payload?.task?.mode ?? state.mode, tick: message.simulation_tick ?? state.tick, lastCommand: message.payload?.command ?? null, snapshot: message.payload?.snapshot ?? state.snapshot, program: message.payload?.program ?? message.payload?.snapshot?.state?.program ?? state.program, motion: message.payload?.motion ?? state.motion, devices: message.payload?.devices ?? state.devices, hal: message.payload?.hal ?? state.hal, plc: message.payload?.plc ?? state.plc, python: message.payload?.python ?? state.python, io_ack: message.payload?.io_ack ?? state.io_ack, 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 };
}
@@ -27,9 +35,23 @@ export function renderStatePanel(document, state) {
title.className = "panel-title";
title.textContent = "Machine state";
section.append(title);
const dro = document.createElement("div");
dro.className = "dro-readout";
const positionState = state.program?.state ?? state.motion?.state?.position ?? {};
for (const axis of ["x", "y", "z"]) {
const row = document.createElement("div");
row.className = "dro-row";
const name = document.createElement("span"); name.className = "dro-axis"; name.textContent = axis.toUpperCase();
const value = document.createElement("strong"); value.textContent = Number.isFinite(positionState[axis]) ? Number(positionState[axis]).toFixed(3) : "0.000";
row.append(name, value); dro.append(row);
}
section.append(dro);
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"]];
const program = state.program;
const position = program?.state ? `X ${program.state.x} | Y ${program.state.y} | Z ${program.state.z}` : "-";
const diagnostic = state.diagnostic ? `${state.diagnostic.code}${state.diagnostic.line ? ` @${state.diagnostic.line}` : ""}${state.diagnostic.column ? `:${state.diagnostic.column}` : ""}` : "None";
const fields = [["Connection", state.connected ? "Connected" : "Disconnected"], ["Status", state.status], ["Tick", String(state.tick)], ["Program", program?.program_status ?? "-"], ["Line", program ? `${program.line}/${program.line_count}` : "-"], ["Position", position], ["Mode", state.mode ?? "-"], ["Motion", state.motion?.state?.fault ?? state.motion?.fault ?? "Ready"], ["HAL", state.hal?.status ?? "-"], ["PLC", state.plc?.state?.last_error ?? state.plc?.last_error ?? "-"], ["Python", state.python?.state?.errors?.length ?? state.python?.errors?.length ?? 0], ["IO ack", state.io_ack?.commands?.length ?? 0], ["Alarm", state.alarm ? (typeof state.alarm === "string" ? state.alarm : JSON.stringify(state.alarm)) : "None"], ["Diagnostic", diagnostic]];
for (const [name, value] of fields) {
const term = document.createElement("dt"); term.textContent = name;
const detail = document.createElement("dd"); detail.textContent = value;

View File

@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { EMPTY_STATE, reduceWorkerState, renderStatePanel } from "./state-panel.mjs";
class Node {
constructor(tag) { this.tagName = tag; this.children = []; this.className = ""; this.textContent = ""; }
append(...nodes) { this.children.push(...nodes); }
replaceChildren(...nodes) { this.children = nodes; }
}
const document = { createElement: (tag) => new Node(tag) };
const state = reduceWorkerState(EMPTY_STATE, { kind: "COMMAND_RESULT", simulation_tick: 4, payload: { status: "verified", motion: { state: { fault: "limit" } }, hal: { status: "ready" }, plc: { state: { last_error: null } }, python: { state: { errors: [1] } }, io_ack: { commands: [{ status: "pending" }] } } });
assert.equal(state.motion.state.fault, "limit");
assert.equal(state.hal.status, "ready");
assert.equal(state.python.state.errors.length, 1);
const panel = renderStatePanel(document, state);
assert.equal(panel.className, "panel state-panel");
assert.ok(panel.children.find((child) => child.className === "dro-readout"));
assert.ok(panel.children.find((child) => child.className === "state-grid")?.children.length >= 10);
const error = reduceWorkerState(state, { kind: "ERROR", payload: { code: "hal_preflight", line: 3, column: 2, text: "loadrt unknown" } });
assert.equal(error.diagnostic.line, 3);
console.log(`authoritative_panels=passed fields=${panel.children[1].children.length} fault=${state.motion.state.fault}`);

View File

@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { WorkerClient } from "./worker-client.mjs";
import { runCompatibilityMatrix, performanceBudget } from "./browser-compatibility.mjs";
class CompatibilityWorker {
constructor() { this.onmessage = null; this.onerror = null; this.tick = 0; }
postMessage(message) {
if (message.kind === "SIM_STEP") this.tick += message.payload.cycles;
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: this.tick, payload: { status: "verified", tick: this.tick },
} }));
}
terminate() {}
}
const client = new WorkerClient({ worker: new CompatibilityWorker(), sessionId: "compat-s3", timeoutMs: 1000 });
const matrix = await runCompatibilityMatrix(client, { cycles: 8, chunk: 2 });
assert.equal(matrix.status, "verified");
assert.equal(matrix.logical_tick, 8);
assert.equal(matrix.chunks, 4);
assert.equal(performanceBudget({ cycles: 8, chunks: 4 }).logical_only, true);
assert.equal(performanceBudget({ cycles: 1000001, chunks: 1 }).code, "browser_performance_budget");
client.close();
const deterministic = new WorkerClient({ worker: new CompatibilityWorker() });
assert.equal(deterministic.sessionId, "ui-1");
deterministic.close();
console.log(JSON.stringify({ schema: "browser-compatibility-s3-sample.v1", status: "verified", logical_tick: matrix.logical_tick, chunks: matrix.chunks, session_id: matrix.session_id }));

View File

@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { mkdtemp, rm } from "node:fs/promises";
import { spawn } from "node:child_process";
import { tmpdir } from "node:os";
import { dirname, join, normalize, sep } from "node:path";
import { fileURLToPath } from "node:url";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const appRoot = join(root, "app");
const webRoot = root;
function serve() {
return createServer(async (request, response) => {
const requested = decodeURIComponent((request.url ?? "/").split("?")[0]);
const relative = requested === "/" ? "/app/browser-opfs-probe.html" : requested;
const file = normalize(join(webRoot, relative));
if (!file.startsWith(webRoot + sep)) { response.writeHead(403); response.end(); return; }
try {
const body = await readFile(file);
response.writeHead(200, { "content-type": file.endsWith(".html") ? "text/html" : "text/javascript", "cache-control": "no-store" });
response.end(body);
} catch {
response.writeHead(404); response.end();
}
});
}
function runChrome(url, profile) {
return new Promise((resolve) => {
const command = process.env.CHROME_BIN ?? "google-chrome";
const args = ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--disable-background-networking", "--user-data-dir=" + profile, "--virtual-time-budget=5000", "--dump-dom", url];
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = ""; let stderr = "";
child.stdout.on("data", (chunk) => { stdout += chunk; });
child.stderr.on("data", (chunk) => { stderr += chunk; });
const timer = setTimeout(() => child.kill("SIGKILL"), 15000);
child.on("close", (code, signal) => { clearTimeout(timer); resolve({ code, signal, stdout, stderr }); });
child.on("error", (error) => { clearTimeout(timer); resolve({ code: null, signal: null, stdout, stderr, error: error.message }); });
});
}
const server = serve();
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = server.address().port;
const profile = await mkdtemp(join(tmpdir(), "linuxcnc-opfs-s6-05-"));
const first = await runChrome(`http://127.0.0.1:${port}/app/browser-opfs-probe.html`, profile);
server.close();
await rm(profile, { recursive: true, force: true });
const match = first.stdout.match(/<output id="result">([^<]*)<\/output>/);
if (first.code !== 0 || !match || match[1] === "pending") {
console.log(JSON.stringify({ schema: "browser-opfs-s6-05-sample.v1", status: "blocked", browser: "chromium", code: first.error ? "browser_unavailable" : "opfs_worker_timeout", stage: null }));
} else {
const result = JSON.parse(match[1]);
assert.equal(result.schema, "opfs-runtime.v1", JSON.stringify({ result, stderr: first.stderr }));
assert.equal(result.status, "blocked", JSON.stringify({ result, stderr: first.stderr.slice(-500) }));
assert.ok(["opfs_probe_timeout", "opfs_worker_timeout", "opfs_sync_access_handle_missing", "opfs_probe_failed"].includes(result.code));
console.log(JSON.stringify({ schema: "browser-opfs-s6-05-sample.v1", status: "blocked", browser: "chromium", code: result.code, stage: result.stage ?? null }));
}

View File

@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { SIM_DISPLAY_FAMILY_CATALOG, SIM_DISPLAY_FAMILY_COUNTS } from "./sim-display-families.mjs";
import { displayFamilyForCase, renderDisplayFamily } from "./display-family.mjs";
assert.equal(SIM_DISPLAY_FAMILY_CATALOG.length, 235);
assert.equal(SIM_DISPLAY_FAMILY_COUNTS.axis, 85);
assert.equal(SIM_DISPLAY_FAMILY_COUNTS.qtvcp, 61);
assert.equal(displayFamilyForCase("linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini").family, "axis");
assert.equal(displayFamilyForCase("linuxcnc/configs/sim/qtvcp_screens/qtdragon/qtdragon.ini").family, "qtvcp");
assert.equal(displayFamilyForCase("linuxcnc/configs/sim/touchy/touchy.ini").family, "touchy");
class Node { constructor(tag) { this.tagName = tag; this.children = []; this.className = ""; this.textContent = ""; } append(...nodes) { this.children.push(...nodes); } }
const document = { createElement: (tag) => new Node(tag) };
const panel = renderDisplayFamily(document, { casePath: "linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini" });
assert.equal(panel.className, "display-family");
assert.match(panel.children[0].textContent, /AXIS/);
assert.ok(panel.children[2].children.length >= 5);
console.log(JSON.stringify({ schema: "display-family-s8-07-sample.v1", status: "verified", case_count: SIM_DISPLAY_FAMILY_CATALOG.length, counts: SIM_DISPLAY_FAMILY_COUNTS, selected: displayFamilyForCase("linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini") }));

View File

@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import { CommandController, CONTROL_COMMANDS } 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", payload: { status: "verified", command: kind }, simulation_tick: calls.length }; } };
const controller = new CommandController(client);
assert.equal(CONTROL_COMMANDS.mdi, "MDI_EXECUTE");
await controller.execute("mdi", { block: "G1 X2" }, { operationId: "mdi-1" });
await controller.execute("restart", {}, { operationId: "restart-1" });
assert.deepEqual(calls.map((call) => call.kind), ["MDI_EXECUTE", "PROGRAM_RESTART"]);
const error = reduceWorkerState(EMPTY_STATE, { kind: "ERROR", payload: { code: "program_interpreter", line: 7, column: 4, text: "G1 X?" } });
assert.equal(error.diagnostic.line, 7);
assert.equal(error.diagnostic.column, 4);
assert.equal(error.diagnostic.text, "G1 X?");
console.log(`editor_workflow=passed calls=${calls.length} diagnostic=${error.diagnostic.code}@${error.diagnostic.line}:${error.diagnostic.column}`);

View File

@@ -40,6 +40,9 @@ const [html, app, css] = await Promise.all([
assert.match(html, /type="module"/);
assert.match(app, /WorkerClient/);
assert.match(app, /loadWorkspace/);
assert.match(app, /loadSimulationCase/);
assert.match(app, /jogStart/);
assert.match(app, /override/);
assert.doesNotMatch(app, /_sim_|wasm-core|FileSystemSyncAccessHandle/);
assert.match(css, /@media/);
console.log("frontend_m1=passed messages=" + worker.sent.length);

View File

@@ -8,7 +8,7 @@ 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" });
const machine = renderMachineView(document, { pose: { x: 1, y: 2, z: 3 }, joints: [1, 2, 3], toolpath: [{ x: 0, y: 0 }, { x: 1, y: 1 }], stock: { x: 1 }, stateHash: "fnv1a64:test" });
assert.equal(device.className, "panel device-panel");
assert.equal(hal.className, "panel hal-panel");
assert.equal(machine.className, "panel machine-panel");

View File

@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import { renderMachineView } from "./machine-view.mjs";
class CanvasContext { constructor() { this.pixels = 0; } fillRect() { this.pixels += 1; } strokeRect() { this.pixels += 1; } beginPath() {} arc() {} fill() { this.pixels += 1; } moveTo() {} lineTo() {} stroke() { this.pixels += 1; } }
class Node { constructor(tag) { this.tagName = tag; this.children = []; this.className = ""; this.textContent = ""; this.width = 0; this.height = 0; this.context = new CanvasContext(); } append(...nodes) { this.children.push(...nodes); } setAttribute() {} getContext() { return this.context; } }
const document = { createElement: (tag) => new Node(tag) };
const view = renderMachineView(document, { pose: { x: 1, y: 2, z: 3 }, joints: [1, 2, 3], toolpath: [{ x: 0, y: 0 }, { x: 2, y: 2 }], stock: { width: 2 }, stateHash: "fnv1a64:machine" });
assert.equal(view.children[1].width, 640);
assert.equal(view.children[1].height, 320);
assert.ok(view.children[1].context.pixels > 0);
assert.match(view.children[2].textContent, /joints 3/);
console.log(`machine_view=passed pixels=${view.children[1].context.pixels} canvas=${view.children[1].width}x${view.children[1].height}`);

View File

@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { opfsCapability, runOpfsProbe } from "../workspace/opfs-runtime.mjs";
import { MemoryStorage, WorkspaceJournal } from "../workspace/journal.mjs";
import { createOfflinePackage, createWorkspacePackage, verifyOfflinePackage } from "../workspace/migration.mjs";
import { ReplayTimeline, fnv1a64 } from "../worker/replay.mjs";
const contextId = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
const unavailable = opfsCapability({ navigator: { storage: {} } });
assert.deepEqual(unavailable, { status: "blocked", code: "opfs_unavailable", reason: "storage_get_directory_missing" });
assert.deepEqual(await runOpfsProbe({ storage: {} }), { schema: "opfs-runtime.v1", status: "blocked", code: "opfs_unavailable", reason: "storage_get_directory_missing", project_id: "s6-05-opfs" });
const storage = new MemoryStorage();
const journal = new WorkspaceJournal(storage, { projectId: "s6-05-logical" });
journal.prepare("op-1", [{ path: "config/main.ini", data: "[EMC]\n" }]);
journal.commit("op-1");
const recovered = journal.recover();
assert.equal(recovered.status, "recovered");
assert.deepEqual(recovered.files, { "config/main.ini": "[EMC]\n" });
const timeline = new ReplayTimeline();
timeline.appendEvent(1, 0, "SESSION_READY", {});
timeline.checkpoint(1, 0, { tick: 0 });
const workspace = createWorkspacePackage({ contextId, timeline, journal: journal.records(), committedSeq: 1 });
const offline = createOfflinePackage({ workspacePackage: workspace, resources: [{ path: "index.html", data: "offline" }] });
assert.deepEqual(verifyOfflinePackage(offline, { contextId, workspacePackage: workspace, available: [{ path: "index.html", data: "offline" }] }), { status: "ready", missing: [], corrupted: [], offline: true });
assert.throws(() => verifyOfflinePackage(offline, { contextId, workspacePackage: workspace, available: [], online: false }), /offline_resource_invalid/);
console.log(JSON.stringify({
schema: "opfs-runtime-s6-05-logical-sample.v1",
status: "verified",
context_id: contextId,
opfs_boundary: unavailable,
journal_status: recovered.status,
journal_state_hash: recovered.state_hash,
offline_status: "ready",
offline_bundle_hash: offline.bundle_hash,
deterministic_hash: fnv1a64({ recovered: recovered.state_hash, offline: offline.bundle_hash }),
}));

View File

@@ -0,0 +1,13 @@
import assert from "node:assert/strict";
import { recoveryView, renderRecoveryPanel } from "./recovery-panel.mjs";
class Node { constructor(tag) { this.tagName = tag; this.children = []; this.textContent = ""; this.className = ""; this.listeners = {}; } append(...nodes) { this.children.push(...nodes); } addEventListener(type, callback) { this.listeners[type] = callback; } }
const document = { createElement: (tag) => new Node(tag) };
assert.deepEqual(recoveryView({ status: "blocked", blocked: [{ reason: "offline_resource_invalid" }] }).actions, ["retry", "abort"]);
assert.deepEqual(recoveryView({ status: "network_required" }).actions, ["retry_online"]);
let retry = 0; const panel = renderRecoveryPanel(document, { status: "blocked", blocked: [{ reason: "permission_denied" }] }, { retry: () => { retry += 1; }, abort: () => {} });
assert.equal(panel.children[1].textContent, "blocked: permission_denied");
assert.equal(panel.children[3].children.length, 2);
panel.children[3].children[0].listeners.click();
assert.equal(retry, 1);
console.log(`recovery_ux=passed actions=${panel.children[3].children.length} retry=${retry}`);

View File

@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { WorkerClient, CONTEXT_ID } from "./worker-client.mjs";
class StalledWorker {
constructor() { this.sent = []; this.onmessage = null; this.onerror = null; }
postMessage(message) { this.sent.push(message); if (message.kind === "HELLO") queueMicrotask(() => this.onmessage?.({ data: { schema_version: 1, kind: "CAPABILITIES", context_id: message.context_id, session_id: message.session_id, seq: message.seq, payload: { status: "verified" } } })); }
terminate() { this.terminated = true; }
}
const worker = new StalledWorker();
const client = new WorkerClient({ worker, contextId: CONTEXT_ID, sessionId: "runtime-client", timeoutMs: 20, maxPending: 1 });
assert.equal((await client.hello()).kind, "CAPABILITIES");
const first = client.send("SIM_STEP", { cycles: 1 }, { operationId: "long" });
await assert.rejects(client.send("SIM_STEP", { cycles: 2 }, { operationId: "blocked" }), (error) => error.code === "worker_backpressure");
const cancelled = await client.cancel("long");
assert.equal(cancelled.cancelled, true);
await assert.rejects(first, (error) => error.code === "worker_cancelled");
const timeout = client.send("SIM_STEP", { cycles: 1 }, { operationId: "timeout", timeoutMs: 10 });
await assert.rejects(timeout, (error) => error.code === "worker_timeout");
client.close();
assert.equal(worker.terminated, true);
console.log(`worker_client_runtime=passed sent=${worker.sent.length} cancel=${cancelled.cancelled}`);

View File

@@ -1,15 +1,17 @@
export const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
export const PROTOCOL_SCHEMA = 1;
let CLIENT_INSTANCE = 0;
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 } = {}) {
constructor({ worker = null, workerFactory = null, contextId = CONTEXT_ID, sessionId = null, timeoutMs = 10000, maxPending = 64 } = {}) {
this.contextId = contextId;
this.sessionId = sessionId;
this.sessionId = sessionId ?? `ui-${++CLIENT_INSTANCE}`;
this.timeoutMs = timeoutMs;
this.maxPending = maxPending;
this.worker = worker ?? (workerFactory ? workerFactory() : new Worker("../worker/sim-worker.mjs", { type: "module" }));
this.seq = 0;
this.pending = new Map();
@@ -37,7 +39,7 @@ export class WorkerClient {
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(operationId ?? `seq:${message.seq}`);
this.completed.set(operationId, message);
if (operationId) 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;
@@ -55,6 +57,7 @@ export class WorkerClient {
send(kind, payload = {}, { operationId = null, timeoutMs = this.timeoutMs } = {}) {
if (this.closed) return Promise.reject(new Error("worker_client_closed"));
if (!Number.isSafeInteger(this.maxPending) || this.maxPending < 1) return Promise.reject(new Error("worker_client_backpressure"));
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;
@@ -70,19 +73,24 @@ export class WorkerClient {
let resolvePromise;
let rejectPromise;
const promise = new Promise((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; });
const pendingKey = operationId ?? `seq:${seq}`;
const timer = setTimeout(() => {
this.pending.delete(operationId);
this.pending.delete(pendingKey);
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 });
if (this.pending.size >= this.maxPending) {
clearTimeout(timer);
rejectPromise(Object.assign(new Error("worker_backpressure"), { code: "worker_backpressure" }));
return promise;
}
this.pending.set(pendingKey, { promise, resolve: resolvePromise, reject: rejectPromise, timer, seq, operationId, pendingKey });
try {
this.worker.postMessage(message);
} catch (error) {
clearTimeout(timer);
this.pending.delete(operationId ?? `seq:${seq}`);
this.pending.delete(pendingKey);
rejectPromise(error);
}
return promise;
@@ -93,7 +101,41 @@ export class WorkerClient {
loadWorkspace(rootPath = "/workspace", configuration = undefined, hal = undefined, operationId = "workspace-load") {
return this.send("LOAD_WORKSPACE", { root_path: rootPath, ...(configuration ? { configuration } : {}), ...(hal ? { hal } : {}) }, { operationId });
}
loadProgram(source, path = "program.ngc", operationId = `program-load-${this.seq + 1}`) {
return this.send("PROGRAM_LOAD", { source, path }, { operationId });
}
runProgram(operationId = `program-run-${this.seq + 1}`) { return this.send("PROGRAM_RUN", {}, { operationId }); }
pauseProgram(operationId = `program-pause-${this.seq + 1}`) { return this.send("PROGRAM_PAUSE", {}, { operationId }); }
resumeProgram(operationId = `program-resume-${this.seq + 1}`) { return this.send("PROGRAM_RESUME", {}, { operationId }); }
abortProgram(operationId = `program-abort-${this.seq + 1}`) { return this.send("PROGRAM_ABORT", {}, { operationId }); }
restartProgram(operationId = `program-restart-${this.seq + 1}`) { return this.send("PROGRAM_RESTART", {}, { operationId }); }
stepProgram(operationId = `program-step-${this.seq + 1}`) { return this.send("PROGRAM_STEP", {}, { operationId }); }
seekProgram(line, operationId = `program-seek-${this.seq + 1}`) { return this.send("PROGRAM_SEEK", { line }, { operationId }); }
mdi(block, operationId = `mdi-${this.seq + 1}`) { return this.send("MDI_EXECUTE", { block }, { operationId }); }
step(cycles = 1, operationId = `step-${this.seq + 1}`) { return this.send("SIM_STEP", { cycles }, { operationId }); }
async stepMany(cycles = 1, { chunk = 1, operationPrefix = "batch-step" } = {}) {
if (!Number.isSafeInteger(cycles) || cycles < 0 || cycles > 1000000 || !Number.isSafeInteger(chunk) || chunk < 1 || chunk > 1000000) return Promise.reject(Object.assign(new Error("worker_step_batch_invalid"), { code: "worker_step_batch_invalid" }));
const responses = [];
let remaining = cycles;
let index = 0;
while (remaining > 0) {
const amount = Math.min(chunk, remaining);
responses.push(await this.step(amount, `${operationPrefix}-${index}`));
remaining -= amount; index += 1;
}
return { status: "verified", cycles, chunks: responses.length, last: responses.at(-1) ?? null };
}
cancel(operationId, reason = "cancel_requested") {
const pending = this.pending.get(operationId);
if (!pending) return Promise.resolve({ status: "verified", cancelled: false, operation_id: operationId });
clearTimeout(pending.timer);
this.pending.delete(operationId);
pending.reject(Object.assign(new Error("worker_cancelled"), { code: "worker_cancelled", reason }));
const seq = ++this.seq;
const message = { schema_version: PROTOCOL_SCHEMA, context_id: this.contextId, session_id: this.sessionId, seq, kind: "CANCEL", operation_id: `cancel:${operationId}`, payload: { operation_id: operationId, reason } };
try { this.worker.postMessage(message); } catch { /* cancellation is local even if transport is closed */ }
return Promise.resolve({ status: "verified", cancelled: true, operation_id: 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() {