755 lines
36 KiB
TypeScript
755 lines
36 KiB
TypeScript
/*
|
|
* SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。
|
|
* Implementation reference: draft-2026-07-13-p0-cloud-console。
|
|
* Responsibility: 验证 HWPOD typed topology、Node 更新元数据、主动出站调度与并发门禁。
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { test } from "bun:test";
|
|
|
|
import { createCloudApiBunServer } from "./bun-server.ts";
|
|
import { createHwpodNodeWsRegistry } from "./hwpod-node-ws-registry.ts";
|
|
import { createMemoryHwpodOperationLedger } from "./hwpod-operation-ledger.ts";
|
|
import { createCloudApiServer } from "./server.ts";
|
|
import { hwlabNodeBundledArtifact } from "../hwpod/node-artifact.ts";
|
|
import { connectHwpodNodeWs, createHwpodNodeServer } from "../../tools/src/hwpod-node-lib.ts";
|
|
|
|
function createHwpodTestCloudApiServer(options: any = {}) {
|
|
return createCloudApiServer({ accessController: allowAllAccessController(), ...options });
|
|
}
|
|
|
|
function allowAllAccessController() {
|
|
return {
|
|
required: false,
|
|
configureCodeAgentWorkspaceContext() {},
|
|
async requireNavAccess() { return true; },
|
|
async authenticate() {
|
|
return { ok: true, status: 200, actor: { id: "usr_hwpod_test", role: "admin" }, session: { id: "uss_hwpod_test" } };
|
|
}
|
|
};
|
|
}
|
|
|
|
test("cloud-api exposes hwpod-node-ops contract on /v1", async () => {
|
|
const server = createHwpodTestCloudApiServer();
|
|
await listen(server);
|
|
try {
|
|
const response = await fetch(`${serverUrl(server)}/v1`);
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.hwpod.contractVersion, "hwpod-node-ops-v1");
|
|
assert.equal(payload.hwpod.apiRole, "node-ops-forwarder");
|
|
assert.equal(payload.hwpod.specDiscoveryRoute, "/v1/hwpod/specs");
|
|
assert.equal(payload.hwpod.topologyRoute, "/v1/hwpod/topology");
|
|
assert.equal(payload.hwpod.topologyContractVersion, "hwpod-topology-v1");
|
|
assert.ok(payload.hwpod.supportedOps.includes("node.diagnostics"));
|
|
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.downloadRoute, "/v1/hwlab-node/download/hwlab-node.py");
|
|
assert.equal(payload.hwlabNode.diagnosticsOp, "node.diagnostics");
|
|
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 = createHwpodTestCloudApiServer({
|
|
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");
|
|
assert.equal(payload.artifact.fileName, "hwlab-node.py");
|
|
assert.equal(payload.artifact.sha256, "abc123");
|
|
assert.ok(payload.artifact.sizeBytes > 0);
|
|
assert.equal(payload.readinessStages.at(-1).id, "hwpod-ready");
|
|
assert.ok(payload.installConditions.some((item: any) => item.id === "outbound-websocket"));
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
test("cloud-api serves bundled hwlab-node Python updater artifact by default", async () => {
|
|
const bundled = hwlabNodeBundledArtifact();
|
|
const server = createHwpodTestCloudApiServer({ env: { PATH: process.env.PATH, HWLAB_PUBLIC_ENDPOINT: "https://hwlab.pikapython.com" } });
|
|
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.updateAvailable, true);
|
|
assert.equal(payload.latestVersion, bundled.version);
|
|
assert.equal(payload.downloadUrl, "https://hwlab.pikapython.com/v1/hwlab-node/download/hwlab-node.py");
|
|
assert.match(payload.sha256, /^[a-f0-9]{64}$/u);
|
|
assert.equal(payload.artifact.version, bundled.version);
|
|
assert.equal(payload.artifact.fileName, "hwlab-node.py");
|
|
assert.ok(payload.artifact.sizeBytes > 0);
|
|
|
|
const download = await fetch(`${serverUrl(server)}/v1/hwlab-node/download/hwlab-node.py`);
|
|
const text = await download.text();
|
|
assert.equal(download.status, 200);
|
|
assert.equal(download.headers.get("x-hwlab-node-version"), bundled.version);
|
|
assert.equal(text, bundled.content);
|
|
} finally {
|
|
await close(server);
|
|
}
|
|
});
|
|
|
|
test("cloud-api discovers preinstalled workspace hwpod-specs without hardcoded device constants", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-spec-discovery-"));
|
|
const specDir = path.join(root, ".hwlab");
|
|
await mkdir(specDir, { recursive: true });
|
|
await writeFile(path.join(specDir, "hwpod-spec.yaml"), sampleSpecYaml(), "utf8");
|
|
await writeFile(path.join(specDir, "hwpod-spec.meta.json"), JSON.stringify({ source: { kind: "preinstalled-verified-spec", caseRepo: "pikasTech/hwlab-case-registry", caseId: "d601-f103-v2-compile" } }), "utf8");
|
|
const server = createHwpodTestCloudApiServer({ env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: root } });
|
|
await listen(server);
|
|
try {
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod/specs`);
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.contractVersion, "hwpod-spec-discovery-v1");
|
|
assert.equal(payload.count, 1);
|
|
assert.equal(payload.specs[0].name, "d601-f103-v2");
|
|
assert.equal(payload.specs[0].uid, "D601-F103-V2");
|
|
assert.equal(payload.specs[0].nodeId, "node-d601-f103-v2");
|
|
assert.equal(payload.specs[0].source.caseRepo, "pikasTech/hwlab-case-registry");
|
|
assert.equal(payload.specs[0].source.caseId, "d601-f103-v2-compile");
|
|
} finally {
|
|
await close(server);
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cloud-api discovers configmap-mounted registry hwpod-spec symlinks", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-spec-registry-"));
|
|
const registryDir = path.join(root, "registry");
|
|
const dataDir = path.join(root, "data");
|
|
await mkdir(registryDir, { recursive: true });
|
|
await mkdir(dataDir, { recursive: true });
|
|
const targetPath = path.join(dataDir, "constart-71freq-c.yaml");
|
|
await writeFile(targetPath, sampleConstartSpecYaml(), "utf8");
|
|
await symlink(targetPath, path.join(registryDir, "constart-71freq-c.yaml"));
|
|
const server = createHwpodTestCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_WORKSPACE: root,
|
|
HWLAB_HWPOD_SPEC_REGISTRY_DIRS: registryDir
|
|
}
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod/specs`);
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.contractVersion, "hwpod-spec-discovery-v1");
|
|
const spec = payload.specs.find((item: any) => item.name === "constart-71freq-c");
|
|
assert.ok(spec);
|
|
assert.equal(spec.uid, "CONSTART-71FREQ-C");
|
|
assert.equal(spec.nodeId, "node-d601-f103-v2");
|
|
assert.equal(spec.workspacePath, "F:\\Work\\ConStart");
|
|
} finally {
|
|
await close(server);
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cloud-api probes discovered hwpod-spec availability through node-ops", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-spec-probe-"));
|
|
const specDir = path.join(root, ".hwlab");
|
|
await mkdir(specDir, { recursive: true });
|
|
await writeFile(path.join(specDir, "hwpod-spec.yaml"), sampleSpecYaml(), "utf8");
|
|
const seen: any[] = [];
|
|
const server = createHwpodTestCloudApiServer({
|
|
env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: root },
|
|
hwpodNodeOpsHandler: async (plan: any) => {
|
|
seen.push(plan);
|
|
return { ok: true, status: "completed", results: plan.ops.map((op: any) => ({ opId: op.opId, op: op.op, ok: true, status: "completed", output: { path: op.args.workspacePath } })) };
|
|
}
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod/specs?probe=1`);
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.count, 1);
|
|
assert.equal(payload.availableCount, 1);
|
|
assert.equal(payload.specs[0].availability.status, "available");
|
|
assert.equal(seen[0].hwpodId, "d601-f103-v2");
|
|
assert.equal(seen[0].nodeId, "node-d601-f103-v2");
|
|
assert.equal(seen[0].ops[0].op, "workspace.ls");
|
|
} finally {
|
|
await close(server);
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cloud-api serves the typed HWPOD topology read model from its existing owner", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-topology-"));
|
|
const probes: any[] = [];
|
|
const specDir = path.join(root, ".hwlab");
|
|
await mkdir(specDir, { recursive: true });
|
|
await writeFile(path.join(specDir, "hwpod-spec.yaml"), sampleSpecYaml(), "utf8");
|
|
const server = createHwpodTestCloudApiServer({
|
|
env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: root },
|
|
hwpodNodeOpsHandler: async (plan: any, context: any) => {
|
|
probes.push({ plan, context });
|
|
return { ok: true, status: "completed", planId: plan.planId, results: plan.ops.map((op: any) => ({ opId: op.opId, op: op.op, ok: true, status: "completed" })) };
|
|
},
|
|
hwpodNodeWsRegistry: {
|
|
hasNode: () => false,
|
|
dispatch: async () => ({ ok: false, status: "blocked", results: [] }),
|
|
describe: () => ({
|
|
mode: "hwpod-node-outbound-native-ws",
|
|
connectedCount: 1,
|
|
pendingCount: 0,
|
|
nodes: [{ nodeId: "node-d601-f103-v2", name: "D601 Python Node", status: "online", platform: "win32", version: "0.1.2-python-gui", capabilities: ["workspace.ls"], inFlightCount: 0, maxInFlight: 1 }]
|
|
})
|
|
}
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod/topology?resource=node`);
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.contractVersion, "hwpod-topology-v1");
|
|
assert.equal(payload.owner, "cloud-api-hwpod-domain");
|
|
assert.equal(payload.items[0].nodeId, "node-d601-f103-v2");
|
|
assert.equal(payload.items[0].deviceCount, 1);
|
|
assert.equal(payload.items[0].capabilityMismatch, true);
|
|
assert.equal(probes.length, 0);
|
|
const probeResponse = await fetch(`${serverUrl(server)}/v1/hwpod/topology?resource=node&probe=1`);
|
|
assert.equal(probeResponse.status, 200);
|
|
assert.equal(probes.length, 1);
|
|
assert.equal(probes[0].context.requestMeta.operationKind, "readiness-probe");
|
|
} finally {
|
|
await close(server);
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cloud-api forwards valid hwpod-node-ops plans to injected node handler", async () => {
|
|
const seen: any[] = [];
|
|
const server = createHwpodTestCloudApiServer({
|
|
hwpodNodeOpsHandler: async (plan: any, context: any) => {
|
|
seen.push({ plan, context });
|
|
return {
|
|
ok: true,
|
|
status: "completed",
|
|
results: plan.ops.map((op: any) => ({ opId: op.opId, op: op.op, ok: true, status: "completed", output: { sample: true } }))
|
|
};
|
|
}
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", "x-trace-id": "trc_hwpod_test" },
|
|
body: JSON.stringify(samplePlan())
|
|
});
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.ok, true);
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.contractVersion, "hwpod-node-ops-v1");
|
|
assert.equal(payload.results[0].op, "workspace.ls");
|
|
assert.equal(seen.length, 1);
|
|
assert.equal(seen[0].plan.nodeId, "pc-host-1");
|
|
assert.equal(seen[0].context.requestMeta.traceId, "trc_hwpod_test");
|
|
} finally {
|
|
await close(server);
|
|
}
|
|
});
|
|
|
|
test("cloud-api returns blocked payload when hwpod-node is not wired yet", async () => {
|
|
const server = createHwpodTestCloudApiServer();
|
|
const otelTraceId = "0123456789abcdef0123456789abcdef";
|
|
await listen(server);
|
|
try {
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", "x-hwlab-otel-trace-id": otelTraceId },
|
|
body: JSON.stringify(samplePlan())
|
|
});
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("x-hwlab-otel-trace-id"), otelTraceId);
|
|
assert.equal(payload.ok, false);
|
|
assert.equal(payload.status, "blocked");
|
|
assert.equal(payload.otelTraceId, otelTraceId);
|
|
assert.equal(payload.requestMeta.otelTraceId, otelTraceId);
|
|
assert.equal(payload.blocker.code, "hwpod_node_unavailable");
|
|
assert.equal(payload.blocker.otelTraceId, otelTraceId);
|
|
assert.equal(payload.blocker.diagnostic.traceLine, `OTel traceId: ${otelTraceId}`);
|
|
assert.equal(payload.results[0].blocker.layer, "hwpod-node");
|
|
assert.match(payload.results[0].blocker.userMessage, /OTel traceId:/u);
|
|
} finally {
|
|
await close(server);
|
|
}
|
|
});
|
|
|
|
test("cloud-api forwards hwpod-node-ops to configured thin hwpod-node URL", async () => {
|
|
const node = createHwpodNodeServer({ nodeId: "pc-host-1" });
|
|
await listen(node);
|
|
const server = createHwpodTestCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_HWPOD_NODE_OPS_URL: `${serverUrl(node)}/v1/hwpod-node-ops`
|
|
}
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const plan = samplePlan();
|
|
plan.ops[0].args.workspacePath = process.cwd();
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(plan)
|
|
});
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.ok, true);
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.results[0].op, "workspace.ls");
|
|
assert.equal(payload.results[0].output.entries.some((entry: any) => entry.name === "package.json"), true);
|
|
} finally {
|
|
await close(server);
|
|
await close(node);
|
|
}
|
|
});
|
|
|
|
test("cloud-api blocks direct hwpod-node URL when target node id does not match", async () => {
|
|
const node = createHwpodNodeServer({ nodeId: "g14-host-hwpod-node" });
|
|
await listen(node);
|
|
const server = createHwpodTestCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWPOD_NODE_ID: "g14-host-hwpod-node",
|
|
HWLAB_HWPOD_NODE_OPS_URL: `${serverUrl(node)}/v1/hwpod-node-ops`
|
|
}
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const plan = samplePlan();
|
|
plan.nodeId = "node-d601-f103-v2";
|
|
plan.ops[0].args.workspacePath = "F:\\Work\\HWLAB-CASE-F103";
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(plan)
|
|
});
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.ok, false);
|
|
assert.equal(payload.status, "blocked");
|
|
assert.equal(payload.blocker.code, "hwpod_node_id_mismatch");
|
|
assert.equal(payload.blocker.details.requestedNodeId, "node-d601-f103-v2");
|
|
assert.equal(payload.blocker.details.targetNodeId, "g14-host-hwpod-node");
|
|
assert.equal(payload.results[0].blocker.code, "hwpod_node_id_mismatch");
|
|
} finally {
|
|
await close(server);
|
|
await close(node);
|
|
}
|
|
});
|
|
|
|
test("cloud-api blocks direct hwpod-node URL when node identity is not visible", async () => {
|
|
const target = createHwpodTestCloudApiServer({
|
|
hwpodNodeOpsHandler: async (plan: any) => ({
|
|
ok: true,
|
|
status: "completed",
|
|
results: plan.ops.map((op: any) => ({ opId: op.opId, op: op.op, ok: true, status: "completed", output: { shouldNotRun: true } }))
|
|
})
|
|
});
|
|
await listen(target);
|
|
const server = createHwpodTestCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_HWPOD_NODE_OPS_URL: `${serverUrl(target)}/v1/hwpod-node-ops`
|
|
}
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(samplePlan())
|
|
});
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.ok, false);
|
|
assert.equal(payload.status, "blocked");
|
|
assert.equal(payload.blocker.code, "hwpod_node_identity_unverified");
|
|
assert.equal(payload.results[0].output, undefined);
|
|
} finally {
|
|
await close(server);
|
|
await close(target);
|
|
}
|
|
});
|
|
|
|
test("cloud-api dispatches hwpod-node-ops to outbound WebSocket hwpod-node by nodeId", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-ws-"));
|
|
const runtime = await createCloudApiBunServer({ host: "127.0.0.1", port: 0, env: { PATH: process.env.PATH }, accessController: allowAllAccessController() });
|
|
const connector = connectHwpodNodeWs({ cloudUrl: runtime.url, nodeId: "pc-host-1", reconnect: false, heartbeatIntervalMs: 1000 });
|
|
try {
|
|
await writeFile(path.join(root, "main.c"), "int main(void) { return 0; }\n", "utf8");
|
|
await withTimeout(connector.ready, 3000, "hwpod-node WebSocket registration timed out");
|
|
const plan = samplePlan();
|
|
plan.ops[0].args.workspacePath = root;
|
|
const response = await fetch(`${runtime.url}/v1/hwpod-node-ops`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(plan)
|
|
});
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.ok, true);
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.nodeId, "pc-host-1");
|
|
assert.equal(payload.results[0].op, "workspace.ls");
|
|
assert.equal(payload.results[0].output.entries.some((entry: any) => entry.name === "main.c"), true);
|
|
const recoveredResponse = await fetch(`${runtime.url}/v1/hwpod-node-ops?planId=${encodeURIComponent(plan.planId)}`);
|
|
const recovered = await recoveredResponse.json();
|
|
assert.equal(recoveredResponse.status, 200);
|
|
assert.equal(recovered.status, "completed");
|
|
assert.equal(recovered.result.planId, payload.planId);
|
|
assert.equal(recovered.result.status, payload.status);
|
|
assert.deepEqual(recovered.result.results, payload.results);
|
|
} finally {
|
|
connector.close();
|
|
runtime.stop();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("WebSocket registry reuses one authoritative operation for concurrent duplicate admission", async () => {
|
|
const sent: any[] = [];
|
|
let dispatchCount = 0;
|
|
const socket = { send(value: string) { const message = JSON.parse(value); sent.push(message); if (message.type === "hwpod-node-ops") dispatchCount += 1; }, close() {} };
|
|
const registry = createHwpodNodeWsRegistry({ now: () => "2026-07-17T02:00:00.000Z" });
|
|
registry.openBunSocket(socket);
|
|
registry.handleBunMessage(socket, JSON.stringify({ type: "register", nodeId: "pc-host-1", name: "PC Host", capabilities: ["workspace.ls"], maxInFlight: 2 }));
|
|
const plan = samplePlan();
|
|
plan.nodeId = "pc-host-1";
|
|
plan.planId = "harnessrl-run:build:v1";
|
|
const first = registry.dispatch(plan, { requestId: "req_first" }, { timeoutMs: 5000 });
|
|
const retry = registry.dispatch(plan, { requestId: "req_retry" }, { timeoutMs: 5000 });
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
assert.equal(dispatchCount, 1);
|
|
registry.handleBunMessage(socket, JSON.stringify({ type: "hwpod-node-ops-result", requestId: "req_first", nodeId: "pc-host-1", result: { ok: true, status: "completed", results: [{ opId: "op_1", ok: true }] } }));
|
|
assert.deepEqual(await retry, await first);
|
|
assert.equal((await registry.lookup(plan.planId))?.result?.status, "completed");
|
|
const recovered = await registry.dispatch(plan, { requestId: "req_after_completion" }, { timeoutMs: 5000 });
|
|
assert.equal(recovered.status, "completed");
|
|
assert.equal(dispatchCount, 1);
|
|
registry.closeBunSocket(socket);
|
|
});
|
|
|
|
test("WebSocket registry rebuild recovers one durable plan result without redispatch", async () => {
|
|
const records = new Map();
|
|
let dispatchCount = 0;
|
|
const firstLedger = createMemoryHwpodOperationLedger({ records, now: () => "2026-07-17T02:40:00.000Z" });
|
|
const firstRegistry = createHwpodNodeWsRegistry({ operationLedger: firstLedger, now: () => "2026-07-17T02:40:00.000Z" });
|
|
const firstSocket = { send(value: string) { const message = JSON.parse(value); if (message.type !== "hwpod-node-ops") return; dispatchCount += 1; queueMicrotask(() => { firstRegistry.handleBunMessage(firstSocket, JSON.stringify({ type: "hwpod-node-ops-accepted", requestId: message.requestId, nodeId: "pc-host-1" })); firstRegistry.handleBunMessage(firstSocket, JSON.stringify({ type: "hwpod-node-ops-result", requestId: message.requestId, nodeId: "pc-host-1", result: { ok: true, status: "completed", planId: message.plan.planId, results: [] } })); }); }, close() {} };
|
|
firstRegistry.openBunSocket(firstSocket);
|
|
firstRegistry.handleBunMessage(firstSocket, JSON.stringify({ type: "register", nodeId: "pc-host-1", maxInFlight: 1 }));
|
|
const plan = { ...samplePlan(), nodeId: "pc-host-1", planId: "restart-safe-plan" };
|
|
const firstResult = await firstRegistry.dispatch(plan, { requestId: "req_first" }, { timeoutMs: 1000 });
|
|
firstRegistry.closeBunSocket(firstSocket);
|
|
|
|
const rebuiltLedger = createMemoryHwpodOperationLedger({ records, now: () => "2026-07-17T02:41:00.000Z" });
|
|
const rebuiltRegistry = createHwpodNodeWsRegistry({ operationLedger: rebuiltLedger, now: () => "2026-07-17T02:41:00.000Z" });
|
|
const recovered = await rebuiltRegistry.dispatch(plan, { requestId: "req_rebuilt" }, { timeoutMs: 1000 });
|
|
assert.deepEqual(recovered, firstResult);
|
|
assert.equal((await rebuiltRegistry.lookup(plan.planId))?.result?.status, "completed");
|
|
assert.equal(dispatchCount, 1);
|
|
});
|
|
|
|
test("WebSocket registry retries the same plan identity after pre-accept send failure", async () => {
|
|
const records = new Map();
|
|
const plan = { ...samplePlan(), nodeId: "pc-host-1", planId: "retry-same-plan" };
|
|
const registry = createHwpodNodeWsRegistry({ operationLedger: createMemoryHwpodOperationLedger({ records }) });
|
|
let failSend = true;
|
|
let acceptanceCount = 0;
|
|
let dispatchCount = 0;
|
|
const socket = { send(value: string) { const message = JSON.parse(value); if (message.type !== "hwpod-node-ops") return; dispatchCount += 1; if (failSend) { failSend = false; throw new Error("connection failed before acceptance"); } queueMicrotask(() => { acceptanceCount += 1; registry.handleBunMessage(socket, JSON.stringify({ type: "hwpod-node-ops-accepted", requestId: message.requestId, nodeId: "pc-host-1" })); registry.handleBunMessage(socket, JSON.stringify({ type: "hwpod-node-ops-result", requestId: message.requestId, nodeId: "pc-host-1", result: { ok: true, status: "completed", planId: message.plan.planId, results: [] } })); }); }, close() {} };
|
|
registry.openBunSocket(socket);
|
|
registry.handleBunMessage(socket, JSON.stringify({ type: "register", nodeId: "pc-host-1", maxInFlight: 1 }));
|
|
assert.equal((await registry.dispatch(plan, { requestId: "req_failed" }, { timeoutMs: 1000 })).status, "blocked");
|
|
const result = await registry.dispatch(plan, { requestId: "req_retry" }, { timeoutMs: 1000 });
|
|
assert.equal(result.status, "completed");
|
|
assert.equal(result.planId, plan.planId);
|
|
assert.equal(dispatchCount, 2);
|
|
assert.equal(acceptanceCount, 1);
|
|
});
|
|
|
|
test("WebSocket registry clears pre-accept timeout and disconnect for same-process retry", async () => {
|
|
for (const failureMode of ["timeout", "disconnect"] as const) {
|
|
const registry = createHwpodNodeWsRegistry({ operationLedger: createMemoryHwpodOperationLedger() });
|
|
const plan = { ...samplePlan(), nodeId: "pc-host-1", planId: `retry-${failureMode}-plan` };
|
|
const failedSocket = { send() {}, close() {} };
|
|
registry.openBunSocket(failedSocket);
|
|
registry.handleBunMessage(failedSocket, JSON.stringify({ type: "register", nodeId: "pc-host-1", maxInFlight: 1 }));
|
|
const failedPromise = registry.dispatch(plan, { requestId: `req_${failureMode}_failed` }, { timeoutMs: 10 });
|
|
if (failureMode === "disconnect") registry.closeBunSocket(failedSocket);
|
|
assert.equal((await failedPromise).status, "blocked");
|
|
|
|
let acceptanceCount = 0;
|
|
const retrySocket = { send(value: string) { const message = JSON.parse(value); if (message.type !== "hwpod-node-ops") return; queueMicrotask(() => { acceptanceCount += 1; registry.handleBunMessage(retrySocket, JSON.stringify({ type: "hwpod-node-ops-accepted", requestId: message.requestId, nodeId: "pc-host-1" })); registry.handleBunMessage(retrySocket, JSON.stringify({ type: "hwpod-node-ops-result", requestId: message.requestId, nodeId: "pc-host-1", result: { ok: true, status: "completed", planId: message.plan.planId, results: [] } })); }); }, close() {} };
|
|
registry.openBunSocket(retrySocket);
|
|
registry.handleBunMessage(retrySocket, JSON.stringify({ type: "register", nodeId: "pc-host-1", maxInFlight: 1 }));
|
|
assert.equal((await registry.dispatch(plan, { requestId: `req_${failureMode}_retry` }, { timeoutMs: 1000 })).status, "completed");
|
|
assert.equal(acceptanceCount, 1);
|
|
}
|
|
});
|
|
|
|
test("WebSocket registry replays an accepted plan after disconnect without rebuilding registry", async () => {
|
|
const registry = createHwpodNodeWsRegistry({ operationLedger: createMemoryHwpodOperationLedger() });
|
|
const plan = { ...samplePlan(), nodeId: "pc-host-1", planId: "accepted-disconnect-replay-plan" };
|
|
const executedPlans = new Set<string>();
|
|
const dispatchedPlanIds: string[] = [];
|
|
let authoritativeExecutionCount = 0;
|
|
let dispatchCount = 0;
|
|
const firstSocket = {
|
|
send(value: string) {
|
|
const message = JSON.parse(value);
|
|
if (message.type !== "hwpod-node-ops") return;
|
|
dispatchCount += 1;
|
|
dispatchedPlanIds.push(message.plan.planId);
|
|
if (!executedPlans.has(message.plan.planId)) {
|
|
executedPlans.add(message.plan.planId);
|
|
authoritativeExecutionCount += 1;
|
|
}
|
|
queueMicrotask(() => {
|
|
registry.handleBunMessage(firstSocket, JSON.stringify({ type: "hwpod-node-ops-accepted", requestId: message.requestId, nodeId: "pc-host-1" }));
|
|
registry.closeBunSocket(firstSocket);
|
|
});
|
|
},
|
|
close() {}
|
|
};
|
|
registry.openBunSocket(firstSocket);
|
|
registry.handleBunMessage(firstSocket, JSON.stringify({ type: "register", nodeId: "pc-host-1", maxInFlight: 1 }));
|
|
const first = await registry.dispatch(plan, { requestId: "req_accepted_disconnected" }, { timeoutMs: 1000 });
|
|
assert.equal(first.status, "blocked");
|
|
|
|
const replaySocket = {
|
|
send(value: string) {
|
|
const message = JSON.parse(value);
|
|
if (message.type !== "hwpod-node-ops") return;
|
|
dispatchCount += 1;
|
|
dispatchedPlanIds.push(message.plan.planId);
|
|
const replayed = executedPlans.has(message.plan.planId);
|
|
if (!replayed) {
|
|
executedPlans.add(message.plan.planId);
|
|
authoritativeExecutionCount += 1;
|
|
}
|
|
queueMicrotask(() => {
|
|
registry.handleBunMessage(replaySocket, JSON.stringify({ type: "hwpod-node-ops-accepted", requestId: message.requestId, nodeId: "pc-host-1" }));
|
|
registry.handleBunMessage(replaySocket, JSON.stringify({ type: "hwpod-node-ops-result", requestId: message.requestId, nodeId: "pc-host-1", result: { ok: true, status: "completed", planId: message.plan.planId, replayed, results: [] } }));
|
|
});
|
|
},
|
|
close() {}
|
|
};
|
|
registry.openBunSocket(replaySocket);
|
|
registry.handleBunMessage(replaySocket, JSON.stringify({ type: "register", nodeId: "pc-host-1", maxInFlight: 1 }));
|
|
const recovered = await registry.dispatch(plan, { requestId: "req_accepted_replay" }, { timeoutMs: 1000 });
|
|
assert.equal(recovered.status, "completed");
|
|
assert.equal(recovered.replayed, true);
|
|
assert.deepEqual(dispatchedPlanIds, [plan.planId, plan.planId]);
|
|
assert.equal(authoritativeExecutionCount, 1);
|
|
assert.equal(dispatchCount, 2);
|
|
});
|
|
|
|
test("WebSocket registry bounds ledger write failures and permits node replay recovery", async () => {
|
|
const records = new Map();
|
|
const memoryLedger = createMemoryHwpodOperationLedger({ records });
|
|
let failComplete = true;
|
|
const operationLedger = { ...memoryLedger, async complete(planId: string, result: any) { if (failComplete) { failComplete = false; throw new Error("database unavailable"); } return memoryLedger.complete(planId, result); } };
|
|
const registry = createHwpodNodeWsRegistry({ operationLedger });
|
|
const plan = { ...samplePlan(), nodeId: "pc-host-1", planId: "ledger-replay-plan" };
|
|
let dispatchCount = 0;
|
|
const socket = { send(value: string) { const message = JSON.parse(value); if (message.type !== "hwpod-node-ops") return; dispatchCount += 1; queueMicrotask(() => { registry.handleBunMessage(socket, JSON.stringify({ type: "hwpod-node-ops-accepted", requestId: message.requestId, nodeId: "pc-host-1" })); registry.handleBunMessage(socket, JSON.stringify({ type: "hwpod-node-ops-result", requestId: message.requestId, nodeId: "pc-host-1", result: { ok: true, status: "completed", planId: message.plan.planId, replayed: dispatchCount > 1, results: [] } })); }); }, close() {} };
|
|
registry.openBunSocket(socket);
|
|
registry.handleBunMessage(socket, JSON.stringify({ type: "register", nodeId: "pc-host-1", maxInFlight: 1 }));
|
|
const first = await registry.dispatch(plan, { requestId: "req_ledger_failed" }, { timeoutMs: 1000 });
|
|
assert.equal(first.status, "blocked");
|
|
assert.equal(first.blocker.code, "hwpod_operation_ledger_write_failed");
|
|
const recovered = await registry.dispatch(plan, { requestId: "req_ledger_replay" }, { timeoutMs: 1000 });
|
|
assert.equal(recovered.status, "completed");
|
|
assert.equal(recovered.replayed, true);
|
|
assert.equal(dispatchCount, 2);
|
|
});
|
|
|
|
test("WebSocket registry bounds accepted-ledger failure without returning hardware success", async () => {
|
|
const memoryLedger = createMemoryHwpodOperationLedger();
|
|
let failAccept = true;
|
|
const operationLedger = { ...memoryLedger, async accept(planId: string) { if (failAccept) { failAccept = false; throw new Error("database unavailable"); } return memoryLedger.accept(planId); } };
|
|
const registry = createHwpodNodeWsRegistry({ operationLedger });
|
|
const plan = { ...samplePlan(), nodeId: "pc-host-1", planId: "ledger-accept-replay-plan" };
|
|
let dispatchCount = 0;
|
|
const socket = { send(value: string) { const message = JSON.parse(value); if (message.type !== "hwpod-node-ops") return; dispatchCount += 1; queueMicrotask(() => { registry.handleBunMessage(socket, JSON.stringify({ type: "hwpod-node-ops-accepted", requestId: message.requestId, nodeId: "pc-host-1" })); registry.handleBunMessage(socket, JSON.stringify({ type: "hwpod-node-ops-result", requestId: message.requestId, nodeId: "pc-host-1", result: { ok: true, status: "completed", planId: message.plan.planId, replayed: dispatchCount > 1, results: [] } })); }); }, close() {} };
|
|
registry.openBunSocket(socket);
|
|
registry.handleBunMessage(socket, JSON.stringify({ type: "register", nodeId: "pc-host-1", maxInFlight: 1 }));
|
|
const first = await registry.dispatch(plan, { requestId: "req_accept_failed" }, { timeoutMs: 1000 });
|
|
assert.equal(first.status, "blocked");
|
|
assert.equal(first.blocker.code, "hwpod_operation_ledger_write_failed");
|
|
const recovered = await registry.dispatch(plan, { requestId: "req_accept_replay" }, { timeoutMs: 1000 });
|
|
assert.equal(recovered.status, "completed");
|
|
assert.equal(recovered.replayed, true);
|
|
assert.equal(dispatchCount, 2);
|
|
});
|
|
|
|
test("WebSocket registry keeps readiness probes separate and rejects dispatch above maxInFlight", async () => {
|
|
const sent: any[] = [];
|
|
const socket = { send(value: string) { sent.push(JSON.parse(value)); }, close() {} };
|
|
const registry = createHwpodNodeWsRegistry({ now: () => "2026-07-13T02:00:00.000Z" });
|
|
registry.openBunSocket(socket);
|
|
registry.handleBunMessage(socket, JSON.stringify({ type: "register", nodeId: "pc-host-1", name: "PC Host", capabilities: ["workspace.ls"], maxInFlight: 1 }));
|
|
const plan = samplePlan();
|
|
plan.nodeId = "pc-host-1";
|
|
const probePromise = registry.dispatch(plan, { requestId: "req_probe", operationKind: "readiness-probe" }, { timeoutMs: 5000 });
|
|
const busy = await registry.dispatch(plan, { requestId: "req_user" }, { timeoutMs: 5000 });
|
|
assert.equal(busy.blocker.code, "hwpod_node_busy");
|
|
assert.equal(registry.describe().nodes[0].latestOperation, null);
|
|
assert.equal(registry.describe().nodes[0].latestReadinessProbe.status, "running");
|
|
registry.handleBunMessage(socket, JSON.stringify({ type: "hwpod-node-ops-result", requestId: "req_probe", nodeId: "pc-host-1", result: { ok: true, status: "completed", results: [] } }));
|
|
await probePromise;
|
|
assert.equal(registry.describe().nodes[0].latestReadinessProbe.status, "completed");
|
|
assert.equal(sent.filter((message) => message.type === "hwpod-node-ops").length, 1);
|
|
registry.closeBunSocket(socket);
|
|
});
|
|
|
|
test("cloud-api rejects unsupported hwpod-node ops before forwarding", async () => {
|
|
const server = createHwpodTestCloudApiServer({
|
|
hwpodNodeOpsHandler: async () => {
|
|
throw new Error("must not be called");
|
|
}
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const plan = samplePlan();
|
|
plan.ops[0].op = "debug.flash";
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(plan)
|
|
});
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 400);
|
|
assert.equal(payload.ok, false);
|
|
assert.equal(payload.error.code, "unsupported_hwpod_node_op");
|
|
} finally {
|
|
await close(server);
|
|
}
|
|
});
|
|
|
|
function samplePlan() {
|
|
return {
|
|
contractVersion: "hwpod-node-ops-v1",
|
|
planId: "hwpod_plan_test",
|
|
hwpodId: "hwpod-local",
|
|
nodeId: "pc-host-1",
|
|
intent: "workspace.ls",
|
|
ops: [
|
|
{ opId: "op_01_workspace_ls", op: "workspace.ls", args: { workspacePath: "/workspace/fw", path: "." } }
|
|
]
|
|
};
|
|
}
|
|
|
|
function sampleSpecYaml() {
|
|
return `apiVersion: hwlab.dev/v0alpha1
|
|
kind: Hwpod
|
|
metadata:
|
|
uid: D601-F103-V2
|
|
name: d601-f103-v2
|
|
spec:
|
|
targetDevice:
|
|
board: D601-F103-V2
|
|
mcu: STM32F103
|
|
workspace:
|
|
path: "F:\\\\Work\\\\HWLAB-CASE-F103"
|
|
toolchain: keil-mdk
|
|
debugProbe:
|
|
type: daplink
|
|
ioProbe:
|
|
uart:
|
|
port: COM9
|
|
nodeBinding:
|
|
nodeId: node-d601-f103-v2
|
|
nodeType: pc-host
|
|
`;
|
|
}
|
|
|
|
function sampleConstartSpecYaml() {
|
|
return `apiVersion: hwlab.dev/v0alpha1
|
|
kind: Hwpod
|
|
metadata:
|
|
uid: CONSTART-71FREQ-C
|
|
name: constart-71freq-c
|
|
spec:
|
|
targetDevice:
|
|
board: ConStart 71-FREQ Controller
|
|
mcu: STM32F103
|
|
workspace:
|
|
path: 'F:\\Work\\ConStart'
|
|
toolchain: keil-mdk
|
|
debugProbe:
|
|
type: daplink
|
|
serialNumber: 3FD750C63E342E24
|
|
ioProbe:
|
|
uart:
|
|
port: COM4
|
|
baudRate: 921600
|
|
nodeBinding:
|
|
nodeId: node-d601-f103-v2
|
|
nodeType: pc-host
|
|
`;
|
|
}
|
|
|
|
async function listen(server: any) {
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
}
|
|
|
|
async function close(server: any) {
|
|
await new Promise((resolve, reject) => server.close((error: Error | undefined) => (error ? reject(error) : resolve(undefined))));
|
|
}
|
|
|
|
function serverUrl(server: any) {
|
|
return `http://127.0.0.1:${server.address().port}`;
|
|
}
|
|
|
|
async function withTimeout(promise: Promise<unknown>, timeoutMs: number, message: string) {
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
try {
|
|
return await Promise.race([
|
|
promise,
|
|
new Promise((_resolve, reject) => {
|
|
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
})
|
|
]);
|
|
} finally {
|
|
if (timer !== null) clearTimeout(timer);
|
|
}
|
|
}
|