From 8d8b52e0a049a738f64f03f2417fead27b8c562b Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 09:25:54 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B6=E9=9B=86=20CaseRun=20?= =?UTF-8?q?=E6=9C=AA=E8=B7=9F=E8=B8=AA=E6=96=87=E4=BB=B6=E5=B7=AE=E5=BC=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/hwlab-cli/caserun.test.ts | 25 +++++- tools/src/hwlab-caserun-diff.ts | 155 ++++++++++++++++++++++++++++++++ tools/src/hwlab-caserun-lib.ts | 42 ++++++++- 3 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 tools/src/hwlab-caserun-diff.ts diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index 77d50bb2..6f709a91 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -365,6 +365,7 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit title: "D601-F103-V2 Keil compile-only CaseRun", hwpodSpec: "hwpod-spec.yaml", subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline" }, + diffCollection: { ignore: [{ pattern: "build/**", reason: "generated output" }] }, agentTask: { workspace: "subjectWorktree", prompt: "请通过 HWPOD 做一个很小的源码修改。", timeoutMs: 234000, pollIntervalMs: 10 }, }, null, 2), "utf8"); await writeFile(path.join(caseDir, "hwpod-spec.yaml"), hwpodSpecText(), "utf8"); @@ -450,6 +451,12 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit assert.equal(result.payload.agent.polls <= 3, true); assert.equal(result.payload.evidence.hwpod.source, "case-run-runner-post-agent-compile-check"); assert.match(result.payload.agentDiff.statusShort, /projects\/01_baseline\/main\.c/u); + assert.deepEqual(result.payload.agentDiff.diffCollection.untracked, { total: 2, included: 1, omitted: 1 }); + assert.equal(result.payload.agentDiff.diffCollection.included[0].path, "projects/01_baseline/new.c"); + assert.equal(result.payload.agentDiff.diffCollection.omitted[0].path, "projects/01_baseline/build/out.bin"); + assert.equal(result.payload.agentDiff.diffCollection.omitted[0].reason, "generated output"); + assert.match(await readFile(result.payload.agentDiff.diffPatchPath, "utf8"), /diff --git a\/projects\/01_baseline\/new\.c b\/projects\/01_baseline\/new\.c/u); + 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); @@ -495,6 +502,7 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit assert.equal(manifest.prompt.sha256, result.payload.evidence.agent.promptSha256); assert.equal(manifest.subject.commitId, SUBJECT_COMMIT_ID); assert.equal(manifest.diff.sha256, result.payload.evidence.agentDiff.diffPatchSha256); + assert.deepEqual(manifest.diff.collection.untracked, { total: 2, included: 1, omitted: 1 }); assert.equal(manifest.readableAgent.renderer, "tools/src/hwlab-cli/trace-renderer:traceDisplayRows"); assert.equal(manifest.readableAgent.messagesPath, "agent-messages.json"); assert.equal(manifest.readableAgent.tracePath, "agent-trace.md"); @@ -941,8 +949,23 @@ function caseRunFlowFetch(requests: any[]) { resultPolls += 1; return new Response(JSON.stringify({ ok: true, status: resultPolls > 1 ? "completed" : "running", reply: { content: "done" } }), { status: 200, headers: { "content-type": "application/json" } }); } + if (body.ops?.[0]?.args?.command === "node") { + return hwpodResponse([{ exitCode: 0, stdout: [ + JSON.stringify({ path: "projects/01_baseline/new.c", bytes: 21, sha256: "a".repeat(64) }), + JSON.stringify({ path: "projects/01_baseline/build/out.bin", bytes: 4096, sha256: "b".repeat(64) }) + ].join("\n") }]); + } + if (body.ops?.[0]?.args?.argv?.includes("ls-files")) { + return hwpodResponse([{ exitCode: 0, stdout: "projects/01_baseline/new.c\nprojects/01_baseline/build/out.bin\n" }]); + } + if (body.ops?.[0]?.args?.argv?.includes("--no-index") && body.ops[0].args.argv.includes("--stat")) { + return hwpodResponse([{ exitCode: 1, stdout: " projects/01_baseline/new.c | 1 +\n" }]); + } + if (body.ops?.[0]?.args?.argv?.includes("--no-index") && body.ops[0].args.argv.includes("--binary")) { + return hwpodResponse([{ exitCode: 1, stdout: "diff --git a/dev/null b/projects/01_baseline/new.c\nnew file mode 100644\n--- a/dev/null\n+++ b/projects/01_baseline/new.c\n@@ -0,0 +1 @@\n+int added = 1;\n" }]); + } if (body.ops?.[0]?.args?.argv?.includes("status") && body.ops?.length === 1) { - return hwpodResponse([{ exitCode: 0, stdout: body.ops[0].args.argv.includes("--short") ? " M projects/01_baseline/main.c\n" : "" }]); + return hwpodResponse([{ exitCode: 0, stdout: body.ops[0].args.argv.includes("--short") ? " M projects/01_baseline/main.c\n?? projects/01_baseline/new.c\n?? projects/01_baseline/build/out.bin\n" : "" }]); } if (body.ops?.[0]?.args?.argv?.includes("--stat")) { return hwpodResponse([{ exitCode: 0, stdout: " projects/01_baseline/main.c | 1 +\n" }]); diff --git a/tools/src/hwlab-caserun-diff.ts b/tools/src/hwlab-caserun-diff.ts new file mode 100644 index 00000000..53541538 --- /dev/null +++ b/tools/src/hwlab-caserun-diff.ts @@ -0,0 +1,155 @@ +import { createHash } from "node:crypto"; + +type CommandResult = { stdout?: unknown } & Record; + +export type AgentDiffFileRecord = { + path: string; + source: "tracked" | "untracked"; + reason?: string; + rule?: string; + bytes?: number; + sha256?: string; +}; + +type DiffCollectionRule = { pattern: string; reason: string }; + +export type AgentDiffCollectionSummary = { + contractVersion: "hwpod-case-run-diff-collection-v1"; + included: AgentDiffFileRecord[]; + omitted: AgentDiffFileRecord[]; + config: { include: DiffCollectionRule[]; ignore: DiffCollectionRule[] }; + tracked: { paths: string[]; diffStat: string; diffPatchSha256: string }; + untracked: { total: number; included: number; omitted: number }; +}; + +export async function collectAgentWorkspaceDiff(input: { + definition: Record; + subjectSubdir: string; + worktreePath: string; + statusShort: string; + trackedDiffStat: string; + trackedPatchText: string; + requestGit: (args: string[]) => Promise; + requestProcess: (command: string, args: string[]) => Promise; +}) { + const config = diffCollectionConfig(input.definition); + const roots = statusPaths(input.statusShort, "??"); + const requests: Array<{ label: string; result: CommandResult }> = []; + const included: AgentDiffFileRecord[] = []; + const omitted: AgentDiffFileRecord[] = []; + + if (roots.length > 0) { + const lsFiles = await input.requestGit(["ls-files", "--others", "--exclude-standard", "--", ...roots]); + requests.push({ label: "untracked-ls-files", result: lsFiles }); + const paths = unique(text(lsFiles.stdout).split(/\r?\n/u).map(repoPath).filter(Boolean)); + const metadata = await fileMetadata(input, paths); + requests.push(...metadata.requests); + for (const filePath of paths) { + const decision = classify(filePath, input.subjectSubdir, config); + const record = compact({ path: filePath, source: "untracked" as const, reason: decision.reason, rule: decision.rule, ...metadata.records.get(filePath) }); + (decision.omit ? omitted : included).push(record); + } + } + + const patches: string[] = []; + const stats: string[] = []; + for (const file of included) { + const stat = await input.requestGit(["diff", "--no-index", "--stat", "--", "/dev/null", file.path]); + const patch = await input.requestGit(["diff", "--no-index", "--binary", "--", "/dev/null", file.path]); + requests.push({ label: `untracked-diff-stat:${file.path}`, result: stat }, { label: `untracked-diff-patch:${file.path}`, result: patch }); + stats.push(text(stat.stdout)); + patches.push(normalizeNewFilePatch(text(patch.stdout), file.path)); + } + + const collection: AgentDiffCollectionSummary = { + contractVersion: "hwpod-case-run-diff-collection-v1", + included, + omitted, + config, + tracked: { paths: patchPaths(input.trackedPatchText), diffStat: input.trackedDiffStat, diffPatchSha256: sha256(input.trackedPatchText) }, + untracked: { total: included.length + omitted.length, included: included.length, omitted: omitted.length } + }; + return { + patchText: joinPatch(input.trackedPatchText, patches.join("\n"), omitted), + diffStat: joinStat(input.trackedDiffStat, stats.join("\n"), omitted), + collection, + requests + }; +} + +async function fileMetadata(input: Parameters[0], paths: string[]) { + const records = new Map(); + if (paths.length === 0) return { records, requests: [] as Array<{ label: string; result: CommandResult }> }; + const script = [ + "const fs=require('fs');const path=require('path');const crypto=require('crypto');", + "const root=String(process.argv[1]||'');const files=process.argv.slice(2);", + "const abs=p=>/^[A-Za-z]:[\\\\/]/.test(p)||p.startsWith('/')||p.startsWith('\\\\\\\\');", + "const full=p=>abs(p)?p:(root.includes('\\\\')?path.win32.join(root,p):path.join(root,p));", + "for(const rel of files){try{const buf=fs.readFileSync(full(rel));console.log(JSON.stringify({path:rel,bytes:buf.length,sha256:crypto.createHash('sha256').update(buf).digest('hex')}));}catch(error){console.log(JSON.stringify({path:rel,error:String(error&&error.message||error)}));}}" + ].join(""); + const result = await input.requestProcess("node", ["-e", script, input.worktreePath, ...paths]); + for (const line of text(result.stdout).split(/\r?\n/u)) { + try { + const value = JSON.parse(line); + const filePath = repoPath(value.path); + if (filePath) records.set(filePath, compact({ bytes: Number.isFinite(value.bytes) ? value.bytes : undefined, sha256: text(value.sha256) })); + } catch {} + } + return { records, requests: [{ label: "untracked-file-metadata", result }] }; +} + +function diffCollectionConfig(definition: Record) { + const raw = record(definition.diffCollection ?? definition.agentDiffCollection); + return { + include: rules(raw.include ?? raw.includes, "included by CaseRun diff collection rule"), + ignore: rules(raw.ignore ?? raw.ignores ?? raw.omit ?? raw.omitted, "omitted by CaseRun diff collection rule") + }; +} + +function rules(value: unknown, defaultReason: string): DiffCollectionRule[] { + if (Array.isArray(value)) return value.flatMap((item) => rules(item, defaultReason)); + if (typeof value === "string") return repoPath(value) ? [{ pattern: repoPath(value), reason: defaultReason }] : []; + const raw = record(value); + const pattern = text(raw.pattern ?? raw.path ?? raw.glob); + if (pattern) return [{ pattern: repoPath(pattern), reason: text(raw.reason) || defaultReason }]; + return Object.entries(raw).flatMap(([key, item]) => repoPath(key) ? [{ pattern: repoPath(key), reason: typeof item === "string" ? item : text(record(item).reason) || defaultReason }] : []); +} + +function classify(filePath: string, subdir: string, config: { include: DiffCollectionRule[]; ignore: DiffCollectionRule[] }) { + const candidates = unique([filePath, filePath.startsWith(`${repoPath(subdir)}/`) ? filePath.slice(repoPath(subdir).length + 1) : filePath]); + const matches = (rule: DiffCollectionRule) => candidates.some((candidate) => globMatch(rule.pattern, candidate)); + const include = config.include.find(matches); + const ignore = config.ignore.find(matches); + if (ignore && !include) return { omit: true, reason: ignore.reason, rule: ignore.pattern }; + if (include) return { omit: false, reason: include.reason, rule: include.pattern }; + return { omit: false, reason: "included by default because untracked files are part of the agent diff", rule: "default-include-untracked" }; +} + +function globMatch(patternInput: string, pathInput: string) { + const pattern = repoPath(patternInput); + const filePath = repoPath(pathInput); + if (!pattern || !filePath) return false; + if (!/[?*]/u.test(pattern)) return filePath === pattern || filePath.startsWith(`${pattern}/`); + if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) return true; + let source = ""; + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern[index]; + if (char === "*") { source += pattern[index + 1] === "*" ? ".*" : "[^/]*"; if (pattern[index + 1] === "*") index += 1; } + else if (char === "?") source += "[^/]"; + else source += char?.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + } + return new RegExp(`^${source}${pattern.endsWith("/") ? ".*" : ""}$`, "u").test(filePath); +} + +function statusPaths(status: string, marker: string) { return unique(status.split(/\r?\n/u).flatMap((line) => line.startsWith(`${marker} `) ? [repoPath(parseQuoted(line.slice(3)))] : []).filter(Boolean)); } +function parseQuoted(value: string) { const raw = value.trim(); if (!raw.startsWith('"')) return raw; try { return JSON.parse(raw); } catch { return raw.replace(/^"|"$/gu, ""); } } +function patchPaths(value: string) { return unique(text(value).split(/\r?\n/u).flatMap((line) => line.match(/^diff --git a\/(.*?) b\/(.*)$/u)?.[2] ?? []).map(repoPath)); } +function normalizeNewFilePatch(value: string, filePath: string) { const rel = repoPath(filePath); return text(value).replace(/^diff --git a\/dev\/null b\/(.*?)$/mu, `diff --git a/${rel} b/${rel}`).replace(/^--- a\/dev\/null$/mu, "--- /dev/null").replace(/^\+\+\+ b\/(.*?)$/mu, `+++ b/${rel}`); } +function joinPatch(tracked: string, untracked: string, omitted: AgentDiffFileRecord[]) { return [text(tracked), text(untracked), omitted.length ? ["# CaseRun diffCollection omitted files from patch body:", ...omitted.map((file) => `# - ${file.path} rule=${file.rule ?? ""} reason=${file.reason ?? ""} bytes=${file.bytes ?? ""} sha256=${file.sha256 ?? ""}`)].join("\n") : ""].filter(Boolean).join("\n"); } +function joinStat(tracked: string, untracked: string, omitted: AgentDiffFileRecord[]) { return [text(tracked), text(untracked), omitted.length ? ["[omitted by CaseRun diffCollection]", ...omitted.map((file) => ` ${file.path} | omitted rule=${file.rule ?? ""} reason=${file.reason ?? ""} bytes=${file.bytes ?? ""} sha256=${file.sha256 ?? ""}`)].join("\n") : ""].filter(Boolean).join("\n"); } +function sha256(value: string) { return createHash("sha256").update(value).digest("hex"); } +function repoPath(value: unknown) { return text(value).replace(/\\/gu, "/").replace(/^\.\//u, "").replace(/^\/+|\/+$/gu, ""); } +function text(value: unknown) { return typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim(); } +function record(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } +function compact>(value: T) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "")) as T; } +function unique(values: string[]) { return [...new Set(values.filter(Boolean))]; } diff --git a/tools/src/hwlab-caserun-lib.ts b/tools/src/hwlab-caserun-lib.ts index 85dd13ef..06b191a8 100644 --- a/tools/src/hwlab-caserun-lib.ts +++ b/tools/src/hwlab-caserun-lib.ts @@ -8,6 +8,7 @@ import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/pro import path from "node:path"; import { renderTraceRowsMarkdown, traceDisplayRows, traceNoiseEventCount, type TraceEventRow } from "./hwlab-cli/trace-renderer.ts"; +import { collectAgentWorkspaceDiff, type AgentDiffCollectionSummary } from "./hwlab-caserun-diff.ts"; import { readHwpodSpec } from "./hwpod-harness-lib.ts"; const REMOVED_V02_API_URL = "http://74.48.78.17:19667"; @@ -131,6 +132,7 @@ type AgentDiffStage = { diffStat: string; diffPatchPath: string; diffPatchSha256: string; + diffCollection?: AgentDiffCollectionSummary; sourceRootStatusShort: string; sourceRootBaseline?: SourceRootSnapshot; sourceRootAfterPrepare?: SourceRootSnapshot; @@ -1047,16 +1049,27 @@ async function collectAgentDiff(context: CaseContext, run: PreparedCaseRun) { const status = await requestSubjectCommand(context, run, ["-C", run.subject.worktreePath, "status", "--short"]); const diffStat = await requestSubjectCommand(context, run, ["-C", run.subject.worktreePath, "diff", "--stat"]); const diffPatch = await requestSubjectCommand(context, run, ["-C", run.subject.worktreePath, "diff", "--binary"]); + const collected = await collectAgentWorkspaceDiff({ + definition: run.definition, + subjectSubdir: run.subject.subdir, + worktreePath: run.subject.worktreePath, + statusShort: text(status.stdout), + trackedDiffStat: text(diffStat.stdout), + trackedPatchText: text(diffPatch.stdout), + requestGit: (args) => requestSubjectCommand(context, run, ["-C", run.subject.worktreePath, ...args]), + requestProcess: (command, args) => requestSubjectProcess(context, run, command, args) + }); const sourceAfterAgent = await collectSourceRootSnapshot(context, run, "after-agent"); const statusShort = text(status.stdout); - const patchText = text(diffPatch.stdout); + const patchText = collected.patchText; const diffPatchPath = path.join(run.runDir, "agent-diff.patch"); await writeFile(diffPatchPath, patchText, "utf8"); const diff: AgentDiffStage = { statusShort, - diffStat: text(diffStat.stdout), + diffStat: collected.diffStat, diffPatchPath, diffPatchSha256: sha256(patchText), + diffCollection: collected.collection, sourceRootStatusShort: sourceAfterAgent.statusShort, sourceRootBaseline: run.subject.sourceRootBaseline, sourceRootAfterPrepare: run.subject.sourceRootAfterPrepare, @@ -1067,6 +1080,7 @@ async function collectAgentDiff(context: CaseContext, run: PreparedCaseRun) { requestSummary("status", status), requestSummary("diff-stat", diffStat), requestSummary("diff-patch", diffPatch), + ...collected.requests.map((request) => requestSummary(request.label, request.result)), { label: "source-root-baseline", statusShort: run.subject.sourceRootBaseline?.statusShort ?? "", diffSha256: run.subject.sourceRootBaseline?.diffSha256 ?? "" }, { label: "source-root-after-prepare", statusShort: run.subject.sourceRootAfterPrepare?.statusShort ?? "", diffSha256: run.subject.sourceRootAfterPrepare?.diffSha256 ?? "" }, { label: "source-root-after-agent", statusShort: sourceAfterAgent.statusShort, diffSha256: sourceAfterAgent.diffSha256 } @@ -1076,6 +1090,26 @@ async function collectAgentDiff(context: CaseContext, run: PreparedCaseRun) { return { run: nextRun, diff }; } +async function requestSubjectProcess(context: CaseContext, run: PreparedCaseRun, command: string, argv: string[]) { + const document = await readHwpodSpec(run.specPath); + const hwpodId = text(document.metadata.name) || text(document.metadata.uid) || run.caseId; + const plan = { + contractVersion: "hwpod-node-ops-v1", + planId: `case_subject_cmd_${randomUUID()}`, + hwpodId, + nodeId: document.spec.nodeBinding.nodeId, + intent: "cmd.run", + source: { compiler: "hwlab-cli.case", specPath: run.specPath, specAuthority: "case-run-state" }, + ops: [{ opId: "op_01_subject_cmd", op: "cmd.run", args: { hwpodId, workspacePath: run.subject.repoLocalPath, command, argv } }] + }; + const response = await context.fetchImpl(`${run.apiUrl}/v1/hwpod-node-ops`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(plan) }); + const textBody = await response.text(); + const body = parseJsonMaybe(textBody) ?? { raw: textBody }; + const result = firstHwpodOutput(body); + const exitCode = numberOption(result?.exitCode) ?? (response.status >= 200 && response.status < 300 ? 0 : 1); + return { ok: response.status >= 200 && response.status < 300 && exitCode === 0, httpStatus: response.status, exitCode, stdout: text(result?.stdout), stderr: text(result?.stderr), body: compactObject(body), command: [command, ...argv] }; +} + async function collectSourceRootSnapshot(context: CaseContext, run: PreparedCaseRun, label: string): Promise { const status = await requestSubjectCommand(context, run, ["status", "--short"]); const diffStat = await requestSubjectCommand(context, run, ["diff", "--stat"]); @@ -1430,6 +1464,7 @@ function diffSummary(diff: AgentDiffStage) { diffStat: diff.diffStat, diffPatchPath: diff.diffPatchPath, diffPatchSha256: diff.diffPatchSha256, + diffCollection: diff.diffCollection, sourceRootStatusShort: diff.sourceRootStatusShort, sourceRootBaseline: diff.sourceRootBaseline, sourceRootAfterPrepare: diff.sourceRootAfterPrepare, @@ -2231,7 +2266,7 @@ async function writeReadableAgentArtifacts(caseRepoRunDir: string, run: Prepared 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, patchIncludedInTranscript: true }) + 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); @@ -2520,6 +2555,7 @@ function caseRunArtifactManifest(context: CaseContext, run: PreparedCaseRun, evi 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,