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
+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;