From a62a914306a90c4c248acfe0826e6fdce1062a15 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 02:10:00 +0200 Subject: [PATCH] feat(hwpod): add runtime spec CRUD --- cmd/hwlab-hwpod-api/main.ts | 7 +- internal/hwpod/http.ts | 23 +++- internal/hwpod/runtime.ts | 14 ++- internal/hwpod/spec-document.ts | 38 +++++++ internal/hwpod/spec-registry.test.ts | 67 ++++++++++++ internal/hwpod/spec-registry.ts | 152 +++++++++++++++++++++++++++ tools/hwlab-cli/hwpod.test.ts | 37 ++++++- tools/src/hwpod-harness-lib.ts | 99 ++++++++++++----- 8 files changed, 401 insertions(+), 36 deletions(-) create mode 100644 internal/hwpod/spec-document.ts create mode 100644 internal/hwpod/spec-registry.test.ts create mode 100644 internal/hwpod/spec-registry.ts diff --git a/cmd/hwlab-hwpod-api/main.ts b/cmd/hwlab-hwpod-api/main.ts index 64ab7ec4..6c330f4c 100644 --- a/cmd/hwlab-hwpod-api/main.ts +++ b/cmd/hwlab-hwpod-api/main.ts @@ -1,9 +1,14 @@ #!/usr/bin/env bun +/* + * SPEC: PJ2026-010103 HWPOD 服务; + * PJ2026-01010305 71FREQ 预装 draft-2026-06-26-71freq-v03-hwpod-preinstall。 + * 责任: 启动拥有 runtime spec registry 与 Temporal 提交能力的 HWPOD API。 + */ 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 app = createHwpodHttpApp({ runtimeApiUrl: runtime.runtimeApiUrl, runtimeApiAuthorization: runtime.runtimeApiAuthorization, temporal: runtime.temporal, specRegistry: runtime.specRegistry }); 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) }); diff --git a/internal/hwpod/http.ts b/internal/hwpod/http.ts index a5d56f4b..2ba29d76 100644 --- a/internal/hwpod/http.ts +++ b/internal/hwpod/http.ts @@ -1,12 +1,13 @@ /* - * SPEC: PJ2026-010103 HWPOD 服务。 + * SPEC: PJ2026-010103 HWPOD 服务; + * PJ2026-01010305 71FREQ 预装 draft-2026-06-26-71freq-v03-hwpod-preinstall。 * 实现引用: 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 }) { +export function createHwpodHttpApp(options: { runtimeApiUrl: string; runtimeApiAuthorization: string; temporal: any; specRegistry: any }) { return { async fetch(request: Request) { const url = new URL(request.url); @@ -14,7 +15,19 @@ export function createHwpodHttpApp(options: { runtimeApiUrl: string; runtimeApiA 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/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(); + return json(201, await options.specRegistry.create(specDocument(body))); + } + const specMatch = /^\/v1\/hwpod\/specs\/([^/]+)$/u.exec(url.pathname); + if (specMatch) { + const hwpodId = decodeURIComponent(specMatch[1]); + if (request.method === "GET") return json(200, await options.specRegistry.get(hwpodId)); + if (request.method === "PUT") return json(200, await options.specRegistry.update(hwpodId, specDocument(await request.json()))); + 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-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") { @@ -35,7 +48,8 @@ export function createHwpodHttpApp(options: { runtimeApiUrl: string; runtimeApiA 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 }); + const status = code.includes("not_found") ? 404 : code === "hwpod_spec_frozen" || code === "hwpod_spec_exists" || code === "hwpod_spec_authority_conflict" ? 409 : 400; + return json(status, { ok: false, status: "failed", error: { code, message: error?.message ?? String(error), ...(error?.details && typeof error.details === "object" ? error.details : {}) }, valuesPrinted: false }); } }, close: async () => options.temporal.close() @@ -47,4 +61,5 @@ async function readiness(options: { runtimeApiUrl: string; runtimeApiAuthorizati 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 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 fc5124e3..51b5efff 100644 --- a/internal/hwpod/runtime.ts +++ b/internal/hwpod/runtime.ts @@ -1,10 +1,12 @@ /* - * SPEC: PJ2026-010103 HWPOD 服务。 + * 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 配置解析。 */ import { createHwpodTemporalGateway } from "./temporal.ts"; +import { hwpodSpecRegistryFromEnv } from "./spec-registry.ts"; export function hwpodRuntime(env: Record = process.env) { const address = String(env.HWPOD_TEMPORAL_ADDRESS ?? env.TEMPORAL_ADDRESS ?? "").trim(); @@ -14,7 +16,15 @@ export function hwpodRuntime(env: Record = process.e 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 }) }; + return { + address, + namespace, + taskQueue, + runtimeApiUrl, + runtimeApiAuthorization, + temporal: createHwpodTemporalGateway({ address, namespace, taskQueue }), + specRegistry: hwpodSpecRegistryFromEnv(env), + }; } function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); } diff --git a/internal/hwpod/spec-document.ts b/internal/hwpod/spec-document.ts new file mode 100644 index 00000000..cfc7d3bc --- /dev/null +++ b/internal/hwpod/spec-document.ts @@ -0,0 +1,38 @@ +/* + * SPEC: PJ2026-010101 HWPOD 标准;PJ2026-010102 HWPOD 工具。 + * 责任: 提供 HWPOD spec document 的共享标准化与校验。 + */ + +export function normalizeHwpodSpec(document: any, specPath: string) { + const root = objectValue(document); + if (root.kind !== "Hwpod") throw codedError("invalid_hwpod_spec_kind", "hwpod-spec kind must be Hwpod", { specPath, kind: root.kind }); + const spec = objectValue(root.spec); + const metadata = objectValue(root.metadata); + for (const key of ["targetDevice", "workspace", "debugProbe", "ioProbe"]) { + if (!isPlainObject(spec[key])) throw codedError("invalid_hwpod_spec_element", `hwpod-spec missing ${key}`, { specPath, element: key }); + } + const workspacePath = requiredText(spec.workspace.path, "spec.workspace.path"); + const nodeBinding = objectValue(spec.nodeBinding); + const nodeId = requiredText(nodeBinding.nodeId, "spec.nodeBinding.nodeId"); + const name = requiredText(metadata.name ?? metadata.uid, "metadata.name"); + return { + apiVersion: text(root.apiVersion) || "hwlab.pikastech.com/v1alpha1", + kind: "Hwpod", + metadata: { ...metadata, name }, + spec: { + ...spec, + nodeBinding: { ...nodeBinding, nodeId }, + workspace: { ...spec.workspace, path: workspacePath }, + targetDevice: spec.targetDevice, + debugProbe: spec.debugProbe, + ioProbe: spec.ioProbe, + }, + }; +} + +function objectValue(value: unknown) { return isPlainObject(value) ? value as Record : {}; } +function isPlainObject(value: unknown) { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } +function text(value: unknown) { return typeof value === "string" && value.trim() ? value.trim() : ""; } +function requiredText(value: unknown, name: string) { const normalized = text(value); if (!normalized) throw codedError("required_option_missing", `${name} is required`, { name }); return normalized; } +function codedError(code: string, message: string, details: Record = {}) { return Object.assign(new Error(message), { code, details }); } + diff --git a/internal/hwpod/spec-registry.test.ts b/internal/hwpod/spec-registry.test.ts new file mode 100644 index 00000000..6cf949d1 --- /dev/null +++ b/internal/hwpod/spec-registry.test.ts @@ -0,0 +1,67 @@ +/* + * SPEC: PJ2026-010102 HWPOD 工具;PJ2026-010103 HWPOD 服务; + * PJ2026-01010305 71FREQ 预装 draft-2026-06-26-71freq-v03-hwpod-preinstall。 + * 责任: 验证 runtime spec CRUD、重启持久化与 YAML-first 内置冻结合同。 + */ + +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { createHwpodHttpApp } from "./http.ts"; +import { createHwpodSpecRegistry } from "./spec-registry.ts"; + +const temporaryDirs: string[] = []; +afterEach(async () => { await Promise.all(temporaryDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); + +test("runtime spec repository persists CRUD and freezes YAML-first built-ins", async () => { + const runtimeDir = await tempDir(); + const builtIn = spec("builtin-pod", "/builtin"); + const registry = createHwpodSpecRegistry({ runtimeDir, builtIns: [{ configRef: "config/hwpod.yaml#spec", sha256: "source-sha", document: builtIn }] }); + + const initial = await registry.list(); + expect(initial.specs).toHaveLength(1); + expect(initial.specs[0]).toMatchObject({ hwpodId: "builtin-pod", authority: "yaml-first-builtin", mutable: false, frozen: true }); + + const created = await registry.create(spec("runtime-pod", "/runtime-v1")); + expect(created).toMatchObject({ status: "created", hwpodId: "runtime-pod", authority: "runtime", mutable: true, frozen: false, mutation: true }); + + const restarted = createHwpodSpecRegistry({ runtimeDir, builtIns: [{ configRef: "config/hwpod.yaml#spec", document: builtIn }] }); + expect((await restarted.get("runtime-pod")).document.spec.workspace.path).toBe("/runtime-v1"); + expect((await restarted.update("runtime-pod", spec("runtime-pod", "/runtime-v2"))).status).toBe("updated"); + expect((await restarted.get("runtime-pod")).document.spec.workspace.path).toBe("/runtime-v2"); + expect((await restarted.delete("runtime-pod")).status).toBe("deleted"); + await expect(restarted.get("runtime-pod")).rejects.toMatchObject({ code: "hwpod_spec_not_found" }); + + await expect(restarted.create(spec("builtin-pod", "/shadow"))).rejects.toMatchObject({ code: "hwpod_spec_frozen", details: { mutation: false } }); + await expect(restarted.update("builtin-pod", builtIn)).rejects.toMatchObject({ code: "hwpod_spec_frozen" }); + await expect(restarted.delete("builtin-pod")).rejects.toMatchObject({ code: "hwpod_spec_frozen" }); + expect((await restarted.get("builtin-pod")).document.spec.workspace.path).toBe("/builtin"); +}); + +test("L1 HWPOD HTTP exposes runtime CRUD and typed frozen errors", async () => { + const runtimeDir = await tempDir(); + const registry = createHwpodSpecRegistry({ runtimeDir, 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 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" } }); + expect(await request(app, "GET", "/v1/hwpod/specs/runtime-http")).toMatchObject({ status: 200, body: { document: { spec: { workspace: { path: "/http-v1" } } } } }); + expect(await request(app, "PUT", "/v1/hwpod/specs/runtime-http", { document: spec("runtime-http", "/http-v2") })).toMatchObject({ status: 200, body: { status: "updated" } }); + expect(await request(app, "DELETE", "/v1/hwpod/specs/runtime-http")).toMatchObject({ status: 200, body: { status: "deleted", mutation: true } }); + + const frozen = await request(app, "DELETE", "/v1/hwpod/specs/builtin-pod"); + expect(frozen).toMatchObject({ status: 409, body: { ok: false, error: { code: "hwpod_spec_frozen", authority: "yaml-first-builtin", mutation: false } } }); + expect((await request(app, "GET", "/v1/hwpod/specs/builtin-pod")).body.document.spec.workspace.path).toBe("/builtin"); + 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() }; +} + +async function tempDir() { const dir = await mkdtemp(path.join(tmpdir(), "hwpod-spec-registry-")); temporaryDirs.push(dir); return dir; } +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/internal/hwpod/spec-registry.ts b/internal/hwpod/spec-registry.ts new file mode 100644 index 00000000..34633497 --- /dev/null +++ b/internal/hwpod/spec-registry.ts @@ -0,0 +1,152 @@ +/* + * SPEC: PJ2026-010102 HWPOD 工具;PJ2026-010103 HWPOD 服务; + * PJ2026-01010305 71FREQ 预装 draft-2026-06-26-71freq-v03-hwpod-preinstall。 + * 责任: 为 L0 native function 与 L1 API 提供同一 HWPOD runtime spec registry。 + */ + +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { normalizeHwpodSpec } from "./spec-document.ts"; + +const CONTRACT_VERSION = "hwpod-spec-registry-v2"; + +type BuiltInInput = { configRef: string; sha256?: string; document: unknown }; +type RuntimeRecord = { contractVersion: string; authority: "runtime"; hwpodId: string; createdAt: string; updatedAt: string; document: unknown }; + +export function hwpodSpecRegistryFromEnv(env: Record = process.env, options: { cwd?: string; runtimeDir?: string } = {}) { + const cwd = options.cwd ?? process.cwd(); + const runtimeDirValue = String(options.runtimeDir ?? env.HWPOD_RUNTIME_SPEC_REGISTRY_DIR ?? "").trim(); + if (!runtimeDirValue) throw codedError("hwpod_runtime_spec_registry_dir_required", "HWPOD_RUNTIME_SPEC_REGISTRY_DIR or --runtime-spec-dir is required"); + const runtimeDir = path.resolve(cwd, runtimeDirValue); + const builtIns = parseBuiltIns(env.HWPOD_BUILTIN_SPEC_DOCUMENTS_JSON); + return createHwpodSpecRegistry({ runtimeDir, builtIns }); +} + +export function createHwpodSpecRegistry(options: { runtimeDir: string; builtIns?: BuiltInInput[]; now?: () => string }) { + const runtimeDir = path.resolve(options.runtimeDir); + const now = options.now ?? (() => new Date().toISOString()); + const builtIns = normalizeBuiltIns(options.builtIns ?? []); + + async function list() { + const runtime = await readRuntimeRecords(runtimeDir); + assertNoAuthorityConflicts(builtIns, runtime); + const specs = [...builtIns.values(), ...runtime.values()] + .map(summary) + .sort((left, right) => left.hwpodId.localeCompare(right.hwpodId)); + return { ok: true, status: specs.length === 0 ? "empty" : "completed", contractVersion: CONTRACT_VERSION, count: specs.length, specs, valuesPrinted: false }; + } + + async function get(hwpodId: string) { + const id = validId(hwpodId); + const builtIn = builtIns.get(id); + if (builtIn) return entity(builtIn); + const runtime = (await readRuntimeRecords(runtimeDir)).get(id); + if (!runtime) throw codedError("hwpod_spec_not_found", `HWPOD spec was not found: ${id}`, { hwpodId: id }); + return entity(runtime); + } + + async function create(documentInput: unknown) { + const normalized = normalizedDocument(documentInput, "runtime:create"); + const id = specId(normalized); + assertNotFrozen(id, builtIns); + const runtime = await readRuntimeRecords(runtimeDir); + if (runtime.has(id)) throw codedError("hwpod_spec_exists", `HWPOD runtime spec already exists: ${id}`, { hwpodId: id }); + const observedAt = now(); + const record: RuntimeRecord = { contractVersion: CONTRACT_VERSION, authority: "runtime", hwpodId: id, createdAt: observedAt, updatedAt: observedAt, document: normalized }; + await writeRuntimeRecord(runtimeDir, record); + return mutation("created", record); + } + + async function update(hwpodId: string, documentInput: unknown) { + const id = validId(hwpodId); + assertNotFrozen(id, builtIns); + const runtime = await readRuntimeRecords(runtimeDir); + const existing = runtime.get(id); + if (!existing) throw codedError("hwpod_spec_not_found", `HWPOD runtime spec was not found: ${id}`, { hwpodId: id }); + const normalized = normalizedDocument(documentInput, `runtime:${id}`); + const documentId = specId(normalized); + if (documentId !== id) throw codedError("hwpod_spec_id_mismatch", `HWPOD spec document id ${documentId} does not match route id ${id}`, { hwpodId: id, documentHwpodId: documentId }); + const record: RuntimeRecord = { ...existing, updatedAt: now(), document: normalized }; + await writeRuntimeRecord(runtimeDir, record); + return mutation("updated", record); + } + + async function remove(hwpodId: string) { + const id = validId(hwpodId); + assertNotFrozen(id, builtIns); + const runtime = await readRuntimeRecords(runtimeDir); + const existing = runtime.get(id); + if (!existing) throw codedError("hwpod_spec_not_found", `HWPOD runtime spec was not found: ${id}`, { hwpodId: id }); + await rm(runtimeRecordPath(runtimeDir, id), { force: true }); + return { ok: true, status: "deleted", contractVersion: CONTRACT_VERSION, hwpodId: id, authority: "runtime", mutable: true, frozen: false, mutation: true, previousFingerprint: documentFingerprint(existing.document), valuesPrinted: false }; + } + + return { list, get, create, update, delete: remove, runtimeDir }; +} + +function parseBuiltIns(raw: string | undefined): BuiltInInput[] { + if (!String(raw ?? "").trim()) return []; + let value: unknown; + try { value = JSON.parse(String(raw)); } + catch (error: any) { throw codedError("invalid_hwpod_builtin_specs", `HWPOD_BUILTIN_SPEC_DOCUMENTS_JSON is invalid JSON: ${error?.message ?? String(error)}`); } + if (!Array.isArray(value)) throw codedError("invalid_hwpod_builtin_specs", "HWPOD_BUILTIN_SPEC_DOCUMENTS_JSON must be an array"); + return value.map((item: any, index) => { + if (!item || typeof item !== "object" || Array.isArray(item)) throw codedError("invalid_hwpod_builtin_specs", `built-in spec item ${index} must be an object`); + const configRef = String(item.configRef ?? "").trim(); + if (!configRef) throw codedError("invalid_hwpod_builtin_specs", `built-in spec item ${index} is missing configRef`); + return { configRef, sha256: String(item.sha256 ?? "").trim() || undefined, document: item.document }; + }); +} + +function normalizeBuiltIns(inputs: BuiltInInput[]) { + const records = new Map(); + for (const input of inputs) { + const document = normalizedDocument(input.document, input.configRef); + const hwpodId = specId(document); + if (records.has(hwpodId)) throw codedError("hwpod_spec_authority_conflict", `duplicate YAML-first built-in HWPOD spec: ${hwpodId}`, { hwpodId }); + records.set(hwpodId, { authority: "yaml-first-builtin", hwpodId, configRef: input.configRef, sourceFingerprint: input.sha256 ?? null, document }); + } + return records; +} + +async function readRuntimeRecords(runtimeDir: string) { + const records = new Map(); + let entries: string[] = []; + try { entries = await readdir(runtimeDir); } + catch (error: any) { if (error?.code !== "ENOENT") throw error; } + for (const name of entries.filter((entry) => entry.endsWith(".json")).sort()) { + const file = path.join(runtimeDir, name); + let parsed: any; + try { parsed = JSON.parse(await readFile(file, "utf8")); } + catch (error: any) { throw codedError("invalid_hwpod_runtime_spec", `invalid runtime HWPOD spec record ${file}: ${error?.message ?? String(error)}`, { file }); } + const document = normalizedDocument(parsed?.document, file); + const hwpodId = specId(document); + if (parsed?.authority !== "runtime" || parsed?.hwpodId !== hwpodId) throw codedError("invalid_hwpod_runtime_spec", `runtime HWPOD spec record identity is invalid: ${file}`, { file, hwpodId }); + if (records.has(hwpodId)) throw codedError("hwpod_spec_authority_conflict", `duplicate runtime HWPOD spec: ${hwpodId}`, { hwpodId }); + records.set(hwpodId, { contractVersion: CONTRACT_VERSION, authority: "runtime", hwpodId, createdAt: String(parsed.createdAt ?? ""), updatedAt: String(parsed.updatedAt ?? ""), document }); + } + return records; +} + +async function writeRuntimeRecord(runtimeDir: string, record: RuntimeRecord) { + await mkdir(runtimeDir, { recursive: true }); + const target = runtimeRecordPath(runtimeDir, record.hwpodId); + const temporary = `${target}.${randomUUID()}.tmp`; + await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, "utf8"); + await rename(temporary, target); +} + +function runtimeRecordPath(runtimeDir: string, hwpodId: string) { return path.join(runtimeDir, `${encodeURIComponent(validId(hwpodId))}.json`); } +function normalizedDocument(value: unknown, source: string) { return normalizeHwpodSpec(value, source); } +function specId(document: any) { return validId(String(document?.metadata?.name ?? document?.metadata?.uid ?? "")); } +function validId(value: string) { const id = String(value ?? "").trim(); if (!/^[A-Za-z0-9_.:-]{1,120}$/u.test(id)) throw codedError("invalid_hwpod_spec_id", `invalid HWPOD spec id: ${id || ""}`); return id; } +function assertNotFrozen(id: string, builtIns: Map) { const item = builtIns.get(id); if (item) throw codedError("hwpod_spec_frozen", `YAML-first built-in HWPOD spec is frozen: ${id}`, { hwpodId: id, authority: item.authority, configRef: item.configRef, mutable: false, frozen: true, documentFingerprint: documentFingerprint(item.document), mutation: false }); } +function assertNoAuthorityConflicts(builtIns: Map, runtime: Map) { for (const id of runtime.keys()) if (builtIns.has(id)) throw codedError("hwpod_spec_authority_conflict", `runtime HWPOD spec conflicts with YAML-first built-in spec: ${id}`, { hwpodId: id }); } +function summary(record: any) { const document = record.document; return { hwpodId: record.hwpodId, authority: record.authority, mutable: record.authority === "runtime", frozen: record.authority !== "runtime", documentFingerprint: documentFingerprint(document), ...(record.configRef ? { configRef: record.configRef, sourceFingerprint: record.sourceFingerprint } : { createdAt: record.createdAt, updatedAt: record.updatedAt }), nodeId: document.spec.nodeBinding.nodeId, workspacePath: document.spec.workspace.path }; } +function entity(record: any) { return { ok: true, status: "completed", contractVersion: CONTRACT_VERSION, ...summary(record), document: record.document, valuesPrinted: false }; } +function mutation(status: "created" | "updated", record: RuntimeRecord) { return { ...entity(record), status, mutation: true }; } +function documentFingerprint(document: unknown) { return `sha256:${createHash("sha256").update(stableJson(document)).digest("hex")}`; } +function stableJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; if (value && typeof value === "object") return `{${Object.keys(value as any).sort().map((key) => `${JSON.stringify(key)}:${stableJson((value as any)[key])}`).join(",")}}`; return JSON.stringify(value) ?? "null"; } +function codedError(code: string, message: string, details?: unknown) { return Object.assign(new Error(message), { code, details }); } diff --git a/tools/hwlab-cli/hwpod.test.ts b/tools/hwlab-cli/hwpod.test.ts index 6c0956b4..4cf4bc08 100644 --- a/tools/hwlab-cli/hwpod.test.ts +++ b/tools/hwlab-cli/hwpod.test.ts @@ -195,6 +195,39 @@ test("HWPOD operation status requires explicit over-api mode", async () => { assert.equal(result.payload.error.code, "hwpod_operation_status_over_api_required"); }); -function hwpodSpecText(workspacePath = "F:\\Work\\HWLAB-CASE-F103", buildCommand = "") { - return `kind: Hwpod\nmetadata:\n name: test-hwpod\nspec:\n nodeBinding:\n nodeId: test-node\n workspace:\n path: ${JSON.stringify(workspacePath)}\n${buildCommand ? ` buildCommand: ${JSON.stringify(buildCommand)}\n` : ""} targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n ioProbe:\n uart:\n port: COM9\n`; +test("HWPOD L0 CLI completes runtime spec CRUD and rejects built-in mutation", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwpod-spec-cli-test-")); + const runtimeDir = path.join(root, "runtime"); + const createPath = path.join(root, "create.yaml"); + const updatePath = path.join(root, "update.yaml"); + const frozenPath = path.join(root, "frozen.yaml"); + const env = { + HWPOD_BUILTIN_SPEC_DOCUMENTS_JSON: JSON.stringify([{ configRef: "config/hwpod.yaml#spec", document: hwpodSpecDocument("builtin-hwpod", "/builtin") }]) + }; + try { + await writeFile(createPath, hwpodSpecText("/runtime-v1", "", "runtime-hwpod"), "utf8"); + await writeFile(updatePath, hwpodSpecText("/runtime-v2", "", "runtime-hwpod"), "utf8"); + await writeFile(frozenPath, hwpodSpecText("/shadow", "", "builtin-hwpod"), "utf8"); + + assert.equal((await runHwpodCli(["spec", "create", "--file", createPath, "--local", "--runtime-spec-dir", runtimeDir], { env })).payload.body.status, "created"); + const listed = await runHwpodCli(["spec", "list", "--local", "--runtime-spec-dir", runtimeDir], { env }); + assert.equal(listed.exitCode, 0); + assert.deepEqual(listed.payload.body.specs.map((item: any) => [item.hwpodId, item.authority]), [["builtin-hwpod", "yaml-first-builtin"], ["runtime-hwpod", "runtime"]]); + assert.equal((await runHwpodCli(["spec", "get", "runtime-hwpod", "--local", "--runtime-spec-dir", runtimeDir], { env })).payload.body.document.spec.workspace.path, "/runtime-v1"); + assert.equal((await runHwpodCli(["spec", "update", "runtime-hwpod", "--file", updatePath, "--local", "--runtime-spec-dir", runtimeDir], { env })).payload.body.status, "updated"); + assert.equal((await runHwpodCli(["spec", "delete", "runtime-hwpod", "--local", "--runtime-spec-dir", runtimeDir], { env })).payload.body.status, "deleted"); + + const frozen = await runHwpodCli(["spec", "create", "--file", frozenPath, "--local", "--runtime-spec-dir", runtimeDir], { env }); + assert.equal(frozen.exitCode, 1); + assert.equal(frozen.payload.error.code, "hwpod_spec_frozen"); + assert.equal(frozen.payload.error.details.mutation, false); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +function hwpodSpecText(workspacePath = "F:\\Work\\HWLAB-CASE-F103", buildCommand = "", name = "test-hwpod") { + return `kind: Hwpod\nmetadata:\n name: ${name}\nspec:\n nodeBinding:\n nodeId: test-node\n workspace:\n path: ${JSON.stringify(workspacePath)}\n${buildCommand ? ` buildCommand: ${JSON.stringify(buildCommand)}\n` : ""} targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n ioProbe:\n uart:\n port: COM9\n`; } + +function hwpodSpecDocument(name: string, workspacePath: string) { return { kind: "Hwpod", metadata: { name }, spec: { nodeBinding: { nodeId: "test-node" }, workspace: { path: workspacePath }, targetDevice: { board: "test" }, debugProbe: { type: "test" }, ioProbe: { type: "test" } } }; } diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index 829c4ede..7f0a39ad 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -1,9 +1,16 @@ +/* + * SPEC: PJ2026-010102 HWPOD 工具; + * PJ2026-01010305 71FREQ 预装 draft-2026-06-26-71freq-v03-hwpod-preinstall。 + * 责任: 提供 HWPOD spec、编译、workspace 与硬件操作的统一 CLI。 + */ import { randomUUID } from "node:crypto"; import http from "node:http"; import https from "node:https"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; +import { normalizeHwpodSpec } from "../../internal/hwpod/spec-document.ts"; +import { hwpodSpecRegistryFromEnv } from "../../internal/hwpod/spec-registry.ts"; import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "./hwpod-node-ops-contract.ts"; import { executeHwpodNodeOpsPlan } from "./hwpod-node-lib.ts"; import { resolveRuntimeEndpoint, runtimeEndpointVisibility } from "./runtime-endpoint-resolver.ts"; @@ -111,6 +118,7 @@ export async function runHwpodCli(argv: string[], options: { env?: EnvLike; fetc modes: ["l0-native-function", "l1-native-api"] }); } + if (command === "spec") return await runHwpodSpecCrud({ parsed, env, fetchImpl: options.fetchImpl, now }); if (command === "operation") { if (parsed._[1] !== "status") throw cliError("unsupported_operation_command", `unsupported hwpod operation command: ${parsed._[1] || ""}`); if (parsed.overApi !== true) throw cliError("hwpod_operation_status_over_api_required", "HWPOD operation status requires --over-api", { next: "pass --over-api" }); @@ -221,6 +229,50 @@ export async function readHwpodSpec(specPath = DEFAULT_HWPOD_SPEC_PATH) { return normalizeHwpodSpec(parseSimpleYaml(text, specPath), specPath); } +async function runHwpodSpecCrud({ parsed, env, fetchImpl, now }: { parsed: ParsedArgs; env: EnvLike; fetchImpl?: FetchLike; now: () => string }) { + if (parsed.local !== true && parsed.overApi !== true) throw cliError("hwpod_spec_transport_required", "HWPOD spec CRUD requires --local or --over-api"); + const subcommand = text(parsed._[1]) || "list"; + if (!new Set(["list", "get", "create", "update", "delete"]).has(subcommand)) throw cliError("unsupported_spec_command", `unsupported hwpod spec command: ${subcommand}`); + const hwpodId = subcommand === "get" || subcommand === "update" || subcommand === "delete" ? requiredText(parsed.hwpodId ?? parsed._[2], "hwpodId") : ""; + const document = subcommand === "create" || subcommand === "update" ? await readSpecMutationDocument(parsed) : undefined; + if (parsed.local === true) { + const runtimeDir = requiredText(parsed.runtimeSpecDir ?? env.HWPOD_RUNTIME_SPEC_REGISTRY_DIR, "runtimeSpecDir"); + const registry = hwpodSpecRegistryFromEnv(env, { runtimeDir }); + const body = subcommand === "list" ? await registry.list() + : subcommand === "get" ? await registry.get(hwpodId) + : subcommand === "create" ? await registry.create(document) + : subcommand === "update" ? await registry.update(hwpodId, document) + : await registry.delete(hwpodId); + return result(0, ok("hwpod-cli.spec", { mode: "l0-native-function", transport: "native-function", serviceRuntime: false, subcommand, hwpodId: hwpodId || body.hwpodId || null, body }, body.status), now); + } + const response = await requestHwpodSpecOverApi({ parsed, env, fetchImpl, subcommand, hwpodId, document }); + const exitCode = response.body?.ok === false || response.status >= 400 ? 1 : 0; + const payload = ok("hwpod-cli.spec", { mode: "l1-native-api", transport: "over-api", serviceRuntime: true, subcommand, hwpodId: hwpodId || response.body?.hwpodId || null, route: response.route, runtimeEndpoint: response.runtimeEndpoint, body: response.body, httpStatus: response.status }, response.body?.status ?? (exitCode === 0 ? "completed" : "failed")); + if (exitCode !== 0) payload.ok = false; + return result(exitCode, payload, now); +} + +async function readSpecMutationDocument(parsed: ParsedArgs) { + const file = requiredText(parsed.file ?? parsed.spec ?? parsed.specPath, "file"); + return readHwpodSpec(file); +} + +async function requestHwpodSpecOverApi({ parsed, env, fetchImpl, subcommand, hwpodId, document }: { parsed: ParsedArgs; env: EnvLike; fetchImpl?: FetchLike; subcommand: string; hwpodId: string; document: any }) { + const endpoint = resolveRuntimeEndpoint({ kind: "api", parsed, env }); + const suffix = hwpodId ? `/${encodeURIComponent(hwpodId)}` : ""; + const method = subcommand === "create" ? "POST" : subcommand === "update" ? "PUT" : subcommand === "delete" ? "DELETE" : "GET"; + const route = { method, path: `/v1/hwpod/specs${suffix}` }; + const headers = authHeaders(parsed, env); + const bodyText = document === undefined ? undefined : JSON.stringify({ document }); + const response = fetchImpl + ? await fetchImpl(`${endpoint.baseUrl}${route.path}`, { method, headers, ...(bodyText === undefined ? {} : { body: bodyText }) }) + : bodyText === undefined + ? await requestJsonNative(`${endpoint.baseUrl}${route.path}`, { method, headers, timeoutMs: numberValue(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS }) + : await postJsonNative(`${endpoint.baseUrl}${route.path}`, { method, headers, body: bodyText, timeoutMs: numberValue(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS }); + const body = await response.json().catch(() => null); + return { status: response.status, body, route, runtimeEndpoint: runtimeEndpointVisibility(endpoint) }; +} + export function compileHwpodNodeOpsPlan({ document, specPath = DEFAULT_HWPOD_SPEC_PATH, specAuthority = "code-agent-workspace", intent, args = {}, operationId = process.env.HWLAB_HWPOD_OPERATION_IDENTITY, now = () => new Date().toISOString() }: any) { const normalizedIntent = normalizeIntent(intent); const nodeId = document.spec.nodeBinding.nodeId; @@ -287,6 +339,8 @@ function cliHelp() { "hwlab-cli hwpod build --local --spec .hwlab/hwpod-spec.yaml", "hwlab-cli hwpod workspace ls . --hwpod-id d601-f103-v2 --over-api", "hwlab-cli hwpod operation status --over-api", + "hwlab-cli hwpod spec list --local --runtime-spec-dir .state/hwpod-specs", + "hwlab-cli hwpod spec create --file hwpod-spec.yaml --over-api", "bun tools/hwpod-cli.ts workspace cat projects/01_baseline/User/main.c --hwpod-id d601-f103-v2 --workspace-path ", "bun tools/hwpod-cli.ts workspace read projects/01_baseline/User/main.c --hwpod-id d601-f103-v2 --workspace-path ", "bun tools/hwpod-cli.ts workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --hwpod-id d601-f103-v2 --workspace-path ", @@ -329,6 +383,7 @@ function cliHelp() { } function hwpodCliCommandHelp(command: string, parsed: ParsedArgs) { + if (command === "spec") return hwpodSpecHelp(text(parsed._[1])); if (command === "workspace") return hwpodWorkspaceHelp(text(parsed._[1])); if (command === "job") return hwpodJobHelp(text(parsed._[1])); if (command === "build") return hwpodBuildHelp(); @@ -338,6 +393,23 @@ function hwpodCliCommandHelp(command: string, parsed: ParsedArgs) { return cliHelp(); } +function hwpodSpecHelp(subcommand = "") { + return ok("hwpod-cli.spec.help", { + command: "spec", + subcommand: subcommand || null, + usage: [ + "hwpod spec list --local --runtime-spec-dir ", + "hwpod spec get --local --runtime-spec-dir ", + "hwpod spec create --file --local --runtime-spec-dir ", + "hwpod spec update --file --local --runtime-spec-dir ", + "hwpod spec delete --local --runtime-spec-dir ", + "hwpod spec list|get|create|update|delete ... --over-api --api-base-url " + ], + boundary: "Runtime CRUD only mutates runtime specs. YAML-first built-in specs are frozen and return hwpod_spec_frozen for create, update, or delete.", + transports: { local: "L0 native function", overApi: "L1 native API" } + }); +} + function hwpodWorkspaceHelp(subcommand = "") { return ok("hwpod-cli.workspace.help", { contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, @@ -1081,33 +1153,6 @@ function defaultSpecDocument(parsed: ParsedArgs, now: () => string) { }; } -function normalizeHwpodSpec(document: any, specPath: string) { - const root = objectValue(document); - if (root.kind !== "Hwpod") throw cliError("invalid_hwpod_spec_kind", "hwpod-spec kind must be Hwpod", { specPath, kind: root.kind }); - const spec = objectValue(root.spec); - const metadata = objectValue(root.metadata); - for (const key of ["targetDevice", "workspace", "debugProbe", "ioProbe"]) { - if (!isPlainObject(spec[key])) throw cliError("invalid_hwpod_spec_element", `hwpod-spec missing ${key}`, { specPath, element: key }); - } - const workspacePath = requiredText(spec.workspace.path, "spec.workspace.path"); - const nodeBinding = objectValue(spec.nodeBinding); - const nodeId = requiredText(nodeBinding.nodeId, "spec.nodeBinding.nodeId"); - const name = requiredText(metadata.name ?? metadata.uid, "metadata.name"); - return { - apiVersion: text(root.apiVersion) || "hwlab.pikastech.com/v1alpha1", - kind: "Hwpod", - metadata: { ...metadata, name }, - spec: { - ...spec, - nodeBinding: { ...nodeBinding, nodeId }, - workspace: { ...spec.workspace, path: workspacePath }, - targetDevice: spec.targetDevice, - debugProbe: spec.debugProbe, - ioProbe: spec.ioProbe - } - }; -} - async function submitHwpodNodeOpsPlan({ parsed, env, fetchImpl, plan }: { parsed: ParsedArgs; env: EnvLike; fetchImpl?: FetchLike; plan: any }) { const endpoint = resolveRuntimeEndpoint({ kind: "api", parsed, env }); const route = { method: "POST", path: "/v1/hwpod-node-ops" };