From 4a4aa1bc29463c08ce4920106b7ade942c30db23 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sat, 6 Jun 2026 10:47:35 +0800 Subject: [PATCH] feat: isolate CaseRun subject worktrees --- docs/reference/spec-hwpod-harness.md | 20 ++- tools/hwlab-cli/caserun.test.ts | 71 +++++++++-- tools/hwpod-harness.test.ts | 13 +- tools/hwpod-node.test.ts | 21 ++++ tools/src/hwlab-caserun-lib.ts | 181 ++++++++++++++++++++++++++- tools/src/hwpod-harness-lib.ts | 22 +++- tools/src/hwpod-node-lib.ts | 5 +- 7 files changed, 304 insertions(+), 29 deletions(-) diff --git a/docs/reference/spec-hwpod-harness.md b/docs/reference/spec-hwpod-harness.md index 4b1045e1..77b8120d 100644 --- a/docs/reference/spec-hwpod-harness.md +++ b/docs/reference/spec-hwpod-harness.md @@ -177,9 +177,21 @@ bun tools/hwpod-node.ts serve --host 127.0.0.1 --port 19678 ## CaseRun 无服务阶段 -第一阶段 CaseRun 不引入常驻调度服务,由 `hwlab-cli case` 组合 case registry repo、本地 `.state`、`hwpod-cli`、`hwpod-compiler-cli`、`hwlab-api` 和在线 `hwpod-node` 完成单次运行。标准 case registry repo 是 `pikasTech/hwlab-case-registry`,本地默认 checkout 路径是 `/root/hwlab-case-registry`;历史 `pikasTech/hwpod-cases` 只作为迁移前来源。case registry repo 提供 `cases//case.json` 与同目录 `hwpod-spec.yaml`;每次运行会把 spec 复制到 `.state/hwlab-cli/caserun//.hwlab/hwpod-spec.yaml`,形成隔离的 run-local spec。 +第一阶段 CaseRun 不引入常驻调度服务,由 `hwlab-cli case` 组合 case registry repo、本地 `.state`、`hwpod-cli`、`hwpod-compiler-cli`、`hwlab-api` 和在线 `hwpod-node` 完成单次运行。标准 case registry repo 是 `pikasTech/hwlab-case-registry`,本地默认 checkout 路径是 `/root/hwlab-case-registry`;历史 `pikasTech/hwpod-cases` 只作为迁移前来源。case registry repo 提供 `cases//case.json` 与同目录 `hwpod-spec.yaml`;每次运行会先在 subject repo 本地 checkout 下创建隔离 Git worktree,再把 spec 复制到 `.state/hwlab-cli/caserun//.hwlab/hwpod-spec.yaml` 并把 `spec.workspace.path` 改写到该隔离 worktree,形成 run-local spec。 -CaseRun 的 case registry repo 与 subject repo 必须分离。标准 registry repo 是 `pikasTech/hwlab-case-registry`,G14 标准 checkout 是 `/root/hwlab-case-registry`;历史 `pikasTech/hwpod-cases` 和 `/root/hwpod-cases` 只作为迁移前来源,不再作为新 CaseRun 入口或写入目标。case registry repo 只保存 `case.json`、HWPOD spec 或 spec 引用、`subject` 引用、期望结果和 `runs///` 审计产物;它不保存或替代实际固件、工程、测试样例或产品代码库。subject repo 是被开发、被测试、被编译或被下载验证的真实源码仓库,由 `case.json` 通过 `subject` 字段引用独立 Git repo、ref 和 subdir;`case run` 准备阶段必须把 subject ref 解析为固定 subject commit,复制或 checkout 到 run-local workspace,再从隔离副本执行 build/download/smoke。`branch` 或 `tag` 只能作为人工入口,`evidence.json` 必须记录 subject repo、subject commit、subdir 和 run-local workspace,避免把 floating ref 或外部脏 workspace 当作可审计证据。 +CaseRun 的 case registry repo 与 subject repo 必须分离。标准 registry repo 是 `pikasTech/hwlab-case-registry`,G14 标准 checkout 是 `/root/hwlab-case-registry`;历史 `pikasTech/hwpod-cases` 和 `/root/hwpod-cases` 只作为迁移前来源,不再作为新 CaseRun 入口或写入目标。case registry repo 只保存 `case.json`、HWPOD spec 或 spec 引用、`subject` 引用、期望结果和 `runs///` 审计产物;它不保存或替代实际固件、工程、测试样例或产品代码库。subject repo 是被开发、被测试、被编译或被下载验证的真实源码仓库,`case.json.subject` 必须明确写入 `repoLocalPath` 和 `commitId`,其中 `repoLocalPath` 是绑定 HWPOD node 上的本地 Git checkout,`commitId` 是完整 40 字符 subject commit id。`case run` 准备阶段只能从 `repoLocalPath` 执行 `git worktree add --detach `,不得 clone/fetch GitHub,也不得把 branch、tag 或当前 HEAD 作为执行依据或 fallback。`subdir` 只描述 subject repo 内的 case 关注区域,不能替代 `spec.workspace.path` 的隔离 worktree。`evidence.json` 必须记录 subject repo 本地路径、subject commit、subdir 和 run-local worktree,避免把 floating ref 或外部脏 workspace 当作可审计证据。 + +标准 `case.json` 的 subject 字段形态如下: + +```json +{ + "subject": { + "repoLocalPath": "F:\\Work\\HWLAB-CASE-F103", + "commitId": "df7a4e6e551fa90d64bde5537cc000f89d63dd20", + "subdir": "projects/01_baseline" + } +} +``` CaseRun 是强化学习 Harness 的最小执行单元,但它本身不是完整 epoch。完整 epoch 还需要样本选择、策略生成、奖励/评价、批量调度、跨 run 归因和自动训练闭环;第一阶段只要求一个 `caseId` 能从 case registry repo 进入真实 HWPOD 编译闭环,并把可审计产物稳定落盘。 @@ -192,7 +204,7 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts case collect d601-f103-v2-compile --case-re bun tools/hwlab-cli/bin/hwlab-cli.ts case run d601-f103-v2-compile --case-repo /root/hwlab-case-registry ``` -`case run` 默认按 compile-only 验收:prepare 后调用 `hwpod-cli build`,再通过 `cmd.run` 查询 Keil 异步 job-status,收集 `evidence.json` 到 run state,并把审计副本写入 case registry repo 的 `runs///`。下载不作为 compile-only CaseRun 的隐式步骤;后续需要下载验收时,应作为独立 action 显式加入 case 定义和验收口径。 +`case run` 默认按 compile-only 验收:prepare 先在 subject repo 本地 checkout 下创建 `.worktree/caserun-` 隔离 worktree 并写入 run-local spec,然后调用 `hwpod-cli build`,再通过 `cmd.run` 查询 Keil 异步 job-status,收集 `evidence.json` 到 run state,并把审计副本写入 case registry repo 的 `runs///`。下载不作为 compile-only CaseRun 的隐式步骤;后续需要下载验收时,应作为独立 action 显式加入 case 定义和验收口径。 当前标准 smoke case 是 `d601-f103-v2-compile`。它绑定已发现并预装的 `D601-F103-V2` HWPOD spec,目标是让 Code Agent 通过 Web 等价入口或 AgentRun runner 调用 `hwlab-cli case run d601-f103-v2-compile`,而不是由操作员手工编译后代替通过。通过结果必须至少包含:`status=succeeded`、Keil build job terminal success、`returnCode=0`、`compileOnly=true`、`downloadSkipped=true`,并记录 `.hex` 和 `.axf` artifact 路径。 @@ -212,5 +224,5 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts case run d601-f103-v2-compile --case-repo / 3. `hwpod-cli --dry-run` 输出的 plan 不需要云端 spec,也不读取其他本地旧 profile authority。 4. `hwlab-api` 的 node-ops 入口能接收 plan,返回 JSON result;无可用 node 时必须返回结构化 blocker,而不是静默伪造 DEV-LIVE。 5. 真实 CLI 验收必须证明 `hwpod-cli -> hwpod-compiler-cli -> hwlab-api /v1/hwpod-node-ops -> hwpod-node` 全链路走通。 -6. CaseRun 验收必须能用 `hwlab-cli case run ` 从 case registry repo 读取 case 定义,产出 isolated run state 和 case registry repo evidence;compile-only case 的通过条件是 Keil build job terminal success,并明确记录 downloadSkipped。 +6. CaseRun 验收必须能用 `hwlab-cli case run ` 从 case registry repo 读取 case 定义,按 `subject.repoLocalPath` 和 `subject.commitId` 创建隔离 subject worktree,产出 isolated run state 和 case registry repo evidence;compile-only case 的通过条件是 Keil build job terminal success,并明确记录 downloadSkipped。 7. CaseRun 的真实验收必须由 Code Agent 或 AgentRun runner 从用户入口发起;操作员直接运行 `hwpod-cli build`、直接调用 Keil 或只检查 node 侧 job,不等同于 CaseRun 通过。 diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index 6bebc26d..ece6c3f4 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -7,6 +7,9 @@ import { test } from "bun:test"; import { runHwlabCli } from "../src/hwlab-cli-lib.ts"; import { artifactsFromKeilStatusForTest, extractKeilJobId, jobStatusCommandForTest } from "../src/hwlab-caserun-lib.ts"; +const SUBJECT_COMMIT_ID = "df7a4e6e551fa90d64bde5537cc000f89d63dd20"; +const SUBJECT_REPO_LOCAL_PATH = "F:\\Work\\HWLAB-CASE-F103"; + test("hwlab-cli case prepare copies a case hwpod-spec into isolated run state", async () => { const root = await mkdtempCaseRoot(); const caseRepo = path.join(root, "hwlab-case-registry"); @@ -17,17 +20,51 @@ test("hwlab-cli case prepare copies a case hwpod-spec into isolated run state", await writeFile(path.join(caseRepo, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); await mkdir(caseDir, { recursive: true }); await mkdir(cwd, { recursive: true }); - await writeFile(path.join(caseDir, "case.json"), JSON.stringify({ contractVersion: "hwpod-case-v1", caseId: "d601-f103-v2-compile", hwpodSpec: "hwpod-spec.yaml", subject: { repo: "git@github.com:pikasTech/D601-HWLAB.git", ref: "main", subdir: "projects/01_baseline" } }, null, 2), "utf8"); - await writeFile(path.join(caseDir, "hwpod-spec.yaml"), "kind: Hwpod\nspec:\n nodeBinding:\n nodeId: node-d601-f103-v2\n workspace:\n path: F:\\\\Work\\\\D601-HWLAB\n targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n ioProbe:\n uart:\n port: COM9\n", "utf8"); + await writeFile(path.join(caseDir, "case.json"), JSON.stringify({ contractVersion: "hwpod-case-v1", caseId: "d601-f103-v2-compile", hwpodSpec: "hwpod-spec.yaml", subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline" } }, null, 2), "utf8"); + await writeFile(path.join(caseDir, "hwpod-spec.yaml"), hwpodSpecText(), "utf8"); - const result = await runHwlabCli(["case", "prepare", "d601-f103-v2-compile", "--case-repo", caseRepo, "--run-id", "run-test"], { cwd, now: () => "2026-06-05T00:00:00.000Z" }); + const requests: any[] = []; + const result = await runHwlabCli(["case", "prepare", "d601-f103-v2-compile", "--case-repo", caseRepo, "--run-id", "run-test"], { cwd, now: () => "2026-06-05T00:00:00.000Z", fetchImpl: subjectWorktreeFetch(requests) }); assert.equal(result.exitCode, 0); assert.equal(result.payload.action, "case.prepare.prepare"); assert.equal(result.payload.runId, "run-test"); assert.equal(result.payload.caseRepo, caseRepo); assert.equal(result.payload.compileOnly, true); - assert.match(await readFile(path.join(cwd, ".state", "hwlab-cli", "caserun", "run-test", ".hwlab", "hwpod-spec.yaml"), "utf8"), /node-d601-f103-v2/u); + assert.equal(result.payload.subject.repoLocalPath, SUBJECT_REPO_LOCAL_PATH); + assert.equal(result.payload.subject.commitId, SUBJECT_COMMIT_ID); + assert.equal(result.payload.subject.worktreePath, "F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-run-test"); + const runLocalSpec = await readFile(path.join(cwd, ".state", "hwlab-cli", "caserun", "run-test", ".hwlab", "hwpod-spec.yaml"), "utf8"); + 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[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"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("case prepare rejects legacy subject repo/ref without a local repo path and commit id", async () => { + const root = await mkdtempCaseRoot(); + const caseRepo = path.join(root, "hwlab-case-registry"); + const cwd = path.join(root, "hwlab"); + const caseDir = path.join(caseRepo, "cases", "legacy-case"); + try { + await mkdir(path.join(caseRepo, ".git"), { recursive: true }); + await writeFile(path.join(caseRepo, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); + await mkdir(caseDir, { recursive: true }); + await mkdir(cwd, { recursive: true }); + await writeFile(path.join(caseDir, "case.json"), JSON.stringify({ contractVersion: "hwpod-case-v1", caseId: "legacy-case", hwpodSpec: "hwpod-spec.yaml", subject: { repo: "git@github.com:pikasTech/D601-HWLAB.git", ref: "main" } }, null, 2), "utf8"); + await writeFile(path.join(caseDir, "hwpod-spec.yaml"), hwpodSpecText(), "utf8"); + + const result = await runHwlabCli(["case", "prepare", "legacy-case", "--case-repo", caseRepo], { cwd, now: () => "2026-06-05T00:00:00.000Z", fetchImpl: subjectWorktreeFetch([]) }); + + assert.equal(result.exitCode, 1); + assert.equal(result.payload.error.code, "subject_repo_local_path_required"); } finally { await rm(root, { recursive: true, force: true }); } @@ -44,7 +81,7 @@ test("case prepare uses the new registry by default and refuses the removed hwpo await createCaseRepo(removedRepo, caseId); await mkdir(cwd, { recursive: true }); - const defaultResult = await runHwlabCli(["case", "prepare", caseId, "--run-id", "run-default"], { cwd, now: () => "2026-06-05T00:00:00.000Z" }); + const defaultResult = await runHwlabCli(["case", "prepare", caseId, "--run-id", "run-default"], { cwd, now: () => "2026-06-05T00:00:00.000Z", fetchImpl: subjectWorktreeFetch([]) }); assert.equal(defaultResult.exitCode, 0); assert.equal(defaultResult.payload.caseRepo, newRepo); @@ -67,7 +104,9 @@ test("case run extracts Keil async job ids from hwpod-node output", () => { test("case run uses cmd.run command plus argv for Keil job-status", () => { const command = jobStatusCommandForTest("py -3", "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py", "job-1"); - assert.deepEqual(command, { command: "py", argv: ["-3", "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py", "job-status", "job-1"] }); + assert.equal(command.command, "cmd.exe"); + assert.deepEqual(command.argv.slice(0, 3), ["/d", "/s", "/c"]); + assert.match(command.argv[3], /^py -3 C:\\Users\\liang\\\.agents\\skills\\keil\\keil-cli\.py job-status job-1$/u); }); test("case run exposes Keil hex and axf artifacts in evidence summary", () => { @@ -83,6 +122,22 @@ async function createCaseRepo(caseRepo: string, caseId: string) { await mkdir(path.join(caseRepo, ".git"), { recursive: true }); await writeFile(path.join(caseRepo, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); await mkdir(caseDir, { recursive: true }); - await writeFile(path.join(caseDir, "case.json"), JSON.stringify({ contractVersion: "hwpod-case-v1", caseId, hwpodSpec: "hwpod-spec.yaml", subject: { repo: "git@github.com:pikasTech/D601-HWLAB.git", ref: "main", subdir: "projects/01_baseline" } }, null, 2), "utf8"); - await writeFile(path.join(caseDir, "hwpod-spec.yaml"), "kind: Hwpod\nspec:\n nodeBinding:\n nodeId: node-d601-f103-v2\n workspace:\n path: F:\\\\Work\\\\D601-HWLAB\n targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n ioProbe:\n uart:\n port: COM9\n", "utf8"); + await writeFile(path.join(caseDir, "case.json"), JSON.stringify({ contractVersion: "hwpod-case-v1", caseId, hwpodSpec: "hwpod-spec.yaml", subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline" } }, null, 2), "utf8"); + await writeFile(path.join(caseDir, "hwpod-spec.yaml"), hwpodSpecText(), "utf8"); +} + +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"; +} + +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" } }); + }; } diff --git a/tools/hwpod-harness.test.ts b/tools/hwpod-harness.test.ts index 1fa8cc57..07be2195 100644 --- a/tools/hwpod-harness.test.ts +++ b/tools/hwpod-harness.test.ts @@ -93,11 +93,16 @@ test("hwpod-compiler-cli generates Keil build and download commands from structu const build = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.build"], { now: () => NOW }); assert.equal(build.exitCode, 0); - assert.equal(build.payload.plan.ops[0].op, "debug.build"); + assert.equal(build.payload.plan.ops[0].op, "cmd.run"); assert.equal(build.payload.plan.ops[0].args.target, "USART"); - assert.match(build.payload.plan.ops[0].args.command, /^py -3 C:\\Users\\liang\\\.agents\\skills\\keil\\keil-cli\.py build -p/u); - assert.match(build.payload.plan.ops[0].args.command, /F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103\.uvprojx/u); - assert.match(build.payload.plan.ops[0].args.command, / -t USART$/u); + 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.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); assert.equal(build.payload.plan.ops[0].args.commandBinding.source, "hwpod-compiler.keil-mdk"); assert.equal(build.payload.plan.ops[0].args.commandBinding.action, "build"); diff --git a/tools/hwpod-node.test.ts b/tools/hwpod-node.test.ts index 623aa8c9..c375174b 100644 --- a/tools/hwpod-node.test.ts +++ b/tools/hwpod-node.test.ts @@ -74,6 +74,27 @@ test("hwpod-node executes debug ops only through explicit node-side command bind } }); +test("hwpod-node reports cmd.run non-zero exits as failed results", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-cmd-fail-")); + try { + const result = await executeHwpodNodeOpsPlan({ + contractVersion: "hwpod-node-ops-v1", + planId: "hwpod_plan_cmd_fail", + hwpodId: "hwpod-local", + nodeId: "pc-host-1", + ops: [{ opId: "op_cmd", op: "cmd.run", args: { workspacePath: root, command: "sh", argv: ["-c", "echo no >&2; exit 7"] } }] + }, { now: () => "2026-06-05T00:00:00.000Z" }); + + assert.equal(result.ok, false); + assert.equal(result.results[0].ok, false); + assert.equal(result.results[0].status, "failed"); + assert.equal(result.results[0].output.exitCode, 7); + assert.equal(result.results[0].blocker.code, "hwpod_node_command_failed"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("hwpod-node applies workspace patches through the stable node op", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-patch-")); try { diff --git a/tools/src/hwlab-caserun-lib.ts b/tools/src/hwlab-caserun-lib.ts index 4e830ef8..4852e934 100644 --- a/tools/src/hwlab-caserun-lib.ts +++ b/tools/src/hwlab-caserun-lib.ts @@ -39,6 +39,16 @@ type PreparedCaseRun = { createdAt: string; updatedAt: string; definition: Record; + subject: PreparedSubjectRun; +}; + +type PreparedSubjectRun = { + repoLocalPath: string; + commitId: string; + subdir: string; + worktreePath: string; + workspacePath: string; + setup: Record; }; export async function caseCommand(context: CaseContext) { @@ -57,6 +67,10 @@ export function caseHelp() { compileOnlyDefault: true, stateRoot: ".state/hwlab-cli/caserun", standardCaseRepo: STANDARD_CASE_REPO_PATH, + subjectRequired: { + repoLocalPath: "local checkout path on the bound HWPOD node", + commitId: "fixed subject repo commit id; no GitHub clone/fetch fallback" + }, commands: [ `case prepare CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`, `case build CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-dir DIR]`, @@ -91,6 +105,7 @@ export async function prepareCaseRun(context: CaseContext, action = "prepare") { const specPath = path.join(runDir, ".hwlab", "hwpod-spec.yaml"); const now = context.now(); const apiUrl = apiUrlFrom(context, loaded.definition); + const subject = await prepareSubjectWorktree(context, { caseId, runId, runDir, apiUrl, definition: loaded.definition, sourceSpecPath: loaded.specPath }); const run: PreparedCaseRun = { caseId, runId, @@ -104,10 +119,11 @@ export async function prepareCaseRun(context: CaseContext, action = "prepare") { compileOnly: true, createdAt: now, updatedAt: now, - definition: loaded.definition + definition: loaded.definition, + subject }; await mkdir(path.dirname(specPath), { recursive: true }); - await writeFile(specPath, loaded.specText, "utf8"); + await writeFile(specPath, rewriteHwpodSpecWorkspacePath(loaded.specText, subject.workspacePath), "utf8"); await writeJson(path.join(runDir, "run.json"), run); return ok(`case.${action}.prepare`, { caseId, @@ -118,6 +134,7 @@ export async function prepareCaseRun(context: CaseContext, action = "prepare") { sourceSpecPath: loaded.specPath, apiUrl, compileOnly: true, + subject, summary: prepareSummary(run), run }); @@ -147,6 +164,7 @@ export async function buildCaseRun(context: CaseContext, prepared?: PreparedCase status: okStatus ? "succeeded" : "failed", observedAt: context.now(), apiUrl: run.apiUrl, + subject: run.subject, hwpod: clean({ command: commandVisibility(command), exitCode: invoked.exitCode, @@ -251,6 +269,142 @@ async function loadCaseDefinition(caseRepo: string, caseId: string) { return { caseDir, caseFile, definition, specPath, specText }; } +async function prepareSubjectWorktree(context: CaseContext, input: { caseId: string; runId: string; runDir: string; apiUrl: string; definition: Record; sourceSpecPath: string }) { + const subject = subjectFromDefinition(input.definition); + const worktreePath = subjectWorktreePath(subject.repoLocalPath, input.runId); + const relativeWorktreePath = subjectRelativeWorktreePath(subject.repoLocalPath, worktreePath); + const setup = await requestSubjectWorktree(context, input, subject, worktreePath, relativeWorktreePath); + return { ...subject, worktreePath, workspacePath: worktreePath, setup }; +} + +function subjectFromDefinition(definition: Record) { + const subject = definition.subject; + if (!subject || typeof subject !== "object" || Array.isArray(subject)) { + throw cliError("subject_required", "case.json subject is required", { required: ["subject.repoLocalPath", "subject.commitId"] }); + } + const source = subject as Record; + const repoLocalPath = text(source.repoLocalPath); + if (!repoLocalPath) throw cliError("subject_repo_local_path_required", "case.json subject.repoLocalPath is required and must point to a local subject repo checkout on the bound HWPOD node", { field: "subject.repoLocalPath" }); + const commitId = text(source.commitId).toLowerCase(); + if (!commitId) throw cliError("subject_commit_id_required", "case.json subject.commitId is required and must be a fixed subject repo commit id", { field: "subject.commitId" }); + if (!/^[0-9a-f]{40}$/iu.test(commitId)) { + throw cliError("invalid_subject_commit_id", "subject.commitId must be a full 40-character git commit id", { commitId }); + } + return { repoLocalPath, commitId, subdir: text(source.subdir) }; +} + +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 plan = { + contractVersion: "hwpod-node-ops-v1", + planId: `case_subject_${randomUUID()}`, + hwpodId, + 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"] + } + }] + }; + const response = await context.fetchImpl(`${input.apiUrl}/v1/hwpod-node-ops`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(plan) + }); + const textBody = await response.text(); + const body = parseJsonMaybe(textBody) ?? { raw: textBody }; + const status = subjectWorktreeStatus(response.status, body, { commitId: subject.commitId, worktreePath }); + 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) }); +} + +function subjectWorktreeStatus(httpStatus: number, body: any, expected: { commitId: string; worktreePath: string }) { + const results = Array.isArray(body?.body?.results) ? body.body.results : Array.isArray(body?.results) ? body.results : []; + const outputs = results.map((result: any) => result.output ?? result); + const exitCodes = outputs.map((output: any) => numberOption(output.exitCode)); + 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 ok = commandOk && actualHead === expected.commitId; + return { ok, exitCode: exitCodes.find((exitCode) => exitCode !== 0) ?? exitCodes[exitCodes.length - 1], stdout, stderr, actualHead, actualWorktreePath: expected.worktreePath }; +} + +function subjectRelativeWorktreePath(repoLocalPath: string, worktreePath: string) { + const repo = repoLocalPath.replace(/[\\/]+$/u, ""); + const normalizedRepo = normalizeLocalPath(repo); + const normalizedWorktree = normalizeLocalPath(worktreePath); + if (!normalizedWorktree.startsWith(`${normalizedRepo}/`)) throw cliError("invalid_subject_worktree_path", "subject worktree path must stay under subject repo local path", { repoLocalPath, worktreePath }); + const relative = worktreePath.slice(repo.length).replace(/^[\\/]+/u, ""); + return relative || "."; +} + +function normalizeLocalPath(value: string) { + return value.replace(/[\\/]+$/u, "").replace(/\\/gu, "/").toLowerCase(); +} + +function subjectWorktreePath(repoLocalPath: string, runId: string) { + const separator = looksLikeWindowsPath(repoLocalPath) ? "\\" : "/"; + return [repoLocalPath.replace(/[\\/]+$/u, ""), ".worktree", `caserun-${slug(runId)}`].join(separator); +} + +function looksLikeWindowsPath(value: string) { + return /^[A-Za-z]:[\\/]/u.test(value) || value.includes("\\"); +} + +function rewriteHwpodSpecWorkspacePath(specText: string, workspacePath: string) { + const lines = specText.replace(/\r\n/gu, "\n").split("\n"); + let workspaceIndent: number | null = null; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + const workspaceMatch = line.match(/^(\s*)workspace:\s*(?:#.*)?$/u); + if (workspaceMatch) { + workspaceIndent = workspaceMatch[1].length; + continue; + } + if (workspaceIndent === null) continue; + const indent = line.match(/^(\s*)/u)?.[1].length ?? 0; + if (line.trim() && indent <= workspaceIndent) { + workspaceIndent = null; + continue; + } + const pathMatch = line.match(/^(\s*)path\s*:/u); + if (pathMatch) { + lines[index] = `${pathMatch[1]}path: ${JSON.stringify(workspacePath)}`; + return lines.join("\n"); + } + } + throw cliError("hwpod_spec_workspace_path_missing", "hwpod-spec.yaml must contain spec.workspace.path so CaseRun can isolate the subject worktree", {}); +} + async function pollKeilJobStatus(context: CaseContext, run: PreparedCaseRun, jobId: string) { const timeoutMs = Math.min(numberOption(context.parsed.jobTimeoutMs ?? context.parsed.timeoutMs) ?? DEFAULT_JOB_TIMEOUT_MS, MAX_JOB_TIMEOUT_MS); const pollIntervalMs = numberOption(context.parsed.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS; @@ -272,7 +426,7 @@ async function requestKeilJobStatus(context: CaseContext, run: PreparedCaseRun, const document = await readHwpodSpec(run.specPath); const keilCliPath = text(document.spec.workspace.keilCliPath) || text(document.spec.debugProbe.keilCliPath) || "keil-cli.py"; const pythonCommand = text(document.spec.workspace.pythonCommand) || text(document.spec.debugProbe.pythonCommand) || "py -3"; - const jobStatusCommand = jobStatusCommandForTest(pythonCommand, keilCliPath, jobId); + const jobStatusCommand = jobStatusCommandForTest(pythonCommand, keilCliPath, jobId, document.spec.workspace.path); const hwpodId = text(document.metadata.name) || text(document.metadata.uid) || run.caseId; const plan = { contractVersion: "hwpod-node-ops-v1", @@ -327,7 +481,7 @@ function runIdFrom(context: CaseContext, caseId: string) { } function prepareSummary(run: PreparedCaseRun) { - return { status: "prepared", caseId: run.caseId, runId: run.runId, specPath: run.specPath, apiUrl: run.apiUrl }; + return { status: "prepared", caseId: run.caseId, runId: run.runId, specPath: run.specPath, apiUrl: run.apiUrl, subject: { repoLocalPath: run.subject.repoLocalPath, commitId: run.subject.commitId, worktreePath: run.subject.worktreePath } }; } function buildSummary(evidence: any) { @@ -335,7 +489,7 @@ function buildSummary(evidence: any) { } function renderEvidenceSummary(evidence: any) { - return `# HWPOD CaseRun ${evidence.caseId}\n\n- runId: ${evidence.runId}\n- status: ${evidence.status}\n- compileOnly: ${evidence.compileOnly}\n- jobId: ${evidence.keilJob?.jobId ?? ""}\n- downloadSkipped: true\n`; + return `# HWPOD CaseRun ${evidence.caseId}\n\n- runId: ${evidence.runId}\n- status: ${evidence.status}\n- compileOnly: ${evidence.compileOnly}\n- subjectRepoLocalPath: ${evidence.subject?.repoLocalPath ?? ""}\n- subjectCommitId: ${evidence.subject?.commitId ?? ""}\n- subjectWorktreePath: ${evidence.subject?.worktreePath ?? ""}\n- jobId: ${evidence.keilJob?.jobId ?? ""}\n- downloadSkipped: true\n`; } async function runProcess(command: string[], cwd: string, env: Record) { @@ -388,7 +542,22 @@ function splitCommandWords(value: string) { export function jobStatusCommandForTest(pythonCommand: string, keilCliPath: string, jobId: string) { const python = splitCommandWords(pythonCommand); - return { command: python[0] ?? "py", argv: [...python.slice(1), keilCliPath, "job-status", jobId] }; + return commandRunForTokens([python[0] ?? "py", ...python.slice(1), keilCliPath, "job-status", jobId], keilCliPath); +} + +function commandRunForTokens(tokens: string[], pathHint = "") { + if (tokens.some(looksLikeWindowsPath) || looksLikeWindowsPath(pathHint)) return { command: "cmd.exe", argv: ["/d", "/s", "/c", shellCommand(tokens)] }; + return { command: tokens[0] ?? "", argv: tokens.slice(1) }; +} + +function shellCommand(tokens: string[]) { + return tokens.map(shellArg).join(" "); +} + +function shellArg(value: string) { + const raw = String(value); + if (/^[A-Za-z0-9_./:@\\-]+$/u.test(raw)) return raw; + return `"${raw.replace(/(["^&|<>])/gu, "^$1").replace(/%/gu, "%%")}"`; } export function artifactsFromKeilStatusForTest(value: any) { diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index b9f6a57a..80412b95 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -322,7 +322,7 @@ function opsForIntent(intent: string, args: any, document: any) { if (intent === "workspace.cat") return [{ op: "workspace.cat", args: { ...common, path: requiredText(args.path, "path") } }]; if (intent === "workspace.rg") return [{ op: "workspace.rg", args: { ...common, pattern: requiredText(args.pattern, "pattern"), path: text(args.path) || ".", ignoreCase: args.ignoreCase === true } }]; if (intent === "workspace.apply-patch") return [{ op: "workspace.apply-patch", args: clean({ ...common, patch: text(args.patch), patchBase64: text(args.patchBase64), reason: text(args.reason) }) }]; - if (intent === "debug.build") return [{ op: "debug.build", args: debugBuildArgs(common, args, document) }]; + 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.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) }) }]; @@ -342,17 +342,20 @@ function commonOpArgs(document: any) { }; } -function debugBuildArgs(common: any, args: any, document: any) { +function debugBuildOps(common: any, args: any, document: any) { const explicitCommand = text(args.command) || text(document.spec.workspace.buildCommand); const generated = explicitCommand ? null : keilCommandForIntent("debug.build", args, document); - return clean({ + const opArgs = clean({ ...common, target: text(args.target) || generated?.target, - command: explicitCommand || generated?.command, commandBinding: generated?.binding, timeoutMs: numberValue(args.timeoutMs) ?? generated?.timeoutMs, reason: text(args.reason) }); + if (generated?.commandRun) { + return [{ op: "cmd.run", args: clean({ ...opArgs, command: generated.commandRun.command, argv: generated.commandRun.argv, commandLine: generated.command }) }]; + } + return [{ op: "debug.build", args: clean({ ...opArgs, command: explicitCommand || generated?.command }) }]; } function debugDownloadArgs(common: any, args: any, document: any) { @@ -382,10 +385,11 @@ function keilCommandForIntent(intent: "debug.build" | "debug.download", args: an const timeoutMs = numberValue(args.timeoutMs) ?? numberValue(workspace.keilCommandTimeoutMs) ?? numberValue(debugProbe.keilCommandTimeoutMs) ?? DEFAULT_KEIL_COMMAND_TIMEOUT_MS; const commonTokens = [...splitCommandWords(pythonCommand), keilCliPath]; if (intent === "debug.build") { - const command = shellCommand([...commonTokens, "build", "-p", project, ...targetOption(target)]); + const commandTokens = [...commonTokens, "build", "-p", project, ...targetOption(target)]; return { target, - command, + command: shellCommand(commandTokens), + commandRun: commandRun(commandTokens), timeoutMs, binding: clean({ kind: "keil-mdk", source: "hwpod-compiler.keil-mdk", action: "build", project, target, keilCliPath, pythonCommand }) }; @@ -471,6 +475,12 @@ function shellCommand(tokens: string[]) { return tokens.map(shellArg).join(" "); } +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 }; +} + function shellArg(value: string) { const raw = String(value); if (/^[A-Za-z0-9_./:@\\-]+$/u.test(raw)) return raw; diff --git a/tools/src/hwpod-node-lib.ts b/tools/src/hwpod-node-lib.ts index b55a698d..3987115c 100644 --- a/tools/src/hwpod-node-lib.ts +++ b/tools/src/hwpod-node-lib.ts @@ -269,7 +269,10 @@ async function executeOp(op: any, context: any) { if (name === "workspace.cat") return opOk(opId, name, await workspaceCat(args)); if (name === "workspace.rg") return opOk(opId, name, await workspaceRg(args)); if (name === "workspace.apply-patch") return opOk(opId, name, await workspaceApplyPatch(args)); - if (name === "cmd.run") return opOk(opId, name, await cmdRun(args)); + if (name === "cmd.run") { + const output = await cmdRun(args); + return output.ok ? opOk(opId, name, output) : opFailed(opId, name, output, "hwpod_node_command_failed", `cmd.run node-side command exited with ${output.exitCode}`); + } if (["debug.build", "debug.download", "debug.reset"].includes(name)) { const output = await debugCommand(name, args); return output.ok ? opOk(opId, name, output) : opFailed(opId, name, output, "hwpod_node_command_failed", `${name} node-side command exited with ${output.exitCode}`);