Merge pull request #2578 from pikasTech/feat/caserun-static-audit
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success

CaseRun 模块单命令静态审计入口
This commit is contained in:
Lyon
2026-07-16 23:49:21 +08:00
committed by GitHub
9 changed files with 654 additions and 156 deletions
+3
View File
@@ -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);
+55
View File
@@ -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"));
});
+461
View File
@@ -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<string, unknown>;
};
export type CaseRunAuditReport = {
ok: boolean;
action: "case.audit";
status: "passed" | "failed";
scope: {
root: string;
sourceDir: string;
fileCount: number;
maxLines: number;
};
checks: Record<AuditCategory, { status: "passed" | "failed"; findingCount: number }>;
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<CaseRunAuditReport> {
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<SourceModule> => {
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<string, SourceModule>) {
const dependencies = new Set<string>();
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<string, string[]>) {
const cycles = new Map<string, string[]>();
const visiting = new Set<string>();
const visited = new Set<string>();
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<string, { module: SourceModule; node: ts.FunctionDeclaration; fingerprint: string }[]>();
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<string, typeof entries>();
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<number, ts.Statement[]>();
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<string, unknown> };
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<typeof auditFailure>) {
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<string, unknown> = {}) {
return Object.assign(new Error(message), { code, details });
}
+70 -8
View File
@@ -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<string, unknown>): 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<CaseRegistryArchive> {
@@ -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<string, unknown>): 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)
});
}
+1 -2
View File
@@ -1,4 +1,4 @@
import { createHash } from "node:crypto";
import { sha256 } from "./hwlab-caserun-evidence.ts";
type CommandResult = { stdout?: unknown } & Record<string, unknown>;
@@ -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<string, unknown> { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
+15
View File
@@ -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<string, unknown> = {}) {
return Object.assign(new Error(message), { code, details });
}
export function clean<T extends Record<string, unknown>>(value: T): T {
return Object.fromEntries(
Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null),
+36
View File
@@ -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;
}
+11 -141
View File
@@ -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<typeof errorSummary>) {
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<string, unknown> = {}, status = "succeeded") { return { ok: status !== "failed", action, status, ...extra }; }
function cliError(code: string, message: string, details: Record<string, unknown> = {}) { 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<T extends Record<string, unknown>>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T; }
+2 -5
View File
@@ -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<string, unknown>; sourceSpecPath: string }, collectSnapshot: (context: CaseContext, run: PreparedCaseRun, label: string) => Promise<SourceRootSnapshot>) {