diff --git a/tools/hwlab-cli/bin/hwlab-cli.ts b/tools/hwlab-cli/bin/hwlab-cli.ts index dfc91ad9..af396967 100644 --- a/tools/hwlab-cli/bin/hwlab-cli.ts +++ b/tools/hwlab-cli/bin/hwlab-cli.ts @@ -5,4 +5,7 @@ const argv = process.argv.slice(2); if (argv[0] === "kafka") { const { mainKafkaCli } = await import("../../src/hwlab-cli/kafka-regenerate.ts"); await mainKafkaCli(argv.slice(1)); +} else if (argv[0] === "case" && argv[1] === "audit") { + const { mainCaseRunAuditCli } = await import("../../src/hwlab-caserun-audit.ts"); + await mainCaseRunAuditCli(argv.slice(2)); } else await main(argv); diff --git a/tools/hwlab-cli/caserun-audit.test.ts b/tools/hwlab-cli/caserun-audit.test.ts new file mode 100644 index 00000000..a29588f8 --- /dev/null +++ b/tools/hwlab-cli/caserun-audit.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { auditCaseRunSources, renderCaseRunAuditText } from "../src/hwlab-caserun-audit.ts"; + +test("CaseRun static audit passes clean source modules", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "caserun-audit-clean-")); + const sourceDir = path.join(root, "tools", "src"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(path.join(sourceDir, "hwlab-caserun-clean.ts"), "export const value: string = 'ok';\n", "utf8"); + + const report = await auditCaseRunSources({ root }); + assert.equal(report.ok, true, renderCaseRunAuditText(report)); + assert.equal(report.status, "passed"); + assert.equal(report.findingCount, 0); +}); + +test("CaseRun static audit returns typed findings for representative source defects", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "caserun-audit-")); + const sourceDir = path.join(root, "tools", "src"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(path.join(sourceDir, "hwlab-caserun-runtime.ts"), [ + "import { helper } from './hwlab-caserun-helper.ts';", + "const first = 1; const second = 2;", + "export type MissingAlias = MissingCaseRunType;", + "export const incompatibleValue: string = first;", + "export const missingProperty = ({ value: first }).missing;", + "export const runtimeValue = helper(first + second);", + ].join("\n"), "utf8"); + await writeFile(path.join(sourceDir, "hwlab-caserun-helper.ts"), [ + "import { runtimeValue } from './hwlab-caserun-runtime.ts';", + "function duplicate(value: string) { return String(value).trim(); }", + "export function helper(value: number) { return duplicate(runtimeValue + value); }", + ].join("\n"), "utf8"); + await writeFile(path.join(sourceDir, "hwlab-caserun-other.ts"), [ + "function duplicate(value: string) { return String(value).trim(); }", + ...Array.from({ length: 20 }, (_, index) => `export const line${index} = ${index};`), + ].join("\n"), "utf8"); + + const report = await auditCaseRunSources({ root, maxLines: 10 }); + const codes = new Set(report.findings.map((finding) => finding.code)); + assert.equal(report.ok, false); + assert.equal(report.status, "failed"); + assert.ok(codes.has("caserun_type_ts2304")); + assert.ok(codes.has("caserun_type_ts2322")); + assert.ok(codes.has("caserun_type_ts2339")); + assert.ok(codes.has("caserun_runtime_reverse_dependency")); + assert.ok(codes.has("caserun_dependency_cycle")); + assert.ok(codes.has("caserun_duplicate_pure_helper")); + assert.ok(codes.has("caserun_compressed_top_level_declaration")); + assert.ok(codes.has("caserun_source_line_limit_exceeded")); +}); diff --git a/tools/src/hwlab-caserun-audit.ts b/tools/src/hwlab-caserun-audit.ts new file mode 100644 index 00000000..e382d28d --- /dev/null +++ b/tools/src/hwlab-caserun-audit.ts @@ -0,0 +1,461 @@ +// Responsibility: bounded, read-only static audit for CaseRun TypeScript source modules. + +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; + +import ts from "typescript"; + +const DEFAULT_SOURCE_DIR = "tools/src"; +const DEFAULT_MAX_LINES = 2000; +const MAX_TEXT_FINDINGS = 20; +const MAX_JSON_FINDINGS = 100; + +type AuditCategory = "type_check" | "dependency" | "duplicate_helper" | "compressed_declaration" | "line_count"; + +export type CaseRunAuditFinding = { + code: string; + category: AuditCategory; + file: string; + line: number; + message: string; + details?: Record; +}; + +export type CaseRunAuditReport = { + ok: boolean; + action: "case.audit"; + status: "passed" | "failed"; + scope: { + root: string; + sourceDir: string; + fileCount: number; + maxLines: number; + }; + checks: Record; + findings: CaseRunAuditFinding[]; + findingCount: number; + findingsTruncated: boolean; +}; + +type SourceModule = { + absolutePath: string; + relativePath: string; + sourceFile: ts.SourceFile; + text: string; + lineCount: number; +}; + +type AuditOptions = { + root?: string; + sourceDir?: string; + maxLines?: number; + maxFindings?: number; +}; + +export async function mainCaseRunAuditCli(argv = process.argv.slice(2)) { + const json = argv.includes("--json"); + try { + const parsed = parseAuditArgs(argv); + if (parsed.help) { + process.stdout.write(`${renderCaseRunAuditHelp()}\n`); + process.exitCode = 0; + return; + } + const report = await auditCaseRunSources({ + root: parsed.root, + sourceDir: parsed.sourceDir, + maxLines: parsed.maxLines, + maxFindings: json ? MAX_JSON_FINDINGS : MAX_TEXT_FINDINGS, + }); + process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : `${renderCaseRunAuditText(report)}\n`); + process.exitCode = report.ok ? 0 : 2; + } catch (error) { + const failure = auditFailure(error); + process.stdout.write(json ? `${JSON.stringify(failure, null, 2)}\n` : `${renderAuditFailureText(failure)}\n`); + process.exitCode = 1; + } +} + +export async function auditCaseRunSources(options: AuditOptions = {}): Promise { + const root = path.resolve(options.root ?? process.cwd()); + const sourceDir = normalizeSourceDir(options.sourceDir ?? DEFAULT_SOURCE_DIR); + const maxLines = positiveInteger(options.maxLines ?? DEFAULT_MAX_LINES, "maxLines"); + const maxFindings = positiveInteger(options.maxFindings ?? MAX_JSON_FINDINGS, "maxFindings"); + const modules = await loadSourceModules(root, sourceDir); + if (modules.length === 0) throw cliError("caserun_audit_scope_empty", "no CaseRun TypeScript source modules were found", { root, sourceDir }); + + const findings = [ + ...typeCheckFindings(modules), + ...dependencyFindings(modules), + ...duplicateHelperFindings(modules), + ...compressedDeclarationFindings(modules), + ...lineCountFindings(modules, maxLines), + ].sort(compareFindings); + const boundedFindings = findings.slice(0, maxFindings); + const checks = checkSummary(findings); + return { + ok: findings.length === 0, + action: "case.audit", + status: findings.length === 0 ? "passed" : "failed", + scope: { root, sourceDir, fileCount: modules.length, maxLines }, + checks, + findings: boundedFindings, + findingCount: findings.length, + findingsTruncated: boundedFindings.length !== findings.length, + }; +} + +export function renderCaseRunAuditText(report: CaseRunAuditReport) { + const checkOrder: AuditCategory[] = ["type_check", "dependency", "duplicate_helper", "compressed_declaration", "line_count"]; + const lines = [ + `CaseRun static audit: ${report.status}`, + `scope: ${report.scope.sourceDir} files=${report.scope.fileCount} maxLines=${report.scope.maxLines}`, + ...checkOrder.map((category) => `${category}: ${report.checks[category].status} findings=${report.checks[category].findingCount}`), + ]; + for (const finding of report.findings) lines.push(`${finding.code} ${finding.file}:${finding.line} ${finding.message}`); + if (report.findingsTruncated) lines.push(`findings: showing=${report.findings.length} total=${report.findingCount}`); + return lines.join("\n"); +} + +function renderCaseRunAuditHelp() { + return [ + "CaseRun static audit", + "usage: hwlab-cli case audit [--json] [--root PATH] [--source-dir PATH] [--max-lines N]", + "exit codes: 0 passed, 1 command failure, 2 audit findings", + ].join("\n"); +} + +async function loadSourceModules(root: string, sourceDir: string) { + const absoluteSourceDir = path.resolve(root, sourceDir); + const entries = await readdir(absoluteSourceDir, { withFileTypes: true }); + const fileNames = entries + .filter((entry) => entry.isFile() && /^hwlab-caserun-.*\.ts$/u.test(entry.name)) + .map((entry) => entry.name) + .sort(); + return Promise.all(fileNames.map(async (fileName): Promise => { + const absolutePath = path.join(absoluteSourceDir, fileName); + const text = await readFile(absolutePath, "utf8"); + return { + absolutePath, + relativePath: repoRelative(root, absolutePath), + sourceFile: ts.createSourceFile(absolutePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS), + text, + lineCount: text.length === 0 ? 0 : text.split(/\r?\n/u).length, + }; + })); +} + +function typeCheckFindings(modules: SourceModule[]) { + const moduleByPath = new Map(modules.map((module) => [path.resolve(module.absolutePath), module])); + const program = ts.createProgram([...moduleByPath.keys()], { + allowImportingTsExtensions: true, + allowJs: false, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + skipLibCheck: true, + strict: false, + target: ts.ScriptTarget.ES2022, + }); + return ts.getPreEmitDiagnostics(program) + .filter((diagnostic) => diagnostic.file && moduleByPath.has(path.resolve(diagnostic.file.fileName))) + .filter(isActionableDiagnostic) + .map((diagnostic): CaseRunAuditFinding => { + const module = moduleByPath.get(path.resolve(diagnostic.file!.fileName))!; + const line = diagnostic.start === undefined ? 1 : diagnostic.file!.getLineAndCharacterOfPosition(diagnostic.start).line + 1; + return { + code: `caserun_type_ts${diagnostic.code}`, + category: "type_check", + file: module.relativePath, + line, + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, " "), + details: { diagnosticCode: diagnostic.code }, + }; + }); +} + +function isActionableDiagnostic(diagnostic: ts.Diagnostic) { + if (diagnostic.category !== ts.DiagnosticCategory.Error) return false; + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, " "); + if (diagnostic.code === 2307 && /Cannot find module '(node:|typescript|kafkajs|yaml|pg|playwright)/u.test(message)) return false; + if ([2580, 2591].includes(diagnostic.code) && /Cannot find name '(Buffer|process)'/u.test(message)) return false; + if (diagnostic.code === 2552 && /Cannot find name 'Buffer'/u.test(message)) return false; + if ([2867, 2868].includes(diagnostic.code) && /Cannot find name 'Bun'/u.test(message)) return false; + if (diagnostic.code === 2503 && /Cannot find namespace 'NodeJS'/u.test(message)) return false; + return true; +} + +function dependencyFindings(modules: SourceModule[]) { + const byAbsolutePath = new Map(modules.map((module) => [path.resolve(module.absolutePath), module])); + const edges = new Map(modules.map((module) => [module.absolutePath, runtimeDependencies(module, byAbsolutePath)])); + const findings: CaseRunAuditFinding[] = []; + for (const [from, targets] of edges) { + for (const target of targets) { + if (path.basename(target) !== "hwlab-caserun-runtime.ts" || path.basename(from) === "hwlab-caserun-lib.ts") continue; + const module = byAbsolutePath.get(path.resolve(from))!; + findings.push({ + code: "caserun_runtime_reverse_dependency", + category: "dependency", + file: module.relativePath, + line: importLineFor(module, target), + message: "CaseRun source module must not depend on the runtime orchestrator", + details: { target: byAbsolutePath.get(path.resolve(target))?.relativePath ?? target }, + }); + } + } + for (const cycle of dependencyCycles(edges)) { + const first = byAbsolutePath.get(path.resolve(cycle[0]))!; + findings.push({ + code: "caserun_dependency_cycle", + category: "dependency", + file: first.relativePath, + line: 1, + message: `runtime dependency cycle: ${cycle.map((item) => path.basename(item)).join(" -> ")}`, + details: { cycle: cycle.map((item) => byAbsolutePath.get(path.resolve(item))?.relativePath ?? item) }, + }); + } + return findings; +} + +function runtimeDependencies(module: SourceModule, modules: Map) { + const dependencies = new Set(); + for (const statement of module.sourceFile.statements) { + if (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) continue; + if (!statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier)) continue; + if (isTypeOnlyDependency(statement)) continue; + const specifier = statement.moduleSpecifier.text; + if (!specifier.startsWith(".")) continue; + const resolved = resolveModulePath(module.absolutePath, specifier); + if (resolved && modules.has(path.resolve(resolved))) dependencies.add(path.resolve(resolved)); + } + return [...dependencies].sort(); +} + +function isTypeOnlyDependency(statement: ts.ImportDeclaration | ts.ExportDeclaration) { + if (ts.isExportDeclaration(statement)) return statement.isTypeOnly; + const clause = statement.importClause; + if (!clause) return false; + if (clause.isTypeOnly) return true; + if (clause.name) return false; + const bindings = clause.namedBindings; + return Boolean(bindings && ts.isNamedImports(bindings) && bindings.elements.length > 0 && bindings.elements.every((element) => element.isTypeOnly)); +} + +function resolveModulePath(sourcePath: string, specifier: string) { + const candidate = path.resolve(path.dirname(sourcePath), specifier); + if (path.extname(candidate)) return candidate; + return `${candidate}.ts`; +} + +function importLineFor(module: SourceModule, target: string) { + for (const statement of module.sourceFile.statements) { + if ((!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || !statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier)) continue; + if (path.resolve(resolveModulePath(module.absolutePath, statement.moduleSpecifier.text) ?? "") === path.resolve(target)) return lineOf(module.sourceFile, statement); + } + return 1; +} + +function dependencyCycles(edges: Map) { + const cycles = new Map(); + const visiting = new Set(); + const visited = new Set(); + const stack: string[] = []; + const visit = (node: string) => { + if (visiting.has(node)) { + const start = stack.indexOf(node); + const cycle = [...stack.slice(start), node]; + const key = canonicalCycle(cycle); + cycles.set(key, cycle); + return; + } + if (visited.has(node)) return; + visiting.add(node); + stack.push(node); + for (const target of edges.get(node) ?? []) visit(target); + stack.pop(); + visiting.delete(node); + visited.add(node); + }; + for (const node of edges.keys()) visit(node); + return [...cycles.values()]; +} + +function canonicalCycle(cycle: string[]) { + const nodes = cycle.slice(0, -1).map((item) => path.basename(item)); + const rotations = nodes.map((_, index) => [...nodes.slice(index), ...nodes.slice(0, index)].join("|")); + return rotations.sort()[0] ?? ""; +} + +function duplicateHelperFindings(modules: SourceModule[]) { + const helpers = new Map(); + for (const module of modules) { + for (const statement of module.sourceFile.statements) { + if (!ts.isFunctionDeclaration(statement) || !statement.name || !statement.body || !isPureHelper(statement)) continue; + const name = statement.name.text; + const entries = helpers.get(name) ?? []; + entries.push({ module, node: statement, fingerprint: helperFingerprint(statement, module.sourceFile) }); + helpers.set(name, entries); + } + } + const findings: CaseRunAuditFinding[] = []; + for (const [name, entries] of helpers) { + const byFingerprint = new Map(); + for (const entry of entries) { + const matches = byFingerprint.get(entry.fingerprint) ?? []; + matches.push(entry); + byFingerprint.set(entry.fingerprint, matches); + } + for (const matches of byFingerprint.values()) { + if (matches.length < 2) continue; + for (const entry of matches) findings.push({ + code: "caserun_duplicate_pure_helper", + category: "duplicate_helper", + file: entry.module.relativePath, + line: lineOf(entry.module.sourceFile, entry.node), + message: `duplicate top-level pure helper: ${name}`, + details: { declarations: matches.map((match) => `${match.module.relativePath}:${lineOf(match.module.sourceFile, match.node)}`) }, + }); + } + } + return findings; +} + +function isPureHelper(node: ts.FunctionDeclaration) { + if (node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) || node.asteriskToken) return false; + let impure = false; + const visit = (child: ts.Node) => { + if (ts.isAwaitExpression(child) || ts.isYieldExpression(child) || ts.isThrowStatement(child) || ts.isNewExpression(child) || ts.isDeleteExpression(child)) impure = true; + if (ts.isBinaryExpression(child) && isAssignmentOperatorKind(child.operatorToken.kind)) impure = true; + if (ts.isPrefixUnaryExpression(child) || ts.isPostfixUnaryExpression(child)) { + if (child.operator === ts.SyntaxKind.PlusPlusToken || child.operator === ts.SyntaxKind.MinusMinusToken) impure = true; + } + if (!impure) ts.forEachChild(child, visit); + }; + visit(node.body!); + return !impure; +} + +function isAssignmentOperatorKind(kind: ts.SyntaxKind) { + return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment; +} + +function helperFingerprint(node: ts.FunctionDeclaration, sourceFile: ts.SourceFile) { + return `${node.typeParameters?.length ?? 0}:${node.parameters.length}:${node.body!.getText(sourceFile).replace(/\s+/gu, " ").trim()}`; +} + +function compressedDeclarationFindings(modules: SourceModule[]) { + const findings: CaseRunAuditFinding[] = []; + for (const module of modules) { + const statementsByLine = new Map(); + for (const statement of module.sourceFile.statements) { + const line = lineOf(module.sourceFile, statement); + const statements = statementsByLine.get(line) ?? []; + statements.push(statement); + statementsByLine.set(line, statements); + } + for (const [line, statements] of statementsByLine) { + if (statements.length < 2 || !statements.some(isDeclarationStatement)) continue; + findings.push({ + code: "caserun_compressed_top_level_declaration", + category: "compressed_declaration", + file: module.relativePath, + line, + message: "multiple top-level declarations share one physical line", + details: { statementCount: statements.length }, + }); + } + } + return findings; +} + +function isDeclarationStatement(statement: ts.Statement) { + return ts.isVariableStatement(statement) || ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement); +} + +function lineCountFindings(modules: SourceModule[], maxLines: number) { + return modules.flatMap((module): CaseRunAuditFinding[] => module.lineCount < maxLines ? [] : [{ + code: "caserun_source_line_limit_exceeded", + category: "line_count", + file: module.relativePath, + line: maxLines, + message: `CaseRun source file must stay below ${maxLines} lines; found ${module.lineCount}`, + details: { lineCount: module.lineCount, maxLines, comparison: "<" }, + }]); +} + +function checkSummary(findings: CaseRunAuditFinding[]) { + const categories: AuditCategory[] = ["type_check", "dependency", "duplicate_helper", "compressed_declaration", "line_count"]; + return Object.fromEntries(categories.map((category) => { + const findingCount = findings.filter((finding) => finding.category === category).length; + return [category, { status: findingCount === 0 ? "passed" : "failed", findingCount }]; + })) as CaseRunAuditReport["checks"]; +} + +function parseAuditArgs(argv: string[]) { + const result: { help: boolean; root?: string; sourceDir?: string; maxLines?: number } = { help: false }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--json") continue; + if (["--help", "-h", "help"].includes(argument)) { + result.help = true; + continue; + } + const [name, inlineValue] = argument.split("=", 2); + if (["--root", "--source-dir", "--max-lines"].includes(name)) { + const value = inlineValue ?? argv[index + 1]; + if (!inlineValue) index += 1; + if (!value) throw cliError("missing_required_value", `${name} requires a value`, { option: name }); + if (name === "--root") result.root = value; + if (name === "--source-dir") result.sourceDir = value; + if (name === "--max-lines") result.maxLines = positiveInteger(Number(value), "maxLines"); + continue; + } + throw cliError("unsupported_case_audit_option", `unsupported case audit option: ${argument}`, { option: argument }); + } + return result; +} + +function auditFailure(error: unknown) { + const value = error as { code?: string; message?: string; details?: Record }; + return { + ok: false, + action: "case.audit", + status: "error", + error: { + code: value?.code ?? "caserun_audit_failed", + message: value?.message ?? String(error), + details: value?.details ?? {}, + }, + }; +} + +function renderAuditFailureText(failure: ReturnType) { + return `CaseRun static audit: error\n${failure.error.code}: ${failure.error.message}`; +} + +function normalizeSourceDir(value: string) { + const normalized = value.replace(/\\/gu, "/").replace(/^\.\//u, "").replace(/\/$/u, ""); + if (!normalized || normalized === ".." || normalized.startsWith("../") || path.isAbsolute(value)) throw cliError("invalid_case_audit_source_dir", "sourceDir must be a relative path inside the audit root", { sourceDir: value }); + return normalized; +} + +function positiveInteger(value: number, field: string) { + if (!Number.isInteger(value) || value <= 0) throw cliError("invalid_positive_integer", `${field} must be a positive integer`, { field, value }); + return value; +} + +function repoRelative(root: string, absolutePath: string) { + return path.relative(root, absolutePath).replace(/\\/gu, "/"); +} + +function lineOf(sourceFile: ts.SourceFile, node: ts.Node) { + return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; +} + +function compareFindings(left: CaseRunAuditFinding, right: CaseRunAuditFinding) { + return left.category.localeCompare(right.category) || left.file.localeCompare(right.file) || left.line - right.line || left.code.localeCompare(right.code); +} + +function cliError(code: string, message: string, details: Record = {}) { + return Object.assign(new Error(message), { code, details }); +} diff --git a/tools/src/hwlab-caserun-closeout.ts b/tools/src/hwlab-caserun-closeout.ts index 6c109caa..3272c792 100644 --- a/tools/src/hwlab-caserun-closeout.ts +++ b/tools/src/hwlab-caserun-closeout.ts @@ -1,32 +1,92 @@ // SPEC: PJ2026-0103 HarnessRL draft-2026-06-25-p0-web-caserun-e2e. // Responsibility: CaseRun evidence rendering, archive, aggregate, and terminal closeout. -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, + agentToolCallSummaryFromResult, arrayOfObjects, clean, + cliError, clipText, + commandVisibility, commandsFromToolCallSummary, compactObject, hwpodCommandKind, mentionsHwpod, numberOption, parseJsonMaybe, + sha256, summarizeAgentTrace, text, uniqueStrings, } from "./hwlab-caserun-evidence.ts"; -import type { ArchivedAgentTraceSnapshot, AgentTraceCommand, AgentTraceStage, CaseContext, CaseReadableAgentArchive, CaseRegistryArchive, CaseRegistryArchiveFile, PreparedCaseRun } from "./hwlab-caserun-shared.ts"; +import { webUrlFrom } from "./hwlab-caserun-runtime-config.ts"; +import type { AgentRunStage, 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 sha256Buffer(value: Buffer) { - return createHash("sha256").update(value).digest("hex"); +export function agentTraceLookup(input: { baseUrl: string; traceId: string; sessionId?: string; conversationId?: string; threadId?: string }) { + const traceId = input.traceId; + const commands = clean({ + result: `hwlab-cli client agent result ${traceId}`, + trace: `hwlab-cli client agent trace ${traceId} --render web`, + traceFull: `hwlab-cli client agent trace ${traceId} --render web --full`, + inspect: `hwlab-cli client agent inspect --trace-id ${traceId}`, + sessionStatus: input.sessionId ? `hwlab-cli client agent session status ${input.sessionId}` : undefined, + }); + return clean({ + source: "hwlab-cli.client.agent", + strategy: "id_plus_existing_cli", + note: "CaseRun records trace/session identity and reuses existing HWLAB Web-equivalent agent CLI for trace/result/inspect; it does not implement a separate trace query path.", + baseUrl: input.baseUrl, + traceId, + sessionId: input.sessionId, + conversationId: input.conversationId, + threadId: input.threadId, + commands, + }); +} + +export function agentTraceIdentityArtifact(run: PreparedCaseRun, agent: AgentRunStage) { + const traceId = text(agent.traceId); + const lookup = agent.traceLookup ?? agentTraceLookup({ baseUrl: agent.baseUrl, traceId, sessionId: agent.sessionId, conversationId: agent.conversationId, threadId: agent.threadId }); + const resultBody = agent.result?.body && typeof agent.result.body === "object" ? agent.result.body : agent.result; + return clean({ + contractVersion: "hwpod-case-run-agent-trace-identity-v1", + source: "caserun-identity", + lookupOnly: true, + status: text(resultBody?.status ?? agent.stageStatus) || "recorded", + traceId, + sessionId: agent.sessionId, + conversationId: agent.conversationId, + threadId: agent.threadId, + providerProfile: agent.providerProfile, + requestedProviderProfile: agent.requestedProviderProfile, + resolvedBackendProfile: agent.resolvedBackendProfile, + provider: agent.provider, + model: agent.model, + backend: agent.backend, + infrastructureBackend: agent.infrastructureBackend, + providerTrace: agent.providerTrace, + projectId: agent.projectId, + resultStatus: text(resultBody?.status), + terminalStatus: agent.terminalStatus, + commandStatus: agent.commandStatus, + agentRunStatus: agent.agentRunStatus, + toolCallSummary: agent.toolCallSummary ?? agentToolCallSummaryFromResult(resultBody), + finalResponse: resultBody?.finalResponse ?? resultBody?.reply ?? null, + traceSummary: resultBody?.traceSummary ?? null, + terminalEvidence: resultBody?.terminalEvidence ?? null, + agentRun: compactObject(resultBody?.agentRun ?? resultBody?.traceSummary?.agentRun ?? null), + lookup, + hint: "Use lookup.commands.trace or lookup.commands.result to query the full Web-equivalent AgentRun trace with the existing HWLAB CLI.", + runId: run.runId, + caseId: run.caseId, + }); } function requiredText(value: unknown, field: string) { @@ -305,7 +365,7 @@ export function aggregateTraceBody(messages: any, traceMarkdown: string) { return renderTraceRowsMarkdown(rows.map((row) => traceEventRowFromAggregate(row))); } if (traceMarkdown) { - return renderTraceRowsMarkdown([{ rowId: "trace-markdown", seq: null, tone: "source", header: "Rendered trace markdown", body: traceMarkdown, bodyFormat: "markdown" }]); + return renderTraceRowsMarkdown([{ rowId: "trace-markdown", seq: null, sequenceAuthority: "source", tone: "source", header: "Rendered trace markdown", body: traceMarkdown, bodyFormat: "markdown" }]); } return `_No readable trace artifact was found._`; } @@ -353,6 +413,7 @@ export function traceEventRowFromAggregate(row: Record): TraceE return { rowId: text(row.rowId) || text(row.header) || "trace-row", seq: numberOption(row.seq) ?? null, + sequenceAuthority: row.sequenceAuthority === "projected" ? "projected" : "source", tone: traceRowTone(row.tone), header: text(row.header) || text(row.rowId) || "trace row", body: row.body === undefined || row.body === null ? null : String(row.body), @@ -398,7 +459,7 @@ export function aggregateArtifactIndex(manifest: any) { export async function registryAggregateFileRecord(file: string) { const content = await readFile(file); - return { bytes: content.length, sha256: sha256Buffer(content) }; + return { bytes: content.length, sha256: sha256(content) }; } export async function archiveCaseRunArtifacts(context: CaseContext, run: PreparedCaseRun, evidence: any, options: { sync?: boolean } = {}): Promise { @@ -568,7 +629,7 @@ export async function materializeFullAgentTraceWithExistingCli(context: CaseCont 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 baseUrl = text((lookup as any).baseUrl) || webUrlFrom(context); 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) { @@ -608,6 +669,7 @@ export function traceEventRowFromObject(row: Record): TraceEven return { rowId: text(row.rowId ?? row.id), seq: numberOption(row.seq) ?? null, + sequenceAuthority: row.sequenceAuthority === "projected" ? "projected" : "source", tone: traceRowTone(row.tone), header: text(row.header ?? row.title ?? row.label), body: row.body === null || row.body === undefined ? null : text(row.body ?? row.content ?? row.text ?? row.message), @@ -783,7 +845,7 @@ export async function registryArtifactFileRecord(run: PreparedCaseRun, caseRepoR return clean({ path: artifactRel(rel), bytes: content.length, - sha256: sha256Buffer(content), + sha256: sha256(content), source: artifactSourceLabel(run, source) }); } diff --git a/tools/src/hwlab-caserun-diff.ts b/tools/src/hwlab-caserun-diff.ts index 89045444..852116e7 100644 --- a/tools/src/hwlab-caserun-diff.ts +++ b/tools/src/hwlab-caserun-diff.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { sha256 } from "./hwlab-caserun-evidence.ts"; type CommandResult = { stdout?: unknown } & Record; @@ -165,7 +165,6 @@ function patchPaths(value: string) { return unique(text(value).split(/\r?\n/u).f function normalizeNewFilePatch(value: string, filePath: string) { const rel = repoPath(filePath); return text(value).replace(/^diff --git a\/dev\/null b\/(.*?)$/mu, `diff --git a/${rel} b/${rel}`).replace(/^--- a\/dev\/null$/mu, "--- /dev/null").replace(/^\+\+\+ b\/(.*?)$/mu, `+++ b/${rel}`); } function joinPatch(tracked: string, untracked: string, omitted: AgentDiffFileRecord[]) { return [text(tracked), text(untracked), omitted.length ? ["# CaseRun diffCollection omitted files from patch body:", ...omitted.map((file) => `# - ${file.path} rule=${file.rule ?? ""} reason=${file.reason ?? ""} bytes=${file.bytes ?? ""} sha256=${file.sha256 ?? ""}`)].join("\n") : ""].filter(Boolean).join("\n"); } function joinStat(tracked: string, untracked: string, omitted: AgentDiffFileRecord[]) { return [text(tracked), text(untracked), omitted.length ? ["[omitted by CaseRun diffCollection]", ...omitted.map((file) => ` ${file.path} | omitted rule=${file.rule ?? ""} reason=${file.reason ?? ""} bytes=${file.bytes ?? ""} sha256=${file.sha256 ?? ""}`)].join("\n") : ""].filter(Boolean).join("\n"); } -function sha256(value: string) { return createHash("sha256").update(value).digest("hex"); } function repoPath(value: unknown) { return text(value).replace(/\\/gu, "/").replace(/^\.\//u, "").replace(/^\/+|\/+$/gu, ""); } function text(value: unknown) { return typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim(); } function record(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } diff --git a/tools/src/hwlab-caserun-evidence.ts b/tools/src/hwlab-caserun-evidence.ts index c79a62f1..badfda50 100644 --- a/tools/src/hwlab-caserun-evidence.ts +++ b/tools/src/hwlab-caserun-evidence.ts @@ -1,6 +1,9 @@ // SPEC: PJ2026-0103 HarnessRL draft-2026-06-25-p0-web-caserun-e2e. // Responsibility: normalize AgentRun provider, terminal, and tool-call evidence for CaseRun. +import { createHash } from "node:crypto"; +import path from "node:path"; + export type AgentToolCallSummaryItem = { source: string; index: number; @@ -145,6 +148,18 @@ export function text(value: unknown) { return String(value ?? "").trim(); } +export function sha256(value: string | Uint8Array) { + return createHash("sha256").update(value).digest("hex"); +} + +export function commandVisibility(command: string[]) { + return command.map((item) => item.includes("/") || item.includes("\\") ? path.basename(item) || item : item); +} + +export function cliError(code: string, message: string, details: Record = {}) { + return Object.assign(new Error(message), { code, details }); +} + export function clean>(value: T): T { return Object.fromEntries( Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null), diff --git a/tools/src/hwlab-caserun-runtime-config.ts b/tools/src/hwlab-caserun-runtime-config.ts new file mode 100644 index 00000000..c579239e --- /dev/null +++ b/tools/src/hwlab-caserun-runtime-config.ts @@ -0,0 +1,36 @@ +// Responsibility: explicit v0.3 CaseRun runtime endpoint resolution. + +import { cliError, text } from "./hwlab-caserun-evidence.ts"; +import type { CaseContext } from "./hwlab-caserun-shared.ts"; + +const REMOVED_V02_API_URL = "http://74.48.78.17:19667"; +const REMOVED_V02_WEB_URL = "http://74.48.78.17:19666"; + +export function webUrlFrom(context: CaseContext) { + const value = text(context.parsed.baseUrl ?? context.parsed.webUrl ?? context.env.HWLAB_RUNTIME_WEB_URL ?? context.env.HWLAB_CLOUD_WEB_URL); + if (!value) { + throw cliError("caserun_runtime_web_url_required", "CaseRun v0.3 requires an explicit runtime Web URL from --base-url, --web-url, HWLAB_RUNTIME_WEB_URL, or HWLAB_CLOUD_WEB_URL; case runtime.webUrl fallback is removed.", { + allowed: ["--base-url", "--web-url", "HWLAB_RUNTIME_WEB_URL", "HWLAB_CLOUD_WEB_URL"], + removedFallbacks: ["case.runtime.webUrl", REMOVED_V02_WEB_URL], + }); + } + return rejectRemovedV02RuntimeUrl(value, "web"); +} + +export function apiUrlFrom(context: CaseContext) { + const value = text(context.parsed.apiUrl ?? context.parsed.apiBaseUrl ?? context.parsed.runtimeApiUrl ?? context.env.HWLAB_RUNTIME_API_URL ?? context.env.HWLAB_CASERUN_RUNTIME_API_URL); + if (!value) { + throw cliError("caserun_runtime_api_url_required", "CaseRun v0.3 requires an explicit runtime API URL from --api-url, --api-base-url, --runtime-api-url, HWLAB_RUNTIME_API_URL, or HWLAB_CASERUN_RUNTIME_API_URL; case runtime.apiUrl fallback is removed.", { + allowed: ["--api-url", "--api-base-url", "--runtime-api-url", "HWLAB_RUNTIME_API_URL", "HWLAB_CASERUN_RUNTIME_API_URL"], + removedFallbacks: ["case.runtime.apiUrl", REMOVED_V02_API_URL], + }); + } + return rejectRemovedV02RuntimeUrl(value, "api"); +} + +function rejectRemovedV02RuntimeUrl(value: string, kind: "api" | "web") { + const normalized = value.replace(/\/+$/u, ""); + const removed = kind === "web" ? REMOVED_V02_WEB_URL : REMOVED_V02_API_URL; + if (normalized === removed) throw cliError("caserun_removed_v02_runtime_url", `CaseRun v0.3 does not support the removed v0.2 ${kind} runtime URL`, { kind, url: normalized }); + return normalized; +} diff --git a/tools/src/hwlab-caserun-runtime.ts b/tools/src/hwlab-caserun-runtime.ts index 3b52e56c..6008b54b 100644 --- a/tools/src/hwlab-caserun-runtime.ts +++ b/tools/src/hwlab-caserun-runtime.ts @@ -2,7 +2,7 @@ // Responsibility: v0.3 CaseRun runtime-origin resolution, evidence normalization, and compile-only HWPOD smoke orchestration; SPEC: PJ2026-0103 HarnessRL draft-2026-06-25-p0-web-caserun-e2e. import { spawn } from "node:child_process"; -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { closeSync, openSync } from "node:fs"; import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -10,13 +10,12 @@ 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 { agentTerminalEvidence, agentToolCallSummaryFromResult, agentWithResultEvidence, applyCaseRunEvidenceRelationships, clean, cliError, clipText, commandVisibility, commandsFromToolCallSummary, compactObject, firstNumberOption, firstTextOption, numberOption, parseJsonMaybe, sha256, summarizeAgentTrace, text, type AgentToolCallSummary } from "./hwlab-caserun-evidence.ts"; +import { apiUrlFrom, webUrlFrom } from "./hwlab-caserun-runtime-config.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"; -import { agentTraceLookupForRun, renderEvidenceSummary, agentStageSummary, agentStageCommand, agentCommandDetail, markdownTableText, writeCaseAggregateFromRegistry, resolveAggregateRunId, aggregateOutputRel, renderCaseAggregateMarkdown, aggregateTraceBody, aggregateMessagesWithFreshFullTrace, freshRenderedTraceBody, traceEventRowFromAggregate, aggregateBullets, aggregateInlineValue, markdownInline, aggregateDetails, escapeHtml, aggregateFence, aggregateArtifactIndex, registryAggregateFileRecord, archiveCaseRunArtifacts, syncCaseRegistryRun, syncCaseRegistryRel, gitProcess, collectSummaryFromArchive, writeReadableAgentArtifacts, archivedAgentTraceSnapshot, existingAgentTraceSummary, archivedAgentTraceBody, materializeFullAgentTraceWithExistingCli, traceRowsFromBody, traceEventRowFromObject, traceRowTone, applyArchivedAgentTraceSummary, agentTraceBody, agentFinalResponse, finalResponseMissingReason, agentMessageRowForArchive, renderAgentTranscript, renderTranscriptRow, renderFinalResponseArtifact, refreshCompletedRunArchive, caseRunArtifactEntries, copyCaseRunArtifact, registryArtifactFileRecord, caseRunArtifactManifest, aggregateManifestEntry, caseRunArtifactTrace, caseRegistryRunRel, artifactRel, artifactSourceLabel } from "./hwlab-caserun-closeout.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, slug } from "./hwlab-caserun-subject.ts"; +import { agentTraceIdentityArtifact, agentTraceLookup, agentTraceLookupForRun, renderEvidenceSummary, agentStageSummary, agentStageCommand, agentCommandDetail, markdownTableText, writeCaseAggregateFromRegistry, resolveAggregateRunId, aggregateOutputRel, renderCaseAggregateMarkdown, aggregateTraceBody, aggregateMessagesWithFreshFullTrace, freshRenderedTraceBody, traceEventRowFromAggregate, aggregateBullets, aggregateInlineValue, markdownInline, aggregateDetails, escapeHtml, aggregateFence, aggregateArtifactIndex, registryAggregateFileRecord, archiveCaseRunArtifacts, syncCaseRegistryRun, syncCaseRegistryRel, gitProcess, collectSummaryFromArchive, writeReadableAgentArtifacts, archivedAgentTraceSnapshot, existingAgentTraceSummary, archivedAgentTraceBody, materializeFullAgentTraceWithExistingCli, traceRowsFromBody, traceEventRowFromObject, traceRowTone, applyArchivedAgentTraceSummary, agentTraceBody, agentFinalResponse, finalResponseMissingReason, agentMessageRowForArchive, renderAgentTranscript, renderTranscriptRow, renderFinalResponseArtifact, refreshCompletedRunArchive, caseRunArtifactEntries, copyCaseRunArtifact, registryArtifactFileRecord, caseRunArtifactManifest, aggregateManifestEntry, caseRunArtifactTrace, caseRegistryRunRel, artifactRel, artifactSourceLabel } from "./hwlab-caserun-closeout.ts"; -const REMOVED_V02_API_URL = "http://74.48.78.17:19667"; -const REMOVED_V02_WEB_URL = "http://74.48.78.17:19666"; const DEFAULT_POLL_INTERVAL_MS = 1000; const DEFAULT_JOB_TIMEOUT_MS = 50000; const MAX_JOB_TIMEOUT_MS = 300000; @@ -74,6 +73,7 @@ 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 audit [--json] [--root PATH] [--source-dir PATH] [--max-lines N]", `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]`, @@ -116,7 +116,7 @@ export async function promptCaseRun(context: CaseContext) { caseFile: loaded.caseFile, sourceSpecPath: loaded.specPath, specPath: loaded.specPath, - apiUrl: apiUrlFrom(context, loaded.definition), + apiUrl: apiUrlFrom(context), compileOnly: true, createdAt: now, updatedAt: now, @@ -215,7 +215,7 @@ export async function startCaseRun(context: CaseContext) { caseFile: "", sourceSpecPath: "", specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"), - apiUrl: apiUrlFrom(context, {}), + apiUrl: apiUrlFrom(context), compileOnly: true, createdAt: context.now(), updatedAt: context.now(), @@ -397,7 +397,7 @@ export async function prepareCaseRun(context: CaseContext, action = "prepare") { const runDir = path.resolve(context.cwd, text(context.parsed.runDir) || path.join(stateRoot(context), runId)); const specPath = path.join(runDir, ".hwlab", "hwpod-spec.yaml"); const now = context.now(); - const apiUrl = apiUrlFrom(context, loaded.definition); + const apiUrl = apiUrlFrom(context); const subject = await prepareSubjectWorktree(context, { caseId, runId, runDir, apiUrl, definition: loaded.definition, sourceSpecPath: loaded.specPath }, collectSourceRootSnapshot); const agentTask = agentTaskFromDefinition(loaded.definition, caseId); const run: PreparedCaseRun = { @@ -683,7 +683,7 @@ async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) { const prompt = await renderAgentTaskPrompt(run, run.agentTask); await writeFile(promptPath, prompt, "utf8"); const promptSha256 = sha256(prompt); - const baseUrl = webUrlFrom(context, run.definition); + const baseUrl = webUrlFrom(context); const providerProfile = text(context.parsed.providerProfile) || run.agentTask.providerProfile; const projectId = text(context.parsed.projectId) || run.agentTask.projectId; const conversationId = text(context.parsed.conversationId) || `cnv_case_${slug(run.caseId)}_${slug(run.runId)}`; @@ -846,67 +846,6 @@ async function submitAgentTask(context: CaseContext, input: { baseUrl: string; p }, "agent_task_submit"); } -function agentTraceLookup(input: { baseUrl: string; traceId: string; sessionId?: string; conversationId?: string; threadId?: string }) { - const traceId = input.traceId; - const base = input.baseUrl; - const commands = clean({ - result: `hwlab-cli client agent result ${traceId}`, - trace: `hwlab-cli client agent trace ${traceId} --render web`, - traceFull: `hwlab-cli client agent trace ${traceId} --render web --full`, - inspect: `hwlab-cli client agent inspect --trace-id ${traceId}`, - sessionStatus: input.sessionId ? `hwlab-cli client agent session status ${input.sessionId}` : undefined - }); - return clean({ - source: "hwlab-cli.client.agent", - strategy: "id_plus_existing_cli", - note: "CaseRun records trace/session identity and reuses existing HWLAB Web-equivalent agent CLI for trace/result/inspect; it does not implement a separate trace query path.", - baseUrl: base, - traceId, - sessionId: input.sessionId, - conversationId: input.conversationId, - threadId: input.threadId, - commands - }); -} - -function agentTraceIdentityArtifact(run: PreparedCaseRun, agent: AgentRunStage) { - const traceId = text(agent.traceId); - const lookup = agent.traceLookup ?? agentTraceLookup({ baseUrl: agent.baseUrl, traceId, sessionId: agent.sessionId, conversationId: agent.conversationId, threadId: agent.threadId }); - const resultBody = agent.result?.body && typeof agent.result.body === "object" ? agent.result.body : agent.result; - return clean({ - contractVersion: "hwpod-case-run-agent-trace-identity-v1", - source: "caserun-identity", - lookupOnly: true, - status: text(resultBody?.status ?? agent.stageStatus) || "recorded", - traceId, - sessionId: agent.sessionId, - conversationId: agent.conversationId, - threadId: agent.threadId, - providerProfile: agent.providerProfile, - requestedProviderProfile: agent.requestedProviderProfile, - resolvedBackendProfile: agent.resolvedBackendProfile, - provider: agent.provider, - model: agent.model, - backend: agent.backend, - infrastructureBackend: agent.infrastructureBackend, - providerTrace: agent.providerTrace, - projectId: agent.projectId, - resultStatus: text(resultBody?.status), - terminalStatus: agent.terminalStatus, - commandStatus: agent.commandStatus, - agentRunStatus: agent.agentRunStatus, - toolCallSummary: agent.toolCallSummary ?? agentToolCallSummaryFromResult(resultBody), - finalResponse: resultBody?.finalResponse ?? resultBody?.reply ?? null, - traceSummary: resultBody?.traceSummary ?? null, - terminalEvidence: resultBody?.terminalEvidence ?? null, - agentRun: compactObject(resultBody?.agentRun ?? resultBody?.traceSummary?.agentRun ?? null), - lookup, - hint: "Use lookup.commands.trace or lookup.commands.result to query the full Web-equivalent AgentRun trace with the existing HWLAB CLI.", - runId: run.runId, - caseId: run.caseId - }); -} - async function pollAgentResult(context: CaseContext, input: { baseUrl: string; traceId: string; acceptedBody: any; resultUrl?: string; run?: PreparedCaseRun; agent?: AgentRunStage }) { const requestedTimeoutMs = effectiveAgentTimeoutMs(context, input.run?.agentTask); const timeoutMs = Math.max(Math.min(requestedTimeoutMs, MAX_AGENT_TIMEOUT_MS), 1000); @@ -1144,17 +1083,6 @@ function caseAgentHeaders(context: CaseContext) { return { "content-type": "application/json", authorization: `Bearer ${apiKey}` }; } -function webUrlFrom(context: CaseContext, definition: any) { - const value = text(context.parsed.baseUrl ?? context.parsed.webUrl ?? context.env.HWLAB_RUNTIME_WEB_URL ?? context.env.HWLAB_CLOUD_WEB_URL); - if (!value) { - throw cliError("caserun_runtime_web_url_required", "CaseRun v0.3 requires an explicit runtime Web URL from --base-url, --web-url, HWLAB_RUNTIME_WEB_URL, or HWLAB_CLOUD_WEB_URL; case runtime.webUrl fallback is removed.", { - allowed: ["--base-url", "--web-url", "HWLAB_RUNTIME_WEB_URL", "HWLAB_CLOUD_WEB_URL"], - removedFallbacks: ["case.runtime.webUrl", REMOVED_V02_WEB_URL] - }); - } - return rejectRemovedV02RuntimeUrl(value, "web"); -} - function firstHwpodOutput(body: any) { const results = Array.isArray(body?.body?.results) ? body.body.results : Array.isArray(body?.results) ? body.results : []; const first = results[0] ?? null; @@ -1216,39 +1144,10 @@ function stringArray(value: unknown) { return Array.isArray(value) ? value.map((item) => text(item)).filter(Boolean) : []; } -function sha256(value: string) { - return createHash("sha256").update(value).digest("hex"); -} - function promptShellArg(value: string) { return `'${String(value).replace(/'/gu, `'\\''`)}'`; } -function sha256Buffer(value: Buffer) { - return createHash("sha256").update(value).digest("hex"); -} - - -function apiUrlFrom(context: CaseContext, definition: any) { - const value = text(context.parsed.apiUrl ?? context.parsed.apiBaseUrl ?? context.parsed.runtimeApiUrl ?? context.env.HWLAB_RUNTIME_API_URL ?? context.env.HWLAB_CASERUN_RUNTIME_API_URL); - if (!value) { - throw cliError("caserun_runtime_api_url_required", "CaseRun v0.3 requires an explicit runtime API URL from --api-url, --api-base-url, --runtime-api-url, HWLAB_RUNTIME_API_URL, or HWLAB_CASERUN_RUNTIME_API_URL; case runtime.apiUrl fallback is removed.", { - allowed: ["--api-url", "--api-base-url", "--runtime-api-url", "HWLAB_RUNTIME_API_URL", "HWLAB_CASERUN_RUNTIME_API_URL"], - removedFallbacks: ["case.runtime.apiUrl", REMOVED_V02_API_URL] - }); - } - return rejectRemovedV02RuntimeUrl(value, "api"); -} - -function rejectRemovedV02RuntimeUrl(value: string, kind: "api" | "web") { - const normalized = value.replace(/\/+$/u, ""); - const removed = kind === "web" ? REMOVED_V02_WEB_URL : REMOVED_V02_API_URL; - if (normalized === removed) { - throw cliError("caserun_removed_v02_runtime_url", `CaseRun v0.3 does not support the removed v0.2 ${kind} runtime URL`, { kind, url: normalized }); - } - return normalized; -} - function stateRoot(context: CaseContext) { return text(context.parsed.stateDir) || path.join(".state", "hwlab-cli", "caserun"); } @@ -1347,7 +1246,7 @@ async function loadControlRunSkeleton(context: CaseContext, caseId: string, runI caseFile: "", sourceSpecPath: "", specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"), - apiUrl: apiUrlFrom(context, {}), + apiUrl: apiUrlFrom(context), compileOnly: true, createdAt: context.now(), updatedAt: context.now(), @@ -1489,34 +1388,5 @@ function extractPrepareBlocker(summary: ReturnType) { return null; } -export function commandVisibility(command: string[]) { - return command.map((item) => item.includes("/") || item.includes("\\") ? path.basename(item) || item : item); -} - -function compactObject(value: any) { - const json = JSON.stringify(value ?? null); - if (json.length <= 4000) return value; - return { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) }; -} - -function parseJsonMaybe(value: unknown) { - if (value && typeof value === "object") return value as any; - const raw = String(value ?? "").trim(); - if (!raw) return null; - try { return JSON.parse(raw); } catch { return null; } -} - -function clipText(value: unknown, maxBytes = 2000) { - const raw = String(value ?? ""); - const buffer = Buffer.from(raw, "utf8"); - if (buffer.length <= maxBytes) return raw; - return `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`; -} - function ok(action: string, extra: Record = {}, status = "succeeded") { return { ok: status !== "failed", action, status, ...extra }; } -function cliError(code: string, message: string, details: Record = {}) { return Object.assign(new Error(message), { code, details }); } function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw cliError("missing_required_value", `${field} is required`, { field }); return result; } -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 slug(value: string) { return value.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "") || "case"; } -function clean>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T; } diff --git a/tools/src/hwlab-caserun-subject.ts b/tools/src/hwlab-caserun-subject.ts index 155898ea..34a2cdb0 100644 --- a/tools/src/hwlab-caserun-subject.ts +++ b/tools/src/hwlab-caserun-subject.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { readHwpodSpec } from "./hwpod-harness-lib.ts"; import { clean, + cliError, clipText, compactObject, firstNumberOption, @@ -19,11 +20,7 @@ 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) { +export 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) {