feat: add HWPOD native workspace operations
This commit is contained in:
@@ -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
@@ -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" } }); }
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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" } } }; }
|
||||
|
||||
Reference in New Issue
Block a user