diff --git a/cmd/hwlab-hwpod-api/main.ts b/cmd/hwlab-hwpod-api/main.ts new file mode 100644 index 00000000..64ab7ec4 --- /dev/null +++ b/cmd/hwlab-hwpod-api/main.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env bun +import { createHwpodHttpApp } from "../../internal/hwpod/http.ts"; +import { hwpodRuntime } from "../../internal/hwpod/runtime.ts"; + +const runtime = hwpodRuntime(); +const app = createHwpodHttpApp({ runtimeApiUrl: runtime.runtimeApiUrl, runtimeApiAuthorization: runtime.runtimeApiAuthorization, temporal: runtime.temporal }); +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) }); +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; } diff --git a/cmd/hwlab-hwpod-worker/main.ts b/cmd/hwlab-hwpod-worker/main.ts new file mode 100644 index 00000000..fa7397ed --- /dev/null +++ b/cmd/hwlab-hwpod-worker/main.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env bun +import path from "node:path"; +import { NativeConnection, Worker } from "@temporalio/worker"; +import { executeHwpodOperation } from "../../internal/hwpod/activities.ts"; +import { hwpodRuntime } from "../../internal/hwpod/runtime.ts"; + +const runtime = hwpodRuntime(); +const connection = await NativeConnection.connect({ address: runtime.address }); +const worker = await Worker.create({ connection, namespace: runtime.namespace, taskQueue: runtime.taskQueue, workflowsPath: path.resolve(import.meta.dir, "../../internal/hwpod/workflows.ts"), activities: { executeHwpodOperation } }); +const healthPort = positivePort(process.env.HWPOD_WORKER_HEALTH_PORT, 6682); +const health = Bun.serve({ hostname: process.env.HWPOD_WORKER_HEALTH_HOST || "0.0.0.0", port: healthPort, fetch(request) { return ["/healthz", "/readyz", "/health/live", "/health/ready"].includes(new URL(request.url).pathname) ? Response.json({ ok: true, serviceId: "hwlab-hwpod-worker", namespace: runtime.namespace, taskQueue: runtime.taskQueue, valuesPrinted: false }) : new Response("not found", { status: 404 }); } }); +process.stdout.write(`${JSON.stringify({ serviceId: "hwlab-hwpod-worker", status: "started", namespace: runtime.namespace, taskQueue: runtime.taskQueue, healthPort: health.port, valuesPrinted: false })}\n`); +for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, () => worker.shutdown()); +try { await worker.run(); } finally { health.stop(true); await connection.close(); await runtime.temporal.close(); } +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_WORKER_HEALTH_PORT must be a valid port"); return parsed; } diff --git a/internal/hwpod/activities.ts b/internal/hwpod/activities.ts new file mode 100644 index 00000000..4260316b --- /dev/null +++ b/internal/hwpod/activities.ts @@ -0,0 +1,20 @@ +/* + * SPEC: PJ2026-010103 HWPOD 服务。 + * 实现引用: draft-2026-07-20-hwpod-temporal-split。 + * 责任: 把独立 HWPOD workflow 接到迁移期 Cloud API authority。 + */ + +import { validateHwpodOperationInput } from "./contracts.ts"; + +export async function executeHwpodOperation(raw: unknown) { + const input = validateHwpodOperationInput(raw); + const response = await fetch(`${input.runtimeApiUrl}/v1/hwpod-node-ops`, { + method: "POST", + headers: { "content-type": "application/json", ...(input.authorization ? { authorization: input.authorization } : {}) }, + body: JSON.stringify(input.plan), + signal: AbortSignal.timeout(900_000) + }); + const body = await response.json().catch(() => null); + if (!body || typeof body !== "object") return { ok: false, status: "failed", operationId: input.operationId, httpStatus: response.status, blocker: { code: "hwpod_runtime_response_invalid" } }; + return { ok: response.ok && (body as any).ok !== false, status: (body as any).status ?? (response.ok ? "completed" : "failed"), operationId: input.operationId, response: body }; +} diff --git a/internal/hwpod/contracts.ts b/internal/hwpod/contracts.ts new file mode 100644 index 00000000..187004e3 --- /dev/null +++ b/internal/hwpod/contracts.ts @@ -0,0 +1,37 @@ +/* + * SPEC: PJ2026-010103 HWPOD 服务。 + * 实现引用: draft-2026-07-20-hwpod-temporal-split。 + * 责任: 独立 HWPOD API、worker 和 Web 共用的稳定输入输出合同。 + */ + +export type HwpodOperationInput = { + operationId: string; + plan: Record; + runtimeApiUrl: string; + authorization?: string; +}; + +export type HwpodOperationResult = { + ok: boolean; + status: string; + operationId: string; + workflowId: string; + workflowRunId: string | null; + result?: Record | null; + error?: { code: string; message: string } | null; +}; + +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; + const operationId = text(input.operationId); + const runtimeApiUrl = text(input.runtimeApiUrl); + const plan = input.plan; + if (!operationId) throw codedError("hwpod_operation_id_required", "operationId is required"); + if (!runtimeApiUrl) throw codedError("hwpod_runtime_api_url_required", "runtimeApiUrl is required"); + if (!plan || typeof plan !== "object" || Array.isArray(plan)) throw codedError("hwpod_operation_plan_required", "plan must be an object"); + return { operationId, runtimeApiUrl: runtimeApiUrl.replace(/\/+$/u, ""), plan: plan as Record, authorization: text(input.authorization) || undefined }; +} + +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 new file mode 100644 index 00000000..a5d56f4b --- /dev/null +++ b/internal/hwpod/http.ts @@ -0,0 +1,50 @@ +/* + * SPEC: PJ2026-010103 HWPOD 服务。 + * 实现引用: draft-2026-07-20-hwpod-temporal-split。 + * 责任: 独立 HWPOD API HTTP 合同。 + */ + +import { validateHwpodOperationInput } from "./contracts.ts"; + +export function createHwpodHttpApp(options: { runtimeApiUrl: string; runtimeApiAuthorization: string; temporal: 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/specs" && request.method === "GET") return proxyGet(options.runtimeApiUrl, `/v1/hwpod/specs${url.search}`, options.runtimeApiAuthorization); + if (url.pathname === "/v1/hwpod/topology" && request.method === "GET") return proxyGet(options.runtimeApiUrl, `/v1/hwpod/topology${url.search}`, options.runtimeApiAuthorization); + 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 started = await options.temporal.start(input); + return json(202, { ok: true, status: "accepted", operationId: input.operationId, ...started, authority: "hwpod-temporal", valuesPrinted: false }); + } + const match = /^\/v1\/hwpod\/operations\/([^/]+)$/u.exec(url.pathname); + if (match && request.method === "GET") { + const operationId = decodeURIComponent(match[1]); + const workflowId = `hwpod-operation-${operationId}`; + const description = await options.temporal.describe(workflowId); + const status = String((description as any)?.status?.name ?? (description as any)?.status ?? "RUNNING").toLowerCase(); + const result = /completed|failed|canceled/u.test(status) ? await options.temporal.result(workflowId).catch((error: any) => ({ ok: false, error: { code: error?.code ?? "hwpod_workflow_result_failed", message: error?.message ?? String(error) } })) : null; + return json(200, { ok: true, operationId, workflowId, workflowRunId: (description as any)?.runId ?? null, status, result, valuesPrinted: false }); + } + return json(404, { ok: false, error: { code: "hwpod_route_not_found", message: "HWPOD API route was not found" } }); + } catch (error: any) { + const code = error?.code ?? "hwpod_http_error"; + return json(code.includes("not_found") ? 404 : 400, { ok: false, error: { code, message: error?.message ?? String(error) }, valuesPrinted: false }); + } + }, + close: async () => options.temporal.close() + }; +} + +async function readiness(options: { runtimeApiUrl: string; runtimeApiAuthorization: string; temporal: any }) { + try { const response = await fetch(`${options.runtimeApiUrl}/v1/hwpod/topology?resource=node&limit=1`, { headers: { authorization: options.runtimeApiAuthorization }, signal: AbortSignal.timeout(5000) }); return json(response.ok ? 200 : 503, { ok: response.ok, serviceId: "hwlab-hwpod-api", temporal: "configured", runtimeApi: response.ok ? "reachable" : "unreachable", valuesPrinted: false }); } + catch { return json(503, { ok: false, serviceId: "hwlab-hwpod-api", temporal: "configured", runtimeApi: "unreachable", 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" } }); } +function json(status: number, body: unknown) { return Response.json(body, { status, headers: { "cache-control": "no-store" } }); } diff --git a/internal/hwpod/hwpod.test.ts b/internal/hwpod/hwpod.test.ts new file mode 100644 index 00000000..d9a1d986 --- /dev/null +++ b/internal/hwpod/hwpod.test.ts @@ -0,0 +1,26 @@ +/* + * SPEC: PJ2026-010103 HWPOD 服务。 + * 实现引用: draft-2026-07-20-hwpod-temporal-split。 + * 责任: HWPOD L0 合同与 activity 转发回归。 + */ +import { afterEach, test, expect } from "bun:test"; +import { executeHwpodOperation } from "./activities.ts"; +import { validateHwpodOperationInput } from "./contracts.ts"; + +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; }); + +test("validates stable HWPOD operation identity", () => { + const input = validateHwpodOperationInput({ operationId: "op-1", runtimeApiUrl: "http://runtime/", plan: { planId: "plan-1" } }); + expect(input.operationId).toBe("op-1"); + expect(input.runtimeApiUrl).toBe("http://runtime"); +}); + +test("forwards an HWPOD activity without changing the plan", async () => { + let received: any; + globalThis.fetch = (async (_url: any, init: any) => { received = JSON.parse(String(init.body)); return Response.json({ ok: true, status: "completed", planId: received.planId }); }) as any; + const result = await executeHwpodOperation({ operationId: "op-2", runtimeApiUrl: "http://runtime", plan: { planId: "plan-2", nodeId: "node-1", hwpodId: "pod-1", ops: [{ op: "node.health" }] } }); + expect(received.planId).toBe("plan-2"); + expect(result.ok).toBe(true); + expect(result.operationId).toBe("op-2"); +}); diff --git a/internal/hwpod/runtime.ts b/internal/hwpod/runtime.ts new file mode 100644 index 00000000..fc5124e3 --- /dev/null +++ b/internal/hwpod/runtime.ts @@ -0,0 +1,20 @@ +/* + * SPEC: PJ2026-010103 HWPOD 服务。 + * 实现引用: draft-2026-07-20-hwpod-temporal-split。 + * 责任: HWPOD API 的 Temporal 与迁移期 Cloud API 配置解析。 + */ + +import { createHwpodTemporalGateway } from "./temporal.ts"; + +export function hwpodRuntime(env: Record = process.env) { + 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 runtimeApiAuthorization = String(env.HWPOD_RUNTIME_API_AUTHORIZATION ?? "").trim(); + 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"); + return { address, namespace, taskQueue, runtimeApiUrl, runtimeApiAuthorization, temporal: createHwpodTemporalGateway({ address, namespace, taskQueue }) }; +} + +function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); } diff --git a/internal/hwpod/temporal.ts b/internal/hwpod/temporal.ts new file mode 100644 index 00000000..1610b64d --- /dev/null +++ b/internal/hwpod/temporal.ts @@ -0,0 +1,36 @@ +/* + * SPEC: PJ2026-010103 HWPOD 服务。 + * 实现引用: draft-2026-07-20-hwpod-temporal-split。 + * 责任: HWPOD API 到共享 Temporal namespace 的最小 gateway。 + */ + +import { Client, Connection } from "@temporalio/client"; + +export function createHwpodTemporalGateway(options: { address: string; namespace: string; taskQueue: string }) { + if (!options.address) throw codedError("hwpod_temporal_address_required", "HWPOD_TEMPORAL_ADDRESS is required"); + let connection: Connection | undefined; + let client: Client | undefined; + const getClient = async () => { + connection ??= await Connection.connect({ address: options.address }); + client ??= new Client({ connection, namespace: options.namespace }); + return client; + }; + return { + async start(input: unknown) { + const workflowId = `hwpod-operation-${String((input as any)?.operationId ?? "unknown")}`; + try { + const handle = await (await getClient()).workflow.start("hwpodOperationWorkflow", { taskQueue: options.taskQueue, workflowId, args: [input] }); + return { workflowId, workflowRunId: handle.firstExecutionRunId, reused: false }; + } catch (error: any) { + if (error?.name !== "WorkflowExecutionAlreadyStartedError") throw error; + const description = await (await getClient()).workflow.getHandle(workflowId).describe(); + return { workflowId, workflowRunId: description.runId, reused: true }; + } + }, + async describe(workflowId: string) { return (await getClient()).workflow.getHandle(workflowId).describe(); }, + async result(workflowId: string) { return (await getClient()).workflow.getHandle(workflowId).result(); }, + async close() { await connection?.close(); connection = undefined; client = undefined; } + }; +} + +function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); } diff --git a/internal/hwpod/workflows.ts b/internal/hwpod/workflows.ts new file mode 100644 index 00000000..c5ba325e --- /dev/null +++ b/internal/hwpod/workflows.ts @@ -0,0 +1,14 @@ +/* + * SPEC: PJ2026-010103 HWPOD 服务。 + * 实现引用: draft-2026-07-20-hwpod-temporal-split。 + * 责任: HWPOD operation durable workflow。 + */ + +import { proxyActivities } from "@temporalio/workflow"; +import type * as activities from "./activities.ts"; + +const { executeHwpodOperation } = proxyActivities({ startToCloseTimeout: "15 minutes", retry: { maximumAttempts: 1 } }); + +export async function hwpodOperationWorkflow(input: any) { + return executeHwpodOperation(input); +} diff --git a/package.json b/package.json index 333d08c4..f1696a95 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ "workbench:worker:dev": "bun --watch cmd/hwlab-workbench-worker/main.ts", "workbench:web:dev": "cd web/hwlab-cloud-web && WORKBENCH_VITE_USE_POLLING=1 bun run dev", "workbench:native:smoke": "bun test internal/workbench/workbench.test.ts && bun run scripts/workbench-native-smoke.mjs", + "hwpod:api:dev": "bun --watch cmd/hwlab-hwpod-api/main.ts", + "hwpod:worker:dev": "bun --watch cmd/hwlab-hwpod-worker/main.ts", + "hwpod:native:smoke": "bun test internal/hwpod/hwpod.test.ts", "workbench:p1:inventory": "node scripts/workbench-long-reliability-p1-inventory.mjs", "dev-runtime-base:build": "node scripts/dev-runtime-base-image.mjs", "cloud-api:smoke": "node scripts/run-bun.mjs scripts/cloud-api-runtime-smoke.mjs", diff --git a/tools/hwlab-cli/bin/hwlab-cli.ts b/tools/hwlab-cli/bin/hwlab-cli.ts index 1b5385a7..d61c2ec9 100644 --- a/tools/hwlab-cli/bin/hwlab-cli.ts +++ b/tools/hwlab-cli/bin/hwlab-cli.ts @@ -26,6 +26,16 @@ if (argv[0] === "tasktree") { console.log(JSON.stringify({ ok: false, operation: "caserun", error: { code: error?.code ?? "caserun_cli_error", message: error?.message ?? String(error) } }, null, 2)); process.exitCode = 1; } +} else if (argv[0] === "hwpod" && argv[1] === "service") { + const { hwpodNativeServiceCommand } = await import("../../src/hwpod-native-service.ts"); + try { + const result = await hwpodNativeServiceCommand({ service: argv[2], action: argv[3] ?? "status", cwd: process.cwd(), env: process.env }); + console.log(JSON.stringify(result, null, 2)); + process.exitCode = result?.ok === false ? 1 : 0; + } catch (error: any) { + console.log(JSON.stringify({ ok: false, operation: "hwpod", error: { code: error?.code ?? "hwpod_cli_error", message: error?.message ?? String(error) } }, null, 2)); + process.exitCode = 1; + } } else if (argv[0] === "workbench") { const { runWorkbenchCli } = await import("../../src/workbench-cli.ts"); try { diff --git a/tools/hwlab-node.py b/tools/hwlab-node.py index a786b51c..340e20bd 100644 --- a/tools/hwlab-node.py +++ b/tools/hwlab-node.py @@ -872,9 +872,9 @@ class WebSocketNodeClient: port = parsed.port or (443 if parsed.scheme == "wss" else 80) host = parsed.hostname or "" raw = socket.create_connection((host, port), timeout=10) - raw.settimeout(1.0) self.sock = ssl.create_default_context().wrap_socket(raw, server_hostname=host) if parsed.scheme == "wss" else raw self._handshake(parsed, host, port) + self.sock.settimeout(1.0) self.bus.emit("log", f"WebSocket 已打开: {ws_url}") self._send_json({ "type": "register", diff --git a/tools/src/hwpod-native-service.ts b/tools/src/hwpod-native-service.ts new file mode 100644 index 00000000..b033caf0 --- /dev/null +++ b/tools/src/hwpod-native-service.ts @@ -0,0 +1,90 @@ +/* + * SPEC: PJ2026-010103 HWPOD 服务。 + * 实现引用: draft-2026-07-20-hwpod-temporal-split。 + * 责任: 通过一个受控入口独立管理 HWPOD L1 API、worker 和 Web。 + */ +import { spawn } from "node:child_process"; +import { closeSync, openSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +type ServiceName = "api" | "worker" | "web"; + +export async function hwpodNativeServiceCommand(input: { service: string; action: string; cwd: string; env: Record }) { + const service = validService(input.service); + const stateDir = path.resolve(input.cwd, requiredEnv(input.env, "HWPOD_NATIVE_SERVICE_STATE_DIR")); + const stateFile = path.join(stateDir, `${service}.json`); + const logFile = path.join(stateDir, `${service}.log`); + if (input.action === "status") return status(service, stateFile, logFile); + if (input.action === "logs") return logs(service, stateFile, logFile); + if (input.action === "stop") return stop(service, stateFile, logFile); + if (input.action === "restart") { await stop(service, stateFile, logFile); return start(service, stateDir, stateFile, logFile, input.cwd, input.env); } + if (input.action === "start") return start(service, stateDir, stateFile, logFile, input.cwd, input.env); + throw codedError("unsupported_service_action", `unsupported HWPOD service action: ${input.action}`); +} + +async function start(service: ServiceName, stateDir: string, stateFile: string, logFile: string, cwd: string, env: Record) { + const current = await readState(stateFile); + if (current?.pid && alive(current.pid)) throw codedError("service_already_running", `${service} is already running`, { pid: current.pid, correctionCommand: `hwlab-cli hwpod service ${service} stop` }); + await mkdir(stateDir, { recursive: true }); + const endpoints = serviceEndpoints(service, env); + const command = serviceCommand(service); + const logFd = openSync(logFile, "w"); + const child = spawn(command[0], command.slice(1), { cwd: service === "web" ? path.join(cwd, "web/hwlab-cloud-web") : cwd, env: serviceEnv(service, env), detached: true, stdio: ["ignore", logFd, logFd] }); + closeSync(logFd); + child.unref(); + const state = { service, pid: child.pid, status: "starting", command, cwd, logFile, endpoints, healthUrl: endpoints.probe.url, startedAt: new Date().toISOString() }; + await writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + return { ok: true, operation: `hwpod.service.${service}.start`, ...state, nextCommand: `hwlab-cli hwpod service ${service} status` }; +} + +async function stop(service: ServiceName, stateFile: string, logFile: string) { + const state = await readState(stateFile); + if (!state?.pid || !alive(state.pid)) { await rm(stateFile, { force: true }); return { ok: true, operation: `hwpod.service.${service}.stop`, service, status: "stopped", alreadyStopped: true, logFile }; } + try { process.kill(-state.pid, "SIGTERM"); } catch (error: any) { if (error?.code !== "ESRCH") throw error; } + await rm(stateFile, { force: true }); + return { ok: true, operation: `hwpod.service.${service}.stop`, service, status: "stopped", pid: state.pid, logFile }; +} + +async function status(service: ServiceName, stateFile: string, logFile: string) { + const state = await readState(stateFile); + const processRunning = Boolean(state?.pid && alive(state.pid)); + const health = processRunning && state?.healthUrl ? await probeHealth(state.healthUrl) : { ok: false, status: null, error: processRunning ? "health-url-not-declared" : "process-not-running" }; + const running = processRunning && health.ok; + return { ok: running, operation: `hwpod.service.${service}.status`, service, status: running ? "running" : processRunning ? "degraded" : "stopped", pid: processRunning ? state.pid : null, endpoints: state?.endpoints ?? null, health, stateFile, logFile, nextCommand: `hwlab-cli hwpod service ${service} logs` }; +} + +async function logs(service: ServiceName, stateFile: string, logFile: string) { + const current = await status(service, stateFile, logFile); + const lines = (await readFile(logFile, "utf8").catch(() => "")).split("\n").filter(Boolean).slice(-80); + return { ...current, operation: `hwpod.service.${service}.logs`, lines, truncated: lines.length === 80 }; +} + +function serviceCommand(service: ServiceName) { + if (service === "api") return ["bun", "cmd/hwlab-hwpod-api/main.ts"]; + if (service === "worker") return ["bun", "cmd/hwlab-hwpod-worker/main.ts"]; + return ["bun", "run", "dev:native-hwpod"]; +} +function serviceEnv(service: ServiceName, env: Record) { + if (service === "api") return { ...process.env, ...env, HWPOD_API_HOST: requiredEnv(env, "HWPOD_API_BIND_HOST"), HWPOD_API_PORT: requiredEnv(env, "HWPOD_API_PORT") }; + if (service === "worker") return { ...process.env, ...env, HWPOD_WORKER_HEALTH_HOST: requiredEnv(env, "HWPOD_WORKER_BIND_HOST"), HWPOD_WORKER_HEALTH_PORT: requiredEnv(env, "HWPOD_WORKER_HEALTH_PORT") }; + return { ...process.env, ...env, HWPOD_WEB_HOST: requiredEnv(env, "HWPOD_WEB_BIND_HOST"), HWPOD_WEB_PORT: requiredEnv(env, "HWPOD_WEB_PORT"), HWPOD_NATIVE_API_URL: requiredEnv(env, "HWPOD_NATIVE_API_URL") }; +} +function serviceEndpoints(service: ServiceName, env: Record) { + const prefix = service === "worker" ? "HWPOD_WORKER" : service === "api" ? "HWPOD_API" : "HWPOD_WEB"; + const bindHost = requiredEnv(env, `${prefix}_BIND_HOST`); + const probeHost = requiredEnv(env, `${prefix}_PROBE_HOST`); + const port = requiredEnv(env, service === "worker" ? "HWPOD_WORKER_HEALTH_PORT" : `${prefix}_PORT`); + const publicBaseUrl = fixedHttpsOrigin(requiredEnv(env, "HWPOD_PUBLIC_BASE_URL"), "HWPOD_PUBLIC_BASE_URL"); + const healthPath = service === "web" ? "/health/live" : "/health/ready"; + return { bind: { host: bindHost, port: validPort(port) }, probe: endpoint(probeHost, port, healthPath), public: service === "worker" ? null : { url: publicBaseUrl } }; +} +function endpoint(host: string, port: string, pathname: string) { const numericPort = validPort(port); return { host, port: numericPort, url: `http://${host}:${numericPort}${pathname}` }; } +function validPort(value: string) { const port = Number(value); if (!Number.isInteger(port) || port < 1 || port > 65535) throw codedError("invalid_service_port", `invalid HWPOD service port: ${value}`); return port; } +function validService(value: string): ServiceName { if (value === "api" || value === "worker" || value === "web") return value; throw codedError("invalid_service", "HWPOD service must be api, worker, or web"); } +function requiredEnv(env: Record, key: string) { const value = String(env[key] ?? "").trim(); if (!value) throw codedError("native_exposure_config_required", `${key} is required from YAML-first HWPOD native development config`); return value; } +function fixedHttpsOrigin(value: string, key: string) { try { const parsed = new URL(value); if (parsed.protocol === "https:" && parsed.pathname === "/" && !parsed.search && !parsed.hash && !parsed.port) return parsed.origin; } catch {} throw codedError("native_exposure_config_invalid", `${key} must be a pathless HTTPS origin`); } +async function readState(file: string) { return JSON.parse(await readFile(file, "utf8").catch(() => "null")); } +function alive(pid: number) { try { process.kill(pid, 0); return true; } catch { return false; } } +async function probeHealth(url: string) { try { const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); return { ok: response.ok, status: response.status, url }; } catch (error) { return { ok: false, status: null, url, error: error instanceof Error ? error.message : String(error) }; } } +function codedError(code: string, message: string, details?: unknown) { return Object.assign(new Error(message), { code, details }); } diff --git a/web/hwlab-cloud-web/hwpod/app.js b/web/hwlab-cloud-web/hwpod/app.js new file mode 100644 index 00000000..33a44064 --- /dev/null +++ b/web/hwlab-cloud-web/hwpod/app.js @@ -0,0 +1,60 @@ +const state = { resources: [], selected: null }; +const resourcesEl = document.querySelector("[data-resources]"); +const detailEl = document.querySelector("[data-detail]"); +const healthEl = document.querySelector("[data-health]"); +document.querySelector("[data-refresh]").addEventListener("click", refresh); +void refresh(); + +async function refresh() { + healthEl.dataset.health = "loading"; + document.querySelector("[data-health-label]").textContent = "同步中"; + try { + const [health, nodes, devices] = await Promise.all([getJson("/health/ready"), getJson("/v1/hwpod/topology?resource=node&limit=100"), getJson("/v1/hwpod/topology?resource=device&limit=100")]); + const nodeItems = Array.isArray(nodes.items) ? nodes.items : []; + const deviceItems = Array.isArray(devices.items) ? devices.items : []; + state.resources = [...nodeItems, ...deviceItems]; + updateStats(nodes.summary || {}, devices.summary || {}); + renderResources(); + if (state.selected) selectResource(state.resources.find(item => resourceKey(item) === state.selected) || null); + healthEl.dataset.health = health.ok ? "ready" : "blocked"; + document.querySelector("[data-health-label]").textContent = health.ok ? "服务就绪" : "依赖阻塞"; + document.querySelector("[data-observed]").textContent = displayTime(devices.observedAt || nodes.observedAt); + } catch (error) { + healthEl.dataset.health = "blocked"; + document.querySelector("[data-health-label]").textContent = "连接失败"; + resourcesEl.innerHTML = `
${escapeHtml(error.message)}
`; + } +} + +function renderResources() { + if (!state.resources.length) { resourcesEl.innerHTML = '
未发现 HWPOD 节点或设备。
'; return; } + resourcesEl.innerHTML = state.resources.map(item => { + const key = resourceKey(item); + const title = item.resourceType === "hwpod" ? item.name || item.hwpodId : item.name || item.nodeId; + const subtitle = item.resourceType === "hwpod" ? `${item.nodeId || "未绑定节点"} · ${item.elements?.workspace?.label || "未声明工作区"}` : `${item.platform || "unknown"} · ${item.deviceCount || 0} 个 HWPOD`; + return ``; + }).join(""); + resourcesEl.querySelectorAll("[data-key]").forEach(button => button.addEventListener("click", () => selectResource(state.resources.find(item => resourceKey(item) === button.dataset.key)))); +} + +function selectResource(item) { + state.selected = item ? resourceKey(item) : null; + renderResources(); + if (!item) { detailEl.innerHTML = '
选择一个资源查看能力、占用状态和 blocker。
'; return; } + const identity = item.resourceType === "hwpod" ? { 类型: "HWPOD", ID: item.hwpodId, 节点: item.nodeId, 状态: item.status } : { 类型: "HWPOD Node", ID: item.nodeId, 平台: item.platform, 状态: item.status }; + const capabilities = item.resourceType === "hwpod" ? item.declaredCapabilities : item.actualCapabilities; + detailEl.innerHTML = `${detailBlock("IDENTITY", kv(identity))}${detailBlock("CAPABILITIES", chips(capabilities))}${blockers(item.blockers)}${detailBlock("RAW STATUS", kv({ 在线: String(Boolean(item.online)), 占用: String(Boolean(item.busy || item.inFlight?.busy)), 最近观测: displayTime(item.observedAt) }))}`; +} + +function updateStats(nodes, devices) { + setStat("nodes", nodes.nodeCount || 0); setStat("online", nodes.onlineNodeCount || 0); setStat("devices", devices.deviceCount || 0); setStat("available", devices.availableDeviceCount || 0); setStat("blocked", (devices.deviceCount || 0) - (devices.availableDeviceCount || 0)); +} +function setStat(name, value) { document.querySelector(`[data-stat="${name}"]`).textContent = String(value); } +function detailBlock(title, body) { return `

${title}

${body}
`; } +function kv(values) { return `
${Object.entries(values).map(([key, value]) => `
${escapeHtml(key)}
${escapeHtml(value ?? "-")}
`).join("")}
`; } +function chips(values) { const items = Array.isArray(values) ? values : []; return items.length ? `
${items.map(value => `${escapeHtml(value)}`).join("")}
` : '未声明能力'; } +function blockers(values) { const items = Array.isArray(values) ? values : []; return items.length ? detailBlock("BLOCKERS", items.map(value => `
${escapeHtml(value.code || "blocked")}
${escapeHtml(value.summary || value.message || "资源不可用")}
`).join("")) : ""; } +function resourceKey(item) { return `${item.resourceType}:${item.hwpodId || item.nodeId}`; } +function displayTime(value) { if (!value) return "尚未同步"; const date = new Date(value); return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("zh-CN", { hour12: false }); } +async function getJson(url) { const response = await fetch(url, { headers: { accept: "application/json" } }); const body = await response.json().catch(() => null); if (!response.ok || !body) throw new Error(body?.error?.message || `${url} 返回 HTTP ${response.status}`); return body; } +function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/gu, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char])); } diff --git a/web/hwlab-cloud-web/hwpod/index.html b/web/hwlab-cloud-web/hwpod/index.html new file mode 100644 index 00000000..3450df89 --- /dev/null +++ b/web/hwlab-cloud-web/hwpod/index.html @@ -0,0 +1,46 @@ + + + + + + + HWPOD 控制台 + + + + +
+
+
+ +
HWPOD硬件资源控制台
+
+
+ 连接中 + +
+
+ +
+
节点-
+
在线-
+
设备-
+
可用-
+
阻塞-
+
+ +
+
+

RESOURCE MAP

节点与设备

+
正在读取 HWPOD topology...
+
+ +
+
+ + diff --git a/web/hwlab-cloud-web/hwpod/styles.css b/web/hwlab-cloud-web/hwpod/styles.css new file mode 100644 index 00000000..8c4fc644 --- /dev/null +++ b/web/hwlab-cloud-web/hwpod/styles.css @@ -0,0 +1,55 @@ +:root { color-scheme: dark; font-family: "IBM Plex Sans", "Noto Sans SC", sans-serif; background: #111410; color: #eef2e8; } +* { box-sizing: border-box; } +html, body { width: 100%; min-height: 100%; margin: 0; } +body { background: #111410; letter-spacing: 0; overflow: hidden; } +button { font: inherit; letter-spacing: 0; } +.shell { min-height: 100vh; display: grid; grid-template-rows: 58px 54px minmax(0, 1fr); background-image: linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px); background-size: 28px 28px; } +.command-bar { display: flex; align-items: center; justify-content: space-between; padding: 0 22px; border-bottom: 1px solid #30372e; background: rgba(17,20,16,.94); } +.identity, .actions, .connection { display: flex; align-items: center; } +.identity { gap: 12px; } +.identity .mark { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid #a7ff3f; color: #a7ff3f; font: 700 12px/1 "IBM Plex Mono", monospace; } +.identity div { display: grid; gap: 2px; } +.identity strong { font-size: 15px; } +.identity small { color: #8d9788; font-size: 11px; } +.actions { gap: 16px; } +.connection { gap: 8px; color: #aeb6a8; font-size: 12px; } +.connection i { width: 7px; height: 7px; border-radius: 50%; background: #e9bd54; box-shadow: 0 0 12px #e9bd54; } +.connection[data-health="ready"] i { background: #a7ff3f; box-shadow: 0 0 12px #a7ff3f; } +.connection[data-health="blocked"] i { background: #ff6b57; box-shadow: 0 0 12px #ff6b57; } +.actions button { min-width: 66px; height: 30px; border: 1px solid #495044; background: #191d18; color: #eef2e8; cursor: pointer; } +.actions button:hover { border-color: #a7ff3f; color: #a7ff3f; } +.status-strip { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border-bottom: 1px solid #30372e; background: #181c17; } +.status-strip div { min-width: 0; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 10px 18px; border-right: 1px solid #30372e; } +.status-strip span { color: #8d9788; font-size: 11px; } +.status-strip strong { font: 600 20px/1 "IBM Plex Mono", monospace; color: #f4f7f0; } +.workspace { min-height: 0; display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(320px, .75fr); } +.resource-pane, .detail-pane { min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); } +.resource-pane { border-right: 1px solid #30372e; } +.pane-heading { min-height: 68px; display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid #30372e; background: rgba(17,20,16,.84); } +.pane-heading p { margin: 0 0 4px; color: #a7ff3f; font: 600 10px/1 "IBM Plex Mono", monospace; } +.pane-heading h1, .pane-heading h2 { margin: 0; font-size: 17px; font-weight: 600; } +.pane-heading time { color: #717b6d; font: 11px/1 "IBM Plex Mono", monospace; } +.resource-list, .detail { min-height: 0; overflow: auto; padding: 14px; } +.resource-list { display: grid; align-content: start; gap: 8px; } +.resource { width: 100%; min-height: 76px; display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 12px 14px; border: 1px solid #333a30; background: #171b16; color: inherit; text-align: left; cursor: pointer; } +.resource:hover, .resource[aria-current="true"] { border-color: #829e65; background: #1c211a; } +.resource .rail { width: 3px; height: 44px; background: #596255; } +.resource[data-status="online"] .rail, .resource[data-status="available"] .rail { background: #a7ff3f; } +.resource[data-status="busy"] .rail { background: #e9bd54; } +.resource[data-status="offline"] .rail, .resource[data-status="blocked"] .rail { background: #ff6b57; } +.resource-copy { min-width: 0; display: grid; gap: 5px; } +.resource-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; } +.resource-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #8d9788; font: 11px/1.35 "IBM Plex Mono", monospace; } +.badge { border: 1px solid #41483d; padding: 5px 8px; color: #b6beb1; font: 10px/1 "IBM Plex Mono", monospace; text-transform: uppercase; } +.detail { display: grid; align-content: start; gap: 14px; } +.detail-empty, .empty { min-height: 180px; display: grid; place-content: center; gap: 7px; color: #747d70; text-align: center; } +.detail-empty strong { color: #c8cec3; } +.detail-block { border-top: 1px solid #353c32; padding-top: 14px; } +.detail-block h3 { margin: 0 0 10px; color: #8d9788; font: 600 10px/1 "IBM Plex Mono", monospace; } +.kv { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 8px 14px; font-size: 12px; } +.kv dt { color: #7f897a; } +.kv dd { margin: 0; overflow-wrap: anywhere; color: #e5e9df; font-family: "IBM Plex Mono", monospace; } +.chips { display: flex; flex-wrap: wrap; gap: 6px; } +.chip { border: 1px solid #3f493b; padding: 5px 7px; color: #c8d0c1; font: 10px/1 "IBM Plex Mono", monospace; } +.blocker { border-left: 3px solid #ff6b57; padding: 10px 12px; background: #271a17; color: #ffc1b8; font-size: 12px; } +@media (max-width: 760px) { body { overflow: auto; } .shell { min-height: 100svh; grid-template-rows: 58px auto auto; } .status-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); } .status-strip div:nth-child(n+4) { border-top: 1px solid #30372e; } .workspace { grid-template-columns: 1fr; } .resource-pane { border-right: 0; } .detail-pane { border-top: 1px solid #30372e; min-height: 420px; } .resource-list, .detail { overflow: visible; } } diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index c2067877..06a3ecae 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -7,6 +7,7 @@ "deps": "node ../../scripts/worktree-deps.mjs --web", "dev": "bun run deps --quiet && vite", "dev:native-caserun": "CHOKIDAR_USEPOLLING=1 HWLAB_CASERUN_NATIVE_TEST=1 bun run dev", + "dev:native-hwpod": "bun run deps --quiet && vite --config scripts/hwpod-native-vite.config.ts", "caserun:native": "bun scripts/caserun-native-supervisor.ts", "caserun:native:start": "bun run scripts/caserun-native-service.ts start", "caserun:native:stop": "bun run scripts/caserun-native-service.ts stop", diff --git a/web/hwlab-cloud-web/scripts/hwpod-native-vite.config.ts b/web/hwlab-cloud-web/scripts/hwpod-native-vite.config.ts new file mode 100644 index 00000000..12a983ed --- /dev/null +++ b/web/hwlab-cloud-web/scripts/hwpod-native-vite.config.ts @@ -0,0 +1,24 @@ +/* + * SPEC: PJ2026-010103 HWPOD 服务。 + * 实现引用: draft-2026-07-20-hwpod-temporal-split。 + * 责任: 独立 HWPOD L1 Web 的固定端口、健康检查和 API proxy。 + */ +import path from "node:path"; +import { defineConfig, type Plugin } from "vite"; + +const apiUrl = process.env.HWPOD_NATIVE_API_URL; +if (!apiUrl) throw new Error("HWPOD_NATIVE_API_URL is required"); + +export default defineConfig({ + root: path.resolve(import.meta.dirname, "../hwpod"), + server: { + host: process.env.HWPOD_WEB_HOST || "0.0.0.0", + port: requiredPort(process.env.HWPOD_WEB_PORT), + strictPort: true, + proxy: { "/v1": { target: apiUrl, changeOrigin: false }, "/health/ready": { target: apiUrl, changeOrigin: false } } + }, + plugins: [healthPlugin()] +}); + +function healthPlugin(): Plugin { return { name: "hwpod-native-health", configureServer(server) { server.middlewares.use((request, response, next) => { if (request.url !== "/health/live") return next(); response.statusCode = 200; response.setHeader("content-type", "application/json"); response.end(JSON.stringify({ ok: true, serviceId: "hwlab-hwpod-web", status: "live", valuesPrinted: false })); }); } }; } +function requiredPort(value: string | undefined) { const port = Number.parseInt(String(value ?? ""), 10); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("HWPOD_WEB_PORT is required and must be valid"); return port; }