From 917b910bd0ef62c60a8611a0eaaac224dacdc33b Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sat, 6 Jun 2026 18:09:44 +0800 Subject: [PATCH] fix: structure hwpod download ops --- docs/reference/spec-hwpod-harness.md | 5 ++- tools/hwpod-harness.test.ts | 27 +++++++++------- tools/hwpod-node.test.ts | 30 +++++++++++++++++ tools/src/hwpod-harness-lib.ts | 48 ++++++++++++++++++++++------ tools/src/hwpod-node-lib.ts | 32 +++++++++++++++++-- 5 files changed, 117 insertions(+), 25 deletions(-) diff --git a/docs/reference/spec-hwpod-harness.md b/docs/reference/spec-hwpod-harness.md index 6f30a29e..0204dde0 100644 --- a/docs/reference/spec-hwpod-harness.md +++ b/docs/reference/spec-hwpod-harness.md @@ -105,7 +105,7 @@ spec: - `spec.nodeBinding.nodeId` 必须存在。 - `spec.workspace.path` 必须存在。 -Keil MDK 装配规则:当 `spec.workspace.toolchain` 为 `keil-mdk`、`keil`、`mdk` 或 `uv4` 时,`hwpod-compiler-cli` 可以直接从 `hwpod-spec` 生成 `debug.build` / `debug.download` 的 node-side command。`spec.workspace.keilProject` 或 `spec.workspace.projectPath` 指向 `.uvprojx`,相对路径按 `spec.workspace.path` 解析;`spec.workspace.keilTarget` 或 `spec.workspace.targetName` 指向 Keil target。`spec.debugProbe.probeUid` 用于固定 DAP-Link/CMSIS-DAP 选择,`spec.debugProbe.programBackend` 默认按 Keil UV4 工程链路下载。下载前若存在 `probeUid`,compiler 会先生成 Keil probe-binding 写回命令,再生成 `program --program-backend keil` 命令,以保持旧 host profile 中 probe 绑定与 Keil 下载语义在新 hwpod 概念系统内收敛。 +Keil MDK 装配规则:当 `spec.workspace.toolchain` 为 `keil-mdk`、`keil`、`mdk` 或 `uv4` 时,`hwpod-compiler-cli` 可以直接从 `hwpod-spec` 生成 `debug.build` / `debug.download` 的 node-side command。`spec.workspace.keilProject` 或 `spec.workspace.projectPath` 指向 `.uvprojx`,相对路径按 `spec.workspace.path` 解析;`spec.workspace.keilTarget` 或 `spec.workspace.targetName` 指向 Keil target。`spec.debugProbe.probeUid` 用于固定 DAP-Link/CMSIS-DAP 选择,`spec.debugProbe.programBackend` 默认按 Keil UV4 工程链路下载。下载前若存在 `probeUid`,compiler 会先生成 Keil probe-binding 写回命令,再生成 `program --program-backend keil` 命令,以保持旧 host profile 中 probe 绑定与 Keil 下载语义在新 hwpod 概念系统内收敛。自动生成的 Keil 下载计划必须拆成独立 `cmd.run` 步骤,并使用 `command` + `argv` 传递参数;`probeName`、Windows 路径和 target 名称必须作为独立 argv 元素,不得依赖 `cmd.exe /c`、PowerShell 或 `&&` 拼接来保真带空格参数。 `spec.workspace.buildCommand`、`spec.debugProbe.downloadCommand` 和 `spec.debugProbe.resetCommand` 仍是调试覆盖出口;标准 case 和 Code Agent runner 不应依赖这些字段长期手写 Keil 主流程。旧 `device-host-cli` / device pod profile 不再作为 compiler 输入权威,相关工程、target、probe 和下载 backend 信息必须迁入 `hwpod-spec`。 @@ -137,6 +137,8 @@ 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/串口脚本旁路。 + 标准 plan 形态: ```json @@ -256,3 +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。 diff --git a/tools/hwpod-harness.test.ts b/tools/hwpod-harness.test.ts index 44fe9fda..16ec8aaf 100644 --- a/tools/hwpod-harness.test.ts +++ b/tools/hwpod-harness.test.ts @@ -95,11 +95,9 @@ test("hwpod-compiler-cli generates Keil build and download commands from structu assert.equal(build.exitCode, 0); assert.equal(build.payload.plan.ops[0].op, "cmd.run"); assert.equal(build.payload.plan.ops[0].args.target, "USART"); - assert.equal(build.payload.plan.ops[0].args.command, "cmd.exe"); - assert.deepEqual(build.payload.plan.ops[0].args.argv.slice(0, 3), ["/d", "/s", "/c"]); - assert.match(build.payload.plan.ops[0].args.argv[3], /^py -3 C:\\Users\\liang\\\.agents\\skills\\keil\\keil-cli\.py build -p/u); - assert.match(build.payload.plan.ops[0].args.argv[3], /F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103\.uvprojx/u); - assert.match(build.payload.plan.ops[0].args.argv[3], / -t USART$/u); + assert.equal(build.payload.plan.ops[0].args.command, "py"); + assert.deepEqual(build.payload.plan.ops[0].args.argv.slice(0, 5), ["-3", "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py", "build", "-p", "F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103.uvprojx"]); + assert.deepEqual(build.payload.plan.ops[0].args.argv.slice(-2), ["-t", "USART"]); assert.match(build.payload.plan.ops[0].args.commandLine, /^py -3 C:\\Users\\liang\\\.agents\\skills\\keil\\keil-cli\.py build -p/u); assert.match(build.payload.plan.ops[0].args.commandLine, /F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103\.uvprojx/u); assert.match(build.payload.plan.ops[0].args.commandLine, / -t USART$/u); @@ -108,19 +106,24 @@ test("hwpod-compiler-cli generates Keil build and download commands from structu const download = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.download"], { now: () => NOW }); assert.equal(download.exitCode, 0); - assert.equal(download.payload.plan.ops[0].op, "debug.download"); + assert.deepEqual(download.payload.plan.ops.map((op: any) => op.op), ["cmd.run", "cmd.run"]); + assert.equal(download.payload.plan.ops[0].args.step, "keil-probe-binding"); + assert.equal(download.payload.plan.ops[0].args.command, "py"); + assert.deepEqual(download.payload.plan.ops[0].args.argv.slice(0, 6), ["-3", "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py", "project", "probe-binding", "set", "-p"]); + assert.equal(download.payload.plan.ops[0].args.argv[download.payload.plan.ops[0].args.argv.indexOf("--probe-name") + 1], "CherryUSB MicroLink CMSIS-DAP"); + assert.equal(download.payload.plan.ops[0].args.argv[download.payload.plan.ops[0].args.argv.indexOf("--probe-uid") + 1], "3FD750C63E342E24"); assert.equal(download.payload.plan.ops[0].args.commandBinding.action, "download"); assert.equal(download.payload.plan.ops[0].args.commandBinding.programBackend, "keil"); - assert.match(download.payload.plan.ops[0].args.command, /project probe-binding set -p/u); - assert.match(download.payload.plan.ops[0].args.command, / --target USART /u); - assert.match(download.payload.plan.ops[0].args.command, /--probe-uid 3FD750C63E342E24/u); - assert.match(download.payload.plan.ops[0].args.command, /--probe-name "CherryUSB MicroLink CMSIS-DAP"/u); - assert.match(download.payload.plan.ops[0].args.command, / && py -3 .*keil-cli\.py program -p/u); - assert.match(download.payload.plan.ops[0].args.command, / -m daplink --program-backend keil -u 3FD750C63E342E24 -t USART$/u); + assert.equal(download.payload.plan.ops[1].args.step, "keil-program"); + assert.equal(download.payload.plan.ops[1].args.command, "py"); + assert.deepEqual(download.payload.plan.ops[1].args.argv.slice(0, 5), ["-3", "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py", "program", "-p", "F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103.uvprojx"]); + assert.deepEqual(download.payload.plan.ops[1].args.argv.slice(-8), ["-m", "daplink", "--program-backend", "keil", "-u", "3FD750C63E342E24", "-t", "USART"]); + assert.match(download.payload.plan.ops[0].args.commandLine, /--probe-name "CherryUSB MicroLink CMSIS-DAP"/u); await runHwpodCtl(["spec", "set", "spec.debugProbe.downloadCommand", "custom download command", "--spec", specPath], { now: () => NOW }); const override = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.download"], { now: () => NOW }); assert.equal(override.exitCode, 0); + assert.equal(override.payload.plan.ops[0].op, "debug.download"); assert.equal(override.payload.plan.ops[0].args.command, "custom download command"); assert.equal(override.payload.plan.ops[0].args.commandBinding, undefined); } finally { diff --git a/tools/hwpod-node.test.ts b/tools/hwpod-node.test.ts index a1856e68..65788e3f 100644 --- a/tools/hwpod-node.test.ts +++ b/tools/hwpod-node.test.ts @@ -96,6 +96,36 @@ 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 result = await executeHwpodNodeOpsPlan({ + contractVersion: "hwpod-node-ops-v1", + planId: "hwpod_plan_uart_diag", + hwpodId: "hwpod-local", + nodeId: "pc-host-1", + ops: [{ + opId: "op_uart", + op: "io.uart.read", + args: { + workspacePath: root, + port: "uart1", + ioProbe: { uart: { id: "uart1", port: "COM9" } } + } + }] + }, { 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.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"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("hwpod-node resolves Git for Windows when service PATH omits git", async () => { const result = await resolveHwpodNodeCommand("git", { platform: "win32", diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index ba9d4e00..fe7397c4 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -366,7 +366,7 @@ function opsForIntent(intent: string, args: any, document: any) { if (intent === "workspace.replace") return [{ op: "workspace.replace", args: cleanTextEditArgs({ ...common, path: requiredText(args.path, "path"), find: requiredRawString(args.find, "find"), replace: rawString(args.replace), expectedSha: text(args.expectedSha), all: args.all === true, dryRun: args.dryRun === true, reason: text(args.reason) }) }]; if (intent === "workspace.insert-after") return [{ op: "workspace.insert-after", args: cleanTextEditArgs({ ...common, path: requiredText(args.path, "path"), anchor: requiredRawString(args.anchor, "anchor"), line: requiredRawString(args.line, "line"), expectedSha: text(args.expectedSha), allowMultiple: args.allowMultiple === true, dryRun: args.dryRun === true, reason: text(args.reason) }) }]; if (intent === "debug.build") return debugBuildOps(common, args, document); - if (intent === "debug.download") return [{ op: "debug.download", args: debugDownloadArgs(common, args, document) }]; + if (intent === "debug.download") return debugDownloadOps(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.read") return [{ op: "io.uart.read", args: clean({ ...common, port: text(args.port) || "uart1", maxBytes: numberValue(args.maxBytes), since: text(args.since) }) }]; if (intent === "io.uart.write") return [{ op: "io.uart.write", args: clean({ ...common, port: text(args.port) || "uart1", data: requiredText(args.data, "data") }) }]; @@ -415,6 +415,32 @@ function debugDownloadArgs(common: any, args: any, document: any) { }); } +function debugDownloadOps(common: any, args: any, document: any) { + const explicitCommand = text(args.command) || text(document.spec.debugProbe.downloadCommand); + const generated = explicitCommand ? null : keilCommandForIntent("debug.download", args, document); + const opArgs = clean({ + ...common, + artifact: text(args.artifact), + target: text(args.target) || generated?.target, + commandBinding: generated?.binding, + timeoutMs: numberValue(args.timeoutMs) ?? generated?.timeoutMs, + reason: text(args.reason) + }); + if (generated?.commandRuns?.length) { + return generated.commandRuns.map((entry: any) => ({ + op: "cmd.run", + args: clean({ + ...opArgs, + command: entry.commandRun.command, + argv: entry.commandRun.argv, + commandLine: entry.command, + step: entry.step + }) + })); + } + return [{ op: "debug.download", args: debugDownloadArgs(common, args, document) }]; +} + function keilCommandForIntent(intent: "debug.build" | "debug.download", args: any, document: any) { const workspace = objectValue(document.spec.workspace); const debugProbe = objectValue(document.spec.debugProbe); @@ -443,10 +469,10 @@ function keilCommandForIntent(intent: "debug.build" | "debug.download", args: an const programBackend = text(args.programBackend ?? args.backend) || text(debugProbe.programBackend) || (text(debugProbe.adapter).toLowerCase() === "keil" ? "keil" : "keil"); const probeName = text(args.probeName) || text(debugProbe.probeName) || text(debugProbe.name); const autoBindUvoptx = debugProbe.autoBindUvoptx !== false && debugProbe.autoBindProbe !== false; - const bindCommand = autoBindUvoptx && probeUid - ? shellCommand([...commonTokens, "project", "probe-binding", "set", "-p", project, ...projectTargetOption(target), "--probe-uid", probeUid, ...probeNameOption(probeName)]) - : ""; - const programCommand = shellCommand([ + const bindTokens = autoBindUvoptx && probeUid + ? [...commonTokens, "project", "probe-binding", "set", "-p", project, ...projectTargetOption(target), "--probe-uid", probeUid, ...probeNameOption(probeName)] + : []; + const programTokens = [ ...commonTokens, "program", "-p", @@ -457,12 +483,17 @@ function keilCommandForIntent(intent: "debug.build" | "debug.download", args: an programBackend, ...probeOption(probeUid), ...targetOption(target) - ]); + ]; + const commandRuns = [ + ...(bindTokens.length > 0 ? [{ step: "keil-probe-binding", command: shellCommand(bindTokens), commandRun: commandRun(bindTokens) }] : []), + { step: "keil-program", command: shellCommand(programTokens), commandRun: commandRun(programTokens) } + ]; return { target, - command: bindCommand ? `${bindCommand} && ${programCommand}` : programCommand, + command: commandRuns.map((entry) => entry.command).join(" && "), + commandRuns, timeoutMs, - binding: clean({ kind: "keil-mdk", source: "hwpod-compiler.keil-mdk", action: "download", project, target, keilCliPath, pythonCommand, programmer, programBackend, probeUid, probeName, autoBindUvoptx: Boolean(bindCommand) }) + binding: clean({ kind: "keil-mdk", source: "hwpod-compiler.keil-mdk", action: "download", project, target, keilCliPath, pythonCommand, programmer, programBackend, probeUid, probeName, autoBindUvoptx: bindTokens.length > 0 }) }; } @@ -519,7 +550,6 @@ function shellCommand(tokens: string[]) { } function commandRun(tokens: string[]) { - if (tokens.some(isAbsoluteLikePath)) return { command: "cmd.exe", argv: ["/d", "/s", "/c", shellCommand(tokens)] }; const [command, ...argv] = tokens; return { command, argv }; } diff --git a/tools/src/hwpod-node-lib.ts b/tools/src/hwpod-node-lib.ts index 25899d50..336a62b7 100644 --- a/tools/src/hwpod-node-lib.ts +++ b/tools/src/hwpod-node-lib.ts @@ -282,7 +282,7 @@ async function executeOp(op: any, context: any) { 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)) { - return opBlocked(opId, name, "hwpod_node_op_not_configured", `${name} requires node-side tool binding; the thin node contract is present but this local executor has no binding yet`); + 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}`); } catch (error) { @@ -788,8 +788,8 @@ function opOk(opId: string, op: string, output: any) { return { opId, op, ok: true, status: "completed", output }; } -function opBlocked(opId: string, op: string, code: string, summary: string) { - return { opId, op, ok: false, status: "blocked", blocker: { code, layer: "hwpod-node", retryable: true, summary } }; +function opBlocked(opId: string, op: string, code: string, summary: string, details?: Record) { + return { opId, op, ok: false, status: "blocked", blocker: { code, layer: "hwpod-node", retryable: true, summary, details: details ?? undefined } }; } function opBlockedFromError(opId: string, op: string, error: any) { @@ -804,6 +804,28 @@ function failure(action: string, error: any) { return { ok: false, action, status: "failed", error: { code: error?.code || "hwpod_node_error", message: error?.message || String(error), details: error?.details || undefined } }; } +function uartBindingDiagnostics(op: string, args: any) { + const ioProbe = objectValue(args.ioProbe); + 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); + return { + op, + requestedPort, + resolvedPort: devicePort || requestedPort, + workspacePath: text(args.workspacePath) || null, + ioProbe, + platform: process.platform, + nodeVersion: NODE_VERSION, + requiredBinding: { + kind: "uart-tool", + acceptedSpecPaths: ["spec.ioProbe.uart.port", "spec.ioProbe.port"], + nodeEnvHints: ["HWPOD_UART_TOOL", "HWPOD_UART_PORT"] + }, + supportedFallback: false + }; +} + function parseOptions(argv: string[]): ParsedArgs { const parsed: ParsedArgs = { _: [] }; for (let index = 0; index < argv.length; index += 1) { @@ -878,6 +900,10 @@ function text(value: unknown) { return typeof value === "string" && value.trim() ? value.trim() : ""; } +function objectValue(value: any) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + function numberValue(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isInteger(parsed) ? parsed : undefined;