Merge pull request #2100 from pikasTech/feat/2097-hwlab-node-py

feat: add HWLAB node Python GUI
This commit is contained in:
Lyon
2026-06-25 09:25:28 +08:00
committed by GitHub
3 changed files with 1356 additions and 1 deletions
+34
View File
@@ -20,6 +20,40 @@ test("cloud-api exposes hwpod-node-ops contract on /v1", async () => {
assert.equal(payload.hwpod.specDiscoveryRoute, "/v1/hwpod/specs");
assert.ok(payload.hwpod.supportedOps.includes("workspace.ls"));
assert.ok(payload.hwpod.supportedOps.includes("workspace.insert-after"));
assert.equal(payload.hwlabNode.updateRoute, "/v1/hwlab-node/update");
assert.equal(payload.hwlabNode.updateContractVersion, "hwlab-node-update-v1");
} finally {
await close(server);
}
});
test("cloud-api exposes hwlab-node Python update metadata", async () => {
const server = createCloudApiServer({
env: {
PATH: process.env.PATH,
HWLAB_PUBLIC_ENDPOINT: "https://hwlab.pikapython.com",
HWLAB_NODE_PY_LATEST_VERSION: "0.1.1",
HWLAB_NODE_PY_DOWNLOAD_PATH: "/downloads/hwlab-node.py",
HWLAB_NODE_PY_SHA256: "abc123"
}
});
await listen(server);
try {
const response = await fetch(`${serverUrl(server)}/v1/hwlab-node/update?current=0.1.0&channel=stable&platform=windows`);
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.ok, true);
assert.equal(payload.contractVersion, "hwlab-node-update-v1");
assert.equal(payload.updateAvailable, true);
assert.equal(payload.latestVersion, "0.1.1");
assert.equal(payload.downloadUrl, "https://hwlab.pikapython.com/downloads/hwlab-node.py");
assert.equal(payload.sha256, "abc123");
const currentResponse = await fetch(`${serverUrl(server)}/v1/hwlab-node/update?current=0.1.1&channel=stable&platform=windows`);
const currentPayload = await currentResponse.json();
assert.equal(currentResponse.status, 200);
assert.equal(currentPayload.updateAvailable, false);
assert.equal(currentPayload.downloadUrl, null);
} finally {
await close(server);
}
+85 -1
View File
@@ -623,11 +623,21 @@ async function handleRestAdapter(request, response, url, options) {
nodeRole: "thin-hwpod-node-executor",
supportedOps: Array.from(HWPOD_NODE_OPS)
},
hwlabNode: {
updateRoute: "/v1/hwlab-node/update",
updateContractVersion: "hwlab-node-update-v1",
defaultUpdateIntervalSeconds: 300
},
m3IoControl: describeM3IoControl(options)
});
return;
}
if (url.pathname === "/v1/hwlab-node/update") {
handleHwlabNodeUpdateHttp(request, response, url, options);
return;
}
if (url.pathname === "/v1/auth/session" || url.pathname === "/v1/auth/login" || url.pathname === "/v1/auth/logout" || url.pathname === "/v1/auth/register") {
const authUrl = new URL(url.href);
authUrl.pathname = url.pathname.replace(/^\/v1\/auth/u, "/auth");
@@ -949,6 +959,80 @@ async function handleHwpodNodeOpsHttp(request, response, options) {
}
}
function handleHwlabNodeUpdateHttp(request, response, url, options) {
if (request.method !== "GET") {
sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "GET required." } });
return;
}
const env = options.env ?? process.env;
const currentVersion = cleanText(url.searchParams.get("current") || url.searchParams.get("version"));
const channel = cleanText(url.searchParams.get("channel")) || "stable";
const platform = cleanText(url.searchParams.get("platform")) || "unknown";
const latestVersion = cleanText(env.HWLAB_NODE_PY_LATEST_VERSION) || "0.1.0";
const releaseNotesUrl = cleanText(env.HWLAB_NODE_PY_RELEASE_NOTES_URL) || null;
const downloadUrl = hwlabNodeDownloadUrl(env);
const sha256 = cleanText(env.HWLAB_NODE_PY_SHA256) || null;
const newer = currentVersion ? compareVersionStrings(latestVersion, currentVersion) > 0 : Boolean(latestVersion);
const updateAvailable = Boolean(downloadUrl && newer);
sendJson(response, 200, {
ok: true,
contractVersion: "hwlab-node-update-v1",
serviceId: "hwlab-node",
route: "/v1/hwlab-node/update",
channel,
platform,
currentVersion: currentVersion || null,
latestVersion,
updateAvailable,
downloadUrl: updateAvailable ? downloadUrl : null,
sha256: updateAvailable ? sha256 : null,
releaseNotesUrl,
manualDefault: true,
autoApplyDefault: false,
checkIntervalSeconds: parsePositiveInteger(env.HWLAB_NODE_PY_UPDATE_INTERVAL_SECONDS, 300),
observedAt: new Date().toISOString()
});
}
function hwlabNodeDownloadUrl(env) {
const explicit = cleanText(env.HWLAB_NODE_PY_DOWNLOAD_URL);
if (explicit) return explicit;
const downloadPath = cleanText(env.HWLAB_NODE_PY_DOWNLOAD_PATH);
if (!downloadPath) return null;
if (/^https?:\/\//iu.test(downloadPath)) return downloadPath;
const publicEndpoint = cleanText(env.HWLAB_PUBLIC_ENDPOINT) || "https://hwlab.pikapython.com";
try {
return new URL(downloadPath, publicEndpoint.endsWith("/") ? publicEndpoint : `${publicEndpoint}/`).toString();
} catch {
return null;
}
}
function compareVersionStrings(left, right) {
const a = versionParts(left);
const b = versionParts(right);
for (let index = 0; index < Math.max(a.length, b.length, 3); index += 1) {
const delta = (a[index] ?? 0) - (b[index] ?? 0);
if (delta !== 0) return delta > 0 ? 1 : -1;
}
return 0;
}
function versionParts(value) {
return String(value ?? "")
.trim()
.replace(/^v/iu, "")
.split(/[.+-]/u)
.slice(0, 4)
.map((part) => parseInt(part, 10))
.map((part) => (Number.isFinite(part) && part >= 0 ? part : 0));
}
function cleanText(value) {
const text = String(value ?? "").trim();
return text || "";
}
async function handleHwpodSpecDiscoveryHttp(request, response, url, options) {
const probe = truthyFlag(url.searchParams.get("probe"));
const observedAt = new Date().toISOString();
@@ -1041,7 +1125,7 @@ async function forwardHwpodNodeOpsPlan(targetUrl, plan, requestMeta, options) {
});
}
if (target.ok && target.nodeId && target.nodeId !== plan.nodeId) {
return directHwpodNodeBlocked(plan, "hwpod_node_not_connected", `${plan.nodeId} is not connected via WebSocket and HWLAB_HWPOD_NODE_OPS_URL points to ${target.nodeId}`, {
return directHwpodNodeBlocked(plan, "hwpod_node_id_mismatch", `HWLAB_HWPOD_NODE_OPS_URL points to ${target.nodeId}, not requested node ${plan.nodeId}`, {
requestedNodeId: plan.nodeId,
targetNodeId: target.nodeId,
targetUrl: redactNodeOpsUrl(targetUrl),
+1237
View File
File diff suppressed because it is too large Load Diff