From 517110572d9400e93ec0077f054b03ef0c41c12d Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 14:06:40 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=94=B6=E5=8F=A3=20CaseRun=20?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=E4=B8=8E=E7=BB=88=E6=80=81=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/hwlab-cli/caserun.test.ts | 85 +- tools/src/hwlab-caserun-closeout.ts | 834 ++++++++ tools/src/hwlab-caserun-evidence.ts | 276 +++ tools/src/hwlab-caserun-lib.ts | 2913 +-------------------------- tools/src/hwlab-caserun-runtime.ts | 1459 ++++++++++++++ tools/src/hwlab-caserun-shared.ts | 201 ++ tools/src/hwlab-caserun-subject.ts | 372 ++++ 7 files changed, 3212 insertions(+), 2928 deletions(-) create mode 100644 tools/src/hwlab-caserun-closeout.ts create mode 100644 tools/src/hwlab-caserun-evidence.ts create mode 100644 tools/src/hwlab-caserun-runtime.ts create mode 100644 tools/src/hwlab-caserun-shared.ts create mode 100644 tools/src/hwlab-caserun-subject.ts diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index 6f709a91..34fd6342 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -29,7 +29,7 @@ test("hwlab-cli case prepare copies a case hwpod-spec into isolated run state", const requests: any[] = []; const result = await runHwlabCli(["case", "prepare", "d601-f103-v2-compile", "--case-repo", caseRepo, "--run-id", "run-test"], { cwd, now: () => "2026-06-05T00:00:00.000Z", env: TEST_RUNTIME_ENV, fetchImpl: subjectWorktreeFetch(requests) }); - assert.equal(result.exitCode, 0); + assert.equal(result.exitCode, 0, JSON.stringify(result.payload, null, 2)); assert.equal(result.payload.action, "case.prepare.prepare"); assert.equal(result.payload.runId, "run-test"); assert.equal(result.payload.caseRepo, caseRepo); @@ -162,7 +162,7 @@ test("case prompt previews the real assembled prompt in Chinese without starting const result = await runHwlabCli(["case", "prompt", "d601-f103-v2-prompt-preview", "--case-repo", caseRepo, "--run-id", "run-prompt-preview"], { cwd, now: () => "2026-06-08T00:00:00.000Z", env: TEST_RUNTIME_ENV }); - assert.equal(result.exitCode, 0); + assert.equal(result.exitCode, 0, JSON.stringify(result.payload, null, 2)); assert.equal(result.payload.action, "case.prompt"); assert.equal(result.payload.previewOnly, true); assert.equal(result.payload.serviceRuntime, false); @@ -275,7 +275,7 @@ test("case run start returns immediately and status/result/logs are short pollin } }); -test("case run result refreshes registry artifacts after worker stdout is finalized", async () => { +test("case run result finalizes legacy build-completed state and refreshes registry artifacts", async () => { const root = await mkdtempCaseRoot(); const caseRepo = path.join(root, "hwlab-case-registry"); const cwd = path.join(root, "hwlab"); @@ -304,12 +304,12 @@ test("case run result refreshes registry artifacts after worker stdout is finali compileOnly: true, createdAt: "2026-06-05T00:00:00.000Z", updatedAt: "2026-06-05T00:00:10.000Z", - status: "completed", - stage: "completed", + status: "running", + stage: "build-completed", definition: {}, subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline", worktreePath: "F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-run-refresh-registry", workspacePath: "", setup: {} }, evidencePath: path.join(runDir, "evidence.json"), - _control: { status: "completed", stage: "completed", stdoutPath: path.join(runDir, "worker.stdout.log"), stderrPath: path.join(runDir, "worker.stderr.log"), completedAt: "2026-06-05T00:00:10.000Z", resultPath: path.join(runDir, "result.json") } + _control: { status: "running", stage: "build-completed", stdoutPath: path.join(runDir, "worker.stdout.log"), stderrPath: path.join(runDir, "worker.stderr.log"), resultPath: path.join(runDir, "result.json") } }; const evidence = { contractVersion: "hwpod-case-run-evidence-v1", @@ -344,6 +344,12 @@ test("case run result refreshes registry artifacts after worker stdout is finali assert.equal(manifest.files.some((item: any) => item.path === "worker.stdout.log" && item.bytes === Buffer.byteLength("final worker payload\n")), true); const registryResult = JSON.parse(await readFile(path.join(registryRunDir, "result.json"), "utf8")); assert.equal(registryResult.collect.artifactManifestPath, path.join(registryRunDir, "artifact-manifest.json")); + const finalizedRun = JSON.parse(await readFile(path.join(runDir, "run.json"), "utf8")); + assert.equal(finalizedRun.status, "completed"); + assert.equal(finalizedRun.stage, "completed"); + assert.equal(finalizedRun._control.status, "completed"); + assert.equal(finalizedRun._control.stage, "completed"); + assert.equal(finalizedRun._control.completedAt, "2026-06-05T00:00:11.000Z"); } finally { await rm(root, { recursive: true, force: true }); } @@ -398,6 +404,7 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit assert.equal(runState.agent.threadId, "thread-case"); assert.equal(runState.agent.timeoutMs, 234000); assert.equal(runState.agent.pollIntervalMs, 250); + assert.equal(runState.agent.toolCallSummary.count, 3); assert.match(runState.agent.traceId, /^trc_case_/u); assert.match(runState.agent.nextPollCommand, /client agent result trc_case_/u); assert.equal(runState._control.stage, "agent-running"); @@ -427,7 +434,7 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit fetchImpl: caseRunFlowFetch(requests) }); - assert.equal(result.exitCode, 0); + assert.equal(result.exitCode, 0, JSON.stringify(result.payload, null, 2)); assert.equal(result.payload.action, "case.run"); assert.equal(result.payload.status, "completed"); assert.equal(result.payload.evidence.status, "recorded"); @@ -436,16 +443,27 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit assert.equal(result.payload.agent.stageStatus, "completed"); assert.equal(result.payload.agent.traceId.startsWith("trc_case_"), true); assert.equal(result.payload.agent.timeoutMs, 234000); + assert.equal(result.payload.agent.requestedProviderProfile, "deepseek"); + assert.equal(result.payload.agent.resolvedBackendProfile, "dsflash-go"); + assert.equal(result.payload.agent.model, "deepseek-v4-flash"); + assert.equal(result.payload.agent.infrastructureBackend, "agentrun-v01/dsflash-go"); + assert.equal(result.payload.agent.terminalStatus, "completed"); + assert.equal(result.payload.agent.toolCallSummary.count, 3); assert.equal(result.payload.traceLookup.commands.trace, `hwlab-cli client agent trace ${result.payload.agent.traceId} --render web`); assert.equal(result.payload.traceLookup.commands.result, `hwlab-cli client agent result ${result.payload.agent.traceId}`); assert.equal(result.payload.traceLookup.commands.inspect, `hwlab-cli client agent inspect --trace-id ${result.payload.agent.traceId}`); assert.equal(result.payload.agentTrace.source, "hwlab-cli.client.agent.trace"); assert.equal(result.payload.agentTrace.lookupOnly, undefined); - assert.equal(result.payload.agentTrace.commandCount, 1); - assert.equal(result.payload.agentTrace.hwpodCommandCount, 1); + assert.equal(result.payload.agentTrace.commandCount, 3); + assert.equal(result.payload.agentTrace.hwpodCommandCount, 3); assert.equal(result.payload.agentTrace.hwpodBuildCommandCount, 1); - assert.equal(result.payload.evidence.agentTrace.commandCount, 1); - assert.equal(result.payload.evidence.agentTrace.hwpodCommandCount, 1); + assert.equal(result.payload.evidence.agentTrace.commandCount, 3); + assert.equal(result.payload.evidence.agentTrace.hwpodCommandCount, 3); + assert.equal(result.payload.evidence.agentFinal.present, true); + assert.equal(result.payload.evidence.agentFinal.text, "done"); + assert.equal(result.payload.evidence.postValidation.boundary, "runner-post-agent-validation"); + assert.equal(result.payload.evidence.warnings.agentReportedBuildWarningCount, 2); + assert.equal(result.payload.evidence.warnings.runnerPostValidationWarningCount, 7); assert.equal(result.payload.evidence.agentTrace.keilJobCandidates.includes("20260605_203835_798515c0"), true); assert.equal(result.payload.evidence.agentTask.timeoutMs, 234000); assert.equal(result.payload.agent.polls <= 3, true); @@ -459,7 +477,7 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit assert.match(await readFile(result.payload.agentDiff.diffPatchPath, "utf8"), /build\/out\.bin rule=build\/\*\*/u); assert.equal(result.payload.build.autoEvaluation, false); assert.equal(result.payload.build.traceLookup.commands.trace, result.payload.traceLookup.commands.trace); - assert.equal(result.payload.build.agentTraceHwpodCommandCount, 1); + assert.equal(result.payload.build.agentTraceHwpodCommandCount, 3); assert.equal(result.payload.build.hwpodExitCode, 0); assert.equal(result.payload.build.hwpodSource, "case-run-runner-post-agent-compile-check"); assert.equal(result.payload.build.keilStatus, "completed"); @@ -510,7 +528,15 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit assert.equal(manifest.readableAgent.finalResponsePath, "final-response.md"); assert.equal(manifest.readableAgent.finalResponse.present, true); assert.equal(manifest.readableAgent.finalResponse.text, "done"); - assert.deepEqual(manifest.agentStage.kinds, ["build"]); + assert.deepEqual(manifest.agentStage.kinds, ["build", "download", "uart-read"]); + assert.equal(manifest.agentStage.source, "agentrun-result.toolCallSummary"); + assert.equal(manifest.agentStage.toolCallSummaryCount, 3); + assert.equal(manifest.trace.toolCallSummary.count, 3); + assert.equal(manifest.agent.requestedProviderProfile, "deepseek"); + assert.equal(manifest.agent.resolvedBackendProfile, "dsflash-go"); + assert.equal(manifest.agentFinal.present, true); + assert.equal(manifest.postValidation.warningCount, 7); + assert.equal(manifest.warnings.agentReportedBuildWarningCount, 2); assert.equal(manifest.agentStage.autoEvaluation, false); assert.equal(manifest.decisions.autoEvaluation, false); assert.equal(manifest.decisions.downloadSkipped, undefined); @@ -538,14 +564,14 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit assert.match(finalResponse, /done/u); assert.equal(result.payload.collect.caseRepoRunDir, registryRunDir); assert.equal(result.payload.collect.artifactManifestPath, path.join(registryRunDir, "artifact-manifest.json")); - assert.deepEqual(result.payload.collect.agentStage.kinds, ["build"]); + assert.deepEqual(result.payload.collect.agentStage.kinds, ["build", "download", "uart-read"]); assert.equal(result.payload.collect.caseRepoFiles.some((item: string) => item.endsWith("artifact-manifest.json")), true); const registryResult = JSON.parse(await readFile(path.join(registryRunDir, "result.json"), "utf8")); assert.equal(registryResult.collect.artifactManifestPath, path.join(registryRunDir, "artifact-manifest.json")); const summaryMarkdown = await readFile(path.join(registryRunDir, "summary.md"), "utf8"); assert.match(summaryMarkdown, /traceLookupStrategy: id_plus_existing_cli/u); assert.match(summaryMarkdown, /traceCommand: hwlab-cli client agent trace trc_case_/u); - assert.match(summaryMarkdown, /agentTraceHwpodCommandCount: 1/u); + assert.match(summaryMarkdown, /agentTraceHwpodCommandCount: 3/u); assert.match(summaryMarkdown, /hwpod build --spec \.hwlab\/hwpod-spec\.yaml/u); assert.doesNotMatch(summaryMarkdown, /downloadSkipped/u); @@ -827,7 +853,7 @@ test("case aggregate writes one registry markdown entry without grading or broad } }); - assert.equal(result.exitCode, 0); + assert.equal(result.exitCode, 0, JSON.stringify(result.payload, null, 2)); assert.equal(result.payload.action, "case.aggregate"); assert.equal(result.payload.autoEvaluation, false); assert.equal(result.payload.aggregate.registrySync.status, "pushed"); @@ -947,7 +973,30 @@ function caseRunFlowFetch(requests: any[]) { } if (String(url).startsWith("http://web.test/v1/agent/chat/result/")) { resultPolls += 1; - return new Response(JSON.stringify({ ok: true, status: resultPolls > 1 ? "completed" : "running", reply: { content: "done" } }), { status: 200, headers: { "content-type": "application/json" } }); + const toolCallSummary = { + count: 3, + statusCounts: { completed: 3 }, + exitCodeCounts: { "0": 3 }, + items: [ + { toolName: "commandExecution", status: "completed", exitCode: 0, command: "hwpod build --spec .hwlab/hwpod-spec.yaml" }, + { toolName: "commandExecution", status: "completed", exitCode: 0, command: "hwpod download --artifact firmware.hex" }, + { toolName: "commandExecution", status: "completed", exitCode: 0, command: "hwpod uart read --timeout-ms 3000" } + ] + }; + return new Response(JSON.stringify({ + ok: true, + status: resultPolls > 1 ? "completed" : "running", + reply: { content: "done" }, + requestedProviderProfile: "deepseek", + resolvedBackendProfile: "dsflash-go", + model: "deepseek-v4-flash", + backend: "agentrun-v01/dsflash-go", + infrastructureBackend: "agentrun-v01/dsflash-go", + buildWarningCount: 2, + providerTrace: { backendProfile: "dsflash-go", model: "deepseek-v4-flash" }, + agentRun: { runStatus: resultPolls > 1 ? "completed" : "running", commandStatus: resultPolls > 1 ? "completed" : "running", terminalStatus: resultPolls > 1 ? "completed" : "running" }, + ...(resultPolls > 1 ? { toolCallSummary } : { traceSummary: { toolCallSummary } }) + }), { status: 200, headers: { "content-type": "application/json" } }); } if (body.ops?.[0]?.args?.command === "node") { return hwpodResponse([{ exitCode: 0, stdout: [ @@ -974,7 +1023,7 @@ function caseRunFlowFetch(requests: any[]) { return hwpodResponse([{ exitCode: 0, stdout: "+printf(\"hello\");\n" }]); } if (body.ops?.[0]?.args?.argv?.includes("job-status")) { - return hwpodResponse([{ exitCode: 0, stdout: JSON.stringify({ status: "completed", return_code: 0, result: { hex_file: "F:\\out.hex", axf_file: "F:\\out.axf" } }) }]); + return hwpodResponse([{ exitCode: 0, stdout: JSON.stringify({ status: "completed", return_code: 0, warning_count: 7, result: { hex_file: "F:\\out.hex", axf_file: "F:\\out.axf" } }) }]); } return subjectWorktreeFetch(requests)(url, init); }; diff --git a/tools/src/hwlab-caserun-closeout.ts b/tools/src/hwlab-caserun-closeout.ts new file mode 100644 index 00000000..2398b955 --- /dev/null +++ b/tools/src/hwlab-caserun-closeout.ts @@ -0,0 +1,834 @@ +// SPEC: PJ2026-0103 HarnessRL draft-2026-06-25-p0-web-caserun-e2e. +// Responsibility: CaseRun evidence rendering, archive, aggregate, and terminal closeout. + +import { createHash } from "node:crypto"; +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 { agentTerminalEvidence, commandsFromToolCallSummary, hwpodCommandKind, mentionsHwpod, summarizeAgentTrace } from "./hwlab-caserun-evidence.ts"; +import type { ArchivedAgentTraceSnapshot, AgentTraceCommand, AgentTraceStage, CaseContext, CaseReadableAgentArchive, CaseRegistryArchive, CaseRegistryArchiveFile, PreparedCaseRun } from "./hwlab-caserun-shared.ts"; +const CASE_TRACE_RENDERER = "tools/src/hwlab-cli/trace-renderer:traceDisplayRows"; const MAX_AGENT_TRACE_COMMANDS = 30; const HWLAB_API_KEY_PREFIX = "hwl_live_"; +function text(value: unknown) { return String(value ?? "").trim(); } +function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; } +function clean(value: any) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)); } +function compactObject(value: any) { const json = JSON.stringify(value ?? null); return json.length <= 4000 ? value : { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) }; } +function sha256Buffer(value: Buffer) { return createHash("sha256").update(value).digest("hex"); } +function parseJsonMaybe(value: unknown) { if (value && typeof value === "object") return value as any; try { return JSON.parse(String(value ?? "").trim()); } catch { return null; } } +function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw Object.assign(new Error(`${field} is required`), { code: "missing_required_value", details: { field } }); return result; } +function clipText(value: unknown, maxBytes = 2000) { const raw = String(value ?? ""); const buffer = Buffer.from(raw, "utf8"); return buffer.length <= maxBytes ? raw : `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`; } +function arrayOfObjects(value: unknown): any[] { return Array.isArray(value) ? value.filter((item) => item && typeof item === "object" && !Array.isArray(item)) : []; } +function uniqueStrings(values: string[]) { return Array.from(new Set(values.map(text).filter(Boolean))); } +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 ""; } } +async function fileExists(file: string) { try { await stat(file); return true; } catch { return false; } } +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 runProcess(command: string[], cwd: string, env: Record) { const proc = Bun.spawn(command, { cwd, env, stdout: "pipe", stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); return { command, stdout, stderr, exitCode }; } +export function agentTraceLookupForRun(run: any, evidence?: any) { + const agent = run.agent ?? evidence?.agent; + const traceId = text(agent?.traceId ?? evidence?.agentTrace?.traceId ?? run.agentTrace?.traceId); + const baseUrl = text(agent?.baseUrl ?? run.agent?.baseUrl ?? evidence?.agent?.baseUrl); + if (!traceId || !baseUrl) return null; + return { source: "hwlab-cli.client.agent", strategy: "id_plus_existing_cli", baseUrl, traceId, sessionId: text(agent?.sessionId), conversationId: text(agent?.conversationId), threadId: text(agent?.threadId), commands: clean({ result: `hwlab-cli client agent result ${traceId}`, trace: `hwlab-cli client agent trace ${traceId} --render web`, traceFull: `hwlab-cli client agent trace ${traceId} --render web --full`, inspect: `hwlab-cli client agent inspect --trace-id ${traceId}`, sessionStatus: agent?.sessionId ? `hwlab-cli client agent session status ${agent.sessionId}` : undefined }) }; +} +export function renderEvidenceSummary(evidence: any) { + const agentStage = agentStageSummary(evidence); + const traceLookup = evidence.traceLookup ?? evidence.agent?.traceLookup; + const traceCommands = traceLookup?.commands ?? {}; + const stageTable = agentStage.commands.length > 0 ? [ + ``, + `## Agent HWPOD Raw Steps`, + ``, + `| Step | Status | Exit | Command | Raw detail |`, + `|---|---:|---:|---|---|`, + ...agentStage.commands.map((command: any) => `| ${command.kind} | ${command.status || ""} | ${command.exitCode ?? ""} | \`${markdownTableText(command.command, 160)}\` | ${markdownTableText(command.detail, 180)} |`) + ].join("\n") : ""; + return `# HWPOD CaseRun ${evidence.caseId}\n\n- runId: ${evidence.runId}\n- status: ${evidence.status}\n- autoEvaluation: false\n- compileOnly: ${evidence.compileOnly}\n- subjectRepoLocalPath: ${evidence.subject?.repoLocalPath ?? ""}\n- subjectCommitId: ${evidence.subject?.commitId ?? ""}\n- subjectWorktreePath: ${evidence.subject?.worktreePath ?? ""}\n- agentTraceId: ${evidence.agent?.traceId ?? ""}\n- agentSessionId: ${evidence.agent?.sessionId ?? ""}\n- requestedProviderProfile: ${evidence.agent?.requestedProviderProfile ?? evidence.agent?.providerProfile ?? ""}\n- resolvedBackendProfile: ${evidence.agent?.resolvedBackendProfile ?? ""}\n- model: ${evidence.agent?.model ?? ""}\n- infrastructureBackend: ${evidence.agent?.infrastructureBackend ?? ""}\n- traceLookupStrategy: ${traceLookup?.strategy ?? ""}\n- traceCommand: ${traceCommands.trace ?? ""}\n- resultCommand: ${traceCommands.result ?? ""}\n- inspectCommand: ${traceCommands.inspect ?? ""}\n- agentTraceCommandCount: ${evidence.agentTrace?.commandCount ?? 0}\n- agentTraceHwpodCommandCount: ${evidence.agentTrace?.hwpodCommandCount ?? 0}\n- agentTraceHwpodBuildCommandCount: ${evidence.agentTrace?.hwpodBuildCommandCount ?? 0}\n- agentTerminalStatus: ${evidence.agentFinal?.terminalStatus ?? evidence.agentTrace?.terminalStatus ?? ""}\n- agentFinalPresent: ${evidence.agentFinal?.present ?? false}\n- agentFinalMissingReason: ${evidence.agentFinal?.missingReason ?? ""}\n- postValidationStatus: ${evidence.postValidation?.status ?? ""}\n- agentReportedBuildWarningCount: ${evidence.warnings?.agentReportedBuildWarningCount ?? ""}\n- runnerPostValidationWarningCount: ${evidence.warnings?.runnerPostValidationWarningCount ?? ""}\n- agentToolCallSummaryCount: ${evidence.agentTrace?.toolCallSummary?.count ?? evidence.agent?.toolCallSummary?.count ?? ""}\n- agentToolCallStatusCounts: ${JSON.stringify(evidence.agentTrace?.toolCallSummary?.statusCounts ?? evidence.agent?.toolCallSummary?.statusCounts ?? {})}\n- agentToolCallExitCodeCounts: ${JSON.stringify(evidence.agentTrace?.toolCallSummary?.exitCodeCounts ?? evidence.agent?.toolCallSummary?.exitCodeCounts ?? {})}\n- agentStageCommandCount: ${agentStage.commands.length}\n- agentStageKinds: ${agentStage.kinds.join(", ")}\n- diffPatchPath: ${evidence.agentDiff?.diffPatchPath ?? ""}\n- runnerHwpodSource: ${evidence.hwpod?.source ?? ""}\n- hwpodExitCode: ${evidence.hwpod?.exitCode ?? ""}\n- jobId: ${evidence.keilJob?.jobId ?? ""}\n- runnerPostAgentCompileCheck: recorded\n${stageTable}\n`; +} + +export function agentStageSummary(evidence: any) { + const toolCallSummary = evidence.agentTrace?.toolCallSummary ?? evidence.agent?.toolCallSummary ?? null; + const traceCommands = arrayOfObjects(evidence.agentTrace?.commands).filter(mentionsHwpod); + const summaryCommands = commandsFromToolCallSummary(toolCallSummary).filter(mentionsHwpod); + const commands = (summaryCommands.length > 0 ? summaryCommands : traceCommands).map(agentStageCommand).filter((command) => text(command.kind)); + const selected: Record = {}; + for (const command of commands) { + if (!selected[command.kind]) selected[command.kind] = command; + if (command.status === "completed" || command.exitCode === 0) selected[command.kind] = command; + } + const ordered = ["spec-validate", "inspect", "workspace-edit", "build", "download", "uart-read"] + .map((kind) => selected[kind]) + .filter(Boolean); + const extras = commands.filter((command) => !ordered.some((item) => item.kind === command.kind && item.command === command.command)); + return clean({ + source: summaryCommands.length > 0 ? "agentrun-result.toolCallSummary" : "agent-trace-commands", + autoEvaluation: false, + toolCallSummaryCount: toolCallSummary?.count, + commands: [...ordered, ...extras].slice(0, MAX_AGENT_TRACE_COMMANDS), + kinds: uniqueStrings([...ordered, ...extras].map((command) => command.kind)) + }); +} + +export function agentStageCommand(command: AgentTraceCommand) { + return clean({ + kind: hwpodCommandKind(command) || "hwpod-other", + seq: command.seq, + status: command.status, + exitCode: command.exitCode, + command: clipText(command.normalizedCommand || command.command, 1000), + detail: clipText(agentCommandDetail(command), 1000) + }); +} + +export function agentCommandDetail(command: AgentTraceCommand) { + const body = text(command.bodyPreview); + if (!body) return ""; + return body.split(/\r?\n/u).filter((line) => !line.includes(text(command.command))).join(" ").replace(/\s+/gu, " ").trim() || body; +} + +export function markdownTableText(value: unknown, maxBytes = 200) { + return clipText(value, maxBytes).replace(/\|/gu, "\\|").replace(/\r?\n/gu, " ").trim(); +} + +export async function writeCaseAggregateFromRegistry(context: CaseContext, caseRepo: string, options: { sync?: boolean } = {}) { + const caseId = requiredText(context.parsed.caseId ?? context.rest[1], "caseId"); + const runId = await resolveAggregateRunId(caseRepo, caseId, text(context.parsed.runId ?? context.rest[2])); + const runRel = artifactRel(path.join("runs", caseId, runId)); + const caseRepoRunDir = path.join(caseRepo, runRel); + const evidence = await readJsonIfExists(path.join(caseRepoRunDir, "evidence.json")); + const run = await readJsonIfExists(path.join(caseRepoRunDir, "run.json")); + const manifest = await readJsonIfExists(path.join(caseRepoRunDir, "artifact-manifest.json")); + if (!evidence && !run && !manifest) throw cliError("case_registry_run_not_found", "case registry run artifacts were not found", { caseRepoRunDir, caseId, runId }); + const outputRel = aggregateOutputRel(context); + const outputPath = path.join(caseRepoRunDir, outputRel); + const markdown = await renderCaseAggregateMarkdown(context, { caseRepoRunDir, caseId, runId, evidence, run, manifest }); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, markdown, "utf8"); + const file = await registryAggregateFileRecord(outputPath); + const registrySync = options.sync === true ? await syncCaseRegistryRel(context, caseRepo, artifactRel(path.join(runRel, outputRel)), `data: aggregate caserun ${runId}`) : null; + return { caseId, runId, caseRepoRunDir, path: outputPath, rel: artifactRel(path.join(runRel, outputRel)), bytes: file.bytes, sha256: file.sha256, registrySync }; +} + +export async function resolveAggregateRunId(caseRepo: string, caseId: string, explicit: string) { + if (explicit) return explicit; + const runRoot = path.join(caseRepo, "runs", caseId); + let entries: string[] = []; + try { + entries = await readdir(runRoot); + } catch { + throw cliError("case_registry_runs_not_found", "case registry has no run directory for this case", { caseId, runRoot }); + } + const runs = entries.filter((entry) => !entry.startsWith(".")).sort(); + if (runs.length === 0) throw cliError("case_registry_runs_empty", "case registry has no runs for this case", { caseId, runRoot }); + return runs.at(-1) ?? ""; +} + +export function aggregateOutputRel(context: CaseContext) { + const value = text(context.parsed.output ?? context.parsed.outputPath ?? context.parsed.aggregatePath) || "aggregate.md"; + const rel = artifactRel(value).replace(/^\/+/, ""); + if (!rel || rel.includes("../") || rel === ".." || path.isAbsolute(value)) throw cliError("invalid_aggregate_output_path", "aggregate output must be a relative path inside the registry run directory", { output: value }); + if (!rel.endsWith(".md")) throw cliError("invalid_aggregate_output_ext", "aggregate output must be a markdown file", { output: value }); + return rel; +} + +export async function renderCaseAggregateMarkdown(context: CaseContext, input: { caseRepoRunDir: string; caseId: string; runId: string; evidence: any; run: any; manifest: any }) { + const prompt = await readTextIfExists(path.join(input.caseRepoRunDir, "agent-prompt.md")); + const finalResponse = await readTextIfExists(path.join(input.caseRepoRunDir, "final-response.md")); + const diffPatch = await readTextIfExists(path.join(input.caseRepoRunDir, "agent-diff.patch")); + const traceMarkdown = await readTextIfExists(path.join(input.caseRepoRunDir, "agent-trace.md")); + const specText = await readTextIfExists(path.join(input.caseRepoRunDir, ".hwlab", "hwpod-spec.yaml")); + const evidence = input.evidence ?? {}; + const run = input.run ?? {}; + const manifest = input.manifest ?? {}; + const archivedMessages = await readJsonIfExists(path.join(input.caseRepoRunDir, "agent-messages.json")); + const traceLookup = manifest.traceLookup ?? evidence.traceLookup ?? run.agent?.traceLookup; + const commands = traceLookup?.commands ?? {}; + const agent = manifest.agent ?? manifest.trace ?? evidence.agent ?? run.agent ?? {}; + const subject = manifest.subject ?? evidence.subject ?? run.subject ?? {}; + const trace = manifest.trace ?? evidence.agentTrace ?? run.agentTrace ?? {}; + const diff = manifest.diff ?? evidence.agentDiff ?? run.agentDiff ?? {}; + const keilJob = manifest.keilJob ?? evidence.keilJob ?? null; + const agentStage = manifest.agentStage ?? agentStageSummary(evidence); + const messages = await aggregateMessagesWithFreshFullTrace(context, { archivedMessages, traceLookup, traceId: agent.traceId ?? trace.traceId ?? traceLookup?.traceId, run, evidence, manifest }); + const traceBody = aggregateTraceBody(messages, traceMarkdown); + return `${[ + `# HWPOD CaseRun Aggregate: ${input.caseId}`, + ``, + `- caseId: ${input.caseId}`, + `- runId: ${input.runId}`, + `- generatedFrom: case registry artifacts`, + `- aggregationOnly: true`, + `- autoEvaluation: false`, + `- primaryEntry: aggregate.md`, + ``, + `## 运行环境信息`, + ``, + aggregateBullets([ + ["apiUrl", run.apiUrl ?? evidence.apiUrl], + ["compileOnly", evidence.compileOnly ?? run.compileOnly], + ["caseRepoRunDir", input.caseRepoRunDir], + ["sourceRunDir", manifest.sourceRunDir ?? run.runDir], + ["createdAt", run.createdAt], + ["completedAt", run.completedAt ?? run._control?.completedAt], + ["runnerPostAgentCompileCheck", manifest.decisions?.runnerPostAgentCompileCheck ?? evidence.decisions?.runnerPostAgentCompileCheck ?? "recorded"] + ]), + ``, + `## HWPOD 信息`, + ``, + aggregateBullets([ + ["subjectRepoLocalPath", subject.repoLocalPath], + ["subjectCommitId", subject.commitId], + ["subjectSubdir", subject.subdir], + ["subjectWorktreePath", subject.worktreePath], + ["sourceRootBaselineStatus", subject.sourceRootBaseline?.statusShort], + ["sourceRootAfterPrepareStatus", subject.sourceRootAfterPrepare?.statusShort], + ["keilJobId", keilJob?.jobId], + ["keilStatus", keilJob?.status], + ["hwpodExitCode", evidence.hwpod?.exitCode] + ]), + specText ? aggregateDetails("Run-local HWPOD spec", "yaml", specText) : `_Run-local HWPOD spec artifact is missing._`, + ``, + `## Code Agent 信息`, + ``, + aggregateBullets([ + ["providerProfile", agent.providerProfile], + ["projectId", agent.projectId], + ["conversationId", agent.conversationId], + ["sessionId", agent.sessionId], + ["threadId", agent.threadId], + ["traceId", agent.traceId ?? trace.traceId], + ["agentRunId", trace.agentRun?.runId], + ["agentCommandId", trace.agentRun?.commandId], + ["traceSource", trace.source], + ["traceCommand", commands.trace], + ["resultCommand", commands.result], + ["inspectCommand", commands.inspect] + ]), + ``, + `## 输入 Prompt`, + ``, + prompt ? aggregateFence("markdown", prompt) : `_agent-prompt.md artifact is missing._`, + ``, + `## 低噪声 Trace`, + ``, + `- renderer: ${manifest.readableAgent?.renderer ?? messages?.renderer ?? CASE_TRACE_RENDERER}`, + `- sourceEventCount: ${manifest.readableAgent?.traceRender?.sourceEventCount ?? messages?.sourceEventCount ?? trace.sourceEventCount ?? ""}`, + `- renderedRowCount: ${manifest.readableAgent?.traceRender?.renderedRowCount ?? messages?.renderedRowCount ?? trace.renderedRowCount ?? ""}`, + `- hwpodCommandCount: ${trace.hwpodCommandCount ?? evidence.agentTrace?.hwpodCommandCount ?? ""}`, + `- hwpodBuildCommandCount: ${trace.hwpodBuildCommandCount ?? evidence.agentTrace?.hwpodBuildCommandCount ?? ""}`, + ``, + traceBody, + ``, + `## Final Response`, + ``, + finalResponse ? finalResponse.trimEnd() : `_final-response.md artifact is missing._`, + ``, + `## 最后 Diff`, + ``, + aggregateBullets([ + ["statusShort", diff.statusShort], + ["diffStat", diff.diffStat], + ["diffSha256", diff.sha256 ?? diff.diffPatchSha256], + ["sourceRootChangedAfterPrepare", diff.sourceRootChangedAfterPrepare], + ["sourceRootChangedAfterAgent", diff.sourceRootChangedAfterAgent] + ]), + diffPatch ? aggregateFence("diff", diffPatch) : `_agent-diff.patch artifact is missing._`, + ``, + `## 原始产物索引`, + ``, + aggregateArtifactIndex(manifest), + ``, + `## 说明`, + ``, + `本文件只做已完成 CaseRun registry 产物的二次整理聚合,不新增自动结论、门禁或等级。` + ].join("\n")}\n`; +} + +export function aggregateTraceBody(messages: any, traceMarkdown: string) { + const rows = arrayOfObjects(messages?.rows); + if (rows.length > 0) { + return renderTraceRowsMarkdown(rows.map((row) => traceEventRowFromAggregate(row))); + } + if (traceMarkdown) { + return renderTraceRowsMarkdown([{ rowId: "trace-markdown", seq: null, tone: "source", header: "Rendered trace markdown", body: traceMarkdown, bodyFormat: "markdown" }]); + } + return `_No readable trace artifact was found._`; +} + +export async function aggregateMessagesWithFreshFullTrace(context: CaseContext, input: { archivedMessages: any; traceLookup: any; traceId: unknown; run: any; evidence: any; manifest: any }) { + const archivedMessages = input.archivedMessages; + const traceId = text(input.traceId ?? input.traceLookup?.traceId ?? input.run?.agent?.traceId ?? input.evidence?.agent?.traceId ?? input.manifest?.agent?.traceId); + const baseUrl = text(input.traceLookup?.baseUrl ?? input.run?.agent?.baseUrl ?? input.evidence?.agent?.baseUrl); + const apiKey = text(context.env.HWLAB_API_KEY ?? process.env.HWLAB_API_KEY); + if (!traceId || !baseUrl || !apiKey.startsWith(HWLAB_API_KEY_PREFIX)) return archivedMessages; + const command = [process.execPath, path.join(context.cwd, "tools/hwlab-cli/bin/hwlab-cli.mjs"), "client", "agent", "trace", traceId, "--render", "web", "--full"]; + const result = await (context.runProcess ?? runProcess)(command, context.cwd, { ...process.env, ...context.env, HWLAB_RUNTIME_WEB_URL: baseUrl }); + if (result.exitCode !== 0) return archivedMessages; + const parsed = parseJsonMaybe(result.stdout); + const rendered = freshRenderedTraceBody(parsed); + const rows = arrayOfObjects(rendered?.rows); + if (rows.length === 0) return archivedMessages; + return clean({ + ...(archivedMessages && typeof archivedMessages === "object" ? archivedMessages : {}), + renderer: rendered.renderer ?? archivedMessages?.renderer ?? CASE_TRACE_RENDERER, + traceSource: rendered.source ?? archivedMessages?.traceSource, + traceLookup: archivedMessages?.traceLookup ?? input.traceLookup, + traceId: rendered.traceId ?? traceId, + status: rendered.traceStatus ?? rendered.status ?? archivedMessages?.status, + sourceEventCount: rendered.sourceEventCount ?? archivedMessages?.sourceEventCount, + renderedRowCount: rendered.renderedRowCount ?? rows.length, + returnedRowCount: rendered.returnedRowCount ?? rows.length, + noiseEventCount: rendered.noiseEventCount ?? archivedMessages?.noiseEventCount, + finalResponse: rendered.finalResponse ?? archivedMessages?.finalResponse, + rows + }); +} + +export function freshRenderedTraceBody(parsed: any) { + if (!parsed || typeof parsed !== "object") return null; + const body = parsed.body && typeof parsed.body === "object" ? parsed.body : null; + if (Array.isArray(body?.rows)) return body; + const data = parsed.data && typeof parsed.data === "object" ? parsed.data : null; + if (Array.isArray(data?.rows)) return data; + return Array.isArray(parsed.rows) ? parsed : null; +} + +export function traceEventRowFromAggregate(row: Record): TraceEventRow { + const bodyFormat = text(row.bodyFormat); + return { + rowId: text(row.rowId) || text(row.header) || "trace-row", + seq: numberOption(row.seq) ?? null, + tone: traceRowTone(row.tone), + header: text(row.header) || text(row.rowId) || "trace row", + body: row.body === undefined || row.body === null ? null : String(row.body), + terminal: row.terminal === true ? true : undefined, + bodyFormat: bodyFormat === "markdown" || bodyFormat === "text" ? bodyFormat : undefined + }; +} + +export function aggregateBullets(items: Array<[string, unknown]>) { + const lines = items.map(([key, value]) => `- ${key}: ${aggregateInlineValue(value)}`).filter((line) => !line.endsWith(": ")); + return lines.length > 0 ? lines.join("\n") : `_No data recorded._`; +} + +export function aggregateInlineValue(value: unknown) { + if (value === undefined || value === null || value === "") return ""; + if (typeof value === "boolean" || typeof value === "number") return String(value); + if (typeof value === "object") return `\`${markdownInline(JSON.stringify(value))}\``; + return markdownInline(String(value)); +} + +export function markdownInline(value: string) { + return value.replace(/\r?\n/gu, " ").trim(); +} + +export function aggregateDetails(summary: string, language: string, body: string) { + return [`
`, `${escapeHtml(summary)}`, ``, aggregateFence(language, body), ``, `
`].join("\n"); +} + +export function escapeHtml(value: string) { + return String(value).replace(/&/gu, "&").replace(//gu, ">"); +} + +export function aggregateFence(language: string, body: string) { + const fence = body.includes("```") ? "````" : "```"; + return `${fence}${language}\n${body.trimEnd()}\n${fence}`; +} + +export function aggregateArtifactIndex(manifest: any) { + const files = arrayOfObjects(manifest?.files); + if (files.length === 0) return `_artifact-manifest.json is missing or has no files list._`; + return [`| Path | Bytes | SHA-256 |`, `|---|---:|---|`, ...files.map((file) => `| ${markdownTableText(file.path, 220)} | ${file.bytes ?? ""} | ${markdownTableText(file.sha256, 80)} |`)].join("\n"); +} + +export async function registryAggregateFileRecord(file: string) { + const content = await readFile(file); + return { bytes: content.length, sha256: sha256Buffer(content) }; +} + +export async function archiveCaseRunArtifacts(context: CaseContext, run: PreparedCaseRun, evidence: any, options: { sync?: boolean } = {}): Promise { + const runRel = caseRegistryRunRel(run); + const caseRepoRunDir = path.join(run.caseRepo, runRel); + await mkdir(caseRepoRunDir, { recursive: true }); + const agentTraceSnapshot = await archivedAgentTraceSnapshot(context, run, evidence); + applyArchivedAgentTraceSummary(run, evidence, agentTraceSnapshot.summary); + await writeJson(path.join(run.runDir, "evidence.json"), evidence); + await writeJson(path.join(caseRepoRunDir, "evidence.json"), evidence); + await writeFile(path.join(caseRepoRunDir, "summary.md"), renderEvidenceSummary(evidence), "utf8"); + + const materialized = new Map([ + ["evidence.json", path.join(run.runDir, "evidence.json")], + ["summary.md", undefined] + ]); + if (await fileExists(path.join(caseRepoRunDir, "aggregate.md"))) materialized.set("aggregate.md", undefined); + const readableAgent = await writeReadableAgentArtifacts(caseRepoRunDir, run, evidence, agentTraceSnapshot); + for (const rel of readableAgent.generatedFiles) materialized.set(rel, undefined); + const skippedFiles: string[] = []; + for (const entry of caseRunArtifactEntries(run)) { + const rel = artifactRel(entry.rel); + const copied = await copyCaseRunArtifact(entry.source, path.join(caseRepoRunDir, rel)); + if (copied) materialized.set(rel, entry.source); + else skippedFiles.push(rel); + } + + const files: CaseRegistryArchiveFile[] = []; + for (const [rel, source] of materialized) files.push(await registryArtifactFileRecord(run, caseRepoRunDir, rel, source)); + const artifactManifestPath = path.join(caseRepoRunDir, "artifact-manifest.json"); + const existingManifest = await readJsonIfExists(artifactManifestPath); + const manifest = caseRunArtifactManifest(context, run, evidence, files, skippedFiles, readableAgent, existingManifest); + await writeJson(artifactManifestPath, manifest); + const caseRepoFiles = [...files.map((file) => artifactRel(path.join(runRel, file.path))), artifactRel(path.join(runRel, "artifact-manifest.json"))]; + const registrySync = options.sync === true ? await syncCaseRegistryRun(context, run, runRel) : null; + return { caseRepoRunDir, artifactManifestPath, caseRepoFiles, files, skippedFiles, manifest, registrySync }; +} + +export async function syncCaseRegistryRun(context: CaseContext, run: PreparedCaseRun, runRel: string) { + return syncCaseRegistryRel(context, run.caseRepo, runRel, `data: archive caserun ${run.runId}`); +} + +export async function syncCaseRegistryRel(context: CaseContext, caseRepo: string, relInput: string, commitMessage: string) { + if (context.parsed.noCaseRepoGitSync === true || context.parsed.noRegistryGitSync === true) { + return { status: "skipped", reason: "disabled_by_cli_flag" }; + } + const rel = artifactRel(relInput); + const before = await gitProcess(context, caseRepo, ["status", "--short", "--", rel]); + if (before.exitCode !== 0) return { status: "failed", stage: "status_before", command: before.command, exitCode: before.exitCode, stderr: clipText(before.stderr) }; + if (!text(before.stdout)) return { status: "unchanged", path: rel }; + const add = await gitProcess(context, caseRepo, ["add", "--", rel]); + if (add.exitCode !== 0) return { status: "failed", stage: "add", command: add.command, exitCode: add.exitCode, stderr: clipText(add.stderr) }; + const staged = await gitProcess(context, caseRepo, ["diff", "--cached", "--quiet", "--", rel]); + if (staged.exitCode === 0) return { status: "unchanged_after_add", path: rel }; + if (staged.exitCode !== 1) return { status: "failed", stage: "diff_cached", command: staged.command, exitCode: staged.exitCode, stderr: clipText(staged.stderr) }; + const commit = await gitProcess(context, caseRepo, ["commit", "-m", commitMessage, "--", rel]); + if (commit.exitCode !== 0) return { status: "failed", stage: "commit", command: commit.command, exitCode: commit.exitCode, stderr: clipText(commit.stderr), stdout: clipText(commit.stdout) }; + const push = await gitProcess(context, caseRepo, ["push", "origin", "HEAD"]); + if (push.exitCode !== 0) return { status: "failed", stage: "push", command: push.command, exitCode: push.exitCode, stderr: clipText(push.stderr), stdout: clipText(push.stdout) }; + const commitSha = text(commit.stdout.match(/\[\S+\s+([0-9a-f]{7,40})\]/iu)?.[1]); + return clean({ status: "pushed", path: rel, commitMessage, commitSha, commitStdout: clipText(commit.stdout, 1000), pushStdout: clipText(push.stdout, 1000), pushStderr: clipText(push.stderr, 1000) }); +} + +export async function gitProcess(context: CaseContext, cwd: string, args: string[]) { + return (context.runProcess ?? runProcess)(["git", ...args], cwd, { ...process.env, ...context.env }); +} + +export function collectSummaryFromArchive(run: PreparedCaseRun, evidence: any, archive: CaseRegistryArchive) { + return { + caseRepo: run.caseRepo, + caseRepoRunDir: archive.caseRepoRunDir, + caseRepoFiles: archive.caseRepoFiles, + artifactManifestPath: archive.artifactManifestPath, + artifactCount: archive.files.length + 1, + skippedFiles: archive.skippedFiles, + registrySync: archive.registrySync ?? null, + trace: archive.manifest.trace ?? null, + traceLookup: archive.manifest.traceLookup ?? agentTraceLookupForRun(run, evidence), + agentStage: archive.manifest.agentStage ?? agentStageSummary(evidence), + status: evidence.status, + autoEvaluation: false + }; +} + +export async function writeReadableAgentArtifacts(caseRepoRunDir: string, run: PreparedCaseRun, evidence: any, snapshot: ArchivedAgentTraceSnapshot): Promise { + const messagesRel = "agent-messages.json"; + const traceRel = "agent-trace.md"; + const transcriptRel = "agent-transcript.md"; + const finalResponseRel = "final-response.md"; + const traceBody = snapshot.traceBody; + const events = snapshot.events; + const rows = snapshot.rows; + const finalResponse = agentFinalResponse(run, evidence, traceBody, rows); + const traceLookup = traceBody?.lookup ?? evidence.traceLookup ?? agentTraceLookupForRun(run, evidence); + const diffPatch = await readTextIfExists(evidence.agentDiff?.diffPatchPath ?? run.agentDiff?.diffPatchPath ?? path.join(run.runDir, "agent-diff.patch")); + const messages = clean({ + contractVersion: "hwpod-case-run-agent-messages-v1", + renderer: CASE_TRACE_RENDERER, + traceSource: text(traceBody?.source), + lookupOnly: traceBody?.lookupOnly === true ? true : undefined, + traceLookup, + traceId: evidence.agent?.traceId ?? run.agent?.traceId ?? traceBody?.traceId ?? run.agentTrace?.traceId, + conversationId: evidence.agent?.conversationId ?? run.agent?.conversationId, + sessionId: evidence.agent?.sessionId ?? run.agent?.sessionId, + threadId: evidence.agent?.threadId ?? run.agent?.threadId, + status: traceBody?.traceStatus ?? traceBody?.status ?? run.agentTrace?.status ?? evidence.agentTrace?.status, + sourceEventCount: traceBody?.eventCount ?? traceBody?.sourceEventCount ?? events.length, + renderedRowCount: rows.length, + noiseEventCount: traceNoiseEventCount(events), + finalResponse, + rows: rows.map(agentMessageRowForArchive), + diff: clean({ path: "agent-diff.patch", sha256: evidence.agentDiff?.diffPatchSha256 ?? run.agentDiff?.diffPatchSha256, statusShort: evidence.agentDiff?.statusShort ?? run.agentDiff?.statusShort, diffStat: evidence.agentDiff?.diffStat ?? run.agentDiff?.diffStat, collection: evidence.agentDiff?.diffCollection ?? run.agentDiff?.diffCollection, patchIncludedInTranscript: true }) + }); + const transcript = renderAgentTranscript(run, evidence, messages, rows, diffPatch); + await writeJson(path.join(caseRepoRunDir, messagesRel), messages); + await writeFile(path.join(caseRepoRunDir, traceRel), transcript, "utf8"); + await writeFile(path.join(caseRepoRunDir, transcriptRel), transcript, "utf8"); + await writeFile(path.join(caseRepoRunDir, finalResponseRel), renderFinalResponseArtifact(run, evidence, finalResponse, rows), "utf8"); + return { + messagesRel, + traceRel, + transcriptRel, + finalResponseRel, + generatedFiles: [messagesRel, traceRel, transcriptRel, finalResponseRel], + traceRender: clean({ renderer: CASE_TRACE_RENDERER, status: messages.status, sourceEventCount: messages.sourceEventCount, renderedRowCount: messages.renderedRowCount, noiseEventCount: messages.noiseEventCount }), + finalResponse + }; +} + +export async function archivedAgentTraceSnapshot(context: CaseContext, run: PreparedCaseRun, evidence: any): Promise { + const initialTrace = await archivedAgentTraceBody(run, evidence); + const traceBody = await materializeFullAgentTraceWithExistingCli(context, run, evidence, initialTrace); + const bodyForSummary = traceBody?.body && typeof traceBody.body === "object" ? traceBody.body : traceBody; + const events = arrayOfObjects(bodyForSummary?.events ?? bodyForSummary?.trace?.events ?? bodyForSummary?.sourceEvents); + const rows = traceRowsFromBody(bodyForSummary, events); + const hasRenderableTrace = rows.length > 0 || events.length > 0; + const summary = hasRenderableTrace + ? summarizeAgentTrace(text(bodyForSummary?.traceId ?? traceBody?.traceId ?? evidence.agent?.traceId ?? run.agent?.traceId), numberOption(traceBody?.httpStatus) ?? 0, traceBody) + : existingAgentTraceSummary(run, evidence, bodyForSummary); + return { traceBody: bodyForSummary, rows, events, summary }; +} + +export function existingAgentTraceSummary(run: PreparedCaseRun, evidence: any, traceBody: any): AgentTraceStage { + const existing = evidence.agentTrace ?? run.agentTrace; + if (existing?.traceId) return existing as AgentTraceStage; + return summarizeAgentTrace(text(traceBody?.traceId ?? evidence.agent?.traceId ?? run.agent?.traceId), 0, traceBody); +} + +export async function archivedAgentTraceBody(run: PreparedCaseRun, evidence: any) { + const rawTrace = await readJsonIfExists(path.join(run.runDir, "agent-trace.json")); + if (rawTrace) return rawTrace; + const resultTrace = agentTraceBody(run, evidence); + if (resultTrace) return resultTrace; + return run.agent ? agentTraceIdentityArtifact(run, run.agent) : clean({ traceId: evidence.agentTrace?.traceId ?? run.agentTrace?.traceId, status: evidence.agentTrace?.status ?? run.agentTrace?.status ?? "recorded", lookupOnly: true }); +} + +export async function materializeFullAgentTraceWithExistingCli(context: CaseContext, run: PreparedCaseRun, evidence: any, initialTrace: any) { + const body = initialTrace?.body && typeof initialTrace.body === "object" ? initialTrace.body : initialTrace; + const existingEvents = arrayOfObjects(body?.events ?? body?.trace?.events ?? body?.sourceEvents); + if (existingEvents.length > 0) return initialTrace; + if (!run.agent) return initialTrace; + const traceId = text(body?.traceId ?? initialTrace?.traceId ?? evidence.agent?.traceId ?? run.agent?.traceId); + const lookup = agentTraceLookupForRun(run, evidence); + const apiKey = text(context.env.HWLAB_API_KEY ?? process.env.HWLAB_API_KEY); + if (!traceId || !lookup || !apiKey.startsWith(HWLAB_API_KEY_PREFIX)) return initialTrace; + const baseUrl = text((lookup as any).baseUrl) || webUrlFrom(context, run.definition); + const command = [process.execPath, path.join(context.cwd, "tools/hwlab-cli/bin/hwlab-cli.mjs"), "client", "agent", "trace", traceId, "--render", "web", "--full"]; + const result = await (context.runProcess ?? runProcess)(command, context.cwd, { ...process.env, ...context.env, HWLAB_RUNTIME_WEB_URL: baseUrl }); + if (result.exitCode !== 0) { + return clean({ ...body, traceFetch: { status: "failed", source: "existing-hwlab-cli", command: commandVisibility(command), exitCode: result.exitCode, stderr: clipText(result.stderr, 1200), stdout: clipText(result.stdout, 1200) } }); + } + const parsed = parseJsonMaybe(result.stdout); + if (!parsed) return clean({ ...body, traceFetch: { status: "failed", source: "existing-hwlab-cli", command: commandVisibility(command), exitCode: 0, error: "invalid_json_stdout", stdout: clipText(result.stdout, 1200) } }); + const parsedBody = parsed?.body && typeof parsed.body === "object" ? parsed.body : null; + const preserved = clean({ + toolCallSummary: body?.toolCallSummary, + terminalStatus: body?.terminalStatus, + commandStatus: body?.commandStatus, + agentRunStatus: body?.agentRunStatus, + requestedProviderProfile: body?.requestedProviderProfile, + resolvedBackendProfile: body?.resolvedBackendProfile, + provider: body?.provider, + model: body?.model, + backend: body?.backend, + infrastructureBackend: body?.infrastructureBackend, + providerTrace: body?.providerTrace + }); + const materialized = parsedBody ? { ...parsed, body: clean({ ...preserved, ...parsedBody, source: text(parsedBody.source) || "hwlab-cli.client.agent.trace", lookup: parsedBody.lookup ?? lookup }) } : clean({ ...preserved, ...parsed, source: text(parsed.source) || "hwlab-cli.client.agent.trace", lookup: parsed.lookup ?? lookup }); + await writeJson(path.join(run.runDir, "agent-trace.json"), materialized); + return materialized; +} + +export function traceRowsFromBody(traceBody: any, events: Record[]) { + const existingRows = arrayOfObjects(traceBody?.rows ?? traceBody?.renderedRows ?? traceBody?.traceRows).map(traceEventRowFromObject).filter((row) => text(row.rowId || row.header || row.body)); + return existingRows.length > 0 ? existingRows : traceDisplayRows(traceBody ?? {}, events); +} + +export function traceEventRowFromObject(row: Record): TraceEventRow { + return { + rowId: text(row.rowId ?? row.id), + seq: numberOption(row.seq) ?? null, + tone: traceRowTone(row.tone), + header: text(row.header ?? row.title ?? row.label), + body: row.body === null || row.body === undefined ? null : text(row.body ?? row.content ?? row.text ?? row.message), + terminal: row.terminal === true ? true : undefined, + bodyFormat: row.bodyFormat === "markdown" ? "markdown" : row.bodyFormat === "text" ? "text" : undefined + }; +} + +export function traceRowTone(value: unknown): TraceEventRow["tone"] { + const raw = text(value); + return raw === "ok" || raw === "blocked" || raw === "warn" || raw === "source" ? raw : "source"; +} + +export function applyArchivedAgentTraceSummary(run: PreparedCaseRun, evidence: any, summary: AgentTraceStage) { + if (!summary?.traceId) return; + run.agentTrace = summary; + evidence.agentTrace = summary; + evidence.traceLookup = evidence.traceLookup ?? agentTraceLookupForRun(run, evidence); +} + +export function agentTraceBody(run: PreparedCaseRun, evidence: any) { + const result = run.agent?.result ?? evidence.agent?.result ?? null; + return result?.body && typeof result.body === "object" ? result.body : result; +} + +export function agentFinalResponse(run: PreparedCaseRun, evidence: any, traceBody: any, rows: TraceEventRow[]) { + const candidates = [ + traceBody?.finalResponse, + traceBody?.terminalEvidence?.finalResponse, + run.agent?.result?.finalResponse, + run.agent?.result?.reply, + evidence.agent?.finalResponse + ]; + for (const candidate of candidates) { + const body = text((candidate as any)?.text ?? (candidate as any)?.content ?? (candidate as any)?.message); + if (body) return clean({ present: true, status: text((candidate as any)?.status) || "completed", source: text((candidate as any)?.source) || "agent-result", text: body, textChars: body.length, traceId: text((candidate as any)?.traceId ?? traceBody?.traceId ?? run.agent?.traceId) }); + } + const terminal = rows.filter((row) => row.terminal === true || row.tone === "blocked").slice(-5).map(agentMessageRowForArchive); + return clean({ + present: false, + value: null, + reason: finalResponseMissingReason(traceBody, rows), + status: text(traceBody?.status ?? traceBody?.traceStatus ?? run.agent?.stageStatus), + terminalRows: terminal, + error: compactObject(traceBody?.error ?? run.agent?.error ?? null) + }); +} + +export function finalResponseMissingReason(traceBody: any, rows: TraceEventRow[]) { + const errorText = text(traceBody?.error?.message ?? traceBody?.terminalEvidence?.error?.message); + if (errorText) return errorText; + const terminalBody = rows.slice().reverse().map((row) => text(row.body)).find((body) => /error|failed|404|not found|blocked|timeout/iu.test(body)); + return terminalBody ? clipText(terminalBody, 1200) : "finalResponse=null; no authoritative final assistant response was returned by the trace/result payload"; +} + +export function agentMessageRowForArchive(row: TraceEventRow) { + return clean({ rowId: row.rowId, seq: row.seq, tone: row.tone, header: row.header, terminal: row.terminal === true ? true : undefined, bodyFormat: row.bodyFormat, body: row.body }); +} + +export function renderAgentTranscript(run: PreparedCaseRun, evidence: any, messages: any, rows: TraceEventRow[], diffPatch: string) { + const sections = [ + `# CaseRun Agent Transcript`, + ``, + `- caseId: ${run.caseId}`, + `- runId: ${run.runId}`, + `- traceId: ${messages.traceId ?? ""}`, + `- conversationId: ${messages.conversationId ?? ""}`, + `- sessionId: ${messages.sessionId ?? ""}`, + `- threadId: ${messages.threadId ?? ""}`, + `- renderer: ${CASE_TRACE_RENDERER}`, + `- traceLookupStrategy: ${messages.traceLookup?.strategy ?? ""}`, + `- traceCommand: ${messages.traceLookup?.commands?.trace ?? ""}`, + `- resultCommand: ${messages.traceLookup?.commands?.result ?? ""}`, + `- inspectCommand: ${messages.traceLookup?.commands?.inspect ?? ""}`, + `- lookupOnly: ${messages.lookupOnly === true}`, + `- finalResponse: ${messages.finalResponse?.present === true ? "present" : "null"}`, + `- autoEvaluation: false`, + ``, + `## Messages`, + rows.length > 0 ? rows.map(renderTranscriptRow).join("\n\n") : `_No rendered trace rows were returned._`, + ``, + `## Final Response`, + messages.finalResponse?.present === true ? text(messages.finalResponse.text) : [`finalResponse=null`, `reason: ${text(messages.finalResponse?.reason)}`].join("\n"), + ``, + `## Subject Diff`, + ``, + `statusShort:`, + `\`\`\`text`, + text(evidence.agentDiff?.statusShort ?? run.agentDiff?.statusShort) || "(empty)", + `\`\`\``, + ``, + `diffStat:`, + `\`\`\`text`, + text(evidence.agentDiff?.diffStat ?? run.agentDiff?.diffStat) || "(empty)", + `\`\`\``, + ``, + `patch:`, + `\`\`\`diff`, + diffPatch || "(empty)", + `\`\`\`` + ]; + return `${sections.join("\n")}\n`; +} + +export function renderTranscriptRow(row: TraceEventRow) { + const body = text(row.body); + return [`### ${row.header}`, ``, `- rowId: ${row.rowId}`, row.terminal === true ? `- terminal: true` : null, ``, body ? body : `_No body._`].filter((item) => item !== null).join("\n"); +} + +export function renderFinalResponseArtifact(run: PreparedCaseRun, evidence: any, finalResponse: any, rows: TraceEventRow[]) { + const lines = [ + `# CaseRun Final Response`, + ``, + `- caseId: ${run.caseId}`, + `- runId: ${run.runId}`, + `- traceId: ${evidence.agent?.traceId ?? run.agent?.traceId ?? ""}`, + `- present: ${finalResponse.present === true}`, + `` + ]; + if (finalResponse.present === true) return `${[...lines, text(finalResponse.text)].join("\n")}\n`; + const terminalRows = rows.filter((row) => row.terminal === true || row.tone === "blocked").slice(-5); + return `${[ + ...lines, + `finalResponse=null`, + ``, + `reason: ${text(finalResponse.reason)}`, + ``, + `## Terminal/Error Rows`, + terminalRows.length > 0 ? terminalRows.map(renderTranscriptRow).join("\n\n") : `_No terminal/error rows were rendered._` + ].join("\n")}\n`; +} + +export async function refreshCompletedRunArchive(context: CaseContext, run: PreparedCaseRun, evidence?: any, options: { sync?: boolean } = {}) { + if (context.parsed.noCaseRepoRecord === true) return null; + if (!text(run.caseRepo)) return null; + const loadedEvidence = evidence ?? await readJsonIfExists(path.join(run.runDir, "evidence.json")); + if (!loadedEvidence) return null; + const archive = await archiveCaseRunArtifacts(context, run, loadedEvidence, options); + return { evidence: loadedEvidence, archive, summary: collectSummaryFromArchive(run, loadedEvidence, archive) }; +} + +export function caseRunArtifactEntries(run: PreparedCaseRun) { + return [ + { rel: "run.json", source: path.join(run.runDir, "run.json") }, + { rel: "result.json", source: path.join(run.runDir, "result.json") }, + { rel: "agent-trace.json", source: path.join(run.runDir, "agent-trace.json") }, + { rel: "agent-prompt.md", source: path.join(run.runDir, "agent-prompt.md") }, + { rel: "agent-diff.patch", source: path.join(run.runDir, "agent-diff.patch") }, + { rel: path.join(".hwlab", "hwpod-spec.yaml"), source: run.specPath }, + { rel: "worker.stdout.log", source: path.join(run.runDir, "worker.stdout.log") }, + { rel: "worker.stderr.log", source: path.join(run.runDir, "worker.stderr.log") } + ]; +} + +export async function copyCaseRunArtifact(source: string, destination: string) { + try { + const info = await stat(source); + if (!info.isFile()) return false; + await mkdir(path.dirname(destination), { recursive: true }); + await copyFile(source, destination); + return true; + } catch { + return false; + } +} + +export async function registryArtifactFileRecord(run: PreparedCaseRun, caseRepoRunDir: string, rel: string, source?: string): Promise { + const file = path.join(caseRepoRunDir, rel); + const content = await readFile(file); + return clean({ + path: artifactRel(rel), + bytes: content.length, + sha256: sha256Buffer(content), + source: artifactSourceLabel(run, source) + }); +} + +export function caseRunArtifactManifest(context: CaseContext, run: PreparedCaseRun, evidence: any, files: CaseRegistryArchiveFile[], skippedFiles: string[], readableAgent: CaseReadableAgentArchive, existingManifest?: any) { + return clean({ + contractVersion: "hwpod-case-run-artifact-manifest-v1", + caseId: run.caseId, + runId: run.runId, + archivedAt: text(existingManifest?.archivedAt) || context.now(), + sourceRunDir: run.runDir, + caseRepoRunDir: path.join(run.caseRepo, caseRegistryRunRel(run)), + trace: caseRunArtifactTrace(run, evidence), + traceLookup: evidence.traceLookup ?? agentTraceLookupForRun(run, evidence), + subject: evidence.subject ?? run.subject, + agent: clean({ + providerProfile: evidence.agent?.providerProfile ?? run.agent?.providerProfile, + requestedProviderProfile: evidence.agent?.requestedProviderProfile ?? run.agent?.requestedProviderProfile, + resolvedBackendProfile: evidence.agent?.resolvedBackendProfile ?? run.agent?.resolvedBackendProfile, + provider: evidence.agent?.provider ?? run.agent?.provider, + model: evidence.agent?.model ?? run.agent?.model, + backend: evidence.agent?.backend ?? run.agent?.backend, + infrastructureBackend: evidence.agent?.infrastructureBackend ?? run.agent?.infrastructureBackend, + providerTrace: evidence.agent?.providerTrace ?? run.agent?.providerTrace, + projectId: evidence.agent?.projectId ?? run.agent?.projectId, + conversationId: evidence.agent?.conversationId ?? run.agent?.conversationId, + sessionId: evidence.agent?.sessionId ?? run.agent?.sessionId, + threadId: evidence.agent?.threadId ?? run.agent?.threadId, + traceId: evidence.agent?.traceId ?? run.agent?.traceId, + terminalStatus: evidence.agent?.terminalStatus ?? run.agent?.terminalStatus, + commandStatus: evidence.agent?.commandStatus ?? run.agent?.commandStatus, + agentRunStatus: evidence.agent?.agentRunStatus ?? run.agent?.agentRunStatus, + toolCallSummary: evidence.agent?.toolCallSummary ?? run.agent?.toolCallSummary + }), + readableAgent: clean({ + renderer: CASE_TRACE_RENDERER, + messagesPath: readableAgent.messagesRel, + tracePath: readableAgent.traceRel, + transcriptPath: readableAgent.transcriptRel, + finalResponsePath: readableAgent.finalResponseRel, + traceRender: readableAgent.traceRender, + finalResponse: readableAgent.finalResponse + }), + aggregate: aggregateManifestEntry(files), + prompt: clean({ path: "agent-prompt.md", sha256: evidence.agent?.promptSha256 ?? run.agent?.promptSha256 }), + diff: clean({ + path: "agent-diff.patch", + sha256: evidence.agentDiff?.diffPatchSha256 ?? run.agentDiff?.diffPatchSha256, + statusShort: evidence.agentDiff?.statusShort ?? run.agentDiff?.statusShort, + diffStat: evidence.agentDiff?.diffStat ?? run.agentDiff?.diffStat, + collection: evidence.agentDiff?.diffCollection ?? run.agentDiff?.diffCollection, + sourceRootStatusShort: evidence.agentDiff?.sourceRootStatusShort ?? run.agentDiff?.sourceRootStatusShort, + sourceRootChangedAfterPrepare: evidence.agentDiff?.sourceRootChangedAfterPrepare ?? run.agentDiff?.sourceRootChangedAfterPrepare, + sourceRootChangedAfterAgent: evidence.agentDiff?.sourceRootChangedAfterAgent ?? run.agentDiff?.sourceRootChangedAfterAgent, + sourceRootBaseline: evidence.agentDiff?.sourceRootBaseline ?? run.agentDiff?.sourceRootBaseline, + sourceRootAfterPrepare: evidence.agentDiff?.sourceRootAfterPrepare ?? run.agentDiff?.sourceRootAfterPrepare, + sourceRootAfterAgent: evidence.agentDiff?.sourceRootAfterAgent ?? run.agentDiff?.sourceRootAfterAgent + }), + agentStage: agentStageSummary(evidence), + agentFinal: evidence.agentFinal ?? null, + postValidation: evidence.postValidation ?? null, + warnings: evidence.warnings ?? null, + keilJob: evidence.keilJob ?? null, + artifacts: evidence.artifacts ?? [], + decisions: { + autoEvaluation: false, + runnerPostAgentCompileCheck: "recorded", + reason: "flow-only run: the manifest records artifacts and trace without auto-grading or gate decisions" + }, + files, + skippedFiles + }); +} + +export function aggregateManifestEntry(files: CaseRegistryArchiveFile[]) { + const file = files.find((entry) => entry.path === "aggregate.md"); + if (!file) return undefined; + return { path: file.path, bytes: file.bytes, sha256: file.sha256, primaryEntry: true, aggregationOnly: true, autoEvaluation: false }; +} + +export function caseRunArtifactTrace(run: PreparedCaseRun, evidence: any) { + return clean({ + traceId: evidence.agent?.traceId ?? run.agent?.traceId ?? evidence.agentTrace?.traceId ?? run.agentTrace?.traceId, + sessionId: evidence.agent?.sessionId ?? run.agent?.sessionId, + conversationId: evidence.agent?.conversationId ?? run.agent?.conversationId, + threadId: evidence.agent?.threadId ?? run.agent?.threadId, + providerProfile: evidence.agent?.providerProfile ?? run.agent?.providerProfile, + projectId: evidence.agent?.projectId ?? run.agent?.projectId, + source: evidence.agentTrace?.source ?? run.agentTrace?.source, + lookupOnly: evidence.agentTrace?.lookupOnly ?? run.agentTrace?.lookupOnly, + lookup: evidence.traceLookup ?? evidence.agentTrace?.lookup ?? run.agentTrace?.lookup ?? agentTraceLookupForRun(run, evidence), + status: evidence.agentTrace?.status ?? run.agentTrace?.status, + terminalStatus: evidence.agentTrace?.terminalStatus ?? run.agentTrace?.terminalStatus, + toolCallSummary: evidence.agentTrace?.toolCallSummary ?? run.agentTrace?.toolCallSummary ?? evidence.agent?.toolCallSummary ?? run.agent?.toolCallSummary, + commandCount: evidence.agentTrace?.commandCount ?? run.agentTrace?.commandCount, + hwpodCommandCount: evidence.agentTrace?.hwpodCommandCount ?? run.agentTrace?.hwpodCommandCount, + hwpodBuildCommandCount: evidence.agentTrace?.hwpodBuildCommandCount ?? run.agentTrace?.hwpodBuildCommandCount, + agentRun: compactObject(evidence.agentTrace?.agentRun ?? run.agentTrace?.agentRun ?? null) + }); +} + +export function caseRegistryRunRel(run: PreparedCaseRun) { + return artifactRel(path.join("runs", run.caseId, run.runId)); +} + +export function artifactRel(value: string) { + return value.replace(/\\/gu, "/"); +} + +export function artifactSourceLabel(run: PreparedCaseRun, source?: string) { + if (!source) return undefined; + const relative = path.relative(path.resolve(run.runDir), path.resolve(source)); + if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) return artifactRel(relative); + return source; +} diff --git a/tools/src/hwlab-caserun-evidence.ts b/tools/src/hwlab-caserun-evidence.ts new file mode 100644 index 00000000..38c292c9 --- /dev/null +++ b/tools/src/hwlab-caserun-evidence.ts @@ -0,0 +1,276 @@ +// SPEC: PJ2026-0103 HarnessRL draft-2026-06-25-p0-web-caserun-e2e. +// Responsibility: normalize AgentRun provider, terminal, and tool-call evidence for CaseRun. + +export type AgentToolCallSummaryItem = { + source: string; + index: number; + kind?: string; + toolName?: string; + status?: string; + exitCode?: number; + command?: string; + normalizedCommand?: string; + detail?: string; +}; + +export type AgentToolCallSummary = { + source: string; + count: number; + statusCounts: Record; + exitCodeCounts: Record; + terminalStatus?: string; + commandStatus?: string; + agentRunStatus?: string; + items: AgentToolCallSummaryItem[]; + hwpodRawSteps: AgentToolCallSummaryItem[]; +}; + +export function agentWithResultEvidence(agent: any, resultBody: any, agentTask: any = {}) { + const root = resultBody?.body && typeof resultBody.body === "object" ? resultBody.body : resultBody; + return clean({ ...agent, ...agentTerminalEvidence(root), ...agentProviderEvidence({ ...agent, result: root }, agentTask), toolCallSummary: agentToolCallSummaryFromResult(root), result: compactObject(resultBody) }); +} + +export function agentTerminalEvidence(value: any) { + const root = value?.body && typeof value.body === "object" ? value.body : value; + const agentRun = firstObject(root?.agentRun, root?.traceSummary?.agentRun, root?.terminalEvidence?.agentRun, root?.terminalEvidence?.traceSummary?.agentRun); + const terminalStatus = firstTextOption(root?.terminalStatus, root?.traceSummary?.terminalStatus, root?.terminalEvidence?.terminalStatus, agentRun?.terminalStatus); + const commandStatus = firstTextOption(root?.commandStatus, root?.traceSummary?.commandStatus, root?.terminalEvidence?.commandStatus, agentRun?.commandStatus, agentRun?.commandState); + const agentRunStatus = firstTextOption(root?.agentRunStatus, root?.traceSummary?.agentRunStatus, root?.terminalEvidence?.agentRunStatus, agentRun?.runStatus, agentRun?.status); + const terminal = [terminalStatus, commandStatus, agentRunStatus].some((status) => /^(?:completed|failed|cancelled|canceled|timed_out|timeout|natural_end)$/iu.test(status)); + return clean({ terminalStatus, commandStatus, agentRunStatus, terminal }); +} + +export function agentToolCallSummaryFromResult(value: any): AgentToolCallSummary | null { + const root = value?.body && typeof value.body === "object" ? value.body : value; + const summary = firstObject(root?.toolCallSummary, root?.traceSummary?.toolCallSummary, root?.terminalEvidence?.toolCallSummary, root?.terminalEvidence?.traceSummary?.toolCallSummary, root?.agentRun?.toolCallSummary, value?.toolCallSummary); + const items = firstArray(summary?.items, summary?.toolCalls, summary?.calls, summary?.commands).map((item, index) => toolCallItem(item, index)).filter((item) => item.command || item.status || item.exitCode !== undefined || item.toolName); + const statusCounts = countedRecord(summary?.statusCounts ?? summary?.status_counts, items.map((item) => item.status)); + const exitCodeCounts = countedRecord(summary?.exitCodeCounts ?? summary?.exit_code_counts, items.map((item) => item.exitCode === undefined ? "" : String(item.exitCode))); + const count = firstNumberOption(summary?.count, summary?.total, summary?.totalCount, items.length || undefined, sumRecordValues(statusCounts), sumRecordValues(exitCodeCounts)) ?? 0; + if (Object.keys(summary).length === 0 && count === 0 && items.length === 0) return null; + const terminal = agentTerminalEvidence(root); + return clean({ source: text(summary.source) || "agentrun-result.toolCallSummary", count, statusCounts, exitCodeCounts, terminalStatus: terminal.terminalStatus, commandStatus: terminal.commandStatus, agentRunStatus: terminal.agentRunStatus, items, hwpodRawSteps: items.filter((item) => ["build", "download", "uart-read"].includes(text(item.kind))) }); +} + +export function commandsFromToolCallSummary(summary: AgentToolCallSummary | null) { + return (summary?.items ?? []).flatMap((item) => item.command ? [clean({ source: item.source, seq: item.index + 1, toolName: item.toolName, status: item.status, command: item.command, normalizedCommand: item.normalizedCommand, bodyPreview: item.detail, exitCode: item.exitCode })] : []); +} + +export function mentionsHwpod(command: { command?: string; normalizedCommand?: string }) { + const raw = normalizeCommand(command.normalizedCommand || command.command); + return /^(?:hwpod|hwpod-ctl|hwpod-cli)(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts(?:\s|$)/iu.test(raw); +} + +export function hwpodCommandKind(command: { command?: string; normalizedCommand?: string }) { + return commandKind(normalizeCommand(command.normalizedCommand || command.command)); +} + +export function applyCaseRunEvidenceRelationships(evidence: any, run: any) { + const resultBody = run.agent?.result?.body && typeof run.agent.result.body === "object" ? run.agent.result.body : run.agent?.result; + const terminal = agentTerminalEvidence(resultBody); + const finalResponse = firstObject(resultBody?.finalResponse, resultBody?.reply); + const finalText = firstTextOption(finalResponse?.text, finalResponse?.content, typeof resultBody?.finalResponse === "string" ? resultBody.finalResponse : "", typeof resultBody?.reply === "string" ? resultBody.reply : ""); + const agentWarningCount = firstNumberOption(resultBody?.warningCount, resultBody?.buildWarningCount, resultBody?.build?.warningCount, resultBody?.agentBuild?.warningCount); + const runnerWarningCount = firstNumberOption(evidence.keilJob?.warningCount, evidence.keilJob?.warning_count, evidence.keilJob?.summary?.warningCount, evidence.hwpod?.stdoutJson?.warningCount); + evidence.agentFinal = clean({ source: "agentrun-result", present: Boolean(finalText || finalResponse?.present === true), text: finalText, stageStatus: run.agent?.stageStatus, timedOut: run.agent?.timedOut === true, terminalStatus: terminal.terminalStatus, commandStatus: terminal.commandStatus, agentRunStatus: terminal.agentRunStatus, naturalEnd: terminal.terminal === true && run.agent?.timedOut !== true, missingReason: finalText ? undefined : terminal.terminal ? "agentrun-terminal-without-final-response" : run.agent?.timedOut ? "caserun-poll-timeout-before-agentrun-terminal" : "final-response-not-reported" }); + evidence.postValidation = clean({ source: evidence.hwpod?.source, jobId: evidence.keilJob?.jobId, status: evidence.keilJob?.status, returnCode: evidence.keilJob?.returnCode ?? evidence.hwpod?.exitCode, warningCount: runnerWarningCount, boundary: "runner-post-agent-validation" }); + evidence.warnings = clean({ agentReportedBuildWarningCount: agentWarningCount, runnerPostValidationWarningCount: runnerWarningCount }); +} + +function agentProviderEvidence(agent: any, agentTask: any = {}) { + const root = agent?.result?.body && typeof agent.result.body === "object" ? agent.result.body : agent?.result; + const agentRun = firstObject(root?.agentRun, root?.traceSummary?.agentRun, root?.terminalEvidence?.agentRun); + const providerTrace = firstObject(root?.providerTrace, root?.traceSummary?.providerTrace, root?.terminalEvidence?.providerTrace, agentRun?.providerTrace); + const requestedProviderProfile = firstTextOption(agent?.requestedProviderProfile, root?.requestedProviderProfile, root?.providerProfile, agent?.providerProfile, agentTask?.providerProfile); + const resolvedBackendProfile = firstTextOption(agent?.resolvedBackendProfile, root?.resolvedBackendProfile, root?.backendProfile, agentRun?.backendProfile, providerTrace?.backendProfile); + const backend = firstTextOption(agent?.backend, root?.backend, agentRun?.backend, resolvedBackendProfile); + return clean({ requestedProviderProfile, resolvedBackendProfile, provider: firstTextOption(agent?.provider, root?.provider, providerTrace?.provider), model: firstTextOption(agent?.model, root?.model, agentRun?.model, providerTrace?.model), backend, infrastructureBackend: firstTextOption(agent?.infrastructureBackend, root?.infrastructureBackend, agentRun?.infrastructureBackend, backend), providerTrace: Object.keys(providerTrace).length > 0 ? compactObject(providerTrace) : null }); +} + +function toolCallItem(value: Record, index: number): AgentToolCallSummaryItem { + const input = firstObject(value.input, value.args, value.arguments); + const output = firstObject(value.output, value.result); + const command = firstTextOption(value.command, input.command, input.cmd, Array.isArray(input.argv) ? input.argv.join(" ") : input.argv, output.command); + const normalizedCommand = normalizeCommand(command); + return clean({ source: "agentrun-result.toolCallSummary", index, kind: commandKind(normalizedCommand), toolName: firstTextOption(value.toolName, value.name, value.tool), status: firstTextOption(value.status, output.status), exitCode: firstNumberOption(value.exitCode, value.exit_code, output.exitCode, output.exit_code), command: clipText(command, 1000), normalizedCommand: clipText(normalizedCommand, 1000), detail: clipText(firstTextOption(value.detail, value.summary, output.stderr, output.stdout), 1000) }); +} + +function commandKind(raw: string) { + if (/^(?:hwpod|hwpod-cli)\s+build(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts\s+build(?:\s|$)/iu.test(raw)) return "build"; + if (/^(?:hwpod|hwpod-cli)\s+download(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts\s+download(?:\s|$)/iu.test(raw)) return "download"; + if (/^(?:hwpod|hwpod-cli)\s+(?:uart\s+read|io\s+uart\s+read)(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts\s+(?:uart\s+read|io\s+uart\s+read)(?:\s|$)/iu.test(raw)) return "uart-read"; + if (/^(?:hwpod-ctl|hwpod-cli)\s+spec\s+validate(?:\s|$)/iu.test(raw)) return "spec-validate"; + if (/^hwpod\s+inspect(?:\s|$)/iu.test(raw)) return "inspect"; + if (/^hwpod\s+workspace\s+(?:insert-after|apply-patch|write|replace|append)(?:\s|$)/iu.test(raw)) return "workspace-edit"; + return ""; +} + +function normalizeCommand(value: unknown) { return text(value).replace(/^\/bin\/sh\s+-lc\s+/u, "").replace(/^['"]|['"]$/gu, "").replace(/\s+/gu, " ").trim(); } +export function firstObject(...values: any[]): Record { return values.find((value) => value && typeof value === "object" && !Array.isArray(value)) ?? {}; } +function firstArray(...values: any[]): Record[] { return (values.find(Array.isArray) ?? []).filter((value: any) => value && typeof value === "object" && !Array.isArray(value)); } +export function firstTextOption(...values: any[]) { return values.map(text).find(Boolean) ?? ""; } +export function firstNumberOption(...values: any[]) { for (const value of values) { const number = numberOption(value); if (number !== undefined) return number; } return undefined; } +function countedRecord(value: any, fallbacks: Array) { if (value && typeof value === "object" && !Array.isArray(value)) return Object.fromEntries(Object.entries(value).map(([key, count]) => [key, Number(count) || 0])); return fallbacks.filter(Boolean).reduce>((result, key) => ({ ...result, [key as string]: (result[key as string] ?? 0) + 1 }), {}); } +function sumRecordValues(value: Record) { return Object.values(value).reduce((sum, count) => sum + count, 0); } +function compactObject(value: any) { const json = JSON.stringify(value ?? null); return json.length <= 4000 ? value : { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) }; } +function clipText(value: unknown, maxBytes: number) { const raw = String(value ?? ""); const buffer = Buffer.from(raw, "utf8"); return buffer.length <= maxBytes ? raw : `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`; } +function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; } +function text(value: unknown) { return String(value ?? "").trim(); } +function clean>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T; } + +type AgentTraceCommand = { source: string; seq?: number; rowId?: string; header?: string; toolName?: string; status?: string; command?: string; normalizedCommand?: string; bodyPreview?: string; exitCode?: number }; +const MAX_AGENT_TRACE_COMMANDS = 30; + +export function summarizeAgentTrace(traceId: string, httpStatus: number, body: any) { + const traceBody = body?.body ?? body; + const rows = arrayOfObjects(traceBody?.rows ?? traceBody?.renderedRows ?? traceBody?.traceRows); + const events = arrayOfObjects(traceBody?.events ?? traceBody?.trace?.events ?? traceBody?.sourceEvents); + const toolCallSummary = agentToolCallSummaryFromResult(traceBody); + const traceCommands = [...commandsFromTraceRows(rows), ...commandsFromTraceEvents(events)]; + const summaryCommands = commandsFromToolCallSummary(toolCallSummary); + const allCommands = summaryCommands.length > 0 ? summaryCommands : traceCommands; + const commands = selectAgentTraceCommands(allCommands); + const searchable = [ + text(traceBody?.assistantText ?? body?.assistantText), + ...[...traceCommands, ...summaryCommands].map((command) => [command.command, command.bodyPreview].map((item) => text(item)).filter(Boolean).join("\n")) + ].filter(Boolean).join("\n"); + const hwpodCommandCount = allCommands.filter((command) => mentionsHwpod(command)).length; + const hwpodBuildCommandCount = allCommands.filter((command) => mentionsHwpodBuild(command)).length; + return clean({ + traceId, + status: text(traceBody?.status ?? body?.status) || "recorded", + httpStatus, + source: text(traceBody?.source ?? body?.source), + lookupOnly: traceBody?.lookupOnly === true || body?.lookupOnly === true ? true : undefined, + lookup: compactObject(traceBody?.lookup ?? body?.lookup ?? null), + sourceEventCount: numberOption(traceBody?.sourceEventCount ?? traceBody?.traceSummary?.sourceEventCount ?? body?.sourceEventCount), + renderedRowCount: rows.length || numberOption(traceBody?.renderedRowCount), + commandCount: allCommands.length, + hwpodCommandCount, + hwpodBuildCommandCount, + keilJobCandidates: uniqueStrings(Array.from(searchable.matchAll(/20\d{6}_\d{6}_[0-9a-f]{8}/giu)).map((match) => match[0])), + terminalStatus: firstTextOption(traceBody?.terminalStatus, traceBody?.traceSummary?.terminalStatus, body?.terminalStatus, toolCallSummary?.terminalStatus), + agentRun: compactObject(traceBody?.agentRun ?? traceBody?.traceSummary?.agentRun ?? body?.agentRun ?? traceBody?.terminalEvidence?.agentRun ?? null), + toolCallSummary, + commands + }); +} + +function commandsFromTraceRows(rows: Record[]) { + return rows.flatMap((row, index) => { + const header = text(row.header ?? row.title ?? row.label); + const body = text(row.body ?? row.content ?? row.text ?? row.message); + if (!isCommandTraceRow(row, header)) return []; + const command = text((row as any).command) || commandFromText(`${header}\n${body}`) || commandFromRenderedToolBody(body); + if (!command) return []; + return [clean({ + source: "trace-row", + seq: numberOption(row.seq) ?? index + 1, + rowId: text(row.id ?? row.rowId), + header: clipText(header, 500), + toolName: text(row.toolName ?? row.name), + status: text(row.status ?? row.level), + command: clipText(command, 1000), + normalizedCommand: clipText(normalizedHwpodCommand(command), 1000), + bodyPreview: clipText(body, 1200), + exitCode: exitCodeFromText(body) + })]; + }); +} + +function commandsFromTraceEvents(events: Record[]) { + return events.flatMap((event, index) => { + const toolName = text(event.toolName ?? event.name ?? event.label); + const message = text(event.message ?? event.text ?? event.content ?? event.output ?? event.stdout); + if (!isCommandTraceEvent(event, toolName)) return []; + const command = text(event.command ?? event.input ?? event.args) || commandFromExecutionText(message) || commandFromText(message) || commandFromRenderedToolBody(message); + if (!command) return []; + return [clean({ + source: "trace-event", + seq: numberOption(event.seq ?? event.index) ?? index + 1, + rowId: text(event.id ?? event.eventId), + toolName, + status: text(event.status ?? event.level), + command: clipText(command, 1000), + normalizedCommand: clipText(normalizedHwpodCommand(command), 1000), + bodyPreview: clipText(message, 1200), + exitCode: numberOption(event.exitCode) ?? exitCodeFromText(message) + })]; + }); +} + +function isCommandTraceRow(row: Record, header: string) { + return /^tool:/u.test(text(row.rowId ?? row.id)) || /commandExecution|tool_call/iu.test(header) || /commandExecution|tool_call/iu.test(text(row.toolName ?? row.name)); +} + +function isCommandTraceEvent(event: Record, toolName: string) { + const type = text(event.type ?? event.kind ?? event.event); + return /commandExecution/iu.test(toolName) || /commandExecution|tool_call/iu.test(type); +} + +function selectAgentTraceCommands(commands: AgentTraceCommand[]) { + const selected: AgentTraceCommand[] = []; + for (const command of [ + ...selectRepresentativeHwpodCommands(commands), + ...commands.slice(0, 10), + ...commands.slice(-10), + ...commands.filter((item) => mentionsHwpod(item)) + ]) { + const key = `${command.source}:${command.seq ?? ""}:${command.rowId ?? ""}:${command.command ?? ""}:${command.status ?? ""}`; + if (selected.some((item) => `${item.source}:${item.seq ?? ""}:${item.rowId ?? ""}:${item.command ?? ""}:${item.status ?? ""}` === key)) continue; + selected.push(command); + if (selected.length >= MAX_AGENT_TRACE_COMMANDS) break; + } + return selected; +} + +function selectRepresentativeHwpodCommands(commands: AgentTraceCommand[]) { + return ["spec-validate", "inspect", "workspace-edit", "build", "download", "uart-read"].flatMap((kind) => { + const candidates = commands.filter((command) => hwpodCommandKind(command) === kind); + return candidates.find((command) => text(command.status) === "completed" || command.exitCode === 0) ?? candidates[0] ?? []; + }); +} + +function arrayOfObjects(value: unknown): Record[] { + return Array.isArray(value) ? value.filter((item) => item && typeof item === "object" && !Array.isArray(item)) as Record[] : []; +} + +function commandFromText(value: string) { + const match = value.match(/(?:command|cmd)\s*[:=]\s*([^\n]+)/iu); + return text(match?.[1]); +} + +function commandFromRenderedToolBody(value: string) { + const raw = text(value); + if (!raw) return ""; + const match = raw.match(/^(.+?)(?:\s+stdout:|\s+stderr:|\s+exitCode=|\s+exit\s*code\b)/isu); + return text(match?.[1] ?? raw); +} + +function commandFromExecutionText(value: string) { + const raw = text(value); + const match = raw.match(/commandExecution\s+(?:inProgress|completed|started|failed):\s+(.+?)(?:\s+exit=|\s+durationMs=|\s*$)/isu); + return text(match?.[1]); +} + +function exitCodeFromText(value: string) { + const match = value.match(/exit\s*code\s*[:=]?\s*(-?\d+)/iu) ?? value.match(/exitCode\s*[:=]?\s*(-?\d+)/u); + return numberOption(match?.[1]); +} + +function mentionsHwpodBuild(command: AgentTraceCommand) { + const raw = normalizedHwpodCommand(command.normalizedCommand || command.command || ""); + return /^(?:hwpod|hwpod-cli)\s+build(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts\s+build(?:\s|$)/iu.test(raw); +} + +function normalizedHwpodCommand(value: string) { + return unquoteShellCommand(text(value)).replace(/\s+/gu, " ").trim(); +} + +function unquoteShellCommand(value: string) { + return value.replace(/^\/bin\/sh\s+-lc\s+/u, "").replace(/^['"]|['"]$/gu, "").trim(); +} + +function uniqueStrings(values: string[]) { + return Array.from(new Set(values.map((value) => text(value)).filter(Boolean))); +} diff --git a/tools/src/hwlab-caserun-lib.ts b/tools/src/hwlab-caserun-lib.ts index 06b191a8..19844098 100644 --- a/tools/src/hwlab-caserun-lib.ts +++ b/tools/src/hwlab-caserun-lib.ts @@ -1,2910 +1,3 @@ -// SPEC: PJ2026-01010305 71FREQ preinstall draft-2026-06-26-71freq-v03-hwpod-preinstall. -// Responsibility: v0.3 CaseRun runtime-origin resolution and compile-only HWPOD smoke orchestration. - -import { spawn } from "node:child_process"; -import { createHash, 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, type AgentDiffCollectionSummary } from "./hwlab-caserun-diff.ts"; -import { readHwpodSpec } from "./hwpod-harness-lib.ts"; - -const REMOVED_V02_API_URL = "http://74.48.78.17:19667"; -const REMOVED_V02_WEB_URL = "http://74.48.78.17:19666"; -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"; - -type EnvLike = Record; -type ParsedArgs = Record & { _: string[] }; -type FetchLike = typeof fetch; -type RunProcessLike = (command: string[], cwd: string, env: Record) => Promise<{ command: string[]; stdout: string; stderr: string; exitCode: number }>; -type StartProcessLike = (input: { command: string; args: string[]; cwd: string; env: Record; stdoutPath: string; stderrPath: string }) => Promise<{ pid: number }> | { pid: number }; - -type CaseContext = { - parsed: ParsedArgs; - env: EnvLike; - fetchImpl: FetchLike; - cwd: string; - now: () => string; - sleep: (ms: number) => Promise; - runProcess?: RunProcessLike; - startProcess?: StartProcessLike; - argv?: string[]; - rest: string[]; -}; - -type PreparedCaseRun = { - caseId: string; - runId: string; - runDir: string; - caseRepo: string; - caseDir: string; - caseFile: string; - sourceSpecPath: string; - specPath: string; - apiUrl: string; - compileOnly: boolean; - createdAt: string; - updatedAt: string; - definition: Record; - subject: PreparedSubjectRun; - agentTask?: PreparedAgentTask | null; - agent?: AgentRunStage | null; - agentTrace?: AgentTraceStage | null; - agentDiff?: AgentDiffStage | null; - status?: string; - stage?: string; - evidencePath?: string; - completedAt?: string; -}; - -type PreparedSubjectRun = { - repoLocalPath: string; - commitId: string; - subdir: string; - worktreePath: string; - workspacePath: string; - setup: Record; - sourceRootBaseline?: SourceRootSnapshot; - sourceRootAfterPrepare?: SourceRootSnapshot; -}; - -type SourceRootSnapshot = { - label: string; - statusShort: string; - diffStat: string; - diffSha256: string; - diffBytes: number; - commands?: Record; -}; - -type PreparedAgentTask = { - prompt: string; - workspace: "subjectWorktree"; - constraints: string[]; - providerProfile: string; - projectId: string; - timeoutMs?: number; - pollIntervalMs?: number; -}; - -type AgentRunStage = { - stageStatus: string; - baseUrl: string; - projectId: string; - providerProfile: string; - conversationId: string; - sessionId: string; - threadId: string; - traceId: string; - promptPath: string; - promptSha256: string; - accepted: Record | null; - result: Record | null; - polls: number; - timedOut: boolean; - timeoutMs?: number; - pollIntervalMs?: number; - resultUrl?: string; - lastPollAt?: string; - lastHttpStatus?: number; - nextPollCommand?: string; - traceCommand?: string; - inspectCommand?: string; - sessionCommand?: string; - traceLookup?: Record; - sessionResponse: Record | null; - error?: Record | null; -}; - -type AgentDiffStage = { - statusShort: string; - diffStat: string; - diffPatchPath: string; - diffPatchSha256: string; - diffCollection?: AgentDiffCollectionSummary; - sourceRootStatusShort: string; - sourceRootBaseline?: SourceRootSnapshot; - sourceRootAfterPrepare?: SourceRootSnapshot; - sourceRootAfterAgent?: SourceRootSnapshot; - sourceRootChangedAfterPrepare?: boolean; - sourceRootChangedAfterAgent?: boolean; - requests: Record[]; -}; - -type AgentTraceCommand = { - source: string; - seq?: number; - rowId?: string; - header?: string; - toolName?: string; - status?: string; - command?: string; - normalizedCommand?: string; - bodyPreview?: string; - exitCode?: number; -}; - -type AgentTraceStage = { - traceId: string; - status: string; - httpStatus: number; - source?: string; - lookupOnly?: boolean; - lookup?: Record; - sourceEventCount?: number; - renderedRowCount?: number; - commandCount: number; - hwpodCommandCount: number; - hwpodBuildCommandCount: number; - keilJobCandidates: string[]; - terminalStatus?: string; - agentRun?: Record | null; - commands: AgentTraceCommand[]; - error?: Record | null; -}; - -type CaseRegistryArchiveFile = { - path: string; - bytes: number; - sha256: string; - source?: string; -}; - -type CaseRegistryArchive = { - caseRepoRunDir: string; - artifactManifestPath: string; - caseRepoFiles: string[]; - files: CaseRegistryArchiveFile[]; - skippedFiles: string[]; - manifest: Record; - registrySync?: Record | null; -}; - -type CaseReadableAgentArchive = { - messagesRel: string; - traceRel: string; - transcriptRel: string; - finalResponseRel: string; - generatedFiles: string[]; - traceRender: Record; - finalResponse: Record; -}; - -type ArchivedAgentTraceSnapshot = { - traceBody: any; - rows: TraceEventRow[]; - events: Record[]; - summary: AgentTraceStage; -}; - -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 === "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 (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, - 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 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 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 prompt 是无服务预览入口,只按真实 CaseRun 组装路径渲染输入 prompt,不准备 worktree、不提交 agent、不访问硬件。", - "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 aggregate is a service-free post-processing command: it reads an existing case registry run and writes one aggregate markdown entry without pass/fail grading.", - "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." - ] - }); -} - -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 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, loaded.definition), - compileOnly: true, - 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: true, - 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: 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: true, - 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 = { ...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"); - const run = await readRunById(context, runId); - const control = controlFromRun(run); - const status = text(control.status) || text(run.status) || "unknown"; - const stage = statusStage(run, control); - 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"); - const run = await readRunById(context, runId); - const control = controlFromRun(run); - const status = text(control.status) || text(run.status) || "unknown"; - const stage = statusStage(run, control); - 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, loaded.definition); - const subject = await prepareSubjectWorktree(context, { caseId, runId, runDir, apiUrl, definition: loaded.definition, sourceSpecPath: loaded.specPath }); - const agentTask = agentTaskFromDefinition(loaded.definition, caseId); - const run: PreparedCaseRun = { - caseId, - runId, - runDir, - caseRepo, - caseDir: loaded.caseDir, - caseFile: loaded.caseFile, - sourceSpecPath: loaded.specPath, - specPath, - apiUrl, - compileOnly: true, - createdAt: now, - updatedAt: now, - definition: loaded.definition, - 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: true, - agentTask: agentTaskSummary(agentTask), - subject, - summary: prepareSummary(run), - run - }); -} - -export async function buildCaseRun(context: CaseContext, prepared?: PreparedCaseRun) { - const run = prepared ?? await loadOrPrepareCaseRun(context); - const command = [process.execPath, path.join(context.cwd, "tools/hwpod-cli.ts"), "build", "--spec", run.specPath, "--reason", `case-run ${run.caseId} ${run.runId} compile-only`]; - const invoked = await (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 jobId = extractKeilJobId(hwpodPayload); - const job = jobId ? await pollKeilJobStatus(context, run, jobId) : null; - const evidence = clean({ - contractVersion: "hwpod-case-run-evidence-v1", - caseId: run.caseId, - runId: run.runId, - compileOnly: true, - autoEvaluation: false, - status: "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) - }), - keilJob: job ? job.summary : null, - artifacts: job?.summary?.artifacts ?? [], - decisions: { - autoEvaluation: false, - runnerPostAgentCompileCheck: "recorded", - reason: "flow-only run: CaseRun records agent, diff and compile evidence without auto-grading them" - } - }); - await writeJson(path.join(run.runDir, "evidence.json"), evidence); - 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, - runDir: run.runDir, - compileOnly: true, - summary: buildSummary(evidence), - evidence, - run - }, "completed"); -} - -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: true, 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, 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; - 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; -} - -function caseTimeoutsFromDefinition(definition: Record) { - const value = definition.timeouts; - if (!value || typeof value !== "object" || Array.isArray(value)) return {} as Record; - return value as Record; -} - -function defaultAgentTask(definition: Record, 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" - }; -} - -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, run.definition); - 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 = 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 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 = clean({ - stageStatus: result.stageStatus, - baseUrl, - projectId, - providerProfile, - conversationId, - sessionId, - threadId, - traceId, - promptPath, - promptSha256, - accepted: compactObject(accepted.body), - result: compactObject(result.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 - }); - const nextRun = await updateRun(context, run, { agent }); - return { run: nextRun, agent }; -} - -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 | null; sessionResponse?: Record | null; error?: Record | 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"); -} - -function agentTraceLookup(input: { baseUrl: string; traceId: string; sessionId?: string; conversationId?: string; threadId?: string }) { - const traceId = input.traceId; - const base = input.baseUrl; - const commands = clean({ - result: `hwlab-cli client agent result ${traceId}`, - trace: `hwlab-cli client agent trace ${traceId} --render web`, - traceFull: `hwlab-cli client agent trace ${traceId} --render web --full`, - inspect: `hwlab-cli client agent inspect --trace-id ${traceId}`, - sessionStatus: input.sessionId ? `hwlab-cli client agent session status ${input.sessionId}` : undefined - }); - return clean({ - source: "hwlab-cli.client.agent", - strategy: "id_plus_existing_cli", - note: "CaseRun records trace/session identity and reuses existing HWLAB Web-equivalent agent CLI for trace/result/inspect; it does not implement a separate trace query path.", - baseUrl: base, - traceId, - sessionId: input.sessionId, - conversationId: input.conversationId, - threadId: input.threadId, - commands - }); -} - -function agentTraceIdentityArtifact(run: PreparedCaseRun, agent: AgentRunStage) { - const traceId = text(agent.traceId); - const lookup = agent.traceLookup ?? agentTraceLookup({ baseUrl: agent.baseUrl, traceId, sessionId: agent.sessionId, conversationId: agent.conversationId, threadId: agent.threadId }); - const resultBody = agent.result?.body && typeof agent.result.body === "object" ? agent.result.body : agent.result; - return clean({ - contractVersion: "hwpod-case-run-agent-trace-identity-v1", - source: "caserun-identity", - lookupOnly: true, - status: text(resultBody?.status ?? agent.stageStatus) || "recorded", - traceId, - sessionId: agent.sessionId, - conversationId: agent.conversationId, - threadId: agent.threadId, - providerProfile: agent.providerProfile, - projectId: agent.projectId, - resultStatus: text(resultBody?.status), - finalResponse: resultBody?.finalResponse ?? resultBody?.reply ?? null, - traceSummary: resultBody?.traceSummary ?? null, - terminalEvidence: resultBody?.terminalEvidence ?? null, - agentRun: compactObject(resultBody?.agentRun ?? resultBody?.traceSummary?.agentRun ?? null), - lookup, - hint: "Use lookup.commands.trace or lookup.commands.result to query the full Web-equivalent AgentRun trace with the existing HWLAB CLI.", - runId: run.runId, - caseId: run.caseId - }); -} - -async function pollAgentResult(context: CaseContext, input: { baseUrl: string; traceId: string; acceptedBody: any; resultUrl?: string; run?: PreparedCaseRun; agent?: AgentRunStage }) { - 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 (input.run && input.agent) await updateRun(context, input.run, { stage: "agent-running", agent: { ...input.agent, stageStatus: "running", result: compactObject(lastBody), polls, timeoutMs, pollIntervalMs, 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, 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) } }; -} - -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); -} - -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 }; -} - -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: `case_subject_cmd_${randomUUID()}`, - hwpodId, - nodeId: document.spec.nodeBinding.nodeId, - intent: "cmd.run", - source: { compiler: "hwlab-cli.case", specPath: run.specPath, specAuthority: "case-run-state" }, - ops: [{ opId: "op_01_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] }; -} - -async function collectSourceRootSnapshot(context: CaseContext, run: PreparedCaseRun, label: string): Promise { - 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: `case_subject_cmd_${randomUUID()}`, - hwpodId, - nodeId: document.spec.nodeBinding.nodeId, - intent: "cmd.run", - source: { compiler: "hwlab-cli.case", specPath: run.specPath, specAuthority: "case-run-state" }, - ops: [{ - opId: "op_01_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) }; -} - -async function updateRun(context: CaseContext, run: PreparedCaseRun, patch: Partial & Record) { - 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 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 ${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}`, - `验证模式: 仅执行编译构建检查;除非案例明确要求,否则不下载、不做运行态冒烟验证。`, - ``, - `## 运行时装配`, - `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 ${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"); -} - -function summarizeAgentTrace(traceId: string, httpStatus: number, body: any): AgentTraceStage { - const traceBody = body?.body ?? body; - const rows = arrayOfObjects(traceBody?.rows ?? traceBody?.renderedRows ?? traceBody?.traceRows); - const events = arrayOfObjects(traceBody?.events ?? traceBody?.trace?.events ?? traceBody?.sourceEvents); - const allCommands = [...commandsFromTraceRows(rows), ...commandsFromTraceEvents(events)]; - const commands = selectAgentTraceCommands(allCommands); - const searchable = [ - text(traceBody?.assistantText ?? body?.assistantText), - ...allCommands.map((command) => [command.command, command.bodyPreview].map((item) => text(item)).filter(Boolean).join("\n")) - ].filter(Boolean).join("\n"); - const hwpodCommandCount = allCommands.filter((command) => mentionsHwpod(command)).length; - const hwpodBuildCommandCount = allCommands.filter((command) => mentionsHwpodBuild(command)).length; - return clean({ - traceId, - status: text(traceBody?.status ?? body?.status) || "recorded", - httpStatus, - source: text(traceBody?.source ?? body?.source), - lookupOnly: traceBody?.lookupOnly === true || body?.lookupOnly === true ? true : undefined, - lookup: compactObject(traceBody?.lookup ?? body?.lookup ?? null), - sourceEventCount: numberOption(traceBody?.sourceEventCount ?? traceBody?.traceSummary?.sourceEventCount ?? body?.sourceEventCount), - renderedRowCount: rows.length || numberOption(traceBody?.renderedRowCount), - commandCount: allCommands.length, - hwpodCommandCount, - hwpodBuildCommandCount, - keilJobCandidates: uniqueStrings(Array.from(searchable.matchAll(/20\d{6}_\d{6}_[0-9a-f]{8}/giu)).map((match) => match[0])), - terminalStatus: text(traceBody?.terminalStatus ?? traceBody?.traceSummary?.terminalStatus ?? body?.terminalStatus), - agentRun: compactObject(traceBody?.agentRun ?? traceBody?.traceSummary?.agentRun ?? body?.agentRun ?? traceBody?.terminalEvidence?.agentRun ?? null), - commands - }); -} - -function commandsFromTraceRows(rows: Record[]) { - return rows.flatMap((row, index) => { - const header = text(row.header ?? row.title ?? row.label); - const body = text(row.body ?? row.content ?? row.text ?? row.message); - if (!isCommandTraceRow(row, header)) return []; - const command = text((row as any).command) || commandFromText(`${header}\n${body}`) || commandFromRenderedToolBody(body); - if (!command) return []; - return [clean({ - source: "trace-row", - seq: numberOption(row.seq) ?? index + 1, - rowId: text(row.id ?? row.rowId), - header: clipText(header, 500), - toolName: text(row.toolName ?? row.name), - status: text(row.status ?? row.level), - command: clipText(command, 1000), - normalizedCommand: clipText(normalizedHwpodCommand(command), 1000), - bodyPreview: clipText(body, 1200), - exitCode: exitCodeFromText(body) - })]; - }); -} - -function commandsFromTraceEvents(events: Record[]) { - return events.flatMap((event, index) => { - const toolName = text(event.toolName ?? event.name ?? event.label); - const message = text(event.message ?? event.text ?? event.content ?? event.output ?? event.stdout); - if (!isCommandTraceEvent(event, toolName)) return []; - const command = text(event.command ?? event.input ?? event.args) || commandFromExecutionText(message) || commandFromText(message) || commandFromRenderedToolBody(message); - if (!command) return []; - return [clean({ - source: "trace-event", - seq: numberOption(event.seq ?? event.index) ?? index + 1, - rowId: text(event.id ?? event.eventId), - toolName, - status: text(event.status ?? event.level), - command: clipText(command, 1000), - normalizedCommand: clipText(normalizedHwpodCommand(command), 1000), - bodyPreview: clipText(message, 1200), - exitCode: numberOption(event.exitCode) ?? exitCodeFromText(message) - })]; - }); -} - -function isCommandTraceRow(row: Record, header: string) { - return /^tool:/u.test(text(row.rowId ?? row.id)) || /commandExecution|tool_call/iu.test(header) || /commandExecution|tool_call/iu.test(text(row.toolName ?? row.name)); -} - -function isCommandTraceEvent(event: Record, toolName: string) { - const type = text(event.type ?? event.kind ?? event.event); - return /commandExecution/iu.test(toolName) || /commandExecution|tool_call/iu.test(type); -} - -function selectAgentTraceCommands(commands: AgentTraceCommand[]) { - const selected: AgentTraceCommand[] = []; - for (const command of [ - ...selectRepresentativeHwpodCommands(commands), - ...commands.slice(0, 10), - ...commands.slice(-10), - ...commands.filter((item) => mentionsHwpod(item)) - ]) { - const key = `${command.source}:${command.seq ?? ""}:${command.rowId ?? ""}:${command.command ?? ""}:${command.status ?? ""}`; - if (selected.some((item) => `${item.source}:${item.seq ?? ""}:${item.rowId ?? ""}:${item.command ?? ""}:${item.status ?? ""}` === key)) continue; - selected.push(command); - if (selected.length >= MAX_AGENT_TRACE_COMMANDS) break; - } - return selected; -} - -function selectRepresentativeHwpodCommands(commands: AgentTraceCommand[]) { - return ["spec-validate", "inspect", "workspace-edit", "build", "download", "uart-read"].flatMap((kind) => { - const candidates = commands.filter((command) => hwpodCommandKind(command) === kind); - return candidates.find((command) => text(command.status) === "completed" || command.exitCode === 0) ?? candidates[0] ?? []; - }); -} - -function hwpodCommandKind(command: AgentTraceCommand) { - const raw = normalizedHwpodCommand(command.normalizedCommand || command.command || ""); - if (/^(?:hwpod-ctl|hwpod-cli)\s+spec\s+validate(?:\s|$)/iu.test(raw)) return "spec-validate"; - if (/^hwpod\s+inspect(?:\s|$)/iu.test(raw)) return "inspect"; - if (/^hwpod\s+workspace\s+(?:insert-after|apply-patch|write|replace|append)(?:\s|$)/iu.test(raw)) return "workspace-edit"; - if (/^(?:hwpod|hwpod-cli)\s+build(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts\s+build(?:\s|$)/iu.test(raw)) return "build"; - if (/^(?:hwpod|hwpod-cli)\s+download(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts\s+download(?:\s|$)/iu.test(raw)) return "download"; - if (/^(?:hwpod|hwpod-cli)\s+(?:uart\s+read|io\s+uart\s+read)(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts\s+(?:uart\s+read|io\s+uart\s+read)(?:\s|$)/iu.test(raw)) return "uart-read"; - return ""; -} - -function arrayOfObjects(value: unknown): Record[] { - return Array.isArray(value) ? value.filter((item) => item && typeof item === "object" && !Array.isArray(item)) as Record[] : []; -} - -function commandFromText(value: string) { - const match = value.match(/(?:command|cmd)\s*[:=]\s*([^\n]+)/iu); - return text(match?.[1]); -} - -function commandFromRenderedToolBody(value: string) { - const raw = text(value); - if (!raw) return ""; - const match = raw.match(/^(.+?)(?:\s+stdout:|\s+stderr:|\s+exitCode=|\s+exit\s*code\b)/isu); - return text(match?.[1] ?? raw); -} - -function commandFromExecutionText(value: string) { - const raw = text(value); - const match = raw.match(/commandExecution\s+(?:inProgress|completed|started|failed):\s+(.+?)(?:\s+exit=|\s+durationMs=|\s*$)/isu); - return text(match?.[1]); -} - -function exitCodeFromText(value: string) { - const match = value.match(/exit\s*code\s*[:=]?\s*(-?\d+)/iu) ?? value.match(/exitCode\s*[:=]?\s*(-?\d+)/u); - return numberOption(match?.[1]); -} - -function mentionsHwpod(command: AgentTraceCommand) { - const raw = normalizedHwpodCommand(command.normalizedCommand || command.command || ""); - return /^(?:hwpod|hwpod-ctl|hwpod-cli)(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts(?:\s|$)/iu.test(raw); -} - -function mentionsHwpodBuild(command: AgentTraceCommand) { - const raw = normalizedHwpodCommand(command.normalizedCommand || command.command || ""); - return /^(?:hwpod|hwpod-cli)\s+build(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts\s+build(?:\s|$)/iu.test(raw); -} - -function normalizedHwpodCommand(value: string) { - return unquoteShellCommand(text(value)).replace(/\s+/gu, " ").trim(); -} - -function unquoteShellCommand(value: string) { - return value.replace(/^\/bin\/sh\s+-lc\s+/u, "").replace(/^['"]|['"]$/gu, "").trim(); -} - -function uniqueStrings(values: string[]) { - return Array.from(new Set(values.map((value) => text(value)).filter(Boolean))); -} - -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 webUrlFrom(context: CaseContext, definition: any) { - const value = text(context.parsed.baseUrl ?? context.parsed.webUrl ?? context.env.HWLAB_RUNTIME_WEB_URL ?? context.env.HWLAB_CLOUD_WEB_URL); - if (!value) { - throw cliError("caserun_runtime_web_url_required", "CaseRun v0.3 requires an explicit runtime Web URL from --base-url, --web-url, HWLAB_RUNTIME_WEB_URL, or HWLAB_CLOUD_WEB_URL; case runtime.webUrl fallback is removed.", { - allowed: ["--base-url", "--web-url", "HWLAB_RUNTIME_WEB_URL", "HWLAB_CLOUD_WEB_URL"], - removedFallbacks: ["case.runtime.webUrl", REMOVED_V02_WEB_URL] - }); - } - return rejectRemovedV02RuntimeUrl(value, "web"); -} - -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, baseUrl: agent.baseUrl, projectId: agent.projectId, providerProfile: agent.providerProfile, 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, 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, - 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 sha256(value: string) { - return createHash("sha256").update(value).digest("hex"); -} - -function promptShellArg(value: string) { - return `'${String(value).replace(/'/gu, `'\\''`)}'`; -} - -function sha256Buffer(value: Buffer) { - return createHash("sha256").update(value).digest("hex"); -} - -async function prepareSubjectWorktree(context: CaseContext, input: { caseId: string; runId: string; runDir: string; apiUrl: string; definition: Record; sourceSpecPath: string }) { - const subject = subjectFromDefinition(input.definition); - const worktreePath = subjectWorktreePath(subject.repoLocalPath, input.runId); - const relativeWorktreePath = subjectRelativeWorktreePath(subject.repoLocalPath, worktreePath); - const snapshotRun = caseRunForSubjectSnapshot(input, subject, worktreePath); - const sourceRootBaseline = await collectSourceRootSnapshot(context, snapshotRun, "before-prepare"); - const setup = await requestSubjectWorktree(context, input, subject, worktreePath, relativeWorktreePath); - const sourceRootAfterPrepare = await collectSourceRootSnapshot(context, snapshotRun, "after-prepare"); - return { ...subject, worktreePath, workspacePath: worktreePath, setup, sourceRootBaseline, sourceRootAfterPrepare }; -} - -function caseRunForSubjectSnapshot(input: { caseId: string; runId: string; runDir: string; apiUrl: string; definition: Record; sourceSpecPath: string }, subject: { repoLocalPath: string; commitId: string; subdir: string }, worktreePath: string): PreparedCaseRun { - return { - caseId: input.caseId, - runId: input.runId, - runDir: input.runDir, - caseRepo: "", - caseDir: "", - caseFile: "", - sourceSpecPath: input.sourceSpecPath, - specPath: input.sourceSpecPath, - apiUrl: input.apiUrl, - compileOnly: true, - createdAt: "", - updatedAt: "", - definition: input.definition, - subject: { ...subject, worktreePath, workspacePath: worktreePath, setup: {} } - }; -} - -function subjectFromDefinition(definition: Record) { - const subject = definition.subject; - if (!subject || typeof subject !== "object" || Array.isArray(subject)) { - throw cliError("subject_required", "case.json subject is required", { required: ["subject.repoLocalPath", "subject.commitId"] }); - } - const source = subject as Record; - const repoLocalPath = text(source.repoLocalPath); - if (!repoLocalPath) throw cliError("subject_repo_local_path_required", "case.json subject.repoLocalPath is required and must point to a local subject repo checkout on the bound HWPOD node", { field: "subject.repoLocalPath" }); - const commitId = text(source.commitId).toLowerCase(); - if (!commitId) throw cliError("subject_commit_id_required", "case.json subject.commitId is required and must be a fixed subject repo commit id", { field: "subject.commitId" }); - if (!/^[0-9a-f]{40}$/iu.test(commitId)) { - throw cliError("invalid_subject_commit_id", "subject.commitId must be a full 40-character git commit id", { commitId }); - } - return { repoLocalPath, commitId, subdir: text(source.subdir) }; -} - -async function requestSubjectWorktree(context: CaseContext, input: { caseId: string; runId: string; runDir: string; apiUrl: string; sourceSpecPath: string }, subject: { repoLocalPath: string; commitId: string; subdir: string }, worktreePath: string, relativeWorktreePath: string) { - const document = await readHwpodSpec(input.sourceSpecPath); - const hwpodId = text(document.metadata.name) || text(document.metadata.uid) || input.caseId; - const sidecarOp = keilSidecarCopyOp(document, subject, hwpodId, relativeWorktreePath); - const ops = [{ - opId: "op_01_subject_commit", - op: "cmd.run", - args: { - hwpodId, - workspacePath: subject.repoLocalPath, - command: "git", - argv: ["cat-file", "-e", subject.commitId] - } - }, { - opId: "op_02_subject_worktree_add", - op: "cmd.run", - args: { - hwpodId, - workspacePath: subject.repoLocalPath, - command: "git", - argv: ["worktree", "add", "--detach", "--force", relativeWorktreePath, subject.commitId] - } - }, { - opId: "op_03_subject_worktree_head", - op: "cmd.run", - args: { - hwpodId, - workspacePath: subject.repoLocalPath, - command: "git", - argv: ["-C", relativeWorktreePath, "rev-parse", "HEAD"] - } - }, ...(sidecarOp ? [sidecarOp] : [])]; - const plan = { - contractVersion: "hwpod-node-ops-v1", - planId: `case_subject_${randomUUID()}`, - hwpodId, - nodeId: document.spec.nodeBinding.nodeId, - intent: "cmd.run", - source: { compiler: "hwlab-cli.case", specPath: input.sourceSpecPath, specAuthority: "case-registry" }, - ops - }; - const response = await context.fetchImpl(`${input.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 status = subjectWorktreeStatus(response.status, body, { commitId: subject.commitId, worktreePath }); - if (!status.ok) { - throw cliError("subject_worktree_prepare_failed", "failed to prepare isolated subject worktree on the HWPOD node", clean({ httpStatus: response.status, nodeId: document.spec.nodeBinding.nodeId, repoLocalPath: subject.repoLocalPath, commitId: subject.commitId, worktreePath, exitCode: status.exitCode, stdout: clipText(status.stdout), stderr: clipText(status.stderr), body: compactObject(body) })); - } - return clean({ httpStatus: response.status, planId: plan.planId, nodeId: document.spec.nodeBinding.nodeId, exitCode: status.exitCode, stdout: clipText(status.stdout), stderr: clipText(status.stderr), keilSidecars: keilSidecarSummary(status.stdout) }); -} - -function subjectWorktreeStatus(httpStatus: number, body: any, expected: { commitId: string; worktreePath: string }) { - const results = Array.isArray(body?.body?.results) ? body.body.results : Array.isArray(body?.results) ? body.results : []; - const outputs = results.map((result: any) => result.output ?? result); - const exitCodes = outputs.map((output: any) => numberOption(output.exitCode)); - const stdout = outputs.map((output: any) => text(output.stdout)).filter(Boolean).join("\n"); - const stderr = outputs.map((output: any) => text(output.stderr)).filter(Boolean).join("\n"); - const actualHead = text(outputs[2]?.stdout).split(/\r?\n/u)[0]?.toLowerCase() ?? ""; - const commandOk = httpStatus >= 200 && httpStatus < 300 && outputs.length >= 3 && exitCodes.every((exitCode) => exitCode === 0); - const ok = commandOk && actualHead === expected.commitId; - return { ok, exitCode: exitCodes.find((exitCode) => exitCode !== 0) ?? exitCodes[exitCodes.length - 1], stdout, stderr, actualHead, actualWorktreePath: expected.worktreePath }; -} - -function keilSidecarCopyOp(document: any, subject: { repoLocalPath: string }, hwpodId: string, relativeWorktreePath: string) { - const project = subjectRelativeKeilProject(document, subject.repoLocalPath); - if (!project) return null; - const sourceBase = stripUvprojx(project).replace(/\//gu, "\\"); - const destinationBase = windowsJoin(relativeWorktreePath, sourceBase); - return { - opId: "op_04_keil_sidecars", - op: "cmd.run", - args: { - hwpodId, - workspacePath: subject.repoLocalPath, - command: "node", - argv: ["-e", keilSidecarCopyNodeScript(), JSON.stringify({ sourceBase, destinationBase })], - commandBinding: { - kind: "keil-mdk-sidecar-copy", - source: "hwlab-cli.case.prepare", - sourceBase, - destinationBase, - sidecars: [".uvoptx", ".uvopt"] - } - } - }; -} - -function subjectRelativeKeilProject(document: any, repoLocalPath: string) { - const workspace = objectRecord(document.spec?.workspace); - const projectWorkspace = objectRecord(document.spec?.projectWorkspace); - const rawProject = text(workspace.keilProject ?? workspace.projectPath ?? workspace.project ?? projectWorkspace.projectPath); - if (!rawProject || !/\.uvprojx$/iu.test(rawProject)) return ""; - if (!isAbsolutePathLike(rawProject)) return rawProject.replace(/^[\\/]+/u, ""); - const repo = repoLocalPath.replace(/[\\/]+$/u, ""); - const normalizedRepo = normalizeLocalPath(repo); - const normalizedProject = normalizeLocalPath(rawProject); - if (!normalizedProject.startsWith(`${normalizedRepo}/`)) return ""; - return rawProject.slice(repo.length).replace(/^[\\/]+/u, ""); -} - -function stripUvprojx(value: string) { - return value.replace(/\.uvprojx$/iu, ""); -} - -function windowsJoin(parent: string, child: string) { - const left = parent.replace(/[\\/]+$/u, "").replace(/\//gu, "\\"); - const right = child.replace(/^[\\/]+/u, "").replace(/\//gu, "\\"); - return `${left}\\${right}`; -} - -function keilSidecarCopyNodeScript() { - return [ - "const fs=require('fs');", - "const path=require('path');", - "const input=JSON.parse(process.argv[1]||'{}');", - "const sidecars=['.uvoptx','.uvopt'];", - "const copiedFiles=[];", - "const missing=[];", - "for(const ext of sidecars){", - "const source=String(input.sourceBase||'')+ext;", - "const destination=String(input.destinationBase||'')+ext;", - "if(fs.existsSync(source)){", - "const dir=destination.includes('\\\\')?path.win32.dirname(destination):path.dirname(destination);", - "fs.mkdirSync(dir,{recursive:true});", - "fs.copyFileSync(source,destination);", - "copiedFiles.push({ext,source,destination});", - "}else{missing.push(source);}", - "}", - "console.log('caseRunKeilSidecars '+JSON.stringify({copied:copiedFiles.length,copiedFiles,missing,sourceBase:input.sourceBase,destinationBase:input.destinationBase}));" - ].join(""); -} - -function keilSidecarSummary(stdout: string) { - const line = stdout.split(/\r?\n/u).find((item) => item.includes("caseRunKeilSidecars")); - if (!line) return null; - const payload = line.replace(/^.*caseRunKeilSidecars\s*/u, ""); - const parsed = parseJsonMaybe(payload); - if (parsed && typeof parsed === "object") return clean({ ...(parsed as Record), line }); - return clean({ copied: numberOption(line.match(/copied=(\d+)/u)?.[1]), line }); -} - -function objectRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -} - -function isAbsolutePathLike(value: string) { - return /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("/") || value.startsWith("\\\\"); -} - -function subjectRelativeWorktreePath(repoLocalPath: string, worktreePath: string) { - const repo = repoLocalPath.replace(/[\\/]+$/u, ""); - const normalizedRepo = normalizeLocalPath(repo); - const normalizedWorktree = normalizeLocalPath(worktreePath); - if (!normalizedWorktree.startsWith(`${normalizedRepo}/`)) throw cliError("invalid_subject_worktree_path", "subject worktree path must stay under subject repo local path", { repoLocalPath, worktreePath }); - const relative = worktreePath.slice(repo.length).replace(/^[\\/]+/u, ""); - return relative || "."; -} - -function normalizeLocalPath(value: string) { - return value.replace(/[\\/]+$/u, "").replace(/\\/gu, "/").toLowerCase(); -} - -function subjectWorktreePath(repoLocalPath: string, runId: string) { - const separator = looksLikeWindowsPath(repoLocalPath) ? "\\" : "/"; - return [repoLocalPath.replace(/[\\/]+$/u, ""), ".worktree", `caserun-${slug(runId)}`].join(separator); -} - -function looksLikeWindowsPath(value: string) { - return /^[A-Za-z]:[\\/]/u.test(value) || value.includes("\\"); -} - -function rewriteHwpodSpecWorkspacePath(specText: string, workspacePath: string) { - const lines = specText.replace(/\r\n/gu, "\n").split("\n"); - let workspaceIndent: number | null = null; - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index] ?? ""; - const workspaceMatch = line.match(/^(\s*)workspace:\s*(?:#.*)?$/u); - if (workspaceMatch) { - workspaceIndent = workspaceMatch[1].length; - continue; - } - if (workspaceIndent === null) continue; - const indent = line.match(/^(\s*)/u)?.[1].length ?? 0; - if (line.trim() && indent <= workspaceIndent) { - workspaceIndent = null; - continue; - } - const pathMatch = line.match(/^(\s*)path\s*:/u); - if (pathMatch) { - lines[index] = `${pathMatch[1]}path: ${JSON.stringify(workspacePath)}`; - return lines.join("\n"); - } - } - throw cliError("hwpod_spec_workspace_path_missing", "hwpod-spec.yaml must contain spec.workspace.path so CaseRun can isolate the subject worktree", {}); -} - -async function pollKeilJobStatus(context: CaseContext, run: PreparedCaseRun, jobId: string) { - const timeouts = caseTimeoutsFromDefinition(run.definition); - const timeoutMs = Math.min(numberOption(context.parsed.jobTimeoutMs ?? context.parsed.timeoutMs) ?? numberOption(timeouts.jobTimeoutMs ?? timeouts.keilJobTimeoutMs) ?? DEFAULT_JOB_TIMEOUT_MS, MAX_JOB_TIMEOUT_MS); - const pollIntervalMs = numberOption(context.parsed.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS; - const started = Date.now(); - const polls: any[] = []; - let last: any = null; - while (Date.now() - started <= timeoutMs) { - const response = await requestKeilJobStatus(context, run, jobId); - const status = keilJobStatus(response.body); - last = { response, status }; - polls.push(status.summary); - if (status.terminal) return { ok: status.ok, summary: { ...status.summary, jobId, polls: polls.length, timedOut: false } }; - await context.sleep(pollIntervalMs); - } - return { ok: false, summary: { jobId, status: text(last?.status?.summary?.status) || "timeout", polls: polls.length, timedOut: true, last: last?.status?.summary ?? null } }; -} - -async function requestKeilJobStatus(context: CaseContext, run: PreparedCaseRun, jobId: string) { - const document = await readHwpodSpec(run.specPath); - const keilCliPath = text(document.spec.workspace.keilCliPath) || text(document.spec.debugProbe.keilCliPath) || "keil-cli.py"; - const pythonCommand = text(document.spec.workspace.pythonCommand) || text(document.spec.debugProbe.pythonCommand) || "py -3"; - const jobStatusCommand = jobStatusCommandForTest(pythonCommand, keilCliPath, jobId, document.spec.workspace.path); - const hwpodId = text(document.metadata.name) || text(document.metadata.uid) || run.caseId; - const plan = { - contractVersion: "hwpod-node-ops-v1", - planId: `case_job_${randomUUID()}`, - hwpodId, - nodeId: document.spec.nodeBinding.nodeId, - intent: "cmd.run", - source: { compiler: "hwlab-cli.case", specPath: run.specPath, specAuthority: "case-run-state" }, - ops: [{ - opId: "op_01_keil_job_status", - op: "cmd.run", - args: { - hwpodId, - workspacePath: dirnameForCommandPath(keilCliPath), - command: jobStatusCommand.command, - argv: jobStatusCommand.argv - } - }] - }; - const response = await context.fetchImpl(`${run.apiUrl}/v1/hwpod-node-ops`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(plan) - }); - const textBody = await response.text(); - return { status: response.status, body: parseJsonMaybe(textBody) ?? { raw: textBody }, plan }; -} - -function keilJobStatus(body: any) { - const parsed = parseJsonMaybe(hwpodResultTexts(body)[0]) ?? body; - const status = text(parsed?.status ?? parsed?.data?.status ?? parsed?.job?.status) || "unknown"; - const returnCode = numberOption(parsed?.return_code ?? parsed?.returnCode ?? parsed?.data?.return_code); - const success = parsed?.success === true || parsed?.ok === true || returnCode === 0; - const failed = parsed?.success === false || parsed?.ok === false || (returnCode !== undefined && returnCode !== 0) || ["failed", "error", "cancelled"].includes(status); - const terminal = success || failed || ["completed", "succeeded"].includes(status); - const artifacts = artifactsFrom(parsed); - return { terminal, ok: success || ["completed", "succeeded"].includes(status), summary: clean({ status, returnCode, success, artifacts, raw: compactObject(parsed) }) }; -} - -function apiUrlFrom(context: CaseContext, definition: any) { - const value = text(context.parsed.apiUrl ?? context.parsed.apiBaseUrl ?? context.parsed.runtimeApiUrl ?? context.env.HWLAB_RUNTIME_API_URL ?? context.env.HWLAB_CASERUN_RUNTIME_API_URL); - if (!value) { - throw cliError("caserun_runtime_api_url_required", "CaseRun v0.3 requires an explicit runtime API URL from --api-url, --api-base-url, --runtime-api-url, HWLAB_RUNTIME_API_URL, or HWLAB_CASERUN_RUNTIME_API_URL; case runtime.apiUrl fallback is removed.", { - allowed: ["--api-url", "--api-base-url", "--runtime-api-url", "HWLAB_RUNTIME_API_URL", "HWLAB_CASERUN_RUNTIME_API_URL"], - removedFallbacks: ["case.runtime.apiUrl", REMOVED_V02_API_URL] - }); - } - return rejectRemovedV02RuntimeUrl(value, "api"); -} - -function rejectRemovedV02RuntimeUrl(value: string, kind: "api" | "web") { - const normalized = value.replace(/\/+$/u, ""); - const removed = kind === "web" ? REMOVED_V02_WEB_URL : REMOVED_V02_API_URL; - if (normalized === removed) { - throw cliError("caserun_removed_v02_runtime_url", `CaseRun v0.3 does not support the removed v0.2 ${kind} runtime URL`, { kind, url: normalized }); - } - return normalized; -} - -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), hwpodExitCode: evidence.hwpod?.exitCode ?? null, hwpodSource: evidence.hwpod?.source ?? null, jobId: evidence.keilJob?.jobId ?? null, keilStatus: evidence.keilJob?.status ?? null, artifacts: evidence.artifacts ?? [] }; -} - -function agentTraceLookupForRun(run: PreparedCaseRun, evidence?: any) { - const agent = run.agent ?? evidence?.agent; - const traceId = text(agent?.traceId ?? evidence?.agentTrace?.traceId ?? run.agentTrace?.traceId); - if (!traceId) return null; - const baseUrl = text(agent?.baseUrl ?? run.agent?.baseUrl ?? evidence?.agent?.baseUrl); - if (!baseUrl) return null; - return agentTraceLookup({ baseUrl, traceId, sessionId: text(agent?.sessionId), conversationId: text(agent?.conversationId), threadId: text(agent?.threadId) }); -} - -function renderEvidenceSummary(evidence: any) { - const agentStage = agentStageSummary(evidence); - const traceLookup = evidence.traceLookup ?? evidence.agent?.traceLookup; - const traceCommands = traceLookup?.commands ?? {}; - const stageTable = agentStage.commands.length > 0 ? [ - ``, - `## Agent HWPOD Raw Steps`, - ``, - `| Step | Status | Exit | Command | Raw detail |`, - `|---|---:|---:|---|---|`, - ...agentStage.commands.map((command: any) => `| ${command.kind} | ${command.status || ""} | ${command.exitCode ?? ""} | \`${markdownTableText(command.command, 160)}\` | ${markdownTableText(command.detail, 180)} |`) - ].join("\n") : ""; - return `# HWPOD CaseRun ${evidence.caseId}\n\n- runId: ${evidence.runId}\n- status: ${evidence.status}\n- autoEvaluation: false\n- compileOnly: ${evidence.compileOnly}\n- subjectRepoLocalPath: ${evidence.subject?.repoLocalPath ?? ""}\n- subjectCommitId: ${evidence.subject?.commitId ?? ""}\n- subjectWorktreePath: ${evidence.subject?.worktreePath ?? ""}\n- agentTraceId: ${evidence.agent?.traceId ?? ""}\n- agentSessionId: ${evidence.agent?.sessionId ?? ""}\n- traceLookupStrategy: ${traceLookup?.strategy ?? ""}\n- traceCommand: ${traceCommands.trace ?? ""}\n- resultCommand: ${traceCommands.result ?? ""}\n- inspectCommand: ${traceCommands.inspect ?? ""}\n- agentTraceCommandCount: ${evidence.agentTrace?.commandCount ?? 0}\n- agentTraceHwpodCommandCount: ${evidence.agentTrace?.hwpodCommandCount ?? 0}\n- agentTraceHwpodBuildCommandCount: ${evidence.agentTrace?.hwpodBuildCommandCount ?? 0}\n- agentStageCommandCount: ${agentStage.commands.length}\n- agentStageKinds: ${agentStage.kinds.join(", ")}\n- diffPatchPath: ${evidence.agentDiff?.diffPatchPath ?? ""}\n- runnerHwpodSource: ${evidence.hwpod?.source ?? ""}\n- hwpodExitCode: ${evidence.hwpod?.exitCode ?? ""}\n- jobId: ${evidence.keilJob?.jobId ?? ""}\n- runnerPostAgentCompileCheck: recorded\n${stageTable}\n`; -} - -function agentStageSummary(evidence: any) { - const commands = arrayOfObjects(evidence.agentTrace?.commands).filter(mentionsHwpod).map(agentStageCommand).filter((command) => text(command.kind)); - const selected: Record = {}; - for (const command of commands) { - if (!selected[command.kind]) selected[command.kind] = command; - if (command.status === "completed" || command.exitCode === 0) selected[command.kind] = command; - } - const ordered = ["spec-validate", "inspect", "workspace-edit", "build", "download", "uart-read"] - .map((kind) => selected[kind]) - .filter(Boolean); - const extras = commands.filter((command) => !ordered.some((item) => item.kind === command.kind && item.command === command.command)); - return clean({ - source: "agent-trace-commands", - autoEvaluation: false, - commands: [...ordered, ...extras].slice(0, MAX_AGENT_TRACE_COMMANDS), - kinds: uniqueStrings([...ordered, ...extras].map((command) => command.kind)) - }); -} - -function agentStageCommand(command: AgentTraceCommand) { - return clean({ - kind: hwpodCommandKind(command) || "hwpod-other", - seq: command.seq, - status: command.status, - exitCode: command.exitCode, - command: clipText(command.normalizedCommand || command.command, 1000), - detail: clipText(agentCommandDetail(command), 1000) - }); -} - -function agentCommandDetail(command: AgentTraceCommand) { - const body = text(command.bodyPreview); - if (!body) return ""; - return body.split(/\r?\n/u).filter((line) => !line.includes(text(command.command))).join(" ").replace(/\s+/gu, " ").trim() || body; -} - -function markdownTableText(value: unknown, maxBytes = 200) { - return clipText(value, maxBytes).replace(/\|/gu, "\\|").replace(/\r?\n/gu, " ").trim(); -} - -async function writeCaseAggregateFromRegistry(context: CaseContext, caseRepo: string, options: { sync?: boolean } = {}) { - const caseId = requiredText(context.parsed.caseId ?? context.rest[1], "caseId"); - const runId = await resolveAggregateRunId(caseRepo, caseId, text(context.parsed.runId ?? context.rest[2])); - const runRel = artifactRel(path.join("runs", caseId, runId)); - const caseRepoRunDir = path.join(caseRepo, runRel); - const evidence = await readJsonIfExists(path.join(caseRepoRunDir, "evidence.json")); - const run = await readJsonIfExists(path.join(caseRepoRunDir, "run.json")); - const manifest = await readJsonIfExists(path.join(caseRepoRunDir, "artifact-manifest.json")); - if (!evidence && !run && !manifest) throw cliError("case_registry_run_not_found", "case registry run artifacts were not found", { caseRepoRunDir, caseId, runId }); - const outputRel = aggregateOutputRel(context); - const outputPath = path.join(caseRepoRunDir, outputRel); - const markdown = await renderCaseAggregateMarkdown(context, { caseRepoRunDir, caseId, runId, evidence, run, manifest }); - await mkdir(path.dirname(outputPath), { recursive: true }); - await writeFile(outputPath, markdown, "utf8"); - const file = await registryAggregateFileRecord(outputPath); - const registrySync = options.sync === true ? await syncCaseRegistryRel(context, caseRepo, artifactRel(path.join(runRel, outputRel)), `data: aggregate caserun ${runId}`) : null; - return { caseId, runId, caseRepoRunDir, path: outputPath, rel: artifactRel(path.join(runRel, outputRel)), bytes: file.bytes, sha256: file.sha256, registrySync }; -} - -async function resolveAggregateRunId(caseRepo: string, caseId: string, explicit: string) { - if (explicit) return explicit; - const runRoot = path.join(caseRepo, "runs", caseId); - let entries: string[] = []; - try { - entries = await readdir(runRoot); - } catch { - throw cliError("case_registry_runs_not_found", "case registry has no run directory for this case", { caseId, runRoot }); - } - const runs = entries.filter((entry) => !entry.startsWith(".")).sort(); - if (runs.length === 0) throw cliError("case_registry_runs_empty", "case registry has no runs for this case", { caseId, runRoot }); - return runs.at(-1) ?? ""; -} - -function aggregateOutputRel(context: CaseContext) { - const value = text(context.parsed.output ?? context.parsed.outputPath ?? context.parsed.aggregatePath) || "aggregate.md"; - const rel = artifactRel(value).replace(/^\/+/, ""); - if (!rel || rel.includes("../") || rel === ".." || path.isAbsolute(value)) throw cliError("invalid_aggregate_output_path", "aggregate output must be a relative path inside the registry run directory", { output: value }); - if (!rel.endsWith(".md")) throw cliError("invalid_aggregate_output_ext", "aggregate output must be a markdown file", { output: value }); - return rel; -} - -async function renderCaseAggregateMarkdown(context: CaseContext, input: { caseRepoRunDir: string; caseId: string; runId: string; evidence: any; run: any; manifest: any }) { - const prompt = await readTextIfExists(path.join(input.caseRepoRunDir, "agent-prompt.md")); - const finalResponse = await readTextIfExists(path.join(input.caseRepoRunDir, "final-response.md")); - const diffPatch = await readTextIfExists(path.join(input.caseRepoRunDir, "agent-diff.patch")); - const traceMarkdown = await readTextIfExists(path.join(input.caseRepoRunDir, "agent-trace.md")); - const specText = await readTextIfExists(path.join(input.caseRepoRunDir, ".hwlab", "hwpod-spec.yaml")); - const evidence = input.evidence ?? {}; - const run = input.run ?? {}; - const manifest = input.manifest ?? {}; - const archivedMessages = await readJsonIfExists(path.join(input.caseRepoRunDir, "agent-messages.json")); - const traceLookup = manifest.traceLookup ?? evidence.traceLookup ?? run.agent?.traceLookup; - const commands = traceLookup?.commands ?? {}; - const agent = manifest.agent ?? manifest.trace ?? evidence.agent ?? run.agent ?? {}; - const subject = manifest.subject ?? evidence.subject ?? run.subject ?? {}; - const trace = manifest.trace ?? evidence.agentTrace ?? run.agentTrace ?? {}; - const diff = manifest.diff ?? evidence.agentDiff ?? run.agentDiff ?? {}; - const keilJob = manifest.keilJob ?? evidence.keilJob ?? null; - const agentStage = manifest.agentStage ?? agentStageSummary(evidence); - const messages = await aggregateMessagesWithFreshFullTrace(context, { archivedMessages, traceLookup, traceId: agent.traceId ?? trace.traceId ?? traceLookup?.traceId, run, evidence, manifest }); - const traceBody = aggregateTraceBody(messages, traceMarkdown); - return `${[ - `# HWPOD CaseRun Aggregate: ${input.caseId}`, - ``, - `- caseId: ${input.caseId}`, - `- runId: ${input.runId}`, - `- generatedFrom: case registry artifacts`, - `- aggregationOnly: true`, - `- autoEvaluation: false`, - `- primaryEntry: aggregate.md`, - ``, - `## 运行环境信息`, - ``, - aggregateBullets([ - ["apiUrl", run.apiUrl ?? evidence.apiUrl], - ["compileOnly", evidence.compileOnly ?? run.compileOnly], - ["caseRepoRunDir", input.caseRepoRunDir], - ["sourceRunDir", manifest.sourceRunDir ?? run.runDir], - ["createdAt", run.createdAt], - ["completedAt", run.completedAt ?? run._control?.completedAt], - ["runnerPostAgentCompileCheck", manifest.decisions?.runnerPostAgentCompileCheck ?? evidence.decisions?.runnerPostAgentCompileCheck ?? "recorded"] - ]), - ``, - `## HWPOD 信息`, - ``, - aggregateBullets([ - ["subjectRepoLocalPath", subject.repoLocalPath], - ["subjectCommitId", subject.commitId], - ["subjectSubdir", subject.subdir], - ["subjectWorktreePath", subject.worktreePath], - ["sourceRootBaselineStatus", subject.sourceRootBaseline?.statusShort], - ["sourceRootAfterPrepareStatus", subject.sourceRootAfterPrepare?.statusShort], - ["keilJobId", keilJob?.jobId], - ["keilStatus", keilJob?.status], - ["hwpodExitCode", evidence.hwpod?.exitCode] - ]), - specText ? aggregateDetails("Run-local HWPOD spec", "yaml", specText) : `_Run-local HWPOD spec artifact is missing._`, - ``, - `## Code Agent 信息`, - ``, - aggregateBullets([ - ["providerProfile", agent.providerProfile], - ["projectId", agent.projectId], - ["conversationId", agent.conversationId], - ["sessionId", agent.sessionId], - ["threadId", agent.threadId], - ["traceId", agent.traceId ?? trace.traceId], - ["agentRunId", trace.agentRun?.runId], - ["agentCommandId", trace.agentRun?.commandId], - ["traceSource", trace.source], - ["traceCommand", commands.trace], - ["resultCommand", commands.result], - ["inspectCommand", commands.inspect] - ]), - ``, - `## 输入 Prompt`, - ``, - prompt ? aggregateFence("markdown", prompt) : `_agent-prompt.md artifact is missing._`, - ``, - `## 低噪声 Trace`, - ``, - `- renderer: ${manifest.readableAgent?.renderer ?? messages?.renderer ?? CASE_TRACE_RENDERER}`, - `- sourceEventCount: ${manifest.readableAgent?.traceRender?.sourceEventCount ?? messages?.sourceEventCount ?? trace.sourceEventCount ?? ""}`, - `- renderedRowCount: ${manifest.readableAgent?.traceRender?.renderedRowCount ?? messages?.renderedRowCount ?? trace.renderedRowCount ?? ""}`, - `- hwpodCommandCount: ${trace.hwpodCommandCount ?? evidence.agentTrace?.hwpodCommandCount ?? ""}`, - `- hwpodBuildCommandCount: ${trace.hwpodBuildCommandCount ?? evidence.agentTrace?.hwpodBuildCommandCount ?? ""}`, - ``, - traceBody, - ``, - `## Final Response`, - ``, - finalResponse ? finalResponse.trimEnd() : `_final-response.md artifact is missing._`, - ``, - `## 最后 Diff`, - ``, - aggregateBullets([ - ["statusShort", diff.statusShort], - ["diffStat", diff.diffStat], - ["diffSha256", diff.sha256 ?? diff.diffPatchSha256], - ["sourceRootChangedAfterPrepare", diff.sourceRootChangedAfterPrepare], - ["sourceRootChangedAfterAgent", diff.sourceRootChangedAfterAgent] - ]), - diffPatch ? aggregateFence("diff", diffPatch) : `_agent-diff.patch artifact is missing._`, - ``, - `## 原始产物索引`, - ``, - aggregateArtifactIndex(manifest), - ``, - `## 说明`, - ``, - `本文件只做已完成 CaseRun registry 产物的二次整理聚合,不新增自动结论、门禁或等级。` - ].join("\n")}\n`; -} - -function aggregateTraceBody(messages: any, traceMarkdown: string) { - const rows = arrayOfObjects(messages?.rows); - if (rows.length > 0) { - return renderTraceRowsMarkdown(rows.map((row) => traceEventRowFromAggregate(row))); - } - if (traceMarkdown) { - return renderTraceRowsMarkdown([{ rowId: "trace-markdown", seq: null, tone: "source", header: "Rendered trace markdown", body: traceMarkdown, bodyFormat: "markdown" }]); - } - return `_No readable trace artifact was found._`; -} - -async function aggregateMessagesWithFreshFullTrace(context: CaseContext, input: { archivedMessages: any; traceLookup: any; traceId: unknown; run: any; evidence: any; manifest: any }) { - const archivedMessages = input.archivedMessages; - const traceId = text(input.traceId ?? input.traceLookup?.traceId ?? input.run?.agent?.traceId ?? input.evidence?.agent?.traceId ?? input.manifest?.agent?.traceId); - const baseUrl = text(input.traceLookup?.baseUrl ?? input.run?.agent?.baseUrl ?? input.evidence?.agent?.baseUrl); - const apiKey = text(context.env.HWLAB_API_KEY ?? process.env.HWLAB_API_KEY); - if (!traceId || !baseUrl || !apiKey.startsWith(HWLAB_API_KEY_PREFIX)) return archivedMessages; - const command = [process.execPath, path.join(context.cwd, "tools/hwlab-cli/bin/hwlab-cli.mjs"), "client", "agent", "trace", traceId, "--render", "web", "--full"]; - const result = await (context.runProcess ?? runProcess)(command, context.cwd, { ...process.env, ...context.env, HWLAB_RUNTIME_WEB_URL: baseUrl }); - if (result.exitCode !== 0) return archivedMessages; - const parsed = parseJsonMaybe(result.stdout); - const rendered = freshRenderedTraceBody(parsed); - const rows = arrayOfObjects(rendered?.rows); - if (rows.length === 0) return archivedMessages; - return clean({ - ...(archivedMessages && typeof archivedMessages === "object" ? archivedMessages : {}), - renderer: rendered.renderer ?? archivedMessages?.renderer ?? CASE_TRACE_RENDERER, - traceSource: rendered.source ?? archivedMessages?.traceSource, - traceLookup: archivedMessages?.traceLookup ?? input.traceLookup, - traceId: rendered.traceId ?? traceId, - status: rendered.traceStatus ?? rendered.status ?? archivedMessages?.status, - sourceEventCount: rendered.sourceEventCount ?? archivedMessages?.sourceEventCount, - renderedRowCount: rendered.renderedRowCount ?? rows.length, - returnedRowCount: rendered.returnedRowCount ?? rows.length, - noiseEventCount: rendered.noiseEventCount ?? archivedMessages?.noiseEventCount, - finalResponse: rendered.finalResponse ?? archivedMessages?.finalResponse, - rows - }); -} - -function freshRenderedTraceBody(parsed: any) { - if (!parsed || typeof parsed !== "object") return null; - const body = parsed.body && typeof parsed.body === "object" ? parsed.body : null; - if (Array.isArray(body?.rows)) return body; - const data = parsed.data && typeof parsed.data === "object" ? parsed.data : null; - if (Array.isArray(data?.rows)) return data; - return Array.isArray(parsed.rows) ? parsed : null; -} - -function traceEventRowFromAggregate(row: Record): TraceEventRow { - const bodyFormat = text(row.bodyFormat); - return { - rowId: text(row.rowId) || text(row.header) || "trace-row", - seq: numberOption(row.seq) ?? null, - tone: traceRowTone(row.tone), - header: text(row.header) || text(row.rowId) || "trace row", - body: row.body === undefined || row.body === null ? null : String(row.body), - terminal: row.terminal === true ? true : undefined, - bodyFormat: bodyFormat === "markdown" || bodyFormat === "text" ? bodyFormat : undefined - }; -} - -function aggregateBullets(items: Array<[string, unknown]>) { - const lines = items.map(([key, value]) => `- ${key}: ${aggregateInlineValue(value)}`).filter((line) => !line.endsWith(": ")); - return lines.length > 0 ? lines.join("\n") : `_No data recorded._`; -} - -function aggregateInlineValue(value: unknown) { - if (value === undefined || value === null || value === "") return ""; - if (typeof value === "boolean" || typeof value === "number") return String(value); - if (typeof value === "object") return `\`${markdownInline(JSON.stringify(value))}\``; - return markdownInline(String(value)); -} - -function markdownInline(value: string) { - return value.replace(/\r?\n/gu, " ").trim(); -} - -function aggregateDetails(summary: string, language: string, body: string) { - return [`
`, `${escapeHtml(summary)}`, ``, aggregateFence(language, body), ``, `
`].join("\n"); -} - -function escapeHtml(value: string) { - return String(value).replace(/&/gu, "&").replace(//gu, ">"); -} - -function aggregateFence(language: string, body: string) { - const fence = body.includes("```") ? "````" : "```"; - return `${fence}${language}\n${body.trimEnd()}\n${fence}`; -} - -function aggregateArtifactIndex(manifest: any) { - const files = arrayOfObjects(manifest?.files); - if (files.length === 0) return `_artifact-manifest.json is missing or has no files list._`; - return [`| Path | Bytes | SHA-256 |`, `|---|---:|---|`, ...files.map((file) => `| ${markdownTableText(file.path, 220)} | ${file.bytes ?? ""} | ${markdownTableText(file.sha256, 80)} |`)].join("\n"); -} - -async function registryAggregateFileRecord(file: string) { - const content = await readFile(file); - return { bytes: content.length, sha256: sha256Buffer(content) }; -} - -async function archiveCaseRunArtifacts(context: CaseContext, run: PreparedCaseRun, evidence: any, options: { sync?: boolean } = {}): Promise { - const runRel = caseRegistryRunRel(run); - const caseRepoRunDir = path.join(run.caseRepo, runRel); - await mkdir(caseRepoRunDir, { recursive: true }); - const agentTraceSnapshot = await archivedAgentTraceSnapshot(context, run, evidence); - applyArchivedAgentTraceSummary(run, evidence, agentTraceSnapshot.summary); - await writeJson(path.join(run.runDir, "evidence.json"), evidence); - await writeJson(path.join(caseRepoRunDir, "evidence.json"), evidence); - await writeFile(path.join(caseRepoRunDir, "summary.md"), renderEvidenceSummary(evidence), "utf8"); - - const materialized = new Map([ - ["evidence.json", path.join(run.runDir, "evidence.json")], - ["summary.md", undefined] - ]); - if (await fileExists(path.join(caseRepoRunDir, "aggregate.md"))) materialized.set("aggregate.md", undefined); - const readableAgent = await writeReadableAgentArtifacts(caseRepoRunDir, run, evidence, agentTraceSnapshot); - for (const rel of readableAgent.generatedFiles) materialized.set(rel, undefined); - const skippedFiles: string[] = []; - for (const entry of caseRunArtifactEntries(run)) { - const rel = artifactRel(entry.rel); - const copied = await copyCaseRunArtifact(entry.source, path.join(caseRepoRunDir, rel)); - if (copied) materialized.set(rel, entry.source); - else skippedFiles.push(rel); - } - - const files: CaseRegistryArchiveFile[] = []; - for (const [rel, source] of materialized) files.push(await registryArtifactFileRecord(run, caseRepoRunDir, rel, source)); - const artifactManifestPath = path.join(caseRepoRunDir, "artifact-manifest.json"); - const existingManifest = await readJsonIfExists(artifactManifestPath); - const manifest = caseRunArtifactManifest(context, run, evidence, files, skippedFiles, readableAgent, existingManifest); - await writeJson(artifactManifestPath, manifest); - const caseRepoFiles = [...files.map((file) => artifactRel(path.join(runRel, file.path))), artifactRel(path.join(runRel, "artifact-manifest.json"))]; - const registrySync = options.sync === true ? await syncCaseRegistryRun(context, run, runRel) : null; - return { caseRepoRunDir, artifactManifestPath, caseRepoFiles, files, skippedFiles, manifest, registrySync }; -} - -async function syncCaseRegistryRun(context: CaseContext, run: PreparedCaseRun, runRel: string) { - return syncCaseRegistryRel(context, run.caseRepo, runRel, `data: archive caserun ${run.runId}`); -} - -async function syncCaseRegistryRel(context: CaseContext, caseRepo: string, relInput: string, commitMessage: string) { - if (context.parsed.noCaseRepoGitSync === true || context.parsed.noRegistryGitSync === true) { - return { status: "skipped", reason: "disabled_by_cli_flag" }; - } - const rel = artifactRel(relInput); - const before = await gitProcess(context, caseRepo, ["status", "--short", "--", rel]); - if (before.exitCode !== 0) return { status: "failed", stage: "status_before", command: before.command, exitCode: before.exitCode, stderr: clipText(before.stderr) }; - if (!text(before.stdout)) return { status: "unchanged", path: rel }; - const add = await gitProcess(context, caseRepo, ["add", "--", rel]); - if (add.exitCode !== 0) return { status: "failed", stage: "add", command: add.command, exitCode: add.exitCode, stderr: clipText(add.stderr) }; - const staged = await gitProcess(context, caseRepo, ["diff", "--cached", "--quiet", "--", rel]); - if (staged.exitCode === 0) return { status: "unchanged_after_add", path: rel }; - if (staged.exitCode !== 1) return { status: "failed", stage: "diff_cached", command: staged.command, exitCode: staged.exitCode, stderr: clipText(staged.stderr) }; - const commit = await gitProcess(context, caseRepo, ["commit", "-m", commitMessage, "--", rel]); - if (commit.exitCode !== 0) return { status: "failed", stage: "commit", command: commit.command, exitCode: commit.exitCode, stderr: clipText(commit.stderr), stdout: clipText(commit.stdout) }; - const push = await gitProcess(context, caseRepo, ["push", "origin", "HEAD"]); - if (push.exitCode !== 0) return { status: "failed", stage: "push", command: push.command, exitCode: push.exitCode, stderr: clipText(push.stderr), stdout: clipText(push.stdout) }; - const commitSha = text(commit.stdout.match(/\[\S+\s+([0-9a-f]{7,40})\]/iu)?.[1]); - return clean({ status: "pushed", path: rel, commitMessage, commitSha, commitStdout: clipText(commit.stdout, 1000), pushStdout: clipText(push.stdout, 1000), pushStderr: clipText(push.stderr, 1000) }); -} - -async function gitProcess(context: CaseContext, cwd: string, args: string[]) { - return (context.runProcess ?? runProcess)(["git", ...args], cwd, { ...process.env, ...context.env }); -} - -function collectSummaryFromArchive(run: PreparedCaseRun, evidence: any, archive: CaseRegistryArchive) { - return { - caseRepo: run.caseRepo, - caseRepoRunDir: archive.caseRepoRunDir, - caseRepoFiles: archive.caseRepoFiles, - artifactManifestPath: archive.artifactManifestPath, - artifactCount: archive.files.length + 1, - skippedFiles: archive.skippedFiles, - registrySync: archive.registrySync ?? null, - trace: archive.manifest.trace ?? null, - traceLookup: archive.manifest.traceLookup ?? agentTraceLookupForRun(run, evidence), - agentStage: archive.manifest.agentStage ?? agentStageSummary(evidence), - status: evidence.status, - autoEvaluation: false - }; -} - -async function writeReadableAgentArtifacts(caseRepoRunDir: string, run: PreparedCaseRun, evidence: any, snapshot: ArchivedAgentTraceSnapshot): Promise { - const messagesRel = "agent-messages.json"; - const traceRel = "agent-trace.md"; - const transcriptRel = "agent-transcript.md"; - const finalResponseRel = "final-response.md"; - const traceBody = snapshot.traceBody; - const events = snapshot.events; - const rows = snapshot.rows; - const finalResponse = agentFinalResponse(run, evidence, traceBody, rows); - const traceLookup = traceBody?.lookup ?? evidence.traceLookup ?? agentTraceLookupForRun(run, evidence); - const diffPatch = await readTextIfExists(evidence.agentDiff?.diffPatchPath ?? run.agentDiff?.diffPatchPath ?? path.join(run.runDir, "agent-diff.patch")); - const messages = clean({ - contractVersion: "hwpod-case-run-agent-messages-v1", - renderer: CASE_TRACE_RENDERER, - traceSource: text(traceBody?.source), - lookupOnly: traceBody?.lookupOnly === true ? true : undefined, - traceLookup, - traceId: evidence.agent?.traceId ?? run.agent?.traceId ?? traceBody?.traceId ?? run.agentTrace?.traceId, - conversationId: evidence.agent?.conversationId ?? run.agent?.conversationId, - sessionId: evidence.agent?.sessionId ?? run.agent?.sessionId, - threadId: evidence.agent?.threadId ?? run.agent?.threadId, - status: traceBody?.traceStatus ?? traceBody?.status ?? run.agentTrace?.status ?? evidence.agentTrace?.status, - sourceEventCount: traceBody?.eventCount ?? traceBody?.sourceEventCount ?? events.length, - renderedRowCount: rows.length, - noiseEventCount: traceNoiseEventCount(events), - finalResponse, - rows: rows.map(agentMessageRowForArchive), - diff: clean({ path: "agent-diff.patch", sha256: evidence.agentDiff?.diffPatchSha256 ?? run.agentDiff?.diffPatchSha256, statusShort: evidence.agentDiff?.statusShort ?? run.agentDiff?.statusShort, diffStat: evidence.agentDiff?.diffStat ?? run.agentDiff?.diffStat, collection: evidence.agentDiff?.diffCollection ?? run.agentDiff?.diffCollection, patchIncludedInTranscript: true }) - }); - const transcript = renderAgentTranscript(run, evidence, messages, rows, diffPatch); - await writeJson(path.join(caseRepoRunDir, messagesRel), messages); - await writeFile(path.join(caseRepoRunDir, traceRel), transcript, "utf8"); - await writeFile(path.join(caseRepoRunDir, transcriptRel), transcript, "utf8"); - await writeFile(path.join(caseRepoRunDir, finalResponseRel), renderFinalResponseArtifact(run, evidence, finalResponse, rows), "utf8"); - return { - messagesRel, - traceRel, - transcriptRel, - finalResponseRel, - generatedFiles: [messagesRel, traceRel, transcriptRel, finalResponseRel], - traceRender: clean({ renderer: CASE_TRACE_RENDERER, status: messages.status, sourceEventCount: messages.sourceEventCount, renderedRowCount: messages.renderedRowCount, noiseEventCount: messages.noiseEventCount }), - finalResponse - }; -} - -async function archivedAgentTraceSnapshot(context: CaseContext, run: PreparedCaseRun, evidence: any): Promise { - const initialTrace = await archivedAgentTraceBody(run, evidence); - const traceBody = await materializeFullAgentTraceWithExistingCli(context, run, evidence, initialTrace); - const bodyForSummary = traceBody?.body && typeof traceBody.body === "object" ? traceBody.body : traceBody; - const events = arrayOfObjects(bodyForSummary?.events ?? bodyForSummary?.trace?.events ?? bodyForSummary?.sourceEvents); - const rows = traceRowsFromBody(bodyForSummary, events); - const hasRenderableTrace = rows.length > 0 || events.length > 0; - const summary = hasRenderableTrace - ? summarizeAgentTrace(text(bodyForSummary?.traceId ?? traceBody?.traceId ?? evidence.agent?.traceId ?? run.agent?.traceId), numberOption(traceBody?.httpStatus) ?? 0, traceBody) - : existingAgentTraceSummary(run, evidence, bodyForSummary); - return { traceBody: bodyForSummary, rows, events, summary }; -} - -function existingAgentTraceSummary(run: PreparedCaseRun, evidence: any, traceBody: any): AgentTraceStage { - const existing = evidence.agentTrace ?? run.agentTrace; - if (existing?.traceId) return existing as AgentTraceStage; - return summarizeAgentTrace(text(traceBody?.traceId ?? evidence.agent?.traceId ?? run.agent?.traceId), 0, traceBody); -} - -async function archivedAgentTraceBody(run: PreparedCaseRun, evidence: any) { - const rawTrace = await readJsonIfExists(path.join(run.runDir, "agent-trace.json")); - if (rawTrace) return rawTrace; - const resultTrace = agentTraceBody(run, evidence); - if (resultTrace) return resultTrace; - return run.agent ? agentTraceIdentityArtifact(run, run.agent) : clean({ traceId: evidence.agentTrace?.traceId ?? run.agentTrace?.traceId, status: evidence.agentTrace?.status ?? run.agentTrace?.status ?? "recorded", lookupOnly: true }); -} - -async function materializeFullAgentTraceWithExistingCli(context: CaseContext, run: PreparedCaseRun, evidence: any, initialTrace: any) { - const body = initialTrace?.body && typeof initialTrace.body === "object" ? initialTrace.body : initialTrace; - const existingEvents = arrayOfObjects(body?.events ?? body?.trace?.events ?? body?.sourceEvents); - if (existingEvents.length > 0) return initialTrace; - if (!run.agent) return initialTrace; - const traceId = text(body?.traceId ?? initialTrace?.traceId ?? evidence.agent?.traceId ?? run.agent?.traceId); - const lookup = agentTraceLookupForRun(run, evidence); - const apiKey = text(context.env.HWLAB_API_KEY ?? process.env.HWLAB_API_KEY); - if (!traceId || !lookup || !apiKey.startsWith(HWLAB_API_KEY_PREFIX)) return initialTrace; - const baseUrl = text((lookup as any).baseUrl) || webUrlFrom(context, run.definition); - const command = [process.execPath, path.join(context.cwd, "tools/hwlab-cli/bin/hwlab-cli.mjs"), "client", "agent", "trace", traceId, "--render", "web", "--full"]; - const result = await (context.runProcess ?? runProcess)(command, context.cwd, { ...process.env, ...context.env, HWLAB_RUNTIME_WEB_URL: baseUrl }); - if (result.exitCode !== 0) { - return clean({ ...body, traceFetch: { status: "failed", source: "existing-hwlab-cli", command: commandVisibility(command), exitCode: result.exitCode, stderr: clipText(result.stderr, 1200), stdout: clipText(result.stdout, 1200) } }); - } - const parsed = parseJsonMaybe(result.stdout); - if (!parsed) return clean({ ...body, traceFetch: { status: "failed", source: "existing-hwlab-cli", command: commandVisibility(command), exitCode: 0, error: "invalid_json_stdout", stdout: clipText(result.stdout, 1200) } }); - const parsedBody = parsed?.body && typeof parsed.body === "object" ? parsed.body : null; - const materialized = parsedBody ? { ...parsed, body: clean({ ...parsedBody, source: text(parsedBody.source) || "hwlab-cli.client.agent.trace", lookup: parsedBody.lookup ?? lookup }) } : clean({ ...parsed, source: text(parsed.source) || "hwlab-cli.client.agent.trace", lookup: parsed.lookup ?? lookup }); - await writeJson(path.join(run.runDir, "agent-trace.json"), materialized); - return materialized; -} - -function traceRowsFromBody(traceBody: any, events: Record[]) { - const existingRows = arrayOfObjects(traceBody?.rows ?? traceBody?.renderedRows ?? traceBody?.traceRows).map(traceEventRowFromObject).filter((row) => text(row.rowId || row.header || row.body)); - return existingRows.length > 0 ? existingRows : traceDisplayRows(traceBody ?? {}, events); -} - -function traceEventRowFromObject(row: Record): TraceEventRow { - return { - rowId: text(row.rowId ?? row.id), - seq: numberOption(row.seq) ?? null, - tone: traceRowTone(row.tone), - header: text(row.header ?? row.title ?? row.label), - body: row.body === null || row.body === undefined ? null : text(row.body ?? row.content ?? row.text ?? row.message), - terminal: row.terminal === true ? true : undefined, - bodyFormat: row.bodyFormat === "markdown" ? "markdown" : row.bodyFormat === "text" ? "text" : undefined - }; -} - -function traceRowTone(value: unknown): TraceEventRow["tone"] { - const raw = text(value); - return raw === "ok" || raw === "blocked" || raw === "warn" || raw === "source" ? raw : "source"; -} - -function applyArchivedAgentTraceSummary(run: PreparedCaseRun, evidence: any, summary: AgentTraceStage) { - if (!summary?.traceId) return; - run.agentTrace = summary; - evidence.agentTrace = summary; - evidence.traceLookup = evidence.traceLookup ?? agentTraceLookupForRun(run, evidence); -} - -function agentTraceBody(run: PreparedCaseRun, evidence: any) { - const result = run.agent?.result ?? evidence.agent?.result ?? null; - return result?.body && typeof result.body === "object" ? result.body : result; -} - -function agentFinalResponse(run: PreparedCaseRun, evidence: any, traceBody: any, rows: TraceEventRow[]) { - const candidates = [ - traceBody?.finalResponse, - traceBody?.terminalEvidence?.finalResponse, - run.agent?.result?.finalResponse, - run.agent?.result?.reply, - evidence.agent?.finalResponse - ]; - for (const candidate of candidates) { - const body = text((candidate as any)?.text ?? (candidate as any)?.content ?? (candidate as any)?.message); - if (body) return clean({ present: true, status: text((candidate as any)?.status) || "completed", source: text((candidate as any)?.source) || "agent-result", text: body, textChars: body.length, traceId: text((candidate as any)?.traceId ?? traceBody?.traceId ?? run.agent?.traceId) }); - } - const terminal = rows.filter((row) => row.terminal === true || row.tone === "blocked").slice(-5).map(agentMessageRowForArchive); - return clean({ - present: false, - value: null, - reason: finalResponseMissingReason(traceBody, rows), - status: text(traceBody?.status ?? traceBody?.traceStatus ?? run.agent?.stageStatus), - terminalRows: terminal, - error: compactObject(traceBody?.error ?? run.agent?.error ?? null) - }); -} - -function finalResponseMissingReason(traceBody: any, rows: TraceEventRow[]) { - const errorText = text(traceBody?.error?.message ?? traceBody?.terminalEvidence?.error?.message); - if (errorText) return errorText; - const terminalBody = rows.slice().reverse().map((row) => text(row.body)).find((body) => /error|failed|404|not found|blocked|timeout/iu.test(body)); - return terminalBody ? clipText(terminalBody, 1200) : "finalResponse=null; no authoritative final assistant response was returned by the trace/result payload"; -} - -function agentMessageRowForArchive(row: TraceEventRow) { - return clean({ rowId: row.rowId, seq: row.seq, tone: row.tone, header: row.header, terminal: row.terminal === true ? true : undefined, bodyFormat: row.bodyFormat, body: row.body }); -} - -function renderAgentTranscript(run: PreparedCaseRun, evidence: any, messages: any, rows: TraceEventRow[], diffPatch: string) { - const sections = [ - `# CaseRun Agent Transcript`, - ``, - `- caseId: ${run.caseId}`, - `- runId: ${run.runId}`, - `- traceId: ${messages.traceId ?? ""}`, - `- conversationId: ${messages.conversationId ?? ""}`, - `- sessionId: ${messages.sessionId ?? ""}`, - `- threadId: ${messages.threadId ?? ""}`, - `- renderer: ${CASE_TRACE_RENDERER}`, - `- traceLookupStrategy: ${messages.traceLookup?.strategy ?? ""}`, - `- traceCommand: ${messages.traceLookup?.commands?.trace ?? ""}`, - `- resultCommand: ${messages.traceLookup?.commands?.result ?? ""}`, - `- inspectCommand: ${messages.traceLookup?.commands?.inspect ?? ""}`, - `- lookupOnly: ${messages.lookupOnly === true}`, - `- finalResponse: ${messages.finalResponse?.present === true ? "present" : "null"}`, - `- autoEvaluation: false`, - ``, - `## Messages`, - rows.length > 0 ? rows.map(renderTranscriptRow).join("\n\n") : `_No rendered trace rows were returned._`, - ``, - `## Final Response`, - messages.finalResponse?.present === true ? text(messages.finalResponse.text) : [`finalResponse=null`, `reason: ${text(messages.finalResponse?.reason)}`].join("\n"), - ``, - `## Subject Diff`, - ``, - `statusShort:`, - `\`\`\`text`, - text(evidence.agentDiff?.statusShort ?? run.agentDiff?.statusShort) || "(empty)", - `\`\`\``, - ``, - `diffStat:`, - `\`\`\`text`, - text(evidence.agentDiff?.diffStat ?? run.agentDiff?.diffStat) || "(empty)", - `\`\`\``, - ``, - `patch:`, - `\`\`\`diff`, - diffPatch || "(empty)", - `\`\`\`` - ]; - return `${sections.join("\n")}\n`; -} - -function renderTranscriptRow(row: TraceEventRow) { - const body = text(row.body); - return [`### ${row.header}`, ``, `- rowId: ${row.rowId}`, row.terminal === true ? `- terminal: true` : null, ``, body ? body : `_No body._`].filter((item) => item !== null).join("\n"); -} - -function renderFinalResponseArtifact(run: PreparedCaseRun, evidence: any, finalResponse: any, rows: TraceEventRow[]) { - const lines = [ - `# CaseRun Final Response`, - ``, - `- caseId: ${run.caseId}`, - `- runId: ${run.runId}`, - `- traceId: ${evidence.agent?.traceId ?? run.agent?.traceId ?? ""}`, - `- present: ${finalResponse.present === true}`, - `` - ]; - if (finalResponse.present === true) return `${[...lines, text(finalResponse.text)].join("\n")}\n`; - const terminalRows = rows.filter((row) => row.terminal === true || row.tone === "blocked").slice(-5); - return `${[ - ...lines, - `finalResponse=null`, - ``, - `reason: ${text(finalResponse.reason)}`, - ``, - `## Terminal/Error Rows`, - terminalRows.length > 0 ? terminalRows.map(renderTranscriptRow).join("\n\n") : `_No terminal/error rows were rendered._` - ].join("\n")}\n`; -} - -async function refreshCompletedRunArchive(context: CaseContext, run: PreparedCaseRun, evidence?: any, options: { sync?: boolean } = {}) { - if (context.parsed.noCaseRepoRecord === true) return null; - if (!text(run.caseRepo)) return null; - const loadedEvidence = evidence ?? await readJsonIfExists(path.join(run.runDir, "evidence.json")); - if (!loadedEvidence) return null; - const archive = await archiveCaseRunArtifacts(context, run, loadedEvidence, options); - return { evidence: loadedEvidence, archive, summary: collectSummaryFromArchive(run, loadedEvidence, archive) }; -} - -function caseRunArtifactEntries(run: PreparedCaseRun) { - return [ - { rel: "run.json", source: path.join(run.runDir, "run.json") }, - { rel: "result.json", source: path.join(run.runDir, "result.json") }, - { rel: "agent-trace.json", source: path.join(run.runDir, "agent-trace.json") }, - { rel: "agent-prompt.md", source: path.join(run.runDir, "agent-prompt.md") }, - { rel: "agent-diff.patch", source: path.join(run.runDir, "agent-diff.patch") }, - { rel: path.join(".hwlab", "hwpod-spec.yaml"), source: run.specPath }, - { rel: "worker.stdout.log", source: path.join(run.runDir, "worker.stdout.log") }, - { rel: "worker.stderr.log", source: path.join(run.runDir, "worker.stderr.log") } - ]; -} - -async function copyCaseRunArtifact(source: string, destination: string) { - try { - const info = await stat(source); - if (!info.isFile()) return false; - await mkdir(path.dirname(destination), { recursive: true }); - await copyFile(source, destination); - return true; - } catch { - return false; - } -} - -async function registryArtifactFileRecord(run: PreparedCaseRun, caseRepoRunDir: string, rel: string, source?: string): Promise { - const file = path.join(caseRepoRunDir, rel); - const content = await readFile(file); - return clean({ - path: artifactRel(rel), - bytes: content.length, - sha256: sha256Buffer(content), - source: artifactSourceLabel(run, source) - }); -} - -function caseRunArtifactManifest(context: CaseContext, run: PreparedCaseRun, evidence: any, files: CaseRegistryArchiveFile[], skippedFiles: string[], readableAgent: CaseReadableAgentArchive, existingManifest?: any) { - return clean({ - contractVersion: "hwpod-case-run-artifact-manifest-v1", - caseId: run.caseId, - runId: run.runId, - archivedAt: text(existingManifest?.archivedAt) || context.now(), - sourceRunDir: run.runDir, - caseRepoRunDir: path.join(run.caseRepo, caseRegistryRunRel(run)), - trace: caseRunArtifactTrace(run, evidence), - traceLookup: evidence.traceLookup ?? agentTraceLookupForRun(run, evidence), - subject: evidence.subject ?? run.subject, - agent: clean({ - providerProfile: evidence.agent?.providerProfile ?? run.agent?.providerProfile, - projectId: evidence.agent?.projectId ?? run.agent?.projectId, - conversationId: evidence.agent?.conversationId ?? run.agent?.conversationId, - sessionId: evidence.agent?.sessionId ?? run.agent?.sessionId, - threadId: evidence.agent?.threadId ?? run.agent?.threadId, - traceId: evidence.agent?.traceId ?? run.agent?.traceId - }), - readableAgent: clean({ - renderer: CASE_TRACE_RENDERER, - messagesPath: readableAgent.messagesRel, - tracePath: readableAgent.traceRel, - transcriptPath: readableAgent.transcriptRel, - finalResponsePath: readableAgent.finalResponseRel, - traceRender: readableAgent.traceRender, - finalResponse: readableAgent.finalResponse - }), - aggregate: aggregateManifestEntry(files), - prompt: clean({ path: "agent-prompt.md", sha256: evidence.agent?.promptSha256 ?? run.agent?.promptSha256 }), - diff: clean({ - path: "agent-diff.patch", - sha256: evidence.agentDiff?.diffPatchSha256 ?? run.agentDiff?.diffPatchSha256, - statusShort: evidence.agentDiff?.statusShort ?? run.agentDiff?.statusShort, - diffStat: evidence.agentDiff?.diffStat ?? run.agentDiff?.diffStat, - collection: evidence.agentDiff?.diffCollection ?? run.agentDiff?.diffCollection, - sourceRootStatusShort: evidence.agentDiff?.sourceRootStatusShort ?? run.agentDiff?.sourceRootStatusShort, - sourceRootChangedAfterPrepare: evidence.agentDiff?.sourceRootChangedAfterPrepare ?? run.agentDiff?.sourceRootChangedAfterPrepare, - sourceRootChangedAfterAgent: evidence.agentDiff?.sourceRootChangedAfterAgent ?? run.agentDiff?.sourceRootChangedAfterAgent, - sourceRootBaseline: evidence.agentDiff?.sourceRootBaseline ?? run.agentDiff?.sourceRootBaseline, - sourceRootAfterPrepare: evidence.agentDiff?.sourceRootAfterPrepare ?? run.agentDiff?.sourceRootAfterPrepare, - sourceRootAfterAgent: evidence.agentDiff?.sourceRootAfterAgent ?? run.agentDiff?.sourceRootAfterAgent - }), - agentStage: agentStageSummary(evidence), - keilJob: evidence.keilJob ?? null, - artifacts: evidence.artifacts ?? [], - decisions: { - autoEvaluation: false, - runnerPostAgentCompileCheck: "recorded", - reason: "flow-only run: the manifest records artifacts and trace without auto-grading or gate decisions" - }, - files, - skippedFiles - }); -} - -function aggregateManifestEntry(files: CaseRegistryArchiveFile[]) { - const file = files.find((entry) => entry.path === "aggregate.md"); - if (!file) return undefined; - return { path: file.path, bytes: file.bytes, sha256: file.sha256, primaryEntry: true, aggregationOnly: true, autoEvaluation: false }; -} - -function caseRunArtifactTrace(run: PreparedCaseRun, evidence: any) { - return clean({ - traceId: evidence.agent?.traceId ?? run.agent?.traceId ?? evidence.agentTrace?.traceId ?? run.agentTrace?.traceId, - sessionId: evidence.agent?.sessionId ?? run.agent?.sessionId, - conversationId: evidence.agent?.conversationId ?? run.agent?.conversationId, - threadId: evidence.agent?.threadId ?? run.agent?.threadId, - providerProfile: evidence.agent?.providerProfile ?? run.agent?.providerProfile, - projectId: evidence.agent?.projectId ?? run.agent?.projectId, - source: evidence.agentTrace?.source ?? run.agentTrace?.source, - lookupOnly: evidence.agentTrace?.lookupOnly ?? run.agentTrace?.lookupOnly, - lookup: evidence.traceLookup ?? evidence.agentTrace?.lookup ?? run.agentTrace?.lookup ?? agentTraceLookupForRun(run, evidence), - status: evidence.agentTrace?.status ?? run.agentTrace?.status, - terminalStatus: evidence.agentTrace?.terminalStatus ?? run.agentTrace?.terminalStatus, - commandCount: evidence.agentTrace?.commandCount ?? run.agentTrace?.commandCount, - hwpodCommandCount: evidence.agentTrace?.hwpodCommandCount ?? run.agentTrace?.hwpodCommandCount, - hwpodBuildCommandCount: evidence.agentTrace?.hwpodBuildCommandCount ?? run.agentTrace?.hwpodBuildCommandCount, - agentRun: compactObject(evidence.agentTrace?.agentRun ?? run.agentTrace?.agentRun ?? null) - }); -} - -function caseRegistryRunRel(run: PreparedCaseRun) { - return artifactRel(path.join("runs", run.caseId, run.runId)); -} - -function artifactRel(value: string) { - return value.replace(/\\/gu, "/"); -} - -function artifactSourceLabel(run: PreparedCaseRun, source?: string) { - if (!source) return undefined; - const relative = path.relative(path.resolve(run.runDir), path.resolve(source)); - if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) return artifactRel(relative); - return source; -} - -async function runProcess(command: string[], cwd: string, env: Record) { - const proc = Bun.spawn(command, { cwd, env, stdout: "pipe", stderr: "pipe" }); - const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); - return { command, stdout, stderr, exitCode }; -} - -function hwpodResultTexts(payload: any) { - const results = Array.isArray(payload?.body?.results) ? payload.body.results : Array.isArray(payload?.results) ? payload.results : []; - return results.flatMap((item: any) => [item?.stdout, item?.stderr, item?.summary, item?.output?.stdout, item?.output?.stderr, item?.output?.summary]).filter((item: unknown) => text(item)); -} - -function compactHwpodPayload(payload: any) { - if (!payload) return null; - return compactObject({ ok: payload.ok, action: payload.action, status: payload.status, traceId: payload.body?.traceId, results: payload.body?.results }); -} - -function artifactsFrom(value: any) { - const candidates = [value?.artifacts, value?.artifact_paths, value?.artifactPaths, value?.data?.artifacts, value?.data?.artifact_paths]; - for (const candidate of candidates) { - if (Array.isArray(candidate)) return candidate; - } - return [text(value?.result?.hex_file), text(value?.result?.axf_file), text(value?.hex_file), text(value?.axf_file)].filter(Boolean); -} - -async function writeJson(file: string, value: any) { - await mkdir(path.dirname(file), { recursive: true }); - await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -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) { - 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 }; - 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; } -} - -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, 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; 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) { - 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) { - 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 commandVisibility(command: string[]) { - return command.map((item) => item.includes("/") || item.includes("\\") ? path.basename(item) || item : item); -} - -function dirnameForCommandPath(value: string) { - if (/^[A-Za-z]:\\/u.test(value)) return value.replace(/\\[^\\]+$/u, "") || value; - return path.dirname(value); -} - -function splitCommandWords(value: string) { - const words = String(value || "py -3").match(/"[^"]*"|'[^']*'|\S+/gu) ?? ["py", "-3"]; - return words.map((word) => word.replace(/^"(.*)"$/su, "$1").replace(/^'(.*)'$/su, "$1")); -} - -export function jobStatusCommandForTest(pythonCommand: string, keilCliPath: string, jobId: string) { - const python = splitCommandWords(pythonCommand); - return commandRunForTokens([python[0] ?? "py", ...python.slice(1), keilCliPath, "job-status", jobId], keilCliPath); -} - -function commandRunForTokens(tokens: string[], pathHint = "") { - if (tokens.some(looksLikeWindowsPath) || looksLikeWindowsPath(pathHint)) return { command: "cmd.exe", argv: ["/d", "/s", "/c", shellCommand(tokens)] }; - return { command: tokens[0] ?? "", argv: tokens.slice(1) }; -} - -function shellCommand(tokens: string[]) { - return tokens.map(shellArg).join(" "); -} - -function shellArg(value: string) { - const raw = String(value); - if (/^[A-Za-z0-9_./:@\\-]+$/u.test(raw)) return raw; - return `"${raw.replace(/(["^&|<>])/gu, "^$1").replace(/%/gu, "%%")}"`; -} - -export function artifactsFromKeilStatusForTest(value: any) { - return artifactsFrom(value); -} - -function compactObject(value: any) { - const json = JSON.stringify(value ?? null); - if (json.length <= 4000) return value; - return { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) }; -} - -function parseJsonMaybe(value: unknown) { - if (value && typeof value === "object") return value as any; - const raw = String(value ?? "").trim(); - if (!raw) return null; - try { return JSON.parse(raw); } catch { return null; } -} - -function clipText(value: unknown, maxBytes = 2000) { - const raw = String(value ?? ""); - const buffer = Buffer.from(raw, "utf8"); - if (buffer.length <= maxBytes) return raw; - return `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`; -} - -function ok(action: string, extra: Record = {}, status = "succeeded") { return { ok: status !== "failed", action, status, ...extra }; } -function cliError(code: string, message: string, details: Record = {}) { return Object.assign(new Error(message), { code, details }); } -function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw cliError("missing_required_value", `${field} is required`, { field }); return result; } -function text(value: unknown) { return String(value ?? "").trim(); } -function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; } -function slug(value: string) { return value.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "") || "case"; } -function clean>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T; } +// SPEC: PJ2026-0103 HarnessRL draft-2026-06-25-p0-web-caserun-e2e. +export * from "./hwlab-caserun-runtime.ts"; +export { artifactsFromKeilStatusForTest, jobStatusCommandForTest } from "./hwlab-caserun-subject.ts"; diff --git a/tools/src/hwlab-caserun-runtime.ts b/tools/src/hwlab-caserun-runtime.ts new file mode 100644 index 00000000..f5848113 --- /dev/null +++ b/tools/src/hwlab-caserun-runtime.ts @@ -0,0 +1,1459 @@ +// 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 { createHash, 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 { agentTerminalEvidence, agentToolCallSummaryFromResult, agentWithResultEvidence, applyCaseRunEvidenceRelationships, commandsFromToolCallSummary, summarizeAgentTrace, firstNumberOption, firstTextOption, type AgentToolCallSummary } from "./hwlab-caserun-evidence.ts"; +import { readHwpodSpec } from "./hwpod-harness-lib.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 } from "./hwlab-caserun-subject.ts"; +import { 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 REMOVED_V02_API_URL = "http://74.48.78.17:19667"; +const REMOVED_V02_WEB_URL = "http://74.48.78.17:19666"; +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 === "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 (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, + 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 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 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 prompt 是无服务预览入口,只按真实 CaseRun 组装路径渲染输入 prompt,不准备 worktree、不提交 agent、不访问硬件。", + "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 aggregate is a service-free post-processing command: it reads an existing case registry run and writes one aggregate markdown entry without pass/fail grading.", + "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." + ] + }); +} + +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 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, loaded.definition), + compileOnly: true, + 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: true, + 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: 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: true, + 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 = { ...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, loaded.definition); + const subject = await prepareSubjectWorktree(context, { caseId, runId, runDir, apiUrl, definition: loaded.definition, sourceSpecPath: loaded.specPath }, collectSourceRootSnapshot); + const agentTask = agentTaskFromDefinition(loaded.definition, caseId); + const run: PreparedCaseRun = { + caseId, + runId, + runDir, + caseRepo, + caseDir: loaded.caseDir, + caseFile: loaded.caseFile, + sourceSpecPath: loaded.specPath, + specPath, + apiUrl, + compileOnly: true, + createdAt: now, + updatedAt: now, + definition: loaded.definition, + 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: true, + agentTask: agentTaskSummary(agentTask), + subject, + summary: prepareSummary(run), + run + }); +} + +export async function buildCaseRun(context: CaseContext, prepared?: PreparedCaseRun) { + const run = prepared ?? await loadOrPrepareCaseRun(context); + const command = [process.execPath, path.join(context.cwd, "tools/hwpod-cli.ts"), "build", "--spec", run.specPath, "--reason", `case-run ${run.caseId} ${run.runId} compile-only`]; + const invoked = await (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 jobId = extractKeilJobId(hwpodPayload); + const job = jobId ? await pollKeilJobStatus(context, run, jobId, caseTimeoutsFromDefinition(run.definition)) : null; + const evidence = clean({ + contractVersion: "hwpod-case-run-evidence-v1", + caseId: run.caseId, + runId: run.runId, + compileOnly: true, + autoEvaluation: false, + status: "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) + }), + keilJob: job ? job.summary : null, + artifacts: job?.summary?.artifacts ?? [], + decisions: { + autoEvaluation: false, + runnerPostAgentCompileCheck: "recorded", + reason: "flow-only run: CaseRun records agent, diff and compile evidence without auto-grading them" + } + }); + 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: true, + summary: buildSummary(evidence), + evidence, + run: completed.run + }, "completed"); +} + +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: true, 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, 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; + 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) { + const value = definition.timeouts; + if (!value || typeof value !== "object" || Array.isArray(value)) return {} as Record; + return value as Record; +} + +function defaultAgentTask(definition: Record, 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" + }; +} + +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, run.definition); + 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 = 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 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 }; +} + +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 | null; sessionResponse?: Record | null; error?: Record | 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"); +} + +function agentTraceLookup(input: { baseUrl: string; traceId: string; sessionId?: string; conversationId?: string; threadId?: string }) { + const traceId = input.traceId; + const base = input.baseUrl; + const commands = clean({ + result: `hwlab-cli client agent result ${traceId}`, + trace: `hwlab-cli client agent trace ${traceId} --render web`, + traceFull: `hwlab-cli client agent trace ${traceId} --render web --full`, + inspect: `hwlab-cli client agent inspect --trace-id ${traceId}`, + sessionStatus: input.sessionId ? `hwlab-cli client agent session status ${input.sessionId}` : undefined + }); + return clean({ + source: "hwlab-cli.client.agent", + strategy: "id_plus_existing_cli", + note: "CaseRun records trace/session identity and reuses existing HWLAB Web-equivalent agent CLI for trace/result/inspect; it does not implement a separate trace query path.", + baseUrl: base, + traceId, + sessionId: input.sessionId, + conversationId: input.conversationId, + threadId: input.threadId, + commands + }); +} + +function agentTraceIdentityArtifact(run: PreparedCaseRun, agent: AgentRunStage) { + const traceId = text(agent.traceId); + const lookup = agent.traceLookup ?? agentTraceLookup({ baseUrl: agent.baseUrl, traceId, sessionId: agent.sessionId, conversationId: agent.conversationId, threadId: agent.threadId }); + const resultBody = agent.result?.body && typeof agent.result.body === "object" ? agent.result.body : agent.result; + return clean({ + contractVersion: "hwpod-case-run-agent-trace-identity-v1", + source: "caserun-identity", + lookupOnly: true, + status: text(resultBody?.status ?? agent.stageStatus) || "recorded", + traceId, + sessionId: agent.sessionId, + conversationId: agent.conversationId, + threadId: agent.threadId, + providerProfile: agent.providerProfile, + requestedProviderProfile: agent.requestedProviderProfile, + resolvedBackendProfile: agent.resolvedBackendProfile, + provider: agent.provider, + model: agent.model, + backend: agent.backend, + infrastructureBackend: agent.infrastructureBackend, + providerTrace: agent.providerTrace, + projectId: agent.projectId, + resultStatus: text(resultBody?.status), + terminalStatus: agent.terminalStatus, + commandStatus: agent.commandStatus, + agentRunStatus: agent.agentRunStatus, + toolCallSummary: agent.toolCallSummary ?? agentToolCallSummaryFromResult(resultBody), + finalResponse: resultBody?.finalResponse ?? resultBody?.reply ?? null, + traceSummary: resultBody?.traceSummary ?? null, + terminalEvidence: resultBody?.terminalEvidence ?? null, + agentRun: compactObject(resultBody?.agentRun ?? resultBody?.traceSummary?.agentRun ?? null), + lookup, + hint: "Use lookup.commands.trace or lookup.commands.result to query the full Web-equivalent AgentRun trace with the existing HWLAB CLI.", + runId: run.runId, + caseId: run.caseId + }); +} + +async function pollAgentResult(context: CaseContext, input: { baseUrl: string; traceId: string; acceptedBody: any; resultUrl?: string; run?: PreparedCaseRun; agent?: AgentRunStage }) { + 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 (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 }); + } + if (!response.ok) return { stageStatus: "result_request_failed", body: lastBody, polls, timedOut: false, lastPollAt, lastHttpStatus: lastStatus, error: response.error }; + 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) } }; +} + +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); +} + +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: `case_subject_cmd_${randomUUID()}`, + hwpodId, + nodeId: document.spec.nodeBinding.nodeId, + intent: "cmd.run", + source: { compiler: "hwlab-cli.case", specPath: run.specPath, specAuthority: "case-run-state" }, + ops: [{ opId: "op_01_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 { + 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: `case_subject_cmd_${randomUUID()}`, + hwpodId, + nodeId: document.spec.nodeBinding.nodeId, + intent: "cmd.run", + source: { compiler: "hwlab-cli.case", specPath: run.specPath, specAuthority: "case-run-state" }, + ops: [{ + opId: "op_01_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) }; +} + +async function updateRun(context: CaseContext, run: PreparedCaseRun, patch: Partial & Record) { + 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 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 ${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}`, + `验证模式: 仅执行编译构建检查;除非案例明确要求,否则不下载、不做运行态冒烟验证。`, + ``, + `## 运行时装配`, + `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 ${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 webUrlFrom(context: CaseContext, definition: any) { + const value = text(context.parsed.baseUrl ?? context.parsed.webUrl ?? context.env.HWLAB_RUNTIME_WEB_URL ?? context.env.HWLAB_CLOUD_WEB_URL); + if (!value) { + throw cliError("caserun_runtime_web_url_required", "CaseRun v0.3 requires an explicit runtime Web URL from --base-url, --web-url, HWLAB_RUNTIME_WEB_URL, or HWLAB_CLOUD_WEB_URL; case runtime.webUrl fallback is removed.", { + allowed: ["--base-url", "--web-url", "HWLAB_RUNTIME_WEB_URL", "HWLAB_CLOUD_WEB_URL"], + removedFallbacks: ["case.runtime.webUrl", REMOVED_V02_WEB_URL] + }); + } + return rejectRemovedV02RuntimeUrl(value, "web"); +} + +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 sha256(value: string) { + return createHash("sha256").update(value).digest("hex"); +} + +function promptShellArg(value: string) { + return `'${String(value).replace(/'/gu, `'\\''`)}'`; +} + +function sha256Buffer(value: Buffer) { + return createHash("sha256").update(value).digest("hex"); +} + + +function apiUrlFrom(context: CaseContext, definition: any) { + const value = text(context.parsed.apiUrl ?? context.parsed.apiBaseUrl ?? context.parsed.runtimeApiUrl ?? context.env.HWLAB_RUNTIME_API_URL ?? context.env.HWLAB_CASERUN_RUNTIME_API_URL); + if (!value) { + throw cliError("caserun_runtime_api_url_required", "CaseRun v0.3 requires an explicit runtime API URL from --api-url, --api-base-url, --runtime-api-url, HWLAB_RUNTIME_API_URL, or HWLAB_CASERUN_RUNTIME_API_URL; case runtime.apiUrl fallback is removed.", { + allowed: ["--api-url", "--api-base-url", "--runtime-api-url", "HWLAB_RUNTIME_API_URL", "HWLAB_CASERUN_RUNTIME_API_URL"], + removedFallbacks: ["case.runtime.apiUrl", REMOVED_V02_API_URL] + }); + } + return rejectRemovedV02RuntimeUrl(value, "api"); +} + +function rejectRemovedV02RuntimeUrl(value: string, kind: "api" | "web") { + const normalized = value.replace(/\/+$/u, ""); + const removed = kind === "web" ? REMOVED_V02_WEB_URL : REMOVED_V02_API_URL; + if (normalized === removed) { + throw cliError("caserun_removed_v02_runtime_url", `CaseRun v0.3 does not support the removed v0.2 ${kind} runtime URL`, { kind, url: normalized }); + } + return normalized; +} + +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) { + 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) { + 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 }; + await writeJson(file, nextRun); + return { run: nextRun as PreparedCaseRun, control }; +} + +async function finalizeBuildCompletedRun(context: CaseContext, run: PreparedCaseRun, control: Record, status: string, stage: ReturnType) { + 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, 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; 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) { + 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) { + 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; +} + +export function commandVisibility(command: string[]) { + return command.map((item) => item.includes("/") || item.includes("\\") ? path.basename(item) || item : item); +} + +function compactObject(value: any) { + const json = JSON.stringify(value ?? null); + if (json.length <= 4000) return value; + return { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) }; +} + +function parseJsonMaybe(value: unknown) { + if (value && typeof value === "object") return value as any; + const raw = String(value ?? "").trim(); + if (!raw) return null; + try { return JSON.parse(raw); } catch { return null; } +} + +function clipText(value: unknown, maxBytes = 2000) { + const raw = String(value ?? ""); + const buffer = Buffer.from(raw, "utf8"); + if (buffer.length <= maxBytes) return raw; + return `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`; +} + +function ok(action: string, extra: Record = {}, status = "succeeded") { return { ok: status !== "failed", action, status, ...extra }; } +function cliError(code: string, message: string, details: Record = {}) { return Object.assign(new Error(message), { code, details }); } +function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw cliError("missing_required_value", `${field} is required`, { field }); return result; } +function text(value: unknown) { return String(value ?? "").trim(); } +function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; } +function slug(value: string) { return value.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "") || "case"; } +function clean>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T; } diff --git a/tools/src/hwlab-caserun-shared.ts b/tools/src/hwlab-caserun-shared.ts new file mode 100644 index 00000000..18c8d344 --- /dev/null +++ b/tools/src/hwlab-caserun-shared.ts @@ -0,0 +1,201 @@ +// SPEC: PJ2026-0103 HarnessRL draft-2026-06-25-p0-web-caserun-e2e. +// Responsibility: shared CaseRun contracts used by runtime, subject, and closeout modules. + +import type { AgentDiffCollectionSummary } from "./hwlab-caserun-diff.ts"; +import type { AgentToolCallSummary } from "./hwlab-caserun-evidence.ts"; + +export type EnvLike = Record; +export type ParsedArgs = Record & { _: string[] }; +export type FetchLike = typeof fetch; +export type RunProcessLike = (command: string[], cwd: string, env: Record) => Promise<{ command: string[]; stdout: string; stderr: string; exitCode: number }>; +export type StartProcessLike = (input: { command: string; args: string[]; cwd: string; env: Record; stdoutPath: string; stderrPath: string }) => Promise<{ pid: number }> | { pid: number }; + +export type CaseContext = { + parsed: ParsedArgs; + env: EnvLike; + fetchImpl: FetchLike; + cwd: string; + now: () => string; + sleep: (ms: number) => Promise; + runProcess?: RunProcessLike; + startProcess?: StartProcessLike; + argv?: string[]; + rest: string[]; +}; + +export type PreparedCaseRun = { + caseId: string; + runId: string; + runDir: string; + caseRepo: string; + caseDir: string; + caseFile: string; + sourceSpecPath: string; + specPath: string; + apiUrl: string; + compileOnly: boolean; + createdAt: string; + updatedAt: string; + definition: Record; + subject: PreparedSubjectRun; + agentTask?: PreparedAgentTask | null; + agent?: AgentRunStage | null; + agentTrace?: AgentTraceStage | null; + agentDiff?: AgentDiffStage | null; + status?: string; + stage?: string; + evidencePath?: string; + completedAt?: string; +}; + +export type PreparedSubjectRun = { + repoLocalPath: string; + commitId: string; + subdir: string; + worktreePath: string; + workspacePath: string; + setup: Record; + sourceRootBaseline?: SourceRootSnapshot; + sourceRootAfterPrepare?: SourceRootSnapshot; +}; + +export type SourceRootSnapshot = { + label: string; + statusShort: string; + diffStat: string; + diffSha256: string; + diffBytes: number; + commands?: Record; +}; + +export type PreparedAgentTask = { + prompt: string; + workspace: "subjectWorktree"; + constraints: string[]; + providerProfile: string; + projectId: string; + timeoutMs?: number; + pollIntervalMs?: number; +}; + +export type AgentRunStage = { + stageStatus: string; + baseUrl: string; + projectId: string; + providerProfile: string; + conversationId: string; + sessionId: string; + threadId: string; + traceId: string; + promptPath: string; + promptSha256: string; + accepted: Record | null; + result: Record | null; + terminalStatus?: string; + commandStatus?: string; + agentRunStatus?: string; + requestedProviderProfile?: string; + resolvedBackendProfile?: string; + provider?: string; + model?: string; + backend?: string; + infrastructureBackend?: string; + providerTrace?: Record | null; + toolCallSummary?: AgentToolCallSummary | null; + polls: number; + timedOut: boolean; + timeoutMs?: number; + pollIntervalMs?: number; + resultUrl?: string; + lastPollAt?: string; + lastHttpStatus?: number; + nextPollCommand?: string; + traceCommand?: string; + inspectCommand?: string; + sessionCommand?: string; + traceLookup?: Record; + sessionResponse: Record | null; + error?: Record | null; +}; + +export type AgentDiffStage = { + statusShort: string; + diffStat: string; + diffPatchPath: string; + diffPatchSha256: string; + diffCollection?: AgentDiffCollectionSummary; + sourceRootStatusShort: string; + sourceRootBaseline?: SourceRootSnapshot; + sourceRootAfterPrepare?: SourceRootSnapshot; + sourceRootAfterAgent?: SourceRootSnapshot; + sourceRootChangedAfterPrepare?: boolean; + sourceRootChangedAfterAgent?: boolean; + requests: Record[]; +}; + +export type AgentTraceCommand = { + source: string; + seq?: number; + rowId?: string; + header?: string; + toolName?: string; + status?: string; + command?: string; + normalizedCommand?: string; + bodyPreview?: string; + exitCode?: number; +}; + +export type AgentTraceStage = { + traceId: string; + status: string; + httpStatus: number; + source?: string; + lookupOnly?: boolean; + lookup?: Record; + sourceEventCount?: number; + renderedRowCount?: number; + commandCount: number; + hwpodCommandCount: number; + hwpodBuildCommandCount: number; + keilJobCandidates: string[]; + terminalStatus?: string; + agentRun?: Record | null; + toolCallSummary?: AgentToolCallSummary | null; + commands: AgentTraceCommand[]; + error?: Record | null; +}; + +export type CaseRegistryArchiveFile = { + path: string; + bytes: number; + sha256: string; + source?: string; +}; + +export type CaseRegistryArchive = { + caseRepoRunDir: string; + artifactManifestPath: string; + caseRepoFiles: string[]; + files: CaseRegistryArchiveFile[]; + skippedFiles: string[]; + manifest: Record; + registrySync?: Record | null; +}; + +export type CaseReadableAgentArchive = { + messagesRel: string; + traceRel: string; + transcriptRel: string; + finalResponseRel: string; + generatedFiles: string[]; + traceRender: Record; + finalResponse: Record; +}; + +export type ArchivedAgentTraceSnapshot = { + traceBody: any; + rows: TraceEventRow[]; + events: Record[]; + summary: AgentTraceStage; +}; diff --git a/tools/src/hwlab-caserun-subject.ts b/tools/src/hwlab-caserun-subject.ts new file mode 100644 index 00000000..fe0f7d4a --- /dev/null +++ b/tools/src/hwlab-caserun-subject.ts @@ -0,0 +1,372 @@ +// SPEC: PJ2026-0103 HarnessRL draft-2026-06-25-p0-web-caserun-e2e. +// Responsibility: subject worktree isolation and Keil post-validation collection. + +import { createHash, randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { readHwpodSpec } from "./hwpod-harness-lib.ts"; +import { firstNumberOption } from "./hwlab-caserun-evidence.ts"; +import type { CaseContext, PreparedCaseRun, PreparedSubjectRun, SourceRootSnapshot } from "./hwlab-caserun-shared.ts"; +const DEFAULT_POLL_INTERVAL_MS = 1000; const DEFAULT_JOB_TIMEOUT_MS = 50000; const MAX_JOB_TIMEOUT_MS = 300000; +function text(value: unknown) { return String(value ?? "").trim(); } +function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; } +function clean(value: any) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)); } +function compactObject(value: any) { const json = JSON.stringify(value ?? null); return json.length <= 4000 ? value : { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) }; } +function clipText(value: unknown, maxBytes = 2000) { const raw = String(value ?? ""); const buffer = Buffer.from(raw, "utf8"); return buffer.length <= maxBytes ? raw : `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`; } +function parseJsonMaybe(value: unknown) { if (value && typeof value === "object") return value as any; try { return JSON.parse(String(value ?? "").trim()); } catch { return null; } } +function cliError(code: string, message: string, details: any = {}) { return Object.assign(new Error(message), { code, details }); } +function slug(value: string) { return value.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "") || "case"; } +export async function prepareSubjectWorktree(context: CaseContext, input: { caseId: string; runId: string; runDir: string; apiUrl: string; definition: Record; sourceSpecPath: string }, collectSnapshot: (context: CaseContext, run: PreparedCaseRun, label: string) => Promise) { + const subject = subjectFromDefinition(input.definition); + const worktreePath = subjectWorktreePath(subject.repoLocalPath, input.runId); + const relativeWorktreePath = subjectRelativeWorktreePath(subject.repoLocalPath, worktreePath); + const snapshotRun = caseRunForSubjectSnapshot(input, subject, worktreePath); + const sourceRootBaseline = await collectSnapshot(context, snapshotRun, "before-prepare"); + const setup = await requestSubjectWorktree(context, input, subject, worktreePath, relativeWorktreePath); + const sourceRootAfterPrepare = await collectSnapshot(context, snapshotRun, "after-prepare"); + return { ...subject, worktreePath, workspacePath: worktreePath, setup, sourceRootBaseline, sourceRootAfterPrepare }; +} + +export function caseRunForSubjectSnapshot(input: { caseId: string; runId: string; runDir: string; apiUrl: string; definition: Record; sourceSpecPath: string }, subject: { repoLocalPath: string; commitId: string; subdir: string }, worktreePath: string): PreparedCaseRun { + return { + caseId: input.caseId, + runId: input.runId, + runDir: input.runDir, + caseRepo: "", + caseDir: "", + caseFile: "", + sourceSpecPath: input.sourceSpecPath, + specPath: input.sourceSpecPath, + apiUrl: input.apiUrl, + compileOnly: true, + createdAt: "", + updatedAt: "", + definition: input.definition, + subject: { ...subject, worktreePath, workspacePath: worktreePath, setup: {} } + }; +} + +export function subjectFromDefinition(definition: Record) { + const subject = definition.subject; + if (!subject || typeof subject !== "object" || Array.isArray(subject)) { + throw cliError("subject_required", "case.json subject is required", { required: ["subject.repoLocalPath", "subject.commitId"] }); + } + const source = subject as Record; + const repoLocalPath = text(source.repoLocalPath); + if (!repoLocalPath) throw cliError("subject_repo_local_path_required", "case.json subject.repoLocalPath is required and must point to a local subject repo checkout on the bound HWPOD node", { field: "subject.repoLocalPath" }); + const commitId = text(source.commitId).toLowerCase(); + if (!commitId) throw cliError("subject_commit_id_required", "case.json subject.commitId is required and must be a fixed subject repo commit id", { field: "subject.commitId" }); + if (!/^[0-9a-f]{40}$/iu.test(commitId)) { + throw cliError("invalid_subject_commit_id", "subject.commitId must be a full 40-character git commit id", { commitId }); + } + return { repoLocalPath, commitId, subdir: text(source.subdir) }; +} + +export async function requestSubjectWorktree(context: CaseContext, input: { caseId: string; runId: string; runDir: string; apiUrl: string; sourceSpecPath: string }, subject: { repoLocalPath: string; commitId: string; subdir: string }, worktreePath: string, relativeWorktreePath: string) { + const document = await readHwpodSpec(input.sourceSpecPath); + const hwpodId = text(document.metadata.name) || text(document.metadata.uid) || input.caseId; + const sidecarOp = keilSidecarCopyOp(document, subject, hwpodId, relativeWorktreePath); + const ops = [{ + opId: "op_01_subject_commit", + op: "cmd.run", + args: { + hwpodId, + workspacePath: subject.repoLocalPath, + command: "git", + argv: ["cat-file", "-e", subject.commitId] + } + }, { + opId: "op_02_subject_worktree_add", + op: "cmd.run", + args: { + hwpodId, + workspacePath: subject.repoLocalPath, + command: "git", + argv: ["worktree", "add", "--detach", "--force", relativeWorktreePath, subject.commitId] + } + }, { + opId: "op_03_subject_worktree_head", + op: "cmd.run", + args: { + hwpodId, + workspacePath: subject.repoLocalPath, + command: "git", + argv: ["-C", relativeWorktreePath, "rev-parse", "HEAD"] + } + }, ...(sidecarOp ? [sidecarOp] : [])]; + const plan = { + contractVersion: "hwpod-node-ops-v1", + planId: `case_subject_${randomUUID()}`, + hwpodId, + nodeId: document.spec.nodeBinding.nodeId, + intent: "cmd.run", + source: { compiler: "hwlab-cli.case", specPath: input.sourceSpecPath, specAuthority: "case-registry" }, + ops + }; + const response = await context.fetchImpl(`${input.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 status = subjectWorktreeStatus(response.status, body, { commitId: subject.commitId, worktreePath }); + if (!status.ok) { + throw cliError("subject_worktree_prepare_failed", "failed to prepare isolated subject worktree on the HWPOD node", clean({ httpStatus: response.status, nodeId: document.spec.nodeBinding.nodeId, repoLocalPath: subject.repoLocalPath, commitId: subject.commitId, worktreePath, exitCode: status.exitCode, stdout: clipText(status.stdout), stderr: clipText(status.stderr), body: compactObject(body) })); + } + return clean({ httpStatus: response.status, planId: plan.planId, nodeId: document.spec.nodeBinding.nodeId, exitCode: status.exitCode, stdout: clipText(status.stdout), stderr: clipText(status.stderr), keilSidecars: keilSidecarSummary(status.stdout) }); +} + +export function subjectWorktreeStatus(httpStatus: number, body: any, expected: { commitId: string; worktreePath: string }) { + const results = Array.isArray(body?.body?.results) ? body.body.results : Array.isArray(body?.results) ? body.results : []; + const outputs = results.map((result: any) => result.output ?? result); + const exitCodes = outputs.map((output: any) => numberOption(output.exitCode)); + const stdout = outputs.map((output: any) => text(output.stdout)).filter(Boolean).join("\n"); + const stderr = outputs.map((output: any) => text(output.stderr)).filter(Boolean).join("\n"); + const actualHead = text(outputs[2]?.stdout).split(/\r?\n/u)[0]?.toLowerCase() ?? ""; + const commandOk = httpStatus >= 200 && httpStatus < 300 && outputs.length >= 3 && exitCodes.every((exitCode) => exitCode === 0); + const ok = commandOk && actualHead === expected.commitId; + return { ok, exitCode: exitCodes.find((exitCode) => exitCode !== 0) ?? exitCodes[exitCodes.length - 1], stdout, stderr, actualHead, actualWorktreePath: expected.worktreePath }; +} + +export function keilSidecarCopyOp(document: any, subject: { repoLocalPath: string }, hwpodId: string, relativeWorktreePath: string) { + const project = subjectRelativeKeilProject(document, subject.repoLocalPath); + if (!project) return null; + const sourceBase = stripUvprojx(project).replace(/\//gu, "\\"); + const destinationBase = windowsJoin(relativeWorktreePath, sourceBase); + return { + opId: "op_04_keil_sidecars", + op: "cmd.run", + args: { + hwpodId, + workspacePath: subject.repoLocalPath, + command: "node", + argv: ["-e", keilSidecarCopyNodeScript(), JSON.stringify({ sourceBase, destinationBase })], + commandBinding: { + kind: "keil-mdk-sidecar-copy", + source: "hwlab-cli.case.prepare", + sourceBase, + destinationBase, + sidecars: [".uvoptx", ".uvopt"] + } + } + }; +} + +export function subjectRelativeKeilProject(document: any, repoLocalPath: string) { + const workspace = objectRecord(document.spec?.workspace); + const projectWorkspace = objectRecord(document.spec?.projectWorkspace); + const rawProject = text(workspace.keilProject ?? workspace.projectPath ?? workspace.project ?? projectWorkspace.projectPath); + if (!rawProject || !/\.uvprojx$/iu.test(rawProject)) return ""; + if (!isAbsolutePathLike(rawProject)) return rawProject.replace(/^[\\/]+/u, ""); + const repo = repoLocalPath.replace(/[\\/]+$/u, ""); + const normalizedRepo = normalizeLocalPath(repo); + const normalizedProject = normalizeLocalPath(rawProject); + if (!normalizedProject.startsWith(`${normalizedRepo}/`)) return ""; + return rawProject.slice(repo.length).replace(/^[\\/]+/u, ""); +} + +export function stripUvprojx(value: string) { + return value.replace(/\.uvprojx$/iu, ""); +} + +export function windowsJoin(parent: string, child: string) { + const left = parent.replace(/[\\/]+$/u, "").replace(/\//gu, "\\"); + const right = child.replace(/^[\\/]+/u, "").replace(/\//gu, "\\"); + return `${left}\\${right}`; +} + +export function keilSidecarCopyNodeScript() { + return [ + "const fs=require('fs');", + "const path=require('path');", + "const input=JSON.parse(process.argv[1]||'{}');", + "const sidecars=['.uvoptx','.uvopt'];", + "const copiedFiles=[];", + "const missing=[];", + "for(const ext of sidecars){", + "const source=String(input.sourceBase||'')+ext;", + "const destination=String(input.destinationBase||'')+ext;", + "if(fs.existsSync(source)){", + "const dir=destination.includes('\\\\')?path.win32.dirname(destination):path.dirname(destination);", + "fs.mkdirSync(dir,{recursive:true});", + "fs.copyFileSync(source,destination);", + "copiedFiles.push({ext,source,destination});", + "}else{missing.push(source);}", + "}", + "console.log('caseRunKeilSidecars '+JSON.stringify({copied:copiedFiles.length,copiedFiles,missing,sourceBase:input.sourceBase,destinationBase:input.destinationBase}));" + ].join(""); +} + +export function keilSidecarSummary(stdout: string) { + const line = stdout.split(/\r?\n/u).find((item) => item.includes("caseRunKeilSidecars")); + if (!line) return null; + const payload = line.replace(/^.*caseRunKeilSidecars\s*/u, ""); + const parsed = parseJsonMaybe(payload); + if (parsed && typeof parsed === "object") return clean({ ...(parsed as Record), line }); + return clean({ copied: numberOption(line.match(/copied=(\d+)/u)?.[1]), line }); +} + +export function objectRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +export function isAbsolutePathLike(value: string) { + return /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("/") || value.startsWith("\\\\"); +} + +export function subjectRelativeWorktreePath(repoLocalPath: string, worktreePath: string) { + const repo = repoLocalPath.replace(/[\\/]+$/u, ""); + const normalizedRepo = normalizeLocalPath(repo); + const normalizedWorktree = normalizeLocalPath(worktreePath); + if (!normalizedWorktree.startsWith(`${normalizedRepo}/`)) throw cliError("invalid_subject_worktree_path", "subject worktree path must stay under subject repo local path", { repoLocalPath, worktreePath }); + const relative = worktreePath.slice(repo.length).replace(/^[\\/]+/u, ""); + return relative || "."; +} + +export function normalizeLocalPath(value: string) { + return value.replace(/[\\/]+$/u, "").replace(/\\/gu, "/").toLowerCase(); +} + +export function subjectWorktreePath(repoLocalPath: string, runId: string) { + const separator = looksLikeWindowsPath(repoLocalPath) ? "\\" : "/"; + return [repoLocalPath.replace(/[\\/]+$/u, ""), ".worktree", `caserun-${slug(runId)}`].join(separator); +} + +export function looksLikeWindowsPath(value: string) { + return /^[A-Za-z]:[\\/]/u.test(value) || value.includes("\\"); +} + +export function rewriteHwpodSpecWorkspacePath(specText: string, workspacePath: string) { + const lines = specText.replace(/\r\n/gu, "\n").split("\n"); + let workspaceIndent: number | null = null; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + const workspaceMatch = line.match(/^(\s*)workspace:\s*(?:#.*)?$/u); + if (workspaceMatch) { + workspaceIndent = workspaceMatch[1].length; + continue; + } + if (workspaceIndent === null) continue; + const indent = line.match(/^(\s*)/u)?.[1].length ?? 0; + if (line.trim() && indent <= workspaceIndent) { + workspaceIndent = null; + continue; + } + const pathMatch = line.match(/^(\s*)path\s*:/u); + if (pathMatch) { + lines[index] = `${pathMatch[1]}path: ${JSON.stringify(workspacePath)}`; + return lines.join("\n"); + } + } + throw cliError("hwpod_spec_workspace_path_missing", "hwpod-spec.yaml must contain spec.workspace.path so CaseRun can isolate the subject worktree", {}); +} + +export function hwpodResultTexts(payload: any) { + const results = Array.isArray(payload?.body?.results) ? payload.body.results : Array.isArray(payload?.results) ? payload.results : []; + return results.flatMap((item: any) => [item?.stdout, item?.stderr, item?.summary, item?.output?.stdout, item?.output?.stderr, item?.output?.summary]).filter((item: unknown) => text(item)); +} + +export function artifactsFrom(value: any) { + const candidates = [value?.artifacts, value?.artifact_paths, value?.artifactPaths, value?.data?.artifacts, value?.data?.artifact_paths]; + for (const candidate of candidates) { + if (Array.isArray(candidate)) return candidate; + } + return [text(value?.result?.hex_file), text(value?.result?.axf_file), text(value?.hex_file), text(value?.axf_file)].filter(Boolean); +} + +export function dirnameForCommandPath(value: string) { + if (/^[A-Za-z]:\\/u.test(value)) return value.replace(/\\[^\\]+$/u, "") || value; + return path.dirname(value); +} + +function splitCommandWords(value: string) { + const words = String(value || "py -3").match(/"[^"]*"|'[^']*'|\S+/gu) ?? ["py", "-3"]; + return words.map((word) => word.replace(/^"(.*)"$/su, "$1").replace(/^'(.*)'$/su, "$1")); +} + +export function jobStatusCommandForTest(pythonCommand: string, keilCliPath: string, jobId: string) { + const python = splitCommandWords(pythonCommand); + return commandRunForTokens([python[0] ?? "py", ...python.slice(1), keilCliPath, "job-status", jobId], keilCliPath); +} + +function commandRunForTokens(tokens: string[], pathHint = "") { + if (tokens.some(looksLikeWindowsPath) || looksLikeWindowsPath(pathHint)) return { command: "cmd.exe", argv: ["/d", "/s", "/c", shellCommand(tokens)] }; + return { command: tokens[0] ?? "", argv: tokens.slice(1) }; +} + +function shellCommand(tokens: string[]) { + return tokens.map(shellArg).join(" "); +} + +function shellArg(value: string) { + const raw = String(value); + if (/^[A-Za-z0-9_./:@\\-]+$/u.test(raw)) return raw; + return `"${raw.replace(/(["^&|<>])/gu, "^$1").replace(/%/gu, "%%")}"`; +} + +export function artifactsFromKeilStatusForTest(value: any) { + return artifactsFrom(value); +} + +export async function pollKeilJobStatus(context: CaseContext, run: PreparedCaseRun, jobId: string, timeouts: Record) { + const timeoutMs = Math.min(numberOption(context.parsed.jobTimeoutMs ?? context.parsed.timeoutMs) ?? numberOption(timeouts.jobTimeoutMs ?? timeouts.keilJobTimeoutMs) ?? DEFAULT_JOB_TIMEOUT_MS, MAX_JOB_TIMEOUT_MS); + const pollIntervalMs = numberOption(context.parsed.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS; + const started = Date.now(); + const polls: any[] = []; + let last: any = null; + while (Date.now() - started <= timeoutMs) { + const response = await requestKeilJobStatus(context, run, jobId); + const status = keilJobStatus(response.body); + last = { response, status }; + polls.push(status.summary); + if (status.terminal) return { ok: status.ok, summary: { ...status.summary, jobId, polls: polls.length, timedOut: false } }; + await context.sleep(pollIntervalMs); + } + return { ok: false, summary: { jobId, status: text(last?.status?.summary?.status) || "timeout", polls: polls.length, timedOut: true, last: last?.status?.summary ?? null } }; +} + +export async function requestKeilJobStatus(context: CaseContext, run: PreparedCaseRun, jobId: string) { + const document = await readHwpodSpec(run.specPath); + const keilCliPath = text(document.spec.workspace.keilCliPath) || text(document.spec.debugProbe.keilCliPath) || "keil-cli.py"; + const pythonCommand = text(document.spec.workspace.pythonCommand) || text(document.spec.debugProbe.pythonCommand) || "py -3"; + const jobStatusCommand = jobStatusCommandForTest(pythonCommand, keilCliPath, jobId, document.spec.workspace.path); + const hwpodId = text(document.metadata.name) || text(document.metadata.uid) || run.caseId; + const plan = { + contractVersion: "hwpod-node-ops-v1", + planId: `case_job_${randomUUID()}`, + hwpodId, + nodeId: document.spec.nodeBinding.nodeId, + intent: "cmd.run", + source: { compiler: "hwlab-cli.case", specPath: run.specPath, specAuthority: "case-run-state" }, + ops: [{ + opId: "op_01_keil_job_status", + op: "cmd.run", + args: { + hwpodId, + workspacePath: dirnameForCommandPath(keilCliPath), + command: jobStatusCommand.command, + argv: jobStatusCommand.argv + } + }] + }; + const response = await context.fetchImpl(`${run.apiUrl}/v1/hwpod-node-ops`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(plan) + }); + const textBody = await response.text(); + return { status: response.status, body: parseJsonMaybe(textBody) ?? { raw: textBody }, plan }; +} + +export function keilJobStatus(body: any) { + const parsed = parseJsonMaybe(hwpodResultTexts(body)[0]) ?? body; + const status = text(parsed?.status ?? parsed?.data?.status ?? parsed?.job?.status) || "unknown"; + const returnCode = numberOption(parsed?.return_code ?? parsed?.returnCode ?? parsed?.data?.return_code); + const success = parsed?.success === true || parsed?.ok === true || returnCode === 0; + const failed = parsed?.success === false || parsed?.ok === false || (returnCode !== undefined && returnCode !== 0) || ["failed", "error", "cancelled"].includes(status); + const terminal = success || failed || ["completed", "succeeded"].includes(status); + const artifacts = artifactsFrom(parsed); + const warningCount = firstNumberOption(parsed?.warningCount, parsed?.warning_count, parsed?.data?.warningCount, parsed?.data?.warning_count, parsed?.summary?.warningCount, parsed?.summary?.warning_count); + return { terminal, ok: success || ["completed", "succeeded"].includes(status), summary: clean({ status, returnCode, warningCount, success, artifacts, raw: compactObject(parsed) }) }; +} From 8a84e6d6a9063e4e0bd28a4b300b62466445f93a Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 14:35:52 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20CaseRun=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=AE=A1=E6=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/src/hwlab-caserun-closeout.ts | 90 +++++++++++++++++++++++------ tools/src/hwlab-caserun-evidence.ts | 47 ++++++++++++--- tools/src/hwlab-caserun-shared.ts | 1 + tools/src/hwlab-caserun-subject.ts | 30 ++++++---- 4 files changed, 134 insertions(+), 34 deletions(-) diff --git a/tools/src/hwlab-caserun-closeout.ts b/tools/src/hwlab-caserun-closeout.ts index 2398b955..e214749b 100644 --- a/tools/src/hwlab-caserun-closeout.ts +++ b/tools/src/hwlab-caserun-closeout.ts @@ -5,24 +5,80 @@ import { createHash } from "node:crypto"; 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 { agentTerminalEvidence, commandsFromToolCallSummary, hwpodCommandKind, mentionsHwpod, summarizeAgentTrace } from "./hwlab-caserun-evidence.ts"; +import { + agentTerminalEvidence, + arrayOfObjects, + clean, + clipText, + commandsFromToolCallSummary, + compactObject, + hwpodCommandKind, + mentionsHwpod, + numberOption, + parseJsonMaybe, + summarizeAgentTrace, + text, + uniqueStrings, +} from "./hwlab-caserun-evidence.ts"; import type { ArchivedAgentTraceSnapshot, AgentTraceCommand, AgentTraceStage, CaseContext, CaseReadableAgentArchive, CaseRegistryArchive, CaseRegistryArchiveFile, PreparedCaseRun } from "./hwlab-caserun-shared.ts"; -const CASE_TRACE_RENDERER = "tools/src/hwlab-cli/trace-renderer:traceDisplayRows"; const MAX_AGENT_TRACE_COMMANDS = 30; const HWLAB_API_KEY_PREFIX = "hwl_live_"; -function text(value: unknown) { return String(value ?? "").trim(); } -function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; } -function clean(value: any) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)); } -function compactObject(value: any) { const json = JSON.stringify(value ?? null); return json.length <= 4000 ? value : { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) }; } -function sha256Buffer(value: Buffer) { return createHash("sha256").update(value).digest("hex"); } -function parseJsonMaybe(value: unknown) { if (value && typeof value === "object") return value as any; try { return JSON.parse(String(value ?? "").trim()); } catch { return null; } } -function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw Object.assign(new Error(`${field} is required`), { code: "missing_required_value", details: { field } }); return result; } -function clipText(value: unknown, maxBytes = 2000) { const raw = String(value ?? ""); const buffer = Buffer.from(raw, "utf8"); return buffer.length <= maxBytes ? raw : `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`; } -function arrayOfObjects(value: unknown): any[] { return Array.isArray(value) ? value.filter((item) => item && typeof item === "object" && !Array.isArray(item)) : []; } -function uniqueStrings(values: string[]) { return Array.from(new Set(values.map(text).filter(Boolean))); } -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 ""; } } -async function fileExists(file: string) { try { await stat(file); return true; } catch { return false; } } -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 runProcess(command: string[], cwd: string, env: Record) { const proc = Bun.spawn(command, { cwd, env, stdout: "pipe", stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); return { command, stdout, stderr, exitCode }; } +const CASE_TRACE_RENDERER = "tools/src/hwlab-cli/trace-renderer:traceDisplayRows"; +const MAX_AGENT_TRACE_COMMANDS = 30; +const HWLAB_API_KEY_PREFIX = "hwl_live_"; + +function sha256Buffer(value: Buffer) { + return createHash("sha256").update(value).digest("hex"); +} + +function requiredText(value: unknown, field: string) { + const result = text(value); + if (!result) { + throw Object.assign(new Error(`${field} is required`), { + code: "missing_required_value", + details: { field }, + }); + } + return result; +} + +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 ""; + } +} + +async function fileExists(file: string) { + try { + await stat(file); + return true; + } catch { + return false; + } +} + +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 runProcess(command: string[], cwd: string, env: Record) { + const proc = Bun.spawn(command, { cwd, env, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { command, stdout, stderr, exitCode }; +} export function agentTraceLookupForRun(run: any, evidence?: any) { const agent = run.agent ?? evidence?.agent; const traceId = text(agent?.traceId ?? evidence?.agentTrace?.traceId ?? run.agentTrace?.traceId); diff --git a/tools/src/hwlab-caserun-evidence.ts b/tools/src/hwlab-caserun-evidence.ts index 38c292c9..c79a62f1 100644 --- a/tools/src/hwlab-caserun-evidence.ts +++ b/tools/src/hwlab-caserun-evidence.ts @@ -112,11 +112,44 @@ export function firstTextOption(...values: any[]) { return values.map(text).find export function firstNumberOption(...values: any[]) { for (const value of values) { const number = numberOption(value); if (number !== undefined) return number; } return undefined; } function countedRecord(value: any, fallbacks: Array) { if (value && typeof value === "object" && !Array.isArray(value)) return Object.fromEntries(Object.entries(value).map(([key, count]) => [key, Number(count) || 0])); return fallbacks.filter(Boolean).reduce>((result, key) => ({ ...result, [key as string]: (result[key as string] ?? 0) + 1 }), {}); } function sumRecordValues(value: Record) { return Object.values(value).reduce((sum, count) => sum + count, 0); } -function compactObject(value: any) { const json = JSON.stringify(value ?? null); return json.length <= 4000 ? value : { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) }; } -function clipText(value: unknown, maxBytes: number) { const raw = String(value ?? ""); const buffer = Buffer.from(raw, "utf8"); return buffer.length <= maxBytes ? raw : `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`; } -function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; } -function text(value: unknown) { return String(value ?? "").trim(); } -function clean>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T; } +export function compactObject(value: any) { + const json = JSON.stringify(value ?? null); + return json.length <= 4000 + ? value + : { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) }; +} + +export function clipText(value: unknown, maxBytes = 2000) { + const raw = String(value ?? ""); + const buffer = Buffer.from(raw, "utf8"); + return buffer.length <= maxBytes + ? raw + : `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`; +} + +export function numberOption(value: unknown) { + const parsed = Number.parseInt(String(value ?? ""), 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function parseJsonMaybe(value: unknown) { + if (value && typeof value === "object") return value as any; + try { + return JSON.parse(String(value ?? "").trim()); + } catch { + return null; + } +} + +export function text(value: unknown) { + return String(value ?? "").trim(); +} + +export function clean>(value: T): T { + return Object.fromEntries( + Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null), + ) as T; +} type AgentTraceCommand = { source: string; seq?: number; rowId?: string; header?: string; toolName?: string; status?: string; command?: string; normalizedCommand?: string; bodyPreview?: string; exitCode?: number }; const MAX_AGENT_TRACE_COMMANDS = 30; @@ -231,7 +264,7 @@ function selectRepresentativeHwpodCommands(commands: AgentTraceCommand[]) { }); } -function arrayOfObjects(value: unknown): Record[] { +export function arrayOfObjects(value: unknown): Record[] { return Array.isArray(value) ? value.filter((item) => item && typeof item === "object" && !Array.isArray(item)) as Record[] : []; } @@ -271,6 +304,6 @@ function unquoteShellCommand(value: string) { return value.replace(/^\/bin\/sh\s+-lc\s+/u, "").replace(/^['"]|['"]$/gu, "").trim(); } -function uniqueStrings(values: string[]) { +export function uniqueStrings(values: string[]) { return Array.from(new Set(values.map((value) => text(value)).filter(Boolean))); } diff --git a/tools/src/hwlab-caserun-shared.ts b/tools/src/hwlab-caserun-shared.ts index 18c8d344..dbf4639e 100644 --- a/tools/src/hwlab-caserun-shared.ts +++ b/tools/src/hwlab-caserun-shared.ts @@ -3,6 +3,7 @@ import type { AgentDiffCollectionSummary } from "./hwlab-caserun-diff.ts"; import type { AgentToolCallSummary } from "./hwlab-caserun-evidence.ts"; +import type { TraceEventRow } from "./hwlab-cli/trace-renderer.ts"; export type EnvLike = Record; export type ParsedArgs = Record & { _: string[] }; diff --git a/tools/src/hwlab-caserun-subject.ts b/tools/src/hwlab-caserun-subject.ts index fe0f7d4a..155898ea 100644 --- a/tools/src/hwlab-caserun-subject.ts +++ b/tools/src/hwlab-caserun-subject.ts @@ -5,17 +5,27 @@ import { createHash, randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { readHwpodSpec } from "./hwpod-harness-lib.ts"; -import { firstNumberOption } from "./hwlab-caserun-evidence.ts"; +import { + clean, + clipText, + compactObject, + firstNumberOption, + numberOption, + parseJsonMaybe, + text, +} from "./hwlab-caserun-evidence.ts"; import type { CaseContext, PreparedCaseRun, PreparedSubjectRun, SourceRootSnapshot } from "./hwlab-caserun-shared.ts"; -const DEFAULT_POLL_INTERVAL_MS = 1000; const DEFAULT_JOB_TIMEOUT_MS = 50000; const MAX_JOB_TIMEOUT_MS = 300000; -function text(value: unknown) { return String(value ?? "").trim(); } -function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; } -function clean(value: any) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)); } -function compactObject(value: any) { const json = JSON.stringify(value ?? null); return json.length <= 4000 ? value : { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) }; } -function clipText(value: unknown, maxBytes = 2000) { const raw = String(value ?? ""); const buffer = Buffer.from(raw, "utf8"); return buffer.length <= maxBytes ? raw : `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`; } -function parseJsonMaybe(value: unknown) { if (value && typeof value === "object") return value as any; try { return JSON.parse(String(value ?? "").trim()); } catch { return null; } } -function cliError(code: string, message: string, details: any = {}) { return Object.assign(new Error(message), { code, details }); } -function slug(value: string) { return value.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "") || "case"; } +const DEFAULT_POLL_INTERVAL_MS = 1000; +const DEFAULT_JOB_TIMEOUT_MS = 50000; +const MAX_JOB_TIMEOUT_MS = 300000; + +function cliError(code: string, message: string, details: any = {}) { + return Object.assign(new Error(message), { code, details }); +} + +function slug(value: string) { + return value.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "") || "case"; +} export async function prepareSubjectWorktree(context: CaseContext, input: { caseId: string; runId: string; runDir: string; apiUrl: string; definition: Record; sourceSpecPath: string }, collectSnapshot: (context: CaseContext, run: PreparedCaseRun, label: string) => Promise) { const subject = subjectFromDefinition(input.definition); const worktreePath = subjectWorktreePath(subject.repoLocalPath, input.runId);