Files
pikasTech-HWLAB/tools/src/hwlab-caserun-runtime.ts
T

1535 lines
84 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPEC: PJ2026-01010305 71FREQ preinstall draft-2026-06-26-71freq-v03-hwpod-preinstall.
// Responsibility: v0.3 CaseRun runtime-origin resolution, evidence normalization, and compile-only HWPOD smoke orchestration; SPEC: PJ2026-0103 HarnessRL draft-2026-06-25-p0-web-caserun-e2e.
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { closeSync, openSync } from "node:fs";
import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { renderTraceRowsMarkdown, traceDisplayRows, traceNoiseEventCount, type TraceEventRow } from "./hwlab-cli/trace-renderer.ts";
import { collectAgentWorkspaceDiff } from "./hwlab-caserun-diff.ts";
import { inspectCaseRegistryRun, refreshCaseRegistryArtifacts, skillUsageCaseRegistryRun } from "./hwlab-caserun-diagnostics.ts";
import { agentTerminalEvidence, agentToolCallSummaryFromResult, agentWithResultEvidence, applyCaseRunEvidenceRelationships, clean, cliError, clipText, commandVisibility, commandsFromToolCallSummary, compactObject, firstNumberOption, firstTextOption, numberOption, parseJsonMaybe, sha256, summarizeAgentTrace, text, type AgentToolCallSummary } from "./hwlab-caserun-evidence.ts";
import { apiUrlFrom, webUrlFrom } from "./hwlab-caserun-runtime-config.ts";
import { readHwpodSpec } from "./hwpod-harness-lib.ts";
import { harnessRLRuntime } from "../../internal/harnessrl/runtime.ts";
import { createApiCaseRunTransport, createLocalCaseRunTransport, type CaseRunCommand, type CaseRunTransport } from "../../internal/harnessrl/transport.ts";
import { caseValidationPlanFromDefinition, hwpodCommandForValidationStep, normalizeValidationStepResult, validationReplayRelationship } from "./hwlab-caserun-validation.ts";
import { prepareSubjectWorktree, caseRunForSubjectSnapshot, subjectFromDefinition, requestSubjectWorktree, subjectWorktreeStatus, keilSidecarCopyOp, subjectRelativeKeilProject, stripUvprojx, windowsJoin, keilSidecarCopyNodeScript, keilSidecarSummary, objectRecord, isAbsolutePathLike, subjectRelativeWorktreePath, normalizeLocalPath, subjectWorktreePath, looksLikeWindowsPath, rewriteHwpodSpecWorkspacePath, pollKeilJobStatus, requestKeilJobStatus, keilJobStatus, hwpodResultTexts, artifactsFrom, slug } from "./hwlab-caserun-subject.ts";
import { agentTraceIdentityArtifact, agentTraceLookup, agentTraceLookupForRun, renderEvidenceSummary, agentStageSummary, agentStageCommand, agentCommandDetail, markdownTableText, writeCaseAggregateFromRegistry, resolveAggregateRunId, aggregateOutputRel, renderCaseAggregateMarkdown, aggregateTraceBody, aggregateMessagesWithFreshFullTrace, freshRenderedTraceBody, traceEventRowFromAggregate, aggregateBullets, aggregateInlineValue, markdownInline, aggregateDetails, escapeHtml, aggregateFence, aggregateArtifactIndex, registryAggregateFileRecord, archiveCaseRunArtifacts, syncCaseRegistryRun, syncCaseRegistryRel, gitProcess, collectSummaryFromArchive, writeReadableAgentArtifacts, archivedAgentTraceSnapshot, existingAgentTraceSummary, archivedAgentTraceBody, materializeFullAgentTraceWithExistingCli, traceRowsFromBody, traceEventRowFromObject, traceRowTone, applyArchivedAgentTraceSummary, agentTraceBody, agentFinalResponse, finalResponseMissingReason, agentMessageRowForArchive, renderAgentTranscript, renderTranscriptRow, renderFinalResponseArtifact, refreshCompletedRunArchive, caseRunArtifactEntries, copyCaseRunArtifact, registryArtifactFileRecord, caseRunArtifactManifest, aggregateManifestEntry, caseRunArtifactTrace, caseRegistryRunRel, artifactRel, artifactSourceLabel } from "./hwlab-caserun-closeout.ts";
const DEFAULT_POLL_INTERVAL_MS = 1000;
const DEFAULT_JOB_TIMEOUT_MS = 50000;
const MAX_JOB_TIMEOUT_MS = 300000;
const DEFAULT_AGENT_TIMEOUT_MS = 600000;
const MAX_AGENT_TIMEOUT_MS = 3600000;
const MAX_AGENT_TRACE_COMMANDS = 30;
const HWLAB_API_KEY_PREFIX = "hwl_live_";
const STANDARD_CASE_REPO_PATH = "/root/hwlab-case-registry";
const REMOVED_CASE_REPO_PATH = "/root/hwpod-cases";
const CASE_TRACE_RENDERER = "tools/src/hwlab-cli/trace-renderer:traceDisplayRows";
import type { ArchivedAgentTraceSnapshot, AgentDiffStage, AgentRunStage, AgentTraceCommand, AgentTraceStage, CaseContext, CaseReadableAgentArchive, CaseRegistryArchive, CaseRegistryArchiveFile, PreparedAgentTask, PreparedCaseRun, SourceRootSnapshot } from "./hwlab-caserun-shared.ts";
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 === "prompt") return promptCaseRun(context);
if (subcommand === "aggregate") return aggregateCaseRun(context);
if (subcommand === "refresh" || subcommand === "backfill") return refreshCaseRegistryArtifacts(context, await resolveCaseRepo(context));
if (subcommand === "prepare") return prepareCaseRun(context);
if (subcommand === "build") return buildCaseRun(context);
if (subcommand === "collect") return collectCaseRun(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 (["start", "status", "events", "aggregate", "cancel"].includes(mode)) return harnessRLCaseRunCommand(context, mode as CaseRunCommand["kind"]);
if (mode === "result") return resultCaseRun(context);
if (mode === "logs") return logsCaseRun(context);
if (mode === "inspect") return inspectCaseRegistryRun(context, await resolveCaseRepo(context), "inspect");
if (mode === "trace-summary" || mode === "trace") return inspectCaseRegistryRun(context, await resolveCaseRepo(context), "trace-summary");
if (mode === "skill-usage" || mode === "skills") return skillUsageCaseRegistryRun(context, await resolveCaseRepo(context));
if (mode === "worker") return runCaseRunWorker(context);
return runCaseRun(context);
}
export function caseHelp() {
return ok("case.help", {
runTransport: {
default: "local HarnessRL application service",
overApi: "explicit --over-api --base-url <origin>",
fallback: false
},
compileOnlyDefault: true,
agentTaskRequiredForRun: false,
autoEvaluation: false,
stateRoot: ".state/hwlab-cli/caserun",
standardCaseRepo: STANDARD_CASE_REPO_PATH,
subjectRequired: {
repoLocalPath: "local checkout path on the bound HWPOD node",
commitId: "fixed subject repo commit id; no GitHub clone/fetch fallback"
},
commands: [
`case prompt CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`,
`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 aggregate CASE_ID --run-id RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--output aggregate.md]`,
"case audit [--json] [--root PATH] [--source-dir PATH] [--max-lines N]",
`case refresh CASE_ID --run-id RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--source local|runtime]`,
`case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--provider-profile PROFILE]`,
`case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`,
`case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --over-api --base-url <origin> [--timeout-ms 30000] [--run-id RUN_ID]`,
"case run status RUN_ID [--over-api --base-url <origin> --timeout-ms 30000]",
"case run events RUN_ID [--over-api --base-url <origin> --timeout-ms 30000]",
"case run aggregate RUN_ID [--over-api --base-url <origin> --timeout-ms 30000]",
"case run cancel RUN_ID [--over-api --base-url <origin> --timeout-ms 30000]",
`case run inspect RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`,
`case run trace-summary RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`,
`case run skill-usage RUN_ID --skill SKILL_NAME --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`
],
notes: [
"case prompt 是无服务预览入口,只按真实 CaseRun 组装路径渲染输入 prompt,不准备 worktree、不提交 agent、不访问硬件。",
"case build executes the validation plan declared by the builtin v0.3 case definition.",
"case run prepares the subject worktree, submits a prompt to HWLAB Code Agent, records agent provenance and workspace diff, then runs compile validation.",
"case aggregate is registry-only and offline by default; it reads existing artifacts without querying runtime.",
"case refresh/backfill defaults to local registry artifacts; only explicit --source runtime may query the existing trace CLI.",
"case run inspect/trace-summary/skill-usage are bounded, service-free registry diagnostics.",
"case run start/status/events/aggregate/cancel share one HarnessRL command/DTO/error contract; start returns immediately and the remaining commands progressively disclose durable state.",
"The default local adapter calls the native HarnessRL application service directly; only explicit --over-api calls HTTP, and neither transport falls back to the other.",
"This version records raw stage evidence only; it does not auto-grade agent output, diff contents, or Keil evidence."
]
});
}
async function harnessRLCaseRunCommand(context: CaseContext, kind: CaseRunCommand["kind"]) {
const command: CaseRunCommand = kind === "start"
? { kind, caseId: requiredText(context.parsed.caseId ?? context.rest[2], "caseId"), ...(text(context.parsed.runId) ? { runId: text(context.parsed.runId) } : {}) }
: { kind, runId: requiredText(context.parsed.runId ?? context.rest[2], "runId") };
const transport = await (context.caseRunTransportFactory?.(context) ?? createHarnessRLTransport(context));
try {
const response = await transport.execute(command);
return ok(`case.run.${kind}`, {
transport: transport.mode,
command: kind,
...transport.visibility(command),
...response,
...(kind === "start" ? { startReturned: true } : {})
}, kind === "start" ? "submitted" : "succeeded");
} finally {
await transport.close();
}
}
function createHarnessRLTransport(context: CaseContext): CaseRunTransport {
if (context.parsed.overApi === true) return createApiCaseRunTransport({ baseUrl: text(context.parsed.baseUrl), fetchImpl: context.fetchImpl, apiKey: text(context.env.HWLAB_API_KEY) || undefined, timeoutMs: firstNumberOption(context.parsed.timeoutMs) });
if (context.parsed.baseUrl) throw cliError("caserun_over_api_required", "--base-url is only valid with explicit --over-api");
const env = {
...context.env,
HWLAB_CASERUN_REPO_ROOT: context.cwd,
...(text(context.parsed.caseRepo ?? context.parsed.caseRepoPath) ? { HWLAB_CASERUN_CASE_REPO: text(context.parsed.caseRepo ?? context.parsed.caseRepoPath) } : {}),
...(text(context.parsed.stateDir) ? { HARNESSRL_STATE_DIR: text(context.parsed.stateDir) } : {})
};
const runtime = harnessRLRuntime(env);
return createLocalCaseRunTransport({ service: runtime.service, close: () => runtime.service.close() });
}
export async function promptCaseRun(context: CaseContext) {
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 subject = subjectFromDefinition(loaded.definition);
const worktreePath = subjectWorktreePath(subject.repoLocalPath, runId);
const agentTask = agentTaskFromDefinition(loaded.definition, caseId);
const validationPlan = caseValidationPlanFromDefinition(loaded.definition);
const now = context.now();
const run: PreparedCaseRun = {
caseId,
runId,
runDir,
caseRepo,
caseDir: loaded.caseDir,
caseFile: loaded.caseFile,
sourceSpecPath: loaded.specPath,
specPath: loaded.specPath,
apiUrl: apiUrlFrom(context),
compileOnly: validationPlan.compileOnly,
validationPlan,
createdAt: now,
updatedAt: now,
definition: loaded.definition,
subject: { ...subject, worktreePath, workspacePath: worktreePath, setup: { previewOnly: true } },
agentTask
};
const prompt = await renderAgentTaskPrompt(run, agentTask);
return ok("case.prompt", {
caseId,
runId,
runDir,
caseRepo,
sourceSpecPath: loaded.specPath,
compileOnly: validationPlan.compileOnly,
validationPlan,
serviceRuntime: false,
previewOnly: true,
promptSha256: sha256(prompt),
agentTask: agentTaskSummary(agentTask),
subject: { repoLocalPath: subject.repoLocalPath, commitId: subject.commitId, worktreePath },
prompt
});
}
export async function aggregateCaseRun(context: CaseContext) {
const caseRepo = await resolveCaseRepo(context);
const aggregate = await writeCaseAggregateFromRegistry(context, caseRepo, { sync: context.parsed.noCaseRepoGitSync !== true });
return ok("case.aggregate", {
caseId: aggregate.caseId,
runId: aggregate.runId,
caseRepo,
caseRepoRunDir: aggregate.caseRepoRunDir,
aggregate: {
path: aggregate.path,
rel: aggregate.rel,
bytes: aggregate.bytes,
sha256: aggregate.sha256,
registrySync: aggregate.registrySync
},
aggregationOnly: true,
autoEvaluation: false
}, "completed");
}
export async function runCaseRun(context: CaseContext) {
const prepared = await prepareCaseRun(context, "run");
const agentStage = await runAgentTaskStage(context, prepared.run);
const traceStage = await collectAgentTraceEvidence(context, agentStage.run);
const diffStage = await collectAgentDiff(context, traceStage.run);
const built = await buildCaseRun(context, diffStage.run);
const collected = await collectCaseRun(context, built.run, built.evidence);
const resultPath = path.join(collected.run.runDir, "result.json");
const completed = await writeRunControl(context, collected.run, { status: "completed", stage: "completed", completedAt: context.now(), resultPath });
await writeJson(resultPath, compactCaseRunResult({ ...collected, run: completed.run }));
let finalArchive = context.parsed.noCaseRepoRecord === true ? null : await archiveCaseRunArtifacts(context, completed.run, collected.evidence, { sync: false });
let finalCollect = finalArchive ? collectSummaryFromArchive(completed.run, collected.evidence, finalArchive) : collected.summary;
await writeJson(resultPath, compactCaseRunResult({ ...collected, run: completed.run, summary: finalCollect }));
if (context.parsed.noCaseRepoRecord !== true) {
finalArchive = await archiveCaseRunArtifacts(context, completed.run, collected.evidence, { sync: true });
finalCollect = collectSummaryFromArchive(completed.run, collected.evidence, finalArchive);
await writeJson(resultPath, compactCaseRunResult({ ...collected, run: completed.run, summary: finalCollect }));
}
const finalAgentTrace = collected.evidence.agentTrace ?? traceStage.trace;
return ok("case.run", {
caseId: collected.run.caseId,
runId: collected.run.runId,
runDir: collected.run.runDir,
compileOnly: (collected as any).run.compileOnly,
prepare: prepared.summary,
agent: agentSummary(agentStage.agent),
agentTrace: traceSummary(finalAgentTrace),
traceLookup: agentTraceLookupForRun(traceStage.run),
agentDiff: diffSummary(diffStage.diff),
build: buildSummary(collected.evidence),
collect: finalCollect,
evidence: collected.evidence
}, "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: apiUrlFrom(context),
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: CaseContext = { ...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 {
let payload = await runCaseRun(workerContext);
const run = await readRunFromDir(runDir);
const completed = 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);
if (workerContext.parsed.noCaseRepoRecord !== true) {
const evidence = await readJsonIfExists(path.join(runDir, "evidence.json"));
if (evidence) {
let archive = await archiveCaseRunArtifacts(workerContext, completed.run, evidence, { sync: false });
payload = { ...payload, collect: collectSummaryFromArchive(completed.run, evidence, archive) };
await writeJson(path.join(runDir, "result.json"), payload);
archive = await archiveCaseRunArtifacts(workerContext, completed.run, evidence, { sync: true });
payload = { ...payload, collect: collectSummaryFromArchive(completed.run, evidence, archive) };
await writeJson(path.join(runDir, "result.json"), payload);
}
}
return payload;
} catch (error) {
const run = await readRunFromDir(runDir).catch(() => loadControlRunSkeleton(workerContext, caseId, runId, runDir));
const summary = errorSummary(error);
const blocker = extractPrepareBlocker(summary);
await writeRunControl(workerContext, run, { status: "failed", stage: "failed", completedAt: context.now(), exitCode: 1, error: summary, ...(blocker ? { blocker } : {}) });
throw error;
}
}
export async function statusCaseRun(context: CaseContext) {
const runId = requiredText(context.parsed.runId ?? context.rest[2], "runId");
let run = await readRunById(context, runId);
let control = controlFromRun(run);
let status = text(control.status) || text(run.status) || "unknown";
let stage = statusStage(run, control);
({ run, control, status, stage } = await finalizeBuildCompletedRun(context, run, control, status, stage));
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);
const registryArchive = status === "completed" ? await refreshCompletedRunArchive(context, run) : null;
return ok("case.run.status", {
caseId: run.caseId,
runId: run.runId,
runDir: run.runDir,
stateFile: path.join(run.runDir, "run.json"),
status,
stage: stage.stage,
staleControlWarning: stage.warning,
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,
registryArchive: registryArchive?.summary ?? null,
prepare: run.subject?.worktreePath ? prepareSummary(run) : null,
agent: run.agent ? agentSummary(run.agent) : null,
traceLookup: run.agent ? agentTraceLookupForRun(run) : null,
agentDiff: run.agentDiff ? diffSummary(run.agentDiff) : null,
blocker: control.blocker ?? 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");
let run = await readRunById(context, runId);
let control = controlFromRun(run);
let status = text(control.status) || text(run.status) || "unknown";
let stage = statusStage(run, control);
({ run, control, status, stage } = await finalizeBuildCompletedRun(context, run, control, status, stage));
if (status !== "completed") {
return ok("case.run.result", {
caseId: run.caseId,
runId: run.runId,
runDir: run.runDir,
status,
stage: stage.stage,
staleControlWarning: stage.warning,
ready: false,
agent: run.agent ? agentSummary(run.agent) : null,
traceLookup: run.agent ? agentTraceLookupForRun(run) : null,
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 resultPath = path.join(run.runDir, "result.json");
let result = await readJsonIfExists(resultPath);
let registryArchive = evidence ? await refreshCompletedRunArchive(context, run, evidence, { sync: false }) : null;
if (registryArchive && result && typeof result === "object") {
result = { ...result, collect: registryArchive.summary };
await writeJson(resultPath, result);
registryArchive = await refreshCompletedRunArchive(context, run, evidence, { sync: true });
result = { ...result, collect: registryArchive.summary };
await writeJson(resultPath, result);
} else if (evidence) {
registryArchive = await refreshCompletedRunArchive(context, run, evidence, { sync: true });
}
return ok("case.run.result", {
caseId: run.caseId,
runId: run.runId,
runDir: run.runDir,
status,
ready: true,
evidencePath,
resultPath,
registryArchive: registryArchive?.summary ?? null,
summary: evidence ? buildSummary(evidence) : null,
agent: run.agent ? agentSummary(run.agent) : null,
traceLookup: run.agent ? agentTraceLookupForRun(run) : 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);
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);
const subject = await prepareSubjectWorktree(context, { caseId, runId, runDir, apiUrl, definition: loaded.definition, sourceSpecPath: loaded.specPath }, collectSourceRootSnapshot);
const agentTask = agentTaskFromDefinition(loaded.definition, caseId);
const validationPlan = caseValidationPlanFromDefinition(loaded.definition);
const run: PreparedCaseRun = {
caseId,
runId,
runDir,
caseRepo,
caseDir: loaded.caseDir,
caseFile: loaded.caseFile,
sourceSpecPath: loaded.specPath,
specPath,
apiUrl,
compileOnly: validationPlan.compileOnly,
validationPlan,
createdAt: now,
updatedAt: now,
definition: loaded.definition,
subject,
agentTask
};
await mkdir(path.dirname(specPath), { recursive: true });
await writeFile(specPath, rewriteHwpodSpecWorkspacePath(loaded.specText, subject.workspacePath), "utf8");
const status = action === "prepare" ? "prepared" : "running";
await writeRunControl(context, run, { status, stage: "prepared" });
return ok(`case.${action}.prepare`, {
caseId,
runId,
runDir,
caseRepo,
specPath,
sourceSpecPath: loaded.specPath,
apiUrl,
compileOnly: validationPlan.compileOnly,
validationPlan,
agentTask: agentTaskSummary(agentTask),
subject,
summary: prepareSummary(run),
run
});
}
export async function buildCaseRun(context: CaseContext, prepared?: PreparedCaseRun) {
return buildCaseRunWithAuthorityMode(context, prepared, false);
}
export async function recoverBuildCaseRun(context: CaseContext, prepared?: PreparedCaseRun) {
return buildCaseRunWithAuthorityMode(context, prepared, true);
}
async function buildCaseRunWithAuthorityMode(context: CaseContext, prepared: PreparedCaseRun | undefined, recoverOnly: boolean) {
const run = prepared ?? await loadOrPrepareCaseRun(context);
const validationPlan = run.validationPlan ?? caseValidationPlanFromDefinition(run.definition);
const buildStep = validationPlan.steps.find((step) => step.kind === "build")!;
const command = hwpodCommandForValidationStep(buildStep, { executable: process.execPath, cliPath: path.join(context.cwd, "tools/hwpod-cli.ts"), specPath: run.specPath, reason: `case-run ${run.caseId} ${run.runId} ${validationPlan.mode}` });
const invoked = recoverOnly
? await recoverHwpodInvocation(context, run)
: await (context.runProcess ?? 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 hwpodDocument = await readHwpodSpec(run.specPath);
const operation = summarizeHwpodOperationForTest(hwpodPayload, hwpodDocument, "debug.build");
const jobId = operation.jobId;
const job = jobId ? await pollKeilJobStatus(context, run, jobId, caseTimeoutsFromDefinition(run.definition)) : null;
const buildWaitBlocker = buildWaitBlockerForTest(operation, job?.summary ?? null, invoked.exitCode);
const buildValidation = normalizeValidationStepResult({ step: buildStep, payload: hwpodPayload, document: hwpodDocument, exitCode: invoked.exitCode, stderr: invoked.stderr });
const validationResults = [{ ...buildValidation, blocker: buildWaitBlocker ?? buildValidation.blocker }];
for (const step of validationPlan.steps.filter((item) => item.kind !== "build")) {
if (validationResults.some((item) => item.blocker)) break;
const stepCommand = hwpodCommandForValidationStep(step, { executable: process.execPath, cliPath: path.join(context.cwd, "tools/hwpod-cli.ts"), specPath: run.specPath, reason: `case-run ${run.caseId} ${run.runId} ${step.kind}` });
const stepInvoked = await (context.runProcess ?? runProcess)(stepCommand, context.cwd, {
...process.env,
...context.env,
HWLAB_RUNTIME_API_URL: run.apiUrl,
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1"
});
validationResults.push(normalizeValidationStepResult({ step, payload: parseJsonMaybe(stepInvoked.stdout), document: hwpodDocument, exitCode: stepInvoked.exitCode, stderr: stepInvoked.stderr }));
}
const validationBlocker = validationResults.find((item) => item.blocker)?.blocker ?? null;
const validationReplay = validationReplayRelationship(validationPlan, validationResults);
const validationArchive = clean({ contractVersion: "hwpod-case-validation-observations-v1", caseId: run.caseId, runId: run.runId, observedAt: context.now(), plan: validationPlan, results: validationResults, replay: validationReplay });
await writeJson(path.join(run.runDir, "validation-observations.json"), validationArchive);
const evidence = clean({
contractVersion: "hwpod-case-run-evidence-v1",
caseId: run.caseId,
runId: run.runId,
compileOnly: validationPlan.compileOnly,
autoEvaluation: false,
status: validationBlocker ? "blocked" : "recorded",
observedAt: context.now(),
apiUrl: run.apiUrl,
subject: run.subject,
agentTask: run.agentTask ? agentTaskSummary(run.agentTask) : null,
agent: run.agent ? agentSummary(run.agent) : null,
traceLookup: agentTraceLookupForRun(run),
agentTrace: run.agentTrace ? traceSummary(run.agentTrace) : null,
agentDiff: run.agentDiff ? diffSummary(run.agentDiff) : null,
hwpod: clean({
source: "case-run-runner-post-agent-compile-check",
command: commandVisibility(command),
exitCode: invoked.exitCode,
stdoutJson: compactHwpodPayload(hwpodPayload),
stderr: clipText(invoked.stderr),
operation,
buildWaitBlocker
}),
keilJob: job ? job.summary : null,
validation: clean({ plan: validationPlan, results: validationResults, blocker: validationBlocker, archivePath: "validation-observations.json" }),
replay: validationReplay,
artifacts: job?.summary && "artifacts" in job.summary ? job.summary.artifacts : [],
decisions: {
autoEvaluation: false,
runnerPostAgentCompileCheck: "recorded",
reason: "CaseRun records Agent final independently and projects only authoritative HWPOD post-validation observations"
}
});
applyCaseRunEvidenceRelationships(evidence, run);
const evidencePath = path.join(run.runDir, "evidence.json");
await writeJson(evidencePath, evidence);
const completed = await writeRunControl(context, { ...run, status: "completed", stage: "completed", evidencePath }, { status: "completed", stage: "completed", completedAt: context.now(), exitCode: invoked.exitCode, evidencePath });
return ok("case.build", {
caseId: run.caseId,
runId: run.runId,
runDir: run.runDir,
compileOnly: validationPlan.compileOnly,
validationPlan,
summary: buildSummary(evidence),
evidence,
run: completed.run
}, "completed");
}
export function summarizeHwpodOperationForTest(payload: any, document: any, expectedOp = "") {
const response = objectRecord(payload?.body?.body ?? payload?.body ?? payload);
const results = Array.isArray(response.results) ? response.results : [];
const result = results.find((item: any) => !expectedOp || text(item?.op) === expectedOp) ?? results[0] ?? {};
const output = objectRecord(result?.output);
const expected = {
hwpodId: text(document?.metadata?.name ?? document?.metadata?.uid),
nodeId: text(document?.spec?.nodeBinding?.nodeId),
workspacePath: text(document?.spec?.workspace?.path)
};
const observed = {
hwpodId: firstTextOption(response.hwpodId, result?.hwpodId, output.hwpodId),
nodeId: firstTextOption(response.nodeId, result?.nodeId, output.nodeId),
workspacePath: firstTextOption(result?.workspacePath, output.workspacePath, output.cwd)
};
const mismatch = Object.entries(expected).flatMap(([field, value]) => {
if (!value) return [];
const observedValue = observed[field as keyof typeof observed];
return observedValue === value ? [] : [{ field, expected: value, observed: observedValue || null }];
});
return clean({
contractVersion: "hwpod-operation-result-v0.3",
authority: "case-hwpod-yaml",
op: text(result?.op) || expectedOp,
opId: text(result?.opId),
status: text(result?.status ?? response.status ?? payload?.status) || "unknown",
ok: result?.ok === true && mismatch.length === 0,
jobId: extractKeilJobId(payload),
expected,
observed: clean(observed),
topology: mismatch.length === 0 ? { ok: true } : { ok: false, blocker: { code: "hwpod_topology_mismatch", summary: "HWPOD operation result does not match YAML-selected topology", details: mismatch } },
blocker: result?.blocker ?? payload?.diagnostic ?? null
});
}
export function buildWaitBlockerForTest(operation: any, job: any, buildExitCode = 0) {
if (operation?.topology?.ok === false) return operation.topology.blocker;
if (operation?.blocker) return operation.blocker;
if (buildExitCode !== 0) return { code: "hwpod_build_operation_failed", summary: "HWPOD build operation failed before a waitable job was returned" };
if (!text(operation?.jobId)) return { code: "hwpod_build_job_id_missing", summary: "HWPOD build operation result did not return a jobId" };
if (job?.timedOut === true) return { code: "hwpod_build_wait_timeout", summary: "HWPOD build job did not reach a terminal state within the bounded wait", details: { jobId: operation.jobId, polls: job.polls } };
if (job && job.ok === false) return { code: "hwpod_build_failed", summary: "HWPOD build job reached a failed terminal state", details: { jobId: operation.jobId, status: job.status, returnCode: job.returnCode } };
const artifacts = Array.isArray(job?.artifacts) ? job.artifacts.map(String) : [];
if (job && !artifacts.some((item: string) => /\.hex$/iu.test(item))) return { code: "hwpod_build_hex_missing", summary: "HWPOD build completed without a HEX artifact", details: { jobId: operation.jobId, artifacts } };
return null;
}
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 archive = context.parsed.noCaseRepoRecord === true ? null : await archiveCaseRunArtifacts(context, run, evidence, { sync: !prepared });
const summary = archive ? collectSummaryFromArchive(run, evidence, archive) : {
caseRepo: run.caseRepo,
caseRepoRunDir: null,
caseRepoFiles: [],
artifactManifestPath: null,
trace: null,
traceLookup: agentTraceLookupForRun(run, evidence),
status: evidence.status,
autoEvaluation: false
};
return ok("case.collect", { caseId: run.caseId, runId: run.runId, runDir: run.runDir, compileOnly: run.compileOnly, summary, evidence, run }, "completed");
}
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);
if (explicit && isRemovedCaseRepoPath(path.resolve(context.cwd, explicit))) {
throw cliError("removed_case_repo_path", "the CaseRun registry moved to /root/hwlab-case-registry", { caseRepo: explicit, standardCaseRepo: STANDARD_CASE_REPO_PATH });
}
const candidates = explicit ? [path.resolve(context.cwd, explicit)] : [
path.resolve(context.cwd, "../hwlab-case-registry"),
STANDARD_CASE_REPO_PATH
];
for (const candidate of candidates) {
try {
await readFile(path.join(candidate, ".git", "HEAD"), "utf8");
return candidate;
} catch {
if (context.parsed.noCaseRepoRecord === true && await statLikeDirectory(path.join(candidate, "cases"))) return candidate;
}
}
throw cliError("case_repo_required", "case repo is required and must be a git checkout", { option: "--case-repo", standardCaseRepo: STANDARD_CASE_REPO_PATH, candidates });
}
async function statLikeDirectory(candidate: string) {
try {
const info = await stat(candidate);
return info.isDirectory();
} catch {
return false;
}
}
function isRemovedCaseRepoPath(candidate: string) {
const normalized = path.resolve(candidate);
return normalized === REMOVED_CASE_REPO_PATH || normalized.endsWith("/hwpod-cases");
}
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 };
}
function agentTaskFromDefinition(definition: Record<string, unknown>, caseId: string) {
const value = definition.agentTask;
if (value === undefined || value === null) return defaultAgentTask(definition, caseId);
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw cliError("invalid_agent_task", "case.json agentTask must be an object", { field: "agentTask" });
}
const source = value as Record<string, unknown>;
const prompt = text(source.prompt);
if (!prompt) throw cliError("agent_task_prompt_required", "case.json agentTask.prompt is required for Code Agent CaseRun", { field: "agentTask.prompt" });
const workspace = text(source.workspace) || "subjectWorktree";
if (workspace !== "subjectWorktree") throw cliError("unsupported_agent_task_workspace", "case.json agentTask.workspace must be subjectWorktree", { workspace });
const timeouts = caseTimeoutsFromDefinition(definition);
return {
prompt,
workspace,
constraints: stringArray(source.constraints),
providerProfile: text(source.providerProfile) || "deepseek",
projectId: text(source.projectId) || "prj_hwpod_workbench",
timeoutMs: numberOption(source.timeoutMs ?? source.agentTimeoutMs ?? timeouts.agentTimeoutMs),
pollIntervalMs: numberOption(source.pollIntervalMs ?? source.agentPollIntervalMs ?? timeouts.agentPollIntervalMs)
} as PreparedAgentTask;
}
export function caseTimeoutsFromDefinition(definition: Record<string, unknown>) {
const value = definition.timeouts;
if (!value || typeof value !== "object" || Array.isArray(value)) return {} as Record<string, unknown>;
return value as Record<string, unknown>;
}
function defaultAgentTask(definition: Record<string, unknown>, caseId: string): PreparedAgentTask {
const title = text(definition.title) || caseId;
return {
prompt: `请根据 CaseRun ${caseId}${title})在 isolated subject worktree 中完成任务准备或最小可观察修改;不要修改 case registry 或原 subject repo checkout。`,
workspace: "subjectWorktree",
constraints: [
"不得修改 case registry",
"不得修改原 subject repo checkout",
"只允许修改 isolated subject worktree",
"保持 compile-only 流程可继续执行"
],
providerProfile: "deepseek",
projectId: "prj_hwpod_workbench"
};
}
export async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) {
if (!run.agentTask) {
run = await updateRun(context, run, { agentTask: defaultAgentTask(run.definition, run.caseId) });
}
const promptPath = path.join(run.runDir, "agent-prompt.md");
const prompt = await renderAgentTaskPrompt(run, run.agentTask);
await writeFile(promptPath, prompt, "utf8");
const promptSha256 = sha256(prompt);
const baseUrl = webUrlFrom(context);
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 });
const nextRun = await updateRun(context, run, { agent });
return { run: nextRun, agent };
}
const sessionId = "sessionId" in session ? session.sessionId : "";
const threadId = "threadId" in session ? 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 resultPath = text(accepted.body?.resultUrl) || `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
const resultUrl = resultPath.startsWith("http") ? resultPath : `${baseUrl}${resultPath}`;
const traceLookup = agentTraceLookup({ baseUrl, traceId, sessionId, conversationId, threadId });
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,
timeoutMs: effectiveAgentTimeoutMs(context, run.agentTask),
pollIntervalMs: effectiveAgentPollIntervalMs(context, run.agentTask),
resultUrl,
nextPollCommand: traceLookup.commands.result,
traceCommand: traceLookup.commands.trace,
inspectCommand: traceLookup.commands.inspect,
sessionCommand: traceLookup.commands.sessionStatus,
traceLookup,
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 = agentWithResultEvidence({
stageStatus: result.stageStatus,
baseUrl,
projectId,
providerProfile,
conversationId,
sessionId,
threadId,
traceId,
promptPath,
promptSha256,
accepted: compactObject(accepted.body),
polls: result.polls,
timedOut: result.timedOut,
timeoutMs: effectiveAgentTimeoutMs(context, run.agentTask),
pollIntervalMs: effectiveAgentPollIntervalMs(context, run.agentTask),
resultUrl,
lastPollAt: result.lastPollAt,
lastHttpStatus: result.lastHttpStatus,
nextPollCommand: traceLookup.commands.result,
traceCommand: traceLookup.commands.trace,
inspectCommand: traceLookup.commands.inspect,
sessionCommand: traceLookup.commands.sessionStatus,
traceLookup,
sessionResponse: compactObject(session.body),
error: result.error
}, result.body, run.agentTask) as AgentRunStage;
const nextRun = await updateRun(context, run, { agent });
return { run: nextRun, agent };
}
export async function recoverAgentTaskStage(context: CaseContext, run: PreparedCaseRun) {
const baseUrl = webUrlFrom(context);
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)}`;
const traceId = text(context.parsed.traceId);
if (!traceId) throw Object.assign(new Error("AgentRun recovery requires the stable trace identity"), { code: "agentrun_recovery_identity_required" });
const existing = run.agent?.traceId === traceId ? run.agent : null;
const resultUrl = text(existing?.resultUrl) || `${baseUrl}/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
const recoveredAgent = existing ?? agentFailureStage({
stageStatus: "recovering",
baseUrl,
projectId,
providerProfile,
conversationId,
traceId,
promptPath: path.join(run.runDir, "agent-prompt.md"),
promptSha256: ""
});
const result = await pollAgentResult(context, { baseUrl, traceId, acceptedBody: { resultUrl }, resultUrl, run, agent: recoveredAgent, notFoundCode: "agentrun_task_not_found" });
const agent = agentWithResultEvidence({
...recoveredAgent,
stageStatus: result.stageStatus,
resultUrl,
polls: result.polls,
timedOut: result.timedOut,
lastPollAt: result.lastPollAt,
lastHttpStatus: result.lastHttpStatus,
error: result.error
}, result.body, run.agentTask) as AgentRunStage;
const nextRun = await updateRun(context, run, { stage: "agent-recovered", agent });
return { run: nextRun, agent };
}
export async function collectAgentTraceEvidence(context: CaseContext, run: PreparedCaseRun) {
const agent = run.agent;
const traceId = text(agent?.traceId);
if (!agent || !traceId) {
const trace: AgentTraceStage = clean({ traceId, status: "skipped_no_trace_id", httpStatus: 0, source: "caserun-identity", lookupOnly: true, commandCount: 0, hwpodCommandCount: 0, hwpodBuildCommandCount: 0, keilJobCandidates: [], commands: [] });
const nextRun = await updateRun(context, run, { agentTrace: trace, stage: "agent-trace-collected" });
return { run: nextRun, trace };
}
const traceBody = agentTraceIdentityArtifact(run, agent);
await writeJson(path.join(run.runDir, "agent-trace.json"), traceBody);
const trace = summarizeAgentTrace(traceId, 0, traceBody);
const nextRun = await updateRun(context, run, { agentTrace: trace, stage: "agent-trace-collected" });
return { run: nextRun, trace };
}
async function tryCreateAgentSession(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string }) {
try {
return await createAgentSession(context, input);
} catch (error) {
const code = typeof (error as any)?.code === "string" ? (error as any).code : "agent_session_exception";
return { ok: false, body: null, error: { code, message: error instanceof Error ? error.message : String(error), details: (error as any)?.details ?? null } };
}
}
function agentFailureStage(input: { stageStatus: string; baseUrl: string; projectId: string; providerProfile: string; conversationId: string; sessionId?: string; threadId?: string; traceId?: string; promptPath: string; promptSha256: string; accepted?: Record<string, unknown> | null; sessionResponse?: Record<string, unknown> | null; error?: Record<string, unknown> | null }): AgentRunStage {
return clean({
stageStatus: input.stageStatus,
baseUrl: input.baseUrl,
projectId: input.projectId,
providerProfile: input.providerProfile,
conversationId: input.conversationId,
sessionId: input.sessionId ?? "",
threadId: input.threadId ?? "",
traceId: input.traceId ?? "",
promptPath: input.promptPath,
promptSha256: input.promptSha256,
accepted: input.accepted ?? null,
result: null,
polls: 0,
timedOut: false,
sessionResponse: input.sessionResponse ?? null,
error: input.error ?? null
}) as AgentRunStage;
}
async function createAgentSession(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string }) {
const response = await caseFetchJson(context, `${input.baseUrl}/v1/agent/sessions`, {
method: "POST",
headers: caseAgentHeaders(context),
body: JSON.stringify({ projectId: input.projectId, providerProfile: input.providerProfile, conversationId: input.conversationId, updatedByClient: "hwlab-cli.case-run" })
}, "agent_session_create");
if (!response.ok) return { ok: false, error: response.error, body: response.body };
const session = response.body?.session ?? response.body;
const sessionId = text(session?.sessionId ?? session?.id);
if (!sessionId) return { ok: false, error: { code: "agent_session_id_missing", httpStatus: response.status, body: compactObject(response.body) }, body: response.body };
return {
ok: true,
sessionId,
threadId: text(session?.threadId ?? response.body?.threadId),
body: response.body
};
}
async function submitAgentTask(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string; sessionId: string; threadId: string; traceId: string; prompt: string }) {
return caseFetchJson(context, `${input.baseUrl}/v1/agent/chat`, {
method: "POST",
headers: { ...caseAgentHeaders(context), "x-trace-id": input.traceId, prefer: "respond-async", "x-hwlab-short-connection": "1" },
body: JSON.stringify(clean({
message: input.prompt,
conversationId: input.conversationId,
sessionId: input.sessionId,
threadId: input.threadId,
traceId: input.traceId,
providerProfile: input.providerProfile,
shortConnection: true,
projectId: input.projectId,
updatedByClient: "hwlab-cli.case-run"
}))
}, "agent_task_submit");
}
async function pollAgentResult(context: CaseContext, input: { baseUrl: string; traceId: string; acceptedBody: any; resultUrl?: string; run?: PreparedCaseRun; agent?: AgentRunStage; notFoundCode?: string }) {
const requestedTimeoutMs = effectiveAgentTimeoutMs(context, input.run?.agentTask);
const timeoutMs = Math.max(Math.min(requestedTimeoutMs, MAX_AGENT_TIMEOUT_MS), 1000);
const pollIntervalMs = effectiveAgentPollIntervalMs(context, input.run?.agentTask);
const startedAt = Date.now();
const resultPath = text(input.acceptedBody?.resultUrl) || `/v1/agent/chat/result/${encodeURIComponent(input.traceId)}`;
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 && response.status === 404 && input.notFoundCode) {
throw Object.assign(new Error(`AgentRun task ${input.traceId} was not found`), { code: input.notFoundCode, traceId: input.traceId, status: response.status });
}
if (!response.ok) return { stageStatus: "result_request_failed", body: lastBody, polls, timedOut: false, lastPollAt, lastHttpStatus: lastStatus, error: response.error };
if (input.run && input.agent) {
const runningAgent = agentWithResultEvidence({ ...input.agent, stageStatus: "running", polls, timeoutMs, pollIntervalMs, resultUrl, lastPollAt, lastHttpStatus: lastStatus }, lastBody, input.run.agentTask);
await updateRun(context, input.run, { stage: "agent-running", agent: runningAgent });
}
const terminal = agentTerminalEvidence(lastBody);
const status = text(lastBody?.status);
if ((status && status !== "running") || terminal.terminal) {
return { stageStatus: status && status !== "running" ? status : terminal.terminalStatus || "completed", body: lastBody, polls, timedOut: false, lastPollAt, lastHttpStatus: lastStatus, error: null };
}
await context.sleep(pollIntervalMs);
}
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 recoverHwpodInvocation(context: CaseContext, run: PreparedCaseRun) {
const planId = text(context.env.HWLAB_HWPOD_OPERATION_IDENTITY);
if (!planId) throw Object.assign(new Error("HWPOD recovery requires the stable operation identity"), { code: "hwpod_recovery_identity_required" });
const timeoutMs = Math.max(numberOption(context.parsed.timeoutMs) ?? DEFAULT_KEIL_COMMAND_TIMEOUT_MS, 1000);
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const response = await context.fetchImpl(`${run.apiUrl}/v1/hwpod-node-ops?planId=${encodeURIComponent(planId)}`, { method: "GET" });
const body = parseJsonMaybe(await response.text());
if (response.status === 404) throw Object.assign(new Error(`HWPOD operation ${planId} was not found`), { code: "hwpod_operation_not_found", planId });
if (!response.ok) throw Object.assign(new Error(`HWPOD operation lookup failed with HTTP ${response.status}`), { code: "hwpod_operation_lookup_failed", planId, status: response.status });
if (body?.result) return { stdout: JSON.stringify(body.result), stderr: "", exitCode: body.result.ok === false ? 1 : 0 };
await context.sleep(Math.max(numberOption(context.parsed.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS, 250));
}
throw Object.assign(new Error(`HWPOD operation ${planId} did not complete within ${timeoutMs}ms`), { code: "hwpod_operation_recovery_timeout", planId, timeoutMs });
}
function effectiveAgentTimeoutMs(context: CaseContext, agentTask?: PreparedAgentTask | null) {
return numberOption(context.parsed.agentTimeoutMs ?? context.parsed.timeoutMs) ?? agentTask?.timeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
}
function effectiveAgentPollIntervalMs(context: CaseContext, agentTask?: PreparedAgentTask | null) {
return Math.max(numberOption(context.parsed.agentPollIntervalMs ?? context.parsed.pollIntervalMs) ?? agentTask?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, 250);
}
export async function collectAgentDiff(context: CaseContext, run: PreparedCaseRun) {
const status = await requestSubjectCommand(context, run, ["-C", run.subject.worktreePath, "status", "--short"]);
const diffStat = await requestSubjectCommand(context, run, ["-C", run.subject.worktreePath, "diff", "--stat"]);
const diffPatch = await requestSubjectCommand(context, run, ["-C", run.subject.worktreePath, "diff", "--binary"]);
const collected = await collectAgentWorkspaceDiff({
definition: run.definition,
subjectSubdir: run.subject.subdir,
worktreePath: run.subject.worktreePath,
statusShort: text(status.stdout),
trackedDiffStat: text(diffStat.stdout),
trackedPatchText: text(diffPatch.stdout),
requestGit: (args) => requestSubjectCommand(context, run, ["-C", run.subject.worktreePath, ...args]),
requestProcess: (command, args) => requestSubjectProcess(context, run, command, args)
});
const sourceAfterAgent = await collectSourceRootSnapshot(context, run, "after-agent");
const statusShort = text(status.stdout);
const patchText = collected.patchText;
const diffPatchPath = path.join(run.runDir, "agent-diff.patch");
await writeFile(diffPatchPath, patchText, "utf8");
const diff: AgentDiffStage = {
statusShort,
diffStat: collected.diffStat,
diffPatchPath,
diffPatchSha256: sha256(patchText),
diffCollection: collected.collection,
sourceRootStatusShort: sourceAfterAgent.statusShort,
sourceRootBaseline: run.subject.sourceRootBaseline,
sourceRootAfterPrepare: run.subject.sourceRootAfterPrepare,
sourceRootAfterAgent: sourceAfterAgent,
sourceRootChangedAfterPrepare: sourceRootChanged(run.subject.sourceRootBaseline, run.subject.sourceRootAfterPrepare),
sourceRootChangedAfterAgent: sourceRootChanged(run.subject.sourceRootAfterPrepare ?? run.subject.sourceRootBaseline, sourceAfterAgent),
requests: [
requestSummary("status", status),
requestSummary("diff-stat", diffStat),
requestSummary("diff-patch", diffPatch),
...collected.requests.map((request) => requestSummary(request.label, request.result)),
{ label: "source-root-baseline", statusShort: run.subject.sourceRootBaseline?.statusShort ?? "", diffSha256: run.subject.sourceRootBaseline?.diffSha256 ?? "" },
{ label: "source-root-after-prepare", statusShort: run.subject.sourceRootAfterPrepare?.statusShort ?? "", diffSha256: run.subject.sourceRootAfterPrepare?.diffSha256 ?? "" },
{ label: "source-root-after-agent", statusShort: sourceAfterAgent.statusShort, diffSha256: sourceAfterAgent.diffSha256 }
]
};
const nextRun = await updateRun(context, run, { agentDiff: diff });
return { run: nextRun, diff };
}
export async function requestSubjectProcess(context: CaseContext, run: PreparedCaseRun, command: string, argv: string[]) {
const document = await readHwpodSpec(run.specPath);
const hwpodId = text(document.metadata.name) || text(document.metadata.uid) || run.caseId;
const plan = {
contractVersion: "hwpod-node-ops-v1",
planId: stableOperationIdentity(context, `subject-${command}-${argv.join("-")}`),
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_subject_cmd", op: "cmd.run", args: { hwpodId, workspacePath: run.subject.repoLocalPath, command, 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();
const body = parseJsonMaybe(textBody) ?? { raw: textBody };
const result = firstHwpodOutput(body);
const exitCode = numberOption(result?.exitCode) ?? (response.status >= 200 && response.status < 300 ? 0 : 1);
return { ok: response.status >= 200 && response.status < 300 && exitCode === 0, httpStatus: response.status, exitCode, stdout: text(result?.stdout), stderr: text(result?.stderr), body: compactObject(body), command: [command, ...argv] };
}
export async function collectSourceRootSnapshot(context: CaseContext, run: PreparedCaseRun, label: string): Promise<SourceRootSnapshot> {
const status = await requestSubjectCommand(context, run, ["status", "--short"]);
const diffStat = await requestSubjectCommand(context, run, ["diff", "--stat"]);
const diffPatch = await requestSubjectCommand(context, run, ["diff", "--binary"]);
const diffText = text(diffPatch.stdout);
return {
label,
statusShort: text(status.stdout),
diffStat: text(diffStat.stdout),
diffSha256: sha256(diffText),
diffBytes: Buffer.byteLength(diffText),
commands: {
status: requestSummary("source-root-status", status),
diffStat: requestSummary("source-root-diff-stat", diffStat),
diffPatch: requestSummary("source-root-diff-patch", diffPatch)
}
};
}
function sourceRootChanged(before?: SourceRootSnapshot, after?: SourceRootSnapshot) {
if (!before || !after) return undefined;
return before.statusShort !== after.statusShort || before.diffSha256 !== after.diffSha256;
}
async function requestSubjectCommand(context: CaseContext, run: PreparedCaseRun, argv: string[]) {
const document = await readHwpodSpec(run.specPath);
const hwpodId = text(document.metadata.name) || text(document.metadata.uid) || run.caseId;
const plan = {
contractVersion: "hwpod-node-ops-v1",
planId: stableOperationIdentity(context, `subject-git-${argv.join("-")}`),
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_subject_cmd",
op: "cmd.run",
args: { hwpodId, workspacePath: run.subject.repoLocalPath, command: "git", 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();
const body = parseJsonMaybe(textBody) ?? { raw: textBody };
const result = firstHwpodOutput(body);
const exitCode = numberOption(result?.exitCode) ?? (response.status >= 200 && response.status < 300 ? 0 : 1);
return { ok: response.status >= 200 && response.status < 300 && exitCode === 0, httpStatus: response.status, exitCode, stdout: text(result?.stdout), stderr: text(result?.stderr), body: compactObject(body) };
}
function stableOperationIdentity(context: CaseContext, suffix: string) {
const base = text(context.parsed.operationIdentity);
return base ? `${base}:${slug(suffix)}` : `case_subject_cmd_${randomUUID()}`;
}
async function updateRun(context: CaseContext, run: PreparedCaseRun, patch: Partial<PreparedCaseRun> & Record<string, unknown>) {
const nextRun = { ...run, ...patch, updatedAt: context.now() } as PreparedCaseRun;
await writeRunState(nextRun);
return nextRun;
}
async function renderAgentTaskPrompt(run: PreparedCaseRun, agentTask: PreparedAgentTask) {
const hwpodDocument = await readHwpodSpec(run.specPath);
const hwpodId = text(hwpodDocument.metadata.name) || text(hwpodDocument.metadata.uid) || run.caseId;
const hwpodWorkspaceArgs = `--hwpod-id ${hwpodId} --workspace-path ${promptShellArg(run.subject.worktreePath)}`;
const validationPlan = run.validationPlan ?? caseValidationPlanFromDefinition(run.definition);
const validationModeLine = validationPlan.compileOnly
? "验证模式: 仅执行编译构建检查;不下载、不做运行态冒烟验证。"
: `验证模式: ${validationPlan.mode}Agent final CaseRun 后置 HWPOD validation 独立记录。`;
const constraints = [
"思维过程和输出消息一律使用中文",
...agentTask.constraints,
"只能修改 isolated subject worktree,不得修改 case registry repo。",
"不得修改原 subject repo checkout;所有源码修改必须落在 subjectWorktreePath。",
"AgentRun 通过 kind=gitbundle 装配当前 v0.2 的 tools/ 与 .agents/skills;若标准 hwpod 命令能力缺失,报告 gitbundle runtime assembly 问题,不要改走旁路。",
"不要依赖 workspaceFiles、seed files、hostPath skill 目录、ConfigMap 或 runner 本地 .hwlab/hwpod-spec.yaml 作为工具/skill/HWPOD 注入 fallback。",
`必须通过 HWPOD registry/service 引用 hwpodId=${hwpodId};所有 hwpod/hwpod-ctl 命令都携带 ${hwpodWorkspaceArgs}`,
"如果 case prompt 或旧帮助文本提到 .hwlab/hwpod-spec.yaml,把它视为过时写法并替换为本任务给出的 --hwpod-id/--workspace-path 参数;不要创建、复制或修补本地 spec 文件。",
"若 case prompt 要求源码修改,必须只改 subjectWorktreePath;若 prompt 未要求源码修改,则保持 subject 源码不变。",
"不要运行 CaseRun 答案执行器;你本人必须通过 hwpod/hwpod-ctl 标准入口触发编译验证。",
`hwpod build/download 是长任务短连接入口;不要再用 shell sleep/&&/timeout/watch/head/pipe 或 shell loop 包住它们。记录返回 JSON 里的 jobId/job_id,再用独立的 hwpod job status <jobId> ${hwpodWorkspaceArgs} 短命令做有限轮询。`
].filter(Boolean);
return [
`# HWPOD CaseRun 代码代理任务`,
``,
`案例ID: ${run.caseId}`,
`运行ID: ${run.runId}`,
`主体仓库本地路径: ${run.subject.repoLocalPath}`,
`主体提交ID: ${run.subject.commitId}`,
`主体隔离工作区路径: ${run.subject.worktreePath}`,
`hwpodId: ${hwpodId}`,
`hwpodWorkspaceArgs: ${hwpodWorkspaceArgs}`,
validationModeLine,
`CaseRun 后置验证动作: ${validationPlan.steps.map((step) => `${step.actionId}:${step.kind}`).join(", ")}`,
``,
`## 运行时装配`,
`AgentRun 通过 ResourceBundleRef kind=gitbundle 装配 HWLAB 运行时资源:仓库子路径 \`tools\` 会复制到工作区 \`tools\`,仓库子路径 \`skills\` 会复制到工作区 \`.agents/skills\`。CaseRun 不再发送临时 workspaceFiles 或 seed-file 载荷。`,
``,
`## HWPOD 运行时`,
`通过运行时服务按 ID 使用 HWPOD,不要求 runner 本地存在 \`.hwlab/hwpod-spec.yaml\`。`,
`每个 hwpod/hwpod-ctl 命令都必须携带这些参数:\`${hwpodWorkspaceArgs}\`。`,
`本任务的标准冒烟步骤:`,
`- \`hwpod-ctl spec validate ${hwpodWorkspaceArgs}\``,
`- \`hwpod inspect ${hwpodWorkspaceArgs}\``,
`- 工作区读取、搜索和编辑:\`hwpod workspace ... ${hwpodWorkspaceArgs}\``,
`- 编译检查:\`hwpod build ${hwpodWorkspaceArgs}\``,
``,
`## 任务`,
agentTask.prompt,
``,
`## 约束`,
...constraints.map((item) => `- ${item}`),
``,
`## 执行流程`,
`- 先确认标准 \`hwpod\`、\`hwpod-ctl\` 和 \`hwpod-compiler\` 命令可从 gitbundle 工具目录调用。`,
`- 使用标准 hwpod/hwpod-ctl 命令并携带 \`${hwpodWorkspaceArgs}\` 完成案例任务。只有案例明确要求时才运行 build/download/UART 步骤,并回报返回的 JSON、job、artifact 或串口摘要。`,
`- 对 hwpod build/download,保持 HWPOD 命令本身不被包装,让它返回异步 JSON;随后用独立短命令 \`hwpod job status <jobId> ${hwpodWorkspaceArgs}\` 对返回的 job id 做有限轮询。不要用 shell sleep、&&、timeout、watch、head、pipe 或 shell loop 包裹状态轮询。`,
`- 你的回合结束后,CaseRun 会检查 subjectWorktreePath 下的 git diff,并可能把 runner 后置编译作为单独证据记录。`,
`- CaseRun 只记录 trace、session、conversation、agent 命令执行、workspace diff 和 Keil 构建证据,不做自动评分或门禁判断。`
].join("\n");
}
async function caseFetchJson(context: CaseContext, url: string, init: RequestInit, errorCode: string) {
try {
const response = await context.fetchImpl(url, init);
const textBody = await response.text();
const body = parseJsonMaybe(textBody) ?? { raw: textBody };
const ok = response.status >= 200 && response.status < 300 && body?.ok !== false;
return { ok, status: response.status, body, error: ok ? null : { code: errorCode, httpStatus: response.status, body: compactObject(body) } };
} catch (error) {
return { ok: false, status: 0, body: null, error: { code: errorCode, message: error instanceof Error ? error.message : String(error) } };
}
}
function caseAgentHeaders(context: CaseContext) {
const apiKey = text(context.env.HWLAB_API_KEY);
if (!apiKey.startsWith(HWLAB_API_KEY_PREFIX)) throw cliError("api_key_required", "case run Code Agent stage requires HWLAB_API_KEY for HWLAB Cloud Web", { allowed: "HWLAB_API_KEY" });
return { "content-type": "application/json", authorization: `Bearer ${apiKey}` };
}
function firstHwpodOutput(body: any) {
const results = Array.isArray(body?.body?.results) ? body.body.results : Array.isArray(body?.results) ? body.results : [];
const first = results[0] ?? null;
return first?.output ?? first;
}
function requestSummary(label: string, value: any) {
return { label, httpStatus: value.httpStatus, exitCode: value.exitCode, stdoutBytes: Buffer.byteLength(text(value.stdout)), stderr: clipText(value.stderr, 1000) };
}
function agentTaskSummary(agentTask: PreparedAgentTask) {
return clean({ workspace: agentTask.workspace, providerProfile: agentTask.providerProfile, projectId: agentTask.projectId, timeoutMs: agentTask.timeoutMs, pollIntervalMs: agentTask.pollIntervalMs, promptSha256: sha256(agentTask.prompt), constraints: agentTask.constraints });
}
function agentSummary(agent: AgentRunStage) {
return clean({ stageStatus: agent.stageStatus, terminalStatus: agent.terminalStatus, commandStatus: agent.commandStatus, agentRunStatus: agent.agentRunStatus, baseUrl: agent.baseUrl, projectId: agent.projectId, providerProfile: agent.providerProfile, requestedProviderProfile: agent.requestedProviderProfile, resolvedBackendProfile: agent.resolvedBackendProfile, provider: agent.provider, model: agent.model, backend: agent.backend, infrastructureBackend: agent.infrastructureBackend, providerTrace: agent.providerTrace, conversationId: agent.conversationId, sessionId: agent.sessionId, threadId: agent.threadId, traceId: agent.traceId, promptPath: agent.promptPath, promptSha256: agent.promptSha256, polls: agent.polls, timedOut: agent.timedOut, timeoutMs: agent.timeoutMs, pollIntervalMs: agent.pollIntervalMs, lastHttpStatus: agent.lastHttpStatus, nextPollCommand: agent.nextPollCommand, traceCommand: agent.traceCommand, inspectCommand: agent.inspectCommand, sessionCommand: agent.sessionCommand, traceLookup: agent.traceLookup, toolCallSummary: agent.toolCallSummary, error: agent.error });
}
function traceSummary(trace: AgentTraceStage) {
return clean({
traceId: trace.traceId,
status: trace.status,
httpStatus: trace.httpStatus,
source: trace.source,
lookupOnly: trace.lookupOnly,
lookup: trace.lookup,
sourceEventCount: trace.sourceEventCount,
renderedRowCount: trace.renderedRowCount,
commandCount: trace.commandCount,
hwpodCommandCount: trace.hwpodCommandCount,
hwpodBuildCommandCount: trace.hwpodBuildCommandCount,
keilJobCandidates: trace.keilJobCandidates,
terminalStatus: trace.terminalStatus,
agentRun: trace.agentRun,
toolCallSummary: trace.toolCallSummary,
commands: trace.commands,
error: trace.error
});
}
function diffSummary(diff: AgentDiffStage) {
return clean({
statusShort: diff.statusShort,
diffStat: diff.diffStat,
diffPatchPath: diff.diffPatchPath,
diffPatchSha256: diff.diffPatchSha256,
diffCollection: diff.diffCollection,
sourceRootStatusShort: diff.sourceRootStatusShort,
sourceRootBaseline: diff.sourceRootBaseline,
sourceRootAfterPrepare: diff.sourceRootAfterPrepare,
sourceRootAfterAgent: diff.sourceRootAfterAgent,
sourceRootChangedAfterPrepare: diff.sourceRootChangedAfterPrepare,
sourceRootChangedAfterAgent: diff.sourceRootChangedAfterAgent,
requests: diff.requests
});
}
function stringArray(value: unknown) {
return Array.isArray(value) ? value.map((item) => text(item)).filter(Boolean) : [];
}
function promptShellArg(value: string) {
return `'${String(value).replace(/'/gu, `'\\''`)}'`;
}
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, subject: { repoLocalPath: run.subject.repoLocalPath, commitId: run.subject.commitId, worktreePath: run.subject.worktreePath } };
}
function buildSummary(evidence: any) {
return { status: evidence.status, autoEvaluation: false, traceLookup: evidence.traceLookup ?? null, agentTraceCommandCount: evidence.agentTrace?.commandCount ?? 0, agentTraceHwpodCommandCount: evidence.agentTrace?.hwpodCommandCount ?? 0, agentStage: agentStageSummary(evidence), agentFinal: evidence.agentFinal ?? null, postValidation: evidence.postValidation ?? null, warnings: evidence.warnings ?? null, hwpodExitCode: evidence.hwpod?.exitCode ?? null, hwpodSource: evidence.hwpod?.source ?? null, jobId: evidence.keilJob?.jobId ?? null, keilStatus: evidence.keilJob?.status ?? null, artifacts: evidence.artifacts ?? [] };
}
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 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 });
}
async function writeJson(file: string, value: any) {
await mkdir(path.dirname(file), { recursive: true });
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: syncControlStage(control, run) });
}
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 finalizeBuildCompletedRun(context: CaseContext, run: PreparedCaseRun, control: Record<string, unknown>, status: string, stage: ReturnType<typeof statusStage>) {
if (status === "completed" || status === "failed" || stage.stage !== "build-completed") return { run, control, status, stage };
const evidencePath = text(run.evidencePath ?? control.evidencePath) || path.join(run.runDir, "evidence.json");
if (!await fileExists(evidencePath)) return { run, control, status, stage };
const completed = await writeRunControl(context, { ...run, status: "completed", stage: "completed", evidencePath }, { status: "completed", stage: "completed", completedAt: context.now(), evidencePath });
return { run: completed.run, control: completed.control, status: "completed", stage: statusStage(completed.run, completed.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; }
}
async function readTextIfExists(file: string) {
try { return await readFile(file, "utf8"); } catch { return ""; }
}
function controlFromRun(run: any) {
return run?._control && typeof run._control === "object" ? run._control : {};
}
function syncControlStage(control: Record<string, unknown>, run: PreparedCaseRun) {
const status = text(control.status);
if (status && !["completed", "failed"].includes(status)) return clean({ ...control, stage: latestRunStage(run) });
return 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: apiUrlFrom(context),
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 fileExists(file: string) {
try { return (await stat(file)).isFile(); } catch { return false; }
}
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 latestRunStage(run: PreparedCaseRun) {
return selectLatestStage([text(run.stage), inferRunStage(run)]);
}
function statusStage(run: PreparedCaseRun, control: Record<string, unknown>) {
const controlStage = text(control.stage);
const runStage = text(run.stage);
const inferredStage = inferRunStage(run);
const selectedStage = selectLatestStage([controlStage, runStage, inferredStage]);
const terminalControl = ["completed", "failed"].includes(controlStage);
const stage = terminalControl ? controlStage : selectedStage || "queued";
const warning = controlStage && controlStage !== stage && !terminalControl ? {
code: "stale_control_stage",
controlStage,
runStage: runStage || null,
inferredStage,
selectedStage: stage
} : undefined;
return { stage, warning };
}
function selectLatestStage(stages: string[]) {
return stages.filter(Boolean).reduce((best, stage) => stageRank(stage) >= stageRank(best) ? stage : best, "");
}
function stageRank(stage: string) {
if (stage === "completed" || stage === "failed") return 100;
if (stage === "build-completed") return 90;
if (stage === "agent-diff-collected") return 80;
if (stage === "agent-trace-collected") return 70;
if (stage === "agent-completed") return 65;
if (stage === "agent-running") return 60;
if (stage === "agent-submitting") return 50;
if (stage === "agent-session-starting") return 40;
if (stage === "worker-running") return 35;
if (stage === "started") return 34;
if (stage === "queued") return 30;
if (stage === "prepared") return 20;
if (stage.startsWith("agent-")) return 55;
return 0;
}
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 extractPrepareBlocker(summary: ReturnType<typeof errorSummary>) {
const results = (summary.details as any)?.body?.body?.results ?? (summary.details as any)?.body?.results ?? [];
if (!Array.isArray(results) || results.length === 0) return null;
for (const r of results) {
const blocker = (r as any)?.blocker;
if (blocker && typeof blocker.code === "string") {
return { code: blocker.code, summary: blocker.summary ?? "", layer: blocker.layer ?? null };
}
}
return null;
}
function ok<T extends Record<string, unknown>>(action: string, extra: T, status = "succeeded") { return { ok: status !== "failed", action, status, ...extra }; }
function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw cliError("missing_required_value", `${field} is required`, { field }); return result; }