diff --git a/internal/hwpod/http.ts b/internal/hwpod/http.ts index 5cf96705..a0283664 100644 --- a/internal/hwpod/http.ts +++ b/internal/hwpod/http.ts @@ -6,6 +6,7 @@ */ import { validateHwpodOperationInput } from "./contracts.ts"; +import { buildHwpodTopologyReadModel } from "../cloud/hwpod-topology-read-model.ts"; export function createHwpodHttpApp(options: { runtimeApiUrl: string; runtimeApiAuthorization: string; temporal: any; specRegistry: any }) { return { @@ -28,7 +29,9 @@ export function createHwpodHttpApp(options: { runtimeApiUrl: string; runtimeApiA if (request.method === "DELETE") return json(200, await options.specRegistry.delete(hwpodId)); 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 proxyGet(options.runtimeApiUrl, `/v1/hwpod/topology${url.search}`, options.runtimeApiAuthorization); + if (url.pathname === "/v1/hwpod/topology" && request.method === "GET") { + return json(200, await buildHwpodTopologyFromRegistry(options.specRegistry, Object.fromEntries(url.searchParams))); + } 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(); @@ -59,6 +62,23 @@ export function createHwpodHttpApp(options: { runtimeApiUrl: string; runtimeApiA }; } +export async function buildHwpodTopologyFromRegistry(specRegistry: any, query: Record = {}) { + const listed = await specRegistry.list(); + const specs = await Promise.all((listed.specs ?? []).map(async (summary: any) => { + const entity = await specRegistry.get(summary.hwpodId); + return { + ...summary, + document: entity.document, + authority: entity.authority, + nodeId: entity.nodeId, + }; + })); + return buildHwpodTopologyReadModel(specs, { + mode: "hwpod-native-api-no-node-registry", + nodes: [], + }, query); +} + async function readiness(options: { runtimeApiUrl: string; runtimeApiAuthorization: string; temporal: any; specRegistry: any }) { try { const specs = await options.specRegistry.list(); diff --git a/internal/hwpod/spec-registry.test.ts b/internal/hwpod/spec-registry.test.ts index 8794d5a2..448f9a8f 100644 --- a/internal/hwpod/spec-registry.test.ts +++ b/internal/hwpod/spec-registry.test.ts @@ -6,7 +6,7 @@ import { expect, test } from "bun:test"; -import { createHwpodHttpApp } from "./http.ts"; +import { buildHwpodTopologyFromRegistry, createHwpodHttpApp } from "./http.ts"; import { createHwpodSpecRegistry, type HwpodRuntimeSpecRecord, type HwpodRuntimeSpecStore } from "./spec-registry.ts"; test("runtime spec repository persists CRUD and freezes YAML-first built-ins", async () => { @@ -53,6 +53,31 @@ test("L1 HWPOD HTTP exposes runtime CRUD and typed frozen errors", async () => { await app.close(); }); +test("L0 HWPOD topology is built from the local registry without Cloud API", async () => { + const registry = createHwpodSpecRegistry({ + store: memoryStore(), + builtIns: [{ configRef: "config/hwpod.yaml#spec", document: spec("builtin-pod", "/builtin") }], + }); + await registry.create(spec("runtime-pod", "/runtime")); + + const topology = await buildHwpodTopologyFromRegistry(registry, { resource: "device", q: "runtime-pod" }); + expect(topology).toMatchObject({ + ok: true, + contractVersion: "hwpod-topology-v1", + resource: "device", + service: { connectedNodeCount: 0, websocketMode: "hwpod-native-api-no-node-registry" }, + summary: { deviceCount: 2, onlineNodeCount: 0 }, + items: [{ hwpodId: "runtime-pod", nodeId: "node-test", status: "offline", source: { authority: "runtime" } }], + }); + 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 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(); +}); + 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() }; diff --git a/tools/hwlab-cli/bin/hwlab-cli.ts b/tools/hwlab-cli/bin/hwlab-cli.ts index 2f19be96..1b75cbc0 100644 --- a/tools/hwlab-cli/bin/hwlab-cli.ts +++ b/tools/hwlab-cli/bin/hwlab-cli.ts @@ -27,9 +27,11 @@ if (argv[0] === "tasktree") { process.exitCode = 1; } } else if (argv[0] === "hwpod" && argv[1] === "service") { - const { hwpodNativeServiceCommand } = await import("../../src/hwpod-native-service.ts"); + const { hwpodNativeServiceCommand, hwpodNativeServicesStatus } = 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 }); + const result = argv[2] === "status" && argv[3] === undefined + ? await hwpodNativeServicesStatus({ cwd: process.cwd(), env: process.env }) + : 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) { diff --git a/tools/src/hwpod-native-service.test.ts b/tools/src/hwpod-native-service.test.ts new file mode 100644 index 00000000..f0d448ce --- /dev/null +++ b/tools/src/hwpod-native-service.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { hwpodNativeServicesStatus } from "./hwpod-native-service.ts"; + +test("HWPOD aggregate status reports API, worker, and Web together", async () => { + const cwd = await mkdtemp(path.join(tmpdir(), "hwpod-native-status-")); + try { + const result = await hwpodNativeServicesStatus({ cwd, env: { HWPOD_NATIVE_SERVICE_STATE_DIR: ".state/services" } }); + expect(result).toMatchObject({ + ok: false, + status: "stopped", + summary: { total: 3, running: 0, unavailable: 3 }, + services: { + api: { service: "api", status: "stopped" }, + worker: { service: "worker", status: "stopped" }, + web: { service: "web", status: "stopped" }, + }, + }); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/tools/src/hwpod-native-service.ts b/tools/src/hwpod-native-service.ts index 899f041b..222cad04 100644 --- a/tools/src/hwpod-native-service.ts +++ b/tools/src/hwpod-native-service.ts @@ -9,6 +9,20 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; type ServiceName = "api" | "worker" | "web"; +const SERVICE_NAMES: ServiceName[] = ["api", "worker", "web"]; + +export async function hwpodNativeServicesStatus(input: { cwd: string; env: Record }) { + const results = await Promise.all(SERVICE_NAMES.map((service) => hwpodNativeServiceCommand({ service, action: "status", ...input }))); + const runningCount = results.filter((result) => result.ok).length; + return { + ok: runningCount === SERVICE_NAMES.length, + operation: "hwpod.service.status", + status: runningCount === SERVICE_NAMES.length ? "running" : runningCount === 0 ? "stopped" : "degraded", + summary: { total: SERVICE_NAMES.length, running: runningCount, unavailable: SERVICE_NAMES.length - runningCount }, + services: Object.fromEntries(results.map((result) => [result.service, result])), + valuesPrinted: false, + }; +} export async function hwpodNativeServiceCommand(input: { service: string; action: string; cwd: string; env: Record }) { const service = validService(input.service); @@ -71,7 +85,7 @@ function serviceCommand(service: ServiceName) { 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") }; + 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"), HWPOD_WEB_ACCESS_PROFILE_JSON: requiredEnv(env, "HWPOD_WEB_ACCESS_PROFILE_JSON") }; } function serviceEndpoints(service: ServiceName, env: Record) { const prefix = service === "worker" ? "HWPOD_WORKER" : service === "api" ? "HWPOD_API" : "HWPOD_WEB"; diff --git a/web/hwlab-cloud-web/scripts/hwpod-native-vite-config.test.ts b/web/hwlab-cloud-web/scripts/hwpod-native-vite-config.test.ts new file mode 100644 index 00000000..830c3702 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/hwpod-native-vite-config.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { parseHwpodNativeAccessProfile } from "./hwpod-native-vite.config"; +import { firstAllowedNavPath } from "../src/stores/auth"; + +describe("HWPOD native Cloud Console profile", () => { + it("accepts the YAML-rendered HWPOD-only navigation profile", () => { + const profile = parseHwpodNativeAccessProfile(JSON.stringify({ + id: "hwpod-native-l1", + allowedNavIds: ["admin.hwpodGroups"], + startPath: "/hwpods/devices", + })); + expect(profile).toEqual({ id: "hwpod-native-l1", allowedNavIds: ["admin.hwpodGroups"], startPath: "/hwpods/devices" }); + expect(firstAllowedNavPath(profile.allowedNavIds)).toBe(profile.startPath); + }); + + it("fails closed without a declared navigation profile", () => { + expect(() => parseHwpodNativeAccessProfile(undefined)).toThrow(/HWPOD_WEB_ACCESS_PROFILE_JSON/u); + }); +}); 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 ae269a1c..5de92d1a 100644 --- a/web/hwlab-cloud-web/scripts/hwpod-native-vite.config.ts +++ b/web/hwlab-cloud-web/scripts/hwpod-native-vite.config.ts @@ -1,23 +1,67 @@ /* * SPEC: PJ2026-010103 HWPOD 服务。 * 实现引用: draft-2026-07-20-hwpod-temporal-split。 - * 责任: 独立 HWPOD L1 Web 的固定端口、健康检查和 API proxy。 + * 责任: 复用完整 Cloud Console 壳的 HWPOD L1 Web、原生会话和 API proxy。 */ -import path from "node:path"; +import { fileURLToPath, URL } from "node:url"; +import type { ServerResponse } from "node:http"; + +import vue from "@vitejs/plugin-vue"; import { defineConfig, type Plugin } from "vite"; export default defineConfig(({ command }) => ({ - root: path.resolve(import.meta.dirname, "../hwpod"), - build: { outDir: "dist", emptyOutDir: true }, + root: fileURLToPath(new URL("..", import.meta.url)), + cacheDir: ".state/vite/hwpod", + resolve: { alias: { "@": fileURLToPath(new URL("../src", import.meta.url)) } }, + build: { outDir: "hwpod/dist", emptyOutDir: true }, server: command === "serve" ? { - host: process.env.HWPOD_WEB_HOST || "0.0.0.0", + host: requiredString(process.env.HWPOD_WEB_HOST, "HWPOD_WEB_HOST"), port: requiredPort(process.env.HWPOD_WEB_PORT), strictPort: true, - proxy: { "/v1": { target: requiredApiUrl(), changeOrigin: false }, "/health/ready": { target: requiredApiUrl(), changeOrigin: false } } + proxy: { + "/v1/hwpod": { target: requiredApiUrl(), changeOrigin: false }, + "/health/ready": { target: requiredApiUrl(), changeOrigin: false }, + } } : undefined, - plugins: [healthPlugin()] + plugins: command === "serve" + ? [nativeSessionPlugin(process.env.HWPOD_WEB_ACCESS_PROFILE_JSON), healthPlugin(), vue()] + : [vue()] })); 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 nativeSessionPlugin(serialized: string | undefined): Plugin { + const profile = parseHwpodNativeAccessProfile(serialized); + const session = { + authenticated: true, + authMethod: "native-development", + identityAuthority: "yaml-native-profile", + sessionKind: "native-hwpod", + user: { id: "native-hwpod", username: "native-hwpod", displayName: "HWPOD Native", role: "admin", status: "active" }, + capabilities: { admin: "available" }, + access: { nav: { profileId: profile.id, allowedIds: profile.allowedNavIds, valuesRedacted: true } }, + valuesRedacted: true, + }; + return { name: "hwpod-native-session", configureServer(server) { server.middlewares.use((request, response, next) => { + const pathname = new URL(request.url ?? "/", "http://hwpod-native").pathname; + if (pathname === "/auth/session" && request.method === "GET") return sendJson(response, 200, session); + if (pathname === "/auth/logout" && request.method === "POST") return sendJson(response, 200, { ok: true, authenticated: true, valuesRedacted: true }); + return next(); + }); } }; +} +export function parseHwpodNativeAccessProfile(serialized: string | undefined) { + let value: unknown; + try { value = JSON.parse(requiredString(serialized, "HWPOD_WEB_ACCESS_PROFILE_JSON")); } + catch (error) { throw new Error(`HWPOD_WEB_ACCESS_PROFILE_JSON must be valid JSON: ${error instanceof Error ? error.message : String(error)}`); } + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("HWPOD_WEB_ACCESS_PROFILE_JSON must be an object"); + const raw = value as Record; + const id = requiredString(raw.id, "HWPOD_WEB_ACCESS_PROFILE_JSON.id"); + const startPath = requiredString(raw.startPath, "HWPOD_WEB_ACCESS_PROFILE_JSON.startPath"); + const allowedNavIds = Array.isArray(raw.allowedNavIds) ? raw.allowedNavIds.map((item) => requiredString(item, "HWPOD_WEB_ACCESS_PROFILE_JSON.allowedNavIds[]")) : []; + if (allowedNavIds.length === 0) throw new Error("HWPOD_WEB_ACCESS_PROFILE_JSON.allowedNavIds must not be empty"); + if (!startPath.startsWith("/")) throw new Error("HWPOD_WEB_ACCESS_PROFILE_JSON.startPath must be an absolute application path"); + return { id, allowedNavIds, startPath }; +} +function sendJson(response: ServerResponse, status: number, body: unknown) { response.statusCode = status; response.setHeader("content-type", "application/json"); response.setHeader("cache-control", "no-store"); response.end(JSON.stringify(body)); } function requiredApiUrl() { const value = process.env.HWPOD_NATIVE_API_URL; if (!value) throw new Error("HWPOD_NATIVE_API_URL is required"); return value; } 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; } +function requiredString(value: unknown, name: string) { const text = typeof value === "string" ? value.trim() : ""; if (!text) throw new Error(`${name} is required`); return text; } diff --git a/web/hwlab-cloud-web/src/stores/auth.ts b/web/hwlab-cloud-web/src/stores/auth.ts index 3229159d..9aa3a9fa 100644 --- a/web/hwlab-cloud-web/src/stores/auth.ts +++ b/web/hwlab-cloud-web/src/stores/auth.ts @@ -138,8 +138,9 @@ function accessFromUnknown(value: unknown): AuthAccess | undefined { return { nav: { profileId: nonEmptyString(nav.profileId) ?? undefined, allowedIds, valuesRedacted: nav.valuesRedacted === true } }; } -function firstAllowedNavPath(allowedIds: string[]): string { +export function firstAllowedNavPath(allowedIds: string[]): string { const ordered = [ + { id: "admin.hwpodGroups", path: "/hwpods/devices" }, { id: "workbench.code", path: "/workbench" }, { id: "workbench.debug", path: "/workbench/debug" }, { id: "opencode.root", path: "/opencode" },