fix: add async CaseRun CLI controls
This commit is contained in:
@@ -214,10 +214,16 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts case prepare d601-f103-v2-compile --case-re
|
||||
bun tools/hwlab-cli/bin/hwlab-cli.ts case build d601-f103-v2-compile --case-repo /root/hwlab-case-registry --run-dir .state/hwlab-cli/caserun/<runId>
|
||||
bun tools/hwlab-cli/bin/hwlab-cli.ts case collect d601-f103-v2-compile --case-repo /root/hwlab-case-registry --run-dir .state/hwlab-cli/caserun/<runId>
|
||||
bun tools/hwlab-cli/bin/hwlab-cli.ts case run d601-f103-v2-compile --case-repo /root/hwlab-case-registry
|
||||
bun tools/hwlab-cli/bin/hwlab-cli.ts case run start d601-f103-v2-compile --case-repo /root/hwlab-case-registry --run-id <runId>
|
||||
bun tools/hwlab-cli/bin/hwlab-cli.ts case run status <runId>
|
||||
bun tools/hwlab-cli/bin/hwlab-cli.ts case run result <runId>
|
||||
bun tools/hwlab-cli/bin/hwlab-cli.ts case run logs <runId> --tail 8000
|
||||
```
|
||||
|
||||
`case run` 在当前版本只负责编排流程和记录原始证据,不做 evidence 自动评价、自动打分或门禁判断。流程是:prepare 先在 subject repo 本地 checkout 下创建 `.worktree/caserun-<runId>` 隔离 worktree并写入 run-local spec,然后生成 Code Agent prompt/context,创建或调用 HWLAB Code Agent session,把 isolated worktree、run-local HWPOD spec、任务目标和约束交给 Code Agent;agent 阶段结束或超时后,CaseRun 采集 subject worktree 的 `git status` / `git diff --stat` / `git diff --binary`,再调用 `hwpod-cli build`,通过 `cmd.run` 查询 Keil 异步 job-status,最后收集 `evidence.json` 到 run state,并把审计副本写入 case registry repo 的 `runs/<caseId>/<runId>/`。下载不作为 compile-only CaseRun 的隐式步骤;后续需要下载时,应作为独立 action 显式加入 case 定义和流程记录。
|
||||
|
||||
长时间 CaseRun 必须优先使用 `case run start/status/result/logs` 短连接形态,而不是用 shell/nohup 包装同步 `case run`。`start` 只负责创建 `.state/hwlab-cli/caserun/<runId>/`、启动后台 worker 并立即返回 `runId`、PID、stdout/stderr 路径和下一条查询命令;`status`、`result`、`logs` 都是短查询。`run.json` 是阶段状态权威,阶段开始时就要刷新 `status`、`stage`、`traceId/sessionId/conversationId/threadId`、AgentRun/trace 轮询信息、stdout/stderr byte count 和下一条轮询命令;长等待期间不得出现无输出、无 trace、无状态文件更新的黑洞窗口。
|
||||
|
||||
当前标准 smoke case 是 `d601-f103-v2-compile`。它绑定已发现并预装的 `D601-F103-V2` HWPOD spec,目标是让 CaseRun 通过 Web 等价入口或 AgentRun runner 布置 Code Agent 舞台,再调用 `hwlab-cli case run d601-f103-v2-compile` 跑完整流程,而不是由操作员手工编译后代替运行。当前版本的结果只要求记录:subject provenance、agent prompt/session/trace/conversation/provider、subject worktree diff 文件、HWPOD build invocation、Keil job/status/artifact 字段、`compileOnly=true` 和 `downloadSkipped=true`;不得把这些 evidence 自动归纳为 Code Agent 通过/失败。
|
||||
|
||||
产物归属按两条线分开:
|
||||
@@ -237,5 +243,6 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts case run d601-f103-v2-compile --case-repo /
|
||||
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 定义,按 `subject.repoLocalPath` 和 `subject.commitId` 创建隔离 subject worktree,生成 Code Agent prompt/context,记录 agent session/trace/conversation/provider,采集 subject worktree diff,执行 compile-only smoke,并产出 isolated run state 和 case registry repo evidence;当前版本只记录流程证据,不设置通过条件、不自动评价 evidence,并明确记录 downloadSkipped。
|
||||
7. `d601-f103-v2-compile` 的真实流程只覆盖当前编排链路和 compile-only smoke;操作员直接运行 `hwpod-cli build`、直接调用 Keil 或只检查 node 侧 job,不等同于 CaseRun 流程跑完。
|
||||
8. agent-task CaseRun 的真实流程必须证明 `case run` 已创建或调用 Code Agent / AgentRun session,任务 prompt 已进入 agent 上下文,CaseRun 已采集隔离 subject worktree diff,并已继续执行编译、下载或 I/O smoke 等后续动作;当前版本只记录这些事实,不自动判断 agent 是否完成任务。
|
||||
7. 长任务验收必须覆盖 `hwlab-cli case run start <caseId>` 立即返回,以及 `status/result/logs <runId>` 在 worker 运行中能短查询到阶段、耗时、stdout/stderr byte count、trace/session/conversation/thread 信息和下一条轮询命令;不得把同步命令长时间无输出视为有效 CaseRun 操作体验。
|
||||
8. `d601-f103-v2-compile` 的真实流程只覆盖当前编排链路和 compile-only smoke;操作员直接运行 `hwpod-cli build`、直接调用 Keil 或只检查 node 侧 job,不等同于 CaseRun 流程跑完。
|
||||
9. agent-task CaseRun 的真实流程必须证明 `case run` 已创建或调用 Code Agent / AgentRun session,任务 prompt 已进入 agent 上下文,CaseRun 已采集隔离 subject worktree diff,并已继续执行编译、下载或 I/O smoke 等后续动作;当前版本只记录这些事实,不自动判断 agent 是否完成任务。
|
||||
|
||||
@@ -113,6 +113,68 @@ 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"]);
|
||||
});
|
||||
|
||||
test("case run start returns immediately and status/result/logs are short polling commands", async () => {
|
||||
const root = await mkdtempCaseRoot();
|
||||
const caseRepo = path.join(root, "hwlab-case-registry");
|
||||
const cwd = path.join(root, "hwlab");
|
||||
const caseId = "d601-f103-v2-compile";
|
||||
try {
|
||||
await createCaseRepo(caseRepo, caseId);
|
||||
await mkdir(cwd, { recursive: true });
|
||||
const started = await runHwlabCli([
|
||||
"case",
|
||||
"run",
|
||||
"start",
|
||||
caseId,
|
||||
"--case-repo",
|
||||
caseRepo,
|
||||
"--run-id",
|
||||
"run-async"
|
||||
], {
|
||||
cwd,
|
||||
now: () => "2026-06-05T00:00:00.000Z",
|
||||
startProcess: async (input) => {
|
||||
await writeFile(input.stdoutPath, "worker accepted\n", "utf8");
|
||||
await writeFile(input.stderrPath, "", "utf8");
|
||||
return { pid: 4242 };
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(started.exitCode, 0);
|
||||
assert.equal(started.payload.action, "case.run.start");
|
||||
assert.equal(started.payload.status, "submitted");
|
||||
assert.equal(started.payload.startReturned, true);
|
||||
assert.equal(started.payload.pid, 4242);
|
||||
assert.match(started.payload.statusCommand, /case run status run-async/u);
|
||||
assert.match(started.payload.resultCommand, /case run result run-async/u);
|
||||
assert.match(started.payload.logsCommand, /case run logs run-async/u);
|
||||
|
||||
const status = await runHwlabCli(["case", "run", "status", "run-async"], { cwd, now: () => "2026-06-05T00:00:01.000Z" });
|
||||
assert.equal(status.exitCode, 0);
|
||||
assert.equal(status.payload.action, "case.run.status");
|
||||
assert.equal(status.payload.status, "running");
|
||||
assert.equal(status.payload.stage, "started");
|
||||
assert.equal(status.payload.pid, 4242);
|
||||
assert.equal(status.payload.stdoutBytes, Buffer.byteLength("worker accepted\n"));
|
||||
assert.equal(status.payload.stderrBytes, 0);
|
||||
assert.match(status.payload.nextPollCommand, /case run status run-async/u);
|
||||
|
||||
const result = await runHwlabCli(["case", "run", "result", "run-async"], { cwd, now: () => "2026-06-05T00:00:02.000Z" });
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.payload.action, "case.run.result");
|
||||
assert.equal(result.payload.ready, false);
|
||||
assert.equal(result.payload.status, "running");
|
||||
assert.match(result.payload.nextPollCommand, /case run status run-async/u);
|
||||
|
||||
const logs = await runHwlabCli(["case", "run", "logs", "run-async", "--tail", "200"], { cwd, now: () => "2026-06-05T00:00:03.000Z" });
|
||||
assert.equal(logs.exitCode, 0);
|
||||
assert.equal(logs.payload.action, "case.run.logs");
|
||||
assert.equal(logs.payload.stdout, "worker accepted\n");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("case run orchestrates agent prompt, diff capture, and compile evidence without auto evaluation", async () => {
|
||||
const root = await mkdtempCaseRoot();
|
||||
const caseRepo = path.join(root, "hwlab-case-registry");
|
||||
@@ -134,6 +196,7 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit
|
||||
await writeFile(path.join(caseDir, "hwpod-spec.yaml"), hwpodSpecText(), "utf8");
|
||||
|
||||
const requests: any[] = [];
|
||||
let sawAgentRunningState = false;
|
||||
const result = await runHwlabCli([
|
||||
"case",
|
||||
"run",
|
||||
@@ -149,7 +212,17 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit
|
||||
], {
|
||||
cwd,
|
||||
now: () => "2026-06-05T00:00:00.000Z",
|
||||
sleep: async () => undefined,
|
||||
sleep: async () => {
|
||||
const runState = JSON.parse(await readFile(path.join(cwd, ".state", "hwlab-cli", "caserun", "run-agent-flow", "run.json"), "utf8"));
|
||||
assert.equal(runState.stage, "agent-running");
|
||||
assert.equal(runState.agent.stageStatus, "running");
|
||||
assert.equal(runState.agent.sessionId, "ses_case_run");
|
||||
assert.equal(runState.agent.conversationId, "cnv_case_d601-f103-v2-compile_run-agent-flow");
|
||||
assert.equal(runState.agent.threadId, "thread-case");
|
||||
assert.match(runState.agent.traceId, /^trc_case_/u);
|
||||
assert.match(runState.agent.nextPollCommand, /client agent result trc_case_/u);
|
||||
sawAgentRunningState = true;
|
||||
},
|
||||
env: { HWLAB_API_KEY: "hwl_live_test_case_agent_key" },
|
||||
runProcess: async () => ({ command: [], exitCode: 0, stdout: JSON.stringify({ body: { results: [{ output: { stdout: JSON.stringify({ job_id: "job-compile-1" }) } }] } }), stderr: "" }),
|
||||
fetchImpl: caseRunFlowFetch(requests)
|
||||
@@ -168,6 +241,7 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit
|
||||
assert.equal(result.payload.build.hwpodExitCode, 0);
|
||||
assert.equal(result.payload.build.keilStatus, "completed");
|
||||
assert.equal(result.payload.collect.autoEvaluation, false);
|
||||
assert.equal(sawAgentRunningState, true);
|
||||
|
||||
const prompt = await readFile(path.join(cwd, ".state", "hwlab-cli", "caserun", "run-agent-flow", "agent-prompt.md"), "utf8");
|
||||
assert.match(prompt, /without auto-grading/u);
|
||||
|
||||
+357
-12
@@ -1,5 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { closeSync, openSync } from "node:fs";
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { readHwpodSpec } from "./hwpod-harness-lib.ts";
|
||||
@@ -18,6 +20,7 @@ type EnvLike = Record<string, string | undefined>;
|
||||
type ParsedArgs = Record<string, unknown> & { _: string[] };
|
||||
type FetchLike = typeof fetch;
|
||||
type RunProcessLike = (command: string[], cwd: string, env: Record<string, string | undefined>) => Promise<{ command: string[]; stdout: string; stderr: string; exitCode: number }>;
|
||||
type StartProcessLike = (input: { command: string; args: string[]; cwd: string; env: Record<string, string | undefined>; stdoutPath: string; stderrPath: string }) => Promise<{ pid: number }> | { pid: number };
|
||||
|
||||
type CaseContext = {
|
||||
parsed: ParsedArgs;
|
||||
@@ -27,6 +30,8 @@ type CaseContext = {
|
||||
now: () => string;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
runProcess?: RunProcessLike;
|
||||
startProcess?: StartProcessLike;
|
||||
argv?: string[];
|
||||
rest: string[];
|
||||
};
|
||||
|
||||
@@ -48,6 +53,10 @@ type PreparedCaseRun = {
|
||||
agentTask?: PreparedAgentTask | null;
|
||||
agent?: AgentRunStage | null;
|
||||
agentDiff?: AgentDiffStage | null;
|
||||
status?: string;
|
||||
stage?: string;
|
||||
evidencePath?: string;
|
||||
completedAt?: string;
|
||||
};
|
||||
|
||||
type PreparedSubjectRun = {
|
||||
@@ -82,6 +91,10 @@ type AgentRunStage = {
|
||||
result: Record<string, unknown> | null;
|
||||
polls: number;
|
||||
timedOut: boolean;
|
||||
resultUrl?: string;
|
||||
lastPollAt?: string;
|
||||
lastHttpStatus?: number;
|
||||
nextPollCommand?: string;
|
||||
sessionResponse: Record<string, unknown> | null;
|
||||
error?: Record<string, unknown> | null;
|
||||
};
|
||||
@@ -101,10 +114,20 @@ export async function caseCommand(context: CaseContext) {
|
||||
if (subcommand === "prepare") return prepareCaseRun(context);
|
||||
if (subcommand === "build") return buildCaseRun(context);
|
||||
if (subcommand === "collect") return collectCaseRun(context);
|
||||
if (subcommand === "run") return runCaseRun(context);
|
||||
if (subcommand === "run") return caseRunCommand(context);
|
||||
throw cliError("unsupported_case_command", `unsupported case command: ${subcommand}`, { subcommand });
|
||||
}
|
||||
|
||||
async function caseRunCommand(context: CaseContext) {
|
||||
const mode = context.rest[1] || "";
|
||||
if (mode === "start") return startCaseRun(context);
|
||||
if (mode === "status") return statusCaseRun(context);
|
||||
if (mode === "result") return resultCaseRun(context);
|
||||
if (mode === "logs") return logsCaseRun(context);
|
||||
if (mode === "worker") return runCaseRunWorker(context);
|
||||
return runCaseRun(context);
|
||||
}
|
||||
|
||||
export function caseHelp() {
|
||||
return ok("case.help", {
|
||||
serviceRuntime: false,
|
||||
@@ -121,11 +144,16 @@ export function caseHelp() {
|
||||
`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]`,
|
||||
`case collect CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --run-dir DIR`,
|
||||
`case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--provider-profile deepseek|codex-api|minimax-m3]`
|
||||
`case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--provider-profile deepseek|codex-api|minimax-m3]`,
|
||||
`case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`,
|
||||
`case run status RUN_ID [--state-dir .state/hwlab-cli/caserun]`,
|
||||
`case run result RUN_ID [--state-dir .state/hwlab-cli/caserun]`,
|
||||
`case run logs RUN_ID [--tail 8000]`
|
||||
],
|
||||
notes: [
|
||||
"case build remains a compile-only HWPOD smoke stage.",
|
||||
"case run prepares the subject worktree, submits a prompt to HWLAB Code Agent, records agent provenance and workspace diff, then runs compile validation.",
|
||||
"case run start/status/result/logs is the cli-spec async control surface for long CaseRun flows; start returns immediately and status/result/logs are short polling commands.",
|
||||
"This version records raw stage evidence only; it does not auto-grade agent output, diff contents, or Keil evidence."
|
||||
]
|
||||
});
|
||||
@@ -137,6 +165,8 @@ export async function runCaseRun(context: CaseContext) {
|
||||
const diffStage = await collectAgentDiff(context, agentStage.run);
|
||||
const built = await buildCaseRun(context, diffStage.run);
|
||||
const collected = await collectCaseRun(context, built.run, built.evidence);
|
||||
await writeRunControl(context, collected.run, { status: "completed", stage: "completed", completedAt: context.now(), resultPath: path.join(collected.run.runDir, "result.json") });
|
||||
await writeJson(path.join(collected.run.runDir, "result.json"), compactCaseRunResult(collected));
|
||||
return ok("case.run", {
|
||||
caseId: collected.run.caseId,
|
||||
runId: collected.run.runId,
|
||||
@@ -151,6 +181,160 @@ export async function runCaseRun(context: CaseContext) {
|
||||
}, "completed");
|
||||
}
|
||||
|
||||
export async function startCaseRun(context: CaseContext) {
|
||||
const caseId = requiredText(context.parsed.caseId ?? context.rest[2], "caseId");
|
||||
const runId = runIdFrom(context, caseId);
|
||||
const runDir = path.resolve(context.cwd, text(context.parsed.runDir) || path.join(stateRoot(context), runId));
|
||||
const stdoutPath = path.join(runDir, "worker.stdout.log");
|
||||
const stderrPath = path.join(runDir, "worker.stderr.log");
|
||||
await mkdir(runDir, { recursive: true });
|
||||
const passthrough = workerPassthroughArgs(context, caseId, runId, runDir);
|
||||
const command = process.execPath;
|
||||
const args = [path.join(context.cwd, "tools/hwlab-cli/bin/hwlab-cli.mjs"), "case", "run", "worker", ...passthrough];
|
||||
const control = await writeRunControl(context, {
|
||||
caseId,
|
||||
runId,
|
||||
runDir,
|
||||
caseRepo: text(context.parsed.caseRepo ?? context.parsed.caseRepoPath ?? context.env.HWLAB_CASE_REPO),
|
||||
caseDir: "",
|
||||
caseFile: "",
|
||||
sourceSpecPath: "",
|
||||
specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"),
|
||||
apiUrl: text(context.parsed.apiUrl ?? context.parsed.apiBaseUrl ?? context.parsed.runtimeApiUrl ?? context.env.HWLAB_RUNTIME_API_URL) || DEFAULT_API_URL,
|
||||
compileOnly: true,
|
||||
createdAt: context.now(),
|
||||
updatedAt: context.now(),
|
||||
definition: {},
|
||||
subject: { repoLocalPath: "", commitId: "", subdir: "", worktreePath: "", workspacePath: "", setup: {} },
|
||||
status: "running",
|
||||
stage: "queued"
|
||||
}, { status: "running", stage: "queued", stdoutPath, stderrPath, startedAt: context.now(), command: commandVisibility([command, ...args]) });
|
||||
const child = await (context.startProcess ?? startDetachedProcess)({ command, args, cwd: context.cwd, env: { ...process.env, ...context.env, HWLAB_CASERUN_ASYNC_WORKER: "1" }, stdoutPath, stderrPath });
|
||||
await writeRunControl(context, control.run, { status: "running", stage: "started", pid: child.pid, stdoutPath, stderrPath, startedAt: control.control.startedAt, command: commandVisibility([command, ...args]) });
|
||||
return ok("case.run.start", {
|
||||
caseId,
|
||||
runId,
|
||||
runDir,
|
||||
pid: child.pid,
|
||||
stateFile: path.join(runDir, "run.json"),
|
||||
stdoutPath,
|
||||
stderrPath,
|
||||
statusCommand: `hwlab-cli case run status ${runId} --state-dir ${stateRoot(context)}`,
|
||||
resultCommand: `hwlab-cli case run result ${runId} --state-dir ${stateRoot(context)}`,
|
||||
logsCommand: `hwlab-cli case run logs ${runId} --state-dir ${stateRoot(context)} --tail 8000`,
|
||||
startReturned: true,
|
||||
async: true
|
||||
}, "submitted");
|
||||
}
|
||||
|
||||
async function runCaseRunWorker(context: CaseContext) {
|
||||
const caseId = requiredText(context.parsed.caseId ?? context.rest[2], "caseId");
|
||||
const runId = requiredText(context.parsed.runId, "runId");
|
||||
const runDir = path.resolve(context.cwd, requiredText(context.parsed.runDir, "runDir"));
|
||||
const workerContext = { ...context, parsed: { ...context.parsed, runId, runDir }, rest: ["run", caseId] };
|
||||
await writeRunControl(workerContext, await loadControlRunSkeleton(workerContext, caseId, runId, runDir), { status: "running", stage: "worker-running", startedAt: context.now() });
|
||||
try {
|
||||
const payload = await runCaseRun(workerContext);
|
||||
const run = await readRunFromDir(runDir);
|
||||
await writeRunControl(workerContext, run, { status: "completed", stage: "completed", completedAt: context.now(), exitCode: 0, resultPath: path.join(runDir, "result.json") });
|
||||
await writeJson(path.join(runDir, "result.json"), payload);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
const run = await readRunFromDir(runDir).catch(() => loadControlRunSkeleton(workerContext, caseId, runId, runDir));
|
||||
await writeRunControl(workerContext, run, { status: "failed", stage: "failed", completedAt: context.now(), exitCode: 1, error: errorSummary(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function statusCaseRun(context: CaseContext) {
|
||||
const runId = requiredText(context.parsed.runId ?? context.rest[2], "runId");
|
||||
const run = await readRunById(context, runId);
|
||||
const control = controlFromRun(run);
|
||||
const stdoutPath = text(control.stdoutPath) || path.join(run.runDir, "worker.stdout.log");
|
||||
const stderrPath = text(control.stderrPath) || path.join(run.runDir, "worker.stderr.log");
|
||||
const stdoutBytes = await fileSize(stdoutPath);
|
||||
const stderrBytes = await fileSize(stderrPath);
|
||||
return ok("case.run.status", {
|
||||
caseId: run.caseId,
|
||||
runId: run.runId,
|
||||
runDir: run.runDir,
|
||||
stateFile: path.join(run.runDir, "run.json"),
|
||||
status: text(control.status) || text(run.status) || "unknown",
|
||||
stage: text(control.stage) || text(run.stage) || inferRunStage(run),
|
||||
pid: control.pid ?? null,
|
||||
startedAt: control.startedAt ?? run.createdAt,
|
||||
updatedAt: run.updatedAt,
|
||||
completedAt: control.completedAt ?? run.completedAt ?? null,
|
||||
elapsedMs: elapsedMs(control.startedAt ?? run.createdAt, control.completedAt),
|
||||
stdoutPath,
|
||||
stderrPath,
|
||||
stdoutBytes,
|
||||
stderrBytes,
|
||||
prepare: run.subject?.worktreePath ? prepareSummary(run) : null,
|
||||
agent: run.agent ? agentSummary(run.agent) : null,
|
||||
agentDiff: run.agentDiff ? diffSummary(run.agentDiff) : null,
|
||||
evidencePath: text(run.evidencePath ?? control.resultPath) || null,
|
||||
nextPollCommand: `hwlab-cli case run status ${run.runId} --state-dir ${stateRoot(context)}`,
|
||||
resultCommand: `hwlab-cli case run result ${run.runId} --state-dir ${stateRoot(context)}`,
|
||||
logsCommand: `hwlab-cli case run logs ${run.runId} --state-dir ${stateRoot(context)} --tail 8000`
|
||||
});
|
||||
}
|
||||
|
||||
export async function resultCaseRun(context: CaseContext) {
|
||||
const runId = requiredText(context.parsed.runId ?? context.rest[2], "runId");
|
||||
const run = await readRunById(context, runId);
|
||||
const control = controlFromRun(run);
|
||||
const status = text(control.status) || text(run.status) || "unknown";
|
||||
if (status !== "completed") {
|
||||
return ok("case.run.result", {
|
||||
caseId: run.caseId,
|
||||
runId: run.runId,
|
||||
runDir: run.runDir,
|
||||
status,
|
||||
stage: text(control.stage) || inferRunStage(run),
|
||||
ready: false,
|
||||
nextPollCommand: `hwlab-cli case run status ${run.runId} --state-dir ${stateRoot(context)}`,
|
||||
logsCommand: `hwlab-cli case run logs ${run.runId} --state-dir ${stateRoot(context)} --tail 8000`
|
||||
}, "running");
|
||||
}
|
||||
const evidencePath = text(run.evidencePath) || path.join(run.runDir, "evidence.json");
|
||||
const evidence = await readJsonIfExists(evidencePath);
|
||||
const result = await readJsonIfExists(path.join(run.runDir, "result.json"));
|
||||
return ok("case.run.result", {
|
||||
caseId: run.caseId,
|
||||
runId: run.runId,
|
||||
runDir: run.runDir,
|
||||
status,
|
||||
ready: true,
|
||||
evidencePath,
|
||||
resultPath: path.join(run.runDir, "result.json"),
|
||||
summary: evidence ? buildSummary(evidence) : null,
|
||||
agent: run.agent ? agentSummary(run.agent) : null,
|
||||
agentDiff: run.agentDiff ? diffSummary(run.agentDiff) : null,
|
||||
evidence: context.parsed.full === true ? evidence : compactObject(evidence),
|
||||
result: context.parsed.full === true ? result : compactObject(result)
|
||||
}, "completed");
|
||||
}
|
||||
|
||||
export async function logsCaseRun(context: CaseContext) {
|
||||
const runId = requiredText(context.parsed.runId ?? context.rest[2], "runId");
|
||||
const run = await readRunById(context, runId);
|
||||
const control = controlFromRun(run);
|
||||
const tailBytes = Math.max(1, Math.min(numberOption(context.parsed.tail ?? context.parsed.tailBytes) ?? 8000, 50000));
|
||||
const stdoutPath = text(control.stdoutPath) || path.join(run.runDir, "worker.stdout.log");
|
||||
const stderrPath = text(control.stderrPath) || path.join(run.runDir, "worker.stderr.log");
|
||||
return ok("case.run.logs", {
|
||||
caseId: run.caseId,
|
||||
runId: run.runId,
|
||||
runDir: run.runDir,
|
||||
tailBytes,
|
||||
stdoutPath,
|
||||
stderrPath,
|
||||
stdout: await readTail(stdoutPath, tailBytes),
|
||||
stderr: await readTail(stderrPath, tailBytes)
|
||||
});
|
||||
}
|
||||
|
||||
export async function prepareCaseRun(context: CaseContext, action = "prepare") {
|
||||
const caseId = requiredText(context.parsed.caseId ?? context.rest[1], "caseId");
|
||||
const caseRepo = await resolveCaseRepo(context);
|
||||
@@ -181,7 +365,8 @@ export async function prepareCaseRun(context: CaseContext, action = "prepare") {
|
||||
};
|
||||
await mkdir(path.dirname(specPath), { recursive: true });
|
||||
await writeFile(specPath, rewriteHwpodSpecWorkspacePath(loaded.specText, subject.workspacePath), "utf8");
|
||||
await writeJson(path.join(runDir, "run.json"), run);
|
||||
const status = action === "prepare" ? "prepared" : "running";
|
||||
await writeRunControl(context, run, { status, stage: "prepared" });
|
||||
return ok(`case.${action}.prepare`, {
|
||||
caseId,
|
||||
runId,
|
||||
@@ -237,7 +422,7 @@ export async function buildCaseRun(context: CaseContext, prepared?: PreparedCase
|
||||
}
|
||||
});
|
||||
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") });
|
||||
await writeRunControl(context, { ...run, status: evidence.status, evidencePath: path.join(run.runDir, "evidence.json") }, { status: "running", stage: "build-completed", evidencePath: path.join(run.runDir, "evidence.json") });
|
||||
return ok("case.build", {
|
||||
caseId: run.caseId,
|
||||
runId: run.runId,
|
||||
@@ -375,6 +560,7 @@ async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) {
|
||||
const providerProfile = text(context.parsed.providerProfile) || run.agentTask.providerProfile;
|
||||
const projectId = text(context.parsed.projectId) || run.agentTask.projectId;
|
||||
const conversationId = text(context.parsed.conversationId) || `cnv_case_${slug(run.caseId)}_${slug(run.runId)}`;
|
||||
run = await updateRun(context, run, { stage: "agent-session-starting", agent: agentFailureStage({ stageStatus: "session_starting", baseUrl, projectId, providerProfile, conversationId, promptPath, promptSha256 }) });
|
||||
const session = await tryCreateAgentSession(context, { baseUrl, projectId, providerProfile, conversationId });
|
||||
if (!session.ok) {
|
||||
const agent = agentFailureStage({ stageStatus: "session_failed", baseUrl, projectId, providerProfile, conversationId, promptPath, promptSha256, error: session.error, sessionResponse: session.body });
|
||||
@@ -384,13 +570,36 @@ async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) {
|
||||
const sessionId = session.sessionId;
|
||||
const threadId = session.threadId;
|
||||
const traceId = text(context.parsed.traceId) || `trc_case_${slug(run.caseId)}_${randomUUID().replace(/-/gu, "")}`;
|
||||
run = await updateRun(context, run, { stage: "agent-submitting", agent: agentFailureStage({ stageStatus: "session_created", baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, promptPath, promptSha256, sessionResponse: compactObject(session.body) }) });
|
||||
const accepted = await submitAgentTask(context, { baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, prompt });
|
||||
if (!accepted.ok) {
|
||||
const agent = agentFailureStage({ stageStatus: "submit_failed", baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, promptPath, promptSha256, accepted: accepted.body, error: accepted.error, sessionResponse: session.body });
|
||||
const nextRun = await updateRun(context, run, { agent });
|
||||
return { run: nextRun, agent };
|
||||
}
|
||||
const result = await pollAgentResult(context, { baseUrl, traceId, acceptedBody: accepted.body });
|
||||
const resultPath = text(accepted.body?.resultUrl) || `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
|
||||
const resultUrl = resultPath.startsWith("http") ? resultPath : `${baseUrl}${resultPath}`;
|
||||
const submittedAgent: AgentRunStage = clean({
|
||||
stageStatus: "submitted",
|
||||
baseUrl,
|
||||
projectId,
|
||||
providerProfile,
|
||||
conversationId,
|
||||
sessionId,
|
||||
threadId,
|
||||
traceId,
|
||||
promptPath,
|
||||
promptSha256,
|
||||
accepted: compactObject(accepted.body),
|
||||
result: null,
|
||||
polls: 0,
|
||||
timedOut: false,
|
||||
resultUrl,
|
||||
nextPollCommand: `hwlab-cli client agent result ${traceId} --base-url ${baseUrl}`,
|
||||
sessionResponse: compactObject(session.body)
|
||||
});
|
||||
run = await updateRun(context, run, { stage: "agent-running", agent: submittedAgent });
|
||||
const result = await pollAgentResult(context, { baseUrl, traceId, acceptedBody: accepted.body, resultUrl, run, agent: submittedAgent });
|
||||
const agent: AgentRunStage = clean({
|
||||
stageStatus: result.stageStatus,
|
||||
baseUrl,
|
||||
@@ -406,6 +615,10 @@ async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) {
|
||||
result: compactObject(result.body),
|
||||
polls: result.polls,
|
||||
timedOut: result.timedOut,
|
||||
resultUrl,
|
||||
lastPollAt: result.lastPollAt,
|
||||
lastHttpStatus: result.lastHttpStatus,
|
||||
nextPollCommand: `hwlab-cli client agent result ${traceId} --base-url ${baseUrl}`,
|
||||
sessionResponse: compactObject(session.body),
|
||||
error: result.error
|
||||
});
|
||||
@@ -479,29 +692,32 @@ async function submitAgentTask(context: CaseContext, input: { baseUrl: string; p
|
||||
}, "agent_task_submit");
|
||||
}
|
||||
|
||||
async function pollAgentResult(context: CaseContext, input: { baseUrl: string; traceId: string; acceptedBody: any }) {
|
||||
async function pollAgentResult(context: CaseContext, input: { baseUrl: string; traceId: string; acceptedBody: any; resultUrl?: string; run?: PreparedCaseRun; agent?: AgentRunStage }) {
|
||||
const requestedTimeoutMs = numberOption(context.parsed.agentTimeoutMs ?? context.parsed.timeoutMs) ?? DEFAULT_AGENT_TIMEOUT_MS;
|
||||
const timeoutMs = Math.max(Math.min(requestedTimeoutMs, DEFAULT_AGENT_TIMEOUT_MS), 1000);
|
||||
const pollIntervalMs = Math.max(numberOption(context.parsed.agentPollIntervalMs ?? context.parsed.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS, 250);
|
||||
const startedAt = Date.now();
|
||||
const resultPath = text(input.acceptedBody?.resultUrl) || `/v1/agent/chat/result/${encodeURIComponent(input.traceId)}`;
|
||||
const resultUrl = resultPath.startsWith("http") ? resultPath : `${input.baseUrl}${resultPath}`;
|
||||
const resultUrl = input.resultUrl || (resultPath.startsWith("http") ? resultPath : `${input.baseUrl}${resultPath}`);
|
||||
let polls = 0;
|
||||
let lastBody: any = null;
|
||||
let lastStatus = 0;
|
||||
let lastPollAt = "";
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
polls += 1;
|
||||
lastPollAt = context.now();
|
||||
const response = await caseFetchJson(context, resultUrl, { method: "GET", headers: caseAgentHeaders(context) }, "agent_task_result");
|
||||
lastBody = response.body;
|
||||
lastStatus = response.status;
|
||||
if (!response.ok) return { stageStatus: "result_request_failed", body: lastBody, polls, timedOut: false, error: response.error };
|
||||
if (input.run && input.agent) await updateRun(context, input.run, { stage: "agent-running", agent: { ...input.agent, stageStatus: "running", result: compactObject(lastBody), polls, resultUrl, lastPollAt, lastHttpStatus: lastStatus } });
|
||||
if (!response.ok) return { stageStatus: "result_request_failed", body: lastBody, polls, timedOut: false, lastPollAt, lastHttpStatus: lastStatus, error: response.error };
|
||||
const status = text(lastBody?.status);
|
||||
if (status && status !== "running") {
|
||||
return { stageStatus: status, body: lastBody, polls, timedOut: false, error: null };
|
||||
return { stageStatus: status, body: lastBody, polls, timedOut: false, lastPollAt, lastHttpStatus: lastStatus, error: null };
|
||||
}
|
||||
await context.sleep(pollIntervalMs);
|
||||
}
|
||||
return { stageStatus: "timeout", body: lastBody, polls, timedOut: true, error: { code: "agent_task_timeout", traceId: input.traceId, timeoutMs, lastHttpStatus: lastStatus, lastBody: compactObject(lastBody) } };
|
||||
return { stageStatus: "timeout", body: lastBody, polls, timedOut: true, lastPollAt, lastHttpStatus: lastStatus, error: { code: "agent_task_timeout", traceId: input.traceId, timeoutMs, lastHttpStatus: lastStatus, lastBody: compactObject(lastBody) } };
|
||||
}
|
||||
|
||||
async function collectAgentDiff(context: CaseContext, run: PreparedCaseRun) {
|
||||
@@ -560,7 +776,7 @@ async function requestSubjectCommand(context: CaseContext, run: PreparedCaseRun,
|
||||
|
||||
async function updateRun(context: CaseContext, run: PreparedCaseRun, patch: Partial<PreparedCaseRun> & Record<string, unknown>) {
|
||||
const nextRun = { ...run, ...patch, updatedAt: context.now() } as PreparedCaseRun;
|
||||
await writeJson(path.join(nextRun.runDir, "run.json"), nextRun);
|
||||
await writeRunState(nextRun);
|
||||
return nextRun;
|
||||
}
|
||||
|
||||
@@ -899,6 +1115,135 @@ async function writeJson(file: string, value: any) {
|
||||
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function writeRunState(run: PreparedCaseRun) {
|
||||
const current = await readJsonIfExists(path.join(run.runDir, "run.json"));
|
||||
const control = current?._control && typeof current._control === "object" ? current._control : {};
|
||||
await writeJson(path.join(run.runDir, "run.json"), { ...run, _control: control });
|
||||
}
|
||||
|
||||
async function writeRunControl(context: CaseContext, run: PreparedCaseRun, patch: Record<string, unknown>) {
|
||||
const file = path.join(run.runDir, "run.json");
|
||||
const current = await readJsonIfExists(file);
|
||||
const currentControl = current?._control && typeof current._control === "object" ? current._control : {};
|
||||
const currentRun = current && typeof current === "object" ? current : {};
|
||||
const control = clean({ ...currentControl, ...patch, updatedAt: context.now() });
|
||||
const nextRun = { ...currentRun, ...run, status: text(control.status) || run.status, stage: text(control.stage) || run.stage, updatedAt: context.now(), _control: control } as PreparedCaseRun & { _control: Record<string, unknown> };
|
||||
await writeJson(file, nextRun);
|
||||
return { run: nextRun as PreparedCaseRun, control };
|
||||
}
|
||||
|
||||
async function readRunById(context: CaseContext, runId: string) {
|
||||
return readRunFromDir(path.resolve(context.cwd, path.join(stateRoot(context), runId)));
|
||||
}
|
||||
|
||||
async function readRunFromDir(runDir: string) {
|
||||
return JSON.parse(await readFile(path.join(runDir, "run.json"), "utf8")) as PreparedCaseRun;
|
||||
}
|
||||
|
||||
async function readJsonIfExists(file: string) {
|
||||
try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; }
|
||||
}
|
||||
|
||||
function controlFromRun(run: any) {
|
||||
return run?._control && typeof run._control === "object" ? run._control : {};
|
||||
}
|
||||
|
||||
async function loadControlRunSkeleton(context: CaseContext, caseId: string, runId: string, runDir: string) {
|
||||
const existing = await readJsonIfExists(path.join(runDir, "run.json"));
|
||||
if (existing) return existing as PreparedCaseRun;
|
||||
return {
|
||||
caseId,
|
||||
runId,
|
||||
runDir,
|
||||
caseRepo: text(context.parsed.caseRepo ?? context.parsed.caseRepoPath ?? context.env.HWLAB_CASE_REPO),
|
||||
caseDir: "",
|
||||
caseFile: "",
|
||||
sourceSpecPath: "",
|
||||
specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"),
|
||||
apiUrl: text(context.parsed.apiUrl ?? context.parsed.apiBaseUrl ?? context.parsed.runtimeApiUrl ?? context.env.HWLAB_RUNTIME_API_URL) || DEFAULT_API_URL,
|
||||
compileOnly: true,
|
||||
createdAt: context.now(),
|
||||
updatedAt: context.now(),
|
||||
definition: {},
|
||||
subject: { repoLocalPath: "", commitId: "", subdir: "", worktreePath: "", workspacePath: "", setup: {} },
|
||||
status: "running",
|
||||
stage: "worker-running"
|
||||
} as PreparedCaseRun;
|
||||
}
|
||||
|
||||
function workerPassthroughArgs(context: CaseContext, caseId: string, runId: string, runDir: string) {
|
||||
const source = context.argv ?? [];
|
||||
const args: string[] = [];
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const item = source[index] ?? "";
|
||||
if (item === "case" || item === "run" || item === "start" || item === caseId) continue;
|
||||
if (["--run-id", "--run-dir"].includes(item)) { index += 1; continue; }
|
||||
if (item.startsWith("--run-id=") || item.startsWith("--run-dir=")) continue;
|
||||
args.push(item);
|
||||
}
|
||||
return [caseId, "--run-id", runId, "--run-dir", runDir, ...args];
|
||||
}
|
||||
|
||||
function startDetachedProcess(input: { command: string; args: string[]; cwd: string; env: Record<string, string | undefined>; stdoutPath: string; stderrPath: string }) {
|
||||
closeSync(openSync(input.stdoutPath, "a"));
|
||||
closeSync(openSync(input.stderrPath, "a"));
|
||||
const stdout = openSync(input.stdoutPath, "a");
|
||||
const stderr = openSync(input.stderrPath, "a");
|
||||
const child = spawn(input.command, input.args, { cwd: input.cwd, env: input.env as NodeJS.ProcessEnv, detached: true, stdio: ["ignore", stdout, stderr] });
|
||||
child.unref();
|
||||
closeSync(stdout);
|
||||
closeSync(stderr);
|
||||
return { pid: child.pid ?? 0 };
|
||||
}
|
||||
|
||||
async function fileSize(file: string) {
|
||||
try { return (await stat(file)).size; } catch { return 0; }
|
||||
}
|
||||
|
||||
async function readTail(file: string, maxBytes: number) {
|
||||
try {
|
||||
const content = await readFile(file);
|
||||
const start = Math.max(0, content.length - maxBytes);
|
||||
return content.subarray(start).toString("utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function inferRunStage(run: PreparedCaseRun) {
|
||||
if (run.evidencePath) return "build-completed";
|
||||
if (run.agentDiff) return "agent-diff-collected";
|
||||
if (run.agent) return run.agent.stageStatus === "submitted" || run.agent.stageStatus === "running" ? "agent-running" : `agent-${run.agent.stageStatus}`;
|
||||
if (run.subject?.worktreePath) return "prepared";
|
||||
return "queued";
|
||||
}
|
||||
|
||||
function elapsedMs(startedAt: unknown, completedAt: unknown) {
|
||||
const start = Date.parse(text(startedAt));
|
||||
if (!Number.isFinite(start)) return null;
|
||||
const end = completedAt ? Date.parse(text(completedAt)) : Date.now();
|
||||
return Number.isFinite(end) ? Math.max(0, end - start) : null;
|
||||
}
|
||||
|
||||
function compactCaseRunResult(collected: any) {
|
||||
return {
|
||||
ok: true,
|
||||
action: "case.run",
|
||||
status: "completed",
|
||||
caseId: collected.run.caseId,
|
||||
runId: collected.run.runId,
|
||||
runDir: collected.run.runDir,
|
||||
compileOnly: true,
|
||||
collect: collected.summary,
|
||||
evidencePath: path.join(collected.run.runDir, "evidence.json"),
|
||||
evidence: compactObject(collected.evidence)
|
||||
};
|
||||
}
|
||||
|
||||
function errorSummary(error: unknown) {
|
||||
return { code: typeof (error as any)?.code === "string" ? (error as any).code : "caserun_worker_failed", message: error instanceof Error ? error.message : String(error), details: (error as any)?.details ?? null };
|
||||
}
|
||||
|
||||
function commandVisibility(command: string[]) {
|
||||
return command.map((item) => item.includes("/") || item.includes("\\") ? path.basename(item) || item : item);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type CliOptions = {
|
||||
now?: () => string;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
runProcess?: (command: string[], cwd: string, env: Record<string, string | undefined>) => Promise<{ command: string[]; stdout: string; stderr: string; exitCode: number }>;
|
||||
startProcess?: (input: { command: string; args: string[]; cwd: string; env: Record<string, string | undefined>; stdoutPath: string; stderrPath: string }) => Promise<{ pid: number }> | { pid: number };
|
||||
};
|
||||
|
||||
export async function main(argv = process.argv.slice(2), options: CliOptions = {}) {
|
||||
@@ -53,7 +54,9 @@ export async function runHwlabCli(argv: string[], options: CliOptions = {}) {
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
now,
|
||||
sleep: options.sleep ?? wait,
|
||||
runProcess: options.runProcess
|
||||
runProcess: options.runProcess,
|
||||
startProcess: options.startProcess,
|
||||
argv
|
||||
};
|
||||
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) };
|
||||
|
||||
Reference in New Issue
Block a user