Files
pikasTech-HWLAB/tools/src/hwlab-caserun-lib.ts
T
2026-06-08 13:21:26 +08:00

2797 lines
138 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
}
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; }