From 9c016b90752e7cddbf9260320aa6a61dca23f39d Mon Sep 17 00:00:00 2001 From: BigDataDZ <76271875+BigDataDZ@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:00:00 +0800 Subject: [PATCH] fix(dashboard): reuse control-plane Node runtime admission Signed-off-by: BigDataDZ <76271875+BigDataDZ@users.noreply.github.com> --- docs/development/frontend-delivery.md | 8 ++ scripts/dashboard-dev.sh | 14 ++-- .../dashboard_node_runtime.test.ts | 77 +++++++++++++++++++ tests/test_dashboard_command.py | 4 + 4 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 tests/control_plane_ts/dashboard_node_runtime.test.ts diff --git a/docs/development/frontend-delivery.md b/docs/development/frontend-delivery.md index 5f0260629b..78cdd131f9 100644 --- a/docs/development/frontend-delivery.md +++ b/docs/development/frontend-delivery.md @@ -6,6 +6,14 @@ validation only; resolving a source merge followed by a build produces one coherent HTML/JavaScript/CSS set. Do not concatenate competing HTML entrypoints or asset-retention manifests when resolving an older PR. +The source launcher (`bash scripts/dashboard-dev.sh`, also used by source-mode +`loopx dashboard`) needs Node.js 22.22.3 or newer for the control plane, even +when an older Node can run Vite. It uses the runtime's existing version rule, +tries compatible installed NVM candidates, and rejects unsupported runtimes +before installing frontend dependencies. For a UI-only preview, run +`npm run dev:web` in `apps/presentation/dashboard`; that command does not start +the control plane or change the installed runtime. + For owner-authorized reads of existing runtimes, shared access lives in `loopx.control_plane.status.goal_source`. Native status and Explore owners retain their semantics; this Python IO provider adds neither decisions nor lifecycle diff --git a/scripts/dashboard-dev.sh b/scripts/dashboard-dev.sh index aabaf4ae25..c6a445ccf0 100755 --- a/scripts/dashboard-dev.sh +++ b/scripts/dashboard-dev.sh @@ -18,11 +18,11 @@ if [[ "${LOOPX_ENABLE_GOAL_SUBAGENT_CONFIGURATION:-0}" = "1" ]]; then fi node_is_supported() { - "$1" -e ' -const [major, minor] = process.versions.node.split(".").map(Number); -const supported = (major === 20 && minor >= 19) || (major === 22 && minor >= 12) || major > 22; -process.exit(supported ? 0 : 1); -' 2>/dev/null + "$1" --no-warnings --experimental-strip-types --input-type=module -e ' +import { pathToFileURL } from "node:url"; +const { isSupportedNodeVersion } = await import(pathToFileURL(process.argv[1]).href); +process.exit(isSupportedNodeVersion(process.versions.node) ? 0 : 1); +' "${REPO_ROOT}/loopx/control_plane/runtime/node_version.ts" 2>/dev/null } select_node_runtime() { @@ -45,8 +45,8 @@ select_node_runtime() { fi done - echo "LoopX dashboard requires Node.js 20.19+ or 22.12+. Node.js 22 is recommended." >&2 - echo "Install Node.js 22, then retry: loopx dashboard" >&2 + echo "LoopX dashboard requires Node.js 22.22.3 or newer for its control plane." >&2 + echo "Activate a compatible Node.js runtime, then retry: loopx dashboard" >&2 return 1 } diff --git a/tests/control_plane_ts/dashboard_node_runtime.test.ts b/tests/control_plane_ts/dashboard_node_runtime.test.ts new file mode 100644 index 0000000000..bf4100850f --- /dev/null +++ b/tests/control_plane_ts/dashboard_node_runtime.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import {spawnSync} from "node:child_process"; +import {copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {dirname, join} from "node:path"; +import test from "node:test"; +import {fileURLToPath} from "node:url"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); +const contract = "loopx/control_plane/runtime/node_version.ts"; +const shellPath = (path: string): string => process.platform === "win32" + ? path.replaceAll("\\", "/").replace(/^([A-Za-z]):/, (_, drive: string) => `/${drive.toLowerCase()}`) + : path; +const quote = (text: string): string => `'${text.replaceAll("'", "'\\''")}'`; + +// Expected admission comes from the runtime floor and prerelease contract, +// independently of the launcher's implementation or the installed Node version. +const cases: Array<[string, string | null, boolean]> = [ + ["20.19.0", null, false], + ["22.12.0", null, false], + ["22.22.2", null, false], + ["22.22.3-rc.1", null, false], + ["22.22.3", null, true], + ["22.22.3+build.1", null, true], + ["22.22.4-rc.1", null, true], + ["24.0.0", null, true], + ["20.19.0", "24.0.0", true], + ["22.22.2", "22.22.3", true], +]; + +for (const [onPath, inNvm, allowed] of cases) { + test(`dashboard admits PATH ${onPath}, NVM ${inNvm ?? "absent"}: ${allowed}`, t => { + const discovery = spawnSync("bash", ["-c", process.platform === "win32" + ? 'cygpath -w "$(command -v bash)"' : "command -v bash"], {encoding: "utf8"}); + if (discovery.status !== 0) { + t.skip("Bash is required for the source dashboard launcher"); + return; + } + const directory = mkdtempSync(join(tmpdir(), "loopx-dashboard-node-")); + t.after(() => rmSync(directory, {recursive: true, force: true})); + const scripts = join(directory, "scripts"); + const dashboard = join(directory, "apps/presentation/dashboard"); + const bin = join(directory, "bin"); + for (const path of [scripts, dashboard, bin, dirname(join(directory, contract))]) mkdirSync(path, {recursive: true}); + for (const name of ["dashboard-dev.sh", "loopx-python.sh"]) copyFileSync(join(root, "scripts", name), join(scripts, name)); + copyFileSync(join(root, contract), join(directory, contract)); + const log = join(directory, "npm.log"); + const npm = `#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> "$LOOPX_NPM_LOG"\nexit 0\n`; + const node = (path: string, version: string) => { + mkdirSync(dirname(path), {recursive: true}); + const preload = `${path}.cjs`; + writeFileSync(preload, `Object.defineProperty(process.versions, "node", {value: ${JSON.stringify(version)}});\n`); + writeFileSync(path, `#!/usr/bin/env bash\nexec ${quote(shellPath(process.execPath))} --require ${quote(shellPath(preload))} "$@"\n`, {mode: 0o755}); + writeFileSync(join(dirname(path), "npm"), npm, {mode: 0o755}); + }; + node(join(bin, "node"), onPath); + const nvm = join(directory, "nvm"); + if (inNvm) node(join(nvm, "versions/node", `v${inNvm}`, "bin/node"), inNvm); + const env: NodeJS.ProcessEnv = {...process.env, PATH: `${shellPath(bin)}:/usr/bin:/bin`, + HOME: shellPath(directory), NVM_DIR: shellPath(nvm), LOOPX_NPM_LOG: shellPath(log), + // Force the existing UI-only route; no backend, agent or model may start. + LOOPX_PYTHON: shellPath(join(directory, "missing-python"))}; + delete env.NODE_OPTIONS; + const result = spawnSync(discovery.stdout.trim(), [shellPath(join(scripts, "dashboard-dev.sh"))], { + env, encoding: "utf8", timeout: 20_000, + }); + assert.equal(result.error, undefined); + assert.equal(result.status, allowed ? 0 : 1, result.stdout + result.stderr); + if (allowed) { + assert.deepEqual(readFileSync(log, "utf8").trim().split(/\r?\n/), ["ci", "run dev:web"]); + if (inNvm) assert.match(result.stdout, /Using compatible Node\.js/); + } else { + assert.equal(existsSync(log), false, "Reject before npm installation or launch"); + assert.match(result.stderr, /22\.22\.3/); + } + }); +} diff --git a/tests/test_dashboard_command.py b/tests/test_dashboard_command.py index 51231805cd..ffd6155a56 100644 --- a/tests/test_dashboard_command.py +++ b/tests/test_dashboard_command.py @@ -76,6 +76,10 @@ def _copy_dashboard_launcher(scripts_dir: Path) -> None: source_scripts = Path(__file__).resolve().parents[1] / "scripts" for name in ("dashboard-dev.sh", "loopx-python.sh"): shutil.copy2(source_scripts / name, scripts_dir / name) + contract = Path("loopx/control_plane/runtime/node_version.ts") + target = scripts_dir.parent / contract + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_scripts.parent / contract, target) def test_release_runtime_identity_uses_immutable_manifest_fields(tmp_path: Path) -> None: