feat: 收口 CaseRun 证据与终态模型
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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<string, string | undefined>) { const proc = Bun.spawn(command, { cwd, env, stdout: "pipe", stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); return { command, stdout, stderr, exitCode }; }
|
||||
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<string, any> = {};
|
||||
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<string, unknown>): 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 [`<details>`, `<summary>${escapeHtml(summary)}</summary>`, ``, aggregateFence(language, body), ``, `</details>`].join("\n");
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string) {
|
||||
return String(value).replace(/&/gu, "&").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<CaseRegistryArchive> {
|
||||
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<string, string | undefined>([
|
||||
["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<CaseReadableAgentArchive> {
|
||||
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<ArchivedAgentTraceSnapshot> {
|
||||
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<string, unknown>[]) {
|
||||
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<string, unknown>): 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<CaseRegistryArchiveFile> {
|
||||
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;
|
||||
}
|
||||
@@ -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<string, number>;
|
||||
exitCodeCounts: Record<string, number>;
|
||||
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<string, unknown>, 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<string, any> { return values.find((value) => value && typeof value === "object" && !Array.isArray(value)) ?? {}; }
|
||||
function firstArray(...values: any[]): Record<string, unknown>[] { 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<string | undefined>) { 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<Record<string, number>>((result, key) => ({ ...result, [key as string]: (result[key as string] ?? 0) + 1 }), {}); }
|
||||
function sumRecordValues(value: Record<string, number>) { 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<T extends Record<string, unknown>>(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<string, unknown>[]) {
|
||||
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<string, unknown>[]) {
|
||||
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<string, unknown>, 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<string, unknown>, 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<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter((item) => item && typeof item === "object" && !Array.isArray(item)) as Record<string, unknown>[] : [];
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
+3
-2910
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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<string, string | undefined>;
|
||||
export type ParsedArgs = Record<string, unknown> & { _: string[] };
|
||||
export type FetchLike = typeof fetch;
|
||||
export type RunProcessLike = (command: string[], cwd: string, env: Record<string, string | undefined>) => Promise<{ command: string[]; stdout: string; stderr: string; exitCode: number }>;
|
||||
export type StartProcessLike = (input: { command: string; args: string[]; cwd: string; env: Record<string, string | undefined>; 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<void>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
sourceRootBaseline?: SourceRootSnapshot;
|
||||
sourceRootAfterPrepare?: SourceRootSnapshot;
|
||||
};
|
||||
|
||||
export type SourceRootSnapshot = {
|
||||
label: string;
|
||||
statusShort: string;
|
||||
diffStat: string;
|
||||
diffSha256: string;
|
||||
diffBytes: number;
|
||||
commands?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown> | null;
|
||||
result: Record<string, unknown> | null;
|
||||
terminalStatus?: string;
|
||||
commandStatus?: string;
|
||||
agentRunStatus?: string;
|
||||
requestedProviderProfile?: string;
|
||||
resolvedBackendProfile?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
backend?: string;
|
||||
infrastructureBackend?: string;
|
||||
providerTrace?: Record<string, unknown> | 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<string, unknown>;
|
||||
sessionResponse: Record<string, unknown> | null;
|
||||
error?: Record<string, unknown> | 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<string, unknown>[];
|
||||
};
|
||||
|
||||
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<string, unknown>;
|
||||
sourceEventCount?: number;
|
||||
renderedRowCount?: number;
|
||||
commandCount: number;
|
||||
hwpodCommandCount: number;
|
||||
hwpodBuildCommandCount: number;
|
||||
keilJobCandidates: string[];
|
||||
terminalStatus?: string;
|
||||
agentRun?: Record<string, unknown> | null;
|
||||
toolCallSummary?: AgentToolCallSummary | null;
|
||||
commands: AgentTraceCommand[];
|
||||
error?: Record<string, unknown> | 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<string, unknown>;
|
||||
registrySync?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type CaseReadableAgentArchive = {
|
||||
messagesRel: string;
|
||||
traceRel: string;
|
||||
transcriptRel: string;
|
||||
finalResponseRel: string;
|
||||
generatedFiles: string[];
|
||||
traceRender: Record<string, unknown>;
|
||||
finalResponse: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ArchivedAgentTraceSnapshot = {
|
||||
traceBody: any;
|
||||
rows: TraceEventRow[];
|
||||
events: Record<string, unknown>[];
|
||||
summary: AgentTraceStage;
|
||||
};
|
||||
@@ -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<string, unknown>; sourceSpecPath: string }, collectSnapshot: (context: CaseContext, run: PreparedCaseRun, label: string) => Promise<SourceRootSnapshot>) {
|
||||
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<string, unknown>; 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<string, unknown>) {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>), line });
|
||||
return clean({ copied: numberOption(line.match(/copied=(\d+)/u)?.[1]), line });
|
||||
}
|
||||
|
||||
export function objectRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
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<string, unknown>) {
|
||||
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) }) };
|
||||
}
|
||||
Reference in New Issue
Block a user