fix: add async CaseRun CLI controls

This commit is contained in:
Codex Agent
2026-06-06 14:00:18 +08:00
parent d5ed5ceed9
commit 494d3ed3bb
4 changed files with 445 additions and 16 deletions
+75 -1
View File
@@ -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
View File
@@ -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);
}
+4 -1
View File
@@ -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) };