Merge pull request #2577 from pikasTech/feat/2204-caserun-offline-diagnostics
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success

feat: 实现 CaseRun Registry 离线诊断
This commit is contained in:
Lyon
2026-07-16 22:39:47 +08:00
committed by GitHub
4 changed files with 478 additions and 9 deletions
@@ -0,0 +1,191 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { test } from "bun:test";
import { runHwlabCli } from "../src/hwlab-cli-lib.ts";
test("case aggregate remains registry-only even when runtime credentials exist", async () => {
const fixture = await createRegistryFixture("aggregate-offline");
try {
let runtimeCalls = 0;
const result = await runHwlabCli(["case", "aggregate", fixture.caseId, "--run-id", fixture.runId, "--case-repo", fixture.caseRepo, "--no-case-repo-git-sync"], {
cwd: fixture.root,
env: { HWLAB_API_KEY: "hwl_live_should_not_be_used" },
runProcess: async () => {
runtimeCalls += 1;
throw new Error("offline aggregate must not execute runtime commands");
},
});
assert.equal(result.exitCode, 0, JSON.stringify(result.payload, null, 2));
assert.equal(runtimeCalls, 0);
assert.equal(result.payload.action, "case.aggregate");
assert.match(await readFile(path.join(fixture.runDir, "aggregate.md"), "utf8"), /registry artifacts/u);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("case refresh is explicit, registry-backed, and semantically idempotent", async () => {
const fixture = await createRegistryFixture("refresh-local");
try {
const args = ["case", "refresh", fixture.caseId, "--run-id", fixture.runId, "--case-repo", fixture.caseRepo, "--source", "local", "--no-case-repo-git-sync"];
const first = await runHwlabCli(args, { cwd: fixture.root, now: () => "2026-07-16T14:00:00.000Z", env: { HWLAB_API_KEY: "hwl_live_should_not_be_used" }, runProcess: forbiddenRuntime });
assert.equal(first.exitCode, 0, JSON.stringify(first.payload, null, 2));
assert.deepEqual(first.payload.refreshed.provenance, { source: "local", authority: "registry", runtimeAccess: false });
assert.equal(first.payload.refreshed.commandCount, 2);
assert.equal(first.payload.refreshed.hwpodCommandCount, 1);
assert.equal(first.payload.refreshed.hwpodBuildCommandCount, 1);
const before = await fileHashes(fixture.runDir, ["evidence.json", "agent-messages.json", "artifact-manifest.json", "aggregate.md"]);
const second = await runHwlabCli(args, { cwd: fixture.root, now: () => "2026-07-16T15:00:00.000Z", env: { HWLAB_API_KEY: "hwl_live_should_not_be_used" }, runProcess: forbiddenRuntime });
assert.equal(second.exitCode, 0, JSON.stringify(second.payload, null, 2));
assert.deepEqual(await fileHashes(fixture.runDir, Object.keys(before)), before);
const manifest = JSON.parse(await readFile(path.join(fixture.runDir, "artifact-manifest.json"), "utf8"));
assert.deepEqual(manifest.refresh, { source: "local", authority: "registry", runtimeAccess: false });
assert.equal(manifest.trace.toolCallSummary.count, 2);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("case run offline diagnostics inspect trace and skill usage without runtime access", async () => {
const fixture = await createRegistryFixture("inspect-offline");
try {
const options = { cwd: fixture.root, env: { HWLAB_API_KEY: "hwl_live_should_not_be_used" }, runProcess: forbiddenRuntime };
const inspect = await runHwlabCli(["case", "run", "inspect", fixture.runId, "--case-repo", fixture.caseRepo], options);
assert.equal(inspect.exitCode, 0, JSON.stringify(inspect.payload, null, 2));
assert.equal(inspect.payload.serviceRuntime, false);
assert.equal(inspect.payload.traceId, "trc_offline");
assert.equal(inspect.payload.providerProfile, "deepseek");
assert.equal(inspect.payload.terminalStatus, "completed");
assert.equal(inspect.payload.toolCallSummary.count, 2);
const trace = await runHwlabCli(["case", "run", "trace-summary", fixture.runId, "--case-repo", fixture.caseRepo], options);
assert.equal(trace.exitCode, 0, JSON.stringify(trace.payload, null, 2));
assert.equal(trace.payload.commandCount, 2);
assert.equal(trace.payload.traceCommands.length, 2);
const skill = await runHwlabCli(["case", "run", "skill-usage", fixture.runId, "--skill", "keil", "--case-repo", fixture.caseRepo], options);
assert.equal(skill.exitCode, 0, JSON.stringify(skill.payload, null, 2));
assert.equal(skill.payload.serviceRuntime, false);
assert.equal(skill.payload.trace.readSkillFile, true);
assert.equal(skill.payload.trace.matchCount, 1);
assert.equal(skill.payload.resourceBundle.materialized, true);
assert.equal(skill.payload.resourceBundle.evidence[0].source, "artifact-manifest.json");
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("case run skill usage keeps prompt mentions separate from materialized bundles", async () => {
const fixture = await createRegistryFixture("prompt-only-skill");
try {
await writeJson(path.join(fixture.runDir, "agent-messages.json"), { rows: [{ rowId: "tool:build", seq: 1, header: "工具调用", body: "hwpod build --spec .hwlab/hwpod-spec.yaml" }] });
const manifest = JSON.parse(await readFile(path.join(fixture.runDir, "artifact-manifest.json"), "utf8"));
delete manifest.resourceBundle;
await writeJson(path.join(fixture.runDir, "artifact-manifest.json"), manifest);
const result = await runHwlabCli(["case", "run", "skill-usage", fixture.runId, "--skill", "keil", "--case-repo", fixture.caseRepo], { cwd: fixture.root, runProcess: forbiddenRuntime });
assert.equal(result.exitCode, 0, JSON.stringify(result.payload, null, 2));
assert.equal(result.payload.prompt.mentionsSkill, true);
assert.equal(result.payload.trace.readSkillFile, false);
assert.equal(result.payload.resourceBundle.materialized, false);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("case run inspect preserves missing authoritative agent final", async () => {
const fixture = await createRegistryFixture("missing-agent-final");
try {
const reason = "finalResponse=null; provider returned no final assistant response";
const evidence = JSON.parse(await readFile(path.join(fixture.runDir, "evidence.json"), "utf8"));
evidence.agentFinal = { present: false, reason, status: "blocked" };
await writeJson(path.join(fixture.runDir, "evidence.json"), evidence);
const manifest = JSON.parse(await readFile(path.join(fixture.runDir, "artifact-manifest.json"), "utf8"));
manifest.agentFinal = { present: false, reason, status: "blocked" };
await writeJson(path.join(fixture.runDir, "artifact-manifest.json"), manifest);
await writeFile(path.join(fixture.runDir, "final-response.md"), `# CaseRun Final Response\n\n${reason}\n`, "utf8");
const result = await runHwlabCli(["case", "run", "inspect", fixture.runId, "--case-repo", fixture.caseRepo], { cwd: fixture.root, runProcess: forbiddenRuntime });
assert.equal(result.exitCode, 0, JSON.stringify(result.payload, null, 2));
assert.equal(result.payload.agentFinal.present, false);
assert.equal(result.payload.finalResponse.present, false);
assert.equal(result.payload.finalResponse.reason, reason);
assert.match(result.payload.finalResponse.textPreview, /provider returned no final assistant response/u);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("case run skill usage scans all rows before bounding returned matches", async () => {
const fixture = await createRegistryFixture("late-skill-read");
try {
const rows = [
...Array.from({ length: 30 }, (_, index) => ({ rowId: `tool:other:${index + 1}`, seq: index + 1, header: "工具调用", body: "echo unrelated" })),
...Array.from({ length: 35 }, (_, index) => ({ rowId: `tool:skill:${index + 31}`, seq: index + 31, header: "工具调用", body: "cat ~/.agents/skills/keil/SKILL.md" })),
];
await writeJson(path.join(fixture.runDir, "agent-messages.json"), { sourceEventCount: rows.length, renderedRowCount: rows.length, rows });
const result = await runHwlabCli(["case", "run", "skill-usage", fixture.runId, "--skill", "keil", "--case-repo", fixture.caseRepo], { cwd: fixture.root, runProcess: forbiddenRuntime });
assert.equal(result.exitCode, 0, JSON.stringify(result.payload, null, 2));
assert.equal(result.payload.trace.readSkillFile, true);
assert.equal(result.payload.trace.matchCount, 35);
assert.equal(result.payload.trace.matches.length, 30);
assert.equal(result.payload.trace.matches[0].seq, 31);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
test("case refresh accesses runtime only with explicit runtime source", async () => {
const fixture = await createRegistryFixture("refresh-runtime");
try {
let runtimeCalls = 0;
const result = await runHwlabCli(["case", "refresh", fixture.caseId, "--run-id", fixture.runId, "--case-repo", fixture.caseRepo, "--source", "runtime", "--no-case-repo-git-sync"], {
cwd: fixture.root,
env: { HWLAB_API_KEY: "hwl_live_runtime_test" },
runProcess: async () => {
runtimeCalls += 1;
return { command: [], exitCode: 0, stderr: "", stdout: JSON.stringify({ body: { traceId: "trc_offline", source: "hwlab-cli.client.agent.trace", status: "completed", terminalStatus: "completed", rows: [{ rowId: "tool:runtime", seq: 1, header: "工具调用", body: "hwpod build --spec .hwlab/hwpod-spec.yaml" }, { rowId: "final", seq: 2, header: "助手最终消息", terminal: true, body: "runtime refreshed" }] } }) };
},
});
assert.equal(result.exitCode, 0, JSON.stringify(result.payload, null, 2));
assert.equal(runtimeCalls, 1);
assert.deepEqual(result.payload.refreshed.provenance, { source: "runtime", authority: "registry", runtimeAccess: true });
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
async function createRegistryFixture(suffix: string) {
const root = await mkdtemp(path.join(os.tmpdir(), `hwlab-caserun-${suffix}-`));
const caseRepo = path.join(root, "hwlab-case-registry");
const caseId = `case-${suffix}`;
const runId = `run-${suffix}`;
const runDir = path.join(caseRepo, "runs", caseId, runId);
await mkdir(path.join(caseRepo, ".git"), { recursive: true });
await writeFile(path.join(caseRepo, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");
await mkdir(path.join(runDir, ".hwlab"), { recursive: true });
const agent = { stageStatus: "completed", baseUrl: "http://web.test", traceId: "trc_offline", sessionId: "ses_offline", conversationId: "cnv_offline", threadId: "thr_offline", providerProfile: "deepseek", requestedProviderProfile: "deepseek", resolvedBackendProfile: "deepseek-runtime", provider: "deepseek", model: "deepseek-chat", terminalStatus: "completed", commandStatus: "completed", agentRunStatus: "completed", promptSha256: "prompt-sha" };
const toolCallSummary = { source: "agentrun-result.toolCallSummary", count: 2, statusCounts: { completed: 2 }, exitCodeCounts: { "0": 2 }, terminalStatus: "completed", commandStatus: "completed", agentRunStatus: "completed", items: [{ kind: "build", toolName: "commandExecution", status: "completed", exitCode: 0, command: "hwpod build --spec .hwlab/hwpod-spec.yaml" }, { kind: "other", toolName: "commandExecution", status: "completed", exitCode: 0, command: "cat ~/.agents/skills/keil/SKILL.md" }] };
await writeJson(path.join(runDir, "run.json"), { caseId, runId, runDir, caseRepo, specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"), compileOnly: true, createdAt: "2026-07-16T13:00:00.000Z", updatedAt: "2026-07-16T13:00:00.000Z", definition: {}, subject: { repoLocalPath: "C:/work/fw", commitId: "0".repeat(40), subdir: "", worktreePath: "C:/work/fw/.worktree/case", workspacePath: "C:/work/fw/.worktree/case", setup: {} }, agent });
await writeJson(path.join(runDir, "evidence.json"), { contractVersion: "hwpod-case-run-evidence-v1", caseId, runId, compileOnly: true, autoEvaluation: false, status: "recorded", subject: { repoLocalPath: "C:/work/fw", commitId: "0".repeat(40), worktreePath: "C:/work/fw/.worktree/case" }, agent, agentTrace: { traceId: "trc_offline", terminalStatus: "completed", toolCallSummary, commandCount: 2, hwpodCommandCount: 1, hwpodBuildCommandCount: 1 }, agentFinal: { present: true, status: "completed", terminalStatus: "completed" }, postValidation: { source: "case-run-runner-post-agent-compile-check", status: "completed", returnCode: 0 }, decisions: { autoEvaluation: false, runnerPostAgentCompileCheck: "recorded" } });
await writeJson(path.join(runDir, "agent-trace.json"), { contractVersion: "hwpod-case-run-agent-trace-identity-v1", traceId: "trc_offline", status: "completed", terminalStatus: "completed", commandStatus: "completed", toolCallSummary });
await writeJson(path.join(runDir, "agent-messages.json"), { renderer: "tools/src/hwlab-cli/trace-renderer:traceDisplayRows", sourceEventCount: 2, renderedRowCount: 2, rows: [{ rowId: "tool:build", seq: 1, header: "工具调用", body: "hwpod build --spec .hwlab/hwpod-spec.yaml" }, { rowId: "tool:skill", seq: 2, header: "工具调用", body: "cat ~/.agents/skills/keil/SKILL.md" }] });
await writeJson(path.join(runDir, "artifact-manifest.json"), { contractVersion: "hwpod-case-run-artifact-manifest-v1", caseId, runId, agent, trace: { traceId: "trc_offline", terminalStatus: "completed", toolCallSummary, commandCount: 2, hwpodCommandCount: 1, hwpodBuildCommandCount: 1 }, resourceBundle: { materialized: true, skills: [{ name: "keil", manifest: ".agents/skills/keil/SKILL.md" }] }, agentFinal: { present: true, status: "completed" }, postValidation: { status: "completed", returnCode: 0 }, files: [] });
await writeFile(path.join(runDir, "agent-prompt.md"), "请读取 keil skill 后执行编译。\n", "utf8");
await writeFile(path.join(runDir, "final-response.md"), "# CaseRun Final Response\n\ncompleted\n", "utf8");
await writeFile(path.join(runDir, "agent-diff.patch"), "", "utf8");
await writeFile(path.join(runDir, ".hwlab", "hwpod-spec.yaml"), "metadata:\n name: offline\n", "utf8");
return { root, caseRepo, caseId, runId, runDir };
}
async function writeJson(file: string, value: unknown) { await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); }
async function fileHashes(root: string, files: string[]) { return Object.fromEntries(await Promise.all(files.map(async (file) => [file, createHash("sha256").update(await readFile(path.join(root, file))).digest("hex")]))); }
async function forbiddenRuntime() { throw new Error("offline diagnostics must not execute runtime commands"); }
+18 -6
View File
@@ -204,7 +204,7 @@ export async function renderCaseAggregateMarkdown(context: CaseContext, input: {
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 messages = archivedMessages;
const traceBody = aggregateTraceBody(messages, traceMarkdown);
return `${[
`# HWPOD CaseRun Aggregate: ${input.caseId}`,
@@ -391,7 +391,7 @@ export function aggregateFence(language: string, body: string) {
}
export function aggregateArtifactIndex(manifest: any) {
const files = arrayOfObjects(manifest?.files);
const files = arrayOfObjects(manifest?.files).filter((file) => text(file.path) !== "aggregate.md");
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");
}
@@ -534,7 +534,7 @@ export async function archivedAgentTraceSnapshot(context: CaseContext, run: Prep
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 hasRenderableTrace = rows.length > 0 || events.length > 0 || Boolean(bodyForSummary?.toolCallSummary);
const summary = hasRenderableTrace
? summarizeAgentTrace(text(bodyForSummary?.traceId ?? traceBody?.traceId ?? evidence.agent?.traceId ?? run.agent?.traceId), numberOption(traceBody?.httpStatus) ?? 0, traceBody)
: existingAgentTraceSummary(run, evidence, bodyForSummary);
@@ -558,20 +558,28 @@ export async function archivedAgentTraceBody(run: PreparedCaseRun, evidence: any
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;
const forceRuntimeRefresh = context.parsed.caseRefreshSource === "runtime";
if (existingEvents.length > 0 && !forceRuntimeRefresh) 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;
if (!traceId || !lookup || !apiKey.startsWith(HWLAB_API_KEY_PREFIX)) {
if (forceRuntimeRefresh) throw cliError("case_refresh_runtime_unavailable", "explicit runtime refresh requires trace identity, runtime base URL, and HWLAB_API_KEY", { traceId, hasLookup: Boolean(lookup), hasApiKey: 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) {
if (forceRuntimeRefresh) throw cliError("case_refresh_trace_fetch_failed", "explicit runtime refresh failed through the existing trace CLI", { traceId, exitCode: result.exitCode, stderr: clipText(result.stderr, 1200) });
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) } });
if (!parsed) {
if (forceRuntimeRefresh) throw cliError("case_refresh_trace_invalid_json", "explicit runtime refresh returned invalid trace JSON", { traceId, stdout: clipText(result.stdout, 1200) });
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,
@@ -755,6 +763,9 @@ export function caseRunArtifactEntries(run: PreparedCaseRun) {
}
export async function copyCaseRunArtifact(source: string, destination: string) {
if (path.resolve(source) === path.resolve(destination)) {
try { return (await stat(source)).isFile(); } catch { return false; }
}
try {
const info = await stat(source);
if (!info.isFile()) return false;
@@ -842,6 +853,7 @@ export function caseRunArtifactManifest(context: CaseContext, run: PreparedCaseR
runnerPostAgentCompileCheck: "recorded",
reason: "flow-only run: the manifest records artifacts and trace without auto-grading or gate decisions"
},
refresh: evidence.refresh ?? existingManifest?.refresh,
files,
skippedFiles
});
+255
View File
@@ -0,0 +1,255 @@
import { readFile, readdir, stat } from "node:fs/promises";
import path from "node:path";
import {
archiveCaseRunArtifacts,
syncCaseRegistryRel,
writeCaseAggregateFromRegistry,
} from "./hwlab-caserun-closeout.ts";
import { agentToolCallSummaryFromResult, arrayOfObjects, clean, clipText, text } from "./hwlab-caserun-evidence.ts";
import type { CaseContext, PreparedCaseRun } from "./hwlab-caserun-shared.ts";
const MAX_DIAGNOSTIC_ROWS = 30;
export async function refreshCaseRegistryArtifacts(context: CaseContext, caseRepo: string) {
const caseId = text(context.parsed.caseId ?? context.rest[1]);
const runId = text(context.parsed.runId ?? context.rest[2]);
if (!caseId) throw cliError("missing_required_value", "caseId is required", { field: "caseId" });
if (!runId) throw cliError("missing_required_value", "runId is required", { field: "runId" });
const caseRepoRunDir = path.join(caseRepo, "runs", caseId, runId);
const stored = await loadRegistryArtifacts(caseRepo, caseId, runId);
const source = text(context.parsed.source ?? context.parsed.from) || "local";
if (source !== "local" && source !== "runtime") throw cliError("unsupported_case_refresh_source", "case refresh source must be local or runtime", { source });
const run = registryPreparedRun(caseRepo, caseId, runId, caseRepoRunDir, stored.run, stored.evidence, stored.manifest, context.now());
const evidence = {
...stored.evidence,
caseId,
runId,
autoEvaluation: false,
refresh: { source, authority: "registry", runtimeAccess: source === "runtime" },
};
const offlineContext = { ...context, env: { ...context.env, HWLAB_API_KEY: undefined }, parsed: { ...context.parsed, noCaseRepoGitSync: true } };
const archiveContext = source === "runtime" ? { ...context, parsed: { ...context.parsed, caseRefreshSource: "runtime" } } : offlineContext;
const first = await archiveCaseRunArtifacts(archiveContext, run, evidence, { sync: false });
const aggregateContext = { ...offlineContext, parsed: { ...offlineContext.parsed, caseId, runId }, rest: ["aggregate", caseId, runId] };
await writeCaseAggregateFromRegistry(aggregateContext, caseRepo, { sync: false });
const final = await archiveCaseRunArtifacts(offlineContext, run, evidence, { sync: false });
const sync = context.parsed.noCaseRepoGitSync === true
? null
: await syncCaseRegistryRel(context, caseRepo, `runs/${caseId}/${runId}`, `data: refresh caserun artifacts ${runId}`);
const trace = final.manifest.trace ?? first.manifest.trace ?? {};
return ok("case.refresh", {
caseId,
runId,
caseRepo,
caseRepoRunDir,
source,
refreshed: {
files: final.caseRepoFiles,
traceId: trace.traceId,
sourceEventCount: trace.sourceEventCount,
renderedRowCount: trace.renderedRowCount,
commandCount: trace.commandCount,
hwpodCommandCount: trace.hwpodCommandCount,
hwpodBuildCommandCount: trace.hwpodBuildCommandCount,
manifestPath: final.artifactManifestPath,
registrySync: sync,
provenance: evidence.refresh,
},
aggregationOnly: false,
autoEvaluation: false,
});
}
export async function inspectCaseRegistryRun(context: CaseContext, caseRepo: string, view: "inspect" | "trace-summary") {
const artifacts = await loadDiagnosticByRunId(caseRepo, text(context.parsed.runId ?? context.rest[2]), text(context.parsed.caseId ?? context.parsed.case));
const diagnostic = diagnosticFromArtifacts(artifacts);
const payload = view === "trace-summary" ? traceSummary(diagnostic) : diagnostic;
return ok(`case.run.${view}`, { ...payload, inspectionOnly: true, serviceRuntime: false, autoEvaluation: false });
}
export async function skillUsageCaseRegistryRun(context: CaseContext, caseRepo: string) {
const skill = text(context.parsed.skill ?? context.parsed.skillName ?? context.rest[3]);
if (!skill) throw cliError("missing_required_value", "skill is required", { field: "skill" });
const artifacts = await loadDiagnosticByRunId(caseRepo, text(context.parsed.runId ?? context.rest[2]), text(context.parsed.caseId ?? context.parsed.case));
const pattern = new RegExp(`(?:\\.agents[\\\\/]skills[\\\\/]${escapeRegExp(skill)}[\\\\/]SKILL\\.md|\\b${escapeRegExp(skill)}\\b)`, "iu");
const bundleSources = [
["artifact-manifest.json", artifacts.manifest.resourceBundle ?? artifacts.manifest.provenance?.resourceBundle],
["evidence.json", artifacts.evidence.resourceBundle ?? artifacts.evidence.provenance?.resourceBundle],
] as const;
const evidence = bundleSources
.filter(([, value]) => value && pattern.test(JSON.stringify(value)))
.map(([source, value]) => ({ source, materialized: value.materialized === true, preview: clipText(JSON.stringify(value).replace(/\s+/gu, " "), 500) }));
const rows = traceRows(artifacts);
const allMatches = rows.filter((row) => pattern.test(`${row.header ?? ""}\n${row.body ?? ""}`));
const matches = allMatches.slice(0, MAX_DIAGNOSTIC_ROWS).map((row) => ({ rowId: row.rowId, seq: row.seq, header: row.header, preview: clipText(row.body, 500) }));
return ok("case.run.skill-usage", {
caseId: artifacts.caseId,
runId: artifacts.runId,
caseRepo,
caseRepoRunDir: artifacts.caseRepoRunDir,
skill,
prompt: { mentionsSkill: pattern.test(artifacts.promptText), path: path.join(artifacts.caseRepoRunDir, "agent-prompt.md") },
trace: { readSkillFile: allMatches.length > 0, matchCount: allMatches.length, matches },
resourceBundle: { materialized: evidence.some((item) => item.materialized), evidence },
inspectionOnly: true,
serviceRuntime: false,
autoEvaluation: false,
});
}
async function loadDiagnosticByRunId(caseRepo: string, runId: string, explicitCaseId: string) {
if (!runId) throw cliError("missing_required_value", "runId is required", { field: "runId" });
const caseId = explicitCaseId || await resolveCaseId(caseRepo, runId);
return loadRegistryArtifacts(caseRepo, caseId, runId);
}
async function loadRegistryArtifacts(caseRepo: string, caseId: string, runId: string) {
const caseRepoRunDir = path.join(caseRepo, "runs", caseId, runId);
const [run, evidence, manifest, agentTrace, agentMessages, finalResponseText, promptText] = await Promise.all([
readJson(path.join(caseRepoRunDir, "run.json")),
readJson(path.join(caseRepoRunDir, "evidence.json")),
readJson(path.join(caseRepoRunDir, "artifact-manifest.json")),
readJson(path.join(caseRepoRunDir, "agent-trace.json")),
readJson(path.join(caseRepoRunDir, "agent-messages.json")),
readText(path.join(caseRepoRunDir, "final-response.md")),
readText(path.join(caseRepoRunDir, "agent-prompt.md")),
]);
if (!run && !evidence && !manifest) throw cliError("case_registry_run_not_found", "case registry run artifacts were not found", { caseRepoRunDir, caseId, runId });
return { caseRepo, caseId, runId, caseRepoRunDir, run: run ?? {}, evidence: evidence ?? {}, manifest: manifest ?? {}, agentTrace, agentMessages, finalResponseText, promptText };
}
async function resolveCaseId(caseRepo: string, runId: string) {
const root = path.join(caseRepo, "runs");
const entries = await readdir(root).catch(() => []);
const matches: string[] = [];
for (const entry of entries.filter((item) => !item.startsWith("."))) {
if (await isDirectory(path.join(root, entry, runId))) matches.push(entry);
}
if (matches.length === 0) throw cliError("case_registry_run_not_found", "runId was not found in case registry runs", { caseRepo, runId });
if (matches.length > 1) throw cliError("case_registry_run_ambiguous", "runId exists under multiple caseIds; pass --case-id", { caseRepo, runId, caseIds: matches });
return matches[0];
}
function registryPreparedRun(caseRepo: string, caseId: string, runId: string, caseRepoRunDir: string, rawRun: any, rawEvidence: any, manifest: any, now: string): PreparedCaseRun {
const run = rawRun ?? {};
const evidence = rawEvidence ?? {};
const subject = { ...(run.subject ?? {}), ...(evidence.subject ?? {}), ...(manifest?.subject ?? {}) };
const agent = { ...(run.agent ?? {}), ...(evidence.agent ?? {}), ...(manifest?.agent ?? {}) };
return {
...run,
caseId,
runId,
runDir: caseRepoRunDir,
caseRepo,
caseDir: run.caseDir ?? "",
caseFile: run.caseFile ?? "",
sourceSpecPath: run.sourceSpecPath ?? path.join(caseRepoRunDir, ".hwlab", "hwpod-spec.yaml"),
specPath: run.specPath ?? path.join(caseRepoRunDir, ".hwlab", "hwpod-spec.yaml"),
apiUrl: run.apiUrl ?? evidence.apiUrl ?? "",
compileOnly: run.compileOnly !== false,
createdAt: run.createdAt ?? now,
updatedAt: now,
definition: run.definition ?? {},
subject: { repoLocalPath: subject.repoLocalPath ?? "", commitId: subject.commitId ?? "", subdir: subject.subdir ?? "", worktreePath: subject.worktreePath ?? "", workspacePath: subject.workspacePath ?? subject.worktreePath ?? "", setup: subject.setup ?? {} },
agent: agent.traceId ? agent : null,
agentTrace: evidence.agentTrace ?? manifest?.trace ?? run.agentTrace,
agentDiff: evidence.agentDiff ?? manifest?.diff ?? run.agentDiff,
} as PreparedCaseRun;
}
function diagnosticFromArtifacts(artifacts: any) {
const agent = { ...(artifacts.run.agent ?? {}), ...(artifacts.evidence.agent ?? {}), ...(artifacts.manifest.agent ?? {}) };
const trace = { ...(artifacts.run.agentTrace ?? {}), ...(artifacts.evidence.agentTrace ?? {}), ...(artifacts.manifest.trace ?? {}) };
const summary = boundedToolCallSummary(trace.toolCallSummary ?? agent.toolCallSummary ?? agentToolCallSummaryFromResult(artifacts.agentTrace?.body ?? artifacts.agentTrace) ?? null);
const rows = traceRows(artifacts);
const agentFinal = clean({ ...(artifacts.evidence.agentFinal ?? {}), ...(artifacts.manifest.agentFinal ?? {}) });
const commands = summary?.items ?? rows.filter((row) => /commandExecution|tool_call|^tool:/iu.test(`${row.rowId ?? ""} ${row.header ?? ""}`)).map((row) => ({ rowId: row.rowId, seq: row.seq, command: row.command ?? row.body, status: row.status ?? row.tone }));
return clean({
caseId: artifacts.caseId,
runId: artifacts.runId,
caseRepo: artifacts.caseRepo,
caseRepoRunDir: artifacts.caseRepoRunDir,
status: artifacts.run.status ?? artifacts.evidence.status ?? "recorded",
stage: artifacts.run.stage ?? artifacts.run._control?.stage,
compileOnly: artifacts.evidence.compileOnly ?? artifacts.run.compileOnly,
traceId: agent.traceId ?? trace.traceId,
sessionId: agent.sessionId ?? trace.sessionId,
conversationId: agent.conversationId ?? trace.conversationId,
threadId: agent.threadId ?? trace.threadId,
providerProfile: agent.providerProfile,
requestedProviderProfile: agent.requestedProviderProfile,
resolvedBackendProfile: agent.resolvedBackendProfile,
provider: agent.provider,
model: agent.model,
backend: agent.backend,
terminalStatus: trace.terminalStatus ?? agent.terminalStatus,
agentResultStatus: agent.result?.status ?? trace.status,
commandStatus: agent.commandStatus ?? summary?.commandStatus,
agentRunStatus: agent.agentRunStatus ?? summary?.agentRunStatus,
toolCallSummary: summary,
sourceEventCount: trace.sourceEventCount ?? artifacts.agentMessages?.sourceEventCount,
renderedRowCount: trace.renderedRowCount ?? artifacts.agentMessages?.renderedRowCount ?? rows.length,
commandCount: trace.commandCount ?? summary?.count ?? commands.length,
hwpodCommandCount: trace.hwpodCommandCount,
hwpodBuildCommandCount: trace.hwpodBuildCommandCount,
agentFinal,
postValidation: artifacts.manifest.postValidation ?? artifacts.evidence.postValidation,
refresh: artifacts.manifest.refresh ?? artifacts.evidence.refresh,
finalResponse: { present: agentFinal.present === true, reason: agentFinal.reason, path: path.join(artifacts.caseRepoRunDir, "final-response.md"), textPreview: clipText(artifacts.finalResponseText, 800) },
traceCommands: commands.slice(0, MAX_DIAGNOSTIC_ROWS),
paths: { runJson: path.join(artifacts.caseRepoRunDir, "run.json"), evidenceJson: path.join(artifacts.caseRepoRunDir, "evidence.json"), agentTraceJson: path.join(artifacts.caseRepoRunDir, "agent-trace.json"), agentMessagesJson: path.join(artifacts.caseRepoRunDir, "agent-messages.json"), artifactManifestJson: path.join(artifacts.caseRepoRunDir, "artifact-manifest.json"), aggregateMd: path.join(artifacts.caseRepoRunDir, "aggregate.md") },
});
}
function traceSummary(diagnostic: any) {
return clean({
caseId: diagnostic.caseId,
runId: diagnostic.runId,
caseRepo: diagnostic.caseRepo,
caseRepoRunDir: diagnostic.caseRepoRunDir,
traceId: diagnostic.traceId,
providerProfile: diagnostic.providerProfile,
requestedProviderProfile: diagnostic.requestedProviderProfile,
resolvedBackendProfile: diagnostic.resolvedBackendProfile,
provider: diagnostic.provider,
model: diagnostic.model,
backend: diagnostic.backend,
terminalStatus: diagnostic.terminalStatus,
agentResultStatus: diagnostic.agentResultStatus,
commandStatus: diagnostic.commandStatus,
agentRunStatus: diagnostic.agentRunStatus,
toolCallSummary: diagnostic.toolCallSummary,
sourceEventCount: diagnostic.sourceEventCount,
renderedRowCount: diagnostic.renderedRowCount,
commandCount: diagnostic.commandCount,
hwpodCommandCount: diagnostic.hwpodCommandCount,
hwpodBuildCommandCount: diagnostic.hwpodBuildCommandCount,
traceCommands: diagnostic.traceCommands,
paths: diagnostic.paths,
});
}
function boundedToolCallSummary(summary: any) {
if (!summary || typeof summary !== "object") return null;
return clean({
...summary,
items: arrayOfObjects(summary.items).slice(0, MAX_DIAGNOSTIC_ROWS).map((item) => clean({
...item,
command: clipText(item.command, 1000),
detail: clipText(item.detail, 1000),
})),
});
}
function traceRows(artifacts: any) {
const body = artifacts.agentTrace?.body && typeof artifacts.agentTrace.body === "object" ? artifacts.agentTrace.body : artifacts.agentTrace ?? {};
return arrayOfObjects(artifacts.agentMessages?.rows ?? body.rows ?? body.renderedRows ?? body.traceRows);
}
async function readJson(file: string) { try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; } }
async function readText(file: string) { try { return await readFile(file, "utf8"); } catch { return ""; } }
async function isDirectory(file: string) { try { return (await stat(file)).isDirectory(); } catch { return false; } }
function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); }
function cliError(code: string, message: string, details: Record<string, unknown> = {}) { return Object.assign(new Error(message), { code, details }); }
function ok(action: string, extra: Record<string, unknown> = {}) { return { ok: true, action, status: "completed", ...extra }; }
+14 -3
View File
@@ -9,6 +9,7 @@ import path from "node:path";
import { renderTraceRowsMarkdown, traceDisplayRows, traceNoiseEventCount, type TraceEventRow } from "./hwlab-cli/trace-renderer.ts";
import { collectAgentWorkspaceDiff } from "./hwlab-caserun-diff.ts";
import { inspectCaseRegistryRun, refreshCaseRegistryArtifacts, skillUsageCaseRegistryRun } from "./hwlab-caserun-diagnostics.ts";
import { agentTerminalEvidence, agentToolCallSummaryFromResult, agentWithResultEvidence, applyCaseRunEvidenceRelationships, commandsFromToolCallSummary, summarizeAgentTrace, firstNumberOption, firstTextOption, type AgentToolCallSummary } from "./hwlab-caserun-evidence.ts";
import { readHwpodSpec } from "./hwpod-harness-lib.ts";
import { prepareSubjectWorktree, caseRunForSubjectSnapshot, subjectFromDefinition, requestSubjectWorktree, subjectWorktreeStatus, keilSidecarCopyOp, subjectRelativeKeilProject, stripUvprojx, windowsJoin, keilSidecarCopyNodeScript, keilSidecarSummary, objectRecord, isAbsolutePathLike, subjectRelativeWorktreePath, normalizeLocalPath, subjectWorktreePath, looksLikeWindowsPath, rewriteHwpodSpecWorkspacePath, pollKeilJobStatus, requestKeilJobStatus, keilJobStatus, hwpodResultTexts, artifactsFrom } from "./hwlab-caserun-subject.ts";
@@ -34,6 +35,7 @@ export async function caseCommand(context: CaseContext) {
if (["help", "--help", "-h"].includes(subcommand) || context.parsed.help === true) return caseHelp();
if (subcommand === "prompt") return promptCaseRun(context);
if (subcommand === "aggregate") return aggregateCaseRun(context);
if (subcommand === "refresh" || subcommand === "backfill") return refreshCaseRegistryArtifacts(context, await resolveCaseRepo(context));
if (subcommand === "prepare") return prepareCaseRun(context);
if (subcommand === "build") return buildCaseRun(context);
if (subcommand === "collect") return collectCaseRun(context);
@@ -47,6 +49,9 @@ async function caseRunCommand(context: CaseContext) {
if (mode === "status") return statusCaseRun(context);
if (mode === "result") return resultCaseRun(context);
if (mode === "logs") return logsCaseRun(context);
if (mode === "inspect") return inspectCaseRegistryRun(context, await resolveCaseRepo(context), "inspect");
if (mode === "trace-summary" || mode === "trace") return inspectCaseRegistryRun(context, await resolveCaseRepo(context), "trace-summary");
if (mode === "skill-usage" || mode === "skills") return skillUsageCaseRegistryRun(context, await resolveCaseRepo(context));
if (mode === "worker") return runCaseRunWorker(context);
return runCaseRun(context);
}
@@ -69,17 +74,23 @@ export function caseHelp() {
`case build CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-dir DIR]`,
`case collect CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --run-dir DIR`,
`case aggregate CASE_ID --run-id RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--output aggregate.md]`,
`case refresh CASE_ID --run-id RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--source local|runtime]`,
`case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--provider-profile PROFILE]`,
`case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`,
`case run status RUN_ID [--state-dir .state/hwlab-cli/caserun]`,
`case run result RUN_ID [--state-dir .state/hwlab-cli/caserun]`,
`case run logs RUN_ID [--tail 8000]`
`case run logs RUN_ID [--tail 8000]`,
`case run inspect RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`,
`case run trace-summary RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`,
`case run skill-usage RUN_ID --skill SKILL_NAME --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`
],
notes: [
"case prompt 是无服务预览入口,只按真实 CaseRun 组装路径渲染输入 prompt,不准备 worktree、不提交 agent、不访问硬件。",
"case build remains a compile-only HWPOD smoke stage.",
"case run prepares the subject worktree, submits a prompt to HWLAB Code Agent, records agent provenance and workspace diff, then runs compile validation.",
"case aggregate is a service-free post-processing command: it reads an existing case registry run and writes one aggregate markdown entry without pass/fail grading.",
"case aggregate is registry-only and offline by default; it reads existing artifacts without querying runtime.",
"case refresh/backfill defaults to local registry artifacts; only explicit --source runtime may query the existing trace CLI.",
"case run inspect/trace-summary/skill-usage are bounded, service-free registry diagnostics.",
"case run start/status/result/logs is the cli-spec async control surface for long CaseRun flows; start returns immediately and status/result/logs are short polling commands.",
"This version records raw stage evidence only; it does not auto-grade agent output, diff contents, or Keil evidence."
]
@@ -132,7 +143,7 @@ export async function promptCaseRun(context: CaseContext) {
export async function aggregateCaseRun(context: CaseContext) {
const caseRepo = await resolveCaseRepo(context);
const aggregate = await writeCaseAggregateFromRegistry(context, caseRepo, { sync: true });
const aggregate = await writeCaseAggregateFromRegistry(context, caseRepo, { sync: context.parsed.noCaseRepoGitSync !== true });
return ok("case.aggregate", {
caseId: aggregate.caseId,
runId: aggregate.runId,