diff --git a/wasm-port/runtime/sdk/README.md b/wasm-port/runtime/sdk/README.md index 1834964..5e1b7a8 100644 --- a/wasm-port/runtime/sdk/README.md +++ b/wasm-port/runtime/sdk/README.md @@ -569,6 +569,12 @@ private module paths: LinuxCNC-owned tool DB process port contract. Contract-only mode does not run `DB_PROGRAM`, does not parse `.tbl` as a fallback, and keeps execution and promotion disabled. +- `createLinuxCncToolDbNodeRuntimeAdapter()` in + `runtime/sdk/src/tool-db-node-runtime-adapter.js` for Node/native verification + of the real LinuxCNC `DB_PROGRAM` child process. This adapter is intentionally + not exported from the browser-facing SDK index because it depends on Node + process APIs; browser runtime remains locked until a Python/WASM Worker runs + the DB program. - `saveToolDbFile()`, `loadToolDbFile()`, `saveToolDbTranscript()`, and `loadToolDbTranscript()` for OPFS persistence of the DB flat file and protocol transcript artifacts. diff --git a/wasm-port/runtime/sdk/src/tool-db-node-runtime-adapter.js b/wasm-port/runtime/sdk/src/tool-db-node-runtime-adapter.js new file mode 100644 index 0000000..4266957 --- /dev/null +++ b/wasm-port/runtime/sdk/src/tool-db-node-runtime-adapter.js @@ -0,0 +1,160 @@ +import { createWriteStream } from "node:fs"; +import { mkdir, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +function requireString(value, label) { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${label} must be a non-empty string.`); + } + return value; +} + +function defaultRepoRoot() { + return fileURLToPath(new URL("../../../..", import.meta.url)); +} + +function appendPathEnv(current, next) { + return current ? `${next}:${current}` : next; +} + +export function createLinuxCncToolDbNodeRuntimeAdapter({ + repoRoot = defaultRepoRoot(), + linuxCncRoot = join(repoRoot, "linuxcnc"), + dbProgramPath = join(linuxCncRoot, "configs/sim/axis/db_demo/db_nonran.py"), + dbProgramCwd = dirname(dbProgramPath), + dbSavefile = join(repoRoot, "wasm-port/build/native/tool-db-runtime/sdk-node-db-nonran"), + stdoutLog = join(repoRoot, "wasm-port/build/native/tool-db-runtime/sdk-node-db-program.stdout.log"), + stderrLog = join(repoRoot, "wasm-port/build/native/tool-db-runtime/sdk-node-db-program.stderr.log"), + periodMinutes = 999, +} = {}) { + requireString(repoRoot, "repoRoot"); + requireString(linuxCncRoot, "linuxCncRoot"); + requireString(dbProgramPath, "dbProgramPath"); + requireString(dbProgramCwd, "dbProgramCwd"); + requireString(dbSavefile, "dbSavefile"); + + let proc = null; + let stdoutFile = null; + let stderrFile = null; + let stdoutBuffer = ""; + const lineQueue = []; + const waiters = []; + let closed = false; + + function flushWaiters() { + while (lineQueue.length > 0 && waiters.length > 0) { + const waiter = waiters.shift(); + waiter.resolve(lineQueue.shift()); + } + } + + function failWaiters(error) { + while (waiters.length > 0) { + const waiter = waiters.shift(); + waiter.reject(error); + } + } + + function pushStdout(chunk) { + stdoutBuffer += chunk.toString("utf8"); + let newlineIndex = stdoutBuffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, ""); + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); + lineQueue.push(line); + newlineIndex = stdoutBuffer.indexOf("\n"); + } + flushWaiters(); + } + + return { + runtimeMode: "node-linuxcnc-db-program", + dbSavefile, + stdoutLog, + stderrLog, + + async start() { + await mkdir(dirname(dbSavefile), { recursive: true }); + await rm(dbSavefile, { force: true }); + stdoutFile = createWriteStream(stdoutLog, { encoding: "utf8" }); + stderrFile = createWriteStream(stderrLog, { encoding: "utf8" }); + + const env = { + ...process.env, + PYTHONPATH: appendPathEnv(process.env.PYTHONPATH, join(linuxCncRoot, "lib/python")), + LD_LIBRARY_PATH: appendPathEnv(process.env.LD_LIBRARY_PATH, join(linuxCncRoot, "lib")), + }; + + proc = spawn(dbProgramPath, [`--period_minutes=${periodMinutes}`, dbSavefile], { + cwd: dbProgramCwd, + env, + stdio: ["pipe", "pipe", "pipe"], + }); + proc.stdout.on("data", (chunk) => { + stdoutFile.write(chunk); + pushStdout(chunk); + }); + proc.stderr.on("data", (chunk) => stderrFile.write(chunk)); + proc.on("error", (error) => failWaiters(error)); + proc.on("exit", (code) => { + if (!closed && code !== 0) { + failWaiters(new Error(`DB_PROGRAM exited with code ${code}.`)); + } + }); + }, + + async writeLine(line) { + if (!proc?.stdin || proc.stdin.destroyed) { + throw new Error("DB_PROGRAM stdin is not writable."); + } + proc.stdin.write(`${line}\n`); + }, + + async readLine({ timeoutMs = 5000 } = {}) { + if (lineQueue.length > 0) { + return lineQueue.shift(); + } + return new Promise((resolve, reject) => { + let waiter; + const timer = setTimeout(() => { + const index = waiters.indexOf(waiter); + if (index !== -1) { + waiters.splice(index, 1); + } + reject(new Error("timeout waiting for DB_PROGRAM output.")); + }, timeoutMs); + waiter = { + resolve: (line) => { + clearTimeout(timer); + resolve(line); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }; + waiters.push(waiter); + }); + }, + + async close() { + closed = true; + try { + proc?.stdin?.end(); + } catch { + // ignore close races + } + if (proc && proc.exitCode === null && proc.signalCode === null) { + await new Promise((resolve) => { + proc.once("exit", resolve); + proc.kill("SIGTERM"); + setTimeout(resolve, 1000); + }); + } + stdoutFile?.end(); + stderrFile?.end(); + }, + }; +} diff --git a/wasm-port/runtime/sdk/src/tool-db-process-port.js b/wasm-port/runtime/sdk/src/tool-db-process-port.js index e3337d4..4b8fafb 100644 --- a/wasm-port/runtime/sdk/src/tool-db-process-port.js +++ b/wasm-port/runtime/sdk/src/tool-db-process-port.js @@ -207,8 +207,18 @@ export function createLinuxCncToolDbProcessPort({ for (const step of plan) { if (step.direction === "write") { await this.writeLine(step.line); + if (step.requiresFini) { + let finiSeen = false; + while (!finiSeen) { + const reply = await this.readLine(); + finiSeen = reply.startsWith("FINI"); + } + } } else { - await this.readLine(); + const line = await this.readLine(); + if (step.expect && line !== step.expect) { + throw new Error(`ToolDbProcessPort ${step.phase} expected ${step.expect}, got ${line}.`); + } } } return { @@ -226,9 +236,9 @@ export function createLinuxCncToolDbProcessPort({ return transcript.map((entry) => ({ ...entry })); }, - close() { + async close() { if (runtimeAdapter?.close) { - runtimeAdapter.close(); + await runtimeAdapter.close(); } closed = true; return { diff --git a/wasm-port/tests/sdk/node/verify_project_batch_acceptance_artifact.mjs b/wasm-port/tests/sdk/node/verify_project_batch_acceptance_artifact.mjs index 8571f9d..935d4f6 100644 --- a/wasm-port/tests/sdk/node/verify_project_batch_acceptance_artifact.mjs +++ b/wasm-port/tests/sdk/node/verify_project_batch_acceptance_artifact.mjs @@ -35,6 +35,17 @@ assert.equal(jsonWorkflow.report.reportVersion, 1); assert.equal(jsonWorkflow.report.ready, true); assert.equal(jsonWorkflow.report.capabilityMatrix.ready, true); assert.deepEqual(jsonWorkflow.report.capabilityMatrix.capabilityTypes, ["workflow", "api", "gate"]); +assert.equal(jsonWorkflow.report.capabilityMatrix.capabilityCount, 5); +assert.equal(jsonWorkflow.report.capabilityMatrix.acceptedCount, 5); +assert.equal( + jsonWorkflow.report.capabilityMatrix.capabilities.some( + (capability) => + capability.id === "tool-db-node-runtime-adapter" && + capability.evidence === "tool_db_node_runtime_adapter=ok" && + capability.command === "wasm-port/tests/sdk/node/verify_tool_db_node_runtime_adapter.sh", + ), + true, +); assert.equal(jsonWorkflow.report.workflow.ready, true); assert.equal(jsonWorkflow.report.workflow.virtualHalSimConfigSourceCoverageReady, true); assert.equal(jsonWorkflow.report.workflow.virtualHalMotionControllerMatrixReady, true); diff --git a/wasm-port/tests/sdk/node/verify_tool_db_node_runtime_adapter.mjs b/wasm-port/tests/sdk/node/verify_tool_db_node_runtime_adapter.mjs new file mode 100644 index 0000000..9c3ee7e --- /dev/null +++ b/wasm-port/tests/sdk/node/verify_tool_db_node_runtime_adapter.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { + createLinuxCncToolDbProcessPort, + createToolDbProcessDiagnostics, + validateToolDbTranscript, +} from "../../../runtime/sdk/src/tool-db-process-port.js"; +import { + createLinuxCncToolDbNodeRuntimeAdapter, +} from "../../../runtime/sdk/src/tool-db-node-runtime-adapter.js"; + +const adapter = createLinuxCncToolDbNodeRuntimeAdapter({ + dbSavefile: resolve("wasm-port/build/native/tool-db-runtime/sdk-node-runtime-adapter-db-nonran"), + stdoutLog: resolve("wasm-port/build/native/tool-db-runtime/sdk-node-runtime-adapter.stdout.log"), + stderrLog: resolve("wasm-port/build/native/tool-db-runtime/sdk-node-runtime-adapter.stderr.log"), +}); + +const port = createLinuxCncToolDbProcessPort({ + dbProgramPath: "./db_nonran.py", + sourceFiles: [ + "configs/sim/axis/db_demo/db_nonran.py", + "configs/sim/axis/db_demo/db.py", + "lib/python/tooldb.py", + ], + runtimeMode: adapter.runtimeMode, + runtimeAdapter: adapter, +}); + +const startResult = await port.start(); +assert.equal(startResult.runtimeMode, "node-linuxcnc-db-program"); +assert.equal(startResult.runtimeExecutionReady, true); +assert.equal(startResult.executionEnabled, false); +assert.equal(startResult.promotionAllowed, false); + +const planResult = await port.runTransactionPlan(); +assert.equal(planResult.status, "runtime_adapter_transaction_plan_executed"); +assert.equal(planResult.runtimeExecutionReady, true); +assert.equal(planResult.executionEnabled, false); +assert.equal(planResult.promotionAllowed, false); +assert.equal(planResult.tblFallbackSufficient, false); + +await port.writeLine("t 14"); +const t14AfterUnload = await port.readLine(); +assert.match(t14AfterUnload, /^T14 /); +assert.match(t14AfterUnload, /P114\b/); + +const transcript = port.exportTranscript(); +const validation = validateToolDbTranscript(transcript); +assert.equal(validation.ready, true); +assert.equal(transcript.some((entry) => entry.line === "v2.1"), true); +assert.equal(transcript.some((entry) => entry.line.startsWith("T10 ") && entry.line.includes("P110")), true); +assert.equal(transcript.some((entry) => entry.line.startsWith("FINI")), true); + +await port.close(); + +const dbFileText = readFileSync(adapter.dbSavefile, "utf8"); +assert.match(dbFileText, /T11 P111 D0.33 Z0.11/); +assert.match(dbFileText, /T14 P114\b/); + +const diagnostics = createToolDbProcessDiagnostics({ + dbProgramPath: "./db_nonran.py", + opfsDbPath: "/machines/db-demo/tool-db/db_nonran_file", + transcript, + dbFileText, + startupToolCount: 10, + mutationCount: 3, + runtimeMode: adapter.runtimeMode, + runtimeExecutionReady: true, +}); +assert.equal(diagnostics.runtimeMode, "node-linuxcnc-db-program"); +assert.equal(diagnostics.runtimeExecutionReady, true); +assert.equal(diagnostics.protocolTranscriptReady, true); +assert.equal(diagnostics.opfsPersistenceReady, true); +assert.equal(diagnostics.tblFallbackSufficient, false); +assert.equal(diagnostics.executionEnabled, false); +assert.equal(diagnostics.promotionAllowed, false); + +console.log("tool_db_node_runtime_adapter=ok"); diff --git a/wasm-port/tests/sdk/node/verify_tool_db_node_runtime_adapter.sh b/wasm-port/tests/sdk/node/verify_tool_db_node_runtime_adapter.sh new file mode 100755 index 0000000..594864b --- /dev/null +++ b/wasm-port/tests/sdk/node/verify_tool_db_node_runtime_adapter.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../../.." && pwd)" + +node "$ROOT_DIR/tests/sdk/node/verify_tool_db_node_runtime_adapter.mjs" diff --git a/wasm-port/tests/sdk/node/verify_tool_db_process_port.mjs b/wasm-port/tests/sdk/node/verify_tool_db_process_port.mjs index 3eb5a61..0889926 100644 --- a/wasm-port/tests/sdk/node/verify_tool_db_process_port.mjs +++ b/wasm-port/tests/sdk/node/verify_tool_db_process_port.mjs @@ -91,6 +91,6 @@ assert.equal(diagnostics.promotionAllowed, false); assert.match(diagnostics.transcriptHash, /^[0-9a-f]{8}$/); assert.match(diagnostics.dbFileHash, /^[0-9a-f]{8}$/); -assert.equal(port.close().promotionAllowed, false); +assert.equal((await port.close()).promotionAllowed, false); console.log("tool_db_process_port_sdk=ok"); diff --git a/wasm-port/tests/sdk/node/write_project_batch_acceptance_artifact.mjs b/wasm-port/tests/sdk/node/write_project_batch_acceptance_artifact.mjs index 777ee92..13d0572 100644 --- a/wasm-port/tests/sdk/node/write_project_batch_acceptance_artifact.mjs +++ b/wasm-port/tests/sdk/node/write_project_batch_acceptance_artifact.mjs @@ -44,8 +44,18 @@ const report = createProjectBatchAcceptanceReport({ evidence: "project_batch_acceptance_workflow_node_smoke=ok", command: "wasm-port/tests/sdk/node/verify_project_batch_acceptance_workflow.sh", }, + { + id: "tool-db-node-runtime-adapter", + type: "gate", + label: "L4-TOOL-DB Node/native DB_PROGRAM runtime", + evidence: "tool_db_node_runtime_adapter=ok", + command: "wasm-port/tests/sdk/node/verify_tool_db_node_runtime_adapter.sh", + }, + ], + observedOutputs: [ + "project_batch_acceptance_workflow_node_smoke=ok", + "tool_db_node_runtime_adapter=ok", ], - observedOutputs: ["project_batch_acceptance_workflow_node_smoke=ok"], virtualHalSimConfigSourceCoverage: createVirtualHalSimConfigSourceCoverageReport({ manifestText: sourceManifestText, }),