From b9c05f54c02b50797254e96d217842b88f9d1d81 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 03:02:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(hwpod):=20=E6=8C=81=E4=B9=85=E5=8C=96=20ru?= =?UTF-8?q?ntime=20spec=20=E5=88=B0=20PostgreSQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/hwpod/http.ts | 5 +- internal/hwpod/spec-registry.test.ts | 36 +++--- internal/hwpod/spec-registry.ts | 179 +++++++++++++++++---------- tools/hwlab-cli/hwpod.test.ts | 32 +++-- tools/src/hwpod-harness-lib.ts | 39 +++--- 5 files changed, 187 insertions(+), 104 deletions(-) diff --git a/internal/hwpod/http.ts b/internal/hwpod/http.ts index 0e3c3ef9..5cf96705 100644 --- a/internal/hwpod/http.ts +++ b/internal/hwpod/http.ts @@ -52,7 +52,10 @@ export function createHwpodHttpApp(options: { runtimeApiUrl: string; runtimeApiA 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() + close: async () => { + await options.specRegistry.close?.(); + await options.temporal.close(); + } }; } diff --git a/internal/hwpod/spec-registry.test.ts b/internal/hwpod/spec-registry.test.ts index a3cd754f..8794d5a2 100644 --- a/internal/hwpod/spec-registry.test.ts +++ b/internal/hwpod/spec-registry.test.ts @@ -4,21 +4,15 @@ * 责任: 验证 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 { expect, test } from "bun:test"; 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 }))); }); +import { createHwpodSpecRegistry, type HwpodRuntimeSpecRecord, type HwpodRuntimeSpecStore } from "./spec-registry.ts"; test("runtime spec repository persists CRUD and freezes YAML-first built-ins", async () => { - const runtimeDir = await tempDir(); + const store = memoryStore(); const builtIn = spec("builtin-pod", "/builtin"); - const registry = createHwpodSpecRegistry({ runtimeDir, builtIns: [{ configRef: "config/hwpod.yaml#spec", sha256: "source-sha", document: builtIn }] }); + const registry = createHwpodSpecRegistry({ store, builtIns: [{ configRef: "config/hwpod.yaml#spec", sha256: "source-sha", document: builtIn }] }); const initial = await registry.list(); expect(initial.specs).toHaveLength(1); @@ -27,7 +21,7 @@ test("runtime spec repository persists CRUD and freezes YAML-first built-ins", a 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 }] }); + const restarted = createHwpodSpecRegistry({ store, 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"); @@ -41,8 +35,7 @@ test("runtime spec repository persists CRUD and freezes YAML-first built-ins", a }); test("L1 HWPOD HTTP exposes runtime CRUD and typed frozen errors", async () => { - const runtimeDir = await tempDir(); - const registry = createHwpodSpecRegistry({ runtimeDir, builtIns: [{ configRef: "config/hwpod.yaml#spec", document: spec("builtin-pod", "/builtin") }] }); + const registry = createHwpodSpecRegistry({ store: memoryStore(), builtIns: [{ configRef: "config/hwpod.yaml#spec", document: spec("builtin-pod", "/builtin") }] }); const temporal = { close: async () => {}, start: async () => ({}), describe: async () => ({}), result: async () => ({}) }; const app = createHwpodHttpApp({ runtimeApiUrl: "http://runtime.invalid", runtimeApiAuthorization: "Bearer test", temporal, specRegistry: registry }); @@ -65,5 +58,20 @@ async function request(app: any, method: string, pathname: string, body?: unknow 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 memoryStore(): HwpodRuntimeSpecStore { + const records = new Map(); + return { + list: async () => [...records.values()], + get: async (id) => records.get(id) ?? null, + create: async (record) => records.has(record.hwpodId) ? false : (records.set(record.hwpodId, structuredClone(record)), true), + update: async (id, document, updatedAt) => { + const existing = records.get(id); + if (!existing) return null; + const record = { ...existing, document: structuredClone(document), updatedAt }; + records.set(id, record); + return record; + }, + delete: async (id) => { const record = records.get(id) ?? null; records.delete(id); return record; }, + }; +} 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 index 34633497..08af92bb 100644 --- a/internal/hwpod/spec-registry.ts +++ b/internal/hwpod/spec-registry.ts @@ -1,40 +1,46 @@ /* * 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。 + * 责任: 为 L0-L3 提供唯一 host PostgreSQL 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 { createHash } from "node:crypto"; +import pg from "pg"; +import { buildPostgresPoolConfig, postgresSslModeFromConnectionString } from "../db/runtime-store-core.ts"; import { normalizeHwpodSpec } from "./spec-document.ts"; -const CONTRACT_VERSION = "hwpod-spec-registry-v2"; +const { Pool } = pg; +const CONTRACT_VERSION = "hwpod-spec-registry-v3"; 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 type HwpodRuntimeSpecRecord = { authority: "runtime"; hwpodId: string; createdAt: string; updatedAt: string; document: unknown }; +export interface HwpodRuntimeSpecStore { + list(): Promise; + get(hwpodId: string): Promise; + create(record: HwpodRuntimeSpecRecord): Promise; + update(hwpodId: string, document: unknown, updatedAt: string): Promise; + delete(hwpodId: string): Promise; + close?(): Promise; } -export function createHwpodSpecRegistry(options: { runtimeDir: string; builtIns?: BuiltInInput[]; now?: () => string }) { - const runtimeDir = path.resolve(options.runtimeDir); +export function hwpodSpecRegistryFromEnv(env: Record = process.env) { + const databaseUrl = requiredEnv(env, "HWPOD_SPEC_DATABASE_URL"); + const schema = postgresIdentifier(requiredEnv(env, "HWPOD_SPEC_DATABASE_SCHEMA"), "HWPOD_SPEC_DATABASE_SCHEMA"); + const table = postgresIdentifier(requiredEnv(env, "HWPOD_SPEC_DATABASE_TABLE"), "HWPOD_SPEC_DATABASE_TABLE"); + const builtIns = parseBuiltIns(env.HWPOD_BUILTIN_SPEC_DOCUMENTS_JSON); + return createHwpodSpecRegistry({ store: createPostgresHwpodRuntimeSpecStore({ databaseUrl, schema, table }), builtIns }); +} + +export function createHwpodSpecRegistry(options: { store: HwpodRuntimeSpecStore; builtIns?: BuiltInInput[]; now?: () => string }) { + const store = options.store; const now = options.now ?? (() => new Date().toISOString()); const builtIns = normalizeBuiltIns(options.builtIns ?? []); async function list() { - const runtime = await readRuntimeRecords(runtimeDir); + const runtime = new Map((await store.list()).map((record) => [record.hwpodId, record])); assertNoAuthorityConflicts(builtIns, runtime); - const specs = [...builtIns.values(), ...runtime.values()] - .map(summary) - .sort((left, right) => left.hwpodId.localeCompare(right.hwpodId)); + 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 }; } @@ -42,7 +48,7 @@ export function createHwpodSpecRegistry(options: { runtimeDir: string; builtIns? const id = validId(hwpodId); const builtIn = builtIns.get(id); if (builtIn) return entity(builtIn); - const runtime = (await readRuntimeRecords(runtimeDir)).get(id); + const runtime = await store.get(id); if (!runtime) throw codedError("hwpod_spec_not_found", `HWPOD spec was not found: ${id}`, { hwpodId: id }); return entity(runtime); } @@ -51,39 +57,108 @@ export function createHwpodSpecRegistry(options: { runtimeDir: string; builtIns? 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); + const record: HwpodRuntimeSpecRecord = { authority: "runtime", hwpodId: id, createdAt: observedAt, updatedAt: observedAt, document: normalized }; + if (!await store.create(record)) throw codedError("hwpod_spec_exists", `HWPOD runtime spec already exists: ${id}`, { hwpodId: id }); 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); + const record = await store.update(id, normalized, now()); + if (!record) throw codedError("hwpod_spec_not_found", `HWPOD runtime spec was not found: ${id}`, { hwpodId: id }); 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); + const existing = await store.delete(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 }; + return { list, get, create, update, delete: remove, close: async () => store.close?.() }; +} + +export function createPostgresHwpodRuntimeSpecStore(options: { databaseUrl: string; schema: string; table: string }): HwpodRuntimeSpecStore { + const schema = postgresIdentifier(options.schema, "schema"); + const table = postgresIdentifier(options.table, "table"); + const qualifiedTable = `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`; + const pool = new Pool(buildPostgresPoolConfig({ + dbUrl: options.databaseUrl, + sslMode: postgresSslModeFromConnectionString(options.databaseUrl) ?? "disable", + })); + let ready: Promise | undefined; + + function ensureSchema() { + ready ??= migrate(); + return ready; + } + + async function migrate() { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schema)}`); + await client.query(`CREATE TABLE IF NOT EXISTS ${qualifiedTable} ( + hwpod_id TEXT PRIMARY KEY, + authority TEXT NOT NULL DEFAULT 'runtime' CHECK (authority = 'runtime'), + document JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`); + await client.query(`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${table}_updated_at_idx`)} ON ${qualifiedTable} (updated_at DESC, hwpod_id)`); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + ready = undefined; + throw error; + } finally { + client.release(); + } + } + + return { + async list() { + await ensureSchema(); + const result = await pool.query(`SELECT hwpod_id, authority, document, created_at, updated_at FROM ${qualifiedTable} ORDER BY hwpod_id`); + return result.rows.map(runtimeRow); + }, + async get(hwpodId) { + await ensureSchema(); + const result = await pool.query(`SELECT hwpod_id, authority, document, created_at, updated_at FROM ${qualifiedTable} WHERE hwpod_id=$1`, [hwpodId]); + return result.rows[0] ? runtimeRow(result.rows[0]) : null; + }, + async create(record) { + await ensureSchema(); + const result = await pool.query(`INSERT INTO ${qualifiedTable} (hwpod_id, authority, document, created_at, updated_at) VALUES ($1,'runtime',$2::jsonb,$3,$4) ON CONFLICT (hwpod_id) DO NOTHING RETURNING hwpod_id`, [record.hwpodId, JSON.stringify(record.document), record.createdAt, record.updatedAt]); + return result.rowCount === 1; + }, + async update(hwpodId, document, updatedAt) { + await ensureSchema(); + const result = await pool.query(`UPDATE ${qualifiedTable} SET document=$2::jsonb, updated_at=$3 WHERE hwpod_id=$1 AND authority='runtime' RETURNING hwpod_id, authority, document, created_at, updated_at`, [hwpodId, JSON.stringify(document), updatedAt]); + return result.rows[0] ? runtimeRow(result.rows[0]) : null; + }, + async delete(hwpodId) { + await ensureSchema(); + const result = await pool.query(`DELETE FROM ${qualifiedTable} WHERE hwpod_id=$1 AND authority='runtime' RETURNING hwpod_id, authority, document, created_at, updated_at`, [hwpodId]); + return result.rows[0] ? runtimeRow(result.rows[0]) : null; + }, + close: async () => pool.end(), + }; +} + +function runtimeRow(row: any): HwpodRuntimeSpecRecord { + if (row?.authority !== "runtime") throw codedError("invalid_hwpod_runtime_spec", `runtime HWPOD spec authority is invalid: ${String(row?.hwpod_id ?? "")}`); + const document = normalizedDocument(row.document, `postgres:${String(row.hwpod_id ?? "")}`); + const hwpodId = specId(document); + if (hwpodId !== row.hwpod_id) throw codedError("invalid_hwpod_runtime_spec", `runtime HWPOD spec identity is invalid: ${String(row.hwpod_id ?? "")}`); + return { authority: "runtime", hwpodId, createdAt: isoTimestamp(row.created_at), updatedAt: isoTimestamp(row.updated_at), document }; } function parseBuiltIns(raw: string | undefined): BuiltInInput[] { @@ -111,42 +186,18 @@ function normalizeBuiltIns(inputs: BuiltInInput[]) { 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 requiredEnv(env: Record, key: string) { const value = String(env[key] ?? "").trim(); if (!value) throw codedError("hwpod_spec_database_config_required", `${key} is required`); return value; } +function postgresIdentifier(value: string, name: string) { if (!/^[a-z_][a-z0-9_]*$/u.test(value)) throw codedError("invalid_hwpod_spec_database_identifier", `${name} must be a lowercase PostgreSQL identifier`); return value; } +function quoteIdentifier(value: string) { return `"${postgresIdentifier(value, "identifier")}"`; } +function isoTimestamp(value: unknown) { const date = value instanceof Date ? value : new Date(String(value ?? "")); if (!Number.isFinite(date.getTime())) throw codedError("invalid_hwpod_runtime_spec", "runtime HWPOD spec timestamp is invalid"); return date.toISOString(); } 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 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 mutation(status: "created" | "updated", record: HwpodRuntimeSpecRecord) { 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 4cf4bc08..d3353a21 100644 --- a/tools/hwlab-cli/hwpod.test.ts +++ b/tools/hwlab-cli/hwpod.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { test } from "bun:test"; import { runHwpodCli } from "../src/hwpod-harness-lib.ts"; +import { createHwpodSpecRegistry, type HwpodRuntimeSpecRecord, type HwpodRuntimeSpecStore } from "../../internal/hwpod/spec-registry.ts"; test("hwpod-cli help exposes workspace cat and apply-patch stdin usage", async () => { const result = await runHwpodCli(["help"], { now: () => "2026-06-06T00:00:00.000Z" }); @@ -197,27 +198,27 @@ test("HWPOD operation status requires explicit over-api mode", async () => { 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") }]) }; + const specRegistry = createHwpodSpecRegistry({ store: memoryStore(), builtIns: JSON.parse(env.HWPOD_BUILTIN_SPEC_DOCUMENTS_JSON) }); 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((await runHwpodCli(["spec", "create", "--file", createPath, "--local"], { env, specRegistry })).payload.body.status, "created"); + const listed = await runHwpodCli(["spec", "list", "--local"], { env, specRegistry }); 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"); + assert.equal((await runHwpodCli(["spec", "get", "runtime-hwpod", "--local"], { env, specRegistry })).payload.body.document.spec.workspace.path, "/runtime-v1"); + assert.equal((await runHwpodCli(["spec", "update", "runtime-hwpod", "--file", updatePath, "--local"], { env, specRegistry })).payload.body.status, "updated"); + assert.equal((await runHwpodCli(["spec", "delete", "runtime-hwpod", "--local"], { env, specRegistry })).payload.body.status, "deleted"); - const frozen = await runHwpodCli(["spec", "create", "--file", frozenPath, "--local", "--runtime-spec-dir", runtimeDir], { env }); + const frozen = await runHwpodCli(["spec", "create", "--file", frozenPath, "--local"], { env, specRegistry }); assert.equal(frozen.exitCode, 1); assert.equal(frozen.payload.error.code, "hwpod_spec_frozen"); assert.equal(frozen.payload.error.details.mutation, false); @@ -231,3 +232,20 @@ function hwpodSpecText(workspacePath = "F:\\Work\\HWLAB-CASE-F103", buildCommand } 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" } } }; } + +function memoryStore(): HwpodRuntimeSpecStore { + const records = new Map(); + return { + list: async () => [...records.values()], + get: async (id) => records.get(id) ?? null, + create: async (record) => records.has(record.hwpodId) ? false : (records.set(record.hwpodId, structuredClone(record)), true), + update: async (id, document, updatedAt) => { + const existing = records.get(id); + if (!existing) return null; + const record = { ...existing, document: structuredClone(document), updatedAt }; + records.set(id, record); + return record; + }, + delete: async (id) => { const record = records.get(id) ?? null; records.delete(id); return record; }, + }; +} diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index 7f0a39ad..319e1d3b 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -104,7 +104,7 @@ export async function runHwpodCtl(argv: string[], options: { env?: EnvLike; fetc } } -export async function runHwpodCli(argv: string[], options: { env?: EnvLike; fetchImpl?: FetchLike; stdinText?: string; now?: () => string } = {}) { +export async function runHwpodCli(argv: string[], options: { env?: EnvLike; fetchImpl?: FetchLike; stdinText?: string; now?: () => string; specRegistry?: any } = {}) { const env = options.env ?? process.env; const now = options.now ?? (() => new Date().toISOString()); try { @@ -118,7 +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 === "spec") return await runHwpodSpecCrud({ parsed, env, fetchImpl: options.fetchImpl, now, specRegistry: options.specRegistry }); 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" }); @@ -229,21 +229,24 @@ 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 }) { +async function runHwpodSpecCrud({ parsed, env, fetchImpl, now, specRegistry }: { parsed: ParsedArgs; env: EnvLike; fetchImpl?: FetchLike; now: () => string; specRegistry?: any }) { 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 registry = specRegistry ?? hwpodSpecRegistryFromEnv(env); + try { + 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, repository: "host-postgresql", subcommand, hwpodId: hwpodId || body.hwpodId || null, body }, body.status), now); + } finally { + if (specRegistry === undefined) await registry.close?.(); + } } const response = await requestHwpodSpecOverApi({ parsed, env, fetchImpl, subcommand, hwpodId, document }); const exitCode = response.body?.ok === false || response.status >= 400 ? 1 : 0; @@ -339,7 +342,7 @@ 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 list --local", "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 ", @@ -398,14 +401,14 @@ function hwpodSpecHelp(subcommand = "") { 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 --local", + "hwpod spec get --local", + "hwpod spec create --file --local", + "hwpod spec update --file --local", + "hwpod spec delete --local", "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.", + boundary: "Runtime CRUD only mutates host PostgreSQL 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" } }); }