Merge pull request #2565 from pikasTech/feat/v03-caserun-untracked-diff
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success

feat: v0.3 CaseRun 收集未跟踪文件差异
This commit is contained in:
Lyon
2026-07-16 15:26:48 +08:00
committed by GitHub
3 changed files with 218 additions and 4 deletions
+24 -1
View File
@@ -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" }]);
+155
View File
@@ -0,0 +1,155 @@
import { createHash } from "node:crypto";
type CommandResult = { stdout?: unknown } & Record<string, unknown>;
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<string, unknown>;
subjectSubdir: string;
worktreePath: string;
statusShort: string;
trackedDiffStat: string;
trackedPatchText: string;
requestGit: (args: string[]) => Promise<CommandResult>;
requestProcess: (command: string, args: string[]) => Promise<CommandResult>;
}) {
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<typeof collectAgentWorkspaceDiff>[0], paths: string[]) {
const records = new Map<string, { bytes?: number; sha256?: string }>();
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<string, unknown>) {
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<string, unknown> { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
function compact<T extends Record<string, unknown>>(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))]; }
+39 -3
View File
@@ -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<SourceRootSnapshot> {
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,