2797 lines
138 KiB
TypeScript
2797 lines
138 KiB
TypeScript
import { spawn } from "node:child_process";
|
||
import { createHash, 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";
|
||
|
||
import { renderTraceRowsMarkdown, traceDisplayRows, traceNoiseEventCount, type TraceEventRow } from "./hwlab-cli/trace-renderer.ts";
|
||
import { readHwpodSpec } from "./hwpod-harness-lib.ts";
|
||
|
||
const DEFAULT_API_URL = "http://74.48.78.17:19667";
|
||
const DEFAULT_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;
|
||
const DEFAULT_AGENT_TIMEOUT_MS = 600000;
|
||
const MAX_AGENT_TIMEOUT_MS = 3600000;
|
||
const MAX_AGENT_TRACE_COMMANDS = 30;
|
||
const HWLAB_API_KEY_PREFIX = "hwl_live_";
|
||
const STANDARD_CASE_REPO_PATH = "/root/hwlab-case-registry";
|
||
const REMOVED_CASE_REPO_PATH = "/root/hwpod-cases";
|
||
const CASE_TRACE_RENDERER = "tools/src/hwlab-cli/trace-renderer:traceDisplayRows";
|
||
|
||
type EnvLike = Record<string, string | undefined>;
|
||
type ParsedArgs = Record<string, unknown> & { _: string[] };
|
||
type FetchLike = typeof fetch;
|
||
type RunProcessLike = (command: string[], cwd: string, env: Record<string, string | undefined>) => Promise<{ command: string[]; stdout: string; stderr: string; exitCode: number }>;
|
||
type StartProcessLike = (input: { command: string; args: string[]; cwd: string; env: Record<string, string | undefined>; stdoutPath: string; stderrPath: string }) => Promise<{ pid: number }> | { pid: number };
|
||
|
||
type CaseContext = {
|
||
parsed: ParsedArgs;
|
||
env: EnvLike;
|
||
fetchImpl: FetchLike;
|
||
cwd: string;
|
||
now: () => string;
|
||
sleep: (ms: number) => Promise<void>;
|
||
runProcess?: RunProcessLike;
|
||
startProcess?: StartProcessLike;
|
||
argv?: string[];
|
||
rest: string[];
|
||
};
|
||
|
||
type PreparedCaseRun = {
|
||
caseId: string;
|
||
runId: string;
|
||
runDir: string;
|
||
caseRepo: string;
|
||
caseDir: string;
|
||
caseFile: string;
|
||
sourceSpecPath: string;
|
||
specPath: string;
|
||
apiUrl: string;
|
||
compileOnly: boolean;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
definition: Record<string, unknown>;
|
||
subject: PreparedSubjectRun;
|
||
agentTask?: PreparedAgentTask | null;
|
||
agent?: AgentRunStage | null;
|
||
agentTrace?: AgentTraceStage | null;
|
||
agentDiff?: AgentDiffStage | null;
|
||
status?: string;
|
||
stage?: string;
|
||
evidencePath?: string;
|
||
completedAt?: string;
|
||
};
|
||
|
||
type PreparedSubjectRun = {
|
||
repoLocalPath: string;
|
||
commitId: string;
|
||
subdir: string;
|
||
worktreePath: string;
|
||
workspacePath: string;
|
||
setup: Record<string, unknown>;
|
||
sourceRootBaseline?: SourceRootSnapshot;
|
||
sourceRootAfterPrepare?: SourceRootSnapshot;
|
||
};
|
||
|
||
type SourceRootSnapshot = {
|
||
label: string;
|
||
statusShort: string;
|
||
diffStat: string;
|
||
diffSha256: string;
|
||
diffBytes: number;
|
||
commands?: Record<string, unknown>;
|
||
};
|
||
|
||
type PreparedAgentTask = {
|
||
prompt: string;
|
||
workspace: "subjectWorktree";
|
||
constraints: string[];
|
||
providerProfile: string;
|
||
projectId: string;
|
||
timeoutMs?: number;
|
||
pollIntervalMs?: number;
|
||
};
|
||
|
||
type AgentRunStage = {
|
||
stageStatus: string;
|
||
baseUrl: string;
|
||
projectId: string;
|
||
providerProfile: string;
|
||
conversationId: string;
|
||
sessionId: string;
|
||
threadId: string;
|
||
traceId: string;
|
||
promptPath: string;
|
||
promptSha256: string;
|
||
accepted: Record<string, unknown> | null;
|
||
result: Record<string, unknown> | null;
|
||
polls: number;
|
||
timedOut: boolean;
|
||
timeoutMs?: number;
|
||
pollIntervalMs?: number;
|
||
resultUrl?: string;
|
||
lastPollAt?: string;
|
||
lastHttpStatus?: number;
|
||
nextPollCommand?: string;
|
||
traceCommand?: string;
|
||
inspectCommand?: string;
|
||
sessionCommand?: string;
|
||
traceLookup?: Record<string, unknown>;
|
||
sessionResponse: Record<string, unknown> | null;
|
||
error?: Record<string, unknown> | null;
|
||
};
|
||
|
||
type AgentDiffStage = {
|
||
statusShort: string;
|
||
diffStat: string;
|
||
diffPatchPath: string;
|
||
diffPatchSha256: string;
|
||
sourceRootStatusShort: string;
|
||
sourceRootBaseline?: SourceRootSnapshot;
|
||
sourceRootAfterPrepare?: SourceRootSnapshot;
|
||
sourceRootAfterAgent?: SourceRootSnapshot;
|
||
sourceRootChangedAfterPrepare?: boolean;
|
||
sourceRootChangedAfterAgent?: boolean;
|
||
requests: Record<string, unknown>[];
|
||
};
|
||
|
||
type AgentTraceCommand = {
|
||
source: string;
|
||
seq?: number;
|
||
rowId?: string;
|
||
header?: string;
|
||
toolName?: string;
|
||
status?: string;
|
||
command?: string;
|
||
normalizedCommand?: string;
|
||
bodyPreview?: string;
|
||
exitCode?: number;
|
||
};
|
||
|
||
type AgentTraceStage = {
|
||
traceId: string;
|
||
status: string;
|
||
httpStatus: number;
|
||
source?: string;
|
||
lookupOnly?: boolean;
|
||
lookup?: Record<string, unknown>;
|
||
sourceEventCount?: number;
|
||
renderedRowCount?: number;
|
||
commandCount: number;
|
||
hwpodCommandCount: number;
|
||
hwpodBuildCommandCount: number;
|
||
keilJobCandidates: string[];
|
||
terminalStatus?: string;
|
||
agentRun?: Record<string, unknown> | null;
|
||
commands: AgentTraceCommand[];
|
||
error?: Record<string, unknown> | null;
|
||
};
|
||
|
||
type CaseRegistryArchiveFile = {
|
||
path: string;
|
||
bytes: number;
|
||
sha256: string;
|
||
source?: string;
|
||
};
|
||
|
||
type CaseRegistryArchive = {
|
||
caseRepoRunDir: string;
|
||
artifactManifestPath: string;
|
||
caseRepoFiles: string[];
|
||
files: CaseRegistryArchiveFile[];
|
||
skippedFiles: string[];
|
||
manifest: Record<string, unknown>;
|
||
registrySync?: Record<string, unknown> | null;
|
||
};
|
||
|
||
type CaseReadableAgentArchive = {
|
||
messagesRel: string;
|
||
traceRel: string;
|
||
transcriptRel: string;
|
||
finalResponseRel: string;
|
||
generatedFiles: string[];
|
||
traceRender: Record<string, unknown>;
|
||
finalResponse: Record<string, unknown>;
|
||
};
|
||
|
||
type ArchivedAgentTraceSnapshot = {
|
||
traceBody: any;
|
||
rows: TraceEventRow[];
|
||
events: Record<string, unknown>[];
|
||
summary: AgentTraceStage;
|
||
};
|
||
|
||
export async function caseCommand(context: CaseContext) {
|
||
const subcommand = context.rest[0] || "help";
|
||
if (["help", "--help", "-h"].includes(subcommand) || context.parsed.help === true) return caseHelp();
|
||
if (subcommand === "aggregate") return aggregateCaseRun(context);
|
||
if (subcommand === "prepare") return prepareCaseRun(context);
|
||
if (subcommand === "build") return buildCaseRun(context);
|
||
if (subcommand === "collect") return collectCaseRun(context);
|
||
if (subcommand === "run") return caseRunCommand(context);
|
||
throw cliError("unsupported_case_command", `unsupported case command: ${subcommand}`, { subcommand });
|
||
}
|
||
|
||
async function caseRunCommand(context: CaseContext) {
|
||
const mode = context.rest[1] || "";
|
||
if (mode === "start") return startCaseRun(context);
|
||
if (mode === "status") return statusCaseRun(context);
|
||
if (mode === "result") return resultCaseRun(context);
|
||
if (mode === "logs") return logsCaseRun(context);
|
||
if (mode === "worker") return runCaseRunWorker(context);
|
||
return runCaseRun(context);
|
||
}
|
||
|
||
export function caseHelp() {
|
||
return ok("case.help", {
|
||
serviceRuntime: false,
|
||
compileOnlyDefault: true,
|
||
agentTaskRequiredForRun: false,
|
||
autoEvaluation: false,
|
||
stateRoot: ".state/hwlab-cli/caserun",
|
||
standardCaseRepo: STANDARD_CASE_REPO_PATH,
|
||
subjectRequired: {
|
||
repoLocalPath: "local checkout path on the bound HWPOD node",
|
||
commitId: "fixed subject repo commit id; no GitHub clone/fetch fallback"
|
||
},
|
||
commands: [
|
||
`case prepare CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`,
|
||
`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 run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--provider-profile PROFILE]`,
|
||
`case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`,
|
||
`case run status RUN_ID [--state-dir .state/hwlab-cli/caserun]`,
|
||
`case run result RUN_ID [--state-dir .state/hwlab-cli/caserun]`,
|
||
`case run logs RUN_ID [--tail 8000]`
|
||
],
|
||
notes: [
|
||
"case build remains a compile-only HWPOD smoke stage.",
|
||
"case run prepares the subject worktree, submits a prompt to HWLAB Code Agent, records agent provenance and workspace diff, then runs compile validation.",
|
||
"case aggregate is a service-free post-processing command: it reads an existing case registry run and writes one aggregate markdown entry without pass/fail grading.",
|
||
"case run start/status/result/logs is the cli-spec async control surface for long CaseRun flows; start returns immediately and status/result/logs are short polling commands.",
|
||
"This version records raw stage evidence only; it does not auto-grade agent output, diff contents, or Keil evidence."
|
||
]
|
||
});
|
||
}
|
||
|
||
export async function aggregateCaseRun(context: CaseContext) {
|
||
const caseRepo = await resolveCaseRepo(context);
|
||
const aggregate = await writeCaseAggregateFromRegistry(context, caseRepo, { sync: true });
|
||
return ok("case.aggregate", {
|
||
caseId: aggregate.caseId,
|
||
runId: aggregate.runId,
|
||
caseRepo,
|
||
caseRepoRunDir: aggregate.caseRepoRunDir,
|
||
aggregate: {
|
||
path: aggregate.path,
|
||
rel: aggregate.rel,
|
||
bytes: aggregate.bytes,
|
||
sha256: aggregate.sha256,
|
||
registrySync: aggregate.registrySync
|
||
},
|
||
aggregationOnly: true,
|
||
autoEvaluation: false
|
||
}, "completed");
|
||
}
|
||
|
||
export async function runCaseRun(context: CaseContext) {
|
||
const prepared = await prepareCaseRun(context, "run");
|
||
const agentStage = await runAgentTaskStage(context, prepared.run);
|
||
const traceStage = await collectAgentTraceEvidence(context, agentStage.run);
|
||
const diffStage = await collectAgentDiff(context, traceStage.run);
|
||
const built = await buildCaseRun(context, diffStage.run);
|
||
const collected = await collectCaseRun(context, built.run, built.evidence);
|
||
const resultPath = path.join(collected.run.runDir, "result.json");
|
||
const completed = await writeRunControl(context, collected.run, { status: "completed", stage: "completed", completedAt: context.now(), resultPath });
|
||
await writeJson(resultPath, compactCaseRunResult({ ...collected, run: completed.run }));
|
||
let finalArchive = context.parsed.noCaseRepoRecord === true ? null : await archiveCaseRunArtifacts(context, completed.run, collected.evidence, { sync: false });
|
||
let finalCollect = finalArchive ? collectSummaryFromArchive(completed.run, collected.evidence, finalArchive) : collected.summary;
|
||
await writeJson(resultPath, compactCaseRunResult({ ...collected, run: completed.run, summary: finalCollect }));
|
||
if (context.parsed.noCaseRepoRecord !== true) {
|
||
finalArchive = await archiveCaseRunArtifacts(context, completed.run, collected.evidence, { sync: true });
|
||
finalCollect = collectSummaryFromArchive(completed.run, collected.evidence, finalArchive);
|
||
await writeJson(resultPath, compactCaseRunResult({ ...collected, run: completed.run, summary: finalCollect }));
|
||
}
|
||
const finalAgentTrace = collected.evidence.agentTrace ?? traceStage.trace;
|
||
return ok("case.run", {
|
||
caseId: collected.run.caseId,
|
||
runId: collected.run.runId,
|
||
runDir: collected.run.runDir,
|
||
compileOnly: true,
|
||
prepare: prepared.summary,
|
||
agent: agentSummary(agentStage.agent),
|
||
agentTrace: traceSummary(finalAgentTrace),
|
||
traceLookup: agentTraceLookupForRun(traceStage.run),
|
||
agentDiff: diffSummary(diffStage.diff),
|
||
build: buildSummary(collected.evidence),
|
||
collect: finalCollect,
|
||
evidence: collected.evidence
|
||
}, "completed");
|
||
}
|
||
|
||
export async function startCaseRun(context: CaseContext) {
|
||
const caseId = requiredText(context.parsed.caseId ?? context.rest[2], "caseId");
|
||
const runId = runIdFrom(context, caseId);
|
||
const runDir = path.resolve(context.cwd, text(context.parsed.runDir) || path.join(stateRoot(context), runId));
|
||
const stdoutPath = path.join(runDir, "worker.stdout.log");
|
||
const stderrPath = path.join(runDir, "worker.stderr.log");
|
||
await mkdir(runDir, { recursive: true });
|
||
const passthrough = workerPassthroughArgs(context, caseId, runId, runDir);
|
||
const command = process.execPath;
|
||
const args = [path.join(context.cwd, "tools/hwlab-cli/bin/hwlab-cli.mjs"), "case", "run", "worker", ...passthrough];
|
||
const control = await writeRunControl(context, {
|
||
caseId,
|
||
runId,
|
||
runDir,
|
||
caseRepo: text(context.parsed.caseRepo ?? context.parsed.caseRepoPath ?? context.env.HWLAB_CASE_REPO),
|
||
caseDir: "",
|
||
caseFile: "",
|
||
sourceSpecPath: "",
|
||
specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"),
|
||
apiUrl: text(context.parsed.apiUrl ?? context.parsed.apiBaseUrl ?? context.parsed.runtimeApiUrl ?? context.env.HWLAB_RUNTIME_API_URL) || DEFAULT_API_URL,
|
||
compileOnly: true,
|
||
createdAt: context.now(),
|
||
updatedAt: context.now(),
|
||
definition: {},
|
||
subject: { repoLocalPath: "", commitId: "", subdir: "", worktreePath: "", workspacePath: "", setup: {} },
|
||
status: "running",
|
||
stage: "queued"
|
||
}, { status: "running", stage: "queued", stdoutPath, stderrPath, startedAt: context.now(), command: commandVisibility([command, ...args]) });
|
||
const child = await (context.startProcess ?? startDetachedProcess)({ command, args, cwd: context.cwd, env: { ...process.env, ...context.env, HWLAB_CASERUN_ASYNC_WORKER: "1" }, stdoutPath, stderrPath });
|
||
await writeRunControl(context, control.run, { status: "running", stage: "started", pid: child.pid, stdoutPath, stderrPath, startedAt: control.control.startedAt, command: commandVisibility([command, ...args]) });
|
||
return ok("case.run.start", {
|
||
caseId,
|
||
runId,
|
||
runDir,
|
||
pid: child.pid,
|
||
stateFile: path.join(runDir, "run.json"),
|
||
stdoutPath,
|
||
stderrPath,
|
||
statusCommand: `hwlab-cli case run status ${runId} --state-dir ${stateRoot(context)}`,
|
||
resultCommand: `hwlab-cli case run result ${runId} --state-dir ${stateRoot(context)}`,
|
||
logsCommand: `hwlab-cli case run logs ${runId} --state-dir ${stateRoot(context)} --tail 8000`,
|
||
startReturned: true,
|
||
async: true
|
||
}, "submitted");
|
||
}
|
||
|
||
async function runCaseRunWorker(context: CaseContext) {
|
||
const caseId = requiredText(context.parsed.caseId ?? context.rest[2], "caseId");
|
||
const runId = requiredText(context.parsed.runId, "runId");
|
||
const runDir = path.resolve(context.cwd, requiredText(context.parsed.runDir, "runDir"));
|
||
const workerContext = { ...context, parsed: { ...context.parsed, runId, runDir }, rest: ["run", caseId] };
|
||
await writeRunControl(workerContext, await loadControlRunSkeleton(workerContext, caseId, runId, runDir), { status: "running", stage: "worker-running", startedAt: context.now() });
|
||
try {
|
||
let payload = await runCaseRun(workerContext);
|
||
const run = await readRunFromDir(runDir);
|
||
const completed = await writeRunControl(workerContext, run, { status: "completed", stage: "completed", completedAt: context.now(), exitCode: 0, resultPath: path.join(runDir, "result.json") });
|
||
await writeJson(path.join(runDir, "result.json"), payload);
|
||
if (workerContext.parsed.noCaseRepoRecord !== true) {
|
||
const evidence = await readJsonIfExists(path.join(runDir, "evidence.json"));
|
||
if (evidence) {
|
||
let archive = await archiveCaseRunArtifacts(workerContext, completed.run, evidence, { sync: false });
|
||
payload = { ...payload, collect: collectSummaryFromArchive(completed.run, evidence, archive) };
|
||
await writeJson(path.join(runDir, "result.json"), payload);
|
||
archive = await archiveCaseRunArtifacts(workerContext, completed.run, evidence, { sync: true });
|
||
payload = { ...payload, collect: collectSummaryFromArchive(completed.run, evidence, archive) };
|
||
await writeJson(path.join(runDir, "result.json"), payload);
|
||
}
|
||
}
|
||
return payload;
|
||
} catch (error) {
|
||
const run = await readRunFromDir(runDir).catch(() => loadControlRunSkeleton(workerContext, caseId, runId, runDir));
|
||
const summary = errorSummary(error);
|
||
const blocker = extractPrepareBlocker(summary);
|
||
await writeRunControl(workerContext, run, { status: "failed", stage: "failed", completedAt: context.now(), exitCode: 1, error: summary, ...(blocker ? { blocker } : {}) });
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
export async function statusCaseRun(context: CaseContext) {
|
||
const runId = requiredText(context.parsed.runId ?? context.rest[2], "runId");
|
||
const run = await readRunById(context, runId);
|
||
const control = controlFromRun(run);
|
||
const status = text(control.status) || text(run.status) || "unknown";
|
||
const stage = statusStage(run, control);
|
||
const stdoutPath = text(control.stdoutPath) || path.join(run.runDir, "worker.stdout.log");
|
||
const stderrPath = text(control.stderrPath) || path.join(run.runDir, "worker.stderr.log");
|
||
const stdoutBytes = await fileSize(stdoutPath);
|
||
const stderrBytes = await fileSize(stderrPath);
|
||
const registryArchive = status === "completed" ? await refreshCompletedRunArchive(context, run) : null;
|
||
return ok("case.run.status", {
|
||
caseId: run.caseId,
|
||
runId: run.runId,
|
||
runDir: run.runDir,
|
||
stateFile: path.join(run.runDir, "run.json"),
|
||
status,
|
||
stage: stage.stage,
|
||
staleControlWarning: stage.warning,
|
||
pid: control.pid ?? null,
|
||
startedAt: control.startedAt ?? run.createdAt,
|
||
updatedAt: run.updatedAt,
|
||
completedAt: control.completedAt ?? run.completedAt ?? null,
|
||
elapsedMs: elapsedMs(control.startedAt ?? run.createdAt, control.completedAt),
|
||
stdoutPath,
|
||
stderrPath,
|
||
stdoutBytes,
|
||
stderrBytes,
|
||
registryArchive: registryArchive?.summary ?? null,
|
||
prepare: run.subject?.worktreePath ? prepareSummary(run) : null,
|
||
agent: run.agent ? agentSummary(run.agent) : null,
|
||
traceLookup: run.agent ? agentTraceLookupForRun(run) : null,
|
||
agentDiff: run.agentDiff ? diffSummary(run.agentDiff) : null,
|
||
blocker: control.blocker ?? null,
|
||
evidencePath: text(run.evidencePath ?? control.resultPath) || null,
|
||
nextPollCommand: `hwlab-cli case run status ${run.runId} --state-dir ${stateRoot(context)}`,
|
||
resultCommand: `hwlab-cli case run result ${run.runId} --state-dir ${stateRoot(context)}`,
|
||
logsCommand: `hwlab-cli case run logs ${run.runId} --state-dir ${stateRoot(context)} --tail 8000`
|
||
});
|
||
}
|
||
|
||
export async function resultCaseRun(context: CaseContext) {
|
||
const runId = requiredText(context.parsed.runId ?? context.rest[2], "runId");
|
||
const run = await readRunById(context, runId);
|
||
const control = controlFromRun(run);
|
||
const status = text(control.status) || text(run.status) || "unknown";
|
||
const stage = statusStage(run, control);
|
||
if (status !== "completed") {
|
||
return ok("case.run.result", {
|
||
caseId: run.caseId,
|
||
runId: run.runId,
|
||
runDir: run.runDir,
|
||
status,
|
||
stage: stage.stage,
|
||
staleControlWarning: stage.warning,
|
||
ready: false,
|
||
agent: run.agent ? agentSummary(run.agent) : null,
|
||
traceLookup: run.agent ? agentTraceLookupForRun(run) : null,
|
||
nextPollCommand: `hwlab-cli case run status ${run.runId} --state-dir ${stateRoot(context)}`,
|
||
logsCommand: `hwlab-cli case run logs ${run.runId} --state-dir ${stateRoot(context)} --tail 8000`
|
||
}, "running");
|
||
}
|
||
const evidencePath = text(run.evidencePath) || path.join(run.runDir, "evidence.json");
|
||
const evidence = await readJsonIfExists(evidencePath);
|
||
const resultPath = path.join(run.runDir, "result.json");
|
||
let result = await readJsonIfExists(resultPath);
|
||
let registryArchive = evidence ? await refreshCompletedRunArchive(context, run, evidence, { sync: false }) : null;
|
||
if (registryArchive && result && typeof result === "object") {
|
||
result = { ...result, collect: registryArchive.summary };
|
||
await writeJson(resultPath, result);
|
||
registryArchive = await refreshCompletedRunArchive(context, run, evidence, { sync: true });
|
||
result = { ...result, collect: registryArchive.summary };
|
||
await writeJson(resultPath, result);
|
||
} else if (evidence) {
|
||
registryArchive = await refreshCompletedRunArchive(context, run, evidence, { sync: true });
|
||
}
|
||
return ok("case.run.result", {
|
||
caseId: run.caseId,
|
||
runId: run.runId,
|
||
runDir: run.runDir,
|
||
status,
|
||
ready: true,
|
||
evidencePath,
|
||
resultPath,
|
||
registryArchive: registryArchive?.summary ?? null,
|
||
summary: evidence ? buildSummary(evidence) : null,
|
||
agent: run.agent ? agentSummary(run.agent) : null,
|
||
traceLookup: run.agent ? agentTraceLookupForRun(run) : null,
|
||
agentDiff: run.agentDiff ? diffSummary(run.agentDiff) : null,
|
||
evidence: context.parsed.full === true ? evidence : compactObject(evidence),
|
||
result: context.parsed.full === true ? result : compactObject(result)
|
||
}, "completed");
|
||
}
|
||
|
||
export async function logsCaseRun(context: CaseContext) {
|
||
const runId = requiredText(context.parsed.runId ?? context.rest[2], "runId");
|
||
const run = await readRunById(context, runId);
|
||
const control = controlFromRun(run);
|
||
const tailBytes = Math.max(1, Math.min(numberOption(context.parsed.tail ?? context.parsed.tailBytes) ?? 8000, 50000));
|
||
const stdoutPath = text(control.stdoutPath) || path.join(run.runDir, "worker.stdout.log");
|
||
const stderrPath = text(control.stderrPath) || path.join(run.runDir, "worker.stderr.log");
|
||
return ok("case.run.logs", {
|
||
caseId: run.caseId,
|
||
runId: run.runId,
|
||
runDir: run.runDir,
|
||
tailBytes,
|
||
stdoutPath,
|
||
stderrPath,
|
||
stdout: await readTail(stdoutPath, tailBytes),
|
||
stderr: await readTail(stderrPath, tailBytes)
|
||
});
|
||
}
|
||
|
||
export async function prepareCaseRun(context: CaseContext, action = "prepare") {
|
||
const caseId = requiredText(context.parsed.caseId ?? context.rest[1], "caseId");
|
||
const caseRepo = await resolveCaseRepo(context);
|
||
const loaded = await loadCaseDefinition(caseRepo, caseId);
|
||
const runId = runIdFrom(context, caseId);
|
||
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 subject = await prepareSubjectWorktree(context, { caseId, runId, runDir, apiUrl, definition: loaded.definition, sourceSpecPath: loaded.specPath });
|
||
const agentTask = agentTaskFromDefinition(loaded.definition, caseId);
|
||
const run: PreparedCaseRun = {
|
||
caseId,
|
||
runId,
|
||
runDir,
|
||
caseRepo,
|
||
caseDir: loaded.caseDir,
|
||
caseFile: loaded.caseFile,
|
||
sourceSpecPath: loaded.specPath,
|
||
specPath,
|
||
apiUrl,
|
||
compileOnly: true,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
definition: loaded.definition,
|
||
subject,
|
||
agentTask
|
||
};
|
||
await mkdir(path.dirname(specPath), { recursive: true });
|
||
await writeFile(specPath, rewriteHwpodSpecWorkspacePath(loaded.specText, subject.workspacePath), "utf8");
|
||
const status = action === "prepare" ? "prepared" : "running";
|
||
await writeRunControl(context, run, { status, stage: "prepared" });
|
||
return ok(`case.${action}.prepare`, {
|
||
caseId,
|
||
runId,
|
||
runDir,
|
||
caseRepo,
|
||
specPath,
|
||
sourceSpecPath: loaded.specPath,
|
||
apiUrl,
|
||
compileOnly: true,
|
||
agentTask: agentTaskSummary(agentTask),
|
||
subject,
|
||
summary: prepareSummary(run),
|
||
run
|
||
});
|
||
}
|
||
|
||
export async function buildCaseRun(context: CaseContext, prepared?: PreparedCaseRun) {
|
||
const run = prepared ?? await loadOrPrepareCaseRun(context);
|
||
const command = [process.execPath, path.join(context.cwd, "tools/hwpod-cli.ts"), "build", "--spec", run.specPath, "--reason", `case-run ${run.caseId} ${run.runId} compile-only`];
|
||
const invoked = await (context.runProcess ?? runProcess)(command, context.cwd, {
|
||
...process.env,
|
||
...context.env,
|
||
HWLAB_RUNTIME_API_URL: run.apiUrl,
|
||
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1"
|
||
});
|
||
const hwpodPayload = parseJsonMaybe(invoked.stdout);
|
||
const jobId = extractKeilJobId(hwpodPayload);
|
||
const job = jobId ? await pollKeilJobStatus(context, run, jobId) : null;
|
||
const evidence = clean({
|
||
contractVersion: "hwpod-case-run-evidence-v1",
|
||
caseId: run.caseId,
|
||
runId: run.runId,
|
||
compileOnly: true,
|
||
autoEvaluation: false,
|
||
status: "recorded",
|
||
observedAt: context.now(),
|
||
apiUrl: run.apiUrl,
|
||
subject: run.subject,
|
||
agentTask: run.agentTask ? agentTaskSummary(run.agentTask) : null,
|
||
agent: run.agent ? agentSummary(run.agent) : null,
|
||
traceLookup: agentTraceLookupForRun(run),
|
||
agentTrace: run.agentTrace ? traceSummary(run.agentTrace) : null,
|
||
agentDiff: run.agentDiff ? diffSummary(run.agentDiff) : null,
|
||
hwpod: clean({
|
||
source: "case-run-runner-post-agent-compile-check",
|
||
command: commandVisibility(command),
|
||
exitCode: invoked.exitCode,
|
||
stdoutJson: compactHwpodPayload(hwpodPayload),
|
||
stderr: clipText(invoked.stderr)
|
||
}),
|
||
keilJob: job ? job.summary : null,
|
||
artifacts: job?.summary?.artifacts ?? [],
|
||
decisions: {
|
||
autoEvaluation: false,
|
||
runnerPostAgentCompileCheck: "recorded",
|
||
reason: "flow-only run: CaseRun records agent, diff and compile evidence without auto-grading them"
|
||
}
|
||
});
|
||
await writeJson(path.join(run.runDir, "evidence.json"), evidence);
|
||
await writeRunControl(context, { ...run, status: evidence.status, evidencePath: path.join(run.runDir, "evidence.json") }, { status: "running", stage: "build-completed", evidencePath: path.join(run.runDir, "evidence.json") });
|
||
return ok("case.build", {
|
||
caseId: run.caseId,
|
||
runId: run.runId,
|
||
runDir: run.runDir,
|
||
compileOnly: true,
|
||
summary: buildSummary(evidence),
|
||
evidence,
|
||
run
|
||
}, "completed");
|
||
}
|
||
|
||
export async function collectCaseRun(context: CaseContext, prepared?: PreparedCaseRun, knownEvidence?: any) {
|
||
const run = prepared ?? await loadRunFromDirOrFail(context);
|
||
const evidence = knownEvidence ?? JSON.parse(await readFile(path.join(run.runDir, "evidence.json"), "utf8"));
|
||
const archive = context.parsed.noCaseRepoRecord === true ? null : await archiveCaseRunArtifacts(context, run, evidence, { sync: !prepared });
|
||
const summary = archive ? collectSummaryFromArchive(run, evidence, archive) : {
|
||
caseRepo: run.caseRepo,
|
||
caseRepoRunDir: null,
|
||
caseRepoFiles: [],
|
||
artifactManifestPath: null,
|
||
trace: null,
|
||
traceLookup: agentTraceLookupForRun(run, evidence),
|
||
status: evidence.status,
|
||
autoEvaluation: false
|
||
};
|
||
return ok("case.collect", { caseId: run.caseId, runId: run.runId, runDir: run.runDir, compileOnly: true, summary, evidence, run }, "completed");
|
||
}
|
||
|
||
export function extractKeilJobId(payload: any) {
|
||
const candidates = hwpodResultTexts(payload);
|
||
for (const candidate of candidates) {
|
||
const parsed = parseJsonMaybe(candidate);
|
||
const direct = text(parsed?.job_id ?? parsed?.jobId ?? parsed?.data?.job_id ?? parsed?.data?.jobId);
|
||
if (direct) return direct;
|
||
const match = String(candidate ?? "").match(/"job[_-]?id"\s*:\s*"([^"]+)"/iu);
|
||
if (match?.[1]) return match[1];
|
||
}
|
||
return "";
|
||
}
|
||
|
||
async function loadOrPrepareCaseRun(context: CaseContext) {
|
||
if (text(context.parsed.runDir)) return loadRunFromDirOrFail(context);
|
||
const prepared = await prepareCaseRun(context, "build");
|
||
return prepared.run as PreparedCaseRun;
|
||
}
|
||
|
||
async function loadRunFromDirOrFail(context: CaseContext) {
|
||
const runDir = path.resolve(context.cwd, requiredText(context.parsed.runDir, "runDir"));
|
||
return JSON.parse(await readFile(path.join(runDir, "run.json"), "utf8")) as PreparedCaseRun;
|
||
}
|
||
|
||
async function resolveCaseRepo(context: CaseContext) {
|
||
const explicit = text(context.parsed.caseRepo ?? context.parsed.caseRepoPath ?? context.env.HWLAB_CASE_REPO);
|
||
if (explicit && isRemovedCaseRepoPath(path.resolve(context.cwd, explicit))) {
|
||
throw cliError("removed_case_repo_path", "the CaseRun registry moved to /root/hwlab-case-registry", { caseRepo: explicit, standardCaseRepo: STANDARD_CASE_REPO_PATH });
|
||
}
|
||
const candidates = explicit ? [path.resolve(context.cwd, explicit)] : [
|
||
path.resolve(context.cwd, "../hwlab-case-registry"),
|
||
STANDARD_CASE_REPO_PATH
|
||
];
|
||
for (const candidate of candidates) {
|
||
try {
|
||
await readFile(path.join(candidate, ".git", "HEAD"), "utf8");
|
||
return candidate;
|
||
} catch {
|
||
// Try next visible candidate.
|
||
}
|
||
}
|
||
throw cliError("case_repo_required", "case repo is required and must be a git checkout", { option: "--case-repo", standardCaseRepo: STANDARD_CASE_REPO_PATH, candidates });
|
||
}
|
||
|
||
function isRemovedCaseRepoPath(candidate: string) {
|
||
const normalized = path.resolve(candidate);
|
||
return normalized === REMOVED_CASE_REPO_PATH || normalized.endsWith("/hwpod-cases");
|
||
}
|
||
|
||
async function loadCaseDefinition(caseRepo: string, caseId: string) {
|
||
const caseDir = path.join(caseRepo, "cases", caseId);
|
||
const caseFile = path.join(caseDir, "case.json");
|
||
const definition = JSON.parse(await readFile(caseFile, "utf8"));
|
||
const specRel = text(definition.hwpodSpec ?? definition.specPath) || "hwpod-spec.yaml";
|
||
const specPath = path.resolve(caseDir, specRel);
|
||
const specText = await readFile(specPath, "utf8");
|
||
return { caseDir, caseFile, definition, specPath, specText };
|
||
}
|
||
|
||
function agentTaskFromDefinition(definition: Record<string, unknown>, caseId: string) {
|
||
const value = definition.agentTask;
|
||
if (value === undefined || value === null) return defaultAgentTask(definition, caseId);
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||
throw cliError("invalid_agent_task", "case.json agentTask must be an object", { field: "agentTask" });
|
||
}
|
||
const source = value as Record<string, unknown>;
|
||
const prompt = text(source.prompt);
|
||
if (!prompt) throw cliError("agent_task_prompt_required", "case.json agentTask.prompt is required for Code Agent CaseRun", { field: "agentTask.prompt" });
|
||
const workspace = text(source.workspace) || "subjectWorktree";
|
||
if (workspace !== "subjectWorktree") throw cliError("unsupported_agent_task_workspace", "case.json agentTask.workspace must be subjectWorktree", { workspace });
|
||
const timeouts = caseTimeoutsFromDefinition(definition);
|
||
return {
|
||
prompt,
|
||
workspace,
|
||
constraints: stringArray(source.constraints),
|
||
providerProfile: text(source.providerProfile) || "deepseek",
|
||
projectId: text(source.projectId) || "prj_hwpod_workbench",
|
||
timeoutMs: numberOption(source.timeoutMs ?? source.agentTimeoutMs ?? timeouts.agentTimeoutMs),
|
||
pollIntervalMs: numberOption(source.pollIntervalMs ?? source.agentPollIntervalMs ?? timeouts.agentPollIntervalMs)
|
||
} as PreparedAgentTask;
|
||
}
|
||
|
||
function caseTimeoutsFromDefinition(definition: Record<string, unknown>) {
|
||
const value = definition.timeouts;
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return {} as Record<string, unknown>;
|
||
return value as Record<string, unknown>;
|
||
}
|
||
|
||
function defaultAgentTask(definition: Record<string, unknown>, caseId: string): PreparedAgentTask {
|
||
const title = text(definition.title) || caseId;
|
||
return {
|
||
prompt: `请根据 CaseRun ${caseId}(${title})在 isolated subject worktree 中完成任务准备或最小可观察修改;不要修改 case registry 或原 subject repo checkout。`,
|
||
workspace: "subjectWorktree",
|
||
constraints: [
|
||
"不得修改 case registry",
|
||
"不得修改原 subject repo checkout",
|
||
"只允许修改 isolated subject worktree",
|
||
"保持 compile-only 流程可继续执行"
|
||
],
|
||
providerProfile: "deepseek",
|
||
projectId: "prj_hwpod_workbench"
|
||
};
|
||
}
|
||
|
||
async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) {
|
||
if (!run.agentTask) {
|
||
run = await updateRun(context, run, { agentTask: defaultAgentTask(run.definition, run.caseId) });
|
||
}
|
||
const promptPath = path.join(run.runDir, "agent-prompt.md");
|
||
const prompt = await renderAgentTaskPrompt(run, run.agentTask);
|
||
await writeFile(promptPath, prompt, "utf8");
|
||
const promptSha256 = sha256(prompt);
|
||
const baseUrl = webUrlFrom(context, run.definition);
|
||
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)}`;
|
||
run = await updateRun(context, run, { stage: "agent-session-starting", agent: agentFailureStage({ stageStatus: "session_starting", baseUrl, projectId, providerProfile, conversationId, promptPath, promptSha256 }) });
|
||
const session = await tryCreateAgentSession(context, { baseUrl, projectId, providerProfile, conversationId });
|
||
if (!session.ok) {
|
||
const agent = agentFailureStage({ stageStatus: "session_failed", baseUrl, projectId, providerProfile, conversationId, promptPath, promptSha256, error: session.error, sessionResponse: session.body });
|
||
const nextRun = await updateRun(context, run, { agent });
|
||
return { run: nextRun, agent };
|
||
}
|
||
const sessionId = session.sessionId;
|
||
const threadId = session.threadId;
|
||
const traceId = text(context.parsed.traceId) || `trc_case_${slug(run.caseId)}_${randomUUID().replace(/-/gu, "")}`;
|
||
run = await updateRun(context, run, { stage: "agent-submitting", agent: agentFailureStage({ stageStatus: "session_created", baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, promptPath, promptSha256, sessionResponse: compactObject(session.body) }) });
|
||
const accepted = await submitAgentTask(context, { baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, prompt });
|
||
if (!accepted.ok) {
|
||
const agent = agentFailureStage({ stageStatus: "submit_failed", baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, promptPath, promptSha256, accepted: accepted.body, error: accepted.error, sessionResponse: session.body });
|
||
const nextRun = await updateRun(context, run, { agent });
|
||
return { run: nextRun, agent };
|
||
}
|
||
const resultPath = text(accepted.body?.resultUrl) || `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
|
||
const resultUrl = resultPath.startsWith("http") ? resultPath : `${baseUrl}${resultPath}`;
|
||
const traceLookup = agentTraceLookup({ baseUrl, traceId, sessionId, conversationId, threadId });
|
||
const submittedAgent: AgentRunStage = clean({
|
||
stageStatus: "submitted",
|
||
baseUrl,
|
||
projectId,
|
||
providerProfile,
|
||
conversationId,
|
||
sessionId,
|
||
threadId,
|
||
traceId,
|
||
promptPath,
|
||
promptSha256,
|
||
accepted: compactObject(accepted.body),
|
||
result: null,
|
||
polls: 0,
|
||
timedOut: false,
|
||
timeoutMs: effectiveAgentTimeoutMs(context, run.agentTask),
|
||
pollIntervalMs: effectiveAgentPollIntervalMs(context, run.agentTask),
|
||
resultUrl,
|
||
nextPollCommand: traceLookup.commands.result,
|
||
traceCommand: traceLookup.commands.trace,
|
||
inspectCommand: traceLookup.commands.inspect,
|
||
sessionCommand: traceLookup.commands.sessionStatus,
|
||
traceLookup,
|
||
sessionResponse: compactObject(session.body)
|
||
});
|
||
run = await updateRun(context, run, { stage: "agent-running", agent: submittedAgent });
|
||
const result = await pollAgentResult(context, { baseUrl, traceId, acceptedBody: accepted.body, resultUrl, run, agent: submittedAgent });
|
||
const agent: AgentRunStage = clean({
|
||
stageStatus: result.stageStatus,
|
||
baseUrl,
|
||
projectId,
|
||
providerProfile,
|
||
conversationId,
|
||
sessionId,
|
||
threadId,
|
||
traceId,
|
||
promptPath,
|
||
promptSha256,
|
||
accepted: compactObject(accepted.body),
|
||
result: compactObject(result.body),
|
||
polls: result.polls,
|
||
timedOut: result.timedOut,
|
||
timeoutMs: effectiveAgentTimeoutMs(context, run.agentTask),
|
||
pollIntervalMs: effectiveAgentPollIntervalMs(context, run.agentTask),
|
||
resultUrl,
|
||
lastPollAt: result.lastPollAt,
|
||
lastHttpStatus: result.lastHttpStatus,
|
||
nextPollCommand: traceLookup.commands.result,
|
||
traceCommand: traceLookup.commands.trace,
|
||
inspectCommand: traceLookup.commands.inspect,
|
||
sessionCommand: traceLookup.commands.sessionStatus,
|
||
traceLookup,
|
||
sessionResponse: compactObject(session.body),
|
||
error: result.error
|
||
});
|
||
const nextRun = await updateRun(context, run, { agent });
|
||
return { run: nextRun, agent };
|
||
}
|
||
|
||
async function collectAgentTraceEvidence(context: CaseContext, run: PreparedCaseRun) {
|
||
const agent = run.agent;
|
||
const traceId = text(agent?.traceId);
|
||
if (!agent || !traceId) {
|
||
const trace: AgentTraceStage = clean({ traceId, status: "skipped_no_trace_id", httpStatus: 0, source: "caserun-identity", lookupOnly: true, commandCount: 0, hwpodCommandCount: 0, hwpodBuildCommandCount: 0, keilJobCandidates: [], commands: [] });
|
||
const nextRun = await updateRun(context, run, { agentTrace: trace, stage: "agent-trace-collected" });
|
||
return { run: nextRun, trace };
|
||
}
|
||
const traceBody = agentTraceIdentityArtifact(run, agent);
|
||
await writeJson(path.join(run.runDir, "agent-trace.json"), traceBody);
|
||
const trace = summarizeAgentTrace(traceId, 0, traceBody);
|
||
const nextRun = await updateRun(context, run, { agentTrace: trace, stage: "agent-trace-collected" });
|
||
return { run: nextRun, trace };
|
||
}
|
||
|
||
async function tryCreateAgentSession(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string }) {
|
||
try {
|
||
return await createAgentSession(context, input);
|
||
} catch (error) {
|
||
const code = typeof (error as any)?.code === "string" ? (error as any).code : "agent_session_exception";
|
||
return { ok: false, body: null, error: { code, message: error instanceof Error ? error.message : String(error), details: (error as any)?.details ?? null } };
|
||
}
|
||
}
|
||
|
||
function agentFailureStage(input: { stageStatus: string; baseUrl: string; projectId: string; providerProfile: string; conversationId: string; sessionId?: string; threadId?: string; traceId?: string; promptPath: string; promptSha256: string; accepted?: Record<string, unknown> | null; sessionResponse?: Record<string, unknown> | null; error?: Record<string, unknown> | null }): AgentRunStage {
|
||
return clean({
|
||
stageStatus: input.stageStatus,
|
||
baseUrl: input.baseUrl,
|
||
projectId: input.projectId,
|
||
providerProfile: input.providerProfile,
|
||
conversationId: input.conversationId,
|
||
sessionId: input.sessionId ?? "",
|
||
threadId: input.threadId ?? "",
|
||
traceId: input.traceId ?? "",
|
||
promptPath: input.promptPath,
|
||
promptSha256: input.promptSha256,
|
||
accepted: input.accepted ?? null,
|
||
result: null,
|
||
polls: 0,
|
||
timedOut: false,
|
||
sessionResponse: input.sessionResponse ?? null,
|
||
error: input.error ?? null
|
||
}) as AgentRunStage;
|
||
}
|
||
|
||
async function createAgentSession(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string }) {
|
||
const response = await caseFetchJson(context, `${input.baseUrl}/v1/agent/sessions`, {
|
||
method: "POST",
|
||
headers: caseAgentHeaders(context),
|
||
body: JSON.stringify({ projectId: input.projectId, providerProfile: input.providerProfile, conversationId: input.conversationId, updatedByClient: "hwlab-cli.case-run" })
|
||
}, "agent_session_create");
|
||
if (!response.ok) return { ok: false, error: response.error, body: response.body };
|
||
const session = response.body?.session ?? response.body;
|
||
const sessionId = text(session?.sessionId ?? session?.id);
|
||
if (!sessionId) return { ok: false, error: { code: "agent_session_id_missing", httpStatus: response.status, body: compactObject(response.body) }, body: response.body };
|
||
return {
|
||
ok: true,
|
||
sessionId,
|
||
threadId: text(session?.threadId ?? response.body?.threadId),
|
||
body: response.body
|
||
};
|
||
}
|
||
|
||
async function submitAgentTask(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string; sessionId: string; threadId: string; traceId: string; prompt: string }) {
|
||
return caseFetchJson(context, `${input.baseUrl}/v1/agent/chat`, {
|
||
method: "POST",
|
||
headers: { ...caseAgentHeaders(context), "x-trace-id": input.traceId, prefer: "respond-async", "x-hwlab-short-connection": "1" },
|
||
body: JSON.stringify(clean({
|
||
message: input.prompt,
|
||
conversationId: input.conversationId,
|
||
sessionId: input.sessionId,
|
||
threadId: input.threadId,
|
||
traceId: input.traceId,
|
||
providerProfile: input.providerProfile,
|
||
shortConnection: true,
|
||
projectId: input.projectId,
|
||
updatedByClient: "hwlab-cli.case-run"
|
||
}))
|
||
}, "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,
|
||
projectId: agent.projectId,
|
||
resultStatus: text(resultBody?.status),
|
||
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);
|
||
const pollIntervalMs = effectiveAgentPollIntervalMs(context, input.run?.agentTask);
|
||
const startedAt = Date.now();
|
||
const resultPath = text(input.acceptedBody?.resultUrl) || `/v1/agent/chat/result/${encodeURIComponent(input.traceId)}`;
|
||
const resultUrl = input.resultUrl || (resultPath.startsWith("http") ? resultPath : `${input.baseUrl}${resultPath}`);
|
||
let polls = 0;
|
||
let lastBody: any = null;
|
||
let lastStatus = 0;
|
||
let lastPollAt = "";
|
||
while (Date.now() - startedAt < timeoutMs) {
|
||
polls += 1;
|
||
lastPollAt = context.now();
|
||
const response = await caseFetchJson(context, resultUrl, { method: "GET", headers: caseAgentHeaders(context) }, "agent_task_result");
|
||
lastBody = response.body;
|
||
lastStatus = response.status;
|
||
if (input.run && input.agent) await updateRun(context, input.run, { stage: "agent-running", agent: { ...input.agent, stageStatus: "running", result: compactObject(lastBody), polls, timeoutMs, pollIntervalMs, resultUrl, lastPollAt, lastHttpStatus: lastStatus } });
|
||
if (!response.ok) return { stageStatus: "result_request_failed", body: lastBody, polls, timedOut: false, lastPollAt, lastHttpStatus: lastStatus, error: response.error };
|
||
const status = text(lastBody?.status);
|
||
if (status && status !== "running") {
|
||
return { stageStatus: status, body: lastBody, polls, timedOut: false, lastPollAt, lastHttpStatus: lastStatus, error: null };
|
||
}
|
||
await context.sleep(pollIntervalMs);
|
||
}
|
||
return { stageStatus: "timeout", body: lastBody, polls, timedOut: true, lastPollAt, lastHttpStatus: lastStatus, error: { code: "agent_task_timeout", traceId: input.traceId, timeoutMs, lastHttpStatus: lastStatus, lastBody: compactObject(lastBody) } };
|
||
}
|
||
|
||
function effectiveAgentTimeoutMs(context: CaseContext, agentTask?: PreparedAgentTask | null) {
|
||
return numberOption(context.parsed.agentTimeoutMs ?? context.parsed.timeoutMs) ?? agentTask?.timeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
|
||
}
|
||
|
||
function effectiveAgentPollIntervalMs(context: CaseContext, agentTask?: PreparedAgentTask | null) {
|
||
return Math.max(numberOption(context.parsed.agentPollIntervalMs ?? context.parsed.pollIntervalMs) ?? agentTask?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, 250);
|
||
}
|
||
|
||
async function collectAgentDiff(context: CaseContext, run: PreparedCaseRun) {
|
||
const status = await requestSubjectCommand(context, run, ["-C", run.subject.worktreePath, "status", "--short"]);
|
||
const diffStat = await requestSubjectCommand(context, run, ["-C", run.subject.worktreePath, "diff", "--stat"]);
|
||
const diffPatch = await requestSubjectCommand(context, run, ["-C", run.subject.worktreePath, "diff", "--binary"]);
|
||
const sourceAfterAgent = await collectSourceRootSnapshot(context, run, "after-agent");
|
||
const statusShort = text(status.stdout);
|
||
const patchText = text(diffPatch.stdout);
|
||
const diffPatchPath = path.join(run.runDir, "agent-diff.patch");
|
||
await writeFile(diffPatchPath, patchText, "utf8");
|
||
const diff: AgentDiffStage = {
|
||
statusShort,
|
||
diffStat: text(diffStat.stdout),
|
||
diffPatchPath,
|
||
diffPatchSha256: sha256(patchText),
|
||
sourceRootStatusShort: sourceAfterAgent.statusShort,
|
||
sourceRootBaseline: run.subject.sourceRootBaseline,
|
||
sourceRootAfterPrepare: run.subject.sourceRootAfterPrepare,
|
||
sourceRootAfterAgent: sourceAfterAgent,
|
||
sourceRootChangedAfterPrepare: sourceRootChanged(run.subject.sourceRootBaseline, run.subject.sourceRootAfterPrepare),
|
||
sourceRootChangedAfterAgent: sourceRootChanged(run.subject.sourceRootAfterPrepare ?? run.subject.sourceRootBaseline, sourceAfterAgent),
|
||
requests: [
|
||
requestSummary("status", status),
|
||
requestSummary("diff-stat", diffStat),
|
||
requestSummary("diff-patch", diffPatch),
|
||
{ label: "source-root-baseline", statusShort: run.subject.sourceRootBaseline?.statusShort ?? "", diffSha256: run.subject.sourceRootBaseline?.diffSha256 ?? "" },
|
||
{ label: "source-root-after-prepare", statusShort: run.subject.sourceRootAfterPrepare?.statusShort ?? "", diffSha256: run.subject.sourceRootAfterPrepare?.diffSha256 ?? "" },
|
||
{ label: "source-root-after-agent", statusShort: sourceAfterAgent.statusShort, diffSha256: sourceAfterAgent.diffSha256 }
|
||
]
|
||
};
|
||
const nextRun = await updateRun(context, run, { agentDiff: diff });
|
||
return { run: nextRun, diff };
|
||
}
|
||
|
||
async function collectSourceRootSnapshot(context: CaseContext, run: PreparedCaseRun, label: string): Promise<SourceRootSnapshot> {
|
||
const status = await requestSubjectCommand(context, run, ["status", "--short"]);
|
||
const diffStat = await requestSubjectCommand(context, run, ["diff", "--stat"]);
|
||
const diffPatch = await requestSubjectCommand(context, run, ["diff", "--binary"]);
|
||
const diffText = text(diffPatch.stdout);
|
||
return {
|
||
label,
|
||
statusShort: text(status.stdout),
|
||
diffStat: text(diffStat.stdout),
|
||
diffSha256: sha256(diffText),
|
||
diffBytes: Buffer.byteLength(diffText),
|
||
commands: {
|
||
status: requestSummary("source-root-status", status),
|
||
diffStat: requestSummary("source-root-diff-stat", diffStat),
|
||
diffPatch: requestSummary("source-root-diff-patch", diffPatch)
|
||
}
|
||
};
|
||
}
|
||
|
||
function sourceRootChanged(before?: SourceRootSnapshot, after?: SourceRootSnapshot) {
|
||
if (!before || !after) return undefined;
|
||
return before.statusShort !== after.statusShort || before.diffSha256 !== after.diffSha256;
|
||
}
|
||
|
||
async function requestSubjectCommand(context: CaseContext, run: PreparedCaseRun, argv: string[]) {
|
||
const document = await readHwpodSpec(run.specPath);
|
||
const hwpodId = text(document.metadata.name) || text(document.metadata.uid) || run.caseId;
|
||
const plan = {
|
||
contractVersion: "hwpod-node-ops-v1",
|
||
planId: `case_subject_cmd_${randomUUID()}`,
|
||
hwpodId,
|
||
nodeId: document.spec.nodeBinding.nodeId,
|
||
intent: "cmd.run",
|
||
source: { compiler: "hwlab-cli.case", specPath: run.specPath, specAuthority: "case-run-state" },
|
||
ops: [{
|
||
opId: "op_01_subject_cmd",
|
||
op: "cmd.run",
|
||
args: { hwpodId, workspacePath: run.subject.repoLocalPath, command: "git", argv }
|
||
}]
|
||
};
|
||
const response = await context.fetchImpl(`${run.apiUrl}/v1/hwpod-node-ops`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(plan)
|
||
});
|
||
const textBody = await response.text();
|
||
const body = parseJsonMaybe(textBody) ?? { raw: textBody };
|
||
const result = firstHwpodOutput(body);
|
||
const exitCode = numberOption(result?.exitCode) ?? (response.status >= 200 && response.status < 300 ? 0 : 1);
|
||
return { ok: response.status >= 200 && response.status < 300 && exitCode === 0, httpStatus: response.status, exitCode, stdout: text(result?.stdout), stderr: text(result?.stderr), body: compactObject(body) };
|
||
}
|
||
|
||
async function updateRun(context: CaseContext, run: PreparedCaseRun, patch: Partial<PreparedCaseRun> & Record<string, unknown>) {
|
||
const nextRun = { ...run, ...patch, updatedAt: context.now() } as PreparedCaseRun;
|
||
await writeRunState(nextRun);
|
||
return nextRun;
|
||
}
|
||
|
||
async function renderAgentTaskPrompt(run: PreparedCaseRun, agentTask: PreparedAgentTask) {
|
||
const hwpodDocument = await readHwpodSpec(run.specPath);
|
||
const hwpodId = text(hwpodDocument.metadata.name) || text(hwpodDocument.metadata.uid) || run.caseId;
|
||
const hwpodWorkspaceArgs = `--hwpod-id ${hwpodId} --workspace-path ${promptShellArg(run.subject.worktreePath)}`;
|
||
const constraints = [
|
||
...agentTask.constraints,
|
||
"只能修改 isolated subject worktree,不得修改 case registry repo。",
|
||
"不得修改原 subject repo checkout;所有源码修改必须落在 subjectWorktreePath。",
|
||
"AgentRun 通过 kind=gitbundle 装配当前 v0.2 的 tools/ 与 .agents/skills;若标准 hwpod 命令能力缺失,报告 gitbundle runtime assembly 问题,不要改走旁路。",
|
||
"不要依赖 workspaceFiles、seed files、hostPath skill 目录、ConfigMap 或 runner 本地 .hwlab/hwpod-spec.yaml 作为工具/skill/HWPOD 注入 fallback。",
|
||
`必须通过 HWPOD registry/service 引用 hwpodId=${hwpodId};所有 hwpod/hwpod-ctl 命令都携带 ${hwpodWorkspaceArgs}。`,
|
||
"如果 case prompt 或旧帮助文本提到 .hwlab/hwpod-spec.yaml,把它视为过时写法并替换为本任务给出的 --hwpod-id/--workspace-path 参数;不要创建、复制或修补本地 spec 文件。",
|
||
"若 case prompt 要求源码修改,必须只改 subjectWorktreePath;若 prompt 未要求源码修改,则保持 subject 源码不变。",
|
||
"不要运行 CaseRun 答案执行器;你本人必须通过 hwpod/hwpod-ctl 标准入口触发编译验证。",
|
||
`hwpod build/download 是长任务短连接入口;不要再用 shell sleep/&&/timeout/watch/head/pipe 或 shell loop 包住它们。记录返回 JSON 里的 jobId/job_id,再用独立的 hwpod job status <jobId> ${hwpodWorkspaceArgs} 短命令做有限轮询。`
|
||
].filter(Boolean);
|
||
return [
|
||
`# HWPOD CaseRun Code Agent Task`,
|
||
``,
|
||
`caseId: ${run.caseId}`,
|
||
`runId: ${run.runId}`,
|
||
`subjectRepoLocalPath: ${run.subject.repoLocalPath}`,
|
||
`subjectCommitId: ${run.subject.commitId}`,
|
||
`subjectWorktreePath: ${run.subject.worktreePath}`,
|
||
`hwpodId: ${hwpodId}`,
|
||
`hwpodWorkspaceArgs: ${hwpodWorkspaceArgs}`,
|
||
`verificationMode: compile-only build check; no download or runtime smoke unless the case explicitly asks for it`,
|
||
``,
|
||
`## Runtime Assembly`,
|
||
`AgentRun materializes HWLAB runtime assets from ResourceBundleRef kind=gitbundle: repo subpath \`tools\` is copied to workspace \`tools\`, and repo subpath \`skills\` is copied to workspace \`.agents/skills\`. CaseRun no longer sends ad hoc workspaceFiles or seed-file payloads.`,
|
||
``,
|
||
`## HWPOD Runtime`,
|
||
`Use HWPOD by id through the runtime service. Do not require a runner-local \`.hwlab/hwpod-spec.yaml\`.`,
|
||
`Use these arguments on every hwpod/hwpod-ctl command: \`${hwpodWorkspaceArgs}\`.`,
|
||
`Standard smoke sequence for this task:`,
|
||
`- \`hwpod-ctl spec validate ${hwpodWorkspaceArgs}\``,
|
||
`- \`hwpod inspect ${hwpodWorkspaceArgs}\``,
|
||
`- workspace reads/searches/edits: \`hwpod workspace ... ${hwpodWorkspaceArgs}\``,
|
||
`- compile check: \`hwpod build ${hwpodWorkspaceArgs}\``,
|
||
``,
|
||
`## Task`,
|
||
agentTask.prompt,
|
||
``,
|
||
`## Constraints`,
|
||
...constraints.map((item) => `- ${item}`),
|
||
``,
|
||
`## Flow`,
|
||
`- Confirm standard \`hwpod\`, \`hwpod-ctl\` and \`hwpod-compiler\` commands are available from the gitbundle tools directory.`,
|
||
`- Follow the case task using standard hwpod/hwpod-ctl commands with \`${hwpodWorkspaceArgs}\`. Run build/download/UART steps only when the case explicitly asks for them, and report returned JSON/job/artifact/serial summaries.`,
|
||
`- For hwpod build/download, keep the HWPOD command unwrapped so it can return async JSON; then poll the returned job id with separate short \`hwpod job status <jobId> ${hwpodWorkspaceArgs}\` commands a bounded number of times. Do not wrap status polling with shell sleep, &&, timeout, watch, head, pipes, or shell loops.`,
|
||
`- CaseRun will inspect git diff under subjectWorktreePath after your turn completes and may run a runner post-check compile as separate evidence.`,
|
||
`- CaseRun records trace/session/conversation, agent commandExecution, workspace diff and Keil build evidence without auto-grading them.`
|
||
].join("\n");
|
||
}
|
||
|
||
function summarizeAgentTrace(traceId: string, httpStatus: number, body: any): AgentTraceStage {
|
||
const traceBody = body?.body ?? body;
|
||
const rows = arrayOfObjects(traceBody?.rows ?? traceBody?.renderedRows ?? traceBody?.traceRows);
|
||
const events = arrayOfObjects(traceBody?.events ?? traceBody?.trace?.events ?? traceBody?.sourceEvents);
|
||
const allCommands = [...commandsFromTraceRows(rows), ...commandsFromTraceEvents(events)];
|
||
const commands = selectAgentTraceCommands(allCommands);
|
||
const searchable = [
|
||
text(traceBody?.assistantText ?? body?.assistantText),
|
||
...allCommands.map((command) => [command.command, command.bodyPreview].map((item) => text(item)).filter(Boolean).join("\n"))
|
||
].filter(Boolean).join("\n");
|
||
const hwpodCommandCount = allCommands.filter((command) => mentionsHwpod(command)).length;
|
||
const hwpodBuildCommandCount = allCommands.filter((command) => mentionsHwpodBuild(command)).length;
|
||
return clean({
|
||
traceId,
|
||
status: text(traceBody?.status ?? body?.status) || "recorded",
|
||
httpStatus,
|
||
source: text(traceBody?.source ?? body?.source),
|
||
lookupOnly: traceBody?.lookupOnly === true || body?.lookupOnly === true ? true : undefined,
|
||
lookup: compactObject(traceBody?.lookup ?? body?.lookup ?? null),
|
||
sourceEventCount: numberOption(traceBody?.sourceEventCount ?? traceBody?.traceSummary?.sourceEventCount ?? body?.sourceEventCount),
|
||
renderedRowCount: rows.length || numberOption(traceBody?.renderedRowCount),
|
||
commandCount: allCommands.length,
|
||
hwpodCommandCount,
|
||
hwpodBuildCommandCount,
|
||
keilJobCandidates: uniqueStrings(Array.from(searchable.matchAll(/20\d{6}_\d{6}_[0-9a-f]{8}/giu)).map((match) => match[0])),
|
||
terminalStatus: text(traceBody?.terminalStatus ?? traceBody?.traceSummary?.terminalStatus ?? body?.terminalStatus),
|
||
agentRun: compactObject(traceBody?.agentRun ?? traceBody?.traceSummary?.agentRun ?? body?.agentRun ?? traceBody?.terminalEvidence?.agentRun ?? null),
|
||
commands
|
||
});
|
||
}
|
||
|
||
function commandsFromTraceRows(rows: Record<string, unknown>[]) {
|
||
return rows.flatMap((row, index) => {
|
||
const header = text(row.header ?? row.title ?? row.label);
|
||
const body = text(row.body ?? row.content ?? row.text ?? row.message);
|
||
if (!isCommandTraceRow(row, header)) return [];
|
||
const command = text((row as any).command) || commandFromText(`${header}\n${body}`) || commandFromRenderedToolBody(body);
|
||
if (!command) return [];
|
||
return [clean({
|
||
source: "trace-row",
|
||
seq: numberOption(row.seq) ?? index + 1,
|
||
rowId: text(row.id ?? row.rowId),
|
||
header: clipText(header, 500),
|
||
toolName: text(row.toolName ?? row.name),
|
||
status: text(row.status ?? row.level),
|
||
command: clipText(command, 1000),
|
||
normalizedCommand: clipText(normalizedHwpodCommand(command), 1000),
|
||
bodyPreview: clipText(body, 1200),
|
||
exitCode: exitCodeFromText(body)
|
||
})];
|
||
});
|
||
}
|
||
|
||
function commandsFromTraceEvents(events: Record<string, unknown>[]) {
|
||
return events.flatMap((event, index) => {
|
||
const toolName = text(event.toolName ?? event.name ?? event.label);
|
||
const message = text(event.message ?? event.text ?? event.content ?? event.output ?? event.stdout);
|
||
if (!isCommandTraceEvent(event, toolName)) return [];
|
||
const command = text(event.command ?? event.input ?? event.args) || commandFromExecutionText(message) || commandFromText(message) || commandFromRenderedToolBody(message);
|
||
if (!command) return [];
|
||
return [clean({
|
||
source: "trace-event",
|
||
seq: numberOption(event.seq ?? event.index) ?? index + 1,
|
||
rowId: text(event.id ?? event.eventId),
|
||
toolName,
|
||
status: text(event.status ?? event.level),
|
||
command: clipText(command, 1000),
|
||
normalizedCommand: clipText(normalizedHwpodCommand(command), 1000),
|
||
bodyPreview: clipText(message, 1200),
|
||
exitCode: numberOption(event.exitCode) ?? exitCodeFromText(message)
|
||
})];
|
||
});
|
||
}
|
||
|
||
function isCommandTraceRow(row: Record<string, unknown>, header: string) {
|
||
return /^tool:/u.test(text(row.rowId ?? row.id)) || /commandExecution|tool_call/iu.test(header) || /commandExecution|tool_call/iu.test(text(row.toolName ?? row.name));
|
||
}
|
||
|
||
function isCommandTraceEvent(event: Record<string, unknown>, toolName: string) {
|
||
const type = text(event.type ?? event.kind ?? event.event);
|
||
return /commandExecution/iu.test(toolName) || /commandExecution|tool_call/iu.test(type);
|
||
}
|
||
|
||
function selectAgentTraceCommands(commands: AgentTraceCommand[]) {
|
||
const selected: AgentTraceCommand[] = [];
|
||
for (const command of [
|
||
...selectRepresentativeHwpodCommands(commands),
|
||
...commands.slice(0, 10),
|
||
...commands.slice(-10),
|
||
...commands.filter((item) => mentionsHwpod(item))
|
||
]) {
|
||
const key = `${command.source}:${command.seq ?? ""}:${command.rowId ?? ""}:${command.command ?? ""}:${command.status ?? ""}`;
|
||
if (selected.some((item) => `${item.source}:${item.seq ?? ""}:${item.rowId ?? ""}:${item.command ?? ""}:${item.status ?? ""}` === key)) continue;
|
||
selected.push(command);
|
||
if (selected.length >= MAX_AGENT_TRACE_COMMANDS) break;
|
||
}
|
||
return selected;
|
||
}
|
||
|
||
function selectRepresentativeHwpodCommands(commands: AgentTraceCommand[]) {
|
||
return ["spec-validate", "inspect", "workspace-edit", "build", "download", "uart-read"].flatMap((kind) => {
|
||
const candidates = commands.filter((command) => hwpodCommandKind(command) === kind);
|
||
return candidates.find((command) => text(command.status) === "completed" || command.exitCode === 0) ?? candidates[0] ?? [];
|
||
});
|
||
}
|
||
|
||
function hwpodCommandKind(command: AgentTraceCommand) {
|
||
const raw = normalizedHwpodCommand(command.normalizedCommand || command.command || "");
|
||
if (/^(?:hwpod-ctl|hwpod-cli)\s+spec\s+validate(?:\s|$)/iu.test(raw)) return "spec-validate";
|
||
if (/^hwpod\s+inspect(?:\s|$)/iu.test(raw)) return "inspect";
|
||
if (/^hwpod\s+workspace\s+(?:insert-after|apply-patch|write|replace|append)(?:\s|$)/iu.test(raw)) return "workspace-edit";
|
||
if (/^(?:hwpod|hwpod-cli)\s+build(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts\s+build(?:\s|$)/iu.test(raw)) return "build";
|
||
if (/^(?:hwpod|hwpod-cli)\s+download(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts\s+download(?:\s|$)/iu.test(raw)) return "download";
|
||
if (/^(?:hwpod|hwpod-cli)\s+(?:uart\s+read|io\s+uart\s+read)(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts\s+(?:uart\s+read|io\s+uart\s+read)(?:\s|$)/iu.test(raw)) return "uart-read";
|
||
return "";
|
||
}
|
||
|
||
function arrayOfObjects(value: unknown): Record<string, unknown>[] {
|
||
return Array.isArray(value) ? value.filter((item) => item && typeof item === "object" && !Array.isArray(item)) as Record<string, unknown>[] : [];
|
||
}
|
||
|
||
function commandFromText(value: string) {
|
||
const match = value.match(/(?:command|cmd)\s*[:=]\s*([^\n]+)/iu);
|
||
return text(match?.[1]);
|
||
}
|
||
|
||
function commandFromRenderedToolBody(value: string) {
|
||
const raw = text(value);
|
||
if (!raw) return "";
|
||
const match = raw.match(/^(.+?)(?:\s+stdout:|\s+stderr:|\s+exitCode=|\s+exit\s*code\b)/isu);
|
||
return text(match?.[1] ?? raw);
|
||
}
|
||
|
||
function commandFromExecutionText(value: string) {
|
||
const raw = text(value);
|
||
const match = raw.match(/commandExecution\s+(?:inProgress|completed|started|failed):\s+(.+?)(?:\s+exit=|\s+durationMs=|\s*$)/isu);
|
||
return text(match?.[1]);
|
||
}
|
||
|
||
function exitCodeFromText(value: string) {
|
||
const match = value.match(/exit\s*code\s*[:=]?\s*(-?\d+)/iu) ?? value.match(/exitCode\s*[:=]?\s*(-?\d+)/u);
|
||
return numberOption(match?.[1]);
|
||
}
|
||
|
||
function mentionsHwpod(command: AgentTraceCommand) {
|
||
const raw = normalizedHwpodCommand(command.normalizedCommand || command.command || "");
|
||
return /^(?:hwpod|hwpod-ctl|hwpod-cli)(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts(?:\s|$)/iu.test(raw);
|
||
}
|
||
|
||
function mentionsHwpodBuild(command: AgentTraceCommand) {
|
||
const raw = normalizedHwpodCommand(command.normalizedCommand || command.command || "");
|
||
return /^(?:hwpod|hwpod-cli)\s+build(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts\s+build(?:\s|$)/iu.test(raw);
|
||
}
|
||
|
||
function normalizedHwpodCommand(value: string) {
|
||
return unquoteShellCommand(text(value)).replace(/\s+/gu, " ").trim();
|
||
}
|
||
|
||
function unquoteShellCommand(value: string) {
|
||
return value.replace(/^\/bin\/sh\s+-lc\s+/u, "").replace(/^['"]|['"]$/gu, "").trim();
|
||
}
|
||
|
||
function uniqueStrings(values: string[]) {
|
||
return Array.from(new Set(values.map((value) => text(value)).filter(Boolean)));
|
||
}
|
||
|
||
async function caseFetchJson(context: CaseContext, url: string, init: RequestInit, errorCode: string) {
|
||
try {
|
||
const response = await context.fetchImpl(url, init);
|
||
const textBody = await response.text();
|
||
const body = parseJsonMaybe(textBody) ?? { raw: textBody };
|
||
const ok = response.status >= 200 && response.status < 300 && body?.ok !== false;
|
||
return { ok, status: response.status, body, error: ok ? null : { code: errorCode, httpStatus: response.status, body: compactObject(body) } };
|
||
} catch (error) {
|
||
return { ok: false, status: 0, body: null, error: { code: errorCode, message: error instanceof Error ? error.message : String(error) } };
|
||
}
|
||
}
|
||
|
||
function caseAgentHeaders(context: CaseContext) {
|
||
const apiKey = text(context.env.HWLAB_API_KEY);
|
||
if (!apiKey.startsWith(HWLAB_API_KEY_PREFIX)) throw cliError("api_key_required", "case run Code Agent stage requires HWLAB_API_KEY for HWLAB v0.2 Cloud Web", { allowed: "HWLAB_API_KEY" });
|
||
return { "content-type": "application/json", authorization: `Bearer ${apiKey}` };
|
||
}
|
||
|
||
function webUrlFrom(context: CaseContext, definition: any) {
|
||
return text(context.parsed.baseUrl ?? context.parsed.webUrl ?? definition?.runtime?.webUrl ?? context.env.HWLAB_RUNTIME_WEB_URL ?? context.env.HWLAB_CLOUD_WEB_URL) || DEFAULT_WEB_URL;
|
||
}
|
||
|
||
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;
|
||
return first?.output ?? first;
|
||
}
|
||
|
||
function requestSummary(label: string, value: any) {
|
||
return { label, httpStatus: value.httpStatus, exitCode: value.exitCode, stdoutBytes: Buffer.byteLength(text(value.stdout)), stderr: clipText(value.stderr, 1000) };
|
||
}
|
||
|
||
function agentTaskSummary(agentTask: PreparedAgentTask) {
|
||
return clean({ workspace: agentTask.workspace, providerProfile: agentTask.providerProfile, projectId: agentTask.projectId, timeoutMs: agentTask.timeoutMs, pollIntervalMs: agentTask.pollIntervalMs, promptSha256: sha256(agentTask.prompt), constraints: agentTask.constraints });
|
||
}
|
||
|
||
function agentSummary(agent: AgentRunStage) {
|
||
return clean({ stageStatus: agent.stageStatus, baseUrl: agent.baseUrl, projectId: agent.projectId, providerProfile: agent.providerProfile, conversationId: agent.conversationId, sessionId: agent.sessionId, threadId: agent.threadId, traceId: agent.traceId, promptPath: agent.promptPath, promptSha256: agent.promptSha256, polls: agent.polls, timedOut: agent.timedOut, timeoutMs: agent.timeoutMs, pollIntervalMs: agent.pollIntervalMs, lastHttpStatus: agent.lastHttpStatus, nextPollCommand: agent.nextPollCommand, traceCommand: agent.traceCommand, inspectCommand: agent.inspectCommand, sessionCommand: agent.sessionCommand, traceLookup: agent.traceLookup, error: agent.error });
|
||
}
|
||
|
||
function traceSummary(trace: AgentTraceStage) {
|
||
return clean({
|
||
traceId: trace.traceId,
|
||
status: trace.status,
|
||
httpStatus: trace.httpStatus,
|
||
source: trace.source,
|
||
lookupOnly: trace.lookupOnly,
|
||
lookup: trace.lookup,
|
||
sourceEventCount: trace.sourceEventCount,
|
||
renderedRowCount: trace.renderedRowCount,
|
||
commandCount: trace.commandCount,
|
||
hwpodCommandCount: trace.hwpodCommandCount,
|
||
hwpodBuildCommandCount: trace.hwpodBuildCommandCount,
|
||
keilJobCandidates: trace.keilJobCandidates,
|
||
terminalStatus: trace.terminalStatus,
|
||
agentRun: trace.agentRun,
|
||
commands: trace.commands,
|
||
error: trace.error
|
||
});
|
||
}
|
||
|
||
function diffSummary(diff: AgentDiffStage) {
|
||
return clean({
|
||
statusShort: diff.statusShort,
|
||
diffStat: diff.diffStat,
|
||
diffPatchPath: diff.diffPatchPath,
|
||
diffPatchSha256: diff.diffPatchSha256,
|
||
sourceRootStatusShort: diff.sourceRootStatusShort,
|
||
sourceRootBaseline: diff.sourceRootBaseline,
|
||
sourceRootAfterPrepare: diff.sourceRootAfterPrepare,
|
||
sourceRootAfterAgent: diff.sourceRootAfterAgent,
|
||
sourceRootChangedAfterPrepare: diff.sourceRootChangedAfterPrepare,
|
||
sourceRootChangedAfterAgent: diff.sourceRootChangedAfterAgent,
|
||
requests: diff.requests
|
||
});
|
||
}
|
||
|
||
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");
|
||
}
|
||
|
||
async function prepareSubjectWorktree(context: CaseContext, input: { caseId: string; runId: string; runDir: string; apiUrl: string; definition: Record<string, unknown>; sourceSpecPath: string }) {
|
||
const subject = subjectFromDefinition(input.definition);
|
||
const worktreePath = subjectWorktreePath(subject.repoLocalPath, input.runId);
|
||
const relativeWorktreePath = subjectRelativeWorktreePath(subject.repoLocalPath, worktreePath);
|
||
const snapshotRun = caseRunForSubjectSnapshot(input, subject, worktreePath);
|
||
const sourceRootBaseline = await collectSourceRootSnapshot(context, snapshotRun, "before-prepare");
|
||
const setup = await requestSubjectWorktree(context, input, subject, worktreePath, relativeWorktreePath);
|
||
const sourceRootAfterPrepare = await collectSourceRootSnapshot(context, snapshotRun, "after-prepare");
|
||
return { ...subject, worktreePath, workspacePath: worktreePath, setup, sourceRootBaseline, sourceRootAfterPrepare };
|
||
}
|
||
|
||
function caseRunForSubjectSnapshot(input: { caseId: string; runId: string; runDir: string; apiUrl: string; definition: Record<string, unknown>; sourceSpecPath: string }, subject: { repoLocalPath: string; commitId: string; subdir: string }, worktreePath: string): PreparedCaseRun {
|
||
return {
|
||
caseId: input.caseId,
|
||
runId: input.runId,
|
||
runDir: input.runDir,
|
||
caseRepo: "",
|
||
caseDir: "",
|
||
caseFile: "",
|
||
sourceSpecPath: input.sourceSpecPath,
|
||
specPath: input.sourceSpecPath,
|
||
apiUrl: input.apiUrl,
|
||
compileOnly: true,
|
||
createdAt: "",
|
||
updatedAt: "",
|
||
definition: input.definition,
|
||
subject: { ...subject, worktreePath, workspacePath: worktreePath, setup: {} }
|
||
};
|
||
}
|
||
|
||
function subjectFromDefinition(definition: Record<string, unknown>) {
|
||
const subject = definition.subject;
|
||
if (!subject || typeof subject !== "object" || Array.isArray(subject)) {
|
||
throw cliError("subject_required", "case.json subject is required", { required: ["subject.repoLocalPath", "subject.commitId"] });
|
||
}
|
||
const source = subject as Record<string, unknown>;
|
||
const repoLocalPath = text(source.repoLocalPath);
|
||
if (!repoLocalPath) throw cliError("subject_repo_local_path_required", "case.json subject.repoLocalPath is required and must point to a local subject repo checkout on the bound HWPOD node", { field: "subject.repoLocalPath" });
|
||
const commitId = text(source.commitId).toLowerCase();
|
||
if (!commitId) throw cliError("subject_commit_id_required", "case.json subject.commitId is required and must be a fixed subject repo commit id", { field: "subject.commitId" });
|
||
if (!/^[0-9a-f]{40}$/iu.test(commitId)) {
|
||
throw cliError("invalid_subject_commit_id", "subject.commitId must be a full 40-character git commit id", { commitId });
|
||
}
|
||
return { repoLocalPath, commitId, subdir: text(source.subdir) };
|
||
}
|
||
|
||
async function requestSubjectWorktree(context: CaseContext, input: { caseId: string; runId: string; runDir: string; apiUrl: string; sourceSpecPath: string }, subject: { repoLocalPath: string; commitId: string; subdir: string }, worktreePath: string, relativeWorktreePath: string) {
|
||
const document = await readHwpodSpec(input.sourceSpecPath);
|
||
const hwpodId = text(document.metadata.name) || text(document.metadata.uid) || input.caseId;
|
||
const sidecarOp = keilSidecarCopyOp(document, subject, hwpodId, relativeWorktreePath);
|
||
const ops = [{
|
||
opId: "op_01_subject_commit",
|
||
op: "cmd.run",
|
||
args: {
|
||
hwpodId,
|
||
workspacePath: subject.repoLocalPath,
|
||
command: "git",
|
||
argv: ["cat-file", "-e", subject.commitId]
|
||
}
|
||
}, {
|
||
opId: "op_02_subject_worktree_add",
|
||
op: "cmd.run",
|
||
args: {
|
||
hwpodId,
|
||
workspacePath: subject.repoLocalPath,
|
||
command: "git",
|
||
argv: ["worktree", "add", "--detach", "--force", relativeWorktreePath, subject.commitId]
|
||
}
|
||
}, {
|
||
opId: "op_03_subject_worktree_head",
|
||
op: "cmd.run",
|
||
args: {
|
||
hwpodId,
|
||
workspacePath: subject.repoLocalPath,
|
||
command: "git",
|
||
argv: ["-C", relativeWorktreePath, "rev-parse", "HEAD"]
|
||
}
|
||
}, ...(sidecarOp ? [sidecarOp] : [])];
|
||
const plan = {
|
||
contractVersion: "hwpod-node-ops-v1",
|
||
planId: `case_subject_${randomUUID()}`,
|
||
hwpodId,
|
||
nodeId: document.spec.nodeBinding.nodeId,
|
||
intent: "cmd.run",
|
||
source: { compiler: "hwlab-cli.case", specPath: input.sourceSpecPath, specAuthority: "case-registry" },
|
||
ops
|
||
};
|
||
const response = await context.fetchImpl(`${input.apiUrl}/v1/hwpod-node-ops`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(plan)
|
||
});
|
||
const textBody = await response.text();
|
||
const body = parseJsonMaybe(textBody) ?? { raw: textBody };
|
||
const status = subjectWorktreeStatus(response.status, body, { commitId: subject.commitId, worktreePath });
|
||
if (!status.ok) {
|
||
throw cliError("subject_worktree_prepare_failed", "failed to prepare isolated subject worktree on the HWPOD node", clean({ httpStatus: response.status, nodeId: document.spec.nodeBinding.nodeId, repoLocalPath: subject.repoLocalPath, commitId: subject.commitId, worktreePath, exitCode: status.exitCode, stdout: clipText(status.stdout), stderr: clipText(status.stderr), body: compactObject(body) }));
|
||
}
|
||
return clean({ httpStatus: response.status, planId: plan.planId, nodeId: document.spec.nodeBinding.nodeId, exitCode: status.exitCode, stdout: clipText(status.stdout), stderr: clipText(status.stderr), keilSidecars: keilSidecarSummary(status.stdout) });
|
||
}
|
||
|
||
function subjectWorktreeStatus(httpStatus: number, body: any, expected: { commitId: string; worktreePath: string }) {
|
||
const results = Array.isArray(body?.body?.results) ? body.body.results : Array.isArray(body?.results) ? body.results : [];
|
||
const outputs = results.map((result: any) => result.output ?? result);
|
||
const exitCodes = outputs.map((output: any) => numberOption(output.exitCode));
|
||
const stdout = outputs.map((output: any) => text(output.stdout)).filter(Boolean).join("\n");
|
||
const stderr = outputs.map((output: any) => text(output.stderr)).filter(Boolean).join("\n");
|
||
const actualHead = text(outputs[2]?.stdout).split(/\r?\n/u)[0]?.toLowerCase() ?? "";
|
||
const commandOk = httpStatus >= 200 && httpStatus < 300 && outputs.length >= 3 && exitCodes.every((exitCode) => exitCode === 0);
|
||
const ok = commandOk && actualHead === expected.commitId;
|
||
return { ok, exitCode: exitCodes.find((exitCode) => exitCode !== 0) ?? exitCodes[exitCodes.length - 1], stdout, stderr, actualHead, actualWorktreePath: expected.worktreePath };
|
||
}
|
||
|
||
function keilSidecarCopyOp(document: any, subject: { repoLocalPath: string }, hwpodId: string, relativeWorktreePath: string) {
|
||
const project = subjectRelativeKeilProject(document, subject.repoLocalPath);
|
||
if (!project) return null;
|
||
const sourceBase = stripUvprojx(project).replace(/\//gu, "\\");
|
||
const destinationBase = windowsJoin(relativeWorktreePath, sourceBase);
|
||
return {
|
||
opId: "op_04_keil_sidecars",
|
||
op: "cmd.run",
|
||
args: {
|
||
hwpodId,
|
||
workspacePath: subject.repoLocalPath,
|
||
command: "node",
|
||
argv: ["-e", keilSidecarCopyNodeScript(), JSON.stringify({ sourceBase, destinationBase })],
|
||
commandBinding: {
|
||
kind: "keil-mdk-sidecar-copy",
|
||
source: "hwlab-cli.case.prepare",
|
||
sourceBase,
|
||
destinationBase,
|
||
sidecars: [".uvoptx", ".uvopt"]
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
function subjectRelativeKeilProject(document: any, repoLocalPath: string) {
|
||
const workspace = objectRecord(document.spec?.workspace);
|
||
const projectWorkspace = objectRecord(document.spec?.projectWorkspace);
|
||
const rawProject = text(workspace.keilProject ?? workspace.projectPath ?? workspace.project ?? projectWorkspace.projectPath);
|
||
if (!rawProject || !/\.uvprojx$/iu.test(rawProject)) return "";
|
||
if (!isAbsolutePathLike(rawProject)) return rawProject.replace(/^[\\/]+/u, "");
|
||
const repo = repoLocalPath.replace(/[\\/]+$/u, "");
|
||
const normalizedRepo = normalizeLocalPath(repo);
|
||
const normalizedProject = normalizeLocalPath(rawProject);
|
||
if (!normalizedProject.startsWith(`${normalizedRepo}/`)) return "";
|
||
return rawProject.slice(repo.length).replace(/^[\\/]+/u, "");
|
||
}
|
||
|
||
function stripUvprojx(value: string) {
|
||
return value.replace(/\.uvprojx$/iu, "");
|
||
}
|
||
|
||
function windowsJoin(parent: string, child: string) {
|
||
const left = parent.replace(/[\\/]+$/u, "").replace(/\//gu, "\\");
|
||
const right = child.replace(/^[\\/]+/u, "").replace(/\//gu, "\\");
|
||
return `${left}\\${right}`;
|
||
}
|
||
|
||
function keilSidecarCopyNodeScript() {
|
||
return [
|
||
"const fs=require('fs');",
|
||
"const path=require('path');",
|
||
"const input=JSON.parse(process.argv[1]||'{}');",
|
||
"const sidecars=['.uvoptx','.uvopt'];",
|
||
"const copiedFiles=[];",
|
||
"const missing=[];",
|
||
"for(const ext of sidecars){",
|
||
"const source=String(input.sourceBase||'')+ext;",
|
||
"const destination=String(input.destinationBase||'')+ext;",
|
||
"if(fs.existsSync(source)){",
|
||
"const dir=destination.includes('\\\\')?path.win32.dirname(destination):path.dirname(destination);",
|
||
"fs.mkdirSync(dir,{recursive:true});",
|
||
"fs.copyFileSync(source,destination);",
|
||
"copiedFiles.push({ext,source,destination});",
|
||
"}else{missing.push(source);}",
|
||
"}",
|
||
"console.log('caseRunKeilSidecars '+JSON.stringify({copied:copiedFiles.length,copiedFiles,missing,sourceBase:input.sourceBase,destinationBase:input.destinationBase}));"
|
||
].join("");
|
||
}
|
||
|
||
function keilSidecarSummary(stdout: string) {
|
||
const line = stdout.split(/\r?\n/u).find((item) => item.includes("caseRunKeilSidecars"));
|
||
if (!line) return null;
|
||
const payload = line.replace(/^.*caseRunKeilSidecars\s*/u, "");
|
||
const parsed = parseJsonMaybe(payload);
|
||
if (parsed && typeof parsed === "object") return clean({ ...(parsed as Record<string, unknown>), line });
|
||
return clean({ copied: numberOption(line.match(/copied=(\d+)/u)?.[1]), line });
|
||
}
|
||
|
||
function objectRecord(value: unknown): Record<string, unknown> {
|
||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||
}
|
||
|
||
function isAbsolutePathLike(value: string) {
|
||
return /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("/") || value.startsWith("\\\\");
|
||
}
|
||
|
||
function subjectRelativeWorktreePath(repoLocalPath: string, worktreePath: string) {
|
||
const repo = repoLocalPath.replace(/[\\/]+$/u, "");
|
||
const normalizedRepo = normalizeLocalPath(repo);
|
||
const normalizedWorktree = normalizeLocalPath(worktreePath);
|
||
if (!normalizedWorktree.startsWith(`${normalizedRepo}/`)) throw cliError("invalid_subject_worktree_path", "subject worktree path must stay under subject repo local path", { repoLocalPath, worktreePath });
|
||
const relative = worktreePath.slice(repo.length).replace(/^[\\/]+/u, "");
|
||
return relative || ".";
|
||
}
|
||
|
||
function normalizeLocalPath(value: string) {
|
||
return value.replace(/[\\/]+$/u, "").replace(/\\/gu, "/").toLowerCase();
|
||
}
|
||
|
||
function subjectWorktreePath(repoLocalPath: string, runId: string) {
|
||
const separator = looksLikeWindowsPath(repoLocalPath) ? "\\" : "/";
|
||
return [repoLocalPath.replace(/[\\/]+$/u, ""), ".worktree", `caserun-${slug(runId)}`].join(separator);
|
||
}
|
||
|
||
function looksLikeWindowsPath(value: string) {
|
||
return /^[A-Za-z]:[\\/]/u.test(value) || value.includes("\\");
|
||
}
|
||
|
||
function rewriteHwpodSpecWorkspacePath(specText: string, workspacePath: string) {
|
||
const lines = specText.replace(/\r\n/gu, "\n").split("\n");
|
||
let workspaceIndent: number | null = null;
|
||
for (let index = 0; index < lines.length; index += 1) {
|
||
const line = lines[index] ?? "";
|
||
const workspaceMatch = line.match(/^(\s*)workspace:\s*(?:#.*)?$/u);
|
||
if (workspaceMatch) {
|
||
workspaceIndent = workspaceMatch[1].length;
|
||
continue;
|
||
}
|
||
if (workspaceIndent === null) continue;
|
||
const indent = line.match(/^(\s*)/u)?.[1].length ?? 0;
|
||
if (line.trim() && indent <= workspaceIndent) {
|
||
workspaceIndent = null;
|
||
continue;
|
||
}
|
||
const pathMatch = line.match(/^(\s*)path\s*:/u);
|
||
if (pathMatch) {
|
||
lines[index] = `${pathMatch[1]}path: ${JSON.stringify(workspacePath)}`;
|
||
return lines.join("\n");
|
||
}
|
||
}
|
||
throw cliError("hwpod_spec_workspace_path_missing", "hwpod-spec.yaml must contain spec.workspace.path so CaseRun can isolate the subject worktree", {});
|
||
}
|
||
|
||
async function pollKeilJobStatus(context: CaseContext, run: PreparedCaseRun, jobId: string) {
|
||
const timeouts = caseTimeoutsFromDefinition(run.definition);
|
||
const timeoutMs = Math.min(numberOption(context.parsed.jobTimeoutMs ?? context.parsed.timeoutMs) ?? numberOption(timeouts.jobTimeoutMs ?? timeouts.keilJobTimeoutMs) ?? DEFAULT_JOB_TIMEOUT_MS, MAX_JOB_TIMEOUT_MS);
|
||
const pollIntervalMs = numberOption(context.parsed.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS;
|
||
const started = Date.now();
|
||
const polls: any[] = [];
|
||
let last: any = null;
|
||
while (Date.now() - started <= timeoutMs) {
|
||
const response = await requestKeilJobStatus(context, run, jobId);
|
||
const status = keilJobStatus(response.body);
|
||
last = { response, status };
|
||
polls.push(status.summary);
|
||
if (status.terminal) return { ok: status.ok, summary: { ...status.summary, jobId, polls: polls.length, timedOut: false } };
|
||
await context.sleep(pollIntervalMs);
|
||
}
|
||
return { ok: false, summary: { jobId, status: text(last?.status?.summary?.status) || "timeout", polls: polls.length, timedOut: true, last: last?.status?.summary ?? null } };
|
||
}
|
||
|
||
async function requestKeilJobStatus(context: CaseContext, run: PreparedCaseRun, jobId: string) {
|
||
const document = await readHwpodSpec(run.specPath);
|
||
const keilCliPath = text(document.spec.workspace.keilCliPath) || text(document.spec.debugProbe.keilCliPath) || "keil-cli.py";
|
||
const pythonCommand = text(document.spec.workspace.pythonCommand) || text(document.spec.debugProbe.pythonCommand) || "py -3";
|
||
const jobStatusCommand = jobStatusCommandForTest(pythonCommand, keilCliPath, jobId, document.spec.workspace.path);
|
||
const hwpodId = text(document.metadata.name) || text(document.metadata.uid) || run.caseId;
|
||
const plan = {
|
||
contractVersion: "hwpod-node-ops-v1",
|
||
planId: `case_job_${randomUUID()}`,
|
||
hwpodId,
|
||
nodeId: document.spec.nodeBinding.nodeId,
|
||
intent: "cmd.run",
|
||
source: { compiler: "hwlab-cli.case", specPath: run.specPath, specAuthority: "case-run-state" },
|
||
ops: [{
|
||
opId: "op_01_keil_job_status",
|
||
op: "cmd.run",
|
||
args: {
|
||
hwpodId,
|
||
workspacePath: dirnameForCommandPath(keilCliPath),
|
||
command: jobStatusCommand.command,
|
||
argv: jobStatusCommand.argv
|
||
}
|
||
}]
|
||
};
|
||
const response = await context.fetchImpl(`${run.apiUrl}/v1/hwpod-node-ops`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(plan)
|
||
});
|
||
const textBody = await response.text();
|
||
return { status: response.status, body: parseJsonMaybe(textBody) ?? { raw: textBody }, plan };
|
||
}
|
||
|
||
function keilJobStatus(body: any) {
|
||
const parsed = parseJsonMaybe(hwpodResultTexts(body)[0]) ?? body;
|
||
const status = text(parsed?.status ?? parsed?.data?.status ?? parsed?.job?.status) || "unknown";
|
||
const returnCode = numberOption(parsed?.return_code ?? parsed?.returnCode ?? parsed?.data?.return_code);
|
||
const success = parsed?.success === true || parsed?.ok === true || returnCode === 0;
|
||
const failed = parsed?.success === false || parsed?.ok === false || (returnCode !== undefined && returnCode !== 0) || ["failed", "error", "cancelled"].includes(status);
|
||
const terminal = success || failed || ["completed", "succeeded"].includes(status);
|
||
const artifacts = artifactsFrom(parsed);
|
||
return { terminal, ok: success || ["completed", "succeeded"].includes(status), summary: clean({ status, returnCode, success, artifacts, raw: compactObject(parsed) }) };
|
||
}
|
||
|
||
function apiUrlFrom(context: CaseContext, definition: any) {
|
||
return text(context.parsed.apiUrl ?? context.parsed.apiBaseUrl ?? context.parsed.runtimeApiUrl ?? definition?.runtime?.apiUrl ?? context.env.HWLAB_RUNTIME_API_URL) || DEFAULT_API_URL;
|
||
}
|
||
|
||
function stateRoot(context: CaseContext) {
|
||
return text(context.parsed.stateDir) || path.join(".state", "hwlab-cli", "caserun");
|
||
}
|
||
|
||
function runIdFrom(context: CaseContext, caseId: string) {
|
||
const explicit = text(context.parsed.runId);
|
||
if (explicit) return explicit;
|
||
return `${slug(caseId)}-${context.now().replace(/[^0-9]/gu, "").slice(0, 14)}-${randomUUID().slice(0, 8)}`;
|
||
}
|
||
|
||
function prepareSummary(run: PreparedCaseRun) {
|
||
return { status: "prepared", caseId: run.caseId, runId: run.runId, specPath: run.specPath, apiUrl: run.apiUrl, subject: { repoLocalPath: run.subject.repoLocalPath, commitId: run.subject.commitId, worktreePath: run.subject.worktreePath } };
|
||
}
|
||
|
||
function buildSummary(evidence: any) {
|
||
return { status: evidence.status, autoEvaluation: false, traceLookup: evidence.traceLookup ?? null, agentTraceCommandCount: evidence.agentTrace?.commandCount ?? 0, agentTraceHwpodCommandCount: evidence.agentTrace?.hwpodCommandCount ?? 0, agentStage: agentStageSummary(evidence), hwpodExitCode: evidence.hwpod?.exitCode ?? null, hwpodSource: evidence.hwpod?.source ?? null, jobId: evidence.keilJob?.jobId ?? null, keilStatus: evidence.keilJob?.status ?? null, artifacts: evidence.artifacts ?? [] };
|
||
}
|
||
|
||
function agentTraceLookupForRun(run: PreparedCaseRun, evidence?: any) {
|
||
const agent = run.agent ?? evidence?.agent;
|
||
const traceId = text(agent?.traceId ?? evidence?.agentTrace?.traceId ?? run.agentTrace?.traceId);
|
||
if (!traceId) return null;
|
||
const baseUrl = text(agent?.baseUrl ?? run.agent?.baseUrl ?? evidence?.agent?.baseUrl) || runtimeWebUrl(run.definition) || DEFAULT_WEB_URL;
|
||
return agentTraceLookup({ baseUrl, traceId, sessionId: text(agent?.sessionId), conversationId: text(agent?.conversationId), threadId: text(agent?.threadId) });
|
||
}
|
||
|
||
function runtimeWebUrl(definition: Record<string, unknown>) {
|
||
const runtime = definition.runtime;
|
||
if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) return "";
|
||
return text((runtime as Record<string, unknown>).webUrl);
|
||
}
|
||
|
||
function renderEvidenceSummary(evidence: any) {
|
||
const agentStage = agentStageSummary(evidence);
|
||
const traceLookup = evidence.traceLookup ?? evidence.agent?.traceLookup;
|
||
const traceCommands = traceLookup?.commands ?? {};
|
||
const stageTable = agentStage.commands.length > 0 ? [
|
||
``,
|
||
`## Agent HWPOD Raw Steps`,
|
||
``,
|
||
`| Step | Status | Exit | Command | Raw detail |`,
|
||
`|---|---:|---:|---|---|`,
|
||
...agentStage.commands.map((command: any) => `| ${command.kind} | ${command.status || ""} | ${command.exitCode ?? ""} | \`${markdownTableText(command.command, 160)}\` | ${markdownTableText(command.detail, 180)} |`)
|
||
].join("\n") : "";
|
||
return `# HWPOD CaseRun ${evidence.caseId}\n\n- runId: ${evidence.runId}\n- status: ${evidence.status}\n- autoEvaluation: false\n- compileOnly: ${evidence.compileOnly}\n- subjectRepoLocalPath: ${evidence.subject?.repoLocalPath ?? ""}\n- subjectCommitId: ${evidence.subject?.commitId ?? ""}\n- subjectWorktreePath: ${evidence.subject?.worktreePath ?? ""}\n- agentTraceId: ${evidence.agent?.traceId ?? ""}\n- agentSessionId: ${evidence.agent?.sessionId ?? ""}\n- traceLookupStrategy: ${traceLookup?.strategy ?? ""}\n- traceCommand: ${traceCommands.trace ?? ""}\n- resultCommand: ${traceCommands.result ?? ""}\n- inspectCommand: ${traceCommands.inspect ?? ""}\n- agentTraceCommandCount: ${evidence.agentTrace?.commandCount ?? 0}\n- agentTraceHwpodCommandCount: ${evidence.agentTrace?.hwpodCommandCount ?? 0}\n- agentTraceHwpodBuildCommandCount: ${evidence.agentTrace?.hwpodBuildCommandCount ?? 0}\n- agentStageCommandCount: ${agentStage.commands.length}\n- agentStageKinds: ${agentStage.kinds.join(", ")}\n- diffPatchPath: ${evidence.agentDiff?.diffPatchPath ?? ""}\n- runnerHwpodSource: ${evidence.hwpod?.source ?? ""}\n- hwpodExitCode: ${evidence.hwpod?.exitCode ?? ""}\n- jobId: ${evidence.keilJob?.jobId ?? ""}\n- runnerPostAgentCompileCheck: recorded\n${stageTable}\n`;
|
||
}
|
||
|
||
function agentStageSummary(evidence: any) {
|
||
const commands = arrayOfObjects(evidence.agentTrace?.commands).filter(mentionsHwpod).map(agentStageCommand).filter((command) => text(command.kind));
|
||
const selected: Record<string, any> = {};
|
||
for (const command of commands) {
|
||
if (!selected[command.kind]) selected[command.kind] = command;
|
||
if (command.status === "completed" || command.exitCode === 0) selected[command.kind] = command;
|
||
}
|
||
const ordered = ["spec-validate", "inspect", "workspace-edit", "build", "download", "uart-read"]
|
||
.map((kind) => selected[kind])
|
||
.filter(Boolean);
|
||
const extras = commands.filter((command) => !ordered.some((item) => item.kind === command.kind && item.command === command.command));
|
||
return clean({
|
||
source: "agent-trace-commands",
|
||
autoEvaluation: false,
|
||
commands: [...ordered, ...extras].slice(0, MAX_AGENT_TRACE_COMMANDS),
|
||
kinds: uniqueStrings([...ordered, ...extras].map((command) => command.kind))
|
||
});
|
||
}
|
||
|
||
function agentStageCommand(command: AgentTraceCommand) {
|
||
return clean({
|
||
kind: hwpodCommandKind(command) || "hwpod-other",
|
||
seq: command.seq,
|
||
status: command.status,
|
||
exitCode: command.exitCode,
|
||
command: clipText(command.normalizedCommand || command.command, 1000),
|
||
detail: clipText(agentCommandDetail(command), 1000)
|
||
});
|
||
}
|
||
|
||
function agentCommandDetail(command: AgentTraceCommand) {
|
||
const body = text(command.bodyPreview);
|
||
if (!body) return "";
|
||
return body.split(/\r?\n/u).filter((line) => !line.includes(text(command.command))).join(" ").replace(/\s+/gu, " ").trim() || body;
|
||
}
|
||
|
||
function markdownTableText(value: unknown, maxBytes = 200) {
|
||
return clipText(value, maxBytes).replace(/\|/gu, "\\|").replace(/\r?\n/gu, " ").trim();
|
||
}
|
||
|
||
async function writeCaseAggregateFromRegistry(context: CaseContext, caseRepo: string, options: { sync?: boolean } = {}) {
|
||
const caseId = requiredText(context.parsed.caseId ?? context.rest[1], "caseId");
|
||
const runId = await resolveAggregateRunId(caseRepo, caseId, text(context.parsed.runId ?? context.rest[2]));
|
||
const runRel = artifactRel(path.join("runs", caseId, runId));
|
||
const caseRepoRunDir = path.join(caseRepo, runRel);
|
||
const evidence = await readJsonIfExists(path.join(caseRepoRunDir, "evidence.json"));
|
||
const run = await readJsonIfExists(path.join(caseRepoRunDir, "run.json"));
|
||
const manifest = await readJsonIfExists(path.join(caseRepoRunDir, "artifact-manifest.json"));
|
||
if (!evidence && !run && !manifest) throw cliError("case_registry_run_not_found", "case registry run artifacts were not found", { caseRepoRunDir, caseId, runId });
|
||
const outputRel = aggregateOutputRel(context);
|
||
const outputPath = path.join(caseRepoRunDir, outputRel);
|
||
const markdown = await renderCaseAggregateMarkdown(context, { caseRepoRunDir, caseId, runId, evidence, run, manifest });
|
||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||
await writeFile(outputPath, markdown, "utf8");
|
||
const file = await registryAggregateFileRecord(outputPath);
|
||
const registrySync = options.sync === true ? await syncCaseRegistryRel(context, caseRepo, artifactRel(path.join(runRel, outputRel)), `data: aggregate caserun ${runId}`) : null;
|
||
return { caseId, runId, caseRepoRunDir, path: outputPath, rel: artifactRel(path.join(runRel, outputRel)), bytes: file.bytes, sha256: file.sha256, registrySync };
|
||
}
|
||
|
||
async function resolveAggregateRunId(caseRepo: string, caseId: string, explicit: string) {
|
||
if (explicit) return explicit;
|
||
const runRoot = path.join(caseRepo, "runs", caseId);
|
||
let entries: string[] = [];
|
||
try {
|
||
entries = await readdir(runRoot);
|
||
} catch {
|
||
throw cliError("case_registry_runs_not_found", "case registry has no run directory for this case", { caseId, runRoot });
|
||
}
|
||
const runs = entries.filter((entry) => !entry.startsWith(".")).sort();
|
||
if (runs.length === 0) throw cliError("case_registry_runs_empty", "case registry has no runs for this case", { caseId, runRoot });
|
||
return runs.at(-1) ?? "";
|
||
}
|
||
|
||
function aggregateOutputRel(context: CaseContext) {
|
||
const value = text(context.parsed.output ?? context.parsed.outputPath ?? context.parsed.aggregatePath) || "aggregate.md";
|
||
const rel = artifactRel(value).replace(/^\/+/, "");
|
||
if (!rel || rel.includes("../") || rel === ".." || path.isAbsolute(value)) throw cliError("invalid_aggregate_output_path", "aggregate output must be a relative path inside the registry run directory", { output: value });
|
||
if (!rel.endsWith(".md")) throw cliError("invalid_aggregate_output_ext", "aggregate output must be a markdown file", { output: value });
|
||
return rel;
|
||
}
|
||
|
||
async function renderCaseAggregateMarkdown(context: CaseContext, input: { caseRepoRunDir: string; caseId: string; runId: string; evidence: any; run: any; manifest: any }) {
|
||
const prompt = await readTextIfExists(path.join(input.caseRepoRunDir, "agent-prompt.md"));
|
||
const finalResponse = await readTextIfExists(path.join(input.caseRepoRunDir, "final-response.md"));
|
||
const diffPatch = await readTextIfExists(path.join(input.caseRepoRunDir, "agent-diff.patch"));
|
||
const traceMarkdown = await readTextIfExists(path.join(input.caseRepoRunDir, "agent-trace.md"));
|
||
const specText = await readTextIfExists(path.join(input.caseRepoRunDir, ".hwlab", "hwpod-spec.yaml"));
|
||
const evidence = input.evidence ?? {};
|
||
const run = input.run ?? {};
|
||
const manifest = input.manifest ?? {};
|
||
const archivedMessages = await readJsonIfExists(path.join(input.caseRepoRunDir, "agent-messages.json"));
|
||
const traceLookup = manifest.traceLookup ?? evidence.traceLookup ?? run.agent?.traceLookup;
|
||
const commands = traceLookup?.commands ?? {};
|
||
const agent = manifest.agent ?? manifest.trace ?? evidence.agent ?? run.agent ?? {};
|
||
const subject = manifest.subject ?? evidence.subject ?? run.subject ?? {};
|
||
const trace = manifest.trace ?? evidence.agentTrace ?? run.agentTrace ?? {};
|
||
const diff = manifest.diff ?? evidence.agentDiff ?? run.agentDiff ?? {};
|
||
const keilJob = manifest.keilJob ?? evidence.keilJob ?? null;
|
||
const agentStage = manifest.agentStage ?? agentStageSummary(evidence);
|
||
const messages = await aggregateMessagesWithFreshFullTrace(context, { archivedMessages, traceLookup, traceId: agent.traceId ?? trace.traceId ?? traceLookup?.traceId, run, evidence, manifest });
|
||
const traceBody = aggregateTraceBody(messages, traceMarkdown);
|
||
return `${[
|
||
`# HWPOD CaseRun Aggregate: ${input.caseId}`,
|
||
``,
|
||
`- caseId: ${input.caseId}`,
|
||
`- runId: ${input.runId}`,
|
||
`- generatedFrom: case registry artifacts`,
|
||
`- aggregationOnly: true`,
|
||
`- autoEvaluation: false`,
|
||
`- primaryEntry: aggregate.md`,
|
||
``,
|
||
`## 运行环境信息`,
|
||
``,
|
||
aggregateBullets([
|
||
["apiUrl", run.apiUrl ?? evidence.apiUrl],
|
||
["compileOnly", evidence.compileOnly ?? run.compileOnly],
|
||
["caseRepoRunDir", input.caseRepoRunDir],
|
||
["sourceRunDir", manifest.sourceRunDir ?? run.runDir],
|
||
["createdAt", run.createdAt],
|
||
["completedAt", run.completedAt ?? run._control?.completedAt],
|
||
["runnerPostAgentCompileCheck", manifest.decisions?.runnerPostAgentCompileCheck ?? evidence.decisions?.runnerPostAgentCompileCheck ?? "recorded"]
|
||
]),
|
||
``,
|
||
`## HWPOD 信息`,
|
||
``,
|
||
aggregateBullets([
|
||
["subjectRepoLocalPath", subject.repoLocalPath],
|
||
["subjectCommitId", subject.commitId],
|
||
["subjectSubdir", subject.subdir],
|
||
["subjectWorktreePath", subject.worktreePath],
|
||
["sourceRootBaselineStatus", subject.sourceRootBaseline?.statusShort],
|
||
["sourceRootAfterPrepareStatus", subject.sourceRootAfterPrepare?.statusShort],
|
||
["keilJobId", keilJob?.jobId],
|
||
["keilStatus", keilJob?.status],
|
||
["hwpodExitCode", evidence.hwpod?.exitCode]
|
||
]),
|
||
specText ? aggregateDetails("Run-local HWPOD spec", "yaml", specText) : `_Run-local HWPOD spec artifact is missing._`,
|
||
``,
|
||
`## Code Agent 信息`,
|
||
``,
|
||
aggregateBullets([
|
||
["providerProfile", agent.providerProfile],
|
||
["projectId", agent.projectId],
|
||
["conversationId", agent.conversationId],
|
||
["sessionId", agent.sessionId],
|
||
["threadId", agent.threadId],
|
||
["traceId", agent.traceId ?? trace.traceId],
|
||
["agentRunId", trace.agentRun?.runId],
|
||
["agentCommandId", trace.agentRun?.commandId],
|
||
["traceSource", trace.source],
|
||
["traceCommand", commands.trace],
|
||
["resultCommand", commands.result],
|
||
["inspectCommand", commands.inspect]
|
||
]),
|
||
``,
|
||
`## 输入 Prompt`,
|
||
``,
|
||
prompt ? aggregateFence("markdown", prompt) : `_agent-prompt.md artifact is missing._`,
|
||
``,
|
||
`## 低噪声 Trace`,
|
||
``,
|
||
`- renderer: ${manifest.readableAgent?.renderer ?? messages?.renderer ?? CASE_TRACE_RENDERER}`,
|
||
`- sourceEventCount: ${manifest.readableAgent?.traceRender?.sourceEventCount ?? messages?.sourceEventCount ?? trace.sourceEventCount ?? ""}`,
|
||
`- renderedRowCount: ${manifest.readableAgent?.traceRender?.renderedRowCount ?? messages?.renderedRowCount ?? trace.renderedRowCount ?? ""}`,
|
||
`- hwpodCommandCount: ${trace.hwpodCommandCount ?? evidence.agentTrace?.hwpodCommandCount ?? ""}`,
|
||
`- hwpodBuildCommandCount: ${trace.hwpodBuildCommandCount ?? evidence.agentTrace?.hwpodBuildCommandCount ?? ""}`,
|
||
``,
|
||
traceBody,
|
||
``,
|
||
`## Final Response`,
|
||
``,
|
||
finalResponse ? finalResponse.trimEnd() : `_final-response.md artifact is missing._`,
|
||
``,
|
||
`## 最后 Diff`,
|
||
``,
|
||
aggregateBullets([
|
||
["statusShort", diff.statusShort],
|
||
["diffStat", diff.diffStat],
|
||
["diffSha256", diff.sha256 ?? diff.diffPatchSha256],
|
||
["sourceRootChangedAfterPrepare", diff.sourceRootChangedAfterPrepare],
|
||
["sourceRootChangedAfterAgent", diff.sourceRootChangedAfterAgent]
|
||
]),
|
||
diffPatch ? aggregateFence("diff", diffPatch) : `_agent-diff.patch artifact is missing._`,
|
||
``,
|
||
`## 原始产物索引`,
|
||
``,
|
||
aggregateArtifactIndex(manifest),
|
||
``,
|
||
`## 说明`,
|
||
``,
|
||
`本文件只做已完成 CaseRun registry 产物的二次整理聚合,不新增自动结论、门禁或等级。`
|
||
].join("\n")}\n`;
|
||
}
|
||
|
||
function aggregateTraceBody(messages: any, traceMarkdown: string) {
|
||
const rows = arrayOfObjects(messages?.rows);
|
||
if (rows.length > 0) {
|
||
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 `_No readable trace artifact was found._`;
|
||
}
|
||
|
||
async function aggregateMessagesWithFreshFullTrace(context: CaseContext, input: { archivedMessages: any; traceLookup: any; traceId: unknown; run: any; evidence: any; manifest: any }) {
|
||
const archivedMessages = input.archivedMessages;
|
||
const traceId = text(input.traceId ?? input.traceLookup?.traceId ?? input.run?.agent?.traceId ?? input.evidence?.agent?.traceId ?? input.manifest?.agent?.traceId);
|
||
const baseUrl = text(input.traceLookup?.baseUrl ?? input.run?.agent?.baseUrl ?? input.evidence?.agent?.baseUrl);
|
||
const apiKey = text(context.env.HWLAB_API_KEY ?? process.env.HWLAB_API_KEY);
|
||
if (!traceId || !baseUrl || !apiKey.startsWith(HWLAB_API_KEY_PREFIX)) return archivedMessages;
|
||
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) return archivedMessages;
|
||
const parsed = parseJsonMaybe(result.stdout);
|
||
const rendered = freshRenderedTraceBody(parsed);
|
||
const rows = arrayOfObjects(rendered?.rows);
|
||
if (rows.length === 0) return archivedMessages;
|
||
return clean({
|
||
...(archivedMessages && typeof archivedMessages === "object" ? archivedMessages : {}),
|
||
renderer: rendered.renderer ?? archivedMessages?.renderer ?? CASE_TRACE_RENDERER,
|
||
traceSource: rendered.source ?? archivedMessages?.traceSource,
|
||
traceLookup: archivedMessages?.traceLookup ?? input.traceLookup,
|
||
traceId: rendered.traceId ?? traceId,
|
||
status: rendered.traceStatus ?? rendered.status ?? archivedMessages?.status,
|
||
sourceEventCount: rendered.sourceEventCount ?? archivedMessages?.sourceEventCount,
|
||
renderedRowCount: rendered.renderedRowCount ?? rows.length,
|
||
returnedRowCount: rendered.returnedRowCount ?? rows.length,
|
||
noiseEventCount: rendered.noiseEventCount ?? archivedMessages?.noiseEventCount,
|
||
finalResponse: rendered.finalResponse ?? archivedMessages?.finalResponse,
|
||
rows
|
||
});
|
||
}
|
||
|
||
function freshRenderedTraceBody(parsed: any) {
|
||
if (!parsed || typeof parsed !== "object") return null;
|
||
const body = parsed.body && typeof parsed.body === "object" ? parsed.body : null;
|
||
if (Array.isArray(body?.rows)) return body;
|
||
const data = parsed.data && typeof parsed.data === "object" ? parsed.data : null;
|
||
if (Array.isArray(data?.rows)) return data;
|
||
return Array.isArray(parsed.rows) ? parsed : null;
|
||
}
|
||
|
||
function traceEventRowFromAggregate(row: Record<string, unknown>): TraceEventRow {
|
||
const bodyFormat = text(row.bodyFormat);
|
||
return {
|
||
rowId: text(row.rowId) || text(row.header) || "trace-row",
|
||
seq: numberOption(row.seq) ?? null,
|
||
tone: traceRowTone(row.tone),
|
||
header: text(row.header) || text(row.rowId) || "trace row",
|
||
body: row.body === undefined || row.body === null ? null : String(row.body),
|
||
terminal: row.terminal === true ? true : undefined,
|
||
bodyFormat: bodyFormat === "markdown" || bodyFormat === "text" ? bodyFormat : undefined
|
||
};
|
||
}
|
||
|
||
function aggregateBullets(items: Array<[string, unknown]>) {
|
||
const lines = items.map(([key, value]) => `- ${key}: ${aggregateInlineValue(value)}`).filter((line) => !line.endsWith(": "));
|
||
return lines.length > 0 ? lines.join("\n") : `_No data recorded._`;
|
||
}
|
||
|
||
function aggregateInlineValue(value: unknown) {
|
||
if (value === undefined || value === null || value === "") return "";
|
||
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
||
if (typeof value === "object") return `\`${markdownInline(JSON.stringify(value))}\``;
|
||
return markdownInline(String(value));
|
||
}
|
||
|
||
function markdownInline(value: string) {
|
||
return value.replace(/\r?\n/gu, " ").trim();
|
||
}
|
||
|
||
function aggregateDetails(summary: string, language: string, body: string) {
|
||
return [`<details>`, `<summary>${escapeHtml(summary)}</summary>`, ``, aggregateFence(language, body), ``, `</details>`].join("\n");
|
||
}
|
||
|
||
function escapeHtml(value: string) {
|
||
return String(value).replace(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">");
|
||
}
|
||
|
||
function aggregateFence(language: string, body: string) {
|
||
const fence = body.includes("```") ? "````" : "```";
|
||
return `${fence}${language}\n${body.trimEnd()}\n${fence}`;
|
||
}
|
||
|
||
function aggregateArtifactIndex(manifest: any) {
|
||
const files = arrayOfObjects(manifest?.files);
|
||
if (files.length === 0) return `_artifact-manifest.json is missing or has no files list._`;
|
||
return [`| Path | Bytes | SHA-256 |`, `|---|---:|---|`, ...files.map((file) => `| ${markdownTableText(file.path, 220)} | ${file.bytes ?? ""} | ${markdownTableText(file.sha256, 80)} |`)].join("\n");
|
||
}
|
||
|
||
async function registryAggregateFileRecord(file: string) {
|
||
const content = await readFile(file);
|
||
return { bytes: content.length, sha256: sha256Buffer(content) };
|
||
}
|
||
|
||
async function archiveCaseRunArtifacts(context: CaseContext, run: PreparedCaseRun, evidence: any, options: { sync?: boolean } = {}): Promise<CaseRegistryArchive> {
|
||
const runRel = caseRegistryRunRel(run);
|
||
const caseRepoRunDir = path.join(run.caseRepo, runRel);
|
||
await mkdir(caseRepoRunDir, { recursive: true });
|
||
const agentTraceSnapshot = await archivedAgentTraceSnapshot(context, run, evidence);
|
||
applyArchivedAgentTraceSummary(run, evidence, agentTraceSnapshot.summary);
|
||
await writeJson(path.join(run.runDir, "evidence.json"), evidence);
|
||
await writeJson(path.join(caseRepoRunDir, "evidence.json"), evidence);
|
||
await writeFile(path.join(caseRepoRunDir, "summary.md"), renderEvidenceSummary(evidence), "utf8");
|
||
|
||
const materialized = new Map<string, string | undefined>([
|
||
["evidence.json", path.join(run.runDir, "evidence.json")],
|
||
["summary.md", undefined]
|
||
]);
|
||
if (await fileExists(path.join(caseRepoRunDir, "aggregate.md"))) materialized.set("aggregate.md", undefined);
|
||
const readableAgent = await writeReadableAgentArtifacts(caseRepoRunDir, run, evidence, agentTraceSnapshot);
|
||
for (const rel of readableAgent.generatedFiles) materialized.set(rel, undefined);
|
||
const skippedFiles: string[] = [];
|
||
for (const entry of caseRunArtifactEntries(run)) {
|
||
const rel = artifactRel(entry.rel);
|
||
const copied = await copyCaseRunArtifact(entry.source, path.join(caseRepoRunDir, rel));
|
||
if (copied) materialized.set(rel, entry.source);
|
||
else skippedFiles.push(rel);
|
||
}
|
||
|
||
const files: CaseRegistryArchiveFile[] = [];
|
||
for (const [rel, source] of materialized) files.push(await registryArtifactFileRecord(run, caseRepoRunDir, rel, source));
|
||
const artifactManifestPath = path.join(caseRepoRunDir, "artifact-manifest.json");
|
||
const existingManifest = await readJsonIfExists(artifactManifestPath);
|
||
const manifest = caseRunArtifactManifest(context, run, evidence, files, skippedFiles, readableAgent, existingManifest);
|
||
await writeJson(artifactManifestPath, manifest);
|
||
const caseRepoFiles = [...files.map((file) => artifactRel(path.join(runRel, file.path))), artifactRel(path.join(runRel, "artifact-manifest.json"))];
|
||
const registrySync = options.sync === true ? await syncCaseRegistryRun(context, run, runRel) : null;
|
||
return { caseRepoRunDir, artifactManifestPath, caseRepoFiles, files, skippedFiles, manifest, registrySync };
|
||
}
|
||
|
||
async function syncCaseRegistryRun(context: CaseContext, run: PreparedCaseRun, runRel: string) {
|
||
return syncCaseRegistryRel(context, run.caseRepo, runRel, `data: archive caserun ${run.runId}`);
|
||
}
|
||
|
||
async function syncCaseRegistryRel(context: CaseContext, caseRepo: string, relInput: string, commitMessage: string) {
|
||
if (context.parsed.noCaseRepoGitSync === true || context.parsed.noRegistryGitSync === true) {
|
||
return { status: "skipped", reason: "disabled_by_cli_flag" };
|
||
}
|
||
const rel = artifactRel(relInput);
|
||
const before = await gitProcess(context, caseRepo, ["status", "--short", "--", rel]);
|
||
if (before.exitCode !== 0) return { status: "failed", stage: "status_before", command: before.command, exitCode: before.exitCode, stderr: clipText(before.stderr) };
|
||
if (!text(before.stdout)) return { status: "unchanged", path: rel };
|
||
const add = await gitProcess(context, caseRepo, ["add", "--", rel]);
|
||
if (add.exitCode !== 0) return { status: "failed", stage: "add", command: add.command, exitCode: add.exitCode, stderr: clipText(add.stderr) };
|
||
const staged = await gitProcess(context, caseRepo, ["diff", "--cached", "--quiet", "--", rel]);
|
||
if (staged.exitCode === 0) return { status: "unchanged_after_add", path: rel };
|
||
if (staged.exitCode !== 1) return { status: "failed", stage: "diff_cached", command: staged.command, exitCode: staged.exitCode, stderr: clipText(staged.stderr) };
|
||
const commit = await gitProcess(context, caseRepo, ["commit", "-m", commitMessage, "--", rel]);
|
||
if (commit.exitCode !== 0) return { status: "failed", stage: "commit", command: commit.command, exitCode: commit.exitCode, stderr: clipText(commit.stderr), stdout: clipText(commit.stdout) };
|
||
const push = await gitProcess(context, caseRepo, ["push", "origin", "HEAD"]);
|
||
if (push.exitCode !== 0) return { status: "failed", stage: "push", command: push.command, exitCode: push.exitCode, stderr: clipText(push.stderr), stdout: clipText(push.stdout) };
|
||
const commitSha = text(commit.stdout.match(/\[\S+\s+([0-9a-f]{7,40})\]/iu)?.[1]);
|
||
return clean({ status: "pushed", path: rel, commitMessage, commitSha, commitStdout: clipText(commit.stdout, 1000), pushStdout: clipText(push.stdout, 1000), pushStderr: clipText(push.stderr, 1000) });
|
||
}
|
||
|
||
async function gitProcess(context: CaseContext, cwd: string, args: string[]) {
|
||
return (context.runProcess ?? runProcess)(["git", ...args], cwd, { ...process.env, ...context.env });
|
||
}
|
||
|
||
function collectSummaryFromArchive(run: PreparedCaseRun, evidence: any, archive: CaseRegistryArchive) {
|
||
return {
|
||
caseRepo: run.caseRepo,
|
||
caseRepoRunDir: archive.caseRepoRunDir,
|
||
caseRepoFiles: archive.caseRepoFiles,
|
||
artifactManifestPath: archive.artifactManifestPath,
|
||
artifactCount: archive.files.length + 1,
|
||
skippedFiles: archive.skippedFiles,
|
||
registrySync: archive.registrySync ?? null,
|
||
trace: archive.manifest.trace ?? null,
|
||
traceLookup: archive.manifest.traceLookup ?? agentTraceLookupForRun(run, evidence),
|
||
agentStage: archive.manifest.agentStage ?? agentStageSummary(evidence),
|
||
status: evidence.status,
|
||
autoEvaluation: false
|
||
};
|
||
}
|
||
|
||
async function writeReadableAgentArtifacts(caseRepoRunDir: string, run: PreparedCaseRun, evidence: any, snapshot: ArchivedAgentTraceSnapshot): Promise<CaseReadableAgentArchive> {
|
||
const messagesRel = "agent-messages.json";
|
||
const traceRel = "agent-trace.md";
|
||
const transcriptRel = "agent-transcript.md";
|
||
const finalResponseRel = "final-response.md";
|
||
const traceBody = snapshot.traceBody;
|
||
const events = snapshot.events;
|
||
const rows = snapshot.rows;
|
||
const finalResponse = agentFinalResponse(run, evidence, traceBody, rows);
|
||
const traceLookup = traceBody?.lookup ?? evidence.traceLookup ?? agentTraceLookupForRun(run, evidence);
|
||
const diffPatch = await readTextIfExists(evidence.agentDiff?.diffPatchPath ?? run.agentDiff?.diffPatchPath ?? path.join(run.runDir, "agent-diff.patch"));
|
||
const messages = clean({
|
||
contractVersion: "hwpod-case-run-agent-messages-v1",
|
||
renderer: CASE_TRACE_RENDERER,
|
||
traceSource: text(traceBody?.source),
|
||
lookupOnly: traceBody?.lookupOnly === true ? true : undefined,
|
||
traceLookup,
|
||
traceId: evidence.agent?.traceId ?? run.agent?.traceId ?? traceBody?.traceId ?? run.agentTrace?.traceId,
|
||
conversationId: evidence.agent?.conversationId ?? run.agent?.conversationId,
|
||
sessionId: evidence.agent?.sessionId ?? run.agent?.sessionId,
|
||
threadId: evidence.agent?.threadId ?? run.agent?.threadId,
|
||
status: traceBody?.traceStatus ?? traceBody?.status ?? run.agentTrace?.status ?? evidence.agentTrace?.status,
|
||
sourceEventCount: traceBody?.eventCount ?? traceBody?.sourceEventCount ?? events.length,
|
||
renderedRowCount: rows.length,
|
||
noiseEventCount: traceNoiseEventCount(events),
|
||
finalResponse,
|
||
rows: rows.map(agentMessageRowForArchive),
|
||
diff: clean({ path: "agent-diff.patch", sha256: evidence.agentDiff?.diffPatchSha256 ?? run.agentDiff?.diffPatchSha256, statusShort: evidence.agentDiff?.statusShort ?? run.agentDiff?.statusShort, diffStat: evidence.agentDiff?.diffStat ?? run.agentDiff?.diffStat, patchIncludedInTranscript: true })
|
||
});
|
||
const transcript = renderAgentTranscript(run, evidence, messages, rows, diffPatch);
|
||
await writeJson(path.join(caseRepoRunDir, messagesRel), messages);
|
||
await writeFile(path.join(caseRepoRunDir, traceRel), transcript, "utf8");
|
||
await writeFile(path.join(caseRepoRunDir, transcriptRel), transcript, "utf8");
|
||
await writeFile(path.join(caseRepoRunDir, finalResponseRel), renderFinalResponseArtifact(run, evidence, finalResponse, rows), "utf8");
|
||
return {
|
||
messagesRel,
|
||
traceRel,
|
||
transcriptRel,
|
||
finalResponseRel,
|
||
generatedFiles: [messagesRel, traceRel, transcriptRel, finalResponseRel],
|
||
traceRender: clean({ renderer: CASE_TRACE_RENDERER, status: messages.status, sourceEventCount: messages.sourceEventCount, renderedRowCount: messages.renderedRowCount, noiseEventCount: messages.noiseEventCount }),
|
||
finalResponse
|
||
};
|
||
}
|
||
|
||
async function archivedAgentTraceSnapshot(context: CaseContext, run: PreparedCaseRun, evidence: any): Promise<ArchivedAgentTraceSnapshot> {
|
||
const initialTrace = await archivedAgentTraceBody(run, evidence);
|
||
const traceBody = await materializeFullAgentTraceWithExistingCli(context, run, evidence, initialTrace);
|
||
const bodyForSummary = traceBody?.body && typeof traceBody.body === "object" ? traceBody.body : traceBody;
|
||
const events = arrayOfObjects(bodyForSummary?.events ?? bodyForSummary?.trace?.events ?? bodyForSummary?.sourceEvents);
|
||
const rows = traceRowsFromBody(bodyForSummary, events);
|
||
const hasRenderableTrace = rows.length > 0 || events.length > 0;
|
||
const summary = hasRenderableTrace
|
||
? summarizeAgentTrace(text(bodyForSummary?.traceId ?? traceBody?.traceId ?? evidence.agent?.traceId ?? run.agent?.traceId), numberOption(traceBody?.httpStatus) ?? 0, traceBody)
|
||
: existingAgentTraceSummary(run, evidence, bodyForSummary);
|
||
return { traceBody: bodyForSummary, rows, events, summary };
|
||
}
|
||
|
||
function existingAgentTraceSummary(run: PreparedCaseRun, evidence: any, traceBody: any): AgentTraceStage {
|
||
const existing = evidence.agentTrace ?? run.agentTrace;
|
||
if (existing?.traceId) return existing as AgentTraceStage;
|
||
return summarizeAgentTrace(text(traceBody?.traceId ?? evidence.agent?.traceId ?? run.agent?.traceId), 0, traceBody);
|
||
}
|
||
|
||
async function archivedAgentTraceBody(run: PreparedCaseRun, evidence: any) {
|
||
const rawTrace = await readJsonIfExists(path.join(run.runDir, "agent-trace.json"));
|
||
if (rawTrace) return rawTrace;
|
||
const resultTrace = agentTraceBody(run, evidence);
|
||
if (resultTrace) return resultTrace;
|
||
return run.agent ? agentTraceIdentityArtifact(run, run.agent) : clean({ traceId: evidence.agentTrace?.traceId ?? run.agentTrace?.traceId, status: evidence.agentTrace?.status ?? run.agentTrace?.status ?? "recorded", lookupOnly: true });
|
||
}
|
||
|
||
async function materializeFullAgentTraceWithExistingCli(context: CaseContext, run: PreparedCaseRun, evidence: any, initialTrace: any) {
|
||
const body = initialTrace?.body && typeof initialTrace.body === "object" ? initialTrace.body : initialTrace;
|
||
const existingEvents = arrayOfObjects(body?.events ?? body?.trace?.events ?? body?.sourceEvents);
|
||
if (existingEvents.length > 0) return initialTrace;
|
||
if (!run.agent) return initialTrace;
|
||
const traceId = text(body?.traceId ?? initialTrace?.traceId ?? evidence.agent?.traceId ?? run.agent?.traceId);
|
||
const lookup = agentTraceLookupForRun(run, evidence);
|
||
const apiKey = text(context.env.HWLAB_API_KEY ?? process.env.HWLAB_API_KEY);
|
||
if (!traceId || !lookup || !apiKey.startsWith(HWLAB_API_KEY_PREFIX)) return initialTrace;
|
||
const baseUrl = text((lookup as any).baseUrl) || webUrlFrom(context, run.definition);
|
||
const command = [process.execPath, path.join(context.cwd, "tools/hwlab-cli/bin/hwlab-cli.mjs"), "client", "agent", "trace", traceId, "--render", "web", "--full"];
|
||
const result = await (context.runProcess ?? runProcess)(command, context.cwd, { ...process.env, ...context.env, HWLAB_RUNTIME_WEB_URL: baseUrl });
|
||
if (result.exitCode !== 0) {
|
||
return clean({ ...body, traceFetch: { status: "failed", source: "existing-hwlab-cli", command: commandVisibility(command), exitCode: result.exitCode, stderr: clipText(result.stderr, 1200), stdout: clipText(result.stdout, 1200) } });
|
||
}
|
||
const parsed = parseJsonMaybe(result.stdout);
|
||
if (!parsed) return clean({ ...body, traceFetch: { status: "failed", source: "existing-hwlab-cli", command: commandVisibility(command), exitCode: 0, error: "invalid_json_stdout", stdout: clipText(result.stdout, 1200) } });
|
||
const parsedBody = parsed?.body && typeof parsed.body === "object" ? parsed.body : null;
|
||
const materialized = parsedBody ? { ...parsed, body: clean({ ...parsedBody, source: text(parsedBody.source) || "hwlab-cli.client.agent.trace", lookup: parsedBody.lookup ?? lookup }) } : clean({ ...parsed, source: text(parsed.source) || "hwlab-cli.client.agent.trace", lookup: parsed.lookup ?? lookup });
|
||
await writeJson(path.join(run.runDir, "agent-trace.json"), materialized);
|
||
return materialized;
|
||
}
|
||
|
||
function traceRowsFromBody(traceBody: any, events: Record<string, unknown>[]) {
|
||
const existingRows = arrayOfObjects(traceBody?.rows ?? traceBody?.renderedRows ?? traceBody?.traceRows).map(traceEventRowFromObject).filter((row) => text(row.rowId || row.header || row.body));
|
||
return existingRows.length > 0 ? existingRows : traceDisplayRows(traceBody ?? {}, events);
|
||
}
|
||
|
||
function traceEventRowFromObject(row: Record<string, unknown>): TraceEventRow {
|
||
return {
|
||
rowId: text(row.rowId ?? row.id),
|
||
seq: numberOption(row.seq) ?? null,
|
||
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),
|
||
terminal: row.terminal === true ? true : undefined,
|
||
bodyFormat: row.bodyFormat === "markdown" ? "markdown" : row.bodyFormat === "text" ? "text" : undefined
|
||
};
|
||
}
|
||
|
||
function traceRowTone(value: unknown): TraceEventRow["tone"] {
|
||
const raw = text(value);
|
||
return raw === "ok" || raw === "blocked" || raw === "warn" || raw === "source" ? raw : "source";
|
||
}
|
||
|
||
function applyArchivedAgentTraceSummary(run: PreparedCaseRun, evidence: any, summary: AgentTraceStage) {
|
||
if (!summary?.traceId) return;
|
||
run.agentTrace = summary;
|
||
evidence.agentTrace = summary;
|
||
evidence.traceLookup = evidence.traceLookup ?? agentTraceLookupForRun(run, evidence);
|
||
}
|
||
|
||
function agentTraceBody(run: PreparedCaseRun, evidence: any) {
|
||
const result = run.agent?.result ?? evidence.agent?.result ?? null;
|
||
return result?.body && typeof result.body === "object" ? result.body : result;
|
||
}
|
||
|
||
function agentFinalResponse(run: PreparedCaseRun, evidence: any, traceBody: any, rows: TraceEventRow[]) {
|
||
const candidates = [
|
||
traceBody?.finalResponse,
|
||
traceBody?.terminalEvidence?.finalResponse,
|
||
run.agent?.result?.finalResponse,
|
||
run.agent?.result?.reply,
|
||
evidence.agent?.finalResponse
|
||
];
|
||
for (const candidate of candidates) {
|
||
const body = text((candidate as any)?.text ?? (candidate as any)?.content ?? (candidate as any)?.message);
|
||
if (body) return clean({ present: true, status: text((candidate as any)?.status) || "completed", source: text((candidate as any)?.source) || "agent-result", text: body, textChars: body.length, traceId: text((candidate as any)?.traceId ?? traceBody?.traceId ?? run.agent?.traceId) });
|
||
}
|
||
const terminal = rows.filter((row) => row.terminal === true || row.tone === "blocked").slice(-5).map(agentMessageRowForArchive);
|
||
return clean({
|
||
present: false,
|
||
value: null,
|
||
reason: finalResponseMissingReason(traceBody, rows),
|
||
status: text(traceBody?.status ?? traceBody?.traceStatus ?? run.agent?.stageStatus),
|
||
terminalRows: terminal,
|
||
error: compactObject(traceBody?.error ?? run.agent?.error ?? null)
|
||
});
|
||
}
|
||
|
||
function finalResponseMissingReason(traceBody: any, rows: TraceEventRow[]) {
|
||
const errorText = text(traceBody?.error?.message ?? traceBody?.terminalEvidence?.error?.message);
|
||
if (errorText) return errorText;
|
||
const terminalBody = rows.slice().reverse().map((row) => text(row.body)).find((body) => /error|failed|404|not found|blocked|timeout/iu.test(body));
|
||
return terminalBody ? clipText(terminalBody, 1200) : "finalResponse=null; no authoritative final assistant response was returned by the trace/result payload";
|
||
}
|
||
|
||
function agentMessageRowForArchive(row: TraceEventRow) {
|
||
return clean({ rowId: row.rowId, seq: row.seq, tone: row.tone, header: row.header, terminal: row.terminal === true ? true : undefined, bodyFormat: row.bodyFormat, body: row.body });
|
||
}
|
||
|
||
function renderAgentTranscript(run: PreparedCaseRun, evidence: any, messages: any, rows: TraceEventRow[], diffPatch: string) {
|
||
const sections = [
|
||
`# CaseRun Agent Transcript`,
|
||
``,
|
||
`- caseId: ${run.caseId}`,
|
||
`- runId: ${run.runId}`,
|
||
`- traceId: ${messages.traceId ?? ""}`,
|
||
`- conversationId: ${messages.conversationId ?? ""}`,
|
||
`- sessionId: ${messages.sessionId ?? ""}`,
|
||
`- threadId: ${messages.threadId ?? ""}`,
|
||
`- renderer: ${CASE_TRACE_RENDERER}`,
|
||
`- traceLookupStrategy: ${messages.traceLookup?.strategy ?? ""}`,
|
||
`- traceCommand: ${messages.traceLookup?.commands?.trace ?? ""}`,
|
||
`- resultCommand: ${messages.traceLookup?.commands?.result ?? ""}`,
|
||
`- inspectCommand: ${messages.traceLookup?.commands?.inspect ?? ""}`,
|
||
`- lookupOnly: ${messages.lookupOnly === true}`,
|
||
`- finalResponse: ${messages.finalResponse?.present === true ? "present" : "null"}`,
|
||
`- autoEvaluation: false`,
|
||
``,
|
||
`## Messages`,
|
||
rows.length > 0 ? rows.map(renderTranscriptRow).join("\n\n") : `_No rendered trace rows were returned._`,
|
||
``,
|
||
`## Final Response`,
|
||
messages.finalResponse?.present === true ? text(messages.finalResponse.text) : [`finalResponse=null`, `reason: ${text(messages.finalResponse?.reason)}`].join("\n"),
|
||
``,
|
||
`## Subject Diff`,
|
||
``,
|
||
`statusShort:`,
|
||
`\`\`\`text`,
|
||
text(evidence.agentDiff?.statusShort ?? run.agentDiff?.statusShort) || "(empty)",
|
||
`\`\`\``,
|
||
``,
|
||
`diffStat:`,
|
||
`\`\`\`text`,
|
||
text(evidence.agentDiff?.diffStat ?? run.agentDiff?.diffStat) || "(empty)",
|
||
`\`\`\``,
|
||
``,
|
||
`patch:`,
|
||
`\`\`\`diff`,
|
||
diffPatch || "(empty)",
|
||
`\`\`\``
|
||
];
|
||
return `${sections.join("\n")}\n`;
|
||
}
|
||
|
||
function renderTranscriptRow(row: TraceEventRow) {
|
||
const body = text(row.body);
|
||
return [`### ${row.header}`, ``, `- rowId: ${row.rowId}`, row.terminal === true ? `- terminal: true` : null, ``, body ? body : `_No body._`].filter((item) => item !== null).join("\n");
|
||
}
|
||
|
||
function renderFinalResponseArtifact(run: PreparedCaseRun, evidence: any, finalResponse: any, rows: TraceEventRow[]) {
|
||
const lines = [
|
||
`# CaseRun Final Response`,
|
||
``,
|
||
`- caseId: ${run.caseId}`,
|
||
`- runId: ${run.runId}`,
|
||
`- traceId: ${evidence.agent?.traceId ?? run.agent?.traceId ?? ""}`,
|
||
`- present: ${finalResponse.present === true}`,
|
||
``
|
||
];
|
||
if (finalResponse.present === true) return `${[...lines, text(finalResponse.text)].join("\n")}\n`;
|
||
const terminalRows = rows.filter((row) => row.terminal === true || row.tone === "blocked").slice(-5);
|
||
return `${[
|
||
...lines,
|
||
`finalResponse=null`,
|
||
``,
|
||
`reason: ${text(finalResponse.reason)}`,
|
||
``,
|
||
`## Terminal/Error Rows`,
|
||
terminalRows.length > 0 ? terminalRows.map(renderTranscriptRow).join("\n\n") : `_No terminal/error rows were rendered._`
|
||
].join("\n")}\n`;
|
||
}
|
||
|
||
async function refreshCompletedRunArchive(context: CaseContext, run: PreparedCaseRun, evidence?: any, options: { sync?: boolean } = {}) {
|
||
if (context.parsed.noCaseRepoRecord === true) return null;
|
||
if (!text(run.caseRepo)) return null;
|
||
const loadedEvidence = evidence ?? await readJsonIfExists(path.join(run.runDir, "evidence.json"));
|
||
if (!loadedEvidence) return null;
|
||
const archive = await archiveCaseRunArtifacts(context, run, loadedEvidence, options);
|
||
return { evidence: loadedEvidence, archive, summary: collectSummaryFromArchive(run, loadedEvidence, archive) };
|
||
}
|
||
|
||
function caseRunArtifactEntries(run: PreparedCaseRun) {
|
||
return [
|
||
{ rel: "run.json", source: path.join(run.runDir, "run.json") },
|
||
{ rel: "result.json", source: path.join(run.runDir, "result.json") },
|
||
{ rel: "agent-trace.json", source: path.join(run.runDir, "agent-trace.json") },
|
||
{ rel: "agent-prompt.md", source: path.join(run.runDir, "agent-prompt.md") },
|
||
{ rel: "agent-diff.patch", source: path.join(run.runDir, "agent-diff.patch") },
|
||
{ rel: path.join(".hwlab", "hwpod-spec.yaml"), source: run.specPath },
|
||
{ rel: "worker.stdout.log", source: path.join(run.runDir, "worker.stdout.log") },
|
||
{ rel: "worker.stderr.log", source: path.join(run.runDir, "worker.stderr.log") }
|
||
];
|
||
}
|
||
|
||
async function copyCaseRunArtifact(source: string, destination: string) {
|
||
try {
|
||
const info = await stat(source);
|
||
if (!info.isFile()) return false;
|
||
await mkdir(path.dirname(destination), { recursive: true });
|
||
await copyFile(source, destination);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function registryArtifactFileRecord(run: PreparedCaseRun, caseRepoRunDir: string, rel: string, source?: string): Promise<CaseRegistryArchiveFile> {
|
||
const file = path.join(caseRepoRunDir, rel);
|
||
const content = await readFile(file);
|
||
return clean({
|
||
path: artifactRel(rel),
|
||
bytes: content.length,
|
||
sha256: sha256Buffer(content),
|
||
source: artifactSourceLabel(run, source)
|
||
});
|
||
}
|
||
|
||
function caseRunArtifactManifest(context: CaseContext, run: PreparedCaseRun, evidence: any, files: CaseRegistryArchiveFile[], skippedFiles: string[], readableAgent: CaseReadableAgentArchive, existingManifest?: any) {
|
||
return clean({
|
||
contractVersion: "hwpod-case-run-artifact-manifest-v1",
|
||
caseId: run.caseId,
|
||
runId: run.runId,
|
||
archivedAt: text(existingManifest?.archivedAt) || context.now(),
|
||
sourceRunDir: run.runDir,
|
||
caseRepoRunDir: path.join(run.caseRepo, caseRegistryRunRel(run)),
|
||
trace: caseRunArtifactTrace(run, evidence),
|
||
traceLookup: evidence.traceLookup ?? agentTraceLookupForRun(run, evidence),
|
||
subject: evidence.subject ?? run.subject,
|
||
agent: clean({
|
||
providerProfile: evidence.agent?.providerProfile ?? run.agent?.providerProfile,
|
||
projectId: evidence.agent?.projectId ?? run.agent?.projectId,
|
||
conversationId: evidence.agent?.conversationId ?? run.agent?.conversationId,
|
||
sessionId: evidence.agent?.sessionId ?? run.agent?.sessionId,
|
||
threadId: evidence.agent?.threadId ?? run.agent?.threadId,
|
||
traceId: evidence.agent?.traceId ?? run.agent?.traceId
|
||
}),
|
||
readableAgent: clean({
|
||
renderer: CASE_TRACE_RENDERER,
|
||
messagesPath: readableAgent.messagesRel,
|
||
tracePath: readableAgent.traceRel,
|
||
transcriptPath: readableAgent.transcriptRel,
|
||
finalResponsePath: readableAgent.finalResponseRel,
|
||
traceRender: readableAgent.traceRender,
|
||
finalResponse: readableAgent.finalResponse
|
||
}),
|
||
aggregate: aggregateManifestEntry(files),
|
||
prompt: clean({ path: "agent-prompt.md", sha256: evidence.agent?.promptSha256 ?? run.agent?.promptSha256 }),
|
||
diff: clean({
|
||
path: "agent-diff.patch",
|
||
sha256: evidence.agentDiff?.diffPatchSha256 ?? run.agentDiff?.diffPatchSha256,
|
||
statusShort: evidence.agentDiff?.statusShort ?? run.agentDiff?.statusShort,
|
||
diffStat: evidence.agentDiff?.diffStat ?? run.agentDiff?.diffStat,
|
||
sourceRootStatusShort: evidence.agentDiff?.sourceRootStatusShort ?? run.agentDiff?.sourceRootStatusShort,
|
||
sourceRootChangedAfterPrepare: evidence.agentDiff?.sourceRootChangedAfterPrepare ?? run.agentDiff?.sourceRootChangedAfterPrepare,
|
||
sourceRootChangedAfterAgent: evidence.agentDiff?.sourceRootChangedAfterAgent ?? run.agentDiff?.sourceRootChangedAfterAgent,
|
||
sourceRootBaseline: evidence.agentDiff?.sourceRootBaseline ?? run.agentDiff?.sourceRootBaseline,
|
||
sourceRootAfterPrepare: evidence.agentDiff?.sourceRootAfterPrepare ?? run.agentDiff?.sourceRootAfterPrepare,
|
||
sourceRootAfterAgent: evidence.agentDiff?.sourceRootAfterAgent ?? run.agentDiff?.sourceRootAfterAgent
|
||
}),
|
||
agentStage: agentStageSummary(evidence),
|
||
keilJob: evidence.keilJob ?? null,
|
||
artifacts: evidence.artifacts ?? [],
|
||
decisions: {
|
||
autoEvaluation: false,
|
||
runnerPostAgentCompileCheck: "recorded",
|
||
reason: "flow-only run: the manifest records artifacts and trace without auto-grading or gate decisions"
|
||
},
|
||
files,
|
||
skippedFiles
|
||
});
|
||
}
|
||
|
||
function aggregateManifestEntry(files: CaseRegistryArchiveFile[]) {
|
||
const file = files.find((entry) => entry.path === "aggregate.md");
|
||
if (!file) return undefined;
|
||
return { path: file.path, bytes: file.bytes, sha256: file.sha256, primaryEntry: true, aggregationOnly: true, autoEvaluation: false };
|
||
}
|
||
|
||
function caseRunArtifactTrace(run: PreparedCaseRun, evidence: any) {
|
||
return clean({
|
||
traceId: evidence.agent?.traceId ?? run.agent?.traceId ?? evidence.agentTrace?.traceId ?? run.agentTrace?.traceId,
|
||
sessionId: evidence.agent?.sessionId ?? run.agent?.sessionId,
|
||
conversationId: evidence.agent?.conversationId ?? run.agent?.conversationId,
|
||
threadId: evidence.agent?.threadId ?? run.agent?.threadId,
|
||
providerProfile: evidence.agent?.providerProfile ?? run.agent?.providerProfile,
|
||
projectId: evidence.agent?.projectId ?? run.agent?.projectId,
|
||
source: evidence.agentTrace?.source ?? run.agentTrace?.source,
|
||
lookupOnly: evidence.agentTrace?.lookupOnly ?? run.agentTrace?.lookupOnly,
|
||
lookup: evidence.traceLookup ?? evidence.agentTrace?.lookup ?? run.agentTrace?.lookup ?? agentTraceLookupForRun(run, evidence),
|
||
status: evidence.agentTrace?.status ?? run.agentTrace?.status,
|
||
terminalStatus: evidence.agentTrace?.terminalStatus ?? run.agentTrace?.terminalStatus,
|
||
commandCount: evidence.agentTrace?.commandCount ?? run.agentTrace?.commandCount,
|
||
hwpodCommandCount: evidence.agentTrace?.hwpodCommandCount ?? run.agentTrace?.hwpodCommandCount,
|
||
hwpodBuildCommandCount: evidence.agentTrace?.hwpodBuildCommandCount ?? run.agentTrace?.hwpodBuildCommandCount,
|
||
agentRun: compactObject(evidence.agentTrace?.agentRun ?? run.agentTrace?.agentRun ?? null)
|
||
});
|
||
}
|
||
|
||
function caseRegistryRunRel(run: PreparedCaseRun) {
|
||
return artifactRel(path.join("runs", run.caseId, run.runId));
|
||
}
|
||
|
||
function artifactRel(value: string) {
|
||
return value.replace(/\\/gu, "/");
|
||
}
|
||
|
||
function artifactSourceLabel(run: PreparedCaseRun, source?: string) {
|
||
if (!source) return undefined;
|
||
const relative = path.relative(path.resolve(run.runDir), path.resolve(source));
|
||
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) return artifactRel(relative);
|
||
return source;
|
||
}
|
||
|
||
async function runProcess(command: string[], cwd: string, env: Record<string, string | undefined>) {
|
||
const proc = Bun.spawn(command, { cwd, env, stdout: "pipe", stderr: "pipe" });
|
||
const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
||
return { command, stdout, stderr, exitCode };
|
||
}
|
||
|
||
function hwpodResultTexts(payload: any) {
|
||
const results = Array.isArray(payload?.body?.results) ? payload.body.results : Array.isArray(payload?.results) ? payload.results : [];
|
||
return results.flatMap((item: any) => [item?.stdout, item?.stderr, item?.summary, item?.output?.stdout, item?.output?.stderr, item?.output?.summary]).filter((item: unknown) => text(item));
|
||
}
|
||
|
||
function compactHwpodPayload(payload: any) {
|
||
if (!payload) return null;
|
||
return compactObject({ ok: payload.ok, action: payload.action, status: payload.status, traceId: payload.body?.traceId, results: payload.body?.results });
|
||
}
|
||
|
||
function artifactsFrom(value: any) {
|
||
const candidates = [value?.artifacts, value?.artifact_paths, value?.artifactPaths, value?.data?.artifacts, value?.data?.artifact_paths];
|
||
for (const candidate of candidates) {
|
||
if (Array.isArray(candidate)) return candidate;
|
||
}
|
||
return [text(value?.result?.hex_file), text(value?.result?.axf_file), text(value?.hex_file), text(value?.axf_file)].filter(Boolean);
|
||
}
|
||
|
||
async function writeJson(file: string, value: any) {
|
||
await mkdir(path.dirname(file), { recursive: true });
|
||
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||
}
|
||
|
||
async function writeRunState(run: PreparedCaseRun) {
|
||
const current = await readJsonIfExists(path.join(run.runDir, "run.json"));
|
||
const control = current?._control && typeof current._control === "object" ? current._control : {};
|
||
await writeJson(path.join(run.runDir, "run.json"), { ...run, _control: syncControlStage(control, run) });
|
||
}
|
||
|
||
async function writeRunControl(context: CaseContext, run: PreparedCaseRun, patch: Record<string, unknown>) {
|
||
const file = path.join(run.runDir, "run.json");
|
||
const current = await readJsonIfExists(file);
|
||
const currentControl = current?._control && typeof current._control === "object" ? current._control : {};
|
||
const currentRun = current && typeof current === "object" ? current : {};
|
||
const control = clean({ ...currentControl, ...patch, updatedAt: context.now() });
|
||
const nextRun = { ...currentRun, ...run, status: text(control.status) || run.status, stage: text(control.stage) || run.stage, updatedAt: context.now(), _control: control } as PreparedCaseRun & { _control: Record<string, unknown> };
|
||
await writeJson(file, nextRun);
|
||
return { run: nextRun as PreparedCaseRun, control };
|
||
}
|
||
|
||
async function readRunById(context: CaseContext, runId: string) {
|
||
return readRunFromDir(path.resolve(context.cwd, path.join(stateRoot(context), runId)));
|
||
}
|
||
|
||
async function readRunFromDir(runDir: string) {
|
||
return JSON.parse(await readFile(path.join(runDir, "run.json"), "utf8")) as PreparedCaseRun;
|
||
}
|
||
|
||
async function readJsonIfExists(file: string) {
|
||
try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; }
|
||
}
|
||
|
||
async function readTextIfExists(file: string) {
|
||
try { return await readFile(file, "utf8"); } catch { return ""; }
|
||
}
|
||
|
||
function controlFromRun(run: any) {
|
||
return run?._control && typeof run._control === "object" ? run._control : {};
|
||
}
|
||
|
||
function syncControlStage(control: Record<string, unknown>, run: PreparedCaseRun) {
|
||
const status = text(control.status);
|
||
if (status && !["completed", "failed"].includes(status)) return clean({ ...control, stage: latestRunStage(run) });
|
||
return control;
|
||
}
|
||
|
||
async function loadControlRunSkeleton(context: CaseContext, caseId: string, runId: string, runDir: string) {
|
||
const existing = await readJsonIfExists(path.join(runDir, "run.json"));
|
||
if (existing) return existing as PreparedCaseRun;
|
||
return {
|
||
caseId,
|
||
runId,
|
||
runDir,
|
||
caseRepo: text(context.parsed.caseRepo ?? context.parsed.caseRepoPath ?? context.env.HWLAB_CASE_REPO),
|
||
caseDir: "",
|
||
caseFile: "",
|
||
sourceSpecPath: "",
|
||
specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"),
|
||
apiUrl: text(context.parsed.apiUrl ?? context.parsed.apiBaseUrl ?? context.parsed.runtimeApiUrl ?? context.env.HWLAB_RUNTIME_API_URL) || DEFAULT_API_URL,
|
||
compileOnly: true,
|
||
createdAt: context.now(),
|
||
updatedAt: context.now(),
|
||
definition: {},
|
||
subject: { repoLocalPath: "", commitId: "", subdir: "", worktreePath: "", workspacePath: "", setup: {} },
|
||
status: "running",
|
||
stage: "worker-running"
|
||
} as PreparedCaseRun;
|
||
}
|
||
|
||
function workerPassthroughArgs(context: CaseContext, caseId: string, runId: string, runDir: string) {
|
||
const source = context.argv ?? [];
|
||
const args: string[] = [];
|
||
for (let index = 0; index < source.length; index += 1) {
|
||
const item = source[index] ?? "";
|
||
if (item === "case" || item === "run" || item === "start" || item === caseId) continue;
|
||
if (["--run-id", "--run-dir"].includes(item)) { index += 1; continue; }
|
||
if (item.startsWith("--run-id=") || item.startsWith("--run-dir=")) continue;
|
||
args.push(item);
|
||
}
|
||
return [caseId, "--run-id", runId, "--run-dir", runDir, ...args];
|
||
}
|
||
|
||
function startDetachedProcess(input: { command: string; args: string[]; cwd: string; env: Record<string, string | undefined>; stdoutPath: string; stderrPath: string }) {
|
||
closeSync(openSync(input.stdoutPath, "a"));
|
||
closeSync(openSync(input.stderrPath, "a"));
|
||
const stdout = openSync(input.stdoutPath, "a");
|
||
const stderr = openSync(input.stderrPath, "a");
|
||
const child = spawn(input.command, input.args, { cwd: input.cwd, env: input.env as NodeJS.ProcessEnv, detached: true, stdio: ["ignore", stdout, stderr] });
|
||
child.unref();
|
||
closeSync(stdout);
|
||
closeSync(stderr);
|
||
return { pid: child.pid ?? 0 };
|
||
}
|
||
|
||
async function fileSize(file: string) {
|
||
try { return (await stat(file)).size; } catch { return 0; }
|
||
}
|
||
|
||
async function fileExists(file: string) {
|
||
try { return (await stat(file)).isFile(); } catch { return false; }
|
||
}
|
||
|
||
async function readTail(file: string, maxBytes: number) {
|
||
try {
|
||
const content = await readFile(file);
|
||
const start = Math.max(0, content.length - maxBytes);
|
||
return content.subarray(start).toString("utf8");
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function inferRunStage(run: PreparedCaseRun) {
|
||
if (run.evidencePath) return "build-completed";
|
||
if (run.agentDiff) return "agent-diff-collected";
|
||
if (run.agent) return run.agent.stageStatus === "submitted" || run.agent.stageStatus === "running" ? "agent-running" : `agent-${run.agent.stageStatus}`;
|
||
if (run.subject?.worktreePath) return "prepared";
|
||
return "queued";
|
||
}
|
||
|
||
function latestRunStage(run: PreparedCaseRun) {
|
||
return selectLatestStage([text(run.stage), inferRunStage(run)]);
|
||
}
|
||
|
||
function statusStage(run: PreparedCaseRun, control: Record<string, unknown>) {
|
||
const controlStage = text(control.stage);
|
||
const runStage = text(run.stage);
|
||
const inferredStage = inferRunStage(run);
|
||
const selectedStage = selectLatestStage([controlStage, runStage, inferredStage]);
|
||
const terminalControl = ["completed", "failed"].includes(controlStage);
|
||
const stage = terminalControl ? controlStage : selectedStage || "queued";
|
||
const warning = controlStage && controlStage !== stage && !terminalControl ? {
|
||
code: "stale_control_stage",
|
||
controlStage,
|
||
runStage: runStage || null,
|
||
inferredStage,
|
||
selectedStage: stage
|
||
} : undefined;
|
||
return { stage, warning };
|
||
}
|
||
|
||
function selectLatestStage(stages: string[]) {
|
||
return stages.filter(Boolean).reduce((best, stage) => stageRank(stage) >= stageRank(best) ? stage : best, "");
|
||
}
|
||
|
||
function stageRank(stage: string) {
|
||
if (stage === "completed" || stage === "failed") return 100;
|
||
if (stage === "build-completed") return 90;
|
||
if (stage === "agent-diff-collected") return 80;
|
||
if (stage === "agent-trace-collected") return 70;
|
||
if (stage === "agent-completed") return 65;
|
||
if (stage === "agent-running") return 60;
|
||
if (stage === "agent-submitting") return 50;
|
||
if (stage === "agent-session-starting") return 40;
|
||
if (stage === "worker-running") return 35;
|
||
if (stage === "started") return 34;
|
||
if (stage === "queued") return 30;
|
||
if (stage === "prepared") return 20;
|
||
if (stage.startsWith("agent-")) return 55;
|
||
return 0;
|
||
}
|
||
|
||
function elapsedMs(startedAt: unknown, completedAt: unknown) {
|
||
const start = Date.parse(text(startedAt));
|
||
if (!Number.isFinite(start)) return null;
|
||
const end = completedAt ? Date.parse(text(completedAt)) : Date.now();
|
||
return Number.isFinite(end) ? Math.max(0, end - start) : null;
|
||
}
|
||
|
||
function compactCaseRunResult(collected: any) {
|
||
return {
|
||
ok: true,
|
||
action: "case.run",
|
||
status: "completed",
|
||
caseId: collected.run.caseId,
|
||
runId: collected.run.runId,
|
||
runDir: collected.run.runDir,
|
||
compileOnly: true,
|
||
collect: collected.summary,
|
||
evidencePath: path.join(collected.run.runDir, "evidence.json"),
|
||
evidence: compactObject(collected.evidence)
|
||
};
|
||
}
|
||
|
||
function errorSummary(error: unknown) {
|
||
return { code: typeof (error as any)?.code === "string" ? (error as any).code : "caserun_worker_failed", message: error instanceof Error ? error.message : String(error), details: (error as any)?.details ?? null };
|
||
}
|
||
|
||
function extractPrepareBlocker(summary: ReturnType<typeof errorSummary>) {
|
||
const results = (summary.details as any)?.body?.body?.results ?? (summary.details as any)?.body?.results ?? [];
|
||
if (!Array.isArray(results) || results.length === 0) return null;
|
||
for (const r of results) {
|
||
const blocker = (r as any)?.blocker;
|
||
if (blocker && typeof blocker.code === "string") {
|
||
return { code: blocker.code, summary: blocker.summary ?? "", layer: blocker.layer ?? null };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function commandVisibility(command: string[]) {
|
||
return command.map((item) => item.includes("/") || item.includes("\\") ? path.basename(item) || item : item);
|
||
}
|
||
|
||
function dirnameForCommandPath(value: string) {
|
||
if (/^[A-Za-z]:\\/u.test(value)) return value.replace(/\\[^\\]+$/u, "") || value;
|
||
return path.dirname(value);
|
||
}
|
||
|
||
function splitCommandWords(value: string) {
|
||
const words = String(value || "py -3").match(/"[^"]*"|'[^']*'|\S+/gu) ?? ["py", "-3"];
|
||
return words.map((word) => word.replace(/^"(.*)"$/su, "$1").replace(/^'(.*)'$/su, "$1"));
|
||
}
|
||
|
||
export function jobStatusCommandForTest(pythonCommand: string, keilCliPath: string, jobId: string) {
|
||
const python = splitCommandWords(pythonCommand);
|
||
return commandRunForTokens([python[0] ?? "py", ...python.slice(1), keilCliPath, "job-status", jobId], keilCliPath);
|
||
}
|
||
|
||
function commandRunForTokens(tokens: string[], pathHint = "") {
|
||
if (tokens.some(looksLikeWindowsPath) || looksLikeWindowsPath(pathHint)) return { command: "cmd.exe", argv: ["/d", "/s", "/c", shellCommand(tokens)] };
|
||
return { command: tokens[0] ?? "", argv: tokens.slice(1) };
|
||
}
|
||
|
||
function shellCommand(tokens: string[]) {
|
||
return tokens.map(shellArg).join(" ");
|
||
}
|
||
|
||
function shellArg(value: string) {
|
||
const raw = String(value);
|
||
if (/^[A-Za-z0-9_./:@\\-]+$/u.test(raw)) return raw;
|
||
return `"${raw.replace(/(["^&|<>])/gu, "^$1").replace(/%/gu, "%%")}"`;
|
||
}
|
||
|
||
export function artifactsFromKeilStatusForTest(value: any) {
|
||
return artifactsFrom(value);
|
||
}
|
||
|
||
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; }
|