From 8fae961c7c80b85ea9915d50b9e08c9d7f237557 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 5 Jun 2026 21:09:30 +0800 Subject: [PATCH] feat: add compile-only hwpod caserun --- AGENTS.md | 1 + docs/reference/spec-hwpod-harness.md | 16 ++ tools/hwlab-cli/caserun.test.ts | 54 ++++ tools/src/hwlab-caserun-lib.ts | 410 +++++++++++++++++++++++++++ tools/src/hwlab-cli-lib.ts | 6 +- 5 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 tools/hwlab-cli/caserun.test.ts create mode 100644 tools/src/hwlab-caserun-lib.ts diff --git a/AGENTS.md b/AGENTS.md index 0b5557be..a4c8b7fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,6 +133,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - DEV 依赖 runtime base 构建:`npm run dev-runtime-base:build` - Legacy D601 DEV CD:旧脚本入口已删除;事故回放只读历史 issue/commit,不恢复旧命令。 - v0.2 WEB 等价短连接 CLI:在 `G14:/root/hwlab-v02` 或当前 v0.2 worktree 直接运行 `bun tools/hwlab-cli/bin/hwlab-cli.ts client ... --base-url http://74.48.78.17:19666`,默认走 Cloud Web 同源 API;细则见 [docs/reference/spec-v02-hwlab-cli.md](docs/reference/spec-v02-hwlab-cli.md)。 +- v0.2 HWPOD CaseRun 无服务入口:`bun tools/hwlab-cli/bin/hwlab-cli.ts case run --case-repo /root/hwpod-cases`,第一阶段默认 compile-only 并把 evidence 写回 case repo,细则见 [docs/reference/spec-hwpod-harness.md](docs/reference/spec-hwpod-harness.md)。 - D601 k3s 只读观测:legacy 回溯入口,仅在确认需要 D601 事故复盘时使用;当前 G14 运行面观察使用 UniDesk route `G14:k3s`。 - DEV runtime hotfix 只读审计计划:`npm run dev-runtime:hotfix-audit` - Gateway 主动出站本地 smoke:`npm run gateway:demo:smoke`;经本地 edge-proxy 验证用 `npm run gateway:demo:edge-smoke`。 diff --git a/docs/reference/spec-hwpod-harness.md b/docs/reference/spec-hwpod-harness.md index b3f7c3f9..c241b50f 100644 --- a/docs/reference/spec-hwpod-harness.md +++ b/docs/reference/spec-hwpod-harness.md @@ -175,6 +175,21 @@ bun tools/hwpod-node.ts serve --host 127.0.0.1 --port 19678 `hwpod-cli` 的正常任务路径是:读取 spec、调用 compiler、把 node-ops plan 交给 `hwlab-api`,最后输出 closeout/result。`hwpod-ctl` 的管理路径可以在本地直接修改 spec,也可以生成 smoke plan 交给 `hwlab-api` 验证 node 链路。 +## CaseRun 无服务阶段 + +第一阶段 CaseRun 不引入常驻调度服务,由 `hwlab-cli case` 组合 case repo、本地 `.state`、`hwpod-cli`、`hwpod-compiler-cli`、`hwlab-api` 和在线 `hwpod-node` 完成单次运行。case repo 提供 `cases//case.json` 与同目录 `hwpod-spec.yaml`;每次运行会把 spec 复制到 `.state/hwlab-cli/caserun//.hwlab/hwpod-spec.yaml`,形成隔离的 run-local spec。 + +最小入口: + +```bash +bun tools/hwlab-cli/bin/hwlab-cli.ts case prepare d601-f103-v2-compile --case-repo /root/hwpod-cases +bun tools/hwlab-cli/bin/hwlab-cli.ts case build d601-f103-v2-compile --case-repo /root/hwpod-cases --run-dir .state/hwlab-cli/caserun/ +bun tools/hwlab-cli/bin/hwlab-cli.ts case collect d601-f103-v2-compile --case-repo /root/hwpod-cases --run-dir .state/hwlab-cli/caserun/ +bun tools/hwlab-cli/bin/hwlab-cli.ts case run d601-f103-v2-compile --case-repo /root/hwpod-cases +``` + +`case run` 默认按 compile-only 验收:prepare 后调用 `hwpod-cli build`,再通过 `cmd.run` 查询 Keil 异步 job-status,收集 `evidence.json` 到 run state,并把审计副本写入 case repo 的 `runs///`。下载不作为 compile-only CaseRun 的隐式步骤;后续需要下载验收时,应作为独立 action 显式加入 case 定义和验收口径。 + ## 验收标准 快速阶段最小验收: @@ -184,3 +199,4 @@ bun tools/hwpod-node.ts serve --host 127.0.0.1 --port 19678 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 repo 读取 case 定义,产出 isolated run state 和 case repo evidence;compile-only case 的通过条件是 Keil build job terminal success,并明确记录 downloadSkipped。 diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts new file mode 100644 index 00000000..dbaa494e --- /dev/null +++ b/tools/hwlab-cli/caserun.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "bun:test"; + +import { runHwlabCli } from "../src/hwlab-cli-lib.ts"; +import { artifactsFromKeilStatusForTest, extractKeilJobId, jobStatusCommandForTest } from "../src/hwlab-caserun-lib.ts"; + +test("hwlab-cli case prepare copies a case hwpod-spec into isolated run state", async () => { + const root = await mkdtempCaseRoot(); + const caseRepo = path.join(root, "hwpod-cases"); + const cwd = path.join(root, "hwlab"); + const caseDir = path.join(caseRepo, "cases", "d601-f103-v2-compile"); + 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: "d601-f103-v2-compile", hwpodSpec: "hwpod-spec.yaml" }, 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"); + + 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" }); + + assert.equal(result.exitCode, 0); + assert.equal(result.payload.action, "case.prepare.prepare"); + assert.equal(result.payload.runId, "run-test"); + 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); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("case run extracts Keil async job ids from hwpod-node output", () => { + const jobId = extractKeilJobId({ body: { results: [{ stdout: JSON.stringify({ ok: true, job_id: "20260605_203835_798515c0" }) }] } }); + assert.equal(jobId, "20260605_203835_798515c0"); + + const nestedJobId = extractKeilJobId({ body: { results: [{ output: { stdout: JSON.stringify({ accepted: true, job_id: "20260605_210457_bdf3292a" }) } }] } }); + assert.equal(nestedJobId, "20260605_210457_bdf3292a"); +}); + +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"] }); +}); + +test("case run exposes Keil hex and axf artifacts in evidence summary", () => { + assert.deepEqual(artifactsFromKeilStatusForTest({ result: { hex_file: "F:\\out.hex", axf_file: "F:\\out.axf" } }), ["F:\\out.hex", "F:\\out.axf"]); +}); + +async function mkdtempCaseRoot() { + return await import("node:fs/promises").then(({ mkdtemp }) => mkdtemp(path.join(os.tmpdir(), "hwlab-caserun-"))); +} diff --git a/tools/src/hwlab-caserun-lib.ts b/tools/src/hwlab-caserun-lib.ts new file mode 100644 index 00000000..db42290a --- /dev/null +++ b/tools/src/hwlab-caserun-lib.ts @@ -0,0 +1,410 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { readHwpodSpec } from "./hwpod-harness-lib.ts"; + +const DEFAULT_API_URL = "http://74.48.78.17:19667"; +const DEFAULT_POLL_INTERVAL_MS = 1000; +const DEFAULT_JOB_TIMEOUT_MS = 50000; +const MAX_JOB_TIMEOUT_MS = 50000; + +type EnvLike = Record; +type ParsedArgs = Record & { _: string[] }; +type FetchLike = typeof fetch; + +type CaseContext = { + parsed: ParsedArgs; + env: EnvLike; + fetchImpl: FetchLike; + cwd: string; + now: () => string; + sleep: (ms: number) => Promise; + rest: string[]; +}; + +type PreparedCaseRun = { + caseId: string; + runId: string; + runDir: string; + caseRepo: string; + caseDir: string; + caseFile: string; + sourceSpecPath: string; + specPath: string; + apiUrl: string; + compileOnly: boolean; + createdAt: string; + updatedAt: string; + definition: Record; +}; + +export async function caseCommand(context: CaseContext) { + const subcommand = context.rest[0] || "help"; + if (["help", "--help", "-h"].includes(subcommand) || context.parsed.help === true) return caseHelp(); + if (subcommand === "prepare") return prepareCaseRun(context); + if (subcommand === "build") return buildCaseRun(context); + if (subcommand === "collect") return collectCaseRun(context); + if (subcommand === "run") return runCaseRun(context); + throw cliError("unsupported_case_command", `unsupported case command: ${subcommand}`, { subcommand }); +} + +export function caseHelp() { + return ok("case.help", { + serviceRuntime: false, + compileOnlyDefault: true, + stateRoot: ".state/hwlab-cli/caserun", + commands: [ + "case prepare CASE_ID --case-repo /root/hwpod-cases [--run-id RUN_ID]", + "case build CASE_ID --case-repo /root/hwpod-cases [--run-dir DIR]", + "case collect CASE_ID --case-repo /root/hwpod-cases --run-dir DIR", + "case run CASE_ID --case-repo /root/hwpod-cases" + ] + }); +} + +export async function runCaseRun(context: CaseContext) { + const prepared = await prepareCaseRun(context, "run"); + const built = await buildCaseRun(context, prepared.run); + const collected = await collectCaseRun(context, built.run, built.evidence); + return ok("case.run", { + caseId: collected.run.caseId, + runId: collected.run.runId, + runDir: collected.run.runDir, + compileOnly: true, + prepare: prepared.summary, + build: built.summary, + collect: collected.summary, + evidence: collected.evidence + }, collected.evidence.ok === true ? "succeeded" : "failed"); +} + +export async function prepareCaseRun(context: CaseContext, action = "prepare") { + const caseId = requiredText(context.parsed.caseId ?? context.rest[1], "caseId"); + const caseRepo = await resolveCaseRepo(context); + const loaded = await loadCaseDefinition(caseRepo, caseId); + const runId = runIdFrom(context, caseId); + const runDir = path.resolve(context.cwd, text(context.parsed.runDir) || path.join(stateRoot(context), runId)); + const specPath = path.join(runDir, ".hwlab", "hwpod-spec.yaml"); + const now = context.now(); + const apiUrl = apiUrlFrom(context, loaded.definition); + const run: PreparedCaseRun = { + caseId, + runId, + runDir, + caseRepo, + caseDir: loaded.caseDir, + caseFile: loaded.caseFile, + sourceSpecPath: loaded.specPath, + specPath, + apiUrl, + compileOnly: true, + createdAt: now, + updatedAt: now, + definition: loaded.definition + }; + await mkdir(path.dirname(specPath), { recursive: true }); + await writeFile(specPath, loaded.specText, "utf8"); + await writeJson(path.join(runDir, "run.json"), run); + return ok(`case.${action}.prepare`, { + caseId, + runId, + runDir, + caseRepo, + specPath, + sourceSpecPath: loaded.specPath, + apiUrl, + compileOnly: true, + summary: prepareSummary(run), + run + }); +} + +export async function buildCaseRun(context: CaseContext, prepared?: PreparedCaseRun) { + const run = prepared ?? await loadOrPrepareCaseRun(context); + const command = [process.execPath, path.join(context.cwd, "tools/hwpod-cli.ts"), "build", "--spec", run.specPath, "--reason", `case-run ${run.caseId} ${run.runId} compile-only`]; + const invoked = await runProcess(command, context.cwd, { + ...process.env, + ...context.env, + HWLAB_RUNTIME_API_URL: run.apiUrl, + HWLAB_RUNTIME_ENDPOINT_LOCKED: "1" + }); + const hwpodPayload = parseJsonMaybe(invoked.stdout); + const jobId = extractKeilJobId(hwpodPayload); + const job = jobId ? await pollKeilJobStatus(context, run, jobId) : null; + const nodeOk = invoked.exitCode === 0 && hwpodPayload?.ok !== false && firstHwpodResultOk(hwpodPayload) !== false; + const jobOk = job ? job.ok === true : false; + const okStatus = nodeOk && jobOk; + const evidence = clean({ + contractVersion: "hwpod-case-run-evidence-v1", + caseId: run.caseId, + runId: run.runId, + compileOnly: true, + ok: okStatus, + status: okStatus ? "succeeded" : "failed", + observedAt: context.now(), + apiUrl: run.apiUrl, + hwpod: clean({ + command: commandVisibility(command), + exitCode: invoked.exitCode, + stdoutJson: compactHwpodPayload(hwpodPayload), + stderr: clipText(invoked.stderr) + }), + keilJob: job ? job.summary : null, + artifacts: job?.summary?.artifacts ?? [], + decisions: { + downloadSkipped: true, + reason: "compile-only validation is the current case-run acceptance line" + } + }); + await writeJson(path.join(run.runDir, "evidence.json"), evidence); + await writeJson(path.join(run.runDir, "run.json"), { ...run, updatedAt: context.now(), status: evidence.status, evidencePath: path.join(run.runDir, "evidence.json") }); + return ok("case.build", { + caseId: run.caseId, + runId: run.runId, + runDir: run.runDir, + compileOnly: true, + summary: buildSummary(evidence), + evidence, + run + }, evidence.status as string); +} + +export async function collectCaseRun(context: CaseContext, prepared?: PreparedCaseRun, knownEvidence?: any) { + const run = prepared ?? await loadRunFromDirOrFail(context); + const evidence = knownEvidence ?? JSON.parse(await readFile(path.join(run.runDir, "evidence.json"), "utf8")); + const files: string[] = []; + if (context.parsed.noCaseRepoRecord !== true) { + const evidenceRel = path.join("runs", run.caseId, run.runId, "evidence.json"); + const summaryRel = path.join("runs", run.caseId, run.runId, "summary.md"); + await writeJson(path.join(run.caseRepo, evidenceRel), evidence); + await writeFile(path.join(run.caseRepo, summaryRel), renderEvidenceSummary(evidence), "utf8"); + files.push(evidenceRel, summaryRel); + } + const summary = { + caseRepo: run.caseRepo, + caseRepoFiles: files, + status: evidence.status, + ok: evidence.ok === true + }; + return ok("case.collect", { caseId: run.caseId, runId: run.runId, runDir: run.runDir, compileOnly: true, summary, evidence, run }, evidence.status); +} + +export function extractKeilJobId(payload: any) { + const candidates = hwpodResultTexts(payload); + for (const candidate of candidates) { + const parsed = parseJsonMaybe(candidate); + const direct = text(parsed?.job_id ?? parsed?.jobId ?? parsed?.data?.job_id ?? parsed?.data?.jobId); + if (direct) return direct; + const match = String(candidate ?? "").match(/"job[_-]?id"\s*:\s*"([^"]+)"/iu); + if (match?.[1]) return match[1]; + } + return ""; +} + +async function loadOrPrepareCaseRun(context: CaseContext) { + if (text(context.parsed.runDir)) return loadRunFromDirOrFail(context); + const prepared = await prepareCaseRun(context, "build"); + return prepared.run as PreparedCaseRun; +} + +async function loadRunFromDirOrFail(context: CaseContext) { + const runDir = path.resolve(context.cwd, requiredText(context.parsed.runDir, "runDir")); + return JSON.parse(await readFile(path.join(runDir, "run.json"), "utf8")) as PreparedCaseRun; +} + +async function resolveCaseRepo(context: CaseContext) { + const explicit = text(context.parsed.caseRepo ?? context.parsed.caseRepoPath ?? context.env.HWLAB_CASE_REPO); + const candidates = explicit ? [path.resolve(context.cwd, explicit)] : [path.resolve(context.cwd, "../hwpod-cases"), "/root/hwpod-cases"]; + for (const candidate of candidates) { + try { + await readFile(path.join(candidate, ".git", "HEAD"), "utf8"); + return candidate; + } catch { + // Try next visible candidate. + } + } + throw cliError("case_repo_required", "case repo is required and must be a git checkout", { option: "--case-repo", candidates }); +} + +async function loadCaseDefinition(caseRepo: string, caseId: string) { + const caseDir = path.join(caseRepo, "cases", caseId); + const caseFile = path.join(caseDir, "case.json"); + const definition = JSON.parse(await readFile(caseFile, "utf8")); + const specRel = text(definition.hwpodSpec ?? definition.specPath) || "hwpod-spec.yaml"; + const specPath = path.resolve(caseDir, specRel); + const specText = await readFile(specPath, "utf8"); + return { caseDir, caseFile, definition, specPath, specText }; +} + +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; + const started = Date.now(); + const polls: any[] = []; + let last: any = null; + while (Date.now() - started <= timeoutMs) { + const response = await requestKeilJobStatus(context, run, jobId); + const status = keilJobStatus(response.body); + last = { response, status }; + polls.push(status.summary); + if (status.terminal) return { ok: status.ok, summary: { ...status.summary, jobId, polls: polls.length, timedOut: false } }; + await context.sleep(pollIntervalMs); + } + return { ok: false, summary: { jobId, status: text(last?.status?.summary?.status) || "timeout", polls: polls.length, timedOut: true, last: last?.status?.summary ?? null } }; +} + +async function requestKeilJobStatus(context: CaseContext, run: PreparedCaseRun, jobId: string) { + 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 hwpodId = text(document.metadata.uid) || text(document.metadata.name) || run.caseId; + const plan = { + contractVersion: "hwpod-node-ops-v1", + planId: `case_job_${randomUUID()}`, + hwpodId, + nodeId: document.spec.nodeBinding.nodeId, + intent: "cmd.run", + source: { compiler: "hwlab-cli.case", specPath: run.specPath, specAuthority: "case-run-state" }, + ops: [{ + opId: "op_01_keil_job_status", + op: "cmd.run", + args: { + hwpodId, + workspacePath: dirnameForCommandPath(keilCliPath), + command: jobStatusCommand.command, + argv: jobStatusCommand.argv + } + }] + }; + const response = await context.fetchImpl(`${run.apiUrl}/v1/hwpod-node-ops`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(plan) + }); + const textBody = await response.text(); + return { status: response.status, body: parseJsonMaybe(textBody) ?? { raw: textBody }, plan }; +} + +function keilJobStatus(body: any) { + const parsed = parseJsonMaybe(hwpodResultTexts(body)[0]) ?? body; + const status = text(parsed?.status ?? parsed?.data?.status ?? parsed?.job?.status) || "unknown"; + const returnCode = numberOption(parsed?.return_code ?? parsed?.returnCode ?? parsed?.data?.return_code); + const success = parsed?.success === true || parsed?.ok === true || returnCode === 0; + const failed = parsed?.success === false || parsed?.ok === false || (returnCode !== undefined && returnCode !== 0) || ["failed", "error", "cancelled"].includes(status); + const terminal = success || failed || ["completed", "succeeded"].includes(status); + const artifacts = artifactsFrom(parsed); + return { terminal, ok: success || ["completed", "succeeded"].includes(status), summary: clean({ status, returnCode, success, artifacts, raw: compactObject(parsed) }) }; +} + +function apiUrlFrom(context: CaseContext, definition: any) { + return text(context.parsed.apiUrl ?? context.parsed.apiBaseUrl ?? context.parsed.runtimeApiUrl ?? definition?.runtime?.apiUrl ?? context.env.HWLAB_RUNTIME_API_URL) || DEFAULT_API_URL; +} + +function stateRoot(context: CaseContext) { + return text(context.parsed.stateDir) || path.join(".state", "hwlab-cli", "caserun"); +} + +function runIdFrom(context: CaseContext, caseId: string) { + const explicit = text(context.parsed.runId); + if (explicit) return explicit; + return `${slug(caseId)}-${context.now().replace(/[^0-9]/gu, "").slice(0, 14)}-${randomUUID().slice(0, 8)}`; +} + +function prepareSummary(run: PreparedCaseRun) { + return { status: "prepared", caseId: run.caseId, runId: run.runId, specPath: run.specPath, apiUrl: run.apiUrl }; +} + +function buildSummary(evidence: any) { + return { status: evidence.status, ok: evidence.ok, jobId: evidence.keilJob?.jobId ?? null, artifacts: evidence.artifacts ?? [] }; +} + +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`; +} + +async function runProcess(command: string[], cwd: string, env: Record) { + const proc = Bun.spawn(command, { cwd, env, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); + return { command, stdout, stderr, exitCode }; +} + +function hwpodResultTexts(payload: any) { + const results = Array.isArray(payload?.body?.results) ? payload.body.results : Array.isArray(payload?.results) ? payload.results : []; + return results.flatMap((item: any) => [item?.stdout, item?.stderr, item?.summary, item?.output?.stdout, item?.output?.stderr, item?.output?.summary]).filter((item: unknown) => text(item)); +} + +function firstHwpodResultOk(payload: any) { + const results = Array.isArray(payload?.body?.results) ? payload.body.results : []; + return results[0]?.ok; +} + +function compactHwpodPayload(payload: any) { + if (!payload) return null; + return compactObject({ ok: payload.ok, action: payload.action, status: payload.status, traceId: payload.body?.traceId, results: payload.body?.results }); +} + +function artifactsFrom(value: any) { + const candidates = [value?.artifacts, value?.artifact_paths, value?.artifactPaths, value?.data?.artifacts, value?.data?.artifact_paths]; + for (const candidate of candidates) { + if (Array.isArray(candidate)) return candidate; + } + return [text(value?.result?.hex_file), text(value?.result?.axf_file), text(value?.hex_file), text(value?.axf_file)].filter(Boolean); +} + +async function writeJson(file: string, value: any) { + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function commandVisibility(command: string[]) { + return command.map((item) => item.includes("/") || item.includes("\\") ? path.basename(item) || item : item); +} + +function dirnameForCommandPath(value: string) { + if (/^[A-Za-z]:\\/u.test(value)) return value.replace(/\\[^\\]+$/u, "") || value; + return path.dirname(value); +} + +function splitCommandWords(value: string) { + const words = String(value || "py -3").match(/"[^"]*"|'[^']*'|\S+/gu) ?? ["py", "-3"]; + return words.map((word) => word.replace(/^"(.*)"$/su, "$1").replace(/^'(.*)'$/su, "$1")); +} + +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] }; +} + +export function artifactsFromKeilStatusForTest(value: any) { + return artifactsFrom(value); +} + +function compactObject(value: any) { + const json = JSON.stringify(value ?? null); + if (json.length <= 4000) return value; + return { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) }; +} + +function parseJsonMaybe(value: unknown) { + if (value && typeof value === "object") return value as any; + const raw = String(value ?? "").trim(); + if (!raw) return null; + try { return JSON.parse(raw); } catch { return null; } +} + +function clipText(value: unknown, maxBytes = 2000) { + const raw = String(value ?? ""); + const buffer = Buffer.from(raw, "utf8"); + if (buffer.length <= maxBytes) return raw; + return `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`; +} + +function ok(action: string, extra: Record = {}, status = "succeeded") { return { ok: status !== "failed", action, status, ...extra }; } +function cliError(code: string, message: string, details: Record = {}) { return Object.assign(new Error(message), { code, details }); } +function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw cliError("missing_required_value", `${field} is required`, { field }); return result; } +function text(value: unknown) { return String(value ?? "").trim(); } +function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; } +function slug(value: string) { return value.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "") || "case"; } +function clean>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T; } diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index d8687dce..86b14743 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { chmod, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; +import { caseCommand } from "./hwlab-caserun-lib.ts"; import { computeCodeAgentComposerState, isCodeAgentSessionUnusableStatus } from "./hwlab-cli/composer-policy.ts"; import { traceDisplayRows, traceNoiseEventCount } from "./hwlab-cli/trace-renderer.ts"; import { resolveRuntimeEndpoint, runtimeEndpointVisibility, sameRuntimeEndpointScope } from "./runtime-endpoint-resolver.ts"; @@ -52,7 +53,7 @@ export async function runHwlabCli(argv: string[], options: CliOptions = {}) { now, sleep: options.sleep ?? wait }; - const payload = target === "client" ? await clientCommand({ ...context, rest: parsed._.slice(1) }) : help(); + const payload = target === "client" ? await clientCommand({ ...context, rest: parsed._.slice(1) }) : target === "case" ? await caseCommand({ ...context, rest: parsed._.slice(1) }) : help(); return { exitCode: payload.ok === false ? 1 : 0, payload: withMeta(payload, now) }; } catch (error) { return { exitCode: 1, payload: withMeta(failure("hwlab-cli", error), now) }; @@ -154,7 +155,8 @@ function help() { "hwlab-cli client agent steer TRACE_ID --message TEXT|--message-file PATH", "hwlab-cli client harness submit --message TEXT --provider-profile deepseek|codex-api|minimax-m3", "hwlab-cli client harness wait TRACE_ID --timeout-ms 50000", - "hwlab-cli client harness audit TRACE_ID" + "hwlab-cli client harness audit TRACE_ID", + "hwlab-cli case run CASE_ID --case-repo /root/hwpod-cases" ] }); }