feat: isolate CaseRun subject worktrees

This commit is contained in:
Codex Agent
2026-06-06 10:47:35 +08:00
parent 8507956d8f
commit 4a4aa1bc29
7 changed files with 304 additions and 29 deletions
+16 -4
View File
@@ -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/<caseId>/case.json` 与同目录 `hwpod-spec.yaml`;每次运行会把 spec 复制到 `.state/hwlab-cli/caserun/<runId>/.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/<caseId>/case.json` 与同目录 `hwpod-spec.yaml`;每次运行会先在 subject repo 本地 checkout 下创建隔离 Git worktree,再把 spec 复制到 `.state/hwlab-cli/caserun/<runId>/.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/<caseId>/<runId>/` 审计产物;它不保存或替代实际固件、工程、测试样例或产品代码库。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/<caseId>/<runId>/` 审计产物;它不保存或替代实际固件、工程、测试样例或产品代码库。subject repo 是被开发、被测试、被编译或被下载验证的真实源码仓库,`case.json.subject` 必须明确写入 `repoLocalPath``commitId`,其中 `repoLocalPath` 是绑定 HWPOD node 上的本地 Git checkout`commitId` 是完整 40 字符 subject commit id。`case run` 准备阶段只能从 `repoLocalPath` 执行 `git worktree add --detach <worktree> <commitId>`,不得 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/<caseId>/<runId>/`。下载不作为 compile-only CaseRun 的隐式步骤;后续需要下载验收时,应作为独立 action 显式加入 case 定义和验收口径。
`case run` 默认按 compile-only 验收:prepare 先在 subject repo 本地 checkout 下创建 `.worktree/caserun-<runId>` 隔离 worktree 并写入 run-local spec,然后调用 `hwpod-cli build`,再通过 `cmd.run` 查询 Keil 异步 job-status,收集 `evidence.json` 到 run state,并把审计副本写入 case registry repo 的 `runs/<caseId>/<runId>/`。下载不作为 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 <caseId>` 从 case registry repo 读取 case 定义,产出 isolated run state 和 case registry repo evidencecompile-only case 的通过条件是 Keil build job terminal success,并明确记录 downloadSkipped。
6. CaseRun 验收必须能用 `hwlab-cli case run <caseId>` 从 case registry repo 读取 case 定义,`subject.repoLocalPath``subject.commitId` 创建隔离 subject worktree产出 isolated run state 和 case registry repo evidencecompile-only case 的通过条件是 Keil build job terminal success,并明确记录 downloadSkipped。
7. CaseRun 的真实验收必须由 Code Agent 或 AgentRun runner 从用户入口发起;操作员直接运行 `hwpod-cli build`、直接调用 Keil 或只检查 node 侧 job,不等同于 CaseRun 通过。
+63 -8
View File
@@ -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" } });
};
}
+9 -4
View File
@@ -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");
+21
View File
@@ -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 {
+175 -6
View File
@@ -39,6 +39,16 @@ type PreparedCaseRun = {
createdAt: string;
updatedAt: string;
definition: Record<string, unknown>;
subject: PreparedSubjectRun;
};
type PreparedSubjectRun = {
repoLocalPath: string;
commitId: string;
subdir: string;
worktreePath: string;
workspacePath: string;
setup: Record<string, unknown>;
};
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<string, unknown>; 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<string, unknown>) {
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<string, unknown>;
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<string, string | undefined>) {
@@ -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) {
+16 -6
View File
@@ -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;
+4 -1
View File
@@ -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}`);