fix: structure hwpod download ops
This commit is contained in:
+15
-12
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user