feat: add HWPOD native workspace operations

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