diff --git a/tools/src/hwlab-caserun-closeout.ts b/tools/src/hwlab-caserun-closeout.ts index 2398b955..e214749b 100644 --- a/tools/src/hwlab-caserun-closeout.ts +++ b/tools/src/hwlab-caserun-closeout.ts @@ -5,24 +5,80 @@ import { createHash } from "node:crypto"; import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { renderTraceRowsMarkdown, traceDisplayRows, traceNoiseEventCount, type TraceEventRow } from "./hwlab-cli/trace-renderer.ts"; -import { agentTerminalEvidence, commandsFromToolCallSummary, hwpodCommandKind, mentionsHwpod, summarizeAgentTrace } from "./hwlab-caserun-evidence.ts"; +import { + agentTerminalEvidence, + arrayOfObjects, + clean, + clipText, + commandsFromToolCallSummary, + compactObject, + hwpodCommandKind, + mentionsHwpod, + numberOption, + parseJsonMaybe, + summarizeAgentTrace, + text, + uniqueStrings, +} from "./hwlab-caserun-evidence.ts"; import type { ArchivedAgentTraceSnapshot, AgentTraceCommand, AgentTraceStage, CaseContext, CaseReadableAgentArchive, CaseRegistryArchive, CaseRegistryArchiveFile, PreparedCaseRun } from "./hwlab-caserun-shared.ts"; -const CASE_TRACE_RENDERER = "tools/src/hwlab-cli/trace-renderer:traceDisplayRows"; const MAX_AGENT_TRACE_COMMANDS = 30; const HWLAB_API_KEY_PREFIX = "hwl_live_"; -function text(value: unknown) { return String(value ?? "").trim(); } -function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; } -function clean(value: any) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)); } -function compactObject(value: any) { const json = JSON.stringify(value ?? null); return json.length <= 4000 ? value : { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) }; } -function sha256Buffer(value: Buffer) { return createHash("sha256").update(value).digest("hex"); } -function parseJsonMaybe(value: unknown) { if (value && typeof value === "object") return value as any; try { return JSON.parse(String(value ?? "").trim()); } catch { return null; } } -function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw Object.assign(new Error(`${field} is required`), { code: "missing_required_value", details: { field } }); return result; } -function clipText(value: unknown, maxBytes = 2000) { const raw = String(value ?? ""); const buffer = Buffer.from(raw, "utf8"); return buffer.length <= maxBytes ? raw : `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`; } -function arrayOfObjects(value: unknown): any[] { return Array.isArray(value) ? value.filter((item) => item && typeof item === "object" && !Array.isArray(item)) : []; } -function uniqueStrings(values: string[]) { return Array.from(new Set(values.map(text).filter(Boolean))); } -async function readJsonIfExists(file: string) { try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; } } -async function readTextIfExists(file: string) { try { return await readFile(file, "utf8"); } catch { return ""; } } -async function fileExists(file: string) { try { await stat(file); return true; } catch { return false; } } -async function writeJson(file: string, value: any) { await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } -async function runProcess(command: string[], cwd: string, env: Record) { const proc = Bun.spawn(command, { cwd, env, stdout: "pipe", stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); return { command, stdout, stderr, exitCode }; } +const CASE_TRACE_RENDERER = "tools/src/hwlab-cli/trace-renderer:traceDisplayRows"; +const MAX_AGENT_TRACE_COMMANDS = 30; +const HWLAB_API_KEY_PREFIX = "hwl_live_"; + +function sha256Buffer(value: Buffer) { + return createHash("sha256").update(value).digest("hex"); +} + +function requiredText(value: unknown, field: string) { + const result = text(value); + if (!result) { + throw Object.assign(new Error(`${field} is required`), { + code: "missing_required_value", + details: { field }, + }); + } + return result; +} + +async function readJsonIfExists(file: string) { + try { + return JSON.parse(await readFile(file, "utf8")); + } catch { + return null; + } +} + +async function readTextIfExists(file: string) { + try { + return await readFile(file, "utf8"); + } catch { + return ""; + } +} + +async function fileExists(file: string) { + try { + await stat(file); + return true; + } catch { + return false; + } +} + +async function writeJson(file: string, value: any) { + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +async function runProcess(command: string[], cwd: string, env: Record) { + const proc = Bun.spawn(command, { cwd, env, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { command, stdout, stderr, exitCode }; +} export function agentTraceLookupForRun(run: any, evidence?: any) { const agent = run.agent ?? evidence?.agent; const traceId = text(agent?.traceId ?? evidence?.agentTrace?.traceId ?? run.agentTrace?.traceId); diff --git a/tools/src/hwlab-caserun-evidence.ts b/tools/src/hwlab-caserun-evidence.ts index 38c292c9..c79a62f1 100644 --- a/tools/src/hwlab-caserun-evidence.ts +++ b/tools/src/hwlab-caserun-evidence.ts @@ -112,11 +112,44 @@ export function firstTextOption(...values: any[]) { return values.map(text).find export function firstNumberOption(...values: any[]) { for (const value of values) { const number = numberOption(value); if (number !== undefined) return number; } return undefined; } function countedRecord(value: any, fallbacks: Array) { if (value && typeof value === "object" && !Array.isArray(value)) return Object.fromEntries(Object.entries(value).map(([key, count]) => [key, Number(count) || 0])); return fallbacks.filter(Boolean).reduce>((result, key) => ({ ...result, [key as string]: (result[key as string] ?? 0) + 1 }), {}); } function sumRecordValues(value: Record) { return Object.values(value).reduce((sum, count) => sum + count, 0); } -function compactObject(value: any) { const json = JSON.stringify(value ?? null); return json.length <= 4000 ? value : { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) }; } -function clipText(value: unknown, maxBytes: number) { const raw = String(value ?? ""); const buffer = Buffer.from(raw, "utf8"); return buffer.length <= maxBytes ? raw : `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`; } -function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; } -function text(value: unknown) { return String(value ?? "").trim(); } -function clean>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T; } +export function compactObject(value: any) { + const json = JSON.stringify(value ?? null); + return json.length <= 4000 + ? value + : { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) }; +} + +export function clipText(value: unknown, maxBytes = 2000) { + const raw = String(value ?? ""); + const buffer = Buffer.from(raw, "utf8"); + return buffer.length <= maxBytes + ? raw + : `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`; +} + +export function numberOption(value: unknown) { + const parsed = Number.parseInt(String(value ?? ""), 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function parseJsonMaybe(value: unknown) { + if (value && typeof value === "object") return value as any; + try { + return JSON.parse(String(value ?? "").trim()); + } catch { + return null; + } +} + +export function text(value: unknown) { + return String(value ?? "").trim(); +} + +export function clean>(value: T): T { + return Object.fromEntries( + Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null), + ) as T; +} type AgentTraceCommand = { source: string; seq?: number; rowId?: string; header?: string; toolName?: string; status?: string; command?: string; normalizedCommand?: string; bodyPreview?: string; exitCode?: number }; const MAX_AGENT_TRACE_COMMANDS = 30; @@ -231,7 +264,7 @@ function selectRepresentativeHwpodCommands(commands: AgentTraceCommand[]) { }); } -function arrayOfObjects(value: unknown): Record[] { +export function arrayOfObjects(value: unknown): Record[] { return Array.isArray(value) ? value.filter((item) => item && typeof item === "object" && !Array.isArray(item)) as Record[] : []; } @@ -271,6 +304,6 @@ function unquoteShellCommand(value: string) { return value.replace(/^\/bin\/sh\s+-lc\s+/u, "").replace(/^['"]|['"]$/gu, "").trim(); } -function uniqueStrings(values: string[]) { +export function uniqueStrings(values: string[]) { return Array.from(new Set(values.map((value) => text(value)).filter(Boolean))); } diff --git a/tools/src/hwlab-caserun-shared.ts b/tools/src/hwlab-caserun-shared.ts index 18c8d344..dbf4639e 100644 --- a/tools/src/hwlab-caserun-shared.ts +++ b/tools/src/hwlab-caserun-shared.ts @@ -3,6 +3,7 @@ import type { AgentDiffCollectionSummary } from "./hwlab-caserun-diff.ts"; import type { AgentToolCallSummary } from "./hwlab-caserun-evidence.ts"; +import type { TraceEventRow } from "./hwlab-cli/trace-renderer.ts"; export type EnvLike = Record; export type ParsedArgs = Record & { _: string[] }; diff --git a/tools/src/hwlab-caserun-subject.ts b/tools/src/hwlab-caserun-subject.ts index fe0f7d4a..155898ea 100644 --- a/tools/src/hwlab-caserun-subject.ts +++ b/tools/src/hwlab-caserun-subject.ts @@ -5,17 +5,27 @@ import { createHash, randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { readHwpodSpec } from "./hwpod-harness-lib.ts"; -import { firstNumberOption } from "./hwlab-caserun-evidence.ts"; +import { + clean, + clipText, + compactObject, + firstNumberOption, + numberOption, + parseJsonMaybe, + text, +} from "./hwlab-caserun-evidence.ts"; import type { CaseContext, PreparedCaseRun, PreparedSubjectRun, SourceRootSnapshot } from "./hwlab-caserun-shared.ts"; -const DEFAULT_POLL_INTERVAL_MS = 1000; const DEFAULT_JOB_TIMEOUT_MS = 50000; const MAX_JOB_TIMEOUT_MS = 300000; -function text(value: unknown) { return String(value ?? "").trim(); } -function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; } -function clean(value: any) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)); } -function compactObject(value: any) { const json = JSON.stringify(value ?? null); return json.length <= 4000 ? value : { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) }; } -function clipText(value: unknown, maxBytes = 2000) { const raw = String(value ?? ""); const buffer = Buffer.from(raw, "utf8"); return buffer.length <= maxBytes ? raw : `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`; } -function parseJsonMaybe(value: unknown) { if (value && typeof value === "object") return value as any; try { return JSON.parse(String(value ?? "").trim()); } catch { return null; } } -function cliError(code: string, message: string, details: any = {}) { return Object.assign(new Error(message), { code, details }); } -function slug(value: string) { return value.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "") || "case"; } +const DEFAULT_POLL_INTERVAL_MS = 1000; +const DEFAULT_JOB_TIMEOUT_MS = 50000; +const MAX_JOB_TIMEOUT_MS = 300000; + +function cliError(code: string, message: string, details: any = {}) { + return Object.assign(new Error(message), { code, details }); +} + +function slug(value: string) { + return value.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "") || "case"; +} export async function prepareSubjectWorktree(context: CaseContext, input: { caseId: string; runId: string; runDir: string; apiUrl: string; definition: Record; sourceSpecPath: string }, collectSnapshot: (context: CaseContext, run: PreparedCaseRun, label: string) => Promise) { const subject = subjectFromDefinition(input.definition); const worktreePath = subjectWorktreePath(subject.repoLocalPath, input.runId);