feat: isolate CaseRun subject worktrees
This commit is contained in:
@@ -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 evidence;compile-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 evidence;compile-only case 的通过条件是 Keil build job terminal success,并明确记录 downloadSkipped。
|
||||
7. CaseRun 的真实验收必须由 Code Agent 或 AgentRun runner 从用户入口发起;操作员直接运行 `hwpod-cli build`、直接调用 Keil 或只检查 node 侧 job,不等同于 CaseRun 通过。
|
||||
|
||||
@@ -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" } });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user