From 2ec436a89e02a8c61ee9687ce308d77851bb9854 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 13 Jul 2026 04:48:04 +0200 Subject: [PATCH] =?UTF-8?q?fix(hwpod):=20=E6=94=B6=E7=B4=A7=E6=8B=93?= =?UTF-8?q?=E6=89=91=E6=8E=A2=E6=B5=8B=E4=B8=8E=E6=B7=B1=E9=93=BE=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cloud/hwpod-node-ops.test.ts | 36 ++ internal/cloud/hwpod-node-ws-registry.ts | 46 +- .../cloud/hwpod-topology-read-model.test.ts | 6 + internal/cloud/hwpod-topology-read-model.ts | 9 +- internal/cloud/server-hwpod-http.ts | 16 +- internal/cloud/server.ts | 2 +- web/hwlab-cloud-web/src/api/hwpod.ts | 10 +- .../src/components/common/EntityCard.vue | 4 +- .../common/ResourceCollection.test.ts | 19 +- .../components/common/ResourceCollection.vue | 7 +- .../src/components/hwpod/HwpodDeviceCard.vue | 1 + .../components/hwpod/HwpodReadinessRail.vue | 1 + .../composables/useConsoleViewState.test.ts | 19 +- web/hwlab-cloud-web/src/router/index.ts | 6 +- web/hwlab-cloud-web/src/stores/hwpod.ts | 54 +- .../src/styles/console-foundation.css | 10 + web/hwlab-cloud-web/src/types/index.ts | 7 +- .../src/views/admin/HwpodGroupsView.vue | 537 ++++++++++++------ 18 files changed, 563 insertions(+), 227 deletions(-) diff --git a/internal/cloud/hwpod-node-ops.test.ts b/internal/cloud/hwpod-node-ops.test.ts index ee904f88..b156e4ec 100644 --- a/internal/cloud/hwpod-node-ops.test.ts +++ b/internal/cloud/hwpod-node-ops.test.ts @@ -1,3 +1,8 @@ +/* + * 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"; @@ -5,6 +10,7 @@ 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 { createCloudApiServer } from "./server.ts"; import { connectHwpodNodeWs, createHwpodNodeServer } from "../../tools/src/hwpod-node-lib.ts"; @@ -199,11 +205,16 @@ test("cloud-api probes discovered hwpod-spec availability through node-ops", asy 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: [] }), @@ -225,6 +236,11 @@ test("cloud-api serves the typed HWPOD topology read model from its existing own 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 }); @@ -417,6 +433,26 @@ test("cloud-api dispatches hwpod-node-ops to outbound WebSocket hwpod-node by no } }); +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 () => { diff --git a/internal/cloud/hwpod-node-ws-registry.ts b/internal/cloud/hwpod-node-ws-registry.ts index 37d1150f..89c4ea8c 100644 --- a/internal/cloud/hwpod-node-ws-registry.ts +++ b/internal/cloud/hwpod-node-ws-registry.ts @@ -1,3 +1,8 @@ +/* + * SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。 + * Implementation reference: draft-2026-07-13-p0-cloud-console。 + * Responsibility: 管理主动出站 HWPOD Node 连接、并发门禁,以及用户操作与 readiness 探测的独立摘要。 + */ import { randomUUID } from "node:crypto"; const DEFAULT_DISPATCH_TIMEOUT_MS = 30000; @@ -16,6 +21,7 @@ type HwpodNodeConnection = { }; maxInFlight: number; latestOperation: Record | null; + latestReadinessProbe: Record | null; diagnostics: any[]; firstSeenAt: string; lastSeenAt: string; @@ -25,6 +31,8 @@ type PendingDispatch = { nodeId: string; requestId: string; connection: HwpodNodeConnection; + operationKind: "user-operation" | "readiness-probe"; + operation: Record; timer: ReturnType; resolve: (value: any) => void; }; @@ -48,6 +56,7 @@ export function createHwpodNodeWsRegistry(options: any = {}) { runtime: { kind: null, installed: null, desktopVisible: null }, maxInFlight: 1, latestOperation: null, + latestReadinessProbe: null, diagnostics: [], firstSeenAt: now(), lastSeenAt: now() @@ -76,22 +85,29 @@ export function createHwpodNodeWsRegistry(options: any = {}) { if (!nodeId || !connection) { return Promise.resolve(blockedDispatch(plan, requestMeta, `hwpod-node ${nodeId || ""} is not connected through outbound WebSocket`)); } + if (inFlightCountForNode(nodeId, pending) >= connection.maxInFlight) { + return Promise.resolve(blockedDispatch(plan, requestMeta, `hwpod-node ${nodeId} reached its maxInFlight=${connection.maxInFlight} limit`, "hwpod_node_busy")); + } const requestId = safeText(requestMeta.requestId) || `req_hwpod_ws_${randomUUID()}`; const timeoutMs = positiveInteger(dispatchOptions.timeoutMs, DEFAULT_DISPATCH_TIMEOUT_MS); - connection.latestOperation = operationStarted(plan, requestId, now()); + const operationKind = safeText(requestMeta.operationKind) === "readiness-probe" ? "readiness-probe" : "user-operation"; + const operation = operationStarted(plan, requestId, now()); + if (operationKind === "readiness-probe") connection.latestReadinessProbe = operation; + else connection.latestOperation = operation; return new Promise((resolve) => { const timer = setTimeout(() => { pending.delete(requestId); - connection.latestOperation = operationFinished(connection.latestOperation, "timed-out", now(), "hwpod_node_unavailable"); + finishPendingOperation({ nodeId, requestId, connection, operationKind, operation, timer, resolve }, "timed-out", now(), "hwpod_node_unavailable"); resolve(blockedDispatch(plan, requestMeta, `hwpod-node ${nodeId} WebSocket dispatch timed out after ${timeoutMs}ms`)); }, timeoutMs); - pending.set(requestId, { nodeId, requestId, connection, timer, resolve }); + const waiter = { nodeId, requestId, connection, operationKind, operation, timer, resolve } satisfies PendingDispatch; + pending.set(requestId, waiter); try { sendJson(connection, { type: "hwpod-node-ops", requestId, plan, requestMeta }); } catch (error) { clearTimeout(timer); pending.delete(requestId); - connection.latestOperation = operationFinished(connection.latestOperation, "failed", now(), "hwpod_node_unavailable"); + finishPendingOperation(waiter, "failed", now(), "hwpod_node_unavailable"); resolve(blockedDispatch(plan, requestMeta, error instanceof Error ? error.message : String(error))); } }); @@ -119,6 +135,7 @@ export function createHwpodNodeWsRegistry(options: any = {}) { inFlightCount: inFlightCountForNode(connection.nodeId, pending), busy: inFlightCountForNode(connection.nodeId, pending) >= connection.maxInFlight, latestOperation: connection.latestOperation, + latestReadinessProbe: connection.latestReadinessProbe, diagnosticCount: connection.diagnostics.length, lastDiagnostic: connection.diagnostics.at(-1) ?? null, diagnostics: connection.diagnostics.slice(-10), @@ -186,8 +203,8 @@ export function createHwpodNodeWsRegistry(options: any = {}) { const result = message.result && typeof message.result === "object" && !Array.isArray(message.result) ? message.result : { ok: false, status: "failed", results: [], blocker: { code: "hwpod_node_result_invalid", layer: "hwpod-node", retryable: true, summary: "hwpod-node WebSocket result payload is invalid" } }; - waiter.connection.latestOperation = operationFinished( - waiter.connection.latestOperation, + finishPendingOperation( + waiter, safeText(result.status) || (result.ok === false ? "failed" : "completed"), now(), safeText(result.blocker?.code) || null @@ -201,7 +218,7 @@ export function createHwpodNodeWsRegistry(options: any = {}) { if (waiter.nodeId !== connection.nodeId) continue; clearTimeout(waiter.timer); pending.delete(requestId); - waiter.connection.latestOperation = operationFinished(waiter.connection.latestOperation, "disconnected", now(), "hwpod_node_unavailable"); + finishPendingOperation(waiter, "disconnected", now(), "hwpod_node_unavailable"); waiter.resolve(blockedDispatch({ nodeId: waiter.nodeId, ops: [] }, { requestId }, `hwpod-node ${waiter.nodeId} WebSocket disconnected before returning result`)); } } @@ -213,7 +230,7 @@ function sendJson(connection: HwpodNodeConnection, value: any) { connection.socket.send(JSON.stringify(value)); } -function blockedDispatch(plan: any, requestMeta: any, summary: string) { +function blockedDispatch(plan: any, requestMeta: any, summary: string, code = "hwpod_node_unavailable") { const ops = Array.isArray(plan?.ops) ? plan.ops : []; return { ok: false, @@ -223,9 +240,9 @@ function blockedDispatch(plan: any, requestMeta: any, summary: string) { op: safeText(op?.op) || "unknown", ok: false, status: "blocked", - blocker: { code: "hwpod_node_unavailable", layer: "hwpod-node", retryable: true, summary } + blocker: { code, layer: "hwpod-node", retryable: true, summary } })), - blocker: { code: "hwpod_node_unavailable", layer: "hwpod-node", retryable: true, summary }, + blocker: { code, layer: "hwpod-node", retryable: true, summary }, requestMeta }; } @@ -283,6 +300,15 @@ function operationFinished(operation: Record | null, status: st }; } +function finishPendingOperation(waiter: PendingDispatch, status: string, finishedAt: string, blockerCode: string | null) { + const finished = operationFinished(waiter.operation, status, finishedAt, blockerCode); + if (waiter.operationKind === "readiness-probe") { + if (waiter.connection.latestReadinessProbe?.requestId === waiter.requestId) waiter.connection.latestReadinessProbe = finished; + return; + } + if (waiter.connection.latestOperation?.requestId === waiter.requestId) waiter.connection.latestOperation = finished; +} + function inFlightCountForNode(nodeId: string | null, pending: Map) { if (!nodeId) return 0; let count = 0; diff --git a/internal/cloud/hwpod-topology-read-model.test.ts b/internal/cloud/hwpod-topology-read-model.test.ts index 9b8ba75f..16348dd1 100644 --- a/internal/cloud/hwpod-topology-read-model.test.ts +++ b/internal/cloud/hwpod-topology-read-model.test.ts @@ -1,3 +1,8 @@ +/* + * SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。 + * Implementation reference: draft-2026-07-13-p0-cloud-console。 + * Responsibility: 验证 HWPOD typed topology 的状态、脱敏、筛选与双向 cursor。 + */ import assert from "node:assert/strict"; import { test } from "bun:test"; @@ -73,6 +78,7 @@ test("typed topology keeps server-side status filtering and cursor bounded", () const second = buildHwpodTopologyReadModel(specs, registry, { resource: "device", status: "available", limit: 1, cursor: first.page.nextCursor }); assert.equal(second.items.length, 1); assert.notEqual(first.items[0].hwpodId, second.items[0].hwpodId); + assert.equal(second.page.previousCursor, "b2Zmc2V0OjA"); }); function spec(hwpodId: string, nodeId: string, capabilities: string[], available: boolean | null) { diff --git a/internal/cloud/hwpod-topology-read-model.ts b/internal/cloud/hwpod-topology-read-model.ts index 68a1a2be..ffa50071 100644 --- a/internal/cloud/hwpod-topology-read-model.ts +++ b/internal/cloud/hwpod-topology-read-model.ts @@ -1,7 +1,7 @@ /* - * SPEC: PJ2026-010405 云端控制台; PJ2026-010103 HWPOD服务. - * Implementation reference: draft-2026-07-13-p0-cloud-console. - * Responsibility: bounded typed HWPOD node/device topology owned by the Cloud API HWPOD domain. + * SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。 + * Implementation reference: draft-2026-07-13-p0-cloud-console。 + * Responsibility: 由 Cloud API HWPOD domain 生成有界 typed Node/设备 topology,不让浏览器拼装业务真相。 */ export const HWPOD_TOPOLOGY_CONTRACT_VERSION = "hwpod-topology-v1"; @@ -83,6 +83,7 @@ function normalizeOnlineNodes(snapshot: any) { lastSeenAt: text(raw?.lastSeenAt) || null, ageMs: integer(raw?.ageMs), latestOperation: operationSummary(raw?.latestOperation), + latestReadinessProbe: operationSummary(raw?.latestReadinessProbe), diagnostic: diagnosticSummary(raw?.lastDiagnostic) }); } @@ -172,6 +173,7 @@ function buildNode(nodeId: string, onlineNode: any, devices: any[], observedAt: busy }, operation: onlineNode?.latestOperation ?? null, + readinessProbe: onlineNode?.latestReadinessProbe ?? null, diagnostic: onlineNode?.diagnostic ?? null, readiness: nodeReadiness({ onlineNode, online, workspaceReady, hwpodReady, busy, blockers }), blockers, @@ -350,6 +352,7 @@ function paginate(items: any[], options: any) { returned: pageItems.length, total: items.length, cursor: offset > 0 ? encodeCursor(offset) : null, + previousCursor: offset > 0 ? encodeCursor(Math.max(0, offset - limit)) : null, nextCursor: nextOffset < items.length ? encodeCursor(nextOffset) : null } }; diff --git a/internal/cloud/server-hwpod-http.ts b/internal/cloud/server-hwpod-http.ts index 378d0a22..2719c3f3 100644 --- a/internal/cloud/server-hwpod-http.ts +++ b/internal/cloud/server-hwpod-http.ts @@ -1,5 +1,7 @@ /* - * Responsibility: Cloud API HTTP handlers for HWLAB node updates, HWPOD discovery, and node-ops dispatch. + * SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。 + * Implementation reference: draft-2026-07-13-p0-cloud-console。 + * Responsibility: 提供 HWLAB Node 更新、HWPOD typed topology、显式有界 readiness 探测与 node-ops 转发。 */ import { createHash, randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; @@ -155,7 +157,7 @@ export async function handleHwpodSpecDiscoveryHttp(request, response, url, optio const observedAt = new Date().toISOString(); let specs = await discoverHwpodSpecs(options); if (probe) { - specs = await Promise.all(specs.map((spec) => probeDiscoveredHwpodSpec(spec, { request, options, observedAt }))); + specs = await probeDiscoveredHwpodSpecs(specs, { request, options, observedAt }); } sendJson(response, 200, hwpodSpecDiscoveryPayload(specs, { observedAt })); } @@ -168,7 +170,7 @@ export async function handleHwpodTopologyHttp(request, response, url, options) { const observedAt = new Date().toISOString(); let specs = await discoverHwpodSpecs(options); if (truthyFlag(url.searchParams.get("probe"))) { - specs = await Promise.all(specs.map((spec) => probeDiscoveredHwpodSpec(spec, { request, options, observedAt }))); + specs = await probeDiscoveredHwpodSpecs(specs, { request, options, observedAt }); } const payload = buildHwpodTopologyReadModel(specs, options.hwpodNodeWsRegistry.describe(), { observedAt, @@ -277,6 +279,12 @@ function cleanText(value) { return text || ""; } +async function probeDiscoveredHwpodSpecs(specs, context) { + const probed = []; + for (const spec of specs) probed.push(await probeDiscoveredHwpodSpec(spec, context)); + return probed; +} + async function probeDiscoveredHwpodSpec(spec, { request, options, observedAt }) { if (spec.ok === false) return spec; const plan = hwpodSpecWorkspaceProbePlan(spec); @@ -292,7 +300,7 @@ async function probeDiscoveredHwpodSpec(spec, { request, options, observedAt }) } }; } - const requestMeta = hwpodNodeOpsRequestMeta(request, options, "hwpod_spec"); + const requestMeta = { ...hwpodNodeOpsRequestMeta(request, options, "hwpod_spec"), operationKind: "readiness-probe" }; const handled = await dispatchHwpodNodeOpsPlan(validation.plan, requestMeta, options, { request }); const payload = handled.payload; return { diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 5962eeb8..769f1d52 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -1,5 +1,5 @@ /* - * SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p0-projector-resume; PJ2026-010403 API契约 draft-2026-06-20-p0-error-diagnostics; PJ2026-010401 Web工作台 draft-2026-06-20-p0-error-diagnostics; PJ2026-01060505 Workbench Performance draft-2026-06-20-p0-error-diagnostics + * SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p0-projector-resume; PJ2026-010403 API契约 draft-2026-06-20-p0-error-diagnostics; PJ2026-010401 Web工作台 draft-2026-06-20-p0-error-diagnostics; PJ2026-010405 云端控制台 draft-2026-07-13-p0-cloud-console; PJ2026-01060505 Workbench Performance draft-2026-06-20-p0-error-diagnostics * 职责: Cloud API REST/SSE route dispatcher。Workbench 新资源路由应委托 read model / compat wrapper,不在 dispatcher 内写业务事实。 */ import { createServer } from "node:http"; diff --git a/web/hwlab-cloud-web/src/api/hwpod.ts b/web/hwlab-cloud-web/src/api/hwpod.ts index e0109c7c..93aad1d3 100644 --- a/web/hwlab-cloud-web/src/api/hwpod.ts +++ b/web/hwlab-cloud-web/src/api/hwpod.ts @@ -1,3 +1,7 @@ +// SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。 +// Implementation reference: draft-2026-07-13-p0-cloud-console。 +// Responsibility: 调用 HWPOD typed topology、显式 readiness probe 与 Python Node 更新元数据接口。 + import { fetchJson } from "./client"; import type { ApiResult, @@ -9,17 +13,19 @@ import type { HwlabNodeUpdateMetadata } from "@/types"; -interface TopologyQuery { +export interface TopologyQuery { q?: string; status?: string; sort?: "name" | "status" | "lastSeenAt" | "deviceCount"; direction?: "asc" | "desc"; cursor?: string; limit?: number; + probe?: boolean; } function topologyPath(resource: "device" | "node", query: TopologyQuery = {}): string { - const params = new URLSearchParams({ resource, probe: "1", sort: query.sort ?? "name", direction: query.direction ?? "asc", limit: String(query.limit ?? 100) }); + const params = new URLSearchParams({ resource, sort: query.sort ?? "name", direction: query.direction ?? "asc", limit: String(query.limit ?? 50) }); + if (query.probe) params.set("probe", "1"); if (query.q?.trim()) params.set("q", query.q.trim()); if (query.status?.trim()) params.set("status", query.status.trim()); if (query.cursor?.trim()) params.set("cursor", query.cursor.trim()); diff --git a/web/hwlab-cloud-web/src/components/common/EntityCard.vue b/web/hwlab-cloud-web/src/components/common/EntityCard.vue index 587095fc..d74fb9f2 100644 --- a/web/hwlab-cloud-web/src/components/common/EntityCard.vue +++ b/web/hwlab-cloud-web/src/components/common/EntityCard.vue @@ -1,5 +1,6 @@ +