merge: add HWPOD native workspace operations
This commit is contained in:
@@ -6,12 +6,32 @@
|
||||
*/
|
||||
import { createHwpodHttpApp } from "../../internal/hwpod/http.ts";
|
||||
import { hwpodRuntime } from "../../internal/hwpod/runtime.ts";
|
||||
import { createHwpodNodeWsRegistry } from "../../internal/cloud/hwpod-node-ws-registry.ts";
|
||||
|
||||
const runtime = hwpodRuntime();
|
||||
const app = createHwpodHttpApp({ runtimeApiUrl: runtime.runtimeApiUrl, runtimeApiAuthorization: runtime.runtimeApiAuthorization, temporal: runtime.temporal, specRegistry: runtime.specRegistry });
|
||||
const nodeRegistry = createHwpodNodeWsRegistry({ env: process.env });
|
||||
const app = createHwpodHttpApp({ nodeOpsApiUrl: runtime.nodeOpsApiUrl, temporal: runtime.temporal, specRegistry: runtime.specRegistry, nodeRegistry });
|
||||
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) });
|
||||
const expectedNodeToken = required(process.env.HWPOD_NODE_WS_TOKEN, "HWPOD_NODE_WS_TOKEN");
|
||||
const server = Bun.serve({
|
||||
hostname: host,
|
||||
port,
|
||||
idleTimeout: 120,
|
||||
fetch(request, bunServer) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname !== "/v1/hwpod-node/ws") return app.fetch(request);
|
||||
const token = String(request.headers.get("x-hwpod-node-token") ?? url.searchParams.get("token") ?? "");
|
||||
if (token !== expectedNodeToken) return Response.json({ ok: false, error: { code: "hwpod_node_ws_token_invalid" }, valuesPrinted: false }, { status: 401 });
|
||||
return bunServer.upgrade(request, { data: { nodeRegistry } }) ? undefined : Response.json({ ok: false, status: "upgrade_required", valuesPrinted: false }, { status: 426 });
|
||||
},
|
||||
websocket: {
|
||||
open(socket: any) { socket.data.nodeRegistry.openBunSocket(socket); },
|
||||
message(socket: any, message: string | Buffer) { socket.data.nodeRegistry.handleBunMessage(socket, message); },
|
||||
close(socket: any) { socket.data.nodeRegistry.closeBunSocket(socket); },
|
||||
},
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify({ serviceId: "hwlab-hwpod-api", status: "listening", host, port: server.port, temporalNamespace: runtime.namespace, taskQueue: runtime.taskQueue, valuesPrinted: false })}\n`);
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, async () => { server.stop(true); await app.close(); process.exit(0); });
|
||||
function positivePort(value: string | undefined, fallback: number) { const parsed = Number.parseInt(String(value ?? fallback), 10); if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) throw new Error("HWPOD_API_PORT must be a valid port"); return parsed; }
|
||||
function required(value: string | undefined, key: string) { const text = String(value ?? "").trim(); if (!text) throw new Error(`${key} is required`); return text; }
|
||||
|
||||
@@ -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" } } }; }
|
||||
|
||||
+67
-4
@@ -53,7 +53,7 @@ except Exception: # pragma: no cover - import failure is reported in main.
|
||||
messagebox = None
|
||||
|
||||
|
||||
APP_VERSION = "0.1.2"
|
||||
APP_VERSION = "0.1.3"
|
||||
NODE_VERSION = f"{APP_VERSION}-python-gui"
|
||||
CONTRACT_VERSION = "hwpod-node-ops-v1"
|
||||
APP_NAME = "HWLAB Node"
|
||||
@@ -357,7 +357,10 @@ class NodeOpsExecutor:
|
||||
"debug.build",
|
||||
"debug.download",
|
||||
"debug.reset",
|
||||
"io.uart.open",
|
||||
"io.uart.close",
|
||||
"io.uart.read",
|
||||
"io.uart.write",
|
||||
]
|
||||
|
||||
def allowed_workspace_roots(self) -> list[Path]:
|
||||
@@ -427,11 +430,17 @@ class NodeOpsExecutor:
|
||||
return self._command_result(op_id, name, self._cmd_run(args, default_timeout_ms=30000))
|
||||
if name in ("debug.build", "debug.download", "debug.reset"):
|
||||
return self._command_result(op_id, name, self._debug_command(name, args))
|
||||
if name == "io.uart.open":
|
||||
return self._command_result(op_id, name, self._uart_control(args, "start"))
|
||||
if name == "io.uart.close":
|
||||
return self._command_result(op_id, name, self._uart_control(args, "stop"))
|
||||
if name == "io.uart.read":
|
||||
output = self._uart_read(args)
|
||||
if output.get("ok"):
|
||||
return self._ok(op_id, name, output)
|
||||
return self._blocked(op_id, name, output.get("blockerCode", "hwpod_uart_read_failed"), output.get("summary", "io.uart.read failed"), output.get("details", output))
|
||||
if name == "io.uart.write":
|
||||
return self._command_result(op_id, name, self._uart_write(args))
|
||||
return self._blocked(op_id, name, "unsupported_hwpod_node_op", f"unsupported hwpod-node op: {name}")
|
||||
except Exception as exc:
|
||||
return self._blocked(op_id, name, getattr(exc, "errno", None) or exc.__class__.__name__, str(exc), {"trace": traceback.format_exc(limit=4)})
|
||||
@@ -497,17 +506,48 @@ class NodeOpsExecutor:
|
||||
|
||||
def _workspace_ls(self, args: dict) -> dict:
|
||||
target = self._resolve_workspace_path(args, args.get("path") or ".")
|
||||
if not target.is_dir():
|
||||
raise NotADirectoryError(str(args.get("path") or "."))
|
||||
max_entries = max(1, min(safe_int(args.get("maxEntries"), 500), 2000))
|
||||
entries = []
|
||||
for child in target.iterdir():
|
||||
entries.append({"name": child.name, "type": "dir" if child.is_dir() else "file" if child.is_file() else "other"})
|
||||
return {"path": str(target), "entries": sorted(entries, key=lambda item: item["name"].lower())}
|
||||
stat = child.stat()
|
||||
entries.append({
|
||||
"name": child.name,
|
||||
"path": self._relative_workspace_path(args, child),
|
||||
"type": "dir" if child.is_dir() else "file" if child.is_file() else "other",
|
||||
"sizeBytes": stat.st_size if child.is_file() else None,
|
||||
"modifiedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(stat.st_mtime)),
|
||||
})
|
||||
entries.sort(key=lambda item: (item["type"] != "dir", item["name"].lower()))
|
||||
truncated = len(entries) > max_entries
|
||||
return {"path": self._relative_workspace_path(args, target), "entries": entries[:max_entries], "truncated": truncated, "totalEntries": len(entries)}
|
||||
|
||||
def _workspace_cat(self, args: dict) -> dict:
|
||||
target = self._resolve_workspace_path(args, args.get("path"))
|
||||
max_bytes = safe_int(args.get("maxBytes"), 65536)
|
||||
data = target.read_bytes()
|
||||
truncated = len(data) > max_bytes
|
||||
return {"path": str(target), "content": data[:max_bytes].decode("utf-8", errors="replace"), "truncated": truncated}
|
||||
sample = data[:max_bytes]
|
||||
binary = b"\0" in sample
|
||||
return {
|
||||
"path": self._relative_workspace_path(args, target),
|
||||
"sizeBytes": len(data),
|
||||
"contentType": "application/octet-stream" if binary else "text/plain; charset=utf-8",
|
||||
"previewable": not binary,
|
||||
"content": None if binary else sample.decode("utf-8", errors="replace"),
|
||||
"truncated": truncated,
|
||||
"blocker": {"code": "hwpod_workspace_binary_preview_unsupported", "summary": "二进制文件不支持文本预览"} if binary else None,
|
||||
}
|
||||
|
||||
def _relative_workspace_path(self, args: dict, target: Path) -> str:
|
||||
root = self._workspace_root(args)
|
||||
try:
|
||||
relative = target.relative_to(root)
|
||||
except ValueError:
|
||||
raise PermissionError("workspace path is outside the selected workspace root")
|
||||
text = relative.as_posix()
|
||||
return text if text else "."
|
||||
|
||||
def _workspace_rg(self, args: dict) -> dict:
|
||||
pattern = str(args.get("pattern") or "")
|
||||
@@ -760,6 +800,29 @@ class NodeOpsExecutor:
|
||||
return [str(item) for item in value]
|
||||
return ["bun", "scripts/serial-monitor-cli.ts"]
|
||||
|
||||
def _uart_control(self, args: dict, action: str) -> dict:
|
||||
serial_dir = Path(str(args.get("serialMonitorDir") or Path.home() / ".agents" / "skills" / "serial-monitor"))
|
||||
command_base = self._serial_command_base(args)
|
||||
timeout_ms = safe_int(args.get("timeoutMs"), 10000)
|
||||
port = str(args.get("port") or "")
|
||||
baud = args.get("baudRate") or args.get("baudrate")
|
||||
command = [*command_base, "monitor", action]
|
||||
if action == "start":
|
||||
command.extend([*(["-p", port] if port else []), *(["-b", str(baud)] if baud else [])])
|
||||
output = self._spawn_output(command, serial_dir, timeout_ms)
|
||||
body = self._parse_json(output.get("stdout"))
|
||||
return {**output, "ok": output.get("ok") and body.get("success") is True, "bindingSource": "serial-monitor-cli", "port": port or None, "baudRate": baud, "response": body or None}
|
||||
|
||||
def _uart_write(self, args: dict) -> dict:
|
||||
data = str(args.get("data") or "")
|
||||
if not data:
|
||||
return {"ok": False, "blockerCode": "hwpod_uart_data_required", "stderr": "UART data is required", "exitCode": None}
|
||||
serial_dir = Path(str(args.get("serialMonitorDir") or Path.home() / ".agents" / "skills" / "serial-monitor"))
|
||||
command = [*self._serial_command_base(args), "send", data]
|
||||
output = self._spawn_output(command, serial_dir, safe_int(args.get("timeoutMs"), 10000))
|
||||
body = self._parse_json(output.get("stdout"))
|
||||
return {**output, "ok": output.get("ok") and body.get("success") is True, "bindingSource": "serial-monitor-cli", "bytes": len(data.encode("utf-8")), "response": body or None}
|
||||
|
||||
def _uart_read(self, args: dict) -> dict:
|
||||
serial_dir = Path(str(args.get("serialMonitorDir") or Path.home() / ".agents" / "skills" / "serial-monitor"))
|
||||
command_base = self._serial_command_base(args)
|
||||
|
||||
@@ -55,10 +55,9 @@ test("hwpod cli resolves hwpod-id from runtime discovery and applies workspace o
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
status: "completed",
|
||||
specs: [{
|
||||
hwpodId: "d601-f103-v2",
|
||||
authority: "preinstalled-verified-spec",
|
||||
document: {
|
||||
hwpodId: "d601-f103-v2",
|
||||
authority: "preinstalled-verified-spec",
|
||||
document: {
|
||||
apiVersion: "hwlab.dev/v0alpha1",
|
||||
kind: "Hwpod",
|
||||
metadata: { uid: "D601-F103-V2", name: "d601-f103-v2" },
|
||||
@@ -70,7 +69,6 @@ test("hwpod cli resolves hwpod-id from runtime discovery and applies workspace o
|
||||
nodeBinding: { nodeId: "node-d601-f103-v2" }
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
};
|
||||
};
|
||||
@@ -89,7 +87,7 @@ test("hwpod cli resolves hwpod-id from runtime discovery and applies workspace o
|
||||
assert.equal(plan.payload.plan.resourceHints.workspacePath, runWorkspace);
|
||||
assert.equal(plan.payload.plan.source.specAuthority, "preinstalled-verified-spec");
|
||||
assert.equal(plan.payload.plan.ops[0].args.workspacePath, runWorkspace);
|
||||
assert.match(requests[0].url, /\/v1\/hwpod\/specs\?hwpodId=d601-f103-v2/u);
|
||||
assert.match(requests[0].url, /\/v1\/hwpod\/specs\/d601-f103-v2/u);
|
||||
});
|
||||
test("hwpod-compiler-cli compiles workspace-local spec into node ops", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-compiler-"));
|
||||
@@ -268,6 +266,15 @@ test("hwpod-cli dry-run invokes hwpod-compiler-cli and exposes hwpod-node-ops pl
|
||||
const uartSessionOnly = await runHwpodCli(["uart", "read", "--spec", specPath, "--port", "uart1", "--max-bytes", "512", "--session-only", "--dry-run"], { now: () => NOW });
|
||||
assert.equal(uartSessionOnly.payload.plan.ops[0].args.sessionOnly, true);
|
||||
assert.equal(uartSessionOnly.payload.plan.ops[0].args.commandBinding.sessionOnly, true);
|
||||
|
||||
const uartOpen = await runHwpodCli(["uart", "open", "--spec", specPath, "--port", "uart1", "--dry-run"], { now: () => NOW });
|
||||
assert.equal(uartOpen.payload.plan.ops[0].op, "io.uart.open");
|
||||
assert.equal(uartOpen.payload.plan.ops[0].args.port, "COM9");
|
||||
const uartWrite = await runHwpodCli(["uart", "write", "--spec", specPath, "--port", "uart1", "--data", "status", "--dry-run"], { now: () => NOW });
|
||||
assert.equal(uartWrite.payload.plan.ops[0].op, "io.uart.write");
|
||||
assert.equal(uartWrite.payload.plan.ops[0].args.data, "status");
|
||||
const uartClose = await runHwpodCli(["uart", "close", "--spec", specPath, "--port", "uart1", "--dry-run"], { now: () => NOW });
|
||||
assert.equal(uartClose.payload.plan.ops[0].op, "io.uart.close");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -367,6 +367,35 @@ print(json.dumps(executor._uart_read({"workspacePath": ".", "port": "COM9", "bau
|
||||
assert.equal(result.details.serialMonitor.start.stdout, "not-json");
|
||||
});
|
||||
|
||||
test("python hwpod-node maps UART open, write, and close to serial-monitor", () => {
|
||||
const script = `
|
||||
import importlib.util, json, logging
|
||||
spec = importlib.util.spec_from_file_location("hwlab_node", "tools/hwlab-node.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
class Executor(module.NodeOpsExecutor):
|
||||
def __init__(self):
|
||||
super().__init__({"nodeId": "node-test"}, logging.getLogger("test"))
|
||||
self.commands = []
|
||||
def _spawn_output(self, command, cwd, timeout_ms):
|
||||
self.commands.append(command)
|
||||
return {"ok": True, "stdout": json.dumps({"success": True}), "stderr": "", "exitCode": 0}
|
||||
executor = Executor()
|
||||
args = {"workspacePath": ".", "port": "COM9", "baudRate": 115200, "data": "status", "serialMonitorCommand": ["serial-monitor"]}
|
||||
results = [executor._execute_op({"opId": "open", "op": "io.uart.open", "args": args}), executor._execute_op({"opId": "write", "op": "io.uart.write", "args": args}), executor._execute_op({"opId": "close", "op": "io.uart.close", "args": args})]
|
||||
print(json.dumps({"results": results, "commands": executor.commands}))
|
||||
`;
|
||||
const completed = spawnSync("python3", ["-c", script], { cwd: process.cwd(), encoding: "utf8" });
|
||||
assert.equal(completed.status, 0, completed.stderr);
|
||||
const result = JSON.parse(completed.stdout);
|
||||
assert.deepEqual(result.commands, [
|
||||
["serial-monitor", "monitor", "start", "-p", "COM9", "-b", "115200"],
|
||||
["serial-monitor", "send", "status"],
|
||||
["serial-monitor", "monitor", "stop"],
|
||||
]);
|
||||
assert.deepEqual(result.results.map((item: any) => item.status), ["completed", "completed", "completed"]);
|
||||
});
|
||||
|
||||
test("hwpod-node resolves Git for Windows when service PATH omits git", async () => {
|
||||
const result = await resolveHwpodNodeCommand("git", {
|
||||
platform: "win32",
|
||||
|
||||
@@ -643,6 +643,8 @@ function commandToIntent(parsed: ParsedArgs, stdinText?: string) {
|
||||
if (command === "reset") return { intent: "debug.reset", args: clean({ mode: text(parsed.mode ?? parsed._[1]), command: text(parsed.command ?? parsed.commandLine), reason: text(parsed.reason) }) };
|
||||
if (command === "uart") {
|
||||
const subcommand = parsed._[1] || "read";
|
||||
if (subcommand === "open" || subcommand === "start") return { intent: "io.uart.open", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), baudRate: numberValue(parsed.baudRate ?? parsed.baudrate), timeoutMs: numberValue(parsed.timeoutMs), serialMonitorDir: text(parsed.serialMonitorDir), serialMonitorCommand: text(parsed.serialMonitorCommand), reason: text(parsed.reason) }) };
|
||||
if (subcommand === "close" || subcommand === "stop") return { intent: "io.uart.close", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), timeoutMs: numberValue(parsed.timeoutMs), serialMonitorDir: text(parsed.serialMonitorDir), serialMonitorCommand: text(parsed.serialMonitorCommand), reason: text(parsed.reason) }) };
|
||||
if (subcommand === "read") return { intent: "io.uart.read", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), maxBytes: numberValue(parsed.maxBytes), limit: numberValue(parsed.limit), since: text(parsed.since), sessionOnly: parsed.sessionOnly === true, timeoutMs: numberValue(parsed.timeoutMs), serialMonitorDir: text(parsed.serialMonitorDir), serialMonitorCommand: text(parsed.serialMonitorCommand), reason: text(parsed.reason) }) };
|
||||
if (subcommand === "write") return { intent: "io.uart.write", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), data: requiredText(parsed.data ?? parsed._[3] ?? stdinText, "data") }) };
|
||||
throw cliError("unsupported_uart_command", `unsupported uart command: ${subcommand}`);
|
||||
@@ -698,8 +700,9 @@ function opsForIntent(intent: string, args: any, document: any) {
|
||||
if (intent === "debug.download") return debugDownloadOps(common, args, document);
|
||||
if (intent === "debug.job-status") return debugJobStatusOps(common, args, document);
|
||||
if (intent === "debug.reset") return [{ op: "debug.reset", args: clean({ ...common, mode: text(args.mode), command: text(args.command) || text(document.spec.debugProbe.resetCommand), reason: text(args.reason) }) }];
|
||||
if (intent === "io.uart.open" || intent === "io.uart.close") return uartControlOps(common, intent, args, document);
|
||||
if (intent === "io.uart.read") return uartReadOps(common, args, document);
|
||||
if (intent === "io.uart.write") return [{ op: "io.uart.write", args: clean({ ...common, port: text(args.port) || "uart1", data: requiredText(args.data, "data") }) }];
|
||||
if (intent === "io.uart.write") return uartWriteOps(common, args, document);
|
||||
if (intent === "io.uart.jsonrpc") return [{ op: "io.uart.jsonrpc", args: clean({ ...common, port: text(args.port) || "uart1", method: requiredText(args.method, "method"), params: objectValue(args.params) }) }];
|
||||
if (intent === "io.board-comm.jrpctcp") return boardCommObservationOps(common, args, document);
|
||||
if (intent === "io.probe.read") return ioProbeObservationOps(common, args, document);
|
||||
@@ -900,6 +903,22 @@ function uartReadOps(common: any, args: any, document: any) {
|
||||
}];
|
||||
}
|
||||
|
||||
function uartControlOps(common: any, intent: "io.uart.open" | "io.uart.close", args: any, document: any) {
|
||||
const generated = serialMonitorBinding(args, document);
|
||||
return [{
|
||||
op: intent,
|
||||
args: clean({ ...common, port: generated.physicalPort, baudRate: generated.baudRate, serialMonitorDir: generated.serialMonitorDir, serialMonitorCommand: generated.commandBase, timeoutMs: generated.timeoutMs, reason: text(args.reason) })
|
||||
}];
|
||||
}
|
||||
|
||||
function uartWriteOps(common: any, args: any, document: any) {
|
||||
const generated = serialMonitorBinding(args, document);
|
||||
return [{
|
||||
op: "io.uart.write",
|
||||
args: clean({ ...common, port: generated.physicalPort, baudRate: generated.baudRate, data: requiredText(args.data, "data"), serialMonitorDir: generated.serialMonitorDir, serialMonitorCommand: generated.commandBase, timeoutMs: generated.timeoutMs, reason: text(args.reason) })
|
||||
}];
|
||||
}
|
||||
|
||||
function keilCommandForIntent(intent: "debug.build" | "debug.download", args: any, document: any) {
|
||||
const workspace = objectValue(document.spec.workspace);
|
||||
const debugProbe = objectValue(document.spec.debugProbe);
|
||||
@@ -997,15 +1016,8 @@ function keilJobStatusCommand(args: any, document: any) {
|
||||
}
|
||||
|
||||
function serialMonitorReadCommand(args: any, document: any) {
|
||||
const ioProbe = objectValue(document.spec.ioProbe);
|
||||
const uart = objectValue(ioProbe.uart);
|
||||
const tooling = objectValue(document.spec.tooling);
|
||||
const requestedPort = text(args.port) || text(uart.id) || "uart1";
|
||||
const physicalPort = text(args.physicalPort) || text(uart.port) || text(uart.path) || text(ioProbe.port) || requestedPort;
|
||||
const baudRate = numberValue(args.baudRate ?? args.baudrate ?? uart.baudRate ?? uart.baudrate ?? ioProbe.baudRate ?? ioProbe.baudrate) ?? 115200;
|
||||
const serialMonitorDir = text(args.serialMonitorDir) || text(tooling.serialMonitorDir) || text(ioProbe.serialMonitorDir) || "C:\\Users\\liang\\.agents\\skills\\serial-monitor";
|
||||
const commandBase = serialMonitorCommandBaseForCompiler(args, tooling, ioProbe);
|
||||
const timeoutMs = numberValue(args.timeoutMs) ?? 30000;
|
||||
const binding = serialMonitorBinding(args, document);
|
||||
const { requestedPort, physicalPort, baudRate, serialMonitorDir, commandBase, timeoutMs } = binding;
|
||||
const limit = Math.max(1, Math.min(numberValue(args.limit) ?? Math.ceil((numberValue(args.maxBytes) ?? 4096) / 80), 200));
|
||||
const fetchArgs = ["fetch", "-l", String(limit), ...(args.sessionOnly === true ? ["--session-only"] : []), ...(text(args.since) ? ["-s", text(args.since)] : [])];
|
||||
const sequenceRun = jsonCliSequenceCommandRun([
|
||||
@@ -1024,6 +1036,21 @@ function serialMonitorReadCommand(args: any, document: any) {
|
||||
};
|
||||
}
|
||||
|
||||
function serialMonitorBinding(args: any, document: any) {
|
||||
const ioProbe = objectValue(document.spec.ioProbe);
|
||||
const uart = objectValue(ioProbe.uart);
|
||||
const tooling = objectValue(document.spec.tooling);
|
||||
const requestedPort = text(args.port) || text(uart.id) || "uart1";
|
||||
return {
|
||||
requestedPort,
|
||||
physicalPort: text(args.physicalPort) || text(uart.port) || text(uart.path) || text(ioProbe.port) || requestedPort,
|
||||
baudRate: numberValue(args.baudRate ?? args.baudrate ?? uart.baudRate ?? uart.baudrate ?? ioProbe.baudRate ?? ioProbe.baudrate) ?? 115200,
|
||||
serialMonitorDir: text(args.serialMonitorDir) || text(tooling.serialMonitorDir) || text(ioProbe.serialMonitorDir) || "C:\\Users\\liang\\.agents\\skills\\serial-monitor",
|
||||
commandBase: serialMonitorCommandBaseForCompiler(args, tooling, ioProbe),
|
||||
timeoutMs: numberValue(args.timeoutMs) ?? 30000,
|
||||
};
|
||||
}
|
||||
|
||||
function serialMonitorCommandBaseForCompiler(args: any, tooling: any, ioProbe: any) {
|
||||
const configured = text(args.serialMonitorCommand) || text(tooling.serialMonitorCommand) || text(ioProbe.serialMonitorCommand);
|
||||
if (configured) return splitCommandWords(configured);
|
||||
|
||||
@@ -15,9 +15,10 @@ export const HWPOD_NODE_OPS = new Set([
|
||||
"debug.build",
|
||||
"debug.download",
|
||||
"debug.reset",
|
||||
"io.uart.open",
|
||||
"io.uart.close",
|
||||
"io.uart.read",
|
||||
"io.uart.write",
|
||||
"io.uart.jsonrpc",
|
||||
"cmd.run"
|
||||
]);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"name": "hwlab-cloud-web",
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "3.0.0",
|
||||
"@he-tree/vue": "2.10.5",
|
||||
"@pinia/colada": "0.15.3",
|
||||
"@vue-flow/core": "1.48.2",
|
||||
"@vueuse/core": "^10.7.0",
|
||||
@@ -47,6 +48,8 @@
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
|
||||
@@ -109,6 +112,12 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"@he-tree/dnd-utils": ["@he-tree/dnd-utils@0.1.0-alpha.4", "", { "dependencies": { "@babel/runtime": "^7.7.7", "drag-event-service": "^2.0.0", "helper-js": "^3.1.2" } }, "sha512-DuxkFOQ8eztoHrX1/VelqOnQphQbRszemfMKSKlAEEhVjqR/IkaOE2QSRlAdj6OC6AfMvHDPvIZOiEPFwP6/ZQ=="],
|
||||
|
||||
"@he-tree/tree-utils": ["@he-tree/tree-utils@0.1.0-alpha.8", "", { "dependencies": { "@babel/runtime": "^7.7.7", "drag-event-service": "^2.0.0", "helper-js": "^3.1.2" } }, "sha512-4M4BzXbjCg2uY/GDPUGzq1EFGgna5L7NGFF85fF2pSnCIUkWOZY18eaSqvvlOFtyD5DWkkDqxzu0GHFlp/Zb4g=="],
|
||||
|
||||
"@he-tree/vue": ["@he-tree/vue@2.10.5", "", { "dependencies": { "@he-tree/dnd-utils": "^0.1.0-alpha.4", "@he-tree/tree-utils": "^0.1.0-alpha.8", "@virtual-list/vue": "^1.2.1", "helper-js": "^3.1.2", "vue-demi": "latest" }, "peerDependencies": { "@vue/composition-api": "^1.4.6", "vue": "^2.0.0 || >=3.0.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-+/C3FxoGb1WpkElQA0kASSe0W9EUXTQSzg/1c7+1ucheiX4uFc8m1mSuD74lO7FXr6PWWbVRX2lzo/hxoUOTzA=="],
|
||||
|
||||
"@intlify/core-base": ["@intlify/core-base@9.14.5", "", { "dependencies": { "@intlify/message-compiler": "9.14.5", "@intlify/shared": "9.14.5" } }, "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA=="],
|
||||
|
||||
"@intlify/message-compiler": ["@intlify/message-compiler@9.14.5", "", { "dependencies": { "@intlify/shared": "9.14.5", "source-map-js": "^1.0.2" } }, "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ=="],
|
||||
@@ -201,6 +210,8 @@
|
||||
|
||||
"@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="],
|
||||
|
||||
"@virtual-list/vue": ["@virtual-list/vue@1.2.1", "", { "dependencies": { "helper-js": "^3.1.2", "vue-demi": "latest" }, "peerDependencies": { "@vue/composition-api": "^1.4.6", "vue": "^2.0.0 || >=3.0.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-sVangNRLgHJsrwjf2EeXuc8mUExbLRJO39J9/FXFWXXQkue/FBT4ZWN7GH5+GJTeVaEEfHW1W2UuVaf3LEB3jQ=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="],
|
||||
@@ -371,6 +382,8 @@
|
||||
|
||||
"dompurify": ["dompurify@3.4.9", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
|
||||
|
||||
"drag-event-service": ["drag-event-service@2.0.0", "", { "dependencies": { "@babel/runtime": "^7.7.7", "helper-js": "^3.0.0" } }, "sha512-JVEnOEbbNDS3RrWd+tVVIDu2SHQZ9luhEFGSQ1hXMjl+ABw0PxhHmnkGjzPVE836Tsg+k7rkm5GM9Ln/UgbYpA=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
@@ -439,6 +452,8 @@
|
||||
|
||||
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||
|
||||
"helper-js": ["helper-js@3.1.6", "", { "dependencies": { "@babel/runtime": "^7.7.7" } }, "sha512-lhncHDAxS2PTA44aG1AofxT51v0IXEVOmaUCC6HwuGaGqE1yEkjhPH74Vb/Aw4xt8Kt5bMvStCr6FekANp+PGg=="],
|
||||
|
||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "3.0.0",
|
||||
"@he-tree/vue": "2.10.5",
|
||||
"@pinia/colada": "0.15.3",
|
||||
"@vue-flow/core": "1.48.2",
|
||||
"@vueuse/core": "^10.7.0",
|
||||
|
||||
@@ -20,6 +20,7 @@ export default defineConfig(({ command }) => ({
|
||||
port: requiredPort(process.env.HWPOD_WEB_PORT),
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
"/v1/hwpod-node/ws": { target: requiredApiUrl(), changeOrigin: false, ws: true },
|
||||
"/v1/hwpod": { target: requiredApiUrl(), changeOrigin: false },
|
||||
"/health/ready": { target: requiredApiUrl(), changeOrigin: false },
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
HwpodDeviceTopologyItem,
|
||||
HwpodNodeOpsResponse,
|
||||
HwpodNodeTopologyItem,
|
||||
HwpodOperationResponse,
|
||||
HwpodSpecsResponse,
|
||||
HwpodTopologyResponse,
|
||||
HwlabNodeUpdateMetadata
|
||||
@@ -38,5 +39,7 @@ export const hwpodAPI = {
|
||||
groups: (): Promise<ApiResult<HwpodNodeOpsResponse>> => fetchJson("/v1/hwpod-node-ops", { timeoutMs: 12000, timeoutName: "HWPOD groups" }),
|
||||
devices: (query: TopologyQuery = {}): Promise<ApiResult<HwpodTopologyResponse<HwpodDeviceTopologyItem>>> => fetchJson(topologyPath("device", query), { timeoutMs: 20000, timeoutName: "HWPOD device topology" }),
|
||||
nodes: (query: TopologyQuery = {}): Promise<ApiResult<HwpodTopologyResponse<HwpodNodeTopologyItem>>> => fetchJson(topologyPath("node", query), { timeoutMs: 20000, timeoutName: "HWPOD node topology" }),
|
||||
nodeUpdate: (): Promise<ApiResult<HwlabNodeUpdateMetadata>> => fetchJson("/v1/hwlab-node/update?platform=windows&channel=stable", { timeoutMs: 12000, timeoutName: "HWLAB Node update metadata" })
|
||||
nodeUpdate: (): Promise<ApiResult<HwlabNodeUpdateMetadata>> => fetchJson("/v1/hwlab-node/update?platform=windows&channel=stable", { timeoutMs: 12000, timeoutName: "HWLAB Node update metadata" }),
|
||||
startOperation: (input: { hwpodId: string; intent: string; args?: Record<string, unknown> }): Promise<ApiResult<HwpodOperationResponse>> => fetchJson("/v1/hwpod/operations", { method: "POST", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "HWPOD operation submit" }),
|
||||
operation: (operationId: string): Promise<ApiResult<HwpodOperationResponse>> => fetchJson(`/v1/hwpod/operations/${encodeURIComponent(operationId)}`, { timeoutMs: 12000, timeoutName: "HWPOD operation status" })
|
||||
};
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<!-- SPEC: PJ2026-010102 HWPOD 工具;PJ2026-010405 云端控制台。 -->
|
||||
<script setup lang="ts">
|
||||
import { BaseTree } from "@he-tree/vue";
|
||||
import "@he-tree/vue/style/default.css";
|
||||
import { ChevronLeft, Download, File, FileCode2, Folder, Hammer, Plug, RefreshCw, Send, Square, Terminal } from "lucide-vue-next";
|
||||
import { computed, nextTick, onMounted, ref } from "vue";
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog.vue";
|
||||
import { useHwpodStore } from "@/stores/hwpod";
|
||||
import type { HwpodOperationResponse, HwpodWorkspaceEntry } from "@/types";
|
||||
|
||||
type WorkspaceNode = HwpodWorkspaceEntry & { children?: WorkspaceNode[]; loaded?: boolean };
|
||||
|
||||
const props = defineProps<{ hwpodId: string; initialPath?: string }>();
|
||||
const emit = defineEmits<{ back: []; navigate: [path: string] }>();
|
||||
const hwpod = useHwpodStore();
|
||||
const tree = ref<WorkspaceNode[]>([]);
|
||||
const selectedPath = ref(props.initialPath || ".");
|
||||
const preview = ref("");
|
||||
const previewMeta = ref<{ sizeBytes: number; truncated: boolean } | null>(null);
|
||||
const busy = ref<string | null>(null);
|
||||
const localError = ref<string | null>(null);
|
||||
const downloadConfirmOpen = ref(false);
|
||||
const uartPort = ref("uart1");
|
||||
const uartBaudRate = ref(115200);
|
||||
const uartData = ref("");
|
||||
const uartOutput = ref("");
|
||||
const terminalStatus = computed(() => hwpod.lastOperation?.status ?? "idle");
|
||||
|
||||
onMounted(async () => {
|
||||
await loadRoot();
|
||||
if (props.initialPath && props.initialPath !== ".") await openInitialPath(props.initialPath);
|
||||
});
|
||||
|
||||
async function loadRoot(): Promise<void> {
|
||||
const output = await operationOutput("workspace.ls", { path: ".", maxEntries: 1000 });
|
||||
tree.value = workspaceEntries(output);
|
||||
}
|
||||
|
||||
async function selectNode(stat: { data: WorkspaceNode; open?: boolean }): Promise<void> {
|
||||
const node = stat.data;
|
||||
selectedPath.value = node.path;
|
||||
emit("navigate", node.path);
|
||||
if (node.type === "dir") {
|
||||
if (!node.loaded) {
|
||||
const output = await operationOutput("workspace.ls", { path: node.path, maxEntries: 1000 });
|
||||
node.children = workspaceEntries(output);
|
||||
node.loaded = true;
|
||||
tree.value = [...tree.value];
|
||||
await nextTick();
|
||||
}
|
||||
stat.open = true;
|
||||
preview.value = "";
|
||||
previewMeta.value = null;
|
||||
return;
|
||||
}
|
||||
if (node.type !== "file") return;
|
||||
const output = await operationOutput("workspace.cat", { path: node.path, maxBytes: 262144 });
|
||||
if (output.previewable === false) throw new Error(blockerSummary(output) || "该文件不支持文本预览");
|
||||
preview.value = typeof output.content === "string" ? output.content : "";
|
||||
previewMeta.value = { sizeBytes: numberValue(output.sizeBytes), truncated: output.truncated === true };
|
||||
}
|
||||
|
||||
async function openInitialPath(path: string): Promise<void> {
|
||||
const segments = path.replace(/\\/gu, "/").split("/").filter(Boolean);
|
||||
let nodes = tree.value;
|
||||
let current = "";
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
current = current ? `${current}/${segments[index]}` : segments[index];
|
||||
const node = nodes.find((entry) => entry.path === current);
|
||||
if (!node) return;
|
||||
if (index === segments.length - 1) {
|
||||
await selectNode({ data: node });
|
||||
return;
|
||||
}
|
||||
if (node.type !== "dir") return;
|
||||
if (!node.loaded) {
|
||||
const output = await operationOutput("workspace.ls", { path: node.path, maxEntries: 1000 });
|
||||
node.children = workspaceEntries(output);
|
||||
node.loaded = true;
|
||||
}
|
||||
nodes = node.children ?? [];
|
||||
}
|
||||
tree.value = [...tree.value];
|
||||
}
|
||||
|
||||
async function runAction(intent: string, args: Record<string, unknown> = {}): Promise<void> {
|
||||
await operationOutput(intent, args);
|
||||
}
|
||||
|
||||
async function confirmDownload(): Promise<void> {
|
||||
downloadConfirmOpen.value = false;
|
||||
await runAction("debug.download");
|
||||
}
|
||||
|
||||
async function readUart(): Promise<void> {
|
||||
const output = await operationOutput("io.uart.read", uartArgs());
|
||||
uartOutput.value = typeof output.stdout === "string" ? output.stdout : JSON.stringify(output.response ?? output, null, 2);
|
||||
}
|
||||
|
||||
async function writeUart(): Promise<void> {
|
||||
const data = uartData.value;
|
||||
if (!data) return;
|
||||
await operationOutput("io.uart.write", { ...uartArgs(), data });
|
||||
uartData.value = "";
|
||||
}
|
||||
|
||||
function uartArgs(): Record<string, unknown> {
|
||||
return { port: uartPort.value, baudRate: uartBaudRate.value };
|
||||
}
|
||||
|
||||
async function operationOutput(intent: string, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
busy.value = intent;
|
||||
localError.value = null;
|
||||
try {
|
||||
const operation = await hwpod.executeOperation(props.hwpodId, intent, args, 900_000);
|
||||
const activity = record(operation.result);
|
||||
if (activity.ok === false) throw new Error(errorSummary(activity) || `${intent} 执行失败`);
|
||||
const response = record(activity.response);
|
||||
if (response.ok === false) throw new Error(errorSummary(response) || `${intent} 执行失败`);
|
||||
const result = Array.isArray(response.results) ? record(response.results[0]) : {};
|
||||
if (result.ok === false) throw new Error(errorSummary(result) || `${intent} 执行失败`);
|
||||
return record(result.output);
|
||||
} catch (error) {
|
||||
localError.value = error instanceof Error ? error.message : String(error);
|
||||
throw error;
|
||||
} finally {
|
||||
busy.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceEntries(output: Record<string, unknown>): WorkspaceNode[] {
|
||||
return Array.isArray(output.entries) ? output.entries.map((entry) => {
|
||||
const value = record(entry);
|
||||
const type = value.type === "dir" || value.type === "file" ? value.type : "other";
|
||||
return {
|
||||
name: String(value.name ?? ""),
|
||||
path: String(value.path ?? value.name ?? ""),
|
||||
type,
|
||||
sizeBytes: value.sizeBytes == null ? null : numberValue(value.sizeBytes),
|
||||
modifiedAt: typeof value.modifiedAt === "string" ? value.modifiedAt : null,
|
||||
...(type === "dir" ? { children: [], loaded: false } : {}),
|
||||
};
|
||||
}) : [];
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
||||
function numberValue(value: unknown): number { return Number.isFinite(Number(value)) ? Number(value) : 0; }
|
||||
function errorSummary(value: Record<string, unknown>): string { return String(record(value.error).message ?? record(value.blocker).summary ?? value.summary ?? ""); }
|
||||
function blockerSummary(value: Record<string, unknown>): string { return String(record(value.blocker).summary ?? ""); }
|
||||
function operationLabel(operation: HwpodOperationResponse | null): string { return operation ? `${operation.status} · ${operation.operationId}` : "尚未执行"; }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="operations-workspace" data-testid="hwpod-operations-workspace">
|
||||
<header class="workspace-toolbar">
|
||||
<button class="icon-command" type="button" title="返回设备资源" @click="emit('back')"><ChevronLeft :size="17" /></button>
|
||||
<strong>{{ hwpodId }}</strong>
|
||||
<code>{{ selectedPath }}</code>
|
||||
<button class="icon-command" type="button" title="刷新工作区" :disabled="Boolean(busy)" @click="loadRoot"><RefreshCw :size="16" /></button>
|
||||
<span class="operation-state" :data-status="terminalStatus">{{ operationLabel(hwpod.lastOperation) }}</span>
|
||||
</header>
|
||||
|
||||
<div class="workspace-columns">
|
||||
<section class="file-pane" aria-label="工作区文件">
|
||||
<h2>工作区</h2>
|
||||
<BaseTree v-model="tree" class="file-tree" :virtualization="true" :virtualization-prerender-count="30" :default-open="false" aria-label="HWPOD 工作区文件树" @click:node="selectNode">
|
||||
<template #default="{ node, stat, indentStyle }">
|
||||
<div class="tree-row" :class="{ selected: selectedPath === node.path }" :style="indentStyle">
|
||||
<Folder v-if="node.type === 'dir'" :size="15" :fill="stat.open ? 'currentColor' : 'none'" />
|
||||
<File v-else :size="15" />
|
||||
<span>{{ node.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTree>
|
||||
</section>
|
||||
|
||||
<section class="preview-pane" aria-label="文件预览">
|
||||
<h2><FileCode2 :size="16" /> 文件预览</h2>
|
||||
<div v-if="previewMeta" class="preview-meta"><span>{{ previewMeta.sizeBytes }} bytes</span><span v-if="previewMeta.truncated">预览已截断</span></div>
|
||||
<pre v-if="preview">{{ preview }}</pre>
|
||||
<div v-else class="empty-preview">选择文件查看内容</div>
|
||||
</section>
|
||||
|
||||
<aside class="operation-pane" aria-label="HWPOD 操作">
|
||||
<section>
|
||||
<h2><Hammer :size="16" /> 调试</h2>
|
||||
<div class="action-row">
|
||||
<button type="button" :disabled="Boolean(busy)" @click="runAction('debug.build')"><Hammer :size="15" /> 编译</button>
|
||||
<button type="button" :disabled="Boolean(busy)" @click="downloadConfirmOpen = true"><Download :size="15" /> 下载</button>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2><Terminal :size="16" /> 串口</h2>
|
||||
<label>端口<input v-model="uartPort" /></label>
|
||||
<label>波特率<input v-model.number="uartBaudRate" type="number" min="1" /></label>
|
||||
<div class="icon-actions">
|
||||
<button type="button" title="打开串口" :disabled="Boolean(busy)" @click="runAction('io.uart.open', uartArgs())"><Plug :size="16" /></button>
|
||||
<button type="button" title="关闭串口" :disabled="Boolean(busy)" @click="runAction('io.uart.close', uartArgs())"><Square :size="15" /></button>
|
||||
<button type="button" title="读取串口" :disabled="Boolean(busy)" @click="readUart"><RefreshCw :size="15" /></button>
|
||||
</div>
|
||||
<div class="send-row"><input v-model="uartData" aria-label="串口发送内容" @keyup.enter="writeUart" /><button type="button" title="发送串口数据" :disabled="Boolean(busy) || !uartData" @click="writeUart"><Send :size="15" /></button></div>
|
||||
<pre class="uart-output">{{ uartOutput || "等待串口数据" }}</pre>
|
||||
</section>
|
||||
<p v-if="busy" class="operation-message">{{ busy }} · {{ terminalStatus }}</p>
|
||||
<p v-if="localError" class="operation-error" role="alert">{{ localError }}</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog :open="downloadConfirmOpen" title="下载固件" message="下载会写入当前连接的目标板。确认继续?" confirm-label="确认下载" :pending="busy === 'debug.download'" @close="downloadConfirmOpen = false" @confirm="confirmDownload" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.operations-workspace { display: grid; height: 100%; min-width: 0; min-height: 0; grid-template-rows: auto minmax(0, 1fr); border: 1px solid var(--console-border-strong); background: var(--console-surface-raised); }
|
||||
.workspace-toolbar { display: grid; min-width: 0; grid-template-columns: auto auto minmax(80px, 1fr) auto minmax(160px, auto); align-items: center; gap: 8px; min-height: 42px; padding: 5px 8px; border-bottom: 1px solid var(--console-border-strong); }
|
||||
.workspace-toolbar strong, .workspace-toolbar code, .operation-state { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workspace-toolbar code, .operation-state { color: var(--console-graphite-600); font-size: 11px; }
|
||||
.icon-command, .icon-actions button, .send-row button { display: inline-grid; width: 31px; height: 31px; place-items: center; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: #fff; color: var(--console-graphite-800); cursor: pointer; }
|
||||
.workspace-columns { display: grid; min-width: 0; min-height: 0; grid-template-columns: minmax(210px, .85fr) minmax(300px, 1.5fr) minmax(240px, .9fr); }
|
||||
.file-pane, .preview-pane, .operation-pane { min-width: 0; min-height: 0; padding: 10px; overflow: auto; }
|
||||
.file-pane, .preview-pane { border-right: 1px solid var(--console-border-strong); }
|
||||
h2 { display: flex; align-items: center; gap: 6px; margin: 0 0 9px; color: var(--console-graphite-700); font-size: 12px; }
|
||||
.file-tree { height: calc(100% - 28px); min-height: 280px; overflow: auto; }
|
||||
.tree-row { display: flex; min-width: 0; align-items: center; gap: 6px; min-height: 30px; padding-right: 6px; color: var(--console-graphite-700); cursor: pointer; }
|
||||
.tree-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tree-row.selected { background: var(--console-cyan-100); color: var(--console-cyan-700); }
|
||||
.preview-pane pre, .uart-output { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--console-font-mono); font-size: 11px; line-height: 1.55; }
|
||||
.preview-meta { display: flex; gap: 12px; margin-bottom: 8px; color: var(--console-graphite-500); font-size: 10px; }
|
||||
.empty-preview { display: grid; min-height: 180px; place-items: center; color: var(--console-graphite-500); font-size: 12px; }
|
||||
.operation-pane { display: grid; align-content: start; gap: 18px; background: var(--console-surface-muted); }
|
||||
.operation-pane section { display: grid; gap: 8px; }
|
||||
.operation-pane label { display: grid; gap: 4px; color: var(--console-graphite-600); font-size: 10px; font-weight: 700; }
|
||||
.operation-pane input { min-width: 0; height: 32px; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: #fff; padding: 0 8px; }
|
||||
.action-row, .icon-actions, .send-row { display: flex; gap: 6px; }
|
||||
.action-row button { display: inline-flex; min-height: 32px; align-items: center; gap: 6px; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: #fff; padding: 0 10px; color: var(--console-graphite-800); font-weight: 700; cursor: pointer; }
|
||||
.send-row input { flex: 1; }
|
||||
button:disabled { opacity: .45; cursor: wait; }
|
||||
.uart-output { max-height: 190px; min-height: 88px; padding: 8px; overflow: auto; border: 1px solid var(--console-border-strong); background: #171c1a; color: #d8eee5; }
|
||||
.operation-message { color: var(--console-cyan-700); font-size: 11px; }
|
||||
.operation-error { margin: 0; padding: 8px; border-left: 3px solid var(--console-red-700); background: var(--console-red-100); color: var(--console-red-700); font-size: 11px; }
|
||||
@media (max-width: 980px) { .workspace-columns { grid-template-columns: minmax(190px, .8fr) minmax(280px, 1.2fr); } .operation-pane { grid-column: 1 / -1; grid-template-columns: repeat(2, minmax(0, 1fr)); border-top: 1px solid var(--console-border-strong); } }
|
||||
@media (max-width: 680px) { .workspace-toolbar { grid-template-columns: auto minmax(0, 1fr) auto; } .workspace-toolbar code, .operation-state { grid-column: 1 / -1; } .workspace-columns { display: block; overflow: auto; } .file-pane, .preview-pane { min-height: 300px; border-right: 0; border-bottom: 1px solid var(--console-border-strong); } .operation-pane { display: grid; grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
HwpodDeviceTopologyItem,
|
||||
HwpodNodeOpsResponse,
|
||||
HwpodNodeTopologyItem,
|
||||
HwpodOperationResponse,
|
||||
HwpodSpecsResponse,
|
||||
HwpodTopologyResponse,
|
||||
HwlabNodeUpdateMetadata
|
||||
@@ -27,6 +28,8 @@ export const useHwpodStore = defineStore("hwpod", () => {
|
||||
const consoleError = ref<string | null>(null);
|
||||
const consoleStale = ref(false);
|
||||
let consoleRequestId = 0;
|
||||
const lastOperation = ref<HwpodOperationResponse | null>(null);
|
||||
const operationError = ref<string | null>(null);
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
loading.value = true;
|
||||
@@ -79,5 +82,29 @@ export const useHwpodStore = defineStore("hwpod", () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { specs, nodeOps, loading, error, refresh, deviceTopology, nodeTopology, nodeUpdate, consoleLoading, consoleError, consoleStale, refreshConsole };
|
||||
async function executeOperation(hwpodId: string, intent: string, args: Record<string, unknown> = {}, timeoutMs = 60_000): Promise<HwpodOperationResponse> {
|
||||
operationError.value = null;
|
||||
const submitted = await hwpodAPI.startOperation({ hwpodId, intent, args });
|
||||
if (!submitted.ok || !submitted.data?.operationId) throw operationFailure(submitted.error ?? "HWPOD operation 提交失败");
|
||||
lastOperation.value = submitted.data;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await delay(500);
|
||||
const status = await hwpodAPI.operation(submitted.data.operationId);
|
||||
if (!status.ok || !status.data) throw operationFailure(status.error ?? "HWPOD operation 状态查询失败");
|
||||
lastOperation.value = status.data;
|
||||
if (isTerminal(status.data.status)) return status.data;
|
||||
}
|
||||
throw operationFailure(`HWPOD operation ${submitted.data.operationId} 在 ${timeoutMs}ms 内未终止`);
|
||||
}
|
||||
|
||||
function operationFailure(message: string): Error {
|
||||
operationError.value = message;
|
||||
return new Error(message);
|
||||
}
|
||||
|
||||
return { specs, nodeOps, loading, error, refresh, deviceTopology, nodeTopology, nodeUpdate, consoleLoading, consoleError, consoleStale, refreshConsole, lastOperation, operationError, executeOperation };
|
||||
});
|
||||
|
||||
function isTerminal(status: string): boolean { return /completed|failed|canceled|terminated|timed.?out/u.test(status); }
|
||||
function delay(ms: number): Promise<void> { return new Promise((resolve) => window.setTimeout(resolve, ms)); }
|
||||
|
||||
@@ -500,6 +500,28 @@ export interface HwpodTopologyResponse<T extends HwpodDeviceTopologyItem | Hwpod
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
export interface HwpodOperationResponse {
|
||||
ok: boolean;
|
||||
status: string;
|
||||
operationId: string;
|
||||
planId?: string | null;
|
||||
hwpodId?: string | null;
|
||||
workflowId?: string;
|
||||
workflowRunId?: string | null;
|
||||
authority?: string;
|
||||
result?: Record<string, unknown> | null;
|
||||
error?: { code?: string; message?: string } | null;
|
||||
valuesPrinted?: boolean;
|
||||
}
|
||||
|
||||
export interface HwpodWorkspaceEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "dir" | "file" | "other";
|
||||
sizeBytes: number | null;
|
||||
modifiedAt: string | null;
|
||||
}
|
||||
|
||||
export interface HwlabNodeUpdateMetadata {
|
||||
ok: boolean;
|
||||
contractVersion: "hwlab-node-update-v1" | string;
|
||||
|
||||
@@ -11,6 +11,7 @@ import BaseDrawer from "@/components/common/BaseDrawer.vue";
|
||||
import HwpodDevicesWorkspace from "@/components/hwpod/HwpodDevicesWorkspace.vue";
|
||||
import HwpodNodesWorkspace from "@/components/hwpod/HwpodNodesWorkspace.vue";
|
||||
import HwpodOnboardingWorkspace from "@/components/hwpod/HwpodOnboardingWorkspace.vue";
|
||||
import HwpodOperationsWorkspace from "@/components/hwpod/HwpodOperationsWorkspace.vue";
|
||||
import HwpodReadinessRail from "@/components/hwpod/HwpodReadinessRail.vue";
|
||||
import BoundedWorkspace from "@/components/layout/BoundedWorkspace.vue";
|
||||
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
||||
@@ -28,6 +29,7 @@ const hwpod = useHwpodStore();
|
||||
const viewState = useConsoleViewState({ view: "cards", density: "comfortable", sort: "name" });
|
||||
const searchText = ref(viewState.q.value);
|
||||
const copied = ref(false);
|
||||
const workspaceMode = computed(() => tab.value === "devices" && route.query.workspace === "1" && Boolean(text(route.params.hwpodId)));
|
||||
|
||||
const tab = computed<ConsoleTab>(() => {
|
||||
const value = route.path.split("/")[2] ?? "";
|
||||
@@ -135,6 +137,19 @@ function closeInspector(): void {
|
||||
void router.replace({ name: "HwpodDevices", query: collectionQuery() });
|
||||
}
|
||||
|
||||
function openWorkspace(): void {
|
||||
if (!selectedDevice.value) return;
|
||||
void router.replace({ name: "HwpodDevices", params: { hwpodId: selectedDevice.value.hwpodId }, query: { workspace: "1", path: "." } });
|
||||
}
|
||||
|
||||
function closeWorkspace(): void {
|
||||
void router.replace({ name: "HwpodDevices", params: { hwpodId: text(route.params.hwpodId) } });
|
||||
}
|
||||
|
||||
function navigateWorkspace(path: string): void {
|
||||
void router.replace({ name: "HwpodDevices", params: { hwpodId: text(route.params.hwpodId) }, query: { workspace: "1", path } });
|
||||
}
|
||||
|
||||
function selectDeviceItem(device: HwpodDeviceTopologyItem): void {
|
||||
selectDevice(device.hwpodId);
|
||||
}
|
||||
@@ -242,7 +257,8 @@ function text(value: unknown): string {
|
||||
</aside>
|
||||
|
||||
<BoundedWorkspace class="hwpod-workspace" label="HWPOD 资源工作区">
|
||||
<HwpodDevicesWorkspace v-if="tab === 'devices'" :devices="devices" :selected-id="selectedDevice?.hwpodId || null" :view="view" :density="density" :sort="viewState.sort.value" @select="selectDeviceItem" @sort="toggleSort" />
|
||||
<HwpodOperationsWorkspace v-if="workspaceMode" :hwpod-id="text(route.params.hwpodId)" :initial-path="text(route.query.path) || '.'" @back="closeWorkspace" @navigate="navigateWorkspace" />
|
||||
<HwpodDevicesWorkspace v-else-if="tab === 'devices'" :devices="devices" :selected-id="selectedDevice?.hwpodId || null" :view="view" :density="density" :sort="viewState.sort.value" @select="selectDeviceItem" @sort="toggleSort" />
|
||||
<HwpodNodesWorkspace v-else-if="tab === 'nodes'" :nodes="nodes" :selected-id="selectedNodeDetail?.nodeId || null" :view="view" :density="density" :sort="viewState.sort.value" @select="selectNodeItem" @sort="toggleSort" />
|
||||
<HwpodOnboardingWorkspace v-else :metadata="hwpod.nodeUpdate" :nodes="nodes" :selected-node-id="selectedNode?.nodeId || ''" :stages="onboardingStages" :copied="copied" @copy="copySha" @node="chooseNodeId" />
|
||||
</BoundedWorkspace>
|
||||
@@ -255,7 +271,7 @@ function text(value: unknown): string {
|
||||
</nav>
|
||||
</AsyncBoundary>
|
||||
|
||||
<BaseDrawer :open="Boolean(selectedDevice)" :title="selectedDevice?.name || 'HWPOD 详情'" description="Cloud API HWPOD owner 返回的 typed DTO。" @close="closeInspector">
|
||||
<BaseDrawer :open="Boolean(selectedDevice) && !workspaceMode" :title="selectedDevice?.name || 'HWPOD 详情'" description="HWPOD API 返回的 typed DTO。" @close="closeInspector">
|
||||
<template v-if="selectedDevice">
|
||||
<dl class="resource-details">
|
||||
<div><dt>身份</dt><dd>{{ selectedDevice.hwpodId }}</dd></div>
|
||||
@@ -271,6 +287,7 @@ function text(value: unknown): string {
|
||||
<div class="capability-list"><code v-for="capability in selectedDevice.missingCapabilities" :key="capability">{{ capability }}</code><span v-if="!selectedDevice.missingCapabilities.length">无</span></div>
|
||||
<h3>诊断摘要</h3>
|
||||
<p>{{ selectedDevice.diagnostic?.message || selectedDevice.blockers[0]?.summary || '无当前诊断' }}</p>
|
||||
<button class="workspace-command" type="button" @click="openWorkspace">打开工作区</button>
|
||||
</template>
|
||||
</BaseDrawer>
|
||||
|
||||
@@ -324,6 +341,7 @@ dt { color: #6d7a75; font-size: .68rem; font-weight: 800; text-transform: upperc
|
||||
dd { margin: 3px 0 0; overflow-wrap: anywhere; }
|
||||
.capability-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.capability-list code { padding: 5px 7px; border-radius: 5px; background: #fff3dd; color: #754813; }
|
||||
.workspace-command { width: 100%; min-height: 36px; margin-top: 18px; border: 1px solid var(--console-cyan-700); border-radius: var(--console-radius-sm); background: var(--console-cyan-100); color: var(--console-cyan-700); font-weight: 800; cursor: pointer; }
|
||||
.collection-pagination { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; color: var(--console-graphite-600); font-family: var(--console-font-mono); font-size: 11px; }
|
||||
.collection-pagination div { display: flex; gap: 6px; }
|
||||
.collection-pagination button { border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); color: var(--console-graphite-800); padding: 6px 10px; font-weight: 700; cursor: pointer; }
|
||||
|
||||
Reference in New Issue
Block a user