feat: add HWPOD native workspace operations

This commit is contained in:
root
2026-07-21 10:08:55 +02:00
parent 692e4fcc80
commit b33430eadf
18 changed files with 602 additions and 55 deletions
+24
View File
@@ -21,6 +21,18 @@ export type HwpodOperationResult = {
error?: { code: string; message: string } | null;
};
export const HWPOD_WEB_OPERATION_INTENTS = new Set([
"workspace.ls",
"workspace.cat",
"debug.build",
"debug.download",
"debug.reset",
"io.uart.open",
"io.uart.close",
"io.uart.read",
"io.uart.write",
]);
export function validateHwpodOperationInput(value: unknown): HwpodOperationInput {
if (!value || typeof value !== "object" || Array.isArray(value)) throw codedError("hwpod_operation_input_invalid", "HWPOD operation input must be an object");
const input = value as Record<string, unknown>;
@@ -33,5 +45,17 @@ export function validateHwpodOperationInput(value: unknown): HwpodOperationInput
return { operationId, runtimeApiUrl: runtimeApiUrl.replace(/\/+$/u, ""), plan: plan as Record<string, unknown>, authorization: text(input.authorization) || undefined };
}
export function validateHwpodWebOperationRequest(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw codedError("hwpod_operation_request_invalid", "HWPOD operation request must be an object");
const input = value as Record<string, unknown>;
const hwpodId = text(input.hwpodId);
const intent = text(input.intent);
if (!hwpodId) throw codedError("hwpod_id_required", "hwpodId is required");
if (!HWPOD_WEB_OPERATION_INTENTS.has(intent)) throw codedError("hwpod_operation_intent_unsupported", `unsupported HWPOD Web operation intent: ${intent}`);
const args = input.args === undefined ? {} : input.args;
if (!args || typeof args !== "object" || Array.isArray(args)) throw codedError("hwpod_operation_args_invalid", "HWPOD operation args must be an object");
return { hwpodId, intent, args: args as Record<string, unknown> };
}
function text(value: unknown) { return typeof value === "string" ? value.trim() : ""; }
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
+44 -16
View File
@@ -5,17 +5,20 @@
* 责任: 独立 HWPOD API HTTP 合同。
*/
import { validateHwpodOperationInput } from "./contracts.ts";
import { randomUUID } from "node:crypto";
import { compileHwpodNodeOpsPlan } from "../../tools/src/hwpod-harness-lib.ts";
import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts";
import { validateHwpodOperationInput, validateHwpodWebOperationRequest } from "./contracts.ts";
import { buildHwpodTopologyReadModel } from "../cloud/hwpod-topology-read-model.ts";
export function createHwpodHttpApp(options: { runtimeApiUrl: string; runtimeApiAuthorization: string; temporal: any; specRegistry: any }) {
export function createHwpodHttpApp(options: { nodeOpsApiUrl: string; temporal: any; specRegistry: any; nodeRegistry: any }) {
return {
async fetch(request: Request) {
const url = new URL(request.url);
try {
if (url.pathname === "/health/live") return json(200, { ok: true, serviceId: "hwlab-hwpod-api", status: "live", valuesPrinted: false });
if (url.pathname === "/health/ready") return readiness(options);
if (url.pathname === "/v1/hwpod" && request.method === "GET") return json(200, { ok: true, serviceId: "hwlab-hwpod-api", executionAuthority: "temporal", runtimeApiUrl: options.runtimeApiUrl, valuesPrinted: false });
if (url.pathname === "/v1/hwpod" && request.method === "GET") return json(200, { ok: true, serviceId: "hwlab-hwpod-api", executionAuthority: "temporal", nodeOpsApiUrl: options.nodeOpsApiUrl, valuesPrinted: false });
if (url.pathname === "/v1/hwpod/specs" && request.method === "GET") return json(200, await options.specRegistry.list());
if (url.pathname === "/v1/hwpod/specs" && request.method === "POST") {
const body = await request.json();
@@ -30,14 +33,28 @@ export function createHwpodHttpApp(options: { runtimeApiUrl: string; runtimeApiA
return json(405, { ok: false, error: { code: "method_not_allowed", message: "GET, PUT, or DELETE required" }, valuesPrinted: false });
}
if (url.pathname === "/v1/hwpod/topology" && request.method === "GET") {
return json(200, await buildHwpodTopologyFromRegistry(options.specRegistry, Object.fromEntries(url.searchParams)));
return json(200, await buildHwpodTopologyFromRegistry(options.specRegistry, Object.fromEntries(url.searchParams), options.nodeRegistry.describe()));
}
if (url.pathname === "/v1/hwpod-node-ops" && request.method === "GET") {
const planId = url.searchParams.get("planId")?.trim() ?? "";
if (planId) {
const operation = await options.nodeRegistry.lookup(planId);
return json(operation ? 200 : 404, operation
? { ok: true, status: operation.result ? "completed" : "running", contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, ...operation, valuesPrinted: false }
: { ok: false, status: "not-found", contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, planId, error: { code: "hwpod_operation_not_found", message: `hwpod operation ${planId} was not found` }, valuesPrinted: false });
}
return json(200, { ok: true, status: "ready", contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, supportedOps: [...HWPOD_NODE_OPS], websocket: options.nodeRegistry.describe(), valuesPrinted: false });
}
if (url.pathname === "/v1/hwpod-node-ops" && request.method === "POST") {
const plan = validateNodeOpsPlan(await request.json());
const result = await options.nodeRegistry.dispatch(plan, { requestId: `req_hwpod_native_${randomUUID()}`, operationKind: "user-operation", source: "hwpod-native-api" }, { timeoutMs: 900_000 });
return json(result?.ok === false ? 409 : 200, { ...result, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, planId: plan.planId, hwpodId: plan.hwpodId, nodeId: plan.nodeId, valuesPrinted: false });
}
if (url.pathname === "/v1/hwpod-node-ops" && request.method === "GET") return proxyGet(options.runtimeApiUrl, `/v1/hwpod-node-ops${url.search}`, options.runtimeApiAuthorization);
if (url.pathname === "/v1/hwpod/operations" && request.method === "POST") {
const body = await request.json();
const input = validateHwpodOperationInput({ ...(body as any), runtimeApiUrl: options.runtimeApiUrl, authorization: options.runtimeApiAuthorization });
const input = await operationInput(body, options);
const started = await options.temporal.start(input);
return json(202, { ok: true, status: "accepted", operationId: input.operationId, ...started, authority: "hwpod-temporal", valuesPrinted: false });
return json(202, { ok: true, status: "accepted", operationId: input.operationId, planId: input.plan.planId, hwpodId: input.plan.hwpodId, ...started, authority: "hwpod-temporal", valuesPrinted: false });
}
const match = /^\/v1\/hwpod\/operations\/([^/]+)$/u.exec(url.pathname);
if (match && request.method === "GET") {
@@ -62,7 +79,7 @@ export function createHwpodHttpApp(options: { runtimeApiUrl: string; runtimeApiA
};
}
export async function buildHwpodTopologyFromRegistry(specRegistry: any, query: Record<string, string> = {}) {
export async function buildHwpodTopologyFromRegistry(specRegistry: any, query: Record<string, string> = {}, nodeSnapshot: any = { mode: "hwpod-native-api-no-node-registry", nodes: [] }) {
const listed = await specRegistry.list();
const specs = await Promise.all((listed.specs ?? []).map(async (summary: any) => {
const entity = await specRegistry.get(summary.hwpodId);
@@ -73,20 +90,31 @@ export async function buildHwpodTopologyFromRegistry(specRegistry: any, query: R
nodeId: entity.nodeId,
};
}));
return buildHwpodTopologyReadModel(specs, {
mode: "hwpod-native-api-no-node-registry",
nodes: [],
}, query);
return buildHwpodTopologyReadModel(specs, nodeSnapshot, query);
}
async function readiness(options: { runtimeApiUrl: string; runtimeApiAuthorization: string; temporal: any; specRegistry: any }) {
async function readiness(options: { nodeOpsApiUrl: string; temporal: any; specRegistry: any; nodeRegistry: any }) {
try {
const specs = await options.specRegistry.list();
return json(200, { ok: true, serviceId: "hwlab-hwpod-api", temporal: "configured", specRegistry: "ready", specCount: specs.count, runtimeApi: "configured", valuesPrinted: false });
const nodes = options.nodeRegistry.describe();
return json(200, { ok: true, serviceId: "hwlab-hwpod-api", temporal: "configured", specRegistry: "ready", specCount: specs.count, nodeRegistry: "ready", connectedNodeCount: nodes.connectedCount, valuesPrinted: false });
} catch (error: any) {
return json(503, { ok: false, serviceId: "hwlab-hwpod-api", temporal: "configured", specRegistry: "blocked", runtimeApi: "configured", error: { code: error?.code ?? "hwpod_spec_registry_unavailable", message: error?.message ?? String(error) }, valuesPrinted: false });
return json(503, { ok: false, serviceId: "hwlab-hwpod-api", temporal: "configured", specRegistry: "blocked", nodeRegistry: "configured", error: { code: error?.code ?? "hwpod_spec_registry_unavailable", message: error?.message ?? String(error) }, valuesPrinted: false });
}
}
async function proxyGet(base: string, route: string, authorization: string) { const response = await fetch(`${base}${route}`, { headers: { authorization }, signal: AbortSignal.timeout(10_000) }); return new Response(await response.text(), { status: response.status, headers: { "content-type": response.headers.get("content-type") ?? "application/json", "cache-control": "no-store" } }); }
async function operationInput(body: any, options: { nodeOpsApiUrl: string; specRegistry: any }) {
if (body?.plan) return validateHwpodOperationInput({ ...body, runtimeApiUrl: options.nodeOpsApiUrl, authorization: "" });
const request = validateHwpodWebOperationRequest(body);
const spec = await options.specRegistry.get(request.hwpodId);
const operationId = `hwpod_plan_${randomUUID()}`;
const plan = compileHwpodNodeOpsPlan({ document: spec.document, specPath: `hwpod://${request.hwpodId}`, specAuthority: spec.authority, intent: request.intent, args: request.args, operationId });
return validateHwpodOperationInput({ operationId, plan, runtimeApiUrl: options.nodeOpsApiUrl });
}
function validateNodeOpsPlan(value: any) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw Object.assign(new Error("hwpod-node-ops plan must be an object"), { code: "invalid_hwpod_node_ops_plan" });
if (value.contractVersion !== HWPOD_NODE_OPS_CONTRACT_VERSION || !String(value.planId ?? "").trim() || !String(value.nodeId ?? "").trim() || !Array.isArray(value.ops) || value.ops.length === 0) throw Object.assign(new Error("hwpod-node-ops plan identity and ops are required"), { code: "invalid_hwpod_node_ops_plan" });
for (const op of value.ops) if (!HWPOD_NODE_OPS.has(String(op?.op ?? ""))) throw Object.assign(new Error(`unsupported hwpod-node op: ${String(op?.op ?? "")}`), { code: "unsupported_hwpod_node_op" });
return value;
}
function specDocument(body: any) { return body && typeof body === "object" && !Array.isArray(body) && body.document !== undefined ? body.document : body; }
function json(status: number, body: unknown) { return Response.json(body, { status, headers: { "cache-control": "no-store" } }); }
+4 -9
View File
@@ -2,7 +2,7 @@
* SPEC: PJ2026-010103 HWPOD 服务;
* PJ2026-01010305 71FREQ 预装 draft-2026-06-26-71freq-v03-hwpod-preinstall。
* 实现引用: draft-2026-07-20-hwpod-temporal-split。
* 责任: HWPOD API 的 Temporal 与迁移期 Cloud API 配置解析。
* 责任: HWPOD API 的 Temporal、node-ops 与 spec registry 配置解析。
*/
import { createHwpodTemporalGateway } from "./temporal.ts";
@@ -12,18 +12,13 @@ export function hwpodRuntime(env: Record<string, string | undefined> = process.e
const address = String(env.HWPOD_TEMPORAL_ADDRESS ?? env.TEMPORAL_ADDRESS ?? "").trim();
const namespace = String(env.HWPOD_TEMPORAL_NAMESPACE ?? "unidesk").trim();
const taskQueue = String(env.HWPOD_TEMPORAL_TASK_QUEUE ?? "hwlab-v03-hwpod").trim();
const runtimeApiUrl = String(env.HWPOD_RUNTIME_API_URL ?? "").trim().replace(/\/+$/u, "");
const runtimeApiAuthorizationValue = String(env.HWPOD_RUNTIME_API_AUTHORIZATION ?? "").trim();
const runtimeApiAuthorizationPrefix = String(env.HWPOD_RUNTIME_API_AUTHORIZATION_PREFIX ?? "").trim();
const runtimeApiAuthorization = runtimeApiAuthorizationPrefix ? `${runtimeApiAuthorizationPrefix} ${runtimeApiAuthorizationValue}` : runtimeApiAuthorizationValue;
if (!runtimeApiUrl) throw codedError("hwpod_runtime_api_url_required", "HWPOD_RUNTIME_API_URL is required");
if (!runtimeApiAuthorization) throw codedError("hwpod_runtime_api_authorization_required", "HWPOD_RUNTIME_API_AUTHORIZATION is required");
const nodeOpsApiUrl = String(env.HWPOD_NODE_OPS_API_URL ?? "").trim().replace(/\/+$/u, "");
if (!nodeOpsApiUrl) throw codedError("hwpod_node_ops_api_url_required", "HWPOD_NODE_OPS_API_URL is required");
return {
address,
namespace,
taskQueue,
runtimeApiUrl,
runtimeApiAuthorization,
nodeOpsApiUrl,
temporal: createHwpodTemporalGateway({ address, namespace, taskQueue }),
specRegistry: hwpodSpecRegistryFromEnv(env),
};
+26 -3
View File
@@ -37,9 +37,9 @@ test("runtime spec repository persists CRUD and freezes YAML-first built-ins", a
test("L1 HWPOD HTTP exposes runtime CRUD and typed frozen errors", async () => {
const registry = createHwpodSpecRegistry({ store: memoryStore(), builtIns: [{ configRef: "config/hwpod.yaml#spec", document: spec("builtin-pod", "/builtin") }] });
const temporal = { close: async () => {}, start: async () => ({}), describe: async () => ({}), result: async () => ({}) };
const app = createHwpodHttpApp({ runtimeApiUrl: "http://runtime.invalid", runtimeApiAuthorization: "Bearer test", temporal, specRegistry: registry });
const app = createHwpodHttpApp({ nodeOpsApiUrl: "http://native-hwpod.test", temporal, specRegistry: registry, nodeRegistry: nodeRegistry() });
expect(await request(app, "GET", "/health/ready")).toMatchObject({ status: 200, body: { ok: true, specRegistry: "ready", specCount: 1, runtimeApi: "configured" } });
expect(await request(app, "GET", "/health/ready")).toMatchObject({ status: 200, body: { ok: true, specRegistry: "ready", specCount: 1, nodeRegistry: "ready" } });
const created = await request(app, "POST", "/v1/hwpod/specs", { document: spec("runtime-http", "/http-v1") });
expect(created).toMatchObject({ status: 201, body: { ok: true, status: "created", hwpodId: "runtime-http" } });
@@ -72,12 +72,28 @@ test("L0 HWPOD topology is built from the local registry without Cloud API", asy
expect(topology.items[0].blockers).toContainEqual(expect.objectContaining({ code: "hwpod_node_offline" }));
const temporal = { close: async () => {}, start: async () => ({}), describe: async () => ({}), result: async () => ({}) };
const app = createHwpodHttpApp({ runtimeApiUrl: "http://runtime.invalid", runtimeApiAuthorization: "Bearer test", temporal, specRegistry: registry });
const app = createHwpodHttpApp({ nodeOpsApiUrl: "http://native-hwpod.test", temporal, specRegistry: registry, nodeRegistry: nodeRegistry() });
const response = await request(app, "GET", "/v1/hwpod/topology?resource=node");
expect(response).toMatchObject({ status: 200, body: { ok: true, resource: "node", items: [{ nodeId: "node-test", status: "offline" }] } });
await app.close();
});
test("L1 Web operation compiles a registry spec and submits native node-ops through Temporal", async () => {
const registry = createHwpodSpecRegistry({ store: memoryStore(), builtIns: [{ configRef: "config/hwpod.yaml#spec", document: spec("web-pod", "/workspace") }] });
let submitted: any = null;
const temporal = {
close: async () => {},
start: async (input: unknown) => { submitted = input; return { workflowId: "hwpod-operation-test", workflowRunId: "run-test" }; },
describe: async () => ({}),
result: async () => ({}),
};
const app = createHwpodHttpApp({ nodeOpsApiUrl: "http://127.0.0.1:6681", temporal, specRegistry: registry, nodeRegistry: nodeRegistry() });
const response = await request(app, "POST", "/v1/hwpod/operations", { hwpodId: "web-pod", intent: "workspace.ls", args: { path: "." } });
expect(response).toMatchObject({ status: 202, body: { ok: true, status: "accepted", hwpodId: "web-pod", authority: "hwpod-temporal" } });
expect(submitted).toMatchObject({ runtimeApiUrl: "http://127.0.0.1:6681", plan: { hwpodId: "web-pod", nodeId: "node-test", intent: "workspace.ls", ops: [{ op: "workspace.ls", args: { path: ".", workspacePath: "/workspace" } }] } });
await app.close();
});
async function request(app: any, method: string, pathname: string, body?: unknown) {
const response = await app.fetch(new Request(`http://hwpod.test${pathname}`, { method, headers: { "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }));
return { status: response.status, body: await response.json() };
@@ -99,4 +115,11 @@ function memoryStore(): HwpodRuntimeSpecStore {
delete: async (id) => { const record = records.get(id) ?? null; records.delete(id); return record; },
};
}
function nodeRegistry() {
return {
describe: () => ({ mode: "hwpod-native-api-websocket", connectedCount: 0, nodes: [] }),
lookup: async () => null,
dispatch: async () => ({ ok: true, status: "completed", results: [] }),
};
}
function spec(name: string, workspacePath: string) { return { apiVersion: "hwlab.dev/v0alpha1", kind: "Hwpod", metadata: { name }, spec: { targetDevice: { board: "test" }, workspace: { path: workspacePath }, debugProbe: { type: "test" }, ioProbe: { type: "test" }, nodeBinding: { nodeId: "node-test" } } }; }