feat: add compile-only hwpod caserun

This commit is contained in:
Codex Agent
2026-06-05 21:09:30 +08:00
parent 0b6aa9f628
commit 8fae961c7c
5 changed files with 485 additions and 2 deletions
+1
View File
@@ -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 <caseId> --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`
+16
View File
@@ -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/<caseId>/case.json` 与同目录 `hwpod-spec.yaml`;每次运行会把 spec 复制到 `.state/hwlab-cli/caserun/<runId>/.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/<runId>
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/<runId>
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/<caseId>/<runId>/`。下载不作为 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 <caseId>` 从 case repo 读取 case 定义,产出 isolated run state 和 case repo evidencecompile-only case 的通过条件是 Keil build job terminal success,并明确记录 downloadSkipped。
+54
View File
@@ -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-")));
}
+410
View File
@@ -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<string, string | undefined>;
type ParsedArgs = Record<string, unknown> & { _: string[] };
type FetchLike = typeof fetch;
type CaseContext = {
parsed: ParsedArgs;
env: EnvLike;
fetchImpl: FetchLike;
cwd: string;
now: () => string;
sleep: (ms: number) => Promise<void>;
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<string, unknown>;
};
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<string, string | undefined>) {
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<string, unknown> = {}, status = "succeeded") { return { ok: status !== "failed", action, status, ...extra }; }
function cliError(code: string, message: string, details: Record<string, unknown> = {}) { 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<T extends Record<string, unknown>>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T; }
+4 -2
View File
@@ -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"
]
});
}