fix: archive CaseRun agent transcript artifacts

This commit is contained in:
Codex Agent
2026-06-06 17:25:32 +08:00
parent fc865dbc7c
commit 28ffe3cd5d
3 changed files with 305 additions and 3 deletions
+120
View File
@@ -350,6 +350,11 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit
"summary.md",
"run.json",
"result.json",
"agent-trace.json",
"agent-trace.md",
"agent-messages.json",
"agent-transcript.md",
"final-response.md",
"agent-prompt.md",
"agent-diff.patch",
path.join(".hwlab", "hwpod-spec.yaml"),
@@ -366,9 +371,30 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit
assert.equal(manifest.trace.agentRun.runId, "run_agent_trace");
assert.equal(manifest.prompt.sha256, result.payload.evidence.agent.promptSha256);
assert.equal(manifest.diff.sha256, result.payload.evidence.agentDiff.diffPatchSha256);
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");
assert.equal(manifest.readableAgent.transcriptPath, "agent-transcript.md");
assert.equal(manifest.readableAgent.finalResponsePath, "final-response.md");
assert.equal(manifest.readableAgent.finalResponse.present, true);
assert.equal(manifest.readableAgent.finalResponse.text, "done");
assert.equal(manifest.decisions.autoEvaluation, false);
assert.equal(manifest.files.some((item: any) => item.path === "agent-messages.json"), true);
assert.equal(manifest.files.some((item: any) => item.path === "agent-trace.md"), true);
assert.equal(manifest.files.some((item: any) => item.path === "final-response.md"), true);
assert.equal(manifest.files.some((item: any) => item.path === "worker.stdout.log"), true);
assert.equal(manifest.files.some((item: any) => item.path === ".hwlab/hwpod-spec.yaml"), true);
const messages = JSON.parse(await readFile(path.join(registryRunDir, "agent-messages.json"), "utf8"));
assert.equal(messages.renderer, "tools/src/hwlab-cli/trace-renderer:traceDisplayRows");
assert.equal(messages.finalResponse.present, true);
assert.match(JSON.stringify(messages.rows), /hwpod build --spec \.hwlab\/hwpod-spec\.yaml/u);
const traceMarkdown = await readFile(path.join(registryRunDir, "agent-trace.md"), "utf8");
assert.match(traceMarkdown, /renderer: tools\/src\/hwlab-cli\/trace-renderer:traceDisplayRows/u);
assert.match(traceMarkdown, /## Subject Diff/u);
assert.match(traceMarkdown, /\+printf\("hello"\);/u);
const finalResponse = await readFile(path.join(registryRunDir, "final-response.md"), "utf8");
assert.match(finalResponse, /present: true/u);
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.equal(result.payload.collect.caseRepoFiles.some((item: string) => item.endsWith("artifact-manifest.json")), true);
@@ -394,6 +420,100 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit
}
});
test("case run archives rendered trace transcript when finalResponse is null", async () => {
const root = await mkdtempCaseRoot();
const caseRepo = path.join(root, "hwlab-case-registry");
const cwd = path.join(root, "hwlab");
const runId = "run-final-null";
const runDir = path.join(cwd, ".state", "hwlab-cli", "caserun", runId);
const registryRunDir = path.join(caseRepo, "runs", "d601-f103-v2-compile", runId);
try {
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 });
await writeFile(path.join(runDir, ".hwlab", "hwpod-spec.yaml"), hwpodSpecText(), "utf8");
await writeFile(path.join(runDir, "agent-prompt.md"), "prompt\n", "utf8");
await writeFile(path.join(runDir, "agent-diff.patch"), "diff --git a/main.c b/main.c\n+printf(\"hello\");\n", "utf8");
await writeFile(path.join(runDir, "worker.stdout.log"), "worker complete\n", "utf8");
await writeFile(path.join(runDir, "worker.stderr.log"), "", "utf8");
const run = {
caseId: "d601-f103-v2-compile",
runId,
runDir,
caseRepo,
caseDir: "",
caseFile: "",
sourceSpecPath: "",
specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"),
apiUrl: "http://api.test",
compileOnly: true,
createdAt: "2026-06-06T00:00:00.000Z",
updatedAt: "2026-06-06T00:00:10.000Z",
status: "completed",
stage: "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-final-null", workspacePath: "", setup: {} },
agent: { stageStatus: "failed", baseUrl: "http://web.test", projectId: "prj_hwpod_workbench", providerProfile: "deepseek", conversationId: "cnv_final_null", sessionId: "ses_final_null", threadId: "thread-final-null", traceId: "trc_final_null", promptPath: path.join(runDir, "agent-prompt.md"), promptSha256: "prompt-sha", result: { ok: true, status: "failed", finalResponse: null }, polls: 2, timedOut: false, sessionResponse: null },
agentTrace: { traceId: "trc_final_null", status: "failed", httpStatus: 200, sourceEventCount: 3, renderedRowCount: 0, commandCount: 0, hwpodCommandCount: 0, hwpodBuildCommandCount: 0, keilJobCandidates: [], terminalStatus: "failed", agentRun: { runId: "run_agent_final_null", commandId: "cmd_final_null" }, commands: [] },
agentDiff: { statusShort: " M main.c\n", diffStat: " main.c | 1 +\n", diffPatchPath: path.join(runDir, "agent-diff.patch"), diffPatchSha256: "diff-sha", sourceRootStatusShort: "", requests: [] },
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-06T00:00:10.000Z", resultPath: path.join(runDir, "result.json") }
};
const trace = {
ok: true,
status: "failed",
traceId: "trc_final_null",
finalResponse: null,
events: [
{ traceId: "trc_final_null", seq: 1, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "我先检查当前 HWPOD。", createdAt: "2026-06-06T00:00:01.000Z" },
{ traceId: "trc_final_null", seq: 2, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", command: "/bin/sh -lc 'hwpod build --spec .hwlab/hwpod-spec.yaml'", stdoutSummary: "build completed", exitCode: 0, createdAt: "2026-06-06T00:00:02.000Z" },
{ traceId: "trc_final_null", seq: 3, label: "agentrun:terminal:failed", type: "result", terminal: true, status: "failed", message: "Error running remote compact task: unexpected status 404 Not Found", createdAt: "2026-06-06T00:00:03.000Z" }
],
error: { message: "Error running remote compact task: unexpected status 404 Not Found" }
};
const evidence = {
contractVersion: "hwpod-case-run-evidence-v1",
caseId: run.caseId,
runId,
compileOnly: true,
autoEvaluation: false,
status: "recorded",
subject: run.subject,
agent: { traceId: "trc_final_null", sessionId: "ses_final_null", conversationId: "cnv_final_null", threadId: "thread-final-null", providerProfile: "deepseek", projectId: "prj_hwpod_workbench", promptSha256: "prompt-sha" },
agentTrace: run.agentTrace,
agentDiff: run.agentDiff,
hwpod: { source: "case-run-runner-post-agent-compile-check", exitCode: 0 },
artifacts: [],
decisions: { downloadSkipped: true }
};
await writeJsonFile(path.join(runDir, "run.json"), run);
await writeJsonFile(path.join(runDir, "agent-trace.json"), trace);
await writeJsonFile(path.join(runDir, "evidence.json"), evidence);
await writeJsonFile(path.join(runDir, "result.json"), { ok: true, action: "case.run", status: "completed", caseId: run.caseId, runId, runDir, collect: { caseRepoFiles: [] }, evidence: {} });
const result = await runHwlabCli(["case", "run", "result", runId], { cwd, now: () => "2026-06-06T00:00:11.000Z" });
assert.equal(result.exitCode, 0);
const manifest = JSON.parse(await readFile(path.join(registryRunDir, "artifact-manifest.json"), "utf8"));
assert.equal(manifest.readableAgent.finalResponse.present, false);
assert.match(manifest.readableAgent.finalResponse.reason, /404 Not Found/u);
const messages = JSON.parse(await readFile(path.join(registryRunDir, "agent-messages.json"), "utf8"));
assert.equal(messages.finalResponse.present, false);
assert.match(JSON.stringify(messages.rows), / HWPOD/u);
assert.match(JSON.stringify(messages.rows), /hwpod build --spec \.hwlab\/hwpod-spec\.yaml/u);
const traceMarkdown = await readFile(path.join(registryRunDir, "agent-trace.md"), "utf8");
assert.match(traceMarkdown, /finalResponse=null/u);
assert.match(traceMarkdown, /Error running remote compact task/u);
assert.match(traceMarkdown, /## Subject Diff/u);
assert.match(traceMarkdown, /\+printf\("hello"\);/u);
const finalMarkdown = await readFile(path.join(registryRunDir, "final-response.md"), "utf8");
assert.match(finalMarkdown, /present: false/u);
assert.match(finalMarkdown, /404 Not Found/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("case run status reports latest run stage when async control stage is stale", async () => {
const root = await mkdtempCaseRoot();
const cwd = path.join(root, "hwlab");
+184 -2
View File
@@ -4,6 +4,7 @@ import { closeSync, openSync } from "node:fs";
import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { traceDisplayRows, traceNoiseEventCount, type TraceEventRow } from "./hwlab-cli/trace-renderer.ts";
import { readHwpodSpec } from "./hwpod-harness-lib.ts";
const DEFAULT_API_URL = "http://74.48.78.17:19667";
@@ -17,6 +18,7 @@ const MAX_AGENT_TRACE_COMMANDS = 30;
const HWLAB_API_KEY_PREFIX = "hwl_live_";
const STANDARD_CASE_REPO_PATH = "/root/hwlab-case-registry";
const REMOVED_CASE_REPO_PATH = "/root/hwpod-cases";
const CASE_TRACE_RENDERER = "tools/src/hwlab-cli/trace-renderer:traceDisplayRows";
type EnvLike = Record<string, string | undefined>;
type ParsedArgs = Record<string, unknown> & { _: string[] };
@@ -156,6 +158,16 @@ type CaseRegistryArchive = {
manifest: Record<string, unknown>;
};
type CaseReadableAgentArchive = {
messagesRel: string;
traceRel: string;
transcriptRel: string;
finalResponseRel: string;
generatedFiles: string[];
traceRender: Record<string, unknown>;
finalResponse: Record<string, unknown>;
};
export async function caseCommand(context: CaseContext) {
const subcommand = context.rest[0] || "help";
if (["help", "--help", "-h"].includes(subcommand) || context.parsed.help === true) return caseHelp();
@@ -718,6 +730,7 @@ async function collectAgentTraceEvidence(context: CaseContext, run: PreparedCase
const nextRun = await updateRun(context, run, { agentTrace: trace, stage: "agent-trace-collected" });
return { run: nextRun, trace };
}
await writeJson(path.join(run.runDir, "agent-trace.json"), response.body);
const trace = summarizeAgentTrace(traceId, response.status, response.body);
const nextRun = await updateRun(context, run, { agentTrace: trace, stage: "agent-trace-collected" });
return { run: nextRun, trace };
@@ -1389,6 +1402,8 @@ async function archiveCaseRunArtifacts(context: CaseContext, run: PreparedCaseRu
["evidence.json", path.join(run.runDir, "evidence.json")],
["summary.md", undefined]
]);
const readableAgent = await writeReadableAgentArtifacts(caseRepoRunDir, run, evidence);
for (const rel of readableAgent.generatedFiles) materialized.set(rel, undefined);
const skippedFiles: string[] = [];
for (const entry of caseRunArtifactEntries(run)) {
const rel = artifactRel(entry.rel);
@@ -1399,7 +1414,7 @@ async function archiveCaseRunArtifacts(context: CaseContext, run: PreparedCaseRu
const files: CaseRegistryArchiveFile[] = [];
for (const [rel, source] of materialized) files.push(await registryArtifactFileRecord(run, caseRepoRunDir, rel, source));
const manifest = caseRunArtifactManifest(context, run, evidence, files, skippedFiles);
const manifest = caseRunArtifactManifest(context, run, evidence, files, skippedFiles, readableAgent);
const artifactManifestPath = path.join(caseRepoRunDir, "artifact-manifest.json");
await writeJson(artifactManifestPath, manifest);
const caseRepoFiles = [...files.map((file) => artifactRel(path.join(runRel, file.path))), artifactRel(path.join(runRel, "artifact-manifest.json"))];
@@ -1420,6 +1435,159 @@ function collectSummaryFromArchive(run: PreparedCaseRun, evidence: any, archive:
};
}
async function writeReadableAgentArtifacts(caseRepoRunDir: string, run: PreparedCaseRun, evidence: any): Promise<CaseReadableAgentArchive> {
const messagesRel = "agent-messages.json";
const traceRel = "agent-trace.md";
const transcriptRel = "agent-transcript.md";
const finalResponseRel = "final-response.md";
const traceBody = await archivedAgentTraceBody(run, evidence);
const events = arrayOfObjects(traceBody?.events ?? traceBody?.trace?.events ?? traceBody?.sourceEvents);
const rows = traceDisplayRows(traceBody ?? {}, events);
const finalResponse = agentFinalResponse(run, evidence, traceBody, rows);
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,
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, patchIncludedInTranscript: true })
});
const transcript = renderAgentTranscript(run, evidence, messages, rows, diffPatch);
await writeJson(path.join(caseRepoRunDir, messagesRel), messages);
await writeFile(path.join(caseRepoRunDir, traceRel), transcript, "utf8");
await writeFile(path.join(caseRepoRunDir, transcriptRel), transcript, "utf8");
await writeFile(path.join(caseRepoRunDir, finalResponseRel), renderFinalResponseArtifact(run, evidence, finalResponse, rows), "utf8");
return {
messagesRel,
traceRel,
transcriptRel,
finalResponseRel,
generatedFiles: [messagesRel, traceRel, transcriptRel, finalResponseRel],
traceRender: clean({ renderer: CASE_TRACE_RENDERER, status: messages.status, sourceEventCount: messages.sourceEventCount, renderedRowCount: messages.renderedRowCount, noiseEventCount: messages.noiseEventCount }),
finalResponse
};
}
async function archivedAgentTraceBody(run: PreparedCaseRun, evidence: any) {
const rawTrace = await readJsonIfExists(path.join(run.runDir, "agent-trace.json"));
return rawTrace ?? agentTraceBody(run, evidence);
}
function agentTraceBody(run: PreparedCaseRun, evidence: any) {
const result = run.agent?.result ?? evidence.agent?.result ?? null;
return result?.body && typeof result.body === "object" ? result.body : result;
}
function agentFinalResponse(run: PreparedCaseRun, evidence: any, traceBody: any, rows: TraceEventRow[]) {
const candidates = [
traceBody?.finalResponse,
traceBody?.terminalEvidence?.finalResponse,
run.agent?.result?.finalResponse,
run.agent?.result?.reply,
evidence.agent?.finalResponse
];
for (const candidate of candidates) {
const body = text((candidate as any)?.text ?? (candidate as any)?.content ?? (candidate as any)?.message);
if (body) return clean({ present: true, status: text((candidate as any)?.status) || "completed", source: text((candidate as any)?.source) || "agent-result", text: body, textChars: body.length, traceId: text((candidate as any)?.traceId ?? traceBody?.traceId ?? run.agent?.traceId) });
}
const terminal = rows.filter((row) => row.terminal === true || row.tone === "blocked").slice(-5).map(agentMessageRowForArchive);
return clean({
present: false,
value: null,
reason: finalResponseMissingReason(traceBody, rows),
status: text(traceBody?.status ?? traceBody?.traceStatus ?? run.agent?.stageStatus),
terminalRows: terminal,
error: compactObject(traceBody?.error ?? run.agent?.error ?? null)
});
}
function finalResponseMissingReason(traceBody: any, rows: TraceEventRow[]) {
const errorText = text(traceBody?.error?.message ?? traceBody?.terminalEvidence?.error?.message);
if (errorText) return errorText;
const terminalBody = rows.slice().reverse().map((row) => text(row.body)).find((body) => /error|failed|404|not found|blocked|timeout/iu.test(body));
return terminalBody ? clipText(terminalBody, 1200) : "finalResponse=null; no authoritative final assistant response was returned by the trace/result payload";
}
function agentMessageRowForArchive(row: TraceEventRow) {
return clean({ rowId: row.rowId, seq: row.seq, tone: row.tone, header: row.header, terminal: row.terminal === true ? true : undefined, bodyFormat: row.bodyFormat, body: row.body });
}
function renderAgentTranscript(run: PreparedCaseRun, evidence: any, messages: any, rows: TraceEventRow[], diffPatch: string) {
const sections = [
`# CaseRun Agent Transcript`,
``,
`- caseId: ${run.caseId}`,
`- runId: ${run.runId}`,
`- traceId: ${messages.traceId ?? ""}`,
`- conversationId: ${messages.conversationId ?? ""}`,
`- sessionId: ${messages.sessionId ?? ""}`,
`- threadId: ${messages.threadId ?? ""}`,
`- renderer: ${CASE_TRACE_RENDERER}`,
`- finalResponse: ${messages.finalResponse?.present === true ? "present" : "null"}`,
`- autoEvaluation: false`,
``,
`## Messages`,
rows.length > 0 ? rows.map(renderTranscriptRow).join("\n\n") : `_No rendered trace rows were returned._`,
``,
`## Final Response`,
messages.finalResponse?.present === true ? text(messages.finalResponse.text) : [`finalResponse=null`, `reason: ${text(messages.finalResponse?.reason)}`].join("\n"),
``,
`## Subject Diff`,
``,
`statusShort:`,
`\`\`\`text`,
text(evidence.agentDiff?.statusShort ?? run.agentDiff?.statusShort) || "(empty)",
`\`\`\``,
``,
`diffStat:`,
`\`\`\`text`,
text(evidence.agentDiff?.diffStat ?? run.agentDiff?.diffStat) || "(empty)",
`\`\`\``,
``,
`patch:`,
`\`\`\`diff`,
diffPatch || "(empty)",
`\`\`\``
];
return `${sections.join("\n")}\n`;
}
function renderTranscriptRow(row: TraceEventRow) {
const body = text(row.body);
return [`### ${row.header}`, ``, `- rowId: ${row.rowId}`, row.terminal === true ? `- terminal: true` : null, ``, body ? body : `_No body._`].filter((item) => item !== null).join("\n");
}
function renderFinalResponseArtifact(run: PreparedCaseRun, evidence: any, finalResponse: any, rows: TraceEventRow[]) {
const lines = [
`# CaseRun Final Response`,
``,
`- caseId: ${run.caseId}`,
`- runId: ${run.runId}`,
`- traceId: ${evidence.agent?.traceId ?? run.agent?.traceId ?? ""}`,
`- present: ${finalResponse.present === true}`,
``
];
if (finalResponse.present === true) return `${[...lines, text(finalResponse.text)].join("\n")}\n`;
const terminalRows = rows.filter((row) => row.terminal === true || row.tone === "blocked").slice(-5);
return `${[
...lines,
`finalResponse=null`,
``,
`reason: ${text(finalResponse.reason)}`,
``,
`## Terminal/Error Rows`,
terminalRows.length > 0 ? terminalRows.map(renderTranscriptRow).join("\n\n") : `_No terminal/error rows were rendered._`
].join("\n")}\n`;
}
async function refreshCompletedRunArchive(context: CaseContext, run: PreparedCaseRun, evidence?: any) {
if (context.parsed.noCaseRepoRecord === true) return null;
if (!text(run.caseRepo)) return null;
@@ -1433,6 +1601,7 @@ 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 },
@@ -1464,7 +1633,7 @@ async function registryArtifactFileRecord(run: PreparedCaseRun, caseRepoRunDir:
});
}
function caseRunArtifactManifest(context: CaseContext, run: PreparedCaseRun, evidence: any, files: CaseRegistryArchiveFile[], skippedFiles: string[]) {
function caseRunArtifactManifest(context: CaseContext, run: PreparedCaseRun, evidence: any, files: CaseRegistryArchiveFile[], skippedFiles: string[], readableAgent: CaseReadableAgentArchive) {
return clean({
contractVersion: "hwpod-case-run-artifact-manifest-v1",
caseId: run.caseId,
@@ -1482,6 +1651,15 @@ function caseRunArtifactManifest(context: CaseContext, run: PreparedCaseRun, evi
threadId: evidence.agent?.threadId ?? run.agent?.threadId,
traceId: evidence.agent?.traceId ?? run.agent?.traceId
}),
readableAgent: clean({
renderer: CASE_TRACE_RENDERER,
messagesPath: readableAgent.messagesRel,
tracePath: readableAgent.traceRel,
transcriptPath: readableAgent.transcriptRel,
finalResponsePath: readableAgent.finalResponseRel,
traceRender: readableAgent.traceRender,
finalResponse: readableAgent.finalResponse
}),
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 }),
keilJob: evidence.keilJob ?? null,
@@ -1586,6 +1764,10 @@ async function readJsonIfExists(file: string) {
try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; }
}
async function readTextIfExists(file: string) {
try { return await readFile(file, "utf8"); } catch { return ""; }
}
function controlFromRun(run: any) {
return run?._control && typeof run._control === "object" ? run._control : {};
}