fix: preserve keil sidecars for caserun downloads

This commit is contained in:
Codex Agent
2026-06-07 00:52:59 +08:00
parent 53aafde5bd
commit b22e4d25b8
5 changed files with 174 additions and 49 deletions
+17 -7
View File
@@ -38,11 +38,19 @@ test("hwlab-cli case prepare copies a case hwpod-spec into isolated run state",
assert.match(runLocalSpec, /node-d601-f103-v2/u);
assert.match(runLocalSpec, /path: "F:\\\\Work\\\\HWLAB-CASE-F103\\\\\.worktree\\\\caserun-run-test"/u);
assert.equal(requests.length, 1);
assert.equal(requests[0].body.ops.length, 3);
assert.equal(requests[0].body.ops.length, 4);
assert.equal(requests[0].body.ops[0].args.workspacePath, SUBJECT_REPO_LOCAL_PATH);
assert.deepEqual(requests[0].body.ops[0].args.argv, ["cat-file", "-e", SUBJECT_COMMIT_ID]);
assert.deepEqual(requests[0].body.ops[1].args.argv, ["worktree", "add", "--detach", "--force", ".worktree\\caserun-run-test", SUBJECT_COMMIT_ID]);
assert.deepEqual(requests[0].body.ops[2].args.argv, ["-C", ".worktree\\caserun-run-test", "rev-parse", "HEAD"]);
assert.equal(requests[0].body.ops[3].opId, "op_04_keil_sidecars");
assert.equal(requests[0].body.ops[3].args.command, "node");
const sidecarInput = JSON.parse(requests[0].body.ops[3].args.argv[2]);
assert.equal(sidecarInput.sourceBase, "projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103");
assert.equal(sidecarInput.destinationBase, ".worktree\\caserun-run-test\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103");
assert.equal(requests[0].body.ops[3].args.commandBinding.sourceBase, "projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103");
assert.equal(requests[0].body.ops[3].args.commandBinding.destinationBase, ".worktree\\caserun-run-test\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103");
assert.equal(result.payload.subject.setup.keilSidecars.copied, 1);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -700,18 +708,20 @@ async function writeJsonFile(file: string, value: any) {
}
function hwpodSpecText() {
return "kind: Hwpod\nmetadata:\n name: d601-f103-v2\nspec:\n nodeBinding:\n nodeId: node-d601-f103-v2\n workspace:\n path: F:\\\\Work\\\\HWLAB-CASE-F103\n targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n ioProbe:\n uart:\n port: COM9\n";
return "kind: Hwpod\nmetadata:\n name: d601-f103-v2\nspec:\n nodeBinding:\n nodeId: node-d601-f103-v2\n workspace:\n path: F:\\\\Work\\\\HWLAB-CASE-F103\n toolchain: keil-mdk\n keilProject: projects/01_baseline/Projects/MDK-ARM/atk_f103.uvprojx\n keilTarget: USART\n targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n programBackend: keil\n probeUid: 95FFF39D3DB47E0D\n ioProbe:\n uart:\n port: COM9\n";
}
function subjectWorktreeFetch(requests: any[]) {
return async (url: RequestInfo | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body ?? "{}"));
requests.push({ url: String(url), body });
return new Response(JSON.stringify({ body: { results: [
{ ok: true, output: { exitCode: 0, stdout: "" } },
{ ok: true, output: { exitCode: 0, stdout: "Preparing worktree\n" } },
{ ok: true, output: { exitCode: 0, stdout: `${SUBJECT_COMMIT_ID}\n` } }
] } }), { status: 200, headers: { "content-type": "application/json" } });
const results = (body.ops ?? []).map((op: any, index: number) => {
if (op.opId === "op_02_subject_worktree_add") return { ok: true, output: { exitCode: 0, stdout: "Preparing worktree\n" } };
if (op.opId === "op_03_subject_worktree_head") return { ok: true, output: { exitCode: 0, stdout: `${SUBJECT_COMMIT_ID}\n` } };
if (op.opId === "op_04_keil_sidecars") return { ok: true, output: { exitCode: 0, stdout: "caseRunKeilSidecars {\"copied\":1,\"sourceBase\":\"projects\\\\01_baseline\\\\Projects\\\\MDK-ARM\\\\atk_f103\",\"destinationBase\":\".worktree\\\\caserun-run-test\\\\projects\\\\01_baseline\\\\Projects\\\\MDK-ARM\\\\atk_f103\"}\n" } };
return { ok: true, output: { exitCode: 0, stdout: index === 0 ? "" : "" } };
});
return new Response(JSON.stringify({ body: { results } }), { status: 200, headers: { "content-type": "application/json" } });
};
}
+15 -10
View File
@@ -106,16 +106,21 @@ 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.deepEqual(download.payload.plan.ops.map((op: any) => op.op), ["cmd.run"]);
assert.equal(download.payload.plan.ops[0].args.step, "keil-flash");
assert.equal(download.payload.plan.ops[0].args.command, "py");
assert.deepEqual(download.payload.plan.ops[0].args.argv.slice(0, 5), ["-3", "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py", "flash", "-p", "F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103.uvprojx"]);
assert.deepEqual(download.payload.plan.ops[0].args.argv.slice(-8), ["-m", "daplink", "--program-backend", "keil", "-u", "3FD750C63E342E24", "-t", "USART"]);
assert.equal(download.payload.plan.ops[0].args.commandBinding.action, "download");
assert.equal(download.payload.plan.ops[0].args.commandBinding.keilCommand, "flash");
assert.equal(download.payload.plan.ops[0].args.commandBinding.programBackend, "keil");
assert.equal(download.payload.plan.ops[0].args.commandBinding.autoBindUvoptx, false);
assert.match(download.payload.plan.ops[0].args.commandLine, /^py -3 C:\\Users\\liang\\\.agents\\skills\\keil\\keil-cli\.py flash -p/u);
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-sidecar-diagnostics");
assert.equal(download.payload.plan.ops[0].args.command, "node");
assert.deepEqual(JSON.parse(download.payload.plan.ops[0].args.argv[2]), { primary: "F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103.uvoptx", alternate: "F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103.uvopt" });
assert.equal(download.payload.plan.ops[0].args.commandBinding.primary, "F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103.uvoptx");
assert.equal(download.payload.plan.ops[0].args.commandBinding.alternate, "F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103.uvopt");
assert.equal(download.payload.plan.ops[1].args.step, "keil-flash");
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", "flash", "-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.equal(download.payload.plan.ops[1].args.commandBinding.action, "download");
assert.equal(download.payload.plan.ops[1].args.commandBinding.keilCommand, "flash");
assert.equal(download.payload.plan.ops[1].args.commandBinding.programBackend, "keil");
assert.equal(download.payload.plan.ops[1].args.commandBinding.autoBindUvoptx, false);
assert.match(download.payload.plan.ops[1].args.commandLine, /^py -3 C:\\Users\\liang\\\.agents\\skills\\keil\\keil-cli\.py flash -p/u);
const jobStatus = await runHwpodCli(["job", "status", "keil_job_123", "--spec", specPath, "--dry-run"], { now: () => NOW });
assert.equal(jobStatus.exitCode, 0);
+118 -30
View File
@@ -1346,6 +1346,35 @@ function subjectFromDefinition(definition: Record<string, unknown>) {
async function requestSubjectWorktree(context: CaseContext, input: { caseId: string; runId: string; runDir: string; apiUrl: string; sourceSpecPath: string }, subject: { repoLocalPath: string; commitId: string; subdir: string }, worktreePath: string, relativeWorktreePath: string) {
const document = await readHwpodSpec(input.sourceSpecPath);
const hwpodId = text(document.metadata.name) || text(document.metadata.uid) || input.caseId;
const sidecarOp = keilSidecarCopyOp(document, subject, hwpodId, relativeWorktreePath);
const ops = [{
opId: "op_01_subject_commit",
op: "cmd.run",
args: {
hwpodId,
workspacePath: subject.repoLocalPath,
command: "git",
argv: ["cat-file", "-e", subject.commitId]
}
}, {
opId: "op_02_subject_worktree_add",
op: "cmd.run",
args: {
hwpodId,
workspacePath: subject.repoLocalPath,
command: "git",
argv: ["worktree", "add", "--detach", "--force", relativeWorktreePath, subject.commitId]
}
}, {
opId: "op_03_subject_worktree_head",
op: "cmd.run",
args: {
hwpodId,
workspacePath: subject.repoLocalPath,
command: "git",
argv: ["-C", relativeWorktreePath, "rev-parse", "HEAD"]
}
}, ...(sidecarOp ? [sidecarOp] : [])];
const plan = {
contractVersion: "hwpod-node-ops-v1",
planId: `case_subject_${randomUUID()}`,
@@ -1353,34 +1382,7 @@ async function requestSubjectWorktree(context: CaseContext, input: { caseId: str
nodeId: document.spec.nodeBinding.nodeId,
intent: "cmd.run",
source: { compiler: "hwlab-cli.case", specPath: input.sourceSpecPath, specAuthority: "case-registry" },
ops: [{
opId: "op_01_subject_commit",
op: "cmd.run",
args: {
hwpodId,
workspacePath: subject.repoLocalPath,
command: "git",
argv: ["cat-file", "-e", subject.commitId]
}
}, {
opId: "op_02_subject_worktree_add",
op: "cmd.run",
args: {
hwpodId,
workspacePath: subject.repoLocalPath,
command: "git",
argv: ["worktree", "add", "--detach", "--force", relativeWorktreePath, subject.commitId]
}
}, {
opId: "op_03_subject_worktree_head",
op: "cmd.run",
args: {
hwpodId,
workspacePath: subject.repoLocalPath,
command: "git",
argv: ["-C", relativeWorktreePath, "rev-parse", "HEAD"]
}
}]
ops
};
const response = await context.fetchImpl(`${input.apiUrl}/v1/hwpod-node-ops`, {
method: "POST",
@@ -1393,7 +1395,7 @@ async function requestSubjectWorktree(context: CaseContext, input: { caseId: str
if (!status.ok) {
throw cliError("subject_worktree_prepare_failed", "failed to prepare isolated subject worktree on the HWPOD node", clean({ httpStatus: response.status, nodeId: document.spec.nodeBinding.nodeId, repoLocalPath: subject.repoLocalPath, commitId: subject.commitId, worktreePath, exitCode: status.exitCode, stdout: clipText(status.stdout), stderr: clipText(status.stderr), body: compactObject(body) }));
}
return clean({ httpStatus: response.status, planId: plan.planId, nodeId: document.spec.nodeBinding.nodeId, exitCode: status.exitCode, stdout: clipText(status.stdout), stderr: clipText(status.stderr) });
return clean({ httpStatus: response.status, planId: plan.planId, nodeId: document.spec.nodeBinding.nodeId, exitCode: status.exitCode, stdout: clipText(status.stdout), stderr: clipText(status.stderr), keilSidecars: keilSidecarSummary(status.stdout) });
}
function subjectWorktreeStatus(httpStatus: number, body: any, expected: { commitId: string; worktreePath: string }) {
@@ -1403,11 +1405,97 @@ function subjectWorktreeStatus(httpStatus: number, body: any, expected: { commit
const stdout = outputs.map((output: any) => text(output.stdout)).filter(Boolean).join("\n");
const stderr = outputs.map((output: any) => text(output.stderr)).filter(Boolean).join("\n");
const actualHead = text(outputs[2]?.stdout).split(/\r?\n/u)[0]?.toLowerCase() ?? "";
const commandOk = httpStatus >= 200 && httpStatus < 300 && outputs.length === 3 && exitCodes.every((exitCode) => exitCode === 0);
const commandOk = httpStatus >= 200 && httpStatus < 300 && outputs.length >= 3 && exitCodes.every((exitCode) => exitCode === 0);
const ok = commandOk && actualHead === expected.commitId;
return { ok, exitCode: exitCodes.find((exitCode) => exitCode !== 0) ?? exitCodes[exitCodes.length - 1], stdout, stderr, actualHead, actualWorktreePath: expected.worktreePath };
}
function keilSidecarCopyOp(document: any, subject: { repoLocalPath: string }, hwpodId: string, relativeWorktreePath: string) {
const project = subjectRelativeKeilProject(document, subject.repoLocalPath);
if (!project) return null;
const sourceBase = stripUvprojx(project).replace(/\//gu, "\\");
const destinationBase = windowsJoin(relativeWorktreePath, sourceBase);
return {
opId: "op_04_keil_sidecars",
op: "cmd.run",
args: {
hwpodId,
workspacePath: subject.repoLocalPath,
command: "node",
argv: ["-e", keilSidecarCopyNodeScript(), JSON.stringify({ sourceBase, destinationBase })],
commandBinding: {
kind: "keil-mdk-sidecar-copy",
source: "hwlab-cli.case.prepare",
sourceBase,
destinationBase,
sidecars: [".uvoptx", ".uvopt"]
}
}
};
}
function subjectRelativeKeilProject(document: any, repoLocalPath: string) {
const workspace = objectRecord(document.spec?.workspace);
const projectWorkspace = objectRecord(document.spec?.projectWorkspace);
const rawProject = text(workspace.keilProject ?? workspace.projectPath ?? workspace.project ?? projectWorkspace.projectPath);
if (!rawProject || !/\.uvprojx$/iu.test(rawProject)) return "";
if (!isAbsolutePathLike(rawProject)) return rawProject.replace(/^[\\/]+/u, "");
const repo = repoLocalPath.replace(/[\\/]+$/u, "");
const normalizedRepo = normalizeLocalPath(repo);
const normalizedProject = normalizeLocalPath(rawProject);
if (!normalizedProject.startsWith(`${normalizedRepo}/`)) return "";
return rawProject.slice(repo.length).replace(/^[\\/]+/u, "");
}
function stripUvprojx(value: string) {
return value.replace(/\.uvprojx$/iu, "");
}
function windowsJoin(parent: string, child: string) {
const left = parent.replace(/[\\/]+$/u, "").replace(/\//gu, "\\");
const right = child.replace(/^[\\/]+/u, "").replace(/\//gu, "\\");
return `${left}\\${right}`;
}
function keilSidecarCopyNodeScript() {
return [
"const fs=require('fs');",
"const path=require('path');",
"const input=JSON.parse(process.argv[1]||'{}');",
"const sidecars=['.uvoptx','.uvopt'];",
"const copiedFiles=[];",
"const missing=[];",
"for(const ext of sidecars){",
"const source=String(input.sourceBase||'')+ext;",
"const destination=String(input.destinationBase||'')+ext;",
"if(fs.existsSync(source)){",
"const dir=destination.includes('\\\\')?path.win32.dirname(destination):path.dirname(destination);",
"fs.mkdirSync(dir,{recursive:true});",
"fs.copyFileSync(source,destination);",
"copiedFiles.push({ext,source,destination});",
"}else{missing.push(source);}",
"}",
"console.log('caseRunKeilSidecars '+JSON.stringify({copied:copiedFiles.length,copiedFiles,missing,sourceBase:input.sourceBase,destinationBase:input.destinationBase}));"
].join("");
}
function keilSidecarSummary(stdout: string) {
const line = stdout.split(/\r?\n/u).find((item) => item.includes("caseRunKeilSidecars"));
if (!line) return null;
const payload = line.replace(/^.*caseRunKeilSidecars\s*/u, "");
const parsed = parseJsonMaybe(payload);
if (parsed && typeof parsed === "object") return clean({ ...(parsed as Record<string, unknown>), line });
return clean({ copied: numberOption(line.match(/copied=(\d+)/u)?.[1]), line });
}
function objectRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function isAbsolutePathLike(value: string) {
return /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("/") || value.startsWith("\\\\");
}
function subjectRelativeWorktreePath(repoLocalPath: string, worktreePath: string) {
const repo = repoLocalPath.replace(/[\\/]+$/u, "");
const normalizedRepo = normalizeLocalPath(repo);
+23 -1
View File
@@ -531,6 +531,7 @@ function debugDownloadOps(common: any, args: any, document: any) {
op: "cmd.run",
args: clean({
...opArgs,
commandBinding: entry.commandBinding ?? opArgs.commandBinding,
command: entry.commandRun.command,
argv: entry.commandRun.argv,
commandLine: entry.command,
@@ -605,6 +606,7 @@ 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 === true || debugProbe.autoBindProbe === true;
const sidecarDiagnostics = keilSidecarDiagnosticRun(project);
const bindTokens = autoBindUvoptx && probeUid
? [...commonTokens, "project", "probe-binding", "set", "-p", project, ...projectTargetOption(target), "--probe-uid", probeUid, ...probeNameOption(probeName)]
: [];
@@ -622,6 +624,7 @@ function keilCommandForIntent(intent: "debug.build" | "debug.download", args: an
...(args.wait === true ? ["--wait"] : [])
];
const commandRuns = [
sidecarDiagnostics,
...(bindTokens.length > 0 ? [{ step: "keil-probe-binding", command: shellCommand(bindTokens), commandRun: commandRun(bindTokens) }] : []),
{ step: "keil-flash", command: shellCommand(flashTokens), commandRun: commandRun(flashTokens) }
];
@@ -630,10 +633,29 @@ function keilCommandForIntent(intent: "debug.build" | "debug.download", args: an
command: commandRuns.map((entry) => entry.command).join(" && "),
commandRuns,
timeoutMs,
binding: clean({ kind: "keil-mdk", source: "hwpod-compiler.keil-mdk", action: "download", keilCommand: "flash", project, target, keilCliPath, pythonCommand, programmer, programBackend, probeUid, probeName, autoBindUvoptx: bindTokens.length > 0, wait: args.wait === true })
binding: clean({ kind: "keil-mdk", source: "hwpod-compiler.keil-mdk", action: "download", keilCommand: "flash", project, target, keilCliPath, pythonCommand, programmer, programBackend, probeUid, probeName, autoBindUvoptx: bindTokens.length > 0, wait: args.wait === true, keilSidecars: sidecarDiagnostics.commandBinding })
};
}
function keilSidecarDiagnosticRun(project: string) {
const base = project.replace(/\.uvprojx$/iu, "");
const primary = `${base}.uvoptx`;
const alternate = `${base}.uvopt`;
const script = [
"const fs=require('fs');",
"const input=JSON.parse(process.argv[1]||'{}');",
"const primary=String(input.primary||'');",
"const alternate=String(input.alternate||'');",
"let result;",
"if(fs.existsSync(primary)){result={status:'present',primary};}",
"else if(fs.existsSync(alternate)){result={status:'present',alternate};}",
"else{result={status:'missing',primary,alternate,reason:'keil_uvoptx_binding_absent'};}",
"console.log('hwpodKeilSidecar '+JSON.stringify(result));"
].join("");
const run = { command: "node", argv: ["-e", script, JSON.stringify({ primary, alternate })] };
return { step: "keil-sidecar-diagnostics", command: shellCommand([run.command, ...run.argv]), commandRun: run, commandBinding: { primary, alternate } };
}
function keilJobStatusCommand(args: any, document: any) {
const workspace = objectValue(document.spec.workspace);
const debugProbe = objectValue(document.spec.debugProbe);