fix: bind hwpod uart read
This commit is contained in:
@@ -137,7 +137,7 @@ Keil MDK 装配规则:当 `spec.workspace.toolchain` 为 `keil-mdk`、`keil`
|
||||
|
||||
Windows subject worktree 文本修改优先使用 `workspace.apply-patch`、`workspace.replace`、`workspace.insert-after` 或 `workspace.write`,不要把 `cmd.run` 的 PowerShell/cmd quoting 当成标准编辑路径。文本编辑结果必须返回编辑前后 SHA、文件字节数、换行类型、diff 摘要和 dry-run 状态;`apply-patch` 匹配失败时必须返回 normalized preview、候选行号、CRLF/LF 统计、文件 SHA/bytes 和 hwpod-node implementation version,便于判断是上下文不匹配、CRLF 问题还是远端实现版本问题。`workspace.apply-patch` 应保留原文件换行风格,尤其不能把 CRLF subject 文件静默改写成 LF。
|
||||
|
||||
UART op 在薄 `hwpod-node` 未配置本地工具绑定时必须返回结构化 blocker details,至少包含请求 port、从 `spec.ioProbe` 解析出的物理 port、workspacePath、平台、node version、接受的 spec/env 配置位置和 `supportedFallback=false`。这类结果是可诊断 blocker,不得静默伪造空读数,也不得要求 Agent 改走手写 PowerShell/串口脚本旁路。
|
||||
UART read 由薄 `hwpod-node` 绑定节点本地 `serial-monitor` CLI 执行。默认绑定路径是 `~/.agents/skills/serial-monitor`,也可通过 `HWPOD_SERIAL_MONITOR_DIR` / `HWPOD_SERIAL_MONITOR_COMMAND` 或 plan args 覆盖;`spec.ioProbe.uart.port` 和 `spec.ioProbe.uart.baudrate` 是物理串口和波特率权威。`io.uart.read` 只能在 `serial-monitor monitor status` 显示正在监控同一 COM/baud 时返回数据;未监控、串口不匹配或工具失败时必须返回结构化 blocker details,至少包含请求 port、解析出的物理 port/baud、workspacePath、平台、node version、monitor status、可用 ports 和启动建议。不得静默伪造空读数,也不得要求 Agent 改走手写 PowerShell/串口脚本旁路。
|
||||
|
||||
标准 plan 形态:
|
||||
|
||||
@@ -258,4 +258,4 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts case run logs <runId> --tail 8000
|
||||
8. `d601-f103-v2-compile` 的真实流程只覆盖当前编排链路和 compile-only smoke;操作员直接运行 `hwpod-cli build`、直接调用 Keil 或只检查 node 侧 job,不等同于 CaseRun 流程跑完。
|
||||
9. agent-task CaseRun 的真实流程必须证明 `case run` 已创建或调用 Code Agent / AgentRun session,任务 prompt 已进入 agent 上下文,CaseRun 已采集隔离 subject worktree diff,并已继续执行编译、下载或 I/O smoke 等后续动作;当前版本只记录这些事实,不自动判断 agent 是否完成任务。
|
||||
10. Windows subject worktree 文本编辑必须能在 CRLF 文件中通过 `workspace.insert-after` 或 `workspace.replace` 完成一行源码修改,返回 before/after SHA 与 diff 摘要,并保留原文件 CRLF;`workspace.apply-patch` 在 context 不匹配时必须返回可诊断 payload,而不是只给 `context not found`。
|
||||
11. Keil `debug.download` 自动装配必须输出结构化 `cmd.run` argv 步骤;带空格的 `probeName` 在 plan 中必须是单独 argv 元素。`io.uart.read` 未绑定时必须返回包含 `ioProbe`/COM 口/配置 hint 的结构化 blocker details。
|
||||
11. Keil `debug.download` 自动装配必须输出结构化 `cmd.run` argv 步骤;带空格的 `probeName` 在 plan 中必须是单独 argv 元素。`io.uart.read` 必须通过节点本地 `serial-monitor` 读取正在监控的 COM/baud;未监控或工具失败时必须返回包含 `ioProbe`/COM 口/baud/monitor status/启动建议的结构化 blocker details。
|
||||
|
||||
@@ -99,6 +99,13 @@ test("hwpod-node reports cmd.run non-zero exits as failed results", async () =>
|
||||
test("hwpod-node reports structured UART binding diagnostics", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-uart-"));
|
||||
try {
|
||||
const fixture = path.join(root, "serial-monitor-idle.mjs");
|
||||
await writeFile(fixture, `
|
||||
const args = process.argv.slice(2).join(" ");
|
||||
if (args === "monitor status") console.log(JSON.stringify({ action: "monitor_status", success: true, data: { isMonitoring: false, port: null, baudRate: null, lastPort: "COM9", lastBaudRate: 115200 } }));
|
||||
else if (args === "ports") console.log(JSON.stringify({ action: "list_ports", success: true, data: { ports: [{ path: "COM9" }] } }));
|
||||
else { console.error("unsupported " + args); process.exit(2); }
|
||||
`, "utf8");
|
||||
const result = await executeHwpodNodeOpsPlan({
|
||||
contractVersion: "hwpod-node-ops-v1",
|
||||
planId: "hwpod_plan_uart_diag",
|
||||
@@ -110,17 +117,63 @@ test("hwpod-node reports structured UART binding diagnostics", async () => {
|
||||
args: {
|
||||
workspacePath: root,
|
||||
port: "uart1",
|
||||
ioProbe: { uart: { id: "uart1", port: "COM9" } }
|
||||
serialMonitorDir: root,
|
||||
serialMonitorCommand: [process.execPath, fixture],
|
||||
ioProbe: { uart: { id: "uart1", port: "COM9", baudrate: 115200 } }
|
||||
}
|
||||
}]
|
||||
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.results[0].status, "blocked");
|
||||
assert.equal(result.results[0].blocker.code, "hwpod_node_op_not_configured");
|
||||
assert.equal(result.results[0].blocker.code, "hwpod_uart_monitor_not_active");
|
||||
assert.equal(result.results[0].blocker.details.requestedPort, "uart1");
|
||||
assert.equal(result.results[0].blocker.details.resolvedPort, "COM9");
|
||||
assert.deepEqual(result.results[0].blocker.details.requiredBinding.nodeEnvHints, ["HWPOD_UART_TOOL", "HWPOD_UART_PORT"]);
|
||||
assert.equal(result.results[0].blocker.details.baudRate, 115200);
|
||||
assert.equal(result.results[0].blocker.details.serialMonitor.monitorStatus.data.isMonitoring, false);
|
||||
assert.match(result.results[0].blocker.details.serialMonitor.startCommand, /monitor start -p COM9 -b 115200/u);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("hwpod-node reads UART through serial-monitor binding", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-uart-active-"));
|
||||
try {
|
||||
const fixture = path.join(root, "serial-monitor-fixture.mjs");
|
||||
await writeFile(fixture, `
|
||||
const args = process.argv.slice(2).join(" ");
|
||||
if (args === "monitor status") console.log(JSON.stringify({ action: "monitor_status", success: true, data: { isMonitoring: true, port: "COM9", baudRate: 115200, startTime: "2026-06-06T10:00:00Z" } }));
|
||||
else if (args === "ports") console.log(JSON.stringify({ action: "list_ports", success: true, data: { ports: [{ path: "COM9" }] } }));
|
||||
else if (args.startsWith("fetch")) console.log(JSON.stringify({ action: "fetch_data", success: true, data: [{ timestamp: "2026-06-06T10:00:01Z", port: "COM9", data: "UART OK" }], count: 1, totalCount: 1, hasMore: false, truncated: false, sourceFile: "fixture.jsonl" }));
|
||||
else { console.error("unsupported " + args); process.exit(2); }
|
||||
`, "utf8");
|
||||
|
||||
const result = await executeHwpodNodeOpsPlan({
|
||||
contractVersion: "hwpod-node-ops-v1",
|
||||
planId: "hwpod_plan_uart_read",
|
||||
hwpodId: "hwpod-local",
|
||||
nodeId: "pc-host-1",
|
||||
ops: [{
|
||||
opId: "op_uart",
|
||||
op: "io.uart.read",
|
||||
args: {
|
||||
workspacePath: root,
|
||||
port: "uart1",
|
||||
maxBytes: 128,
|
||||
serialMonitorDir: root,
|
||||
serialMonitorCommand: [process.execPath, fixture],
|
||||
ioProbe: { uart: { id: "uart1", port: "COM9", baudrate: 115200 } }
|
||||
}
|
||||
}]
|
||||
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.results[0].status, "completed");
|
||||
assert.equal(result.results[0].output.bindingSource, "serial-monitor-cli");
|
||||
assert.equal(result.results[0].output.resolvedPort, "COM9");
|
||||
assert.equal(result.results[0].output.text, "UART OK");
|
||||
assert.deepEqual(result.results[0].output.command.slice(0, 2), [process.execPath, fixture]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export function connectHwpodNodeWs(config: any, options: any = {}) {
|
||||
type: "register",
|
||||
nodeId,
|
||||
name: text(config.name) || nodeId,
|
||||
capabilities: ["hwpod-node-ops", "cmd.run", "workspace.ls", "workspace.cat", "workspace.rg", "workspace.apply-patch", "workspace.write", "workspace.replace", "workspace.insert-after", "debug.build", "debug.download", "debug.reset"],
|
||||
capabilities: ["hwpod-node-ops", "cmd.run", "workspace.ls", "workspace.cat", "workspace.rg", "workspace.apply-patch", "workspace.write", "workspace.replace", "workspace.insert-after", "debug.build", "debug.download", "debug.reset", "io.uart.read"],
|
||||
labels: { platform: process.platform, arch: process.arch, hostname: os.hostname(), version: NODE_VERSION, startedAt: now() }
|
||||
});
|
||||
sendHeartbeat();
|
||||
@@ -281,7 +281,11 @@ async function executeOp(op: any, context: any) {
|
||||
const output = await debugCommand(name, args);
|
||||
return output.ok ? opOk(opId, name, output) : opFailed(opId, name, output, "hwpod_node_command_failed", `${name} node-side command exited with ${output.exitCode}`);
|
||||
}
|
||||
if (["io.uart.read", "io.uart.write", "io.uart.jsonrpc"].includes(name)) {
|
||||
if (name === "io.uart.read") {
|
||||
const output = await uartRead(args);
|
||||
return output.ok ? opOk(opId, name, output) : opBlocked(opId, name, output.blockerCode, output.summary, output.details);
|
||||
}
|
||||
if (["io.uart.write", "io.uart.jsonrpc"].includes(name)) {
|
||||
return opBlocked(opId, name, "hwpod_node_op_not_configured", `${name} requires node-side UART tool binding on this hwpod-node`, uartBindingDiagnostics(name, args));
|
||||
}
|
||||
return opBlocked(opId, name, "unsupported_hwpod_node_op", `unsupported hwpod-node op: ${name}`);
|
||||
@@ -655,6 +659,87 @@ async function cmdRun(args: any) {
|
||||
return { cwd, command: [command, ...argv], ...output };
|
||||
}
|
||||
|
||||
async function uartRead(args: any) {
|
||||
const diagnostics = uartBindingDiagnostics("io.uart.read", args);
|
||||
const serialMonitorDir = text(args.serialMonitorDir) || text(process.env.HWPOD_SERIAL_MONITOR_DIR) || path.join(os.homedir(), ".agents", "skills", "serial-monitor");
|
||||
const commandBase = serialMonitorCommandBase(args);
|
||||
const timeoutMs = numberValue(args.timeoutMs) ?? 10000;
|
||||
const status = await runSerialMonitor(commandBase, ["monitor", "status"], { cwd: serialMonitorDir, timeoutMs })
|
||||
.catch((error) => ({ ok: false, stdout: "", stderr: error?.message || String(error), exitCode: null, command: [...commandBase, "monitor", "status"] }));
|
||||
const statusBody = parseJsonMaybe(status.stdout);
|
||||
const statusData = objectValue(statusBody?.data);
|
||||
const targetPort = text(diagnostics.resolvedPort) || text(diagnostics.requestedPort);
|
||||
const targetBaudRate = numberValue(diagnostics.baudRate);
|
||||
const activePort = text(statusData.port);
|
||||
const activeBaudRate = numberValue(statusData.baudRate);
|
||||
const monitoringMatches = status.ok && statusBody?.success === true && statusData.isMonitoring === true
|
||||
&& (!targetPort || activePort.toLowerCase() === targetPort.toLowerCase())
|
||||
&& (!targetBaudRate || activeBaudRate === targetBaudRate);
|
||||
if (!monitoringMatches) {
|
||||
const ports = await runSerialMonitor(commandBase, ["ports"], { cwd: serialMonitorDir, timeoutMs }).catch((error) => ({ ok: false, stdout: "", stderr: error?.message || String(error), exitCode: null, command: [] }));
|
||||
return {
|
||||
ok: false,
|
||||
blockerCode: "hwpod_uart_monitor_not_active",
|
||||
summary: `io.uart.read requires serial-monitor to be active on ${targetPort || "the requested UART"}${targetBaudRate ? `/${targetBaudRate}` : ""}`,
|
||||
details: {
|
||||
...diagnostics,
|
||||
serialMonitor: {
|
||||
dir: serialMonitorDir,
|
||||
command: commandBase,
|
||||
monitorStatus: statusBody ?? { parseOk: false, stdout: status.stdout, stderr: status.stderr, exitCode: status.exitCode },
|
||||
ports: parseJsonMaybe(ports.stdout) ?? { parseOk: false, stdout: ports.stdout, stderr: ports.stderr, exitCode: ports.exitCode },
|
||||
startCommand: `cd ${serialMonitorDir} && bun scripts/serial-monitor-cli.ts monitor start -p ${targetPort || "<PORT>"}${targetBaudRate ? ` -b ${targetBaudRate}` : ""}`
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
const limit = Math.max(1, Math.min(numberValue(args.limit) ?? Math.ceil((numberValue(args.maxBytes) ?? 4096) / 80), 200));
|
||||
const fetchArgs = ["fetch", "-l", String(limit), "--session-only", ...(text(args.since) ? ["-s", text(args.since)] : [])];
|
||||
const fetch = await runSerialMonitor(commandBase, fetchArgs, { cwd: serialMonitorDir, timeoutMs });
|
||||
const fetchBody = parseJsonMaybe(fetch.stdout);
|
||||
if (!fetch.ok || fetchBody?.success !== true) {
|
||||
return {
|
||||
ok: false,
|
||||
blockerCode: "hwpod_uart_read_failed",
|
||||
summary: "serial-monitor fetch failed for io.uart.read",
|
||||
details: { ...diagnostics, serialMonitor: { dir: serialMonitorDir, command: commandBase, fetch: fetchBody ?? { parseOk: false, stdout: fetch.stdout, stderr: fetch.stderr, exitCode: fetch.exitCode } } }
|
||||
};
|
||||
}
|
||||
const rows = Array.isArray(fetchBody.data) ? fetchBody.data : [];
|
||||
const maxBytes = numberValue(args.maxBytes) ?? 4096;
|
||||
const joined = rows.map((row: any) => text(row?.data)).filter(Boolean).join("\n");
|
||||
const truncatedText = joined.length > maxBytes ? joined.slice(0, maxBytes) : joined;
|
||||
return {
|
||||
ok: true,
|
||||
bindingSource: "serial-monitor-cli",
|
||||
serialMonitorDir,
|
||||
requestedPort: diagnostics.requestedPort,
|
||||
resolvedPort: targetPort,
|
||||
baudRate: targetBaudRate || activeBaudRate || null,
|
||||
command: [...commandBase, ...fetchArgs],
|
||||
data: rows,
|
||||
count: fetchBody.count ?? rows.length,
|
||||
totalCount: fetchBody.totalCount ?? null,
|
||||
hasMore: fetchBody.hasMore ?? false,
|
||||
text: truncatedText,
|
||||
truncated: joined.length > maxBytes || fetchBody.truncated === true,
|
||||
monitorStatus: statusBody,
|
||||
sourceFile: fetchBody.sourceFile ?? null
|
||||
};
|
||||
}
|
||||
|
||||
async function runSerialMonitor(commandBase: string[], argv: string[], { cwd, timeoutMs }: any) {
|
||||
const output = await spawnOutput([...commandBase, ...argv], { cwd, timeoutMs });
|
||||
return { command: [...commandBase, ...argv], ...output };
|
||||
}
|
||||
|
||||
function serialMonitorCommandBase(args: any) {
|
||||
if (Array.isArray(args.serialMonitorCommand) && args.serialMonitorCommand.length > 0) return args.serialMonitorCommand.map(String);
|
||||
const configured = text(process.env.HWPOD_SERIAL_MONITOR_COMMAND);
|
||||
if (configured) return configured.split(/\s+/u).filter(Boolean);
|
||||
return ["bun", "scripts/serial-monitor-cli.ts"];
|
||||
}
|
||||
|
||||
async function spawnShellOutput(command: string, { cwd, timeoutMs }: any) {
|
||||
const shellCommand = process.platform === "win32" ? ["cmd.exe", "/d", "/s", "/c", command] : ["sh", "-lc", command];
|
||||
const output = await spawnOutput(shellCommand, { cwd, timeoutMs });
|
||||
@@ -809,10 +894,12 @@ function uartBindingDiagnostics(op: string, args: any) {
|
||||
const uart = objectValue(ioProbe.uart);
|
||||
const requestedPort = text(args.port) || text(uart.id) || "uart1";
|
||||
const devicePort = text(uart.port) || text(uart.path) || text(ioProbe.port);
|
||||
const baudRate = numberValue(args.baudRate ?? args.baudrate ?? uart.baudRate ?? uart.baudrate ?? ioProbe.baudRate ?? ioProbe.baudrate);
|
||||
return {
|
||||
op,
|
||||
requestedPort,
|
||||
resolvedPort: devicePort || requestedPort,
|
||||
baudRate: baudRate ?? null,
|
||||
workspacePath: text(args.workspacePath) || null,
|
||||
ioProbe,
|
||||
platform: process.platform,
|
||||
@@ -820,12 +907,20 @@ function uartBindingDiagnostics(op: string, args: any) {
|
||||
requiredBinding: {
|
||||
kind: "uart-tool",
|
||||
acceptedSpecPaths: ["spec.ioProbe.uart.port", "spec.ioProbe.port"],
|
||||
nodeEnvHints: ["HWPOD_UART_TOOL", "HWPOD_UART_PORT"]
|
||||
nodeEnvHints: ["HWPOD_SERIAL_MONITOR_DIR", "HWPOD_SERIAL_MONITOR_COMMAND", "HWPOD_UART_PORT"]
|
||||
},
|
||||
supportedFallback: false
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonMaybe(value: string) {
|
||||
try {
|
||||
return JSON.parse(String(value ?? "").trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptions(argv: string[]): ParsedArgs {
|
||||
const parsed: ParsedArgs = { _: [] };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
|
||||
Reference in New Issue
Block a user