From b33430eadfd1b6cd6f08f29175e6bdfa4a6cbac0 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 10:08:55 +0200 Subject: [PATCH] feat: add HWPOD native workspace operations --- cmd/hwlab-hwpod-api/main.ts | 24 +- internal/hwpod/contracts.ts | 24 ++ internal/hwpod/http.ts | 60 +++-- internal/hwpod/runtime.ts | 13 +- internal/hwpod/spec-registry.test.ts | 29 ++- tools/hwlab-node.py | 71 ++++- tools/hwpod-harness.test.ts | 19 +- tools/hwpod-node.test.ts | 29 +++ tools/src/hwpod-harness-lib.ts | 47 +++- tools/src/hwpod-node-ops-contract.ts | 3 +- web/hwlab-cloud-web/bun.lock | 15 ++ web/hwlab-cloud-web/package.json | 1 + .../scripts/hwpod-native-vite.config.ts | 1 + web/hwlab-cloud-web/src/api/hwpod.ts | 5 +- .../hwpod/HwpodOperationsWorkspace.vue | 243 ++++++++++++++++++ web/hwlab-cloud-web/src/stores/hwpod.ts | 29 ++- web/hwlab-cloud-web/src/types/index.ts | 22 ++ .../src/views/admin/HwpodGroupsView.vue | 22 +- 18 files changed, 602 insertions(+), 55 deletions(-) create mode 100644 web/hwlab-cloud-web/src/components/hwpod/HwpodOperationsWorkspace.vue diff --git a/cmd/hwlab-hwpod-api/main.ts b/cmd/hwlab-hwpod-api/main.ts index 6c330f4c..efa2db29 100644 --- a/cmd/hwlab-hwpod-api/main.ts +++ b/cmd/hwlab-hwpod-api/main.ts @@ -6,12 +6,32 @@ */ import { createHwpodHttpApp } from "../../internal/hwpod/http.ts"; import { hwpodRuntime } from "../../internal/hwpod/runtime.ts"; +import { createHwpodNodeWsRegistry } from "../../internal/cloud/hwpod-node-ws-registry.ts"; const runtime = hwpodRuntime(); -const app = createHwpodHttpApp({ runtimeApiUrl: runtime.runtimeApiUrl, runtimeApiAuthorization: runtime.runtimeApiAuthorization, temporal: runtime.temporal, specRegistry: runtime.specRegistry }); +const nodeRegistry = createHwpodNodeWsRegistry({ env: process.env }); +const app = createHwpodHttpApp({ nodeOpsApiUrl: runtime.nodeOpsApiUrl, temporal: runtime.temporal, specRegistry: runtime.specRegistry, nodeRegistry }); const host = process.env.HWPOD_API_HOST || "0.0.0.0"; const port = positivePort(process.env.HWPOD_API_PORT, 6681); -const server = Bun.serve({ hostname: host, port, fetch: request => app.fetch(request) }); +const expectedNodeToken = required(process.env.HWPOD_NODE_WS_TOKEN, "HWPOD_NODE_WS_TOKEN"); +const server = Bun.serve({ + hostname: host, + port, + idleTimeout: 120, + fetch(request, bunServer) { + const url = new URL(request.url); + if (url.pathname !== "/v1/hwpod-node/ws") return app.fetch(request); + const token = String(request.headers.get("x-hwpod-node-token") ?? url.searchParams.get("token") ?? ""); + if (token !== expectedNodeToken) return Response.json({ ok: false, error: { code: "hwpod_node_ws_token_invalid" }, valuesPrinted: false }, { status: 401 }); + return bunServer.upgrade(request, { data: { nodeRegistry } }) ? undefined : Response.json({ ok: false, status: "upgrade_required", valuesPrinted: false }, { status: 426 }); + }, + websocket: { + open(socket: any) { socket.data.nodeRegistry.openBunSocket(socket); }, + message(socket: any, message: string | Buffer) { socket.data.nodeRegistry.handleBunMessage(socket, message); }, + close(socket: any) { socket.data.nodeRegistry.closeBunSocket(socket); }, + }, +}); process.stdout.write(`${JSON.stringify({ serviceId: "hwlab-hwpod-api", status: "listening", host, port: server.port, temporalNamespace: runtime.namespace, taskQueue: runtime.taskQueue, valuesPrinted: false })}\n`); for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, async () => { server.stop(true); await app.close(); process.exit(0); }); function positivePort(value: string | undefined, fallback: number) { const parsed = Number.parseInt(String(value ?? fallback), 10); if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) throw new Error("HWPOD_API_PORT must be a valid port"); return parsed; } +function required(value: string | undefined, key: string) { const text = String(value ?? "").trim(); if (!text) throw new Error(`${key} is required`); return text; } diff --git a/internal/hwpod/contracts.ts b/internal/hwpod/contracts.ts index 187004e3..882312e1 100644 --- a/internal/hwpod/contracts.ts +++ b/internal/hwpod/contracts.ts @@ -21,6 +21,18 @@ export type HwpodOperationResult = { error?: { code: string; message: string } | null; }; +export const HWPOD_WEB_OPERATION_INTENTS = new Set([ + "workspace.ls", + "workspace.cat", + "debug.build", + "debug.download", + "debug.reset", + "io.uart.open", + "io.uart.close", + "io.uart.read", + "io.uart.write", +]); + export function validateHwpodOperationInput(value: unknown): HwpodOperationInput { if (!value || typeof value !== "object" || Array.isArray(value)) throw codedError("hwpod_operation_input_invalid", "HWPOD operation input must be an object"); const input = value as Record; @@ -33,5 +45,17 @@ export function validateHwpodOperationInput(value: unknown): HwpodOperationInput return { operationId, runtimeApiUrl: runtimeApiUrl.replace(/\/+$/u, ""), plan: plan as Record, authorization: text(input.authorization) || undefined }; } +export function validateHwpodWebOperationRequest(value: unknown) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw codedError("hwpod_operation_request_invalid", "HWPOD operation request must be an object"); + const input = value as Record; + const hwpodId = text(input.hwpodId); + const intent = text(input.intent); + if (!hwpodId) throw codedError("hwpod_id_required", "hwpodId is required"); + if (!HWPOD_WEB_OPERATION_INTENTS.has(intent)) throw codedError("hwpod_operation_intent_unsupported", `unsupported HWPOD Web operation intent: ${intent}`); + const args = input.args === undefined ? {} : input.args; + if (!args || typeof args !== "object" || Array.isArray(args)) throw codedError("hwpod_operation_args_invalid", "HWPOD operation args must be an object"); + return { hwpodId, intent, args: args as Record }; +} + function text(value: unknown) { return typeof value === "string" ? value.trim() : ""; } function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); } diff --git a/internal/hwpod/http.ts b/internal/hwpod/http.ts index a0283664..168e81ef 100644 --- a/internal/hwpod/http.ts +++ b/internal/hwpod/http.ts @@ -5,17 +5,20 @@ * 责任: 独立 HWPOD API HTTP 合同。 */ -import { validateHwpodOperationInput } from "./contracts.ts"; +import { randomUUID } from "node:crypto"; +import { compileHwpodNodeOpsPlan } from "../../tools/src/hwpod-harness-lib.ts"; +import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts"; +import { validateHwpodOperationInput, validateHwpodWebOperationRequest } from "./contracts.ts"; import { buildHwpodTopologyReadModel } from "../cloud/hwpod-topology-read-model.ts"; -export function createHwpodHttpApp(options: { runtimeApiUrl: string; runtimeApiAuthorization: string; temporal: any; specRegistry: any }) { +export function createHwpodHttpApp(options: { nodeOpsApiUrl: string; temporal: any; specRegistry: any; nodeRegistry: any }) { return { async fetch(request: Request) { const url = new URL(request.url); try { if (url.pathname === "/health/live") return json(200, { ok: true, serviceId: "hwlab-hwpod-api", status: "live", valuesPrinted: false }); if (url.pathname === "/health/ready") return readiness(options); - if (url.pathname === "/v1/hwpod" && request.method === "GET") return json(200, { ok: true, serviceId: "hwlab-hwpod-api", executionAuthority: "temporal", runtimeApiUrl: options.runtimeApiUrl, valuesPrinted: false }); + if (url.pathname === "/v1/hwpod" && request.method === "GET") return json(200, { ok: true, serviceId: "hwlab-hwpod-api", executionAuthority: "temporal", nodeOpsApiUrl: options.nodeOpsApiUrl, valuesPrinted: false }); if (url.pathname === "/v1/hwpod/specs" && request.method === "GET") return json(200, await options.specRegistry.list()); if (url.pathname === "/v1/hwpod/specs" && request.method === "POST") { const body = await request.json(); @@ -30,14 +33,28 @@ export function createHwpodHttpApp(options: { runtimeApiUrl: string; runtimeApiA return json(405, { ok: false, error: { code: "method_not_allowed", message: "GET, PUT, or DELETE required" }, valuesPrinted: false }); } if (url.pathname === "/v1/hwpod/topology" && request.method === "GET") { - return json(200, await buildHwpodTopologyFromRegistry(options.specRegistry, Object.fromEntries(url.searchParams))); + return json(200, await buildHwpodTopologyFromRegistry(options.specRegistry, Object.fromEntries(url.searchParams), options.nodeRegistry.describe())); + } + if (url.pathname === "/v1/hwpod-node-ops" && request.method === "GET") { + const planId = url.searchParams.get("planId")?.trim() ?? ""; + if (planId) { + const operation = await options.nodeRegistry.lookup(planId); + return json(operation ? 200 : 404, operation + ? { ok: true, status: operation.result ? "completed" : "running", contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, ...operation, valuesPrinted: false } + : { ok: false, status: "not-found", contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, planId, error: { code: "hwpod_operation_not_found", message: `hwpod operation ${planId} was not found` }, valuesPrinted: false }); + } + return json(200, { ok: true, status: "ready", contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, supportedOps: [...HWPOD_NODE_OPS], websocket: options.nodeRegistry.describe(), valuesPrinted: false }); + } + if (url.pathname === "/v1/hwpod-node-ops" && request.method === "POST") { + const plan = validateNodeOpsPlan(await request.json()); + const result = await options.nodeRegistry.dispatch(plan, { requestId: `req_hwpod_native_${randomUUID()}`, operationKind: "user-operation", source: "hwpod-native-api" }, { timeoutMs: 900_000 }); + return json(result?.ok === false ? 409 : 200, { ...result, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, planId: plan.planId, hwpodId: plan.hwpodId, nodeId: plan.nodeId, valuesPrinted: false }); } - if (url.pathname === "/v1/hwpod-node-ops" && request.method === "GET") return proxyGet(options.runtimeApiUrl, `/v1/hwpod-node-ops${url.search}`, options.runtimeApiAuthorization); if (url.pathname === "/v1/hwpod/operations" && request.method === "POST") { const body = await request.json(); - const input = validateHwpodOperationInput({ ...(body as any), runtimeApiUrl: options.runtimeApiUrl, authorization: options.runtimeApiAuthorization }); + const input = await operationInput(body, options); const started = await options.temporal.start(input); - return json(202, { ok: true, status: "accepted", operationId: input.operationId, ...started, authority: "hwpod-temporal", valuesPrinted: false }); + return json(202, { ok: true, status: "accepted", operationId: input.operationId, planId: input.plan.planId, hwpodId: input.plan.hwpodId, ...started, authority: "hwpod-temporal", valuesPrinted: false }); } const match = /^\/v1\/hwpod\/operations\/([^/]+)$/u.exec(url.pathname); if (match && request.method === "GET") { @@ -62,7 +79,7 @@ export function createHwpodHttpApp(options: { runtimeApiUrl: string; runtimeApiA }; } -export async function buildHwpodTopologyFromRegistry(specRegistry: any, query: Record = {}) { +export async function buildHwpodTopologyFromRegistry(specRegistry: any, query: Record = {}, nodeSnapshot: any = { mode: "hwpod-native-api-no-node-registry", nodes: [] }) { const listed = await specRegistry.list(); const specs = await Promise.all((listed.specs ?? []).map(async (summary: any) => { const entity = await specRegistry.get(summary.hwpodId); @@ -73,20 +90,31 @@ export async function buildHwpodTopologyFromRegistry(specRegistry: any, query: R nodeId: entity.nodeId, }; })); - return buildHwpodTopologyReadModel(specs, { - mode: "hwpod-native-api-no-node-registry", - nodes: [], - }, query); + return buildHwpodTopologyReadModel(specs, nodeSnapshot, query); } -async function readiness(options: { runtimeApiUrl: string; runtimeApiAuthorization: string; temporal: any; specRegistry: any }) { +async function readiness(options: { nodeOpsApiUrl: string; temporal: any; specRegistry: any; nodeRegistry: any }) { try { const specs = await options.specRegistry.list(); - return json(200, { ok: true, serviceId: "hwlab-hwpod-api", temporal: "configured", specRegistry: "ready", specCount: specs.count, runtimeApi: "configured", valuesPrinted: false }); + const nodes = options.nodeRegistry.describe(); + return json(200, { ok: true, serviceId: "hwlab-hwpod-api", temporal: "configured", specRegistry: "ready", specCount: specs.count, nodeRegistry: "ready", connectedNodeCount: nodes.connectedCount, valuesPrinted: false }); } catch (error: any) { - return json(503, { ok: false, serviceId: "hwlab-hwpod-api", temporal: "configured", specRegistry: "blocked", runtimeApi: "configured", error: { code: error?.code ?? "hwpod_spec_registry_unavailable", message: error?.message ?? String(error) }, valuesPrinted: false }); + return json(503, { ok: false, serviceId: "hwlab-hwpod-api", temporal: "configured", specRegistry: "blocked", nodeRegistry: "configured", error: { code: error?.code ?? "hwpod_spec_registry_unavailable", message: error?.message ?? String(error) }, valuesPrinted: false }); } } -async function proxyGet(base: string, route: string, authorization: string) { const response = await fetch(`${base}${route}`, { headers: { authorization }, signal: AbortSignal.timeout(10_000) }); return new Response(await response.text(), { status: response.status, headers: { "content-type": response.headers.get("content-type") ?? "application/json", "cache-control": "no-store" } }); } +async function operationInput(body: any, options: { nodeOpsApiUrl: string; specRegistry: any }) { + if (body?.plan) return validateHwpodOperationInput({ ...body, runtimeApiUrl: options.nodeOpsApiUrl, authorization: "" }); + const request = validateHwpodWebOperationRequest(body); + const spec = await options.specRegistry.get(request.hwpodId); + const operationId = `hwpod_plan_${randomUUID()}`; + const plan = compileHwpodNodeOpsPlan({ document: spec.document, specPath: `hwpod://${request.hwpodId}`, specAuthority: spec.authority, intent: request.intent, args: request.args, operationId }); + return validateHwpodOperationInput({ operationId, plan, runtimeApiUrl: options.nodeOpsApiUrl }); +} +function validateNodeOpsPlan(value: any) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw Object.assign(new Error("hwpod-node-ops plan must be an object"), { code: "invalid_hwpod_node_ops_plan" }); + if (value.contractVersion !== HWPOD_NODE_OPS_CONTRACT_VERSION || !String(value.planId ?? "").trim() || !String(value.nodeId ?? "").trim() || !Array.isArray(value.ops) || value.ops.length === 0) throw Object.assign(new Error("hwpod-node-ops plan identity and ops are required"), { code: "invalid_hwpod_node_ops_plan" }); + for (const op of value.ops) if (!HWPOD_NODE_OPS.has(String(op?.op ?? ""))) throw Object.assign(new Error(`unsupported hwpod-node op: ${String(op?.op ?? "")}`), { code: "unsupported_hwpod_node_op" }); + return value; +} function specDocument(body: any) { return body && typeof body === "object" && !Array.isArray(body) && body.document !== undefined ? body.document : body; } function json(status: number, body: unknown) { return Response.json(body, { status, headers: { "cache-control": "no-store" } }); } diff --git a/internal/hwpod/runtime.ts b/internal/hwpod/runtime.ts index 4b72bd71..ab7b774e 100644 --- a/internal/hwpod/runtime.ts +++ b/internal/hwpod/runtime.ts @@ -2,7 +2,7 @@ * SPEC: PJ2026-010103 HWPOD 服务; * PJ2026-01010305 71FREQ 预装 draft-2026-06-26-71freq-v03-hwpod-preinstall。 * 实现引用: draft-2026-07-20-hwpod-temporal-split。 - * 责任: HWPOD API 的 Temporal 与迁移期 Cloud API 配置解析。 + * 责任: HWPOD API 的 Temporal、node-ops 与 spec registry 配置解析。 */ import { createHwpodTemporalGateway } from "./temporal.ts"; @@ -12,18 +12,13 @@ export function hwpodRuntime(env: Record = process.e const address = String(env.HWPOD_TEMPORAL_ADDRESS ?? env.TEMPORAL_ADDRESS ?? "").trim(); const namespace = String(env.HWPOD_TEMPORAL_NAMESPACE ?? "unidesk").trim(); const taskQueue = String(env.HWPOD_TEMPORAL_TASK_QUEUE ?? "hwlab-v03-hwpod").trim(); - const runtimeApiUrl = String(env.HWPOD_RUNTIME_API_URL ?? "").trim().replace(/\/+$/u, ""); - const runtimeApiAuthorizationValue = String(env.HWPOD_RUNTIME_API_AUTHORIZATION ?? "").trim(); - const runtimeApiAuthorizationPrefix = String(env.HWPOD_RUNTIME_API_AUTHORIZATION_PREFIX ?? "").trim(); - const runtimeApiAuthorization = runtimeApiAuthorizationPrefix ? `${runtimeApiAuthorizationPrefix} ${runtimeApiAuthorizationValue}` : runtimeApiAuthorizationValue; - if (!runtimeApiUrl) throw codedError("hwpod_runtime_api_url_required", "HWPOD_RUNTIME_API_URL is required"); - if (!runtimeApiAuthorization) throw codedError("hwpod_runtime_api_authorization_required", "HWPOD_RUNTIME_API_AUTHORIZATION is required"); + const nodeOpsApiUrl = String(env.HWPOD_NODE_OPS_API_URL ?? "").trim().replace(/\/+$/u, ""); + if (!nodeOpsApiUrl) throw codedError("hwpod_node_ops_api_url_required", "HWPOD_NODE_OPS_API_URL is required"); return { address, namespace, taskQueue, - runtimeApiUrl, - runtimeApiAuthorization, + nodeOpsApiUrl, temporal: createHwpodTemporalGateway({ address, namespace, taskQueue }), specRegistry: hwpodSpecRegistryFromEnv(env), }; diff --git a/internal/hwpod/spec-registry.test.ts b/internal/hwpod/spec-registry.test.ts index 448f9a8f..6871dec0 100644 --- a/internal/hwpod/spec-registry.test.ts +++ b/internal/hwpod/spec-registry.test.ts @@ -37,9 +37,9 @@ test("runtime spec repository persists CRUD and freezes YAML-first built-ins", a test("L1 HWPOD HTTP exposes runtime CRUD and typed frozen errors", async () => { const registry = createHwpodSpecRegistry({ store: memoryStore(), builtIns: [{ configRef: "config/hwpod.yaml#spec", document: spec("builtin-pod", "/builtin") }] }); const temporal = { close: async () => {}, start: async () => ({}), describe: async () => ({}), result: async () => ({}) }; - const app = createHwpodHttpApp({ runtimeApiUrl: "http://runtime.invalid", runtimeApiAuthorization: "Bearer test", temporal, specRegistry: registry }); + const app = createHwpodHttpApp({ nodeOpsApiUrl: "http://native-hwpod.test", temporal, specRegistry: registry, nodeRegistry: nodeRegistry() }); - expect(await request(app, "GET", "/health/ready")).toMatchObject({ status: 200, body: { ok: true, specRegistry: "ready", specCount: 1, runtimeApi: "configured" } }); + expect(await request(app, "GET", "/health/ready")).toMatchObject({ status: 200, body: { ok: true, specRegistry: "ready", specCount: 1, nodeRegistry: "ready" } }); const created = await request(app, "POST", "/v1/hwpod/specs", { document: spec("runtime-http", "/http-v1") }); expect(created).toMatchObject({ status: 201, body: { ok: true, status: "created", hwpodId: "runtime-http" } }); @@ -72,12 +72,28 @@ test("L0 HWPOD topology is built from the local registry without Cloud API", asy expect(topology.items[0].blockers).toContainEqual(expect.objectContaining({ code: "hwpod_node_offline" })); const temporal = { close: async () => {}, start: async () => ({}), describe: async () => ({}), result: async () => ({}) }; - const app = createHwpodHttpApp({ runtimeApiUrl: "http://runtime.invalid", runtimeApiAuthorization: "Bearer test", temporal, specRegistry: registry }); + const app = createHwpodHttpApp({ nodeOpsApiUrl: "http://native-hwpod.test", temporal, specRegistry: registry, nodeRegistry: nodeRegistry() }); const response = await request(app, "GET", "/v1/hwpod/topology?resource=node"); expect(response).toMatchObject({ status: 200, body: { ok: true, resource: "node", items: [{ nodeId: "node-test", status: "offline" }] } }); await app.close(); }); +test("L1 Web operation compiles a registry spec and submits native node-ops through Temporal", async () => { + const registry = createHwpodSpecRegistry({ store: memoryStore(), builtIns: [{ configRef: "config/hwpod.yaml#spec", document: spec("web-pod", "/workspace") }] }); + let submitted: any = null; + const temporal = { + close: async () => {}, + start: async (input: unknown) => { submitted = input; return { workflowId: "hwpod-operation-test", workflowRunId: "run-test" }; }, + describe: async () => ({}), + result: async () => ({}), + }; + const app = createHwpodHttpApp({ nodeOpsApiUrl: "http://127.0.0.1:6681", temporal, specRegistry: registry, nodeRegistry: nodeRegistry() }); + const response = await request(app, "POST", "/v1/hwpod/operations", { hwpodId: "web-pod", intent: "workspace.ls", args: { path: "." } }); + expect(response).toMatchObject({ status: 202, body: { ok: true, status: "accepted", hwpodId: "web-pod", authority: "hwpod-temporal" } }); + expect(submitted).toMatchObject({ runtimeApiUrl: "http://127.0.0.1:6681", plan: { hwpodId: "web-pod", nodeId: "node-test", intent: "workspace.ls", ops: [{ op: "workspace.ls", args: { path: ".", workspacePath: "/workspace" } }] } }); + await app.close(); +}); + async function request(app: any, method: string, pathname: string, body?: unknown) { const response = await app.fetch(new Request(`http://hwpod.test${pathname}`, { method, headers: { "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })); return { status: response.status, body: await response.json() }; @@ -99,4 +115,11 @@ function memoryStore(): HwpodRuntimeSpecStore { delete: async (id) => { const record = records.get(id) ?? null; records.delete(id); return record; }, }; } +function nodeRegistry() { + return { + describe: () => ({ mode: "hwpod-native-api-websocket", connectedCount: 0, nodes: [] }), + lookup: async () => null, + dispatch: async () => ({ ok: true, status: "completed", results: [] }), + }; +} function spec(name: string, workspacePath: string) { return { apiVersion: "hwlab.dev/v0alpha1", kind: "Hwpod", metadata: { name }, spec: { targetDevice: { board: "test" }, workspace: { path: workspacePath }, debugProbe: { type: "test" }, ioProbe: { type: "test" }, nodeBinding: { nodeId: "node-test" } } }; } diff --git a/tools/hwlab-node.py b/tools/hwlab-node.py index 340e20bd..b55377a5 100644 --- a/tools/hwlab-node.py +++ b/tools/hwlab-node.py @@ -53,7 +53,7 @@ except Exception: # pragma: no cover - import failure is reported in main. messagebox = None -APP_VERSION = "0.1.2" +APP_VERSION = "0.1.3" NODE_VERSION = f"{APP_VERSION}-python-gui" CONTRACT_VERSION = "hwpod-node-ops-v1" APP_NAME = "HWLAB Node" @@ -357,7 +357,10 @@ class NodeOpsExecutor: "debug.build", "debug.download", "debug.reset", + "io.uart.open", + "io.uart.close", "io.uart.read", + "io.uart.write", ] def allowed_workspace_roots(self) -> list[Path]: @@ -427,11 +430,17 @@ class NodeOpsExecutor: return self._command_result(op_id, name, self._cmd_run(args, default_timeout_ms=30000)) if name in ("debug.build", "debug.download", "debug.reset"): return self._command_result(op_id, name, self._debug_command(name, args)) + if name == "io.uart.open": + return self._command_result(op_id, name, self._uart_control(args, "start")) + if name == "io.uart.close": + return self._command_result(op_id, name, self._uart_control(args, "stop")) if name == "io.uart.read": output = self._uart_read(args) if output.get("ok"): return self._ok(op_id, name, output) return self._blocked(op_id, name, output.get("blockerCode", "hwpod_uart_read_failed"), output.get("summary", "io.uart.read failed"), output.get("details", output)) + if name == "io.uart.write": + return self._command_result(op_id, name, self._uart_write(args)) return self._blocked(op_id, name, "unsupported_hwpod_node_op", f"unsupported hwpod-node op: {name}") except Exception as exc: return self._blocked(op_id, name, getattr(exc, "errno", None) or exc.__class__.__name__, str(exc), {"trace": traceback.format_exc(limit=4)}) @@ -497,17 +506,48 @@ class NodeOpsExecutor: def _workspace_ls(self, args: dict) -> dict: target = self._resolve_workspace_path(args, args.get("path") or ".") + if not target.is_dir(): + raise NotADirectoryError(str(args.get("path") or ".")) + max_entries = max(1, min(safe_int(args.get("maxEntries"), 500), 2000)) entries = [] for child in target.iterdir(): - entries.append({"name": child.name, "type": "dir" if child.is_dir() else "file" if child.is_file() else "other"}) - return {"path": str(target), "entries": sorted(entries, key=lambda item: item["name"].lower())} + stat = child.stat() + entries.append({ + "name": child.name, + "path": self._relative_workspace_path(args, child), + "type": "dir" if child.is_dir() else "file" if child.is_file() else "other", + "sizeBytes": stat.st_size if child.is_file() else None, + "modifiedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(stat.st_mtime)), + }) + entries.sort(key=lambda item: (item["type"] != "dir", item["name"].lower())) + truncated = len(entries) > max_entries + return {"path": self._relative_workspace_path(args, target), "entries": entries[:max_entries], "truncated": truncated, "totalEntries": len(entries)} def _workspace_cat(self, args: dict) -> dict: target = self._resolve_workspace_path(args, args.get("path")) max_bytes = safe_int(args.get("maxBytes"), 65536) data = target.read_bytes() truncated = len(data) > max_bytes - return {"path": str(target), "content": data[:max_bytes].decode("utf-8", errors="replace"), "truncated": truncated} + sample = data[:max_bytes] + binary = b"\0" in sample + return { + "path": self._relative_workspace_path(args, target), + "sizeBytes": len(data), + "contentType": "application/octet-stream" if binary else "text/plain; charset=utf-8", + "previewable": not binary, + "content": None if binary else sample.decode("utf-8", errors="replace"), + "truncated": truncated, + "blocker": {"code": "hwpod_workspace_binary_preview_unsupported", "summary": "二进制文件不支持文本预览"} if binary else None, + } + + def _relative_workspace_path(self, args: dict, target: Path) -> str: + root = self._workspace_root(args) + try: + relative = target.relative_to(root) + except ValueError: + raise PermissionError("workspace path is outside the selected workspace root") + text = relative.as_posix() + return text if text else "." def _workspace_rg(self, args: dict) -> dict: pattern = str(args.get("pattern") or "") @@ -760,6 +800,29 @@ class NodeOpsExecutor: return [str(item) for item in value] return ["bun", "scripts/serial-monitor-cli.ts"] + def _uart_control(self, args: dict, action: str) -> dict: + serial_dir = Path(str(args.get("serialMonitorDir") or Path.home() / ".agents" / "skills" / "serial-monitor")) + command_base = self._serial_command_base(args) + timeout_ms = safe_int(args.get("timeoutMs"), 10000) + port = str(args.get("port") or "") + baud = args.get("baudRate") or args.get("baudrate") + command = [*command_base, "monitor", action] + if action == "start": + command.extend([*(["-p", port] if port else []), *(["-b", str(baud)] if baud else [])]) + output = self._spawn_output(command, serial_dir, timeout_ms) + body = self._parse_json(output.get("stdout")) + return {**output, "ok": output.get("ok") and body.get("success") is True, "bindingSource": "serial-monitor-cli", "port": port or None, "baudRate": baud, "response": body or None} + + def _uart_write(self, args: dict) -> dict: + data = str(args.get("data") or "") + if not data: + return {"ok": False, "blockerCode": "hwpod_uart_data_required", "stderr": "UART data is required", "exitCode": None} + serial_dir = Path(str(args.get("serialMonitorDir") or Path.home() / ".agents" / "skills" / "serial-monitor")) + command = [*self._serial_command_base(args), "send", data] + output = self._spawn_output(command, serial_dir, safe_int(args.get("timeoutMs"), 10000)) + body = self._parse_json(output.get("stdout")) + return {**output, "ok": output.get("ok") and body.get("success") is True, "bindingSource": "serial-monitor-cli", "bytes": len(data.encode("utf-8")), "response": body or None} + def _uart_read(self, args: dict) -> dict: serial_dir = Path(str(args.get("serialMonitorDir") or Path.home() / ".agents" / "skills" / "serial-monitor")) command_base = self._serial_command_base(args) diff --git a/tools/hwpod-harness.test.ts b/tools/hwpod-harness.test.ts index 2d40e6b5..4b48b81b 100644 --- a/tools/hwpod-harness.test.ts +++ b/tools/hwpod-harness.test.ts @@ -55,10 +55,9 @@ test("hwpod cli resolves hwpod-id from runtime discovery and applies workspace o json: async () => ({ ok: true, status: "completed", - specs: [{ - hwpodId: "d601-f103-v2", - authority: "preinstalled-verified-spec", - document: { + hwpodId: "d601-f103-v2", + authority: "preinstalled-verified-spec", + document: { apiVersion: "hwlab.dev/v0alpha1", kind: "Hwpod", metadata: { uid: "D601-F103-V2", name: "d601-f103-v2" }, @@ -70,7 +69,6 @@ test("hwpod cli resolves hwpod-id from runtime discovery and applies workspace o nodeBinding: { nodeId: "node-d601-f103-v2" } } } - }] }) }; }; @@ -89,7 +87,7 @@ test("hwpod cli resolves hwpod-id from runtime discovery and applies workspace o assert.equal(plan.payload.plan.resourceHints.workspacePath, runWorkspace); assert.equal(plan.payload.plan.source.specAuthority, "preinstalled-verified-spec"); assert.equal(plan.payload.plan.ops[0].args.workspacePath, runWorkspace); - assert.match(requests[0].url, /\/v1\/hwpod\/specs\?hwpodId=d601-f103-v2/u); + assert.match(requests[0].url, /\/v1\/hwpod\/specs\/d601-f103-v2/u); }); test("hwpod-compiler-cli compiles workspace-local spec into node ops", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-compiler-")); @@ -268,6 +266,15 @@ test("hwpod-cli dry-run invokes hwpod-compiler-cli and exposes hwpod-node-ops pl const uartSessionOnly = await runHwpodCli(["uart", "read", "--spec", specPath, "--port", "uart1", "--max-bytes", "512", "--session-only", "--dry-run"], { now: () => NOW }); assert.equal(uartSessionOnly.payload.plan.ops[0].args.sessionOnly, true); assert.equal(uartSessionOnly.payload.plan.ops[0].args.commandBinding.sessionOnly, true); + + const uartOpen = await runHwpodCli(["uart", "open", "--spec", specPath, "--port", "uart1", "--dry-run"], { now: () => NOW }); + assert.equal(uartOpen.payload.plan.ops[0].op, "io.uart.open"); + assert.equal(uartOpen.payload.plan.ops[0].args.port, "COM9"); + const uartWrite = await runHwpodCli(["uart", "write", "--spec", specPath, "--port", "uart1", "--data", "status", "--dry-run"], { now: () => NOW }); + assert.equal(uartWrite.payload.plan.ops[0].op, "io.uart.write"); + assert.equal(uartWrite.payload.plan.ops[0].args.data, "status"); + const uartClose = await runHwpodCli(["uart", "close", "--spec", specPath, "--port", "uart1", "--dry-run"], { now: () => NOW }); + assert.equal(uartClose.payload.plan.ops[0].op, "io.uart.close"); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/tools/hwpod-node.test.ts b/tools/hwpod-node.test.ts index efc3e992..97bc8dc7 100644 --- a/tools/hwpod-node.test.ts +++ b/tools/hwpod-node.test.ts @@ -367,6 +367,35 @@ print(json.dumps(executor._uart_read({"workspacePath": ".", "port": "COM9", "bau assert.equal(result.details.serialMonitor.start.stdout, "not-json"); }); +test("python hwpod-node maps UART open, write, and close to serial-monitor", () => { + const script = ` +import importlib.util, json, logging +spec = importlib.util.spec_from_file_location("hwlab_node", "tools/hwlab-node.py") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +class Executor(module.NodeOpsExecutor): + def __init__(self): + super().__init__({"nodeId": "node-test"}, logging.getLogger("test")) + self.commands = [] + def _spawn_output(self, command, cwd, timeout_ms): + self.commands.append(command) + return {"ok": True, "stdout": json.dumps({"success": True}), "stderr": "", "exitCode": 0} +executor = Executor() +args = {"workspacePath": ".", "port": "COM9", "baudRate": 115200, "data": "status", "serialMonitorCommand": ["serial-monitor"]} +results = [executor._execute_op({"opId": "open", "op": "io.uart.open", "args": args}), executor._execute_op({"opId": "write", "op": "io.uart.write", "args": args}), executor._execute_op({"opId": "close", "op": "io.uart.close", "args": args})] +print(json.dumps({"results": results, "commands": executor.commands})) +`; + const completed = spawnSync("python3", ["-c", script], { cwd: process.cwd(), encoding: "utf8" }); + assert.equal(completed.status, 0, completed.stderr); + const result = JSON.parse(completed.stdout); + assert.deepEqual(result.commands, [ + ["serial-monitor", "monitor", "start", "-p", "COM9", "-b", "115200"], + ["serial-monitor", "send", "status"], + ["serial-monitor", "monitor", "stop"], + ]); + assert.deepEqual(result.results.map((item: any) => item.status), ["completed", "completed", "completed"]); +}); + test("hwpod-node resolves Git for Windows when service PATH omits git", async () => { const result = await resolveHwpodNodeCommand("git", { platform: "win32", diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index 3ecdc937..199663ad 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -643,6 +643,8 @@ function commandToIntent(parsed: ParsedArgs, stdinText?: string) { if (command === "reset") return { intent: "debug.reset", args: clean({ mode: text(parsed.mode ?? parsed._[1]), command: text(parsed.command ?? parsed.commandLine), reason: text(parsed.reason) }) }; if (command === "uart") { const subcommand = parsed._[1] || "read"; + if (subcommand === "open" || subcommand === "start") return { intent: "io.uart.open", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), baudRate: numberValue(parsed.baudRate ?? parsed.baudrate), timeoutMs: numberValue(parsed.timeoutMs), serialMonitorDir: text(parsed.serialMonitorDir), serialMonitorCommand: text(parsed.serialMonitorCommand), reason: text(parsed.reason) }) }; + if (subcommand === "close" || subcommand === "stop") return { intent: "io.uart.close", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), timeoutMs: numberValue(parsed.timeoutMs), serialMonitorDir: text(parsed.serialMonitorDir), serialMonitorCommand: text(parsed.serialMonitorCommand), reason: text(parsed.reason) }) }; if (subcommand === "read") return { intent: "io.uart.read", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), maxBytes: numberValue(parsed.maxBytes), limit: numberValue(parsed.limit), since: text(parsed.since), sessionOnly: parsed.sessionOnly === true, timeoutMs: numberValue(parsed.timeoutMs), serialMonitorDir: text(parsed.serialMonitorDir), serialMonitorCommand: text(parsed.serialMonitorCommand), reason: text(parsed.reason) }) }; if (subcommand === "write") return { intent: "io.uart.write", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), data: requiredText(parsed.data ?? parsed._[3] ?? stdinText, "data") }) }; throw cliError("unsupported_uart_command", `unsupported uart command: ${subcommand}`); @@ -698,8 +700,9 @@ function opsForIntent(intent: string, args: any, document: any) { if (intent === "debug.download") return debugDownloadOps(common, args, document); if (intent === "debug.job-status") return debugJobStatusOps(common, args, document); if (intent === "debug.reset") return [{ op: "debug.reset", args: clean({ ...common, mode: text(args.mode), command: text(args.command) || text(document.spec.debugProbe.resetCommand), reason: text(args.reason) }) }]; + if (intent === "io.uart.open" || intent === "io.uart.close") return uartControlOps(common, intent, args, document); if (intent === "io.uart.read") return uartReadOps(common, args, document); - if (intent === "io.uart.write") return [{ op: "io.uart.write", args: clean({ ...common, port: text(args.port) || "uart1", data: requiredText(args.data, "data") }) }]; + if (intent === "io.uart.write") return uartWriteOps(common, args, document); if (intent === "io.uart.jsonrpc") return [{ op: "io.uart.jsonrpc", args: clean({ ...common, port: text(args.port) || "uart1", method: requiredText(args.method, "method"), params: objectValue(args.params) }) }]; if (intent === "io.board-comm.jrpctcp") return boardCommObservationOps(common, args, document); if (intent === "io.probe.read") return ioProbeObservationOps(common, args, document); @@ -900,6 +903,22 @@ function uartReadOps(common: any, args: any, document: any) { }]; } +function uartControlOps(common: any, intent: "io.uart.open" | "io.uart.close", args: any, document: any) { + const generated = serialMonitorBinding(args, document); + return [{ + op: intent, + args: clean({ ...common, port: generated.physicalPort, baudRate: generated.baudRate, serialMonitorDir: generated.serialMonitorDir, serialMonitorCommand: generated.commandBase, timeoutMs: generated.timeoutMs, reason: text(args.reason) }) + }]; +} + +function uartWriteOps(common: any, args: any, document: any) { + const generated = serialMonitorBinding(args, document); + return [{ + op: "io.uart.write", + args: clean({ ...common, port: generated.physicalPort, baudRate: generated.baudRate, data: requiredText(args.data, "data"), serialMonitorDir: generated.serialMonitorDir, serialMonitorCommand: generated.commandBase, timeoutMs: generated.timeoutMs, reason: text(args.reason) }) + }]; +} + function keilCommandForIntent(intent: "debug.build" | "debug.download", args: any, document: any) { const workspace = objectValue(document.spec.workspace); const debugProbe = objectValue(document.spec.debugProbe); @@ -997,15 +1016,8 @@ function keilJobStatusCommand(args: any, document: any) { } function serialMonitorReadCommand(args: any, document: any) { - const ioProbe = objectValue(document.spec.ioProbe); - const uart = objectValue(ioProbe.uart); - const tooling = objectValue(document.spec.tooling); - const requestedPort = text(args.port) || text(uart.id) || "uart1"; - const physicalPort = text(args.physicalPort) || text(uart.port) || text(uart.path) || text(ioProbe.port) || requestedPort; - const baudRate = numberValue(args.baudRate ?? args.baudrate ?? uart.baudRate ?? uart.baudrate ?? ioProbe.baudRate ?? ioProbe.baudrate) ?? 115200; - const serialMonitorDir = text(args.serialMonitorDir) || text(tooling.serialMonitorDir) || text(ioProbe.serialMonitorDir) || "C:\\Users\\liang\\.agents\\skills\\serial-monitor"; - const commandBase = serialMonitorCommandBaseForCompiler(args, tooling, ioProbe); - const timeoutMs = numberValue(args.timeoutMs) ?? 30000; + const binding = serialMonitorBinding(args, document); + const { requestedPort, physicalPort, baudRate, serialMonitorDir, commandBase, timeoutMs } = binding; const limit = Math.max(1, Math.min(numberValue(args.limit) ?? Math.ceil((numberValue(args.maxBytes) ?? 4096) / 80), 200)); const fetchArgs = ["fetch", "-l", String(limit), ...(args.sessionOnly === true ? ["--session-only"] : []), ...(text(args.since) ? ["-s", text(args.since)] : [])]; const sequenceRun = jsonCliSequenceCommandRun([ @@ -1024,6 +1036,21 @@ function serialMonitorReadCommand(args: any, document: any) { }; } +function serialMonitorBinding(args: any, document: any) { + const ioProbe = objectValue(document.spec.ioProbe); + const uart = objectValue(ioProbe.uart); + const tooling = objectValue(document.spec.tooling); + const requestedPort = text(args.port) || text(uart.id) || "uart1"; + return { + requestedPort, + physicalPort: text(args.physicalPort) || text(uart.port) || text(uart.path) || text(ioProbe.port) || requestedPort, + baudRate: numberValue(args.baudRate ?? args.baudrate ?? uart.baudRate ?? uart.baudrate ?? ioProbe.baudRate ?? ioProbe.baudrate) ?? 115200, + serialMonitorDir: text(args.serialMonitorDir) || text(tooling.serialMonitorDir) || text(ioProbe.serialMonitorDir) || "C:\\Users\\liang\\.agents\\skills\\serial-monitor", + commandBase: serialMonitorCommandBaseForCompiler(args, tooling, ioProbe), + timeoutMs: numberValue(args.timeoutMs) ?? 30000, + }; +} + function serialMonitorCommandBaseForCompiler(args: any, tooling: any, ioProbe: any) { const configured = text(args.serialMonitorCommand) || text(tooling.serialMonitorCommand) || text(ioProbe.serialMonitorCommand); if (configured) return splitCommandWords(configured); diff --git a/tools/src/hwpod-node-ops-contract.ts b/tools/src/hwpod-node-ops-contract.ts index 74b53f12..84647cf5 100644 --- a/tools/src/hwpod-node-ops-contract.ts +++ b/tools/src/hwpod-node-ops-contract.ts @@ -15,9 +15,10 @@ export const HWPOD_NODE_OPS = new Set([ "debug.build", "debug.download", "debug.reset", + "io.uart.open", + "io.uart.close", "io.uart.read", "io.uart.write", "io.uart.jsonrpc", "cmd.run" ]); - diff --git a/web/hwlab-cloud-web/bun.lock b/web/hwlab-cloud-web/bun.lock index 721b2409..d1a8b473 100644 --- a/web/hwlab-cloud-web/bun.lock +++ b/web/hwlab-cloud-web/bun.lock @@ -6,6 +6,7 @@ "name": "hwlab-cloud-web", "dependencies": { "@dagrejs/dagre": "3.0.0", + "@he-tree/vue": "2.10.5", "@pinia/colada": "0.15.3", "@vue-flow/core": "1.48.2", "@vueuse/core": "^10.7.0", @@ -47,6 +48,8 @@ "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], @@ -109,6 +112,12 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + "@he-tree/dnd-utils": ["@he-tree/dnd-utils@0.1.0-alpha.4", "", { "dependencies": { "@babel/runtime": "^7.7.7", "drag-event-service": "^2.0.0", "helper-js": "^3.1.2" } }, "sha512-DuxkFOQ8eztoHrX1/VelqOnQphQbRszemfMKSKlAEEhVjqR/IkaOE2QSRlAdj6OC6AfMvHDPvIZOiEPFwP6/ZQ=="], + + "@he-tree/tree-utils": ["@he-tree/tree-utils@0.1.0-alpha.8", "", { "dependencies": { "@babel/runtime": "^7.7.7", "drag-event-service": "^2.0.0", "helper-js": "^3.1.2" } }, "sha512-4M4BzXbjCg2uY/GDPUGzq1EFGgna5L7NGFF85fF2pSnCIUkWOZY18eaSqvvlOFtyD5DWkkDqxzu0GHFlp/Zb4g=="], + + "@he-tree/vue": ["@he-tree/vue@2.10.5", "", { "dependencies": { "@he-tree/dnd-utils": "^0.1.0-alpha.4", "@he-tree/tree-utils": "^0.1.0-alpha.8", "@virtual-list/vue": "^1.2.1", "helper-js": "^3.1.2", "vue-demi": "latest" }, "peerDependencies": { "@vue/composition-api": "^1.4.6", "vue": "^2.0.0 || >=3.0.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-+/C3FxoGb1WpkElQA0kASSe0W9EUXTQSzg/1c7+1ucheiX4uFc8m1mSuD74lO7FXr6PWWbVRX2lzo/hxoUOTzA=="], + "@intlify/core-base": ["@intlify/core-base@9.14.5", "", { "dependencies": { "@intlify/message-compiler": "9.14.5", "@intlify/shared": "9.14.5" } }, "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA=="], "@intlify/message-compiler": ["@intlify/message-compiler@9.14.5", "", { "dependencies": { "@intlify/shared": "9.14.5", "source-map-js": "^1.0.2" } }, "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ=="], @@ -201,6 +210,8 @@ "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], + "@virtual-list/vue": ["@virtual-list/vue@1.2.1", "", { "dependencies": { "helper-js": "^3.1.2", "vue-demi": "latest" }, "peerDependencies": { "@vue/composition-api": "^1.4.6", "vue": "^2.0.0 || >=3.0.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-sVangNRLgHJsrwjf2EeXuc8mUExbLRJO39J9/FXFWXXQkue/FBT4ZWN7GH5+GJTeVaEEfHW1W2UuVaf3LEB3jQ=="], + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="], "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], @@ -371,6 +382,8 @@ "dompurify": ["dompurify@3.4.9", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="], + "drag-event-service": ["drag-event-service@2.0.0", "", { "dependencies": { "@babel/runtime": "^7.7.7", "helper-js": "^3.0.0" } }, "sha512-JVEnOEbbNDS3RrWd+tVVIDu2SHQZ9luhEFGSQ1hXMjl+ABw0PxhHmnkGjzPVE836Tsg+k7rkm5GM9Ln/UgbYpA=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -439,6 +452,8 @@ "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + "helper-js": ["helper-js@3.1.6", "", { "dependencies": { "@babel/runtime": "^7.7.7" } }, "sha512-lhncHDAxS2PTA44aG1AofxT51v0IXEVOmaUCC6HwuGaGqE1yEkjhPH74Vb/Aw4xt8Kt5bMvStCr6FekANp+PGg=="], + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index 7aa902e6..33319c85 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@dagrejs/dagre": "3.0.0", + "@he-tree/vue": "2.10.5", "@pinia/colada": "0.15.3", "@vue-flow/core": "1.48.2", "@vueuse/core": "^10.7.0", diff --git a/web/hwlab-cloud-web/scripts/hwpod-native-vite.config.ts b/web/hwlab-cloud-web/scripts/hwpod-native-vite.config.ts index d50efd8a..9c7cc1b9 100644 --- a/web/hwlab-cloud-web/scripts/hwpod-native-vite.config.ts +++ b/web/hwlab-cloud-web/scripts/hwpod-native-vite.config.ts @@ -20,6 +20,7 @@ export default defineConfig(({ command }) => ({ port: requiredPort(process.env.HWPOD_WEB_PORT), strictPort: true, proxy: { + "/v1/hwpod-node/ws": { target: requiredApiUrl(), changeOrigin: false, ws: true }, "/v1/hwpod": { target: requiredApiUrl(), changeOrigin: false }, "/health/ready": { target: requiredApiUrl(), changeOrigin: false }, } diff --git a/web/hwlab-cloud-web/src/api/hwpod.ts b/web/hwlab-cloud-web/src/api/hwpod.ts index 93aad1d3..44334bfa 100644 --- a/web/hwlab-cloud-web/src/api/hwpod.ts +++ b/web/hwlab-cloud-web/src/api/hwpod.ts @@ -8,6 +8,7 @@ import type { HwpodDeviceTopologyItem, HwpodNodeOpsResponse, HwpodNodeTopologyItem, + HwpodOperationResponse, HwpodSpecsResponse, HwpodTopologyResponse, HwlabNodeUpdateMetadata @@ -38,5 +39,7 @@ export const hwpodAPI = { groups: (): Promise> => fetchJson("/v1/hwpod-node-ops", { timeoutMs: 12000, timeoutName: "HWPOD groups" }), devices: (query: TopologyQuery = {}): Promise>> => fetchJson(topologyPath("device", query), { timeoutMs: 20000, timeoutName: "HWPOD device topology" }), nodes: (query: TopologyQuery = {}): Promise>> => fetchJson(topologyPath("node", query), { timeoutMs: 20000, timeoutName: "HWPOD node topology" }), - nodeUpdate: (): Promise> => fetchJson("/v1/hwlab-node/update?platform=windows&channel=stable", { timeoutMs: 12000, timeoutName: "HWLAB Node update metadata" }) + nodeUpdate: (): Promise> => fetchJson("/v1/hwlab-node/update?platform=windows&channel=stable", { timeoutMs: 12000, timeoutName: "HWLAB Node update metadata" }), + startOperation: (input: { hwpodId: string; intent: string; args?: Record }): Promise> => fetchJson("/v1/hwpod/operations", { method: "POST", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "HWPOD operation submit" }), + operation: (operationId: string): Promise> => fetchJson(`/v1/hwpod/operations/${encodeURIComponent(operationId)}`, { timeoutMs: 12000, timeoutName: "HWPOD operation status" }) }; diff --git a/web/hwlab-cloud-web/src/components/hwpod/HwpodOperationsWorkspace.vue b/web/hwlab-cloud-web/src/components/hwpod/HwpodOperationsWorkspace.vue new file mode 100644 index 00000000..5de6e881 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/hwpod/HwpodOperationsWorkspace.vue @@ -0,0 +1,243 @@ + + + + + + diff --git a/web/hwlab-cloud-web/src/stores/hwpod.ts b/web/hwlab-cloud-web/src/stores/hwpod.ts index 9375c8bb..ac085fbb 100644 --- a/web/hwlab-cloud-web/src/stores/hwpod.ts +++ b/web/hwlab-cloud-web/src/stores/hwpod.ts @@ -10,6 +10,7 @@ import type { HwpodDeviceTopologyItem, HwpodNodeOpsResponse, HwpodNodeTopologyItem, + HwpodOperationResponse, HwpodSpecsResponse, HwpodTopologyResponse, HwlabNodeUpdateMetadata @@ -27,6 +28,8 @@ export const useHwpodStore = defineStore("hwpod", () => { const consoleError = ref(null); const consoleStale = ref(false); let consoleRequestId = 0; + const lastOperation = ref(null); + const operationError = ref(null); async function refresh(): Promise { loading.value = true; @@ -79,5 +82,29 @@ export const useHwpodStore = defineStore("hwpod", () => { } } - return { specs, nodeOps, loading, error, refresh, deviceTopology, nodeTopology, nodeUpdate, consoleLoading, consoleError, consoleStale, refreshConsole }; + async function executeOperation(hwpodId: string, intent: string, args: Record = {}, timeoutMs = 60_000): Promise { + operationError.value = null; + const submitted = await hwpodAPI.startOperation({ hwpodId, intent, args }); + if (!submitted.ok || !submitted.data?.operationId) throw operationFailure(submitted.error ?? "HWPOD operation 提交失败"); + lastOperation.value = submitted.data; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await delay(500); + const status = await hwpodAPI.operation(submitted.data.operationId); + if (!status.ok || !status.data) throw operationFailure(status.error ?? "HWPOD operation 状态查询失败"); + lastOperation.value = status.data; + if (isTerminal(status.data.status)) return status.data; + } + throw operationFailure(`HWPOD operation ${submitted.data.operationId} 在 ${timeoutMs}ms 内未终止`); + } + + function operationFailure(message: string): Error { + operationError.value = message; + return new Error(message); + } + + return { specs, nodeOps, loading, error, refresh, deviceTopology, nodeTopology, nodeUpdate, consoleLoading, consoleError, consoleStale, refreshConsole, lastOperation, operationError, executeOperation }; }); + +function isTerminal(status: string): boolean { return /completed|failed|canceled|terminated|timed.?out/u.test(status); } +function delay(ms: number): Promise { return new Promise((resolve) => window.setTimeout(resolve, ms)); } diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index a96f3835..a68c5337 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -500,6 +500,28 @@ export interface HwpodTopologyResponse | null; + error?: { code?: string; message?: string } | null; + valuesPrinted?: boolean; +} + +export interface HwpodWorkspaceEntry { + name: string; + path: string; + type: "dir" | "file" | "other"; + sizeBytes: number | null; + modifiedAt: string | null; +} + export interface HwlabNodeUpdateMetadata { ok: boolean; contractVersion: "hwlab-node-update-v1" | string; diff --git a/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue b/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue index 4800425e..14495aaa 100644 --- a/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue +++ b/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue @@ -11,6 +11,7 @@ import BaseDrawer from "@/components/common/BaseDrawer.vue"; import HwpodDevicesWorkspace from "@/components/hwpod/HwpodDevicesWorkspace.vue"; import HwpodNodesWorkspace from "@/components/hwpod/HwpodNodesWorkspace.vue"; import HwpodOnboardingWorkspace from "@/components/hwpod/HwpodOnboardingWorkspace.vue"; +import HwpodOperationsWorkspace from "@/components/hwpod/HwpodOperationsWorkspace.vue"; import HwpodReadinessRail from "@/components/hwpod/HwpodReadinessRail.vue"; import BoundedWorkspace from "@/components/layout/BoundedWorkspace.vue"; import PageCommandBar from "@/components/layout/PageCommandBar.vue"; @@ -28,6 +29,7 @@ const hwpod = useHwpodStore(); const viewState = useConsoleViewState({ view: "cards", density: "comfortable", sort: "name" }); const searchText = ref(viewState.q.value); const copied = ref(false); +const workspaceMode = computed(() => tab.value === "devices" && route.query.workspace === "1" && Boolean(text(route.params.hwpodId))); const tab = computed(() => { const value = route.path.split("/")[2] ?? ""; @@ -135,6 +137,19 @@ function closeInspector(): void { void router.replace({ name: "HwpodDevices", query: collectionQuery() }); } +function openWorkspace(): void { + if (!selectedDevice.value) return; + void router.replace({ name: "HwpodDevices", params: { hwpodId: selectedDevice.value.hwpodId }, query: { workspace: "1", path: "." } }); +} + +function closeWorkspace(): void { + void router.replace({ name: "HwpodDevices", params: { hwpodId: text(route.params.hwpodId) } }); +} + +function navigateWorkspace(path: string): void { + void router.replace({ name: "HwpodDevices", params: { hwpodId: text(route.params.hwpodId) }, query: { workspace: "1", path } }); +} + function selectDeviceItem(device: HwpodDeviceTopologyItem): void { selectDevice(device.hwpodId); } @@ -242,7 +257,8 @@ function text(value: unknown): string { - + + @@ -255,7 +271,7 @@ function text(value: unknown): string { - + @@ -324,6 +341,7 @@ dt { color: #6d7a75; font-size: .68rem; font-weight: 800; text-transform: upperc dd { margin: 3px 0 0; overflow-wrap: anywhere; } .capability-list { display: flex; flex-wrap: wrap; gap: 6px; } .capability-list code { padding: 5px 7px; border-radius: 5px; background: #fff3dd; color: #754813; } +.workspace-command { width: 100%; min-height: 36px; margin-top: 18px; border: 1px solid var(--console-cyan-700); border-radius: var(--console-radius-sm); background: var(--console-cyan-100); color: var(--console-cyan-700); font-weight: 800; cursor: pointer; } .collection-pagination { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; color: var(--console-graphite-600); font-family: var(--console-font-mono); font-size: 11px; } .collection-pagination div { display: flex; gap: 6px; } .collection-pagination button { border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); color: var(--console-graphite-800); padding: 6px 10px; font-weight: 700; cursor: pointer; }