fix: structure hwpod download ops

This commit is contained in:
Codex Agent
2026-06-06 18:09:44 +08:00
parent b7d00dc106
commit 917b910bd0
5 changed files with 117 additions and 25 deletions
+4 -1
View File
@@ -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 <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。
+15 -12
View File
@@ -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 {
+30
View File
@@ -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",
+39 -9
View File
@@ -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 };
}
+29 -3
View File
@@ -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<string, unknown>) {
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;