Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/development/frontend-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions scripts/dashboard-dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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
}

Expand Down
77 changes: 77 additions & 0 deletions tests/control_plane_ts/dashboard_node_runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
}
});
}
4 changes: 4 additions & 0 deletions tests/test_dashboard_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading