diff --git a/docs/reference/spec-hwpod-harness.md b/docs/reference/spec-hwpod-harness.md index 0204dde0..3b7bd072 100644 --- a/docs/reference/spec-hwpod-harness.md +++ b/docs/reference/spec-hwpod-harness.md @@ -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 --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。 diff --git a/tools/hwpod-node.test.ts b/tools/hwpod-node.test.ts index 65788e3f..e87f267e 100644 --- a/tools/hwpod-node.test.ts +++ b/tools/hwpod-node.test.ts @@ -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 }); } diff --git a/tools/src/hwpod-node-lib.ts b/tools/src/hwpod-node-lib.ts index 336a62b7..5d400039 100644 --- a/tools/src/hwpod-node-lib.ts +++ b/tools/src/hwpod-node-lib.ts @@ -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 || ""}${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) {